From 978831ecaa99319ae5a38c7297c8a7ef488f3910 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 23 Jun 2026 20:14:27 +0200 Subject: [PATCH] =?UTF-8?q?auth:=20Gmail=20relay=20deliverability=20?= =?UTF-8?q?=E2=80=94=20From=20match,=20Date,=20plain=20verify=20mail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use SMTP username as From on smtp.gmail.com; add Date/Message-ID headers; verify email plain text only (short link + QR URL line). Co-authored-by: Cursor --- src/AuthMailer.php | 105 +++++++++++++++++++++++++++++++++------------ 1 file changed, 78 insertions(+), 27 deletions(-) diff --git a/src/AuthMailer.php b/src/AuthMailer.php index f309e13..3f7f33c 100644 --- a/src/AuthMailer.php +++ b/src/AuthMailer.php @@ -16,7 +16,8 @@ final class AuthMailer { array $attachments = [], ?string $bodyHtml = null ): bool { - $from = (string) cfg('mail.from', 'Android Cast '); + $smtpUser = (string) cfg('mail.smtp.username', ''); + $from = self::resolveFromAddress($smtpUser); $replyTo = (string) cfg('mail.reply_to', ''); $transport = strtolower((string) cfg('mail.transport', 'smtp')); if ($transport === 'sendmail') { @@ -45,22 +46,37 @@ final class AuthMailer { } } $body = "Open this link to verify your email (valid 24h):\n\n" - . $shortUrl . "\n\n"; - $bodyHtml = null; + . $shortUrl . "\n"; if ($qrImageUrl !== null) { - $body .= "Or scan the QR code on your phone — open this image URL:\n" + $body .= "\nOr scan the QR code on your phone — open this image URL:\n" . $qrImageUrl . "\n"; - $escUrl = htmlspecialchars($shortUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - $escQr = htmlspecialchars($qrImageUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - $bodyHtml = '' - . '

Open this link to verify your email (valid 24h):

' - . '

' . $escUrl . '

' - . '

Or scan this QR code on your phone:

' - . '

Verify email QR code

' - . ''; } - // Hosted QR image (no PNG attachment) — Gmail blocks short-link + attachment as phishing. - return self::send($to, $subject, $body, [], $bodyHtml); + // Plain text only — Gmail relay flags multipart HTML + short links + remote QR images. + return self::send($to, $subject, $body); + } + + private static function resolveFromAddress(string $smtpUser): string { + $from = (string) cfg('mail.from', 'Android Cast '); + $host = strtolower((string) cfg('mail.smtp.host', '')); + if ($host === 'smtp.gmail.com' && $smtpUser !== '') { + return $smtpUser; + } + return self::normalizeFrom($from, $smtpUser); + } + + private static function normalizeFrom(string $from, string $smtpUser = ''): string { + if (preg_match('/<[^>]+>/', trim($from))) { + return trim($from); + } + if (preg_match('/\S+@\S+/', $from, $m)) { + $email = $m[0]; + $name = trim(str_replace($email, '', $from)); + return $name !== '' ? "{$name} <{$email}>" : $email; + } + if ($smtpUser !== '' && str_contains($smtpUser, '@')) { + return $smtpUser; + } + return trim($from); } /** @@ -119,7 +135,16 @@ final class AuthMailer { fclose($fp); return false; } - $ehloHost = 'localhost'; + $ehloHost = (string) cfg('mail.smtp.ehlo', ''); + if ($ehloHost === '') { + $ehloHost = php_uname('n') ?: 'localhost'; + if ($ehloHost === 'localhost' || !str_contains($ehloHost, '.')) { + $origin = (string) cfg('public_origin', ''); + if ($origin !== '' && ($host = parse_url($origin, PHP_URL_HOST))) { + $ehloHost = (string) $host; + } + } + } fwrite($fp, "EHLO {$ehloHost}\r\n"); if (!self::smtpExpect($fp, [250])) { fclose($fp); @@ -175,18 +200,7 @@ final class AuthMailer { return false; } $payload = self::buildMessageBody($body, $attachments, $bodyHtml); - $msg = "From: {$from}\r\n"; - if ($replyTo !== '') { - $msg .= "Reply-To: {$replyTo}\r\n"; - } - $msg .= "To: {$to}\r\nSubject: {$subject}\r\n"; - if ($payload['mime'] === null) { - $msg .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n{$body}\r\n.\r\n"; - } else { - $msg .= "MIME-Version: 1.0\r\n"; - $msg .= 'Content-Type: ' . $payload['mime'] . '; boundary="' . $payload['boundary'] . "\"\r\n\r\n"; - $msg .= $payload['body'] . "\r\n.\r\n"; - } + $msg = self::buildSmtpHeaders($from, $to, $replyTo, $subject, $payload); fwrite($fp, $msg); if (!self::smtpExpect($fp, [250])) { fclose($fp); @@ -197,6 +211,43 @@ final class AuthMailer { return true; } + /** + * @param array{mime:?string,boundary:?string,body:string} $payload + */ + private static function buildSmtpHeaders( + string $from, + string $to, + string $replyTo, + string $subject, + array $payload + ): string { + $msg = 'Date: ' . gmdate('D, d M Y H:i:s') . " +0000\r\n"; + $msg .= 'Message-ID: <' . bin2hex(random_bytes(16)) . '@androidcast.local>' . "\r\n"; + $msg .= "From: {$from}\r\n"; + if ($replyTo !== '') { + $msg .= "Reply-To: {$replyTo}\r\n"; + } + $msg .= "To: {$to}\r\n"; + $msg .= 'Subject: ' . self::encodeHeader($subject) . "\r\n"; + $msg .= "MIME-Version: 1.0\r\n"; + if ($payload['mime'] === null) { + $msg .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $msg .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; + $msg .= $payload['body'] . "\r\n.\r\n"; + return $msg; + } + $msg .= 'Content-Type: ' . $payload['mime'] . '; boundary="' . $payload['boundary'] . "\"\r\n\r\n"; + $msg .= $payload['body'] . "\r\n.\r\n"; + return $msg; + } + + private static function encodeHeader(string $value): string { + if (preg_match('/^[\x20-\x7E]*$/', $value)) { + return $value; + } + return '=?UTF-8?B?' . base64_encode($value) . '?='; + } + /** * @param list $attachments * @return array{mime:?string,boundary:?string,body:string}