$attachments */ public static function send( string $to, string $subject, string $bodyText, array $attachments = [], ?string $bodyHtml = null ): bool { $from = (string) cfg('mail.from', 'Android Cast '); $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 = '' . '

Open this link to verify your email (valid 24h):

' . '

' . $escUrl . '

' . '

Or scan this QR code on your phone:

' . '

Verify email QR code

' . ''; } // Hosted QR image (no PNG attachment) — Gmail blocks short-link + attachment as phishing. return self::send($to, $subject, $body, [], $bodyHtml); } /** * @param list $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 $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 $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 $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 $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); } }