1
0
mirror of git://f0xx.org/ac/ac-deploy synced 2026-07-29 02:57:38 +03:00

cluster0: sync lab-seed AuthMailer with inline CID QR for verify email

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-06-23 21:34:04 +02:00
parent 5b16848366
commit 560e4e12b2
2 changed files with 81 additions and 23 deletions

View File

@@ -8,32 +8,36 @@ final class AuthMailer {
/** /**
* @param list<array{name:string,data:string,type:string}> $attachments * @param list<array{name:string,data:string,type:string}> $attachments
* @param list<array{cid:string,data:string,type:string}> $inlineImages
*/ */
public static function send( public static function send(
string $to, string $to,
string $subject, string $subject,
string $bodyText, string $bodyText,
array $attachments = [], array $attachments = [],
?string $bodyHtml = null ?string $bodyHtml = null,
array $inlineImages = []
): bool { ): bool {
$smtpUser = (string) cfg('mail.smtp.username', ''); $smtpUser = (string) cfg('mail.smtp.username', '');
$from = self::resolveFromAddress($smtpUser); $from = self::resolveFromAddress($smtpUser);
$replyTo = (string) cfg('mail.reply_to', ''); $replyTo = (string) cfg('mail.reply_to', '');
$transport = strtolower((string) cfg('mail.transport', 'smtp')); $transport = strtolower((string) cfg('mail.transport', 'smtp'));
if ($transport === 'sendmail') { 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 { public static function sendVerifyEmail(string $to, string $verifyUrl): bool {
$subject = 'Verify your Android Cast account'; $subject = 'Verify your Android Cast account';
$shortUrl = $verifyUrl; $shortUrl = $verifyUrl;
$qrImageUrl = null; $qrImageUrl = null;
$qrScanUrl = '';
if (is_callable(['ShortLinksRepository', 'shortenForOutboundMail'])) { if (is_callable(['ShortLinksRepository', 'shortenForOutboundMail'])) {
$pack = ShortLinksRepository::shortenForOutboundMail($verifyUrl, 86400); $pack = ShortLinksRepository::shortenForOutboundMail($verifyUrl, 86400);
$shortUrl = (string) ($pack['short_url'] ?? $verifyUrl); $shortUrl = (string) ($pack['short_url'] ?? $verifyUrl);
$qrImageUrl = (string) ($pack['qr_image_url'] ?? ''); $qrImageUrl = (string) ($pack['qr_image_url'] ?? '');
$qrScanUrl = (string) ($pack['qr_scan_url'] ?? '');
if ($qrImageUrl === '' && preg_match('#/([a-f0-9]{6,16})$#', $shortUrl, $m)) { if ($qrImageUrl === '' && preg_match('#/([a-f0-9]{6,16})$#', $shortUrl, $m)) {
$qrImageUrl = preg_replace( $qrImageUrl = preg_replace(
'#/([a-f0-9]{6,16})$#', '#/([a-f0-9]{6,16})$#',
@@ -47,12 +51,33 @@ final class AuthMailer {
} }
$body = "Open this link to verify your email (valid 24h):\n\n" $body = "Open this link to verify your email (valid 24h):\n\n"
. $shortUrl . "\n"; . $shortUrl . "\n";
if ($qrImageUrl !== null) { $bodyHtml = null;
$body .= "\nOr scan the QR code on your phone — open this image URL:\n" $inline = [];
. $qrImageUrl . "\n"; $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. if ($qrImageUrl !== null) {
return self::send($to, $subject, $body); $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 = '<!DOCTYPE html><html><body style="font-family:sans-serif;line-height:1.5">'
. '<p>Open this link to verify your email (valid 24h):</p>'
. '<p><a href="' . $escUrl . '">' . $escUrl . '</a></p>'
. '<p>Or scan this QR code on your phone:</p>'
. '<p><img src="cid:' . $cid . '" alt="Verify email QR code" width="256" height="256"></p>'
. '</body></html>';
$inline[] = ['cid' => $cid, 'data' => $qrPng, 'type' => 'image/png'];
}
return self::send($to, $subject, $body, [], $bodyHtml, $inline);
} }
private static function resolveFromAddress(string $smtpUser): string { private static function resolveFromAddress(string $smtpUser): string {
@@ -89,13 +114,14 @@ final class AuthMailer {
string $from, string $from,
string $replyTo, string $replyTo,
array $attachments, array $attachments,
?string $bodyHtml = null ?string $bodyHtml = null,
array $inlineImages = []
): bool { ): bool {
$headers = "From: {$from}\r\n"; $headers = "From: {$from}\r\n";
if ($replyTo !== '') { if ($replyTo !== '') {
$headers .= "Reply-To: {$replyTo}\r\n"; $headers .= "Reply-To: {$replyTo}\r\n";
} }
$payload = self::buildMessageBody($body, $attachments, $bodyHtml); $payload = self::buildMessageBody($body, $attachments, $bodyHtml, $inlineImages);
if ($payload['mime'] !== null) { if ($payload['mime'] !== null) {
$headers .= "MIME-Version: 1.0\r\n"; $headers .= "MIME-Version: 1.0\r\n";
$headers .= 'Content-Type: ' . $payload['mime'] . '; boundary="' . $payload['boundary'] . "\"\r\n"; $headers .= 'Content-Type: ' . $payload['mime'] . '; boundary="' . $payload['boundary'] . "\"\r\n";
@@ -115,7 +141,8 @@ final class AuthMailer {
string $from, string $from,
string $replyTo, string $replyTo,
array $attachments, array $attachments,
?string $bodyHtml = null ?string $bodyHtml = null,
array $inlineImages = []
): bool { ): bool {
$host = (string) cfg('mail.smtp.host', '127.0.0.1'); $host = (string) cfg('mail.smtp.host', '127.0.0.1');
$port = (int) cfg('mail.smtp.port', 587); $port = (int) cfg('mail.smtp.port', 587);
@@ -128,7 +155,7 @@ final class AuthMailer {
$fp = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 15); $fp = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 15);
if ($fp === false) { if ($fp === false) {
error_log('AuthMailer SMTP connect failed: ' . $errstr); 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); stream_set_timeout($fp, 15);
if (!self::smtpExpect($fp, [220])) { if (!self::smtpExpect($fp, [220])) {
@@ -199,7 +226,7 @@ final class AuthMailer {
fclose($fp); fclose($fp);
return false; return false;
} }
$payload = self::buildMessageBody($body, $attachments, $bodyHtml); $payload = self::buildMessageBody($body, $attachments, $bodyHtml, $inlineImages);
$msg = self::buildSmtpHeaders($from, $to, $replyTo, $subject, $payload); $msg = self::buildSmtpHeaders($from, $to, $replyTo, $subject, $payload);
fwrite($fp, $msg); fwrite($fp, $msg);
if (!self::smtpExpect($fp, [250])) { if (!self::smtpExpect($fp, [250])) {
@@ -250,33 +277,64 @@ final class AuthMailer {
/** /**
* @param list<array{name:string,data:string,type:string}> $attachments * @param list<array{name:string,data:string,type:string}> $attachments
* @param list<array{cid:string,data:string,type:string}> $inlineImages
* @return array{mime:?string,boundary:?string,body:string} * @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 !== []) { if ($attachments !== []) {
$built = self::buildMime($textBody, $attachments); $built = self::buildMime($textBody, $attachments);
return ['mime' => 'multipart/mixed', 'boundary' => $built['boundary'], 'body' => $built['body']]; return ['mime' => 'multipart/mixed', 'boundary' => $built['boundary'], 'body' => $built['body']];
} }
if ($bodyHtml !== null && $bodyHtml !== '') { 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' => 'multipart/alternative', 'boundary' => $built['boundary'], 'body' => $built['body']];
} }
return ['mime' => null, 'boundary' => null, 'body' => $textBody]; return ['mime' => null, 'boundary' => null, 'body' => $textBody];
} }
/** @return array{boundary:string,body:string} */ /**
private static function buildAlternativeMime(string $textBody, string $htmlBody): array { * @param list<array{cid:string,data:string,type:string}> $inlineImages
$boundary = 'ac_alt_' . bin2hex(random_bytes(12)); * @return array{boundary:string,body:string}
$out = "--{$boundary}\r\n"; */
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-Type: text/plain; charset=UTF-8\r\n";
$out .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; $out .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$out .= $textBody . "\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-Type: text/html; charset=UTF-8\r\n";
$out .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; $out .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$out .= $htmlBody . "\r\n"; $out .= $htmlBody . "\r\n";
$out .= "--{$boundary}--\r\n"; $out .= "--{$altBoundary}--\r\n";
return ['boundary' => $boundary, 'body' => $out]; 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";
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];
} }
/** /**

View File

@@ -35,7 +35,7 @@ echo ($ok ? "mail_ok" : "mail_fail") . " short=" . $short;
log "$_out to=$TO" log "$_out to=$TO"
if echo "$_out" | grep -q mail_ok; then 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" log "hint: Gmail relay may still async-bounce verify mail — check spam + mailer-daemon"
fi fi
case "$_out" in case "$_out" in