feat(email): validate the sender domain and log why a send failed #117
Inga granskare
Etiketter
Inga etiketter
area/backups
area/ci
area/control-panel
area/identity
area/infra
area/observability
area/payments
area/security
area/storage
area/web
blocked
needs-info
needs-triage
ready-for-implementation
type
bug
type
chore
type
docs
type
epic
type
feature
type
task
wontfix
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Inget förfallodatum satt.
Beroenden
Inga beroenden satta
Referens
bitborg/bitborg-web!117
Läser in…
Hänvisa till i nytt ärende
Ingen beskrivning angiven.
Ta bort grenen "feat/113-115-mail-sender-validation-and-diagnostics"
Borttagning av en gren är permanent. Även om den borttagna grenen kan fortsätta existera en kort tid innan den faktiskt tas bort, kan det INTE ångras i de flesta fall. Vill du fortsätta?
Closes #115. Part of #113 — the portal half; the infra half is gitborg/gitborg-infra#283.
Two failures from today's mail.gitborg.se move motivated this, and neither was diagnosable from the portal's own output.
Sender validation (#113)
EMAIL_FROMis hardcoded here while the same address is configured independently in bitborg-infra, and nothing made them agree. They drifted: infra moved, the portal kept sending asemail.gitborg.se, and every portal mail was rejected.senderAllowed()now checks the sender's domain againstEMAIL_ALLOWED_SENDER_DOMAINS(supplied by infra from the same variable that drivesforgejo_mailer_from), andsendEmailrefuses before contacting the provider — naming the real problem instead of returning whatever status the provider happens to pick.An unset allowlist is deliberately distinct from a rejecting one. Absent means "infra has not told us yet" and still sends, so deploying this in either order relative to #283 cannot break mail. Only a populated list that excludes our domain refuses.
The From stays hardcoded on purpose — an env-set From could send as an unverified domain. The fix is detection, not configurability.
Failure diagnostics (#115)
sendEmailkeptres.statusand discarded the body, so a wrong-account API key surfaced as a barestatus 422. The provider had explained itself; we threw it away, and diagnosis needed account knowledge rather than logs.The body is now captured, capped at 512 bytes, and rendered by
describeSendFailure()into one line per failure mode:confignot attempted: SWEEGO_API_KEY is unsetsenderrefused before sending: …error+ statusprovider rejected (status 422): {"error":…}error, no statusprovider unreachable or timed outThe route now logs the
configcase too, which it previously skipped. That skip is why an absent log line meant either delivered or never attempted — a systematically broken sender was indistinguishable from a healthy quiet one, and that ambiguity is exactly what let this run unnoticed until a user reported no email.Built test-first
src/lib/email.test.tsis new — the mail path had no unit tests at all, itself part of why this stayed hidden. Each test was watched failing before the code existed. 19 tests: allowlist parsing (whitespace, case, multi-entry, empty), refusal before send with an assertion that the provider was never called, unset-allowlist passthrough, body capture, the 512-byte cap, and every failure sentence.Dependencies are injected following the
health.tspattern rather than mocking globals, usinginrather than??so an explicitundefinedcannot silently pick up an ambient env var and make a result depend on the developer's shell.Verification
vitest run— 114 tests pass (12 files)astro check— 0 errors (the 20 hints are pre-existing)eslint+stylelint— cleanNot covered
detailis operator telemetry and must never reach a user — it is provider output and may echo the request. It is returned for logging only; no template renders it.#116 (a resend path for a failed setup link) is separate and still open — this makes the failure visible, not recoverable.