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

cluster0: verify email QR via hosted image URL, not SMTP attachment

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-06-23 19:01:30 +02:00
parent 7a014118a2
commit 529bd61682
3 changed files with 92 additions and 38 deletions

View File

@@ -9,41 +9,51 @@ final class AuthMailer {
/**
* @param list<array{name:string,data:string,type:string}> $attachments
*/
public static function send(string $to, string $subject, string $bodyText, array $attachments = []): bool {
public static function send(
string $to,
string $subject,
string $bodyText,
array $attachments = [],
?string $bodyHtml = null
): bool {
$from = (string) cfg('mail.from', 'Android Cast <noreply@apps.f0xx.org>');
$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);
return self::sendMail($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml);
}
return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments);
return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml);
}
public static function sendVerifyEmail(string $to, string $verifyUrl): bool {
$subject = 'Verify your Android Cast account';
$shortUrl = $verifyUrl;
$qrScan = $verifyUrl;
$qrPng = null;
$qrImageUrl = 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;
$qrImageUrl = (string) ($pack['qr_image_url'] ?? '');
if ($qrImageUrl === '') {
$qrImageUrl = 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";
$bodyHtml = null;
if ($qrImageUrl !== null) {
$body .= "Or scan the QR code on your phone — open this image URL:\n"
. $qrImageUrl . "\n";
$escUrl = htmlspecialchars($shortUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$escQr = htmlspecialchars($qrImageUrl, 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="' . $escQr . '" alt="Verify email QR code" width="256" height="256"></p>'
. '</body></html>';
}
$attachments = [];
if ($qrPng !== null && $qrPng !== '') {
$attachments[] = [
'name' => 'verify-email-qr.png',
'data' => $qrPng,
'type' => 'image/png',
];
}
return self::send($to, $subject, $body, $attachments);
// Hosted QR image (no PNG attachment) — Gmail blocks short-link + attachment as phishing.
return self::send($to, $subject, $body, [], $bodyHtml);
}
/**
@@ -55,20 +65,21 @@ final class AuthMailer {
string $body,
string $from,
string $replyTo,
array $attachments
array $attachments,
?string $bodyHtml = null
): bool {
$headers = "From: {$from}\r\n";
if ($replyTo !== '') {
$headers .= "Reply-To: {$replyTo}\r\n";
}
if ($attachments === []) {
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
return @mail($to, $subject, $body, $headers);
$payload = self::buildMessageBody($body, $attachments, $bodyHtml);
if ($payload['mime'] !== null) {
$headers .= "MIME-Version: 1.0\r\n";
$headers .= 'Content-Type: ' . $payload['mime'] . '; boundary="' . $payload['boundary'] . "\"\r\n";
return @mail($to, $subject, $payload['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);
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
return @mail($to, $subject, $body, $headers);
}
/**
@@ -80,7 +91,8 @@ final class AuthMailer {
string $body,
string $from,
string $replyTo,
array $attachments
array $attachments,
?string $bodyHtml = null
): bool {
$host = (string) cfg('mail.smtp.host', '127.0.0.1');
$port = (int) cfg('mail.smtp.port', 587);
@@ -93,7 +105,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);
return self::sendMail($to, $subject, $body, $from, $replyTo, $attachments, $bodyHtml);
}
stream_set_timeout($fp, 15);
if (!self::smtpExpect($fp, [220])) {
@@ -155,18 +167,18 @@ final class AuthMailer {
fclose($fp);
return false;
}
$mime = $attachments === [] ? null : self::buildMime($body, $attachments);
$payload = self::buildMessageBody($body, $attachments, $bodyHtml);
$msg = "From: {$from}\r\n";
if ($replyTo !== '') {
$msg .= "Reply-To: {$replyTo}\r\n";
}
$msg .= "To: {$to}\r\nSubject: {$subject}\r\n";
if ($mime === null) {
if ($payload['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";
$msg .= 'Content-Type: ' . $payload['mime'] . '; boundary="' . $payload['boundary'] . "\"\r\n\r\n";
$msg .= $payload['body'] . "\r\n.\r\n";
}
fwrite($fp, $msg);
if (!self::smtpExpect($fp, [250])) {
@@ -178,6 +190,37 @@ final class AuthMailer {
return true;
}
/**
* @param list<array{name:string,data:string,type:string}> $attachments
* @return array{mime:?string,boundary:?string,body:string}
*/
private static function buildMessageBody(string $textBody, array $attachments, ?string $bodyHtml): 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);
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";
$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 .= "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];
}
/**
* @param list<array{name:string,data:string,type:string}> $attachments
* @return array{boundary:string,body:string}
@@ -204,6 +247,17 @@ final class AuthMailer {
/** @param resource $fp @param list<int> $codes */
private static function smtpExpect($fp, array $codes): bool {
$line = self::smtpReadLine($fp);
$code = (int) substr(trim($line), 0, 3);
if (!in_array($code, $codes, true)) {
error_log('AuthMailer SMTP rejected: ' . trim($line));
return false;
}
return true;
}
/** @param resource $fp */
private static function smtpReadLine($fp): string {
$line = '';
while (($chunk = fgets($fp, 512)) !== false) {
$line .= $chunk;
@@ -211,8 +265,7 @@ final class AuthMailer {
break;
}
}
$code = (int) substr(trim($line), 0, 3);
return in_array($code, $codes, true);
return $line;
}
private static function extractAddress(string $from): string {

View File

@@ -487,11 +487,11 @@ final class ShortLinksRepository {
/**
* Short link + QR payload for outbound mail.
*
* @return array{short_url:string,qr_scan_url:string,qr_png:?string}
* @return array{short_url:string,qr_scan_url:string,qr_image_url:?string}
*/
public static function shortenForOutboundMail(string $longUrl, int $ttlSeconds = 86400): array {
$longUrl = trim($longUrl);
$fallback = ['short_url' => $longUrl, 'qr_scan_url' => $longUrl, 'qr_png' => null];
$fallback = ['short_url' => $longUrl, 'qr_scan_url' => $longUrl, 'qr_image_url' => null];
if ($longUrl === '' || !UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) {
return $fallback;
}
@@ -506,10 +506,11 @@ final class ShortLinksRepository {
}
$shortUrl = (string) $mint['short_url'];
$qrScan = self::qrScanUrl($shortUrl);
$qrImage = (string) ($mint['qr_url'] ?? '');
return [
'short_url' => $shortUrl,
'qr_scan_url' => $qrScan,
'qr_png' => self::renderQrPng($qrScan),
'qr_image_url' => $qrImage !== '' ? $qrImage : null,
];
}