mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
Phase 3: Replace SmtpRelay SMTP socket with PHPMailer
This commit is contained in:
File diff suppressed because one or more lines are too long
2
TODO.md
2
TODO.md
@@ -9,7 +9,7 @@
|
|||||||
- [x] Write docs/system-setup.md (PHP extension requirements)
|
- [x] Write docs/system-setup.md (PHP extension requirements)
|
||||||
- [x] Phase 1: Replace Parsedown with league/commonmark (4 call sites)
|
- [x] Phase 1: Replace Parsedown with league/commonmark (4 call sites)
|
||||||
- [x] Phase 2: Replace PeerTubeService HTTP client with Guzzle
|
- [x] Phase 2: Replace PeerTubeService HTTP client with Guzzle
|
||||||
- [ ] Phase 3: Replace SmtpRelay SMTP socket with PHPMailer
|
- [x] Phase 3: Replace SmtpRelay SMTP socket with PHPMailer
|
||||||
- [ ] Phase 4 (optional): Replace Crypto with defuse/php-encryption
|
- [ ] Phase 4 (optional): Replace Crypto with defuse/php-encryption
|
||||||
|
|
||||||
## justfile: combine phpstan + cs-check + cs-fix into lint-php
|
## justfile: combine phpstan + cs-check + cs-fix into lint-php
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use PHPMailer\PHPMailer\Exception as PHPMailerException;
|
||||||
|
use PHPMailer\PHPMailer\PHPMailer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structured exception for SMTP probe failures.
|
* Structured exception for SMTP probe failures.
|
||||||
* Carries a `field` hint so the UI can highlight the relevant input.
|
* Carries a `field` hint so the UI can highlight the relevant input.
|
||||||
@@ -44,14 +47,12 @@ class SmtpSendException extends \RuntimeException
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMTP Relay — credentials stored in the DB, sending via a native PHP socket
|
* SMTP Relay — credentials stored in the DB, sending via PHPMailer.
|
||||||
* client (no external dependencies).
|
|
||||||
*
|
*
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* 1. CRUD on the singleton smtp_settings row.
|
* 1. CRUD on the singleton smtp_settings row.
|
||||||
* 2. Build MIME messages.
|
* 2. Send via PHPMailer SMTP (STARTTLS / SMTPS / plain).
|
||||||
* 3. Send via native SMTP socket (STARTTLS / SSL / plain).
|
* 3. Probe credentials without sending any message (for validation on save).
|
||||||
* 4. Probe credentials without sending any message (for validation on save).
|
|
||||||
*/
|
*/
|
||||||
class SmtpRelay
|
class SmtpRelay
|
||||||
{
|
{
|
||||||
@@ -158,13 +159,10 @@ class SmtpRelay
|
|||||||
/**
|
/**
|
||||||
* Test SMTP credentials without sending any message.
|
* Test SMTP credentials without sending any message.
|
||||||
*
|
*
|
||||||
* Connects, does EHLO + optional STARTTLS, authenticates, then QUITs.
|
* Uses PHPMailer's smtpConnect() + smtpClose() to connect,
|
||||||
|
* EHLO + optional STARTTLS, authenticate, then disconnect.
|
||||||
* No MAIL FROM / RCPT TO / DATA — nothing lands in any mailbox.
|
* No MAIL FROM / RCPT TO / DATA — nothing lands in any mailbox.
|
||||||
*
|
*
|
||||||
* Returns ['ok' => bool, 'error' => string, 'field' => ?string].
|
|
||||||
* `field` is the HTML input id to highlight on failure:
|
|
||||||
* 'smtp_host' | 'smtp_port' | 'smtp_encryption' | 'smtp_username' | 'smtp_password' | null
|
|
||||||
*
|
|
||||||
* @return array{ok:bool, error:string, field:?string}
|
* @return array{ok:bool, error:string, field:?string}
|
||||||
*/
|
*/
|
||||||
public static function test(Database $db): array
|
public static function test(Database $db): array
|
||||||
@@ -175,7 +173,25 @@ class SmtpRelay
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
self::smtpProbe($s);
|
$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];
|
return ['ok' => true, 'error' => '', 'field' => null];
|
||||||
} catch (SmtpProbeException $e) {
|
} catch (SmtpProbeException $e) {
|
||||||
return ['ok' => false, 'error' => $e->getMessage(), 'field' => $e->field];
|
return ['ok' => false, 'error' => $e->getMessage(), 'field' => $e->field];
|
||||||
@@ -184,204 +200,39 @@ class SmtpRelay
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Low-level SMTP probe: connect → EHLO → STARTTLS → AUTH → QUIT.
|
|
||||||
* Throws SmtpProbeException with a `field` hint on every failure point.
|
|
||||||
*/
|
|
||||||
private static function smtpProbe(array $s): void
|
|
||||||
{
|
|
||||||
$host = $s['host'];
|
|
||||||
$port = (int) $s['port'];
|
|
||||||
$encryption = $s['encryption'];
|
|
||||||
$timeout = 10;
|
|
||||||
|
|
||||||
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
|
|
||||||
|
|
||||||
$errno = 0;
|
|
||||||
$errstr = '';
|
|
||||||
$ctx = stream_context_create([
|
|
||||||
'ssl' => [
|
|
||||||
'verify_peer' => true,
|
|
||||||
'verify_peer_name' => true,
|
|
||||||
'peer_name' => $host,
|
|
||||||
'cafile' => self::caBundlePath(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$sock = @stream_socket_client(
|
|
||||||
"{$connectHost}:{$port}",
|
|
||||||
$errno,
|
|
||||||
$errstr,
|
|
||||||
$timeout,
|
|
||||||
STREAM_CLIENT_CONNECT,
|
|
||||||
$ctx
|
|
||||||
);
|
|
||||||
if ($sock === false) {
|
|
||||||
$isNameFail = (
|
|
||||||
stripos($errstr, 'name or service') !== false ||
|
|
||||||
stripos($errstr, 'resolve') !== false ||
|
|
||||||
stripos($errstr, 'getaddrinfo') !== false ||
|
|
||||||
stripos($errstr, 'nodename') !== false
|
|
||||||
);
|
|
||||||
$isRefused = ($errno === 111 || stripos($errstr, 'refused') !== false);
|
|
||||||
$isTimeout = ($errno === 110 || stripos($errstr, 'timed') !== false || $errstr === '');
|
|
||||||
|
|
||||||
if ($isNameFail) {
|
|
||||||
throw new SmtpProbeException(
|
|
||||||
"Hôte introuvable « {$host} » — vérifiez l'adresse du serveur SMTP.",
|
|
||||||
'smtp_host'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ($isRefused) {
|
|
||||||
throw new SmtpProbeException(
|
|
||||||
"Connexion refusée sur le port {$port} — vérifiez le port et le mode de chiffrement.",
|
|
||||||
'smtp_port'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new SmtpProbeException(
|
|
||||||
$isTimeout
|
|
||||||
? "Délai dépassé en tentant de joindre {$host}:{$port} — hôte ou port incorrect ?"
|
|
||||||
: "Connexion impossible à {$host}:{$port} — {$errstr} [{$errno}]",
|
|
||||||
$isTimeout ? 'smtp_host' : null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
stream_set_timeout($sock, $timeout);
|
|
||||||
|
|
||||||
$read = function () use ($sock): string {
|
|
||||||
$buf = '';
|
|
||||||
while (($line = fgets($sock, 512)) !== false) {
|
|
||||||
$buf .= $line;
|
|
||||||
if (isset($line[3]) && $line[3] === ' ') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$meta = stream_get_meta_data($sock);
|
|
||||||
if ($meta['timed_out']) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $buf;
|
|
||||||
};
|
|
||||||
$send = function (string $cmd) use ($sock, $read): string {
|
|
||||||
fwrite($sock, $cmd . "\r\n");
|
|
||||||
return $read();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Greeting
|
|
||||||
$greeting = $read();
|
|
||||||
if (strncmp($greeting, '220', 3) !== 0) {
|
|
||||||
$meta = stream_get_meta_data($sock);
|
|
||||||
if ($meta['timed_out']) {
|
|
||||||
throw new SmtpProbeException(
|
|
||||||
'Délai dépassé en attendant la salutation SMTP — hôte ou port incorrect ?',
|
|
||||||
'smtp_port'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new SmtpProbeException(
|
|
||||||
'Réponse inattendue du serveur : ' . json_encode(trim($greeting)),
|
|
||||||
'smtp_host'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$parseEhlo = function (string $resp): array {
|
|
||||||
$caps = [];
|
|
||||||
foreach (explode("\n", $resp) as $line) {
|
|
||||||
$line = rtrim($line);
|
|
||||||
if (strlen($line) > 4) {
|
|
||||||
$caps[] = strtoupper(substr($line, 4));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $caps;
|
|
||||||
};
|
|
||||||
|
|
||||||
$ehloResp = $send('EHLO ' . gethostname());
|
|
||||||
$caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : [];
|
|
||||||
if (empty($caps)) {
|
|
||||||
$send('HELO ' . gethostname());
|
|
||||||
}
|
|
||||||
|
|
||||||
// STARTTLS upgrade
|
|
||||||
if ($encryption === 'tls') {
|
|
||||||
$stResp = $send('STARTTLS');
|
|
||||||
if (strncmp($stResp, '220', 3) !== 0) {
|
|
||||||
throw new SmtpProbeException(
|
|
||||||
'Le serveur ne supporte pas STARTTLS — essayez SSL (port 465) ou "Aucun".',
|
|
||||||
'smtp_encryption'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
stream_context_set_option($sock, 'ssl', 'peer_name', $host);
|
|
||||||
stream_context_set_option($sock, 'ssl', 'verify_peer', true);
|
|
||||||
stream_context_set_option($sock, 'ssl', 'verify_peer_name', true);
|
|
||||||
stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath());
|
|
||||||
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
||||||
throw new SmtpProbeException(
|
|
||||||
'Échec de la négociation TLS — certificat invalide ou port incorrect ?',
|
|
||||||
'smtp_encryption'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$ehloResp = $send('EHLO ' . gethostname());
|
|
||||||
$caps = $parseEhlo($ehloResp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AUTH
|
|
||||||
if ($s['username'] !== '') {
|
|
||||||
$authLine = '';
|
|
||||||
foreach ($caps as $cap) {
|
|
||||||
if (strncmp($cap, 'AUTH', 4) === 0) {
|
|
||||||
$authLine = $cap;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$mechanisms = preg_split('/[\s=]+/', $authLine);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (in_array('PLAIN', $mechanisms, true)) {
|
|
||||||
$token = base64_encode("\0{$s['username']}\0{$s['password']}");
|
|
||||||
$resp = $send("AUTH PLAIN {$token}");
|
|
||||||
if (strncmp($resp, '235', 3) !== 0) {
|
|
||||||
$code = substr(trim($resp), 0, 3);
|
|
||||||
$field = ($code === '535') ? 'smtp_password' : 'smtp_username';
|
|
||||||
throw new SmtpProbeException(
|
|
||||||
'Authentification refusée : ' . trim($resp),
|
|
||||||
$field
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// AUTH LOGIN challenge/response
|
|
||||||
$r1 = $send('AUTH LOGIN');
|
|
||||||
if (strncmp($r1, '334', 3) !== 0) {
|
|
||||||
throw new SmtpProbeException(
|
|
||||||
"Le serveur n'accepte pas AUTH LOGIN : " . trim($r1),
|
|
||||||
'smtp_username'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$r2 = $send(base64_encode($s['username']));
|
|
||||||
if (strncmp($r2, '334', 3) !== 0) {
|
|
||||||
throw new SmtpProbeException(
|
|
||||||
"Nom d'utilisateur refusé : " . trim($r2),
|
|
||||||
'smtp_username'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$r3 = $send(base64_encode($s['password']));
|
|
||||||
if (strncmp($r3, '235', 3) !== 0) {
|
|
||||||
throw new SmtpProbeException(
|
|
||||||
'Mot de passe refusé : ' . trim($r3),
|
|
||||||
'smtp_password'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (SmtpProbeException $e) {
|
|
||||||
@fclose($sock);
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$send('QUIT');
|
|
||||||
fclose($sock);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Send
|
// 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.
|
* Send an e-mail using the stored SMTP credentials.
|
||||||
*
|
*
|
||||||
@@ -404,211 +255,31 @@ class SmtpRelay
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$boundary = 'xamxam_' . bin2hex(random_bytes(8));
|
|
||||||
$date = date('r');
|
|
||||||
$fromHdr = ($s['from_name'] ?? '') !== ''
|
|
||||||
? '=?UTF-8?B?' . base64_encode($s['from_name']) . "?= <{$s['from_email']}>"
|
|
||||||
: $s['from_email'];
|
|
||||||
$subjectHdr = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
|
||||||
|
|
||||||
$hasPlain = ($plain !== '');
|
|
||||||
if ($hasPlain) {
|
|
||||||
$msgBody = "--{$boundary}\r\n";
|
|
||||||
$msgBody .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
|
||||||
$msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n";
|
|
||||||
$msgBody .= chunk_split(base64_encode($plain)) . "\r\n";
|
|
||||||
$msgBody .= "--{$boundary}\r\n";
|
|
||||||
$msgBody .= "Content-Type: text/html; charset=UTF-8\r\n";
|
|
||||||
$msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n";
|
|
||||||
$msgBody .= chunk_split(base64_encode($body)) . "\r\n";
|
|
||||||
$msgBody .= "--{$boundary}--";
|
|
||||||
$ctHdr = "multipart/alternative; boundary=\"{$boundary}\"";
|
|
||||||
} else {
|
|
||||||
$msgBody = chunk_split(base64_encode($body));
|
|
||||||
$ctHdr = 'text/html; charset=UTF-8';
|
|
||||||
}
|
|
||||||
|
|
||||||
$rawMessage =
|
|
||||||
"Date: {$date}\r\n" .
|
|
||||||
"From: {$fromHdr}\r\n" .
|
|
||||||
"To: {$to}\r\n" .
|
|
||||||
"Subject: {$subjectHdr}\r\n" .
|
|
||||||
"MIME-Version: 1.0\r\n" .
|
|
||||||
"Content-Type: {$ctHdr}\r\n" .
|
|
||||||
(!$hasPlain ? "Content-Transfer-Encoding: base64\r\n" : '') .
|
|
||||||
"\r\n" .
|
|
||||||
$msgBody;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return self::smtpSend($s, $to, $rawMessage);
|
$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) {
|
} catch (SmtpSendException $e) {
|
||||||
error_log('[SmtpRelay] ' . $e->getMessage());
|
error_log('[SmtpRelay] ' . $e->getMessage());
|
||||||
throw $e; // propagate structured exception so callers can react
|
throw $e; // propagate structured exception so callers can react
|
||||||
|
} catch (PHPMailerException $e) {
|
||||||
|
error_log('[SmtpRelay] ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
error_log('[SmtpRelay] ' . $e->getMessage());
|
error_log('[SmtpRelay] ' . $e->getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Low-level native SMTP socket client (full send path).
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Sanitise an e-mail address for use in SMTP envelope commands.
|
|
||||||
* Strips everything except the angle-bracket address to prevent
|
|
||||||
* SMTP command injection via embedded CR/LF.
|
|
||||||
*/
|
|
||||||
private static function sanitiseEnvelope(string $addr): string
|
|
||||||
{
|
|
||||||
// Extract bare address if wrapped in display-name <addr> form
|
|
||||||
if (preg_match('/<([^>\r\n]+)>/', $addr, $m)) {
|
|
||||||
$addr = $m[1];
|
|
||||||
}
|
|
||||||
// Remove any CR or LF characters
|
|
||||||
return str_replace(["\r", "\n"], '', trim($addr));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function smtpSend(array $s, string $to, string $rawMessage): bool
|
|
||||||
{
|
|
||||||
$host = $s['host'];
|
|
||||||
$port = (int) $s['port'];
|
|
||||||
$encryption = $s['encryption'];
|
|
||||||
$timeout = 15;
|
|
||||||
|
|
||||||
// Sanitise envelope addresses
|
|
||||||
$envFrom = self::sanitiseEnvelope($s['from_email']);
|
|
||||||
$envTo = self::sanitiseEnvelope($to);
|
|
||||||
|
|
||||||
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
|
|
||||||
|
|
||||||
$errno = 0;
|
|
||||||
$errstr = '';
|
|
||||||
$ctx = stream_context_create([
|
|
||||||
'ssl' => [
|
|
||||||
'verify_peer' => true,
|
|
||||||
'verify_peer_name' => true,
|
|
||||||
'peer_name' => $host,
|
|
||||||
'cafile' => self::caBundlePath(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$sock = @stream_socket_client(
|
|
||||||
"{$connectHost}:{$port}",
|
|
||||||
$errno,
|
|
||||||
$errstr,
|
|
||||||
$timeout,
|
|
||||||
STREAM_CLIENT_CONNECT,
|
|
||||||
$ctx
|
|
||||||
);
|
|
||||||
if ($sock === false) {
|
|
||||||
throw new \RuntimeException("SMTP connect failed ({$connectHost}:{$port}): {$errstr} [{$errno}]");
|
|
||||||
}
|
|
||||||
stream_set_timeout($sock, $timeout);
|
|
||||||
|
|
||||||
$read = function () use ($sock): string {
|
|
||||||
$buf = '';
|
|
||||||
while (($line = fgets($sock, 512)) !== false) {
|
|
||||||
$buf .= $line;
|
|
||||||
if (isset($line[3]) && $line[3] === ' ') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$meta = stream_get_meta_data($sock);
|
|
||||||
if ($meta['timed_out']) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $buf;
|
|
||||||
};
|
|
||||||
$send = function (string $cmd) use ($sock, $read): string {
|
|
||||||
fwrite($sock, $cmd . "\r\n");
|
|
||||||
return $read();
|
|
||||||
};
|
|
||||||
$expect = function (string $cmd, string $code) use ($send): string {
|
|
||||||
$resp = $send($cmd);
|
|
||||||
if (strncmp($resp, $code, strlen($code)) !== 0) {
|
|
||||||
$respCode = (int) substr(trim($resp), 0, 3);
|
|
||||||
throw new SmtpSendException(
|
|
||||||
"SMTP unexpected response to '{$cmd}': " . trim($resp),
|
|
||||||
$respCode,
|
|
||||||
trim($resp)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return $resp;
|
|
||||||
};
|
|
||||||
|
|
||||||
$greeting = $read();
|
|
||||||
if (strncmp($greeting, '220', 3) !== 0) {
|
|
||||||
$meta = stream_get_meta_data($sock);
|
|
||||||
$detail = $meta['timed_out'] ? '(timed out)' : '(received: ' . json_encode($greeting) . ')';
|
|
||||||
throw new \RuntimeException("SMTP bad greeting {$detail}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$parseEhlo = function (string $resp): array {
|
|
||||||
$caps = [];
|
|
||||||
foreach (explode("\n", $resp) as $line) {
|
|
||||||
$line = rtrim($line);
|
|
||||||
if (strlen($line) > 4) {
|
|
||||||
$caps[] = strtoupper(substr($line, 4));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $caps;
|
|
||||||
};
|
|
||||||
|
|
||||||
$ehloResp = $send('EHLO ' . gethostname());
|
|
||||||
$caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : [];
|
|
||||||
if (empty($caps)) {
|
|
||||||
$send('HELO ' . gethostname());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($encryption === 'tls') {
|
|
||||||
$expect('STARTTLS', '220');
|
|
||||||
stream_context_set_option($sock, 'ssl', 'peer_name', $host);
|
|
||||||
stream_context_set_option($sock, 'ssl', 'verify_peer', true);
|
|
||||||
stream_context_set_option($sock, 'ssl', 'verify_peer_name', true);
|
|
||||||
stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath());
|
|
||||||
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
||||||
throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed (invalid certificate?)');
|
|
||||||
}
|
|
||||||
$ehloResp = $send('EHLO ' . gethostname());
|
|
||||||
$caps = $parseEhlo($ehloResp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($s['username'] !== '') {
|
|
||||||
$authLine = '';
|
|
||||||
foreach ($caps as $cap) {
|
|
||||||
if (strncmp($cap, 'AUTH', 4) === 0) {
|
|
||||||
$authLine = $cap;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$mechanisms = preg_split('/[\s=]+/', $authLine);
|
|
||||||
if (in_array('PLAIN', $mechanisms, true)) {
|
|
||||||
$token = base64_encode("\0{$s['username']}\0{$s['password']}");
|
|
||||||
$expect("AUTH PLAIN {$token}", '235');
|
|
||||||
} else {
|
|
||||||
$expect('AUTH LOGIN', '334');
|
|
||||||
$expect(base64_encode($s['username']), '334');
|
|
||||||
$expect(base64_encode($s['password']), '235');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$expect("MAIL FROM:<{$envFrom}>", '250');
|
|
||||||
$expect("RCPT TO:<{$envTo}>", '250');
|
|
||||||
$expect('DATA', '354');
|
|
||||||
|
|
||||||
// RFC 5321 §4.5.2 dot-stuffing: prepend extra '.' to any line starting with '.'
|
|
||||||
$normalised = str_replace("\r\n", "\n", $rawMessage);
|
|
||||||
$normalised = str_replace("\n", "\r\n", $normalised);
|
|
||||||
$stuffed = str_replace("\r\n.", "\r\n..", $normalised);
|
|
||||||
$resp = $send($stuffed . "\r\n.");
|
|
||||||
if (strncmp($resp, '250', 3) !== 0) {
|
|
||||||
throw new \RuntimeException("SMTP DATA rejected: {$resp}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$send('QUIT');
|
|
||||||
fclose($sock);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Queue stub
|
// Queue stub
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -626,43 +297,4 @@ class SmtpRelay
|
|||||||
): void {
|
): void {
|
||||||
// TODO: INSERT INTO mail_queue …
|
// TODO: INSERT INTO mail_queue …
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Internal
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locate the system CA bundle for TLS peer verification.
|
|
||||||
* PHP uses its compiled-in default when openssl.cafile is set in php.ini;
|
|
||||||
* this provides an explicit path as a fallback for environments where
|
|
||||||
* the INI value is missing.
|
|
||||||
*/
|
|
||||||
private static function caBundlePath(): ?string
|
|
||||||
{
|
|
||||||
// Honour php.ini if set
|
|
||||||
$ini = ini_get('openssl.cafile');
|
|
||||||
if ($ini && file_exists($ini)) {
|
|
||||||
return $ini;
|
|
||||||
}
|
|
||||||
// Common system locations (Debian/Ubuntu, RHEL/CentOS, Alpine)
|
|
||||||
$candidates = [
|
|
||||||
'/etc/ssl/certs/ca-certificates.crt',
|
|
||||||
'/etc/pki/tls/certs/ca-bundle.crt',
|
|
||||||
'/etc/ssl/ca-bundle.pem',
|
|
||||||
'/usr/local/share/certs/ca-root-nss.crt',
|
|
||||||
];
|
|
||||||
foreach ($candidates as $path) {
|
|
||||||
if (file_exists($path)) {
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null; // PHP will fall back to its compiled-in bundle
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function htmlToPlain(string $html): string
|
|
||||||
{
|
|
||||||
$text = strip_tags($html);
|
|
||||||
$text = preg_replace('/\n{3,}/', "\n\n", $text);
|
|
||||||
return trim($text);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
[1779232047]
|
[1779232645]
|
||||||
@@ -365,3 +365,4 @@
|
|||||||
{"timestamp":"2026-05-19T14:39:37+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"share_link","action":"deactivate","status":"success","context":{"link_id":213}}
|
{"timestamp":"2026-05-19T14:39:37+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"share_link","action":"deactivate","status":"success","context":{"link_id":213}}
|
||||||
{"timestamp":"2026-05-19T14:39:39+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"share_link","action":"activate","status":"success","context":{"link_id":213}}
|
{"timestamp":"2026-05-19T14:39:39+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"share_link","action":"activate","status":"success","context":{"link_id":213}}
|
||||||
{"timestamp":"2026-05-19T17:19:51+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"system","action":"files_export","status":"success","context":{"file_count":6,"byte_size":11871654}}
|
{"timestamp":"2026-05-19T17:19:51+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"system","action":"files_export","status":"success","context":{"file_count":6,"byte_size":11871654}}
|
||||||
|
{"timestamp":"2026-05-19T23:10:09+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"page","action":"edit","status":"success","context":{"slug":"about"}}
|
||||||
|
|||||||
@@ -36,20 +36,8 @@ parameters:
|
|||||||
count: 1
|
count: 1
|
||||||
path: app/src/SmtpRelay.php
|
path: app/src/SmtpRelay.php
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Offset ''from_name'' on array\{host\: string, port\: int, encryption\: string, username\: string, password\: string, from_email\: non\-empty\-string, from_name\: string, notify_email\: string\} on left side of \?\? always exists and is not nullable\.$#'
|
|
||||||
identifier: nullCoalesce.offset
|
|
||||||
count: 1
|
|
||||||
path: app/src/SmtpRelay.php
|
|
||||||
|
|
||||||
-
|
-
|
||||||
message: '#^Offset ''notify_email'' on array\{host\: string, port\: int, encryption\: string, username\: string, password\: string, from_email\: string, from_name\: string, notify_email\: string\} on left side of \?\? always exists and is not nullable\.$#'
|
message: '#^Offset ''notify_email'' on array\{host\: string, port\: int, encryption\: string, username\: string, password\: string, from_email\: string, from_name\: string, notify_email\: string\} on left side of \?\? always exists and is not nullable\.$#'
|
||||||
identifier: nullCoalesce.offset
|
identifier: nullCoalesce.offset
|
||||||
count: 1
|
count: 1
|
||||||
path: app/src/SmtpRelay.php
|
path: app/src/SmtpRelay.php
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Static method SmtpRelay\:\:htmlToPlain\(\) is unused\.$#'
|
|
||||||
identifier: method.unused
|
|
||||||
count: 1
|
|
||||||
path: app/src/SmtpRelay.php
|
|
||||||
|
|||||||
Reference in New Issue
Block a user