From a64e5a7d483376b90f07a79c9c00fa4a5c3cfc61 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 23 Jun 2026 18:53:34 +0200 Subject: [PATCH] auth: verify email with short link + QR PNG attachment Multipart SMTP mail attaches verify-email-qr.png; QR encodes s.f0xx.org URL with src=qr via ShortLinksRepository::shortenForOutboundMail. Co-authored-by: Cursor --- src/AuthMailer.php | 107 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 14 deletions(-) diff --git a/src/AuthMailer.php b/src/AuthMailer.php index eb9e6cf..d4bf3fd 100644 --- a/src/AuthMailer.php +++ b/src/AuthMailer.php @@ -6,35 +6,82 @@ final class AuthMailer { private function __construct() { } - public static function send(string $to, string $subject, string $bodyText): bool { + /** + * @param list $attachments + */ + public static function send(string $to, string $subject, string $bodyText, array $attachments = []): 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); + return self::sendMail($to, $subject, $bodyText, $from, $replyTo, $attachments); } - return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo); + return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments); } public static function sendVerifyEmail(string $to, string $verifyUrl): bool { $subject = 'Verify your Android Cast account'; - $link = class_exists('ShortLinksRepository', false) - ? ShortLinksRepository::shortenForOutbound($verifyUrl, 86400) - : $verifyUrl; - $body = "Open this link to verify your email (valid 24h):\n\n" . $link . "\n"; - return self::send($to, $subject, $body); + $shortUrl = $verifyUrl; + $qrScan = $verifyUrl; + $qrPng = 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; + } + $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"; + } + $attachments = []; + if ($qrPng !== null && $qrPng !== '') { + $attachments[] = [ + 'name' => 'verify-email-qr.png', + 'data' => $qrPng, + 'type' => 'image/png', + ]; + } + return self::send($to, $subject, $body, $attachments); } - private static function sendMail(string $to, string $subject, string $body, string $from, string $replyTo): bool { + /** + * @param list $attachments + */ + private static function sendMail( + string $to, + string $subject, + string $body, + string $from, + string $replyTo, + array $attachments + ): bool { $headers = "From: {$from}\r\n"; if ($replyTo !== '') { $headers .= "Reply-To: {$replyTo}\r\n"; } - $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; - return @mail($to, $subject, $body, $headers); + if ($attachments === []) { + $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; + return @mail($to, $subject, $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); } - private static function sendSmtp(string $to, string $subject, string $body, string $from, string $replyTo): bool { + /** + * @param list $attachments + */ + private static function sendSmtp( + string $to, + string $subject, + string $body, + string $from, + string $replyTo, + array $attachments + ): bool { $host = (string) cfg('mail.smtp.host', '127.0.0.1'); $port = (int) cfg('mail.smtp.port', 587); $enc = strtolower((string) cfg('mail.smtp.encryption', 'tls')); @@ -46,7 +93,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); + return self::sendMail($to, $subject, $body, $from, $replyTo, $attachments); } stream_set_timeout($fp, 15); if (!self::smtpExpect($fp, [220])) { @@ -108,11 +155,19 @@ final class AuthMailer { fclose($fp); return false; } + $mime = $attachments === [] ? null : self::buildMime($body, $attachments); $msg = "From: {$from}\r\n"; if ($replyTo !== '') { $msg .= "Reply-To: {$replyTo}\r\n"; } - $msg .= "To: {$to}\r\nSubject: {$subject}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n{$body}\r\n.\r\n"; + $msg .= "To: {$to}\r\nSubject: {$subject}\r\n"; + if ($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"; + } fwrite($fp, $msg); if (!self::smtpExpect($fp, [250])) { fclose($fp); @@ -123,6 +178,30 @@ final class AuthMailer { return true; } + /** + * @param list $attachments + * @return array{boundary:string,body:string} + */ + private static function buildMime(string $textBody, array $attachments): array { + $boundary = 'ac_' . 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"; + foreach ($attachments as $att) { + $name = (string) ($att['name'] ?? 'attachment.bin'); + $type = (string) ($att['type'] ?? 'application/octet-stream'); + $data = (string) ($att['data'] ?? ''); + $out .= "--{$boundary}\r\n"; + $out .= "Content-Type: {$type}; name=\"{$name}\"\r\n"; + $out .= "Content-Transfer-Encoding: base64\r\n"; + $out .= "Content-Disposition: attachment; filename=\"{$name}\"\r\n\r\n"; + $out .= chunk_split(base64_encode($data), 76, "\r\n"); + } + $out .= "--{$boundary}--\r\n"; + return ['boundary' => $boundary, 'body' => $out]; + } + /** @param resource $fp @param list $codes */ private static function smtpExpect($fp, array $codes): bool { $line = '';