diff --git a/src/App/Mailer.php b/src/App/Mailer.php index 91cfc91..9397554 100644 --- a/src/App/Mailer.php +++ b/src/App/Mailer.php @@ -193,51 +193,131 @@ final class Mailer stream_set_timeout($fp, 15); $transcript = []; - $read = function (string $label = 'read') use ($fp, &$transcript) { - $line = fgets($fp, 515); - if ($line !== false) { - $transcript[] = $label . ': ' . trim($line); + $readResponse = function (array $expectCodes = [], string $label = 'read') use ($fp, &$transcript): array { + $lines = []; + while (($line = fgets($fp, 515)) !== false) { + $line = rtrim($line, "\r\n"); + $lines[] = $line; + $transcript[] = $label . ': ' . $line; + // SMTP multiline: code + '-' means more lines, code + ' ' means end + if (strlen($line) >= 4 && $line[3] === ' ') { + break; + } } - return $line; + $code = 0; + if ($lines) { + $code = (int)substr($lines[0], 0, 3); + } + return [ + 'ok' => !$expectCodes || in_array($code, $expectCodes, true), + 'code' => $code, + 'lines' => $lines, + ]; }; - $write = function (string $cmd, string $label = 'write', bool $mask = false) use ($fp, &$transcript) { + $write = function (string $cmd, string $label = 'write', bool $mask = false) use ($fp, &$transcript): void { $transcript[] = $label . ': ' . ($mask ? '[omitted]' : $cmd); fwrite($fp, $cmd . "\r\n"); }; - $read(); + $resp = $readResponse([220], 'greeting'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_greeting_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + $write('EHLO ' . $this->app->config()->primaryDomain); - $read(); + $resp = $readResponse([250], 'ehlo'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_ehlo_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } if ($secure === 'tls') { $write('STARTTLS'); - $read(); - if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + $resp = $readResponse([220], 'starttls'); + if (!$resp['ok'] || !stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { fclose($fp); - $this->log('mail_smtp_starttls_failed', ['host' => $host, 'port' => $port]); + $this->log('mail_smtp_starttls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); $this->sendMailFn($to, $subject, $html, $from, $fromName); return; } $write('EHLO ' . $this->app->config()->primaryDomain); - $read(); + $resp = $readResponse([250], 'ehlo-tls'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_ehlo_tls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } } if ($user !== '') { $write('AUTH LOGIN'); - $read(); + $resp = $readResponse([334], 'auth-login'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_auth_login_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } $write(base64_encode($user), 'auth-user', true); - $read(); + $resp = $readResponse([334], 'auth-user'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_auth_user_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } $write(base64_encode($pass), 'auth-pass', true); - $read(); + $resp = $readResponse([235], 'auth-pass'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_auth_pass_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } } $write('MAIL FROM: <' . $from . '>'); - $read(); + $resp = $readResponse([250], 'mail-from'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_mailfrom_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + $write('RCPT TO: <' . $to . '>'); - $read(); + $resp = $readResponse([250, 251], 'rcpt-to'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_rcpt_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + $write('DATA'); - $read(); + $resp = $readResponse([354], 'data-start'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_data_start_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } $msg = "From: {$fromName} <{$from}>\r\n"; $msg .= "To: <{$to}>\r\n"; @@ -245,14 +325,16 @@ final class Mailer $msg .= "MIME-Version: 1.0\r\n"; $msg .= "Content-Type: text/html; charset=utf-8\r\n\r\n"; $msg .= $html . "\r\n.\r\n"; - $write($msg, 'data'); - $resp = $read('data-response'); + $write($msg, 'data', false); + $resp = $readResponse([250], 'data-end'); + $write('QUIT'); + $readResponse([221], 'quit'); fclose($fp); $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); - if ($resp === false || !str_starts_with((string)$resp, '250')) { + if (!$resp['ok']) { $this->log('mail_smtp_send_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); $this->sendMailFn($to, $subject, $html, $from, $fromName); return;