mirror of
git://f0xx.org/ac/ac-ms-identity
synced 2026-07-30 02:37:39 +03:00
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -6,35 +6,82 @@ final class AuthMailer {
|
||||
private function __construct() {
|
||||
}
|
||||
|
||||
public static function send(string $to, string $subject, string $bodyText): bool {
|
||||
/**
|
||||
* @param list<array{name:string,data:string,type:string}> $attachments
|
||||
*/
|
||||
public static function send(string $to, string $subject, string $bodyText, array $attachments = []): 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);
|
||||
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<array{name:string,data:string,type:string}> $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<array{name:string,data:string,type:string}> $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<array{name:string,data:string,type:string}> $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<int> $codes */
|
||||
private static function smtpExpect($fp, array $codes): bool {
|
||||
$line = '';
|
||||
|
||||
Reference in New Issue
Block a user