mirror of
git://f0xx.org/ac/ac-deploy
synced 2026-07-29 03:38:07 +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:
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user