diff --git a/TODO.md b/TODO.md index f505286..9a0cf71 100644 --- a/TODO.md +++ b/TODO.md @@ -5,4 +5,5 @@ - [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] 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 diff --git a/app/src/SmtpRelay.php b/app/src/SmtpRelay.php index a3f0db0..c00bffa 100644 --- a/app/src/SmtpRelay.php +++ b/app/src/SmtpRelay.php @@ -94,16 +94,15 @@ class SmtpRelay { /** * Send an e-mail using the stored SMTP credentials. * - * Currently uses PHP's `mail()` as a passthrough so the rest of the - * application can call `SmtpRelay::send(…)` everywhere. - * The actual SMTP transport layer will be wired in a later iteration - * (e.g. replace this body with PHPMailer / Symfony Mailer). + * Uses a native PHP socket SMTP client — no external dependencies. + * Supports STARTTLS (port 587 / encryption=tls) and direct SSL (port 465 / + * encryption=ssl). Falls back to plain if encryption=none. * * @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 send request acceptance; false on failure + * @return bool True on successful delivery acceptance; false on failure */ public static function send( Database $db, @@ -112,41 +111,175 @@ class SmtpRelay { string $body, string $plain = '' ): bool { - $settings = self::getSettings($db); - if ($settings['from_email'] === '') { + $s = self::getSettings($db); + if ($s['from_email'] === '') { error_log('[SmtpRelay] send() aborted — no from_email configured'); return false; } - // Build MIME multipart headers - $boundary = 'posterg_' . md5((string) random_int(0, PHP_INT_MAX) . microtime(true)); - $headers = "From: {$settings['from_name']} <{$settings['from_email']}>\r\n"; - $headers .= "Reply-To: {$settings['from_email']}\r\n"; - $headers .= "MIME-Version: 1.0\r\n"; + // Build MIME message + $boundary = 'posterg_' . 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) . '?='; - if ($plain !== '') { - $headers .= "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n"; - $message = "--{$boundary}\r\n"; - $message .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; - $message .= self::htmlToPlain($body) . "\r\n\r\n"; - $message .= "--{$boundary}\r\n"; - $message .= "Content-Type: text/html; charset=UTF-8\r\n\r\n"; - $message .= $body . "\r\n\r\n"; - $message .= "--{$boundary}--"; + $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 { - $headers .= "Content-Type: text/html; charset=UTF-8\r\n"; - $message = $body; + $msgBody = chunk_split(base64_encode($body)); + $ctHdr = 'text/html; charset=UTF-8'; } - // TODO: replace with real SMTP transport (PHPMailer / Symfony Mailer) - // The stored credentials ($settings) will be passed to the mailer then. - $ok = mail($to, $subject, $message, $headers); + $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; - if (!$ok) { - error_log("[SmtpRelay] mail() returned false for {$to}"); + try { + 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; } /** diff --git a/app/storage/test.db b/app/storage/test.db index 2425954..39df89d 100644 Binary files a/app/storage/test.db and b/app/storage/test.db differ