Password reset by email: a single-use link, caught by Mailpit, sent by Resend in production #380

Closed
opened 2026-10-11 16:26:56 +00:00 by root · 8 comments
root commented 2026-10-11 16:26:56 +00:00 (Migrated from gitlab.core-project.space)

Imported from GitHub amaurycoudr/chess-project#380, opened by @amaurycoudr on 2026-10-11 16:26 UTC.


Parent

#364

What to build

A Player who forgot their password asks for a reset link at /forgot-password and always gets the same answer. If the email has an Account, a message goes out with a link to /reset-password that holds 256 random bits in the URL fragment. The link is valid one hour and works once. The page reads the token, removes it from the address bar and posts it with the new password. A successful reset deletes every Session and every reset token of the Account, then signs this browser in. An expired, used or unknown link is told so and offered a new one.

Mail goes through a mail module with one port. Production sends through Resend over SMTP. Mailpit catches mail in dev (beside the shared dev Postgres), in CI (a service container) and in each preview (a preview-only container behind basic auth, under a path prefix on the preview's host). Tests inject an in-memory outbox. An hourly sweep on the injected clock removes expired Sessions and reset tokens.

The owner runs the Resend and DNS checklist with the wizard skill before this merges, because the API now refuses to start without its mail settings.

Seam: loadConfig; the RPC wire (with the in-memory outbox and the fake clock); the front views; e2e

Acceptance criteria

  • auth.requestPasswordReset and auth.resetPassword exist, the latter with RESET_LINK_INVALID for unknown, used and expired alike
  • On the wire, the outbox holds a working link; the link is single-use and expires after one hour; a link older than a completed reset fails; success deletes every Session and sets this browser's cookies; an unknown email sends nothing and gets the same answer
  • Only the SHA-256 hash of a reset token is stored
  • The sweep removes expired Sessions and reset tokens, tested on the fake clock
  • loadConfig requires the SMTP URL, the sender address and the public URL, tested beside the existing config tests
  • The forgot-password view and the reset-password view (including the expired, used or unknown state and its offer of a new link) are tested beside their views; new strings are in English and French
  • The reset email is English, plain text plus minimal HTML, with the link only
  • Mailpit runs in dev (written by env:sync), in CI's e2e job and in each preview, never in production; the preview e2e job passes its URL and credentials to the suite
  • A new e2e spec holds its first test: a Player asks for a reset link, reads it from Mailpit over its HTTP API, sets a new password and is signed in, and signing in with the old password then fails
  • The Resend checklist is run: the subdomain verified, SPF, DKIM and a _dmarc record at p=none added, the SMTP URL stored as a production secret, and one real reset received by the owner with SPF, DKIM and DMARC passing
  • The API's new mail/ folder is declared in the API's layer order and framework-import rules (the owner ruled on the accounts breakdown that declaring a folder the spec creates counts as the requested lint change; nothing else in lint config changes)
> _Imported from GitHub [amaurycoudr/chess-project#380](https://github.com/amaurycoudr/chess-project/issues/380), opened by [@amaurycoudr](https://github.com/amaurycoudr) on 2026-10-11 16:26 UTC._ <!-- github-issue:380 --> --- ## Parent #364 ## What to build A **Player** who forgot their password asks for a reset link at `/forgot-password` and always gets the same answer. If the email has an **Account**, a message goes out with a link to `/reset-password` that holds 256 random bits in the URL fragment. The link is valid one hour and works once. The page reads the token, removes it from the address bar and posts it with the new password. A successful reset deletes every **Session** and every reset token of the **Account**, then signs this browser in. An expired, used or unknown link is told so and offered a new one. Mail goes through a mail module with one port. Production sends through Resend over SMTP. Mailpit catches mail in dev (beside the shared dev Postgres), in CI (a service container) and in each preview (a preview-only container behind basic auth, under a path prefix on the preview's host). Tests inject an in-memory outbox. An hourly sweep on the injected clock removes expired **Sessions** and reset tokens. The owner runs the Resend and DNS checklist with the `wizard` skill before this merges, because the API now refuses to start without its mail settings. Seam: `loadConfig`; the RPC wire (with the in-memory outbox and the fake clock); the front views; e2e ## Acceptance criteria - [ ] `auth.requestPasswordReset` and `auth.resetPassword` exist, the latter with `RESET_LINK_INVALID` for unknown, used and expired alike - [ ] On the wire, the outbox holds a working link; the link is single-use and expires after one hour; a link older than a completed reset fails; success deletes every **Session** and sets this browser's cookies; an unknown email sends nothing and gets the same answer - [ ] Only the SHA-256 hash of a reset token is stored - [ ] The sweep removes expired **Sessions** and reset tokens, tested on the fake clock - [ ] `loadConfig` requires the SMTP URL, the sender address and the public URL, tested beside the existing config tests - [ ] The forgot-password view and the reset-password view (including the expired, used or unknown state and its offer of a new link) are tested beside their views; new strings are in English and French - [ ] The reset email is English, plain text plus minimal HTML, with the link only - [ ] Mailpit runs in dev (written by `env:sync`), in CI's e2e job and in each preview, never in production; the preview e2e job passes its URL and credentials to the suite - [ ] A new e2e spec holds its first test: a **Player** asks for a reset link, reads it from Mailpit over its HTTP API, sets a new password and is signed in, and signing in with the old password then fails - [ ] The Resend checklist is run: the subdomain verified, SPF, DKIM and a `_dmarc` record at `p=none` added, the SMTP URL stored as a production secret, and one real reset received by the owner with SPF, DKIM and DMARC passing - [ ] The API's new `mail/` folder is declared in the API's layer order and framework-import rules (the owner ruled on the accounts breakdown that declaring a folder the spec creates counts as the requested lint change; nothing else in lint config changes)
root commented 2026-10-11 16:26:56 +00:00 (Migrated from gitlab.core-project.space)

GitHub issue relations, imported as "related" links (GitLab CE has no sub-issues or blocking links).

  • Parent (sub-issue of): #364
  • Blocked by: #379
  • Blocking: #381
> _GitHub issue relations, imported as "related" links (GitLab CE has no sub-issues or blocking links)._ <!-- github-relations --> - Parent (sub-issue of): #364 - Blocked by: #379 - Blocking: #381
root commented 2026-10-13 08:39:48 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #381

mentioned in issue #381
root commented 2026-10-13 08:41:22 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #364

mentioned in issue #364
root commented 2026-10-13 08:41:27 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #379

mentioned in issue #379
root commented 2026-10-13 08:42:29 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #364

marked this issue as related to #364
root commented 2026-10-13 08:42:38 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #379

marked this issue as related to #379
root commented 2026-10-13 08:42:39 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #381

marked this issue as related to #381
root commented 2026-10-14 10:36:16 +00:00 (Migrated from gitlab.core-project.space)

mentioned in merge request !523

mentioned in merge request !523
root (Migrated from gitlab.core-project.space) closed this issue 2026-10-14 13:28:24 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
amaurycoudr/chess-project#380
No description provided.