mirror of
git://f0xx.org/ac/ac-ms-identity
synced 2026-07-30 03:38:12 +03:00
auth: Gmail relay deliverability — From match, Date, plain verify mail
Use SMTP username as From on smtp.gmail.com; add Date/Message-ID headers; verify email plain text only (short link + QR URL line). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,7 +16,8 @@ final class AuthMailer {
|
|||||||
array $attachments = [],
|
array $attachments = [],
|
||||||
?string $bodyHtml = null
|
?string $bodyHtml = null
|
||||||
): bool {
|
): bool {
|
||||||
$from = (string) cfg('mail.from', 'Android Cast <noreply@apps.f0xx.org>');
|
$smtpUser = (string) cfg('mail.smtp.username', '');
|
||||||
|
$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') {
|
||||||
@@ -45,22 +46,37 @@ 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\n";
|
. $shortUrl . "\n";
|
||||||
$bodyHtml = null;
|
|
||||||
if ($qrImageUrl !== null) {
|
if ($qrImageUrl !== null) {
|
||||||
$body .= "Or scan the QR code on your phone — open this image URL:\n"
|
$body .= "\nOr scan the QR code on your phone — open this image URL:\n"
|
||||||
. $qrImageUrl . "\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>';
|
|
||||||
}
|
}
|
||||||
// Hosted QR image (no PNG attachment) — Gmail blocks short-link + attachment as phishing.
|
// Plain text only — Gmail relay flags multipart HTML + short links + remote QR images.
|
||||||
return self::send($to, $subject, $body, [], $bodyHtml);
|
return self::send($to, $subject, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveFromAddress(string $smtpUser): string {
|
||||||
|
$from = (string) cfg('mail.from', 'Android Cast <noreply@apps.f0xx.org>');
|
||||||
|
$host = strtolower((string) cfg('mail.smtp.host', ''));
|
||||||
|
if ($host === 'smtp.gmail.com' && $smtpUser !== '') {
|
||||||
|
return $smtpUser;
|
||||||
|
}
|
||||||
|
return self::normalizeFrom($from, $smtpUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeFrom(string $from, string $smtpUser = ''): string {
|
||||||
|
if (preg_match('/<[^>]+>/', trim($from))) {
|
||||||
|
return trim($from);
|
||||||
|
}
|
||||||
|
if (preg_match('/\S+@\S+/', $from, $m)) {
|
||||||
|
$email = $m[0];
|
||||||
|
$name = trim(str_replace($email, '', $from));
|
||||||
|
return $name !== '' ? "{$name} <{$email}>" : $email;
|
||||||
|
}
|
||||||
|
if ($smtpUser !== '' && str_contains($smtpUser, '@')) {
|
||||||
|
return $smtpUser;
|
||||||
|
}
|
||||||
|
return trim($from);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,7 +135,16 @@ final class AuthMailer {
|
|||||||
fclose($fp);
|
fclose($fp);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$ehloHost = 'localhost';
|
$ehloHost = (string) cfg('mail.smtp.ehlo', '');
|
||||||
|
if ($ehloHost === '') {
|
||||||
|
$ehloHost = php_uname('n') ?: 'localhost';
|
||||||
|
if ($ehloHost === 'localhost' || !str_contains($ehloHost, '.')) {
|
||||||
|
$origin = (string) cfg('public_origin', '');
|
||||||
|
if ($origin !== '' && ($host = parse_url($origin, PHP_URL_HOST))) {
|
||||||
|
$ehloHost = (string) $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
fwrite($fp, "EHLO {$ehloHost}\r\n");
|
fwrite($fp, "EHLO {$ehloHost}\r\n");
|
||||||
if (!self::smtpExpect($fp, [250])) {
|
if (!self::smtpExpect($fp, [250])) {
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
@@ -175,18 +200,7 @@ final class AuthMailer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$payload = self::buildMessageBody($body, $attachments, $bodyHtml);
|
$payload = self::buildMessageBody($body, $attachments, $bodyHtml);
|
||||||
$msg = "From: {$from}\r\n";
|
$msg = self::buildSmtpHeaders($from, $to, $replyTo, $subject, $payload);
|
||||||
if ($replyTo !== '') {
|
|
||||||
$msg .= "Reply-To: {$replyTo}\r\n";
|
|
||||||
}
|
|
||||||
$msg .= "To: {$to}\r\nSubject: {$subject}\r\n";
|
|
||||||
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: ' . $payload['mime'] . '; boundary="' . $payload['boundary'] . "\"\r\n\r\n";
|
|
||||||
$msg .= $payload['body'] . "\r\n.\r\n";
|
|
||||||
}
|
|
||||||
fwrite($fp, $msg);
|
fwrite($fp, $msg);
|
||||||
if (!self::smtpExpect($fp, [250])) {
|
if (!self::smtpExpect($fp, [250])) {
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
@@ -197,6 +211,43 @@ final class AuthMailer {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{mime:?string,boundary:?string,body:string} $payload
|
||||||
|
*/
|
||||||
|
private static function buildSmtpHeaders(
|
||||||
|
string $from,
|
||||||
|
string $to,
|
||||||
|
string $replyTo,
|
||||||
|
string $subject,
|
||||||
|
array $payload
|
||||||
|
): string {
|
||||||
|
$msg = 'Date: ' . gmdate('D, d M Y H:i:s') . " +0000\r\n";
|
||||||
|
$msg .= 'Message-ID: <' . bin2hex(random_bytes(16)) . '@androidcast.local>' . "\r\n";
|
||||||
|
$msg .= "From: {$from}\r\n";
|
||||||
|
if ($replyTo !== '') {
|
||||||
|
$msg .= "Reply-To: {$replyTo}\r\n";
|
||||||
|
}
|
||||||
|
$msg .= "To: {$to}\r\n";
|
||||||
|
$msg .= 'Subject: ' . self::encodeHeader($subject) . "\r\n";
|
||||||
|
$msg .= "MIME-Version: 1.0\r\n";
|
||||||
|
if ($payload['mime'] === null) {
|
||||||
|
$msg .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
|
$msg .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||||
|
$msg .= $payload['body'] . "\r\n.\r\n";
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
$msg .= 'Content-Type: ' . $payload['mime'] . '; boundary="' . $payload['boundary'] . "\"\r\n\r\n";
|
||||||
|
$msg .= $payload['body'] . "\r\n.\r\n";
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function encodeHeader(string $value): string {
|
||||||
|
if (preg_match('/^[\x20-\x7E]*$/', $value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
return '=?UTF-8?B?' . base64_encode($value) . '?=';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array{name:string,data:string,type:string}> $attachments
|
* @param list<array{name:string,data:string,type:string}> $attachments
|
||||||
* @return array{mime:?string,boundary:?string,body:string}
|
* @return array{mime:?string,boundary:?string,body:string}
|
||||||
|
|||||||
Reference in New Issue
Block a user