From 529bd616829a83d95c986454ceb0ca7b3f5aa6e6 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 23 Jun 2026 19:01:30 +0200 Subject: [PATCH] cluster0: verify email QR via hosted image URL, not SMTP attachment Co-authored-by: Cursor --- .../lab-seeds/backend/src/AuthMailer.php | 121 +++++++++++++----- .../backend/src/ShortLinksRepository.php | 7 +- sim/cluster0/scripts/verify-mail-lab.sh | 2 +- 3 files changed, 92 insertions(+), 38 deletions(-) diff --git a/sim/cluster0/lab-seeds/backend/src/AuthMailer.php b/sim/cluster0/lab-seeds/backend/src/AuthMailer.php index d4bf3fd..20e559d 100644 --- a/sim/cluster0/lab-seeds/backend/src/AuthMailer.php +++ b/sim/cluster0/lab-seeds/backend/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 { diff --git a/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php b/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php index 6553e13..ef6f03a 100644 --- a/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php +++ b/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php @@ -487,11 +487,11 @@ final class ShortLinksRepository { /** * Short link + QR payload for outbound mail. * - * @return array{short_url:string,qr_scan_url:string,qr_png:?string} + * @return array{short_url:string,qr_scan_url:string,qr_image_url:?string} */ public static function shortenForOutboundMail(string $longUrl, int $ttlSeconds = 86400): array { $longUrl = trim($longUrl); - $fallback = ['short_url' => $longUrl, 'qr_scan_url' => $longUrl, 'qr_png' => null]; + $fallback = ['short_url' => $longUrl, 'qr_scan_url' => $longUrl, 'qr_image_url' => null]; if ($longUrl === '' || !UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) { return $fallback; } @@ -506,10 +506,11 @@ final class ShortLinksRepository { } $shortUrl = (string) $mint['short_url']; $qrScan = self::qrScanUrl($shortUrl); + $qrImage = (string) ($mint['qr_url'] ?? ''); return [ 'short_url' => $shortUrl, 'qr_scan_url' => $qrScan, - 'qr_png' => self::renderQrPng($qrScan), + 'qr_image_url' => $qrImage !== '' ? $qrImage : null, ]; } diff --git a/sim/cluster0/scripts/verify-mail-lab.sh b/sim/cluster0/scripts/verify-mail-lab.sh index aabe696..5f5e8d0 100755 --- a/sim/cluster0/scripts/verify-mail-lab.sh +++ b/sim/cluster0/scripts/verify-mail-lab.sh @@ -30,7 +30,7 @@ echo $ok ? "mail_ok" : "mail_fail"; log "$_out to=$TO short=$(echo "$_long" | sed 's/token=.*/token=…/')" if echo "$_out" | grep -q mail_ok; then - log "hint: expect s.f0xx.org short link + verify-email-qr.png attachment (QR encodes …&src=qr)" + log "hint: expect s.f0xx.org short link + inline QR via s.f0xx.org/api/v1/qr/…png (encodes …&src=qr)" fi case "$_out" in mail_ok) exit 0 ;;