mirror of
git://f0xx.org/ac/ac-ms-identity
synced 2026-07-30 02:57:45 +03:00
Gmail blocks short-link + attachment as phishing; use multipart/alternative HTML with s.f0xx.org/api/v1/qr PNG URL (QR still encodes …&src=qr). Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
11 KiB
PHP
278 lines
11 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/** Outbound mail (registration verify, password reset). Task 2.5 — uses config mail.* */
|
|
final class AuthMailer {
|
|
private function __construct() {
|
|
}
|
|
|
|
/**
|
|
* @param list<array{name:string,data:string,type:string}> $attachments
|
|
*/
|
|
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, $bodyHtml);
|
|
}
|
|
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;
|
|
$qrImageUrl = null;
|
|
if (class_exists('ShortLinksRepository', false)) {
|
|
$pack = ShortLinksRepository::shortenForOutboundMail($verifyUrl, 86400);
|
|
$shortUrl = (string) ($pack['short_url'] ?? $verifyUrl);
|
|
$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";
|
|
$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>';
|
|
}
|
|
// Hosted QR image (no PNG attachment) — Gmail blocks short-link + attachment as phishing.
|
|
return self::send($to, $subject, $body, [], $bodyHtml);
|
|
}
|
|
|
|
/**
|
|
* @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,
|
|
?string $bodyHtml = null
|
|
): bool {
|
|
$headers = "From: {$from}\r\n";
|
|
if ($replyTo !== '') {
|
|
$headers .= "Reply-To: {$replyTo}\r\n";
|
|
}
|
|
$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);
|
|
}
|
|
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
|
return @mail($to, $subject, $body, $headers);
|
|
}
|
|
|
|
/**
|
|
* @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,
|
|
?string $bodyHtml = null
|
|
): 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'));
|
|
$user = (string) cfg('mail.smtp.username', '');
|
|
$pass = (string) cfg('mail.smtp.password', '');
|
|
$remote = ($enc === 'ssl' ? 'ssl://' : '') . $host;
|
|
$errno = 0;
|
|
$errstr = '';
|
|
$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);
|
|
}
|
|
stream_set_timeout($fp, 15);
|
|
if (!self::smtpExpect($fp, [220])) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
$ehloHost = 'localhost';
|
|
fwrite($fp, "EHLO {$ehloHost}\r\n");
|
|
if (!self::smtpExpect($fp, [250])) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
if ($enc === 'tls') {
|
|
fwrite($fp, "STARTTLS\r\n");
|
|
if (!self::smtpExpect($fp, [220])) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
fwrite($fp, "EHLO {$ehloHost}\r\n");
|
|
if (!self::smtpExpect($fp, [250])) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
}
|
|
if ($user !== '') {
|
|
fwrite($fp, "AUTH LOGIN\r\n");
|
|
if (!self::smtpExpect($fp, [334])) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
fwrite($fp, base64_encode($user) . "\r\n");
|
|
if (!self::smtpExpect($fp, [334])) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
fwrite($fp, base64_encode($pass) . "\r\n");
|
|
if (!self::smtpExpect($fp, [235])) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
}
|
|
$fromAddr = self::extractAddress($from);
|
|
fwrite($fp, "MAIL FROM:<{$fromAddr}>\r\n");
|
|
if (!self::smtpExpect($fp, [250])) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
fwrite($fp, "RCPT TO:<{$to}>\r\n");
|
|
if (!self::smtpExpect($fp, [250, 251])) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
fwrite($fp, "DATA\r\n");
|
|
if (!self::smtpExpect($fp, [354])) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
$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 ($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);
|
|
if (!self::smtpExpect($fp, [250])) {
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
fwrite($fp, "QUIT\r\n");
|
|
fclose($fp);
|
|
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}
|
|
*/
|
|
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 = 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;
|
|
if (strlen($chunk) >= 4 && $chunk[3] === ' ') {
|
|
break;
|
|
}
|
|
}
|
|
return $line;
|
|
}
|
|
|
|
private static function extractAddress(string $from): string {
|
|
if (preg_match('/<([^>]+)>/', $from, $m)) {
|
|
return trim($m[1]);
|
|
}
|
|
return trim($from);
|
|
}
|
|
}
|