mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
SmtpRelay: replace mail() stub with native socket SMTP client
This commit is contained in:
1
TODO.md
1
TODO.md
@@ -5,4 +5,5 @@
|
|||||||
- [x] Replace JS toast system with pure HTMX toast fragment (top-right, CSS-only auto-fade)
|
- [x] Replace JS toast system with pure HTMX toast fragment (top-right, CSS-only auto-fade)
|
||||||
- [x] Separate admin views from controllers: move all HTML to `templates/admin/*.php`, fragments to `templates/admin/partials/`
|
- [x] Separate admin views from controllers: move all HTML to `templates/admin/*.php`, fragments to `templates/admin/partials/`
|
||||||
- [x] Add SMTP test email button in parametres.php (action + CSS)
|
- [x] Add SMTP test email button in parametres.php (action + CSS)
|
||||||
|
- [x] Fix SmtpRelay::send() — replace broken mail() stub with native PHP socket SMTP client (STARTTLS/SSL, AUTH PLAIN/LOGIN)
|
||||||
- [x] Lock body scroll on all pages (admin + public); only `main` or inner element scrolls
|
- [x] Lock body scroll on all pages (admin + public); only `main` or inner element scrolls
|
||||||
|
|||||||
@@ -94,16 +94,15 @@ class SmtpRelay {
|
|||||||
/**
|
/**
|
||||||
* Send an e-mail using the stored SMTP credentials.
|
* Send an e-mail using the stored SMTP credentials.
|
||||||
*
|
*
|
||||||
* Currently uses PHP's `mail()` as a passthrough so the rest of the
|
* Uses a native PHP socket SMTP client — no external dependencies.
|
||||||
* application can call `SmtpRelay::send(…)` everywhere.
|
* Supports STARTTLS (port 587 / encryption=tls) and direct SSL (port 465 /
|
||||||
* The actual SMTP transport layer will be wired in a later iteration
|
* encryption=ssl). Falls back to plain if encryption=none.
|
||||||
* (e.g. replace this body with PHPMailer / Symfony Mailer).
|
|
||||||
*
|
*
|
||||||
* @param string $to Recipient e-mail address
|
* @param string $to Recipient e-mail address
|
||||||
* @param string $subject Subject line
|
* @param string $subject Subject line
|
||||||
* @param string $body HTML body
|
* @param string $body HTML body
|
||||||
* @param string $plain Plain-text alternative (optional)
|
* @param string $plain Plain-text alternative (optional)
|
||||||
* @return bool True on send request acceptance; false on failure
|
* @return bool True on successful delivery acceptance; false on failure
|
||||||
*/
|
*/
|
||||||
public static function send(
|
public static function send(
|
||||||
Database $db,
|
Database $db,
|
||||||
@@ -112,41 +111,175 @@ class SmtpRelay {
|
|||||||
string $body,
|
string $body,
|
||||||
string $plain = ''
|
string $plain = ''
|
||||||
): bool {
|
): bool {
|
||||||
$settings = self::getSettings($db);
|
$s = self::getSettings($db);
|
||||||
if ($settings['from_email'] === '') {
|
if ($s['from_email'] === '') {
|
||||||
error_log('[SmtpRelay] send() aborted — no from_email configured');
|
error_log('[SmtpRelay] send() aborted — no from_email configured');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build MIME multipart headers
|
// Build MIME message
|
||||||
$boundary = 'posterg_' . md5((string) random_int(0, PHP_INT_MAX) . microtime(true));
|
$boundary = 'posterg_' . bin2hex(random_bytes(8));
|
||||||
$headers = "From: {$settings['from_name']} <{$settings['from_email']}>\r\n";
|
$date = date('r');
|
||||||
$headers .= "Reply-To: {$settings['from_email']}\r\n";
|
$fromHdr = $s['from_name'] !== ''
|
||||||
$headers .= "MIME-Version: 1.0\r\n";
|
? "=?UTF-8?B?" . base64_encode($s['from_name']) . "?= <{$s['from_email']}>"
|
||||||
|
: $s['from_email'];
|
||||||
|
$subjectHdr = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||||
|
|
||||||
if ($plain !== '') {
|
$hasPlain = ($plain !== '');
|
||||||
$headers .= "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n";
|
if ($hasPlain) {
|
||||||
$message = "--{$boundary}\r\n";
|
$msgBody = "--{$boundary}\r\n";
|
||||||
$message .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n";
|
$msgBody .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
$message .= self::htmlToPlain($body) . "\r\n\r\n";
|
$msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n";
|
||||||
$message .= "--{$boundary}\r\n";
|
$msgBody .= chunk_split(base64_encode($plain)) . "\r\n";
|
||||||
$message .= "Content-Type: text/html; charset=UTF-8\r\n\r\n";
|
$msgBody .= "--{$boundary}\r\n";
|
||||||
$message .= $body . "\r\n\r\n";
|
$msgBody .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||||
$message .= "--{$boundary}--";
|
$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 {
|
} else {
|
||||||
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
|
$msgBody = chunk_split(base64_encode($body));
|
||||||
$message = $body;
|
$ctHdr = 'text/html; charset=UTF-8';
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: replace with real SMTP transport (PHPMailer / Symfony Mailer)
|
$rawMessage =
|
||||||
// The stored credentials ($settings) will be passed to the mailer then.
|
"Date: {$date}\r\n" .
|
||||||
$ok = mail($to, $subject, $message, $headers);
|
"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;
|
||||||
|
|
||||||
if (!$ok) {
|
try {
|
||||||
error_log("[SmtpRelay] mail() returned false for {$to}");
|
return self::smtpSend($s, $to, $rawMessage);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[SmtpRelay] ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level native SMTP socket client.
|
||||||
|
*
|
||||||
|
* @param array $s SMTP settings row
|
||||||
|
* @param string $to Envelope recipient
|
||||||
|
* @param string $rawMessage Full RFC 2822 message (headers + body)
|
||||||
|
*/
|
||||||
|
private static function smtpSend(array $s, string $to, string $rawMessage): bool
|
||||||
|
{
|
||||||
|
$host = $s['host'];
|
||||||
|
$port = (int) $s['port'];
|
||||||
|
$encryption = $s['encryption']; // 'tls' | 'ssl' | 'none'
|
||||||
|
$timeout = 15;
|
||||||
|
|
||||||
|
// For direct SSL (port 465) open with ssl:// wrapper
|
||||||
|
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
|
||||||
|
|
||||||
|
$errno = 0; $errstr = '';
|
||||||
|
$sock = @stream_socket_client(
|
||||||
|
"{$connectHost}:{$port}", $errno, $errstr, $timeout
|
||||||
|
);
|
||||||
|
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)) {
|
||||||
|
$buf .= $line;
|
||||||
|
// 4th char is ' ' when it's the last line of a multi-line reply
|
||||||
|
if (isset($line[3]) && $line[3] === ' ') 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) {
|
||||||
|
throw new \RuntimeException("SMTP unexpected response to '{$cmd}': {$resp}");
|
||||||
|
}
|
||||||
|
return $resp;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Greeting
|
||||||
|
$greeting = $read();
|
||||||
|
if (strncmp($greeting, '220', 3) !== 0) {
|
||||||
|
throw new \RuntimeException("SMTP bad greeting: {$greeting}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return $ok;
|
$parseEhlo = function (string $resp): array {
|
||||||
|
// Returns list of capability tokens, e.g. ['STARTTLS','AUTH PLAIN LOGIN',...]
|
||||||
|
$caps = [];
|
||||||
|
foreach (explode("\n", $resp) as $line) {
|
||||||
|
$line = rtrim($line);
|
||||||
|
if (strlen($line) > 4) {
|
||||||
|
$caps[] = strtoupper(substr($line, 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $caps;
|
||||||
|
};
|
||||||
|
|
||||||
|
// EHLO
|
||||||
|
$ehloResp = $send('EHLO ' . gethostname());
|
||||||
|
$caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : [];
|
||||||
|
if (empty($caps)) {
|
||||||
|
$send('HELO ' . gethostname());
|
||||||
|
}
|
||||||
|
|
||||||
|
// STARTTLS upgrade
|
||||||
|
if ($encryption === 'tls') {
|
||||||
|
$expect('STARTTLS', '220');
|
||||||
|
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||||
|
throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed');
|
||||||
|
}
|
||||||
|
// Re-EHLO after TLS — refresh capabilities
|
||||||
|
$ehloResp = $send('EHLO ' . gethostname());
|
||||||
|
$caps = $parseEhlo($ehloResp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AUTH — pick mechanism from server capabilities
|
||||||
|
if ($s['username'] !== '') {
|
||||||
|
// Find the AUTH line, e.g. "AUTH PLAIN LOGIN" or "AUTH=PLAIN LOGIN"
|
||||||
|
$authLine = '';
|
||||||
|
foreach ($caps as $cap) {
|
||||||
|
if (strncmp($cap, 'AUTH', 4) === 0) { $authLine = $cap; break; }
|
||||||
|
}
|
||||||
|
$mechanisms = preg_split('/[\s=]+/', $authLine);
|
||||||
|
// Prefer PLAIN, fall back to LOGIN
|
||||||
|
if (in_array('PLAIN', $mechanisms, true)) {
|
||||||
|
// AUTH PLAIN: single base64(\0user\0pass)
|
||||||
|
$token = base64_encode("\0{$s['username']}\0{$s['password']}");
|
||||||
|
$expect("AUTH PLAIN {$token}", '235');
|
||||||
|
} else {
|
||||||
|
// AUTH LOGIN: challenge/response
|
||||||
|
$expect('AUTH LOGIN', '334');
|
||||||
|
$expect(base64_encode($s['username']), '334');
|
||||||
|
$expect(base64_encode($s['password']), '235');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envelope
|
||||||
|
$expect("MAIL FROM:<{$s['from_email']}>", '250');
|
||||||
|
$expect("RCPT TO:<{$to}>", '250');
|
||||||
|
$expect('DATA', '354');
|
||||||
|
|
||||||
|
// Message — dot-stuff lines starting with '.'
|
||||||
|
$stuffed = preg_replace('/^\./m', '..', $rawMessage);
|
||||||
|
$resp = $send($stuffed . "\r\n.");
|
||||||
|
if (strncmp($resp, '250', 3) !== 0) {
|
||||||
|
throw new \RuntimeException("SMTP DATA rejected: {$resp}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$send('QUIT');
|
||||||
|
fclose($sock);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user