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{cid:string,data:string,type:string}> $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 = '<!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 {
@@ -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<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}
*/
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<array{cid:string,data:string,type:string}> $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];
}
/**