feat(email): make transactional email live — branded relay, rate-limit, squat fix (PR1a) #161

Merged
developer merged 6 commits from feature/email-relay-pr1 into development 2026-07-03 01:56:55 +00:00
Owner

Make transactional email actually send: the backend already had the OTP engine and a Mailer seam, but BACKEND_SMTP_* was never in compose, so every contour fell back to the log mailer. PR1a of a layered feature; the one-tap confirm deeplink and the gateway/UI surface it needs follow in PR1b.

Backend

  • Swap net/smtp for go-mail behind the existing Mailer seam: a text + HTML Message, TLS mode chosen by port (implicit TLS on 465, else STARTTLS), a dial timeout, no client cert (server cert validated vs the system roots).
  • Branded, image-free, mobile-friendly ru/en HTML template (+ plain-text alternative): a large readable code and an ignore-notice footer with a landing link.
  • New BACKEND_PUBLIC_BASE_URL — the canonical origin for links in the email, never the request Host (anti-injection); required when a relay is configured.
  • Squat fix: an email-login account is a reapable guest until the code is confirmed (then durable), so an abandoned login frees its reserved address.
  • Per-recipient send throttle (60s cooldown + 5/hour) -> HTTP 429.

Deploy

  • Wire the relay through compose + ci.yaml (TEST_) + prod-deploy.yaml (PROD_ env + env.sh) + .env.example + README. An empty host keeps the log mailer, so a contour without relay credentials still boots.

UI

  • Un-hide the Profile email box for guests (register / sign in via email; a returning address opens the existing merge dialog). Provider linking stays hidden until PR2. Email login on the login screen was already visible.

Docs

  • ARCHITECTURE, FUNCTIONAL (+ru), TESTING, backend README.

Verified locally

gofmt/build/vet, account+server unit, integration (Email/Merge/squat, testcontainers), ui check/test:unit/build/e2e (176 passed). No schema change -> no contour wipe.

Caveats

  • The client language is not yet forwarded by the gateway, so fresh web email-LOGIN emails default to English (link emails use the account language) — a small follow-up.
  • The deeplink backend is preserved on branch feature/email-relay-deeplink-wip for PR1b.
Make transactional email actually send: the backend already had the OTP engine and a Mailer seam, but BACKEND_SMTP_* was never in compose, so every contour fell back to the log mailer. PR1a of a layered feature; the one-tap confirm deeplink and the gateway/UI surface it needs follow in PR1b. ## Backend - Swap net/smtp for go-mail behind the existing `Mailer` seam: a text + HTML `Message`, TLS mode chosen by port (implicit TLS on 465, else STARTTLS), a dial timeout, no client cert (server cert validated vs the system roots). - Branded, image-free, mobile-friendly ru/en HTML template (+ plain-text alternative): a large readable code and an ignore-notice footer with a landing link. - New `BACKEND_PUBLIC_BASE_URL` — the canonical origin for links in the email, never the request Host (anti-injection); required when a relay is configured. - Squat fix: an email-login account is a reapable guest until the code is confirmed (then durable), so an abandoned login frees its reserved address. - Per-recipient send throttle (60s cooldown + 5/hour) -> HTTP 429. ## Deploy - Wire the relay through compose + ci.yaml (`TEST_`) + prod-deploy.yaml (`PROD_` env + env.sh) + .env.example + README. An empty host keeps the log mailer, so a contour without relay credentials still boots. ## UI - Un-hide the Profile email box for guests (register / sign in via email; a returning address opens the existing merge dialog). Provider linking stays hidden until PR2. Email login on the login screen was already visible. ## Docs - ARCHITECTURE, FUNCTIONAL (+ru), TESTING, backend README. ## Verified locally gofmt/build/vet, account+server unit, integration (Email/Merge/squat, testcontainers), ui check/test:unit/build/e2e (176 passed). No schema change -> no contour wipe. ## Caveats - The client `language` is not yet forwarded by the gateway, so fresh web email-LOGIN emails default to English (link emails use the account language) — a small follow-up. - The deeplink backend is preserved on branch `feature/email-relay-deeplink-wip` for PR1b.
developer added 5 commits 2026-07-03 01:26:32 +00:00
Swap the net/smtp mailer for go-mail behind the existing Mailer seam: the
Message struct now carries a text + HTML body, TLS mode is chosen from the port
(implicit TLS on 465, else mandatory STARTTLS), a dial timeout bounds the
synchronous send, and no client certificate is needed. Add a branded, image-free,
mobile-friendly ru/en HTML template (with a plain-text alternative) rendering a
large readable code and an ignore-notice footer with a landing link.

Add BACKEND_PUBLIC_BASE_URL config (the canonical origin for the email footer
link, never the request Host — anti-injection), required when a relay is
configured.

Fix the email-login address squat: ProvisionEmail creates the account flagged
is_guest until the code is confirmed, so an abandoned login is reaped like any
guest and its address freed; confirming (login or link) clears the flag. Seed the
new account's language from the client, plumbed through the email-login request.

The confirm deeplink, its transport surface and the send rate-limit land in
follow-up work.
Add an in-memory SendLimiter enforcing a one-per-minute cooldown and a
five-per-rolling-hour cap per recipient address, checked before provisioning or
sending in RequestCode, RequestLoginCode and RequestLinkCode. It guards against
email bombing and protects the relay quota. The limiter is injected in main
(nil in tests, so the domain suite is unaffected); ErrTooManyRequests maps to
HTTP 429.
Add the backend confirm-code relay env to compose (BACKEND_SMTP_HOST/PORT/
USERNAME/PASSWORD/FROM + BACKEND_PUBLIC_BASE_URL), sourced from SMTP_RELAY_* /
SMTP_RELAY_FROM / PUBLIC_BASE_URL. An empty host keeps the backend on the log
mailer so a contour without relay credentials still boots. Port defaults to 465
(implicit TLS). Map the TEST_ set in ci.yaml and the PROD_ set in prod-deploy.yaml
(both the deploy-main env and the env.sh heredoc). Document the six variables in
.env.example and the deploy README (secrets: user/pass; variables: host/port/from
+ the per-contour PUBLIC_BASE_URL, required whenever the relay host is set).
Add an integration test asserting the guest-until-confirmed squat fix (an
email-login account is a reapable guest until the code is confirmed, then
durable). Document the branded relay pipeline in ARCHITECTURE (go-mail, TLS by
port, no client cert, PUBLIC_BASE_URL anti-injection, per-recipient send throttle,
guest-until-confirmed), FUNCTIONAL (+ru), TESTING and the backend README config
table (BACKEND_SMTP_* + BACKEND_PUBLIC_BASE_URL).
feat(ui): show the email upgrade box to guests
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m2s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
01d02fcef6
Un-hide the Profile email box for guest accounts (hidden={!p.isGuest}): a guest
binds an email to register / sign in, and a returning address opens the existing
merge dialog. Provider linking stays hidden — the Telegram control keeps its
wiring behind a hidden attribute — until the non-guest linking matrix (PR2). The
two linking e2e specs remain skipped (they assume a non-guest login and the
visible Telegram control); update their stale comments.
developer added 1 commit 2026-07-03 01:44:57 +00:00
fix(email): explicit TLS mode for non-standard relay ports
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
c702f1bdac
The 465-only implicit-TLS heuristic mis-classified Selectel's SSL port (1127),
which the mailer would have dialled with STARTTLS and failed. Add BACKEND_SMTP_TLS
(ssl|starttls) — empty still derives the mode from the port (implicit on 465, else
STARTTLS) — and dial implicit TLS with WithSSL()+WithPort so any port works, not
just 465. Wire SMTP_RELAY_TLS through compose/ci/prod/.env.example and document it
(Selectel: 1127 = SSL, 1126 = STARTTLS). Unit-tested.
owner approved these changes 2026-07-03 01:55:59 +00:00
developer merged commit 638c147cc0 into development 2026-07-03 01:56:55 +00:00
developer deleted branch feature/email-relay-pr1 2026-07-03 01:56:55 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#161