diff --git a/docs/specs/README.md b/docs/specs/README.md index 88e8107..4d7fa12 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -33,6 +33,7 @@ Subsystem specifications live in this directory. 17. [Public Stats Dashboard](./public-stats-dashboard.md) 18. [Documentation Viewer](./doc-viewer.md) 19. [Account Management Control Panel](./account-management.md) +20. [Password Resets and Resend Email Delivery](./password-resets-resend-email.md) ## Source Baseline diff --git a/docs/specs/password-resets-resend-email.md b/docs/specs/password-resets-resend-email.md new file mode 100644 index 0000000..86263fc --- /dev/null +++ b/docs/specs/password-resets-resend-email.md @@ -0,0 +1,269 @@ +--- +title: Password Resets and Resend Email Delivery +updated: 2026-06-15 +status: planned +--- + +Tempest should provide production-ready email delivery for account security +flows using Resend, while keeping the reset and confirmation state in the +application database. + +R2 stores blobs and backups. Password-reset and email-confirmation flows need +transactional token creation, single-use token consumption, expiry checks, +account mutation, session revocation, and audit events. Those belong in the +database. + +## Source Baseline + +Research checked on 2026-06-15: + +- Resend SMTP documentation: +- Swoosh Resend adapter documentation: + +- Current Tempest implementation: + `lib/tempest/security.ex` +- Current Tempest email helper: + `lib/tempest/security/email.ex` + +## Goals + +- Send password reset, email confirmation, and email update messages through + Resend in production. +- Keep local dev and tests on Swoosh local/test adapters. +- Prefer the Swoosh Resend API adapter for production Resend delivery. +- Keep SMTP as a documented fallback because Resend also supports SMTP. +- Keep raw reset and confirmation tokens out of the database. +- Preserve single-use tokens, expiry, account-scoped audit events, and session + revocation on password reset. +- Make production email configuration explicit and easy to validate on Railway. +- Add smoke coverage that proves a deployed node can request and consume account + security tokens without exposing secrets in logs or responses. + +## Non-goals + +- No email queue in the first pass. +- No marketing, newsletter, broadcast, inbound email, or webhook processing. +- No Resend templates in the first pass. +- No R2 token storage. +- No provider-specific code in XRPC handlers. +- No HTML-only emails. Plain text must always exist. + +## Provider Model + +Tempest should support a small provider switch: + +```text +TEMPEST_EMAIL_PROVIDER=local | smtp | resend +``` + +The default remains local outside production unless explicitly configured. + +### Resend API + +The preferred production provider is Resend via `Swoosh.Adapters.Resend`. +Production config should read: + +```text +TEMPEST_EMAIL_PROVIDER=resend +TEMPEST_RESEND_API_KEY=... +TEMPEST_EMAIL_FROM_NAME=Tempest +TEMPEST_EMAIL_FROM_ADDRESS=no-reply@example.com +``` + +`config/prod.exs` already configures `Swoosh.ApiClient.Req`, which satisfies the +Resend adapter's API-client requirement. The runtime config should fail closed +in production if `TEMPEST_EMAIL_PROVIDER=resend` is selected without an API key +or from address. + +### Resend SMTP Fallback + +SMTP can remain available for environments where API delivery is not desired: + +```text +TEMPEST_EMAIL_PROVIDER=smtp +TEMPEST_SMTP_HOST=smtp.resend.com +TEMPEST_SMTP_PORT=587 +TEMPEST_SMTP_USERNAME=resend +TEMPEST_SMTP_PASSWORD= +TEMPEST_SMTP_SSL=false +TEMPEST_SMTP_TLS=always +TEMPEST_SMTP_AUTH=always +TEMPEST_EMAIL_FROM_NAME=Tempest +TEMPEST_EMAIL_FROM_ADDRESS=no-reply@example.com +``` + +Do not keep separate `TEMPEST_SMTP_FROM_*` and Resend from-address variables in +the long term. Prefer shared `TEMPEST_EMAIL_FROM_*` names and keep the older +SMTP names only as backwards-compatible aliases. + +## Token Model + +Tempest should keep the current `email_tokens` model: + +- generate a high-entropy raw token; +- store only a hash; +- bind the token to one purpose; +- bind update-email tokens to the target email; +- set an expiry; +- mark successful consumption with `used_at`; +- log token issue and consumption events. + +Supported purposes: + +```text +confirm_email +update_email +reset_password +``` + +Password reset must revoke active sessions before accepting the new password. + +## XRPC Behavior + +### requestPasswordReset + +`com.atproto.server.requestPasswordReset` accepts an email, handle, or DID. The +response should not reveal whether an account exists. + +If an account exists, Tempest issues a `reset_password` token and sends a Resend +email to the account's current email address. + +### resetPassword + +`com.atproto.server.resetPassword` accepts a token and new password. On success, +Tempest consumes the token, revokes active sessions, updates the password hash, +and returns `{}`. + +Invalid, expired, or already-used tokens should return protocol-shaped errors +without identifying the account. + +### requestEmailConfirmation + +`com.atproto.server.requestEmailConfirmation` requires authenticated account +access. It sends a `confirm_email` token to the account's current email address. + +The first pass may continue to send even if the email is already confirmed, but +the preferred behavior is to return `{}` without issuing a new token when the +email is already confirmed. + +### confirmEmail + +`com.atproto.server.confirmEmail` should accept both current Tempest token-only +calls and the ATProto-shaped `{email, token}` body. When `email` is present, it +must match the account email associated with the token. + +### requestEmailUpdate + +`com.atproto.server.requestEmailUpdate` requires authenticated account access and +a target `email`. It sends an `update_email` token to the target email address. + +The response should match the protocol shape: + +```json +{ "tokenRequired": true } +``` + +If Tempest later distinguishes unconfirmed accounts, an unconfirmed existing +email may return `{"tokenRequired": false}` and allow direct update. Until that +policy is explicit, require the token. + +### updateEmail + +`com.atproto.server.updateEmail` should accept `{email, token}`. The token must +be an `update_email` token whose stored target email matches the requested email. +On success, update the account email and clear confirmed-email state unless the +flow explicitly verifies the new address in the same transaction. + +## Email Content + +Each email should include: + +- product/instance name; +- account handle when known; +- purpose-specific action text; +- token or action URL; +- expiry time; +- "ignore this email" safety copy; +- plain text body. + +Action URLs are preferred once browser pages exist: + +```text +https:///account/password/reset?token=... +https:///account/email/confirm?token=... +https:///account/email/update?token=... +``` + +Until those pages exist, plain tokens are acceptable. + +## Railway Configuration + +Railway should provide: + +```text +PHX_HOST= +TEMPEST_EMAIL_PROVIDER=resend +TEMPEST_RESEND_API_KEY=... +TEMPEST_EMAIL_FROM_NAME=Tempest +TEMPEST_EMAIL_FROM_ADDRESS=no-reply@ +``` + +The Resend sending domain must be verified before production testing. SPF, DKIM, +and DMARC should be configured at DNS before relying on password reset emails. + +## Observability + +Tempest should emit telemetry for each delivery attempt: + +```text +[:tempest, :email, :deliver] +``` + +Metadata should include: + +- `purpose` +- `provider` +- `status` + +Do not include raw tokens, API keys, auth headers, or recipient-local-part detail +in logs. Full recipient addresses may appear in Swoosh structs during tests, but +production logs should avoid them. + +## Failure Policy + +Token issuance and delivery should be treated as one requested operation: + +- If token insertion fails, return an internal error. +- If Resend delivery fails, return a temporary failure and leave the token + unused. +- Do not delete a token only because delivery failed in the first pass. +- Do not expose provider error bodies to clients. + +If delivery failures become common, add a database-backed email queue in a later +milestone. + +## Verification + +Local verification: + +```bash +mix test test/tempest/security_test.exs test/tempest_web/xrpc/email_flows_test.exs +``` + +Production-style local verification with Resend config: + +```bash +TEMPEST_EMAIL_PROVIDER=resend \ +TEMPEST_RESEND_API_KEY="$TEMPEST_RESEND_API_KEY" \ +TEMPEST_EMAIL_FROM_ADDRESS="$TEMPEST_EMAIL_FROM_ADDRESS" \ +mix test test/tempest/security/email_delivery_config_test.exs +``` + +Deployed smoke verification: + +```bash +hurl --test --jobs 1 \ + --variable base_url=https://tempest.example.com \ + --variable account_email=reset-target@example.com \ + test/smoke/email-security.hurl +``` diff --git a/docs/tasks/19-password-resets-resend-email.md b/docs/tasks/19-password-resets-resend-email.md new file mode 100644 index 0000000..ef0b6ae --- /dev/null +++ b/docs/tasks/19-password-resets-resend-email.md @@ -0,0 +1,129 @@ +--- +title: Milestone 19 - Password Resets and Resend Email Delivery +specs: + - ../specs/password-resets-resend-email.md + - ../specs/accounts-auth.md + - ../specs/security-oauth.md + - ../specs/deployment-observability.md +references: + - ../reference/security-oauth.md + - ../reference/deployment.md +--- + +Goal: make password reset, email confirmation, and email update flows production +usable on Railway with Resend, while keeping token state in the database and R2 +limited to blobs and backups. + +## Configuration + +- [ ] T19-01: Add `TEMPEST_EMAIL_PROVIDER` runtime config with `local`, `smtp`, + and `resend` values. +- [ ] T19-02: Add Resend runtime config for `TEMPEST_RESEND_API_KEY`, + `TEMPEST_EMAIL_FROM_NAME`, and `TEMPEST_EMAIL_FROM_ADDRESS`. +- [ ] T19-03: Configure `Swoosh.Adapters.Resend` when + `TEMPEST_EMAIL_PROVIDER=resend`. +- [ ] T19-04: Keep SMTP support as a fallback provider and map existing + `TEMPEST_SMTP_*` variables to the new shared `TEMPEST_EMAIL_FROM_*` + values where possible. +- [ ] T19-05: Fail closed during production boot when `resend` or `smtp` is + selected without the required credentials or from address. +- [ ] T19-06: Add documentation for Railway env vars, Resend domain + verification, SPF, DKIM, and DMARC expectations. + +## Email Delivery + +- [ ] T19-07: Update `Tempest.Security.Email` so provider metadata is attached + to `[:tempest, :email, :deliver]` telemetry. +- [ ] T19-08: Add text email builders for password reset, email confirmation, + and email update with handle, purpose, expiry, and ignore-this-email copy. +- [ ] T19-09: Add provider options for Resend tags and idempotency keys when the + Resend adapter is active. +- [ ] T19-10: Ensure production logs do not include raw tokens, Resend API keys, + auth headers, or full provider error bodies. +- [ ] T19-11: Add tests for successful Resend adapter config without making a + network call. +- [ ] T19-12: Add tests for missing Resend API key/from address config. + +## XRPC Shape + +- [ ] T19-13: Keep `requestPasswordReset` enumeration-safe for unknown email, + handle, or DID values. +- [ ] T19-14: Verify `resetPassword` consumes one token, rejects reuse, validates + password strength, revokes sessions, and allows login with the new + password. +- [ ] T19-15: Update `requestEmailUpdate` to return + `{"tokenRequired": true}` when a token is required. +- [ ] T19-16: Update `updateEmail` to accept `{email, token}` and verify the + token target email matches the requested email. +- [ ] T19-17: Update `confirmEmail` to accept both token-only calls and + ATProto-shaped `{email, token}` calls. +- [ ] T19-18: Add explicit tests for invalid, expired, reused, wrong-purpose, + and wrong-target-email tokens. + +## Browser Follow-through + +- [ ] T19-19: Add minimal browser pages for entering reset, confirmation, and + update tokens if the account Control Panel work has not provided them yet. +- [ ] T19-20: Generate action URLs in email bodies when `PHX_HOST` is configured; + otherwise fall back to plain token copy. +- [ ] T19-21: Add route and form tests using stable element IDs for the browser + token-entry pages. + +## Deployment Verification + +- [ ] T19-22: Add `test/smoke/email-security.hurl` for deployed password-reset + request and token consumption using an operator-supplied token. +- [ ] T19-23: Add a Railway deployment checklist covering Resend env vars, + verified sending domain, DNS records, and test inbox verification. +- [ ] T19-24: Add an admin/operator note explaining that R2 is not used for + account email tokens. +- [ ] T19-25: Run `mix precommit` after implementation and fix all pending + issues. + +## Integration Tests + +- Resend provider config selects `Swoosh.Adapters.Resend`. +- SMTP provider config still selects `Swoosh.Adapters.SMTP`. +- Missing production email credentials fail clearly. +- Password reset request for an unknown identifier returns success-shaped output. +- Password reset email includes no raw database token hash. +- Password reset token is single-use. +- Password reset revokes existing sessions. +- Email confirmation accepts `{email, token}` and rejects a mismatched email. +- Email update accepts `{email, token}` and rejects a token issued for a + different target email. +- Email delivery telemetry includes purpose, provider, and status. +- Production logs redact provider secrets and raw tokens. + +## HTTP Verification + +```bash +hurl --test --jobs 1 \ + --variable base_url=http://localhost:4000 \ + --variable account_email="email-security-$(date +%s)@example.com" \ + test/smoke/email-security.hurl +``` + +For deployed Railway verification, run against the public HTTPS host after +Resend DNS verification is complete: + +```bash +hurl --test --jobs 1 \ + --variable base_url=https://tempest.example.com \ + --variable account_email=reset-target@example.com \ + test/smoke/email-security.hurl +``` + +## Implementation Notes + +Keep the first version synchronous. Password reset and confirmation volume is +low, and adding a queue before delivery failures are observed would add moving +parts without changing the security model. + +Use the database for token state. Do not introduce R2 reads or writes for email +tokens. + +Prefer Swoosh Resend API delivery over SMTP for production because the current +Phoenix production config already uses `Swoosh.ApiClient.Req`, and the Resend +adapter supports provider tags and idempotency keys. Keep SMTP documented as a +fallback because it is already supported by Tempest and Resend. diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 933ed91..5612c91 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -23,6 +23,7 @@ title: Milestone Tasks 17. [Public Stats Dashboard](./16-public-stats-dashboard.md) 18. [Doc Viewer](./17-doc-viewer.md) 19. [Account Management Control Panel](./18-account-management.md) +20. [Password Resets and Resend Email Delivery](./19-password-resets-resend-email.md) Each file in this directory is a milestone. Each task is intended to be the smallest useful unit of work: one focused implementation change, test, or