mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
301 lines
10 KiB
PHP
301 lines
10 KiB
PHP
<?php
|
|
|
|
use PHPMailer\PHPMailer\Exception as PHPMailerException;
|
|
use PHPMailer\PHPMailer\PHPMailer;
|
|
|
|
/**
|
|
* Structured exception for SMTP probe failures.
|
|
* Carries a `field` hint so the UI can highlight the relevant input.
|
|
*
|
|
* field values: 'smtp_host', 'smtp_port', 'smtp_encryption',
|
|
* 'smtp_username', 'smtp_password', or null (unknown)
|
|
*/
|
|
class SmtpProbeException extends \RuntimeException
|
|
{
|
|
public function __construct(
|
|
string $message,
|
|
public readonly ?string $field = null
|
|
) {
|
|
parent::__construct($message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Structured exception for SMTP send failures.
|
|
* Carries the 3-digit SMTP response code and the full server response.
|
|
*
|
|
* Notable codes:
|
|
* 550 / 551 / 553 — recipient address rejected (unknown user, policy, etc.)
|
|
* 421 / 450 / 451 — transient failures (try again later)
|
|
* 530 / 535 — authentication failure
|
|
*/
|
|
class SmtpSendException extends \RuntimeException
|
|
{
|
|
public function __construct(
|
|
string $message,
|
|
public readonly int $smtpCode = 0,
|
|
public readonly string $smtpResponse = ''
|
|
) {
|
|
parent::__construct($message);
|
|
}
|
|
|
|
/** True when the SMTP server permanently rejected the recipient address. */
|
|
public function isRecipientRejected(): bool
|
|
{
|
|
return in_array($this->smtpCode, [550, 551, 552, 553, 554], true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SMTP Relay — credentials stored in the DB, sending via PHPMailer.
|
|
*
|
|
* Responsibilities:
|
|
* 1. CRUD on the singleton smtp_settings row.
|
|
* 2. Send via PHPMailer SMTP (STARTTLS / SMTPS / plain).
|
|
* 3. Probe credentials without sending any message (for validation on save).
|
|
*/
|
|
class SmtpRelay
|
|
{
|
|
// -----------------------------------------------------------------------
|
|
// DB operations
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Fetch current SMTP settings from the DB.
|
|
*
|
|
* @return array{host:string,port:int,encryption:string,username:string,password:string,from_email:string,from_name:string,notify_email:string}
|
|
*/
|
|
public static function getSettings(Database $db): array
|
|
{
|
|
$stmt = $db->getPDO()->query(
|
|
'SELECT host, port, encryption, username, password, from_email, from_name, notify_email
|
|
FROM v_smtp_active LIMIT 1'
|
|
);
|
|
$row = $stmt->fetch();
|
|
|
|
if ($row) {
|
|
require_once __DIR__ . '/Crypto.php';
|
|
$row['password'] = Crypto::decrypt($row['password']);
|
|
}
|
|
|
|
return $row ?: [
|
|
'host' => '',
|
|
'port' => 587,
|
|
'encryption' => 'tls',
|
|
'username' => '',
|
|
'password' => '',
|
|
'from_email' => '',
|
|
'from_name' => 'XAMXAM',
|
|
'notify_email' => '',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Return the address that should receive admin notification emails.
|
|
* Uses notify_email when set, falls back to from_email.
|
|
*/
|
|
public static function getNotifyEmail(Database $db): string
|
|
{
|
|
$s = self::getSettings($db);
|
|
$notify = trim($s['notify_email'] ?? '');
|
|
return $notify !== '' ? $notify : trim($s['from_email'] ?? '');
|
|
}
|
|
|
|
/**
|
|
* Upsert SMTP settings.
|
|
*
|
|
* @param array $data Keys: host, port, encryption, username, password,
|
|
* from_email, from_name. Missing keys are left unchanged.
|
|
*/
|
|
public static function updateSettings(Database $db, array $data): void
|
|
{
|
|
$current = self::getSettings($db);
|
|
$merged = array_merge($current, $data);
|
|
|
|
$port = max(1, min(65535, (int)$merged['port']));
|
|
$encryption = in_array($merged['encryption'], ['tls', 'ssl', 'none'], true)
|
|
? $merged['encryption'] : 'tls';
|
|
|
|
$stmt = $db->getPDO()->prepare(
|
|
'UPDATE smtp_settings
|
|
SET host = :host,
|
|
port = :port,
|
|
encryption = :encryption,
|
|
username = :username,
|
|
password = :password,
|
|
from_email = :from_email,
|
|
from_name = :from_name,
|
|
notify_email = :notify_email,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = 1'
|
|
);
|
|
|
|
require_once __DIR__ . '/Crypto.php';
|
|
$stmt->execute([
|
|
':host' => trim($merged['host']),
|
|
':port' => $port,
|
|
':encryption' => $encryption,
|
|
':username' => trim($merged['username']),
|
|
':password' => Crypto::encrypt($merged['password']),
|
|
':from_email' => trim($merged['from_email']),
|
|
':from_name' => trim($merged['from_name']),
|
|
':notify_email' => trim($merged['notify_email'] ?? ''),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Check whether the SMTP relay is fully configured.
|
|
*/
|
|
public static function isConfigured(Database $db): bool
|
|
{
|
|
$s = self::getSettings($db);
|
|
return $s['host'] !== '' && $s['username'] !== '' && $s['from_email'] !== '';
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Credential probe (validates without sending any message)
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Test SMTP credentials without sending any message.
|
|
*
|
|
* Uses PHPMailer's smtpConnect() + smtpClose() to connect,
|
|
* EHLO + optional STARTTLS, authenticate, then disconnect.
|
|
* No MAIL FROM / RCPT TO / DATA — nothing lands in any mailbox.
|
|
*
|
|
* @return array{ok:bool, error:string, field:?string}
|
|
*/
|
|
public static function test(Database $db): array
|
|
{
|
|
$s = self::getSettings($db);
|
|
if ($s['host'] === '') {
|
|
return ['ok' => false, 'error' => 'Hôte SMTP non configuré.', 'field' => 'smtp_host'];
|
|
}
|
|
|
|
try {
|
|
$mail = self::createMailer($s);
|
|
$mail->SMTPDebug = 0;
|
|
|
|
// Connect, authenticate, then close — no message sent
|
|
if (!$mail->smtpConnect()) {
|
|
$error = $mail->ErrorInfo;
|
|
$isAuth = stripos($error, 'authenticat') !== false;
|
|
$isLogin = stripos($error, 'login') !== false || stripos($error, 'username') !== false;
|
|
$isPass = stripos($error, 'password') !== false;
|
|
$isTls = stripos($error, 'tls') !== false || stripos($error, 'starttls') !== false;
|
|
|
|
$field = $isAuth
|
|
? ($isPass ? 'smtp_password' : 'smtp_username')
|
|
: ($isTls ? 'smtp_encryption' : null);
|
|
|
|
throw new SmtpProbeException($error ?: 'Échec de la connexion SMTP.', $field);
|
|
}
|
|
$mail->smtpClose();
|
|
|
|
return ['ok' => true, 'error' => '', 'field' => null];
|
|
} catch (SmtpProbeException $e) {
|
|
return ['ok' => false, 'error' => $e->getMessage(), 'field' => $e->field];
|
|
} catch (\Throwable $e) {
|
|
return ['ok' => false, 'error' => $e->getMessage(), 'field' => null];
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Send
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build and return a PHPMailer instance configured from DB settings.
|
|
*/
|
|
private static function createMailer(array $s): PHPMailer
|
|
{
|
|
$mail = new PHPMailer(true);
|
|
$mail->isSMTP();
|
|
$mail->CharSet = PHPMailer::CHARSET_UTF8;
|
|
$mail->Host = $s['host'];
|
|
$mail->Port = (int)$s['port'];
|
|
$mail->Timeout = 15;
|
|
|
|
if ($s['encryption'] === 'ssl') {
|
|
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
|
} elseif ($s['encryption'] === 'tls') {
|
|
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
|
}
|
|
|
|
$mail->SMTPAuth = ($s['username'] !== '');
|
|
if ($mail->SMTPAuth) {
|
|
$mail->Username = $s['username'];
|
|
$mail->Password = $s['password'];
|
|
$mail->AuthType = 'PLAIN'; // Let PHPMailer try PLAIN then LOGIN
|
|
}
|
|
|
|
$mail->setFrom($s['from_email'], $s['from_name'] ?? 'XAMXAM');
|
|
return $mail;
|
|
}
|
|
|
|
/**
|
|
* Send an e-mail using the stored SMTP credentials.
|
|
*
|
|
* @param string $to Recipient e-mail address
|
|
* @param string $subject Subject line
|
|
* @param string $body HTML body
|
|
* @param string $plain Plain-text alternative (optional)
|
|
* @return bool True on successful delivery acceptance; false on failure
|
|
*/
|
|
public static function send(
|
|
Database $db,
|
|
string $to,
|
|
string $subject,
|
|
string $body,
|
|
string $plain = ''
|
|
): bool {
|
|
$s = self::getSettings($db);
|
|
if ($s['from_email'] === '') {
|
|
error_log('[SmtpRelay] send() aborted — no from_email configured');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$mail = self::createMailer($s);
|
|
$mail->addAddress($to);
|
|
$mail->Subject = $subject;
|
|
$mail->Body = $body;
|
|
$mail->isHTML(true);
|
|
|
|
if ($plain !== '') {
|
|
$mail->AltBody = $plain;
|
|
}
|
|
|
|
$mail->send();
|
|
return true;
|
|
} catch (SmtpSendException $e) {
|
|
error_log('[SmtpRelay] ' . $e->getMessage());
|
|
throw $e; // propagate structured exception so callers can react
|
|
} catch (PHPMailerException $e) {
|
|
error_log('[SmtpRelay] ' . $e->getMessage());
|
|
return false;
|
|
} catch (\Throwable $e) {
|
|
error_log('[SmtpRelay] ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Queue stub
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Queue (persist) an e-mail for deferred sending.
|
|
* Stub — will create a `mail_queue` table in a future migration.
|
|
*/
|
|
public static function queue(
|
|
Database $db,
|
|
string $to,
|
|
string $subject,
|
|
string $body,
|
|
string $plain = ''
|
|
): void {
|
|
// TODO: INSERT INTO mail_queue …
|
|
}
|
|
}
|