diff --git a/sim/cluster0/lab-seeds/backend/src/AuthMailer.php b/sim/cluster0/lab-seeds/backend/src/AuthMailer.php index f309e13..3f7f33c 100644 --- a/sim/cluster0/lab-seeds/backend/src/AuthMailer.php +++ b/sim/cluster0/lab-seeds/backend/src/AuthMailer.php @@ -16,7 +16,8 @@ final class AuthMailer { array $attachments = [], ?string $bodyHtml = null ): bool { - $from = (string) cfg('mail.from', 'Android Cast '); + $smtpUser = (string) cfg('mail.smtp.username', ''); + $from = self::resolveFromAddress($smtpUser); $replyTo = (string) cfg('mail.reply_to', ''); $transport = strtolower((string) cfg('mail.transport', 'smtp')); if ($transport === 'sendmail') { @@ -45,22 +46,37 @@ final class AuthMailer { } } $body = "Open this link to verify your email (valid 24h):\n\n" - . $shortUrl . "\n\n"; - $bodyHtml = null; + . $shortUrl . "\n"; 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"; - $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); + // Plain text only — Gmail relay flags multipart HTML + short links + remote QR images. + return self::send($to, $subject, $body); + } + + private static function resolveFromAddress(string $smtpUser): string { + $from = (string) cfg('mail.from', 'Android Cast '); + $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); 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"); if (!self::smtpExpect($fp, [250])) { fclose($fp); @@ -175,18 +200,7 @@ final class AuthMailer { 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"; - } + $msg = self::buildSmtpHeaders($from, $to, $replyTo, $subject, $payload); fwrite($fp, $msg); if (!self::smtpExpect($fp, [250])) { fclose($fp); @@ -197,6 +211,43 @@ final class AuthMailer { 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 $attachments * @return array{mime:?string,boundary:?string,body:string} diff --git a/sim/cluster0/scripts/compose-lab-backend.sh b/sim/cluster0/scripts/compose-lab-backend.sh index 7ba4b52..2df5afe 100755 --- a/sim/cluster0/scripts/compose-lab-backend.sh +++ b/sim/cluster0/scripts/compose-lab-backend.sh @@ -118,7 +118,8 @@ AUTH_KEY="${AUTH_ENCRYPTION_KEY:-lab-cluster-dev-32-char-min-secret!!}" _MSMTP_PASS="$(read_secret MSMTP_APP_PASSWORD 2>/dev/null || true)" [ -n "$_MSMTP_PASS" ] || _MSMTP_PASS="${MSMTP_APP_PASSWORD:-}" MAIL_TRANSPORT="${MAIL_TRANSPORT:-sendmail}" -MAIL_FROM="${MAIL_FROM:-Android Cast Lab }" +_mail_from_secret="$(read_secret MAIL_FROM 2>/dev/null || true)" +[ -n "$_mail_from_secret" ] || _mail_from_secret="${MAIL_FROM:-}" MAIL_REPLY="${MAIL_REPLY_TO:-bestcastr@gmail.com}" SMTP_HOST="${SMTP_HOST:-127.0.0.1}" SMTP_PORT="${SMTP_PORT:-587}" @@ -130,9 +131,11 @@ if [ -n "$_MSMTP_PASS" ]; then SMTP_HOST=smtp.gmail.com SMTP_USER="${MSMTP_USER:-bestcastr@gmail.com}" SMTP_PASS="$_MSMTP_PASS" - MAIL_FROM="${MAIL_FROM:-Android Cast }" fi -unset _MSMTP_PASS +MAIL_FROM="$(normalize_mail_from "$_mail_from_secret" "$SMTP_USER")" +[ -n "$MAIL_FROM" ] || MAIL_FROM="Android Cast Lab <${SMTP_USER:-bestcastr@gmail.com}>" +unset _MSMTP_PASS _mail_from_secret +MAIL_FROM_PHP="$(printf '%s' "$MAIL_FROM" | sed "s/'/\\\\'/g")" AUTH_BEARER_ID="$(read_secret URL_SHORTENER_AUTH_BEARER_ID 2>/dev/null || true)" [ -n "$AUTH_BEARER_ID" ] || AUTH_BEARER_ID="${URL_SHORTENER_AUTH_BEARER_ID:-0}" mkdir -p "${COMPOSED}/config" @@ -174,7 +177,7 @@ return [ ], 'mail' => [ 'transport' => '${MAIL_TRANSPORT}', - 'from' => '${MAIL_FROM}', + 'from' => '${MAIL_FROM_PHP}', 'reply_to' => '${MAIL_REPLY}', 'smtp' => [ 'host' => '${SMTP_HOST}', diff --git a/sim/cluster0/scripts/lib/common.sh b/sim/cluster0/scripts/lib/common.sh index b4ef57d..74daa4f 100644 --- a/sim/cluster0/scripts/lib/common.sh +++ b/sim/cluster0/scripts/lib/common.sh @@ -189,21 +189,55 @@ read_cred() { load_secrets_lab() { _file="${CAST_CLUSTER_ROOT}/secrets.lab.env" [ -f "$_file" ] || return 0 - # Export only simple KEY=value lines (no shell metacharacters in values via source). + _tmp="/tmp/cast-secrets-load-$$" + : > "$_tmp" while IFS= read -r _line || [ -n "$_line" ]; do case "$_line" in ''|\#*) continue ;; *=*) _k="${_line%%=*}" _v="${_line#*=}" + case "$_v" in + \"*) _v="${_v#\"}"; _v="${_v%\"}" ;; + esac case "$_k" in MAIL_*|MSMTP_*|SMTP_*|AUTH_*|GITEA_*|WG_*|RSSH_*|URL_SHORTENER_*) - export "$_k=$_v" + _v_escaped="$(printf '%s' "$_v" | sed "s/'/'\\\\''/g")" + printf "%s='%s'\n" "$_k" "$_v_escaped" >> "$_tmp" ;; esac ;; esac done < "$_file" + # shellcheck disable=SC1090 + . "$_tmp" + rm -f "$_tmp" +} + +# RFC5322 From — secrets without angle brackets break Gmail SMTP (shell ate ). +normalize_mail_from() { + _in="$1" + _smtp_user="${2:-}" + case "$_in" in + *'<'*'>'*) printf '%s' "$_in"; return ;; + esac + case "$_in" in + *@*) + _email="$(printf '%s' "$_in" | awk '{print $NF}')" + _name="$(printf '%s' "$_in" | sed "s/ ${_email}\$//")" + if [ -n "$_name" ]; then + printf '%s <%s>' "$_name" "$_email" + else + printf '%s' "$_email" + fi + return + ;; + esac + if [ -n "$_smtp_user" ] && printf '%s' "$_smtp_user" | grep -q '@'; then + printf 'Android Cast Lab <%s>' "$_smtp_user" + return + fi + printf '%s' "$_in" } read_secret() { @@ -213,6 +247,8 @@ read_secret() { awk -v k="$_key" ' $0 ~ "^" k "=" { sub("^" k "=", "") + sub(/^"/, "") + sub(/"$/, "") print exit } diff --git a/sim/cluster0/scripts/verify-mail-lab.sh b/sim/cluster0/scripts/verify-mail-lab.sh index 6b98337..70dfe42 100755 --- a/sim/cluster0/scripts/verify-mail-lab.sh +++ b/sim/cluster0/scripts/verify-mail-lab.sh @@ -35,7 +35,8 @@ echo ($ok ? "mail_ok" : "mail_fail") . " short=" . $short; log "$_out to=$TO" if echo "$_out" | grep -q mail_ok; then - log "hint: expect s.f0xx.org short link + inline QR via s.f0xx.org/api/v1/qr/…png (encodes …&src=qr)" + log "hint: expect s.f0xx.org short link + QR image URL in plain text (encodes …&src=qr)" + log "hint: Gmail relay may still async-bounce verify mail — check spam + mailer-daemon" fi case "$_out" in mail_ok) exit 0 ;;