From 290109d0f103e70a4799f4055082e95e9f600e63 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 23 Jun 2026 19:01:27 +0200 Subject: [PATCH] auth: embed hosted QR image in verify email instead of PNG attachment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gmail blocks short-link + attachment as phishing; use multipart/alternative HTML with s.f0xx.org/api/v1/qr PNG URL (QR still encodes …&src=qr). Co-authored-by: Cursor --- src/AuthMailer.php | 121 ++++++++++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 34 deletions(-) diff --git a/src/AuthMailer.php b/src/AuthMailer.php index d4bf3fd..20e559d 100644 --- a/src/AuthMailer.php +++ b/src/AuthMailer.php @@ -9,41 +9,51 @@ final class AuthMailer { /** * @param list $attachments */ - public static function send(string $to, string $subject, string $bodyText, array $attachments = []): bool { + public static function send( + string $to, + string $subject, + string $bodyText, + array $attachments = [], + ?string $bodyHtml = null + ): bool { $from = (string) cfg('mail.from', 'Android Cast '); $replyTo = (string) cfg('mail.reply_to', ''); $transport = strtolower((string) cfg('mail.transport', 'smtp')); if ($transport === 'sendmail') { - return self::sendMail($to, $subject, $bodyText, $from, $replyTo, $attachments); + return self::sendMail($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml); } - return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments); + return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml); } public static function sendVerifyEmail(string $to, string $verifyUrl): bool { $subject = 'Verify your Android Cast account'; $shortUrl = $verifyUrl; - $qrScan = $verifyUrl; - $qrPng = null; + $qrImageUrl = null; if (class_exists('ShortLinksRepository', false)) { $pack = ShortLinksRepository::shortenForOutboundMail($verifyUrl, 86400); $shortUrl = (string) ($pack['short_url'] ?? $verifyUrl); - $qrScan = (string) ($pack['qr_scan_url'] ?? $shortUrl); - $qrPng = $pack['qr_png'] ?? null; + $qrImageUrl = (string) ($pack['qr_image_url'] ?? ''); + if ($qrImageUrl === '') { + $qrImageUrl = null; + } } $body = "Open this link to verify your email (valid 24h):\n\n" . $shortUrl . "\n\n"; - if ($qrPng !== null && $qrPng !== '') { - $body .= "Or scan the attached QR code on your phone (opens the same short link).\n"; + $bodyHtml = null; + if ($qrImageUrl !== null) { + $body .= "Or 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

' + . ''; } - $attachments = []; - if ($qrPng !== null && $qrPng !== '') { - $attachments[] = [ - 'name' => 'verify-email-qr.png', - 'data' => $qrPng, - 'type' => 'image/png', - ]; - } - return self::send($to, $subject, $body, $attachments); + // Hosted QR image (no PNG attachment) — Gmail blocks short-link + attachment as phishing. + return self::send($to, $subject, $body, [], $bodyHtml); } /** @@ -55,20 +65,21 @@ final class AuthMailer { string $body, string $from, string $replyTo, - array $attachments + array $attachments, + ?string $bodyHtml = null ): bool { $headers = "From: {$from}\r\n"; if ($replyTo !== '') { $headers .= "Reply-To: {$replyTo}\r\n"; } - if ($attachments === []) { - $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; - return @mail($to, $subject, $body, $headers); + $payload = self::buildMessageBody($body, $attachments, $bodyHtml); + if ($payload['mime'] !== null) { + $headers .= "MIME-Version: 1.0\r\n"; + $headers .= 'Content-Type: ' . $payload['mime'] . '; boundary="' . $payload['boundary'] . "\"\r\n"; + return @mail($to, $subject, $payload['body'], $headers); } - $built = self::buildMime($body, $attachments); - $headers .= "MIME-Version: 1.0\r\n"; - $headers .= 'Content-Type: multipart/mixed; boundary="' . $built['boundary'] . "\"\r\n"; - return @mail($to, $subject, $built['body'], $headers); + $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; + return @mail($to, $subject, $body, $headers); } /** @@ -80,7 +91,8 @@ final class AuthMailer { string $body, string $from, string $replyTo, - array $attachments + array $attachments, + ?string $bodyHtml = null ): bool { $host = (string) cfg('mail.smtp.host', '127.0.0.1'); $port = (int) cfg('mail.smtp.port', 587); @@ -93,7 +105,7 @@ final class AuthMailer { $fp = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 15); if ($fp === false) { error_log('AuthMailer SMTP connect failed: ' . $errstr); - return self::sendMail($to, $subject, $body, $from, $replyTo, $attachments); + return self::sendMail($to, $subject, $body, $from, $replyTo, $attachments, $bodyHtml); } stream_set_timeout($fp, 15); if (!self::smtpExpect($fp, [220])) { @@ -155,18 +167,18 @@ final class AuthMailer { fclose($fp); return false; } - $mime = $attachments === [] ? null : self::buildMime($body, $attachments); + $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 ($mime === null) { + 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: multipart/mixed; boundary="' . $mime['boundary'] . "\"\r\n\r\n"; - $msg .= $mime['body'] . "\r\n.\r\n"; + $msg .= 'Content-Type: ' . $payload['mime'] . '; boundary="' . $payload['boundary'] . "\"\r\n\r\n"; + $msg .= $payload['body'] . "\r\n.\r\n"; } fwrite($fp, $msg); if (!self::smtpExpect($fp, [250])) { @@ -178,6 +190,37 @@ final class AuthMailer { return true; } + /** + * @param list $attachments + * @return array{mime:?string,boundary:?string,body:string} + */ + private static function buildMessageBody(string $textBody, array $attachments, ?string $bodyHtml): array { + if ($attachments !== []) { + $built = self::buildMime($textBody, $attachments); + return ['mime' => 'multipart/mixed', 'boundary' => $built['boundary'], 'body' => $built['body']]; + } + if ($bodyHtml !== null && $bodyHtml !== '') { + $built = self::buildAlternativeMime($textBody, $bodyHtml); + return ['mime' => 'multipart/alternative', 'boundary' => $built['boundary'], 'body' => $built['body']]; + } + return ['mime' => null, 'boundary' => null, 'body' => $textBody]; + } + + /** @return array{boundary:string,body:string} */ + private static function buildAlternativeMime(string $textBody, string $htmlBody): array { + $boundary = 'ac_alt_' . bin2hex(random_bytes(12)); + $out = "--{$boundary}\r\n"; + $out .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $out .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; + $out .= $textBody . "\r\n"; + $out .= "--{$boundary}\r\n"; + $out .= "Content-Type: text/html; charset=UTF-8\r\n"; + $out .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; + $out .= $htmlBody . "\r\n"; + $out .= "--{$boundary}--\r\n"; + return ['boundary' => $boundary, 'body' => $out]; + } + /** * @param list $attachments * @return array{boundary:string,body:string} @@ -204,6 +247,17 @@ final class AuthMailer { /** @param resource $fp @param list $codes */ private static function smtpExpect($fp, array $codes): bool { + $line = self::smtpReadLine($fp); + $code = (int) substr(trim($line), 0, 3); + if (!in_array($code, $codes, true)) { + error_log('AuthMailer SMTP rejected: ' . trim($line)); + return false; + } + return true; + } + + /** @param resource $fp */ + private static function smtpReadLine($fp): string { $line = ''; while (($chunk = fgets($fp, 512)) !== false) { $line .= $chunk; @@ -211,8 +265,7 @@ final class AuthMailer { break; } } - $code = (int) substr(trim($line), 0, 3); - return in_array($code, $codes, true); + return $line; } private static function extractAddress(string $from): string {