From 560e4e12b2deadd8bfdfd8736f8c30b350f29e18 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 23 Jun 2026 21:34:04 +0200 Subject: [PATCH] cluster0: sync lab-seed AuthMailer with inline CID QR for verify email Co-authored-by: Cursor --- .../lab-seeds/backend/src/AuthMailer.php | 102 ++++++++++++++---- sim/cluster0/scripts/verify-mail-lab.sh | 2 +- 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/sim/cluster0/lab-seeds/backend/src/AuthMailer.php b/sim/cluster0/lab-seeds/backend/src/AuthMailer.php index 3f7f33c..8fb5e5b 100644 --- a/sim/cluster0/lab-seeds/backend/src/AuthMailer.php +++ b/sim/cluster0/lab-seeds/backend/src/AuthMailer.php @@ -8,32 +8,36 @@ final class AuthMailer { /** * @param list $attachments + * @param list $inlineImages */ public static function send( string $to, string $subject, string $bodyText, array $attachments = [], - ?string $bodyHtml = null + ?string $bodyHtml = null, + array $inlineImages = [] ): bool { $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') { - return self::sendMail($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml); + return self::sendMail($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml, $inlineImages); } - return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml); + return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml, $inlineImages); } public static function sendVerifyEmail(string $to, string $verifyUrl): bool { $subject = 'Verify your Android Cast account'; $shortUrl = $verifyUrl; $qrImageUrl = null; + $qrScanUrl = ''; if (is_callable(['ShortLinksRepository', 'shortenForOutboundMail'])) { $pack = ShortLinksRepository::shortenForOutboundMail($verifyUrl, 86400); $shortUrl = (string) ($pack['short_url'] ?? $verifyUrl); $qrImageUrl = (string) ($pack['qr_image_url'] ?? ''); + $qrScanUrl = (string) ($pack['qr_scan_url'] ?? ''); if ($qrImageUrl === '' && preg_match('#/([a-f0-9]{6,16})$#', $shortUrl, $m)) { $qrImageUrl = preg_replace( '#/([a-f0-9]{6,16})$#', @@ -47,12 +51,33 @@ final class AuthMailer { } $body = "Open this link to verify your email (valid 24h):\n\n" . $shortUrl . "\n"; - if ($qrImageUrl !== null) { - $body .= "\nOr scan the QR code on your phone — open this image URL:\n" - . $qrImageUrl . "\n"; + $bodyHtml = null; + $inline = []; + $qrPng = null; + if ($qrScanUrl !== '' && is_callable(['ShortLinksRepository', 'renderQrPng'])) { + $qrPng = ShortLinksRepository::renderQrPng($qrScanUrl); } - // Plain text only — Gmail relay flags multipart HTML + short links + remote QR images. - return self::send($to, $subject, $body); + if ($qrImageUrl !== null) { + $body .= "\nOr scan the QR code on your phone"; + if ($qrPng === null || $qrPng === '') { + $body .= " — open this image URL:\n" . $qrImageUrl . "\n"; + } else { + $body .= " (shown below in HTML view).\n"; + $body .= "Fallback image URL: " . $qrImageUrl . "\n"; + } + } + if ($qrPng !== null && $qrPng !== '') { + $cid = 'verify-qr-' . bin2hex(random_bytes(4)) . '@androidcast'; + $escUrl = htmlspecialchars($shortUrl, 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

' + . ''; + $inline[] = ['cid' => $cid, 'data' => $qrPng, 'type' => 'image/png']; + } + return self::send($to, $subject, $body, [], $bodyHtml, $inline); } private static function resolveFromAddress(string $smtpUser): string { @@ -89,13 +114,14 @@ final class AuthMailer { string $from, string $replyTo, array $attachments, - ?string $bodyHtml = null + ?string $bodyHtml = null, + array $inlineImages = [] ): bool { $headers = "From: {$from}\r\n"; if ($replyTo !== '') { $headers .= "Reply-To: {$replyTo}\r\n"; } - $payload = self::buildMessageBody($body, $attachments, $bodyHtml); + $payload = self::buildMessageBody($body, $attachments, $bodyHtml, $inlineImages); if ($payload['mime'] !== null) { $headers .= "MIME-Version: 1.0\r\n"; $headers .= 'Content-Type: ' . $payload['mime'] . '; boundary="' . $payload['boundary'] . "\"\r\n"; @@ -115,7 +141,8 @@ final class AuthMailer { string $from, string $replyTo, array $attachments, - ?string $bodyHtml = null + ?string $bodyHtml = null, + array $inlineImages = [] ): bool { $host = (string) cfg('mail.smtp.host', '127.0.0.1'); $port = (int) cfg('mail.smtp.port', 587); @@ -128,7 +155,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, $bodyHtml); + return self::sendMail($to, $subject, $body, $from, $replyTo, $attachments, $bodyHtml, $inlineImages); } stream_set_timeout($fp, 15); if (!self::smtpExpect($fp, [220])) { @@ -199,7 +226,7 @@ final class AuthMailer { fclose($fp); return false; } - $payload = self::buildMessageBody($body, $attachments, $bodyHtml); + $payload = self::buildMessageBody($body, $attachments, $bodyHtml, $inlineImages); $msg = self::buildSmtpHeaders($from, $to, $replyTo, $subject, $payload); fwrite($fp, $msg); if (!self::smtpExpect($fp, [250])) { @@ -250,33 +277,64 @@ final class AuthMailer { /** * @param list $attachments + * @param list $inlineImages * @return array{mime:?string,boundary:?string,body:string} */ - private static function buildMessageBody(string $textBody, array $attachments, ?string $bodyHtml): array { + private static function buildMessageBody( + string $textBody, + array $attachments, + ?string $bodyHtml, + array $inlineImages = [] + ): 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); + $built = self::buildAlternativeMime($textBody, $bodyHtml, $inlineImages); 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"; + /** + * @param list $inlineImages + * @return array{boundary:string,body:string} + */ + private static function buildAlternativeMime(string $textBody, string $htmlBody, array $inlineImages = []): array { + $altBoundary = 'ac_alt_' . bin2hex(random_bytes(12)); + $out = "--{$altBoundary}\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 .= "--{$altBoundary}\r\n"; + if ($inlineImages === []) { + $out .= "Content-Type: text/html; charset=UTF-8\r\n"; + $out .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; + $out .= $htmlBody . "\r\n"; + $out .= "--{$altBoundary}--\r\n"; + return ['boundary' => $altBoundary, 'body' => $out]; + } + $relBoundary = 'ac_rel_' . bin2hex(random_bytes(12)); + $out .= "Content-Type: multipart/related; boundary=\"{$relBoundary}\"\r\n\r\n"; + $out .= "--{$relBoundary}\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]; + foreach ($inlineImages as $img) { + $cid = (string) ($img['cid'] ?? 'inline'); + $type = (string) ($img['type'] ?? 'image/png'); + $data = (string) ($img['data'] ?? ''); + $out .= "--{$relBoundary}\r\n"; + $out .= "Content-Type: {$type}\r\n"; + $out .= "Content-Transfer-Encoding: base64\r\n"; + $out .= "Content-ID: <{$cid}>\r\n"; + $out .= "Content-Disposition: inline\r\n\r\n"; + $out .= chunk_split(base64_encode($data), 76, "\r\n"); + } + $out .= "--{$relBoundary}--\r\n"; + $out .= "--{$altBoundary}--\r\n"; + return ['boundary' => $altBoundary, 'body' => $out]; } /** diff --git a/sim/cluster0/scripts/verify-mail-lab.sh b/sim/cluster0/scripts/verify-mail-lab.sh index 70dfe42..98fd7c4 100755 --- a/sim/cluster0/scripts/verify-mail-lab.sh +++ b/sim/cluster0/scripts/verify-mail-lab.sh @@ -35,7 +35,7 @@ echo ($ok ? "mail_ok" : "mail_fail") . " short=" . $short; log "$_out to=$TO" if echo "$_out" | grep -q mail_ok; then - log "hint: expect s.f0xx.org short link + QR image URL in plain text (encodes …&src=qr)" + log "hint: expect s.f0xx.org short link + inline QR (CID PNG encodes …&src=qr)" log "hint: Gmail relay may still async-bounce verify mail — check spam + mailer-daemon" fi case "$_out" in