Add stage 7: email verification magic links and a profile page

Backend
- New Mail namespace: a Mailer interface with SMTP (phpmailer), PHP mail()
  (the default fallback), and log-to-file transports, selected by
  MAIL_TRANSPORT. EmailVerifier issues a hashed, 15-minute magic-link token
  and sends the link (APP_URL/verify-email?token=...).
- Migration 005: email_verifications table + users.verification_email_sent_at.
- Registration now emails a verification link (best effort — a send failure
  doesn't fail registration).
- POST /api/auth/verify-email consumes a token and returns a session, so
  opening the link verifies the address (or applies a pending email change)
  and logs the user in. Single-use; distinct 400s for invalid/used/expired.
- POST /api/email/verification resends; POST /api/email/change requests a
  deferred change (current password required; link goes to the new address;
  users.email only updates when that link is opened). Both throttled to once
  per 60s, returning 429 + retry_after.
- GET /api/me and every session payload now include pending_email. Shared
  SessionPayload builds the user/session JSON for all entry points.

Frontend
- /verify-email view: posts the token, adopts the returned session, redirects.
- /profile view: shows address + status, a resend button with a live cooldown
  (driven by retry_after / 429), and a change-email form (new address +
  current password) that surfaces the pending change.
- Header shows a "verify email" badge linking to the profile.

Tests: 9 new (EmailVerificationTest) covering the link lifecycle, throttle,
and deferred change; AuthTest folded into ApiTestCase, which now routes mail
to a per-test log. Suite: 32 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 20:04:49 +01:00
co-authored by Claude Sonnet 5
parent c4c947896e
commit f9b65cc4a7
32 changed files with 1374 additions and 127 deletions
+49 -5
View File
@@ -12,10 +12,12 @@ SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
| 3 | Todo list + item CRUD API | ✅ done |
| 4 | Frontend lists view — list index + create form | ✅ done |
| 5 | Frontend list detail — items UI with drag-and-drop reorder | ✅ done |
| 6 | List view — inline title/description editing, delete via a Manage menu | ✅ done |
| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done |
Registration signs the user in immediately, with the account's email marked
unverified (`user.email_verified` is `false` until a future stage adds a
verification endpoint).
Registration signs the user in immediately and emails a magic link that verifies
the address; `user.email_verified` stays `false` until the link is opened. See
[Email verification](#email-verification--profile).
## Run with Docker
@@ -99,6 +101,15 @@ environment). See [.env.example](.env.example).
| `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location |
| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key |
| `JWT_TTL` | `86400` | Token lifetime in seconds |
| `APP_URL` | `http://localhost:5173` | Frontend base URL used to build magic links |
| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) |
| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@localhost` / `Todo List` | Envelope sender |
| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes |
| `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` |
SMTP is opt-in; without it the API falls back to PHP's `mail()`. The Docker
Compose setup sets `MAIL_TRANSPORT=log` (the container has no MTA) — read the
links with `docker compose exec app cat /var/www/storage/mail.log`.
## API
@@ -128,6 +139,7 @@ Request:
"email": "ada@example.com",
"email_verified": false,
"email_verified_at": null,
"pending_email": null,
"created_at": "2026-09-03T12:00:00Z"
},
"token": "<jwt>",
@@ -166,13 +178,43 @@ Requires `Authorization: Bearer <jwt>`.
"email": "ada@example.com",
"email_verified": false,
"email_verified_at": null,
"pending_email": null,
"created_at": "2026-09-03T12:00:00Z"
}
}
```
`pending_email` is the address a still-valid email-change link is waiting on, or
`null`.
`401` if the header is missing, malformed, or the token is invalid/expired.
### Email verification & profile
Registration emails a magic link — `<APP_URL>/verify-email?token=<opaque>` — that
expires **15 minutes** after it is sent. Only a hash of the token is stored.
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/api/auth/verify-email` | — | consume a token: verify the address (or apply a pending change), then return a session so the caller is logged in |
| `POST` | `/api/email/verification` | ✔ | resend the verification email; `409` if already verified |
| `POST` | `/api/email/change` | ✔ | request a **deferred** email change |
`POST /api/auth/verify-email` body: `{ "token": "..." }`. Success returns the
same `{ user, token, expires_at }` envelope as login. A missing/invalid, already
used, or expired token is `400` (distinct messages).
`POST /api/email/verification` and `/api/email/change` are throttled to **once
per 60 seconds** per user (shared window). When throttled they return `429` with
`error.details.retry_after` (seconds). On success they return `202` with
`retry_after`, and `/api/email/change` also returns `pending_email`.
`POST /api/email/change` body: `{ "email": "new@example.com", "password": "<current>" }`.
The current password is required (`422` if wrong). The address must be free
(`409`) and different from the current one (`422`). The change is **not applied
until** the magic link sent to the new address is opened — until then `GET
/api/me` shows the old address with `pending_email` set.
### Todo lists
All routes below require `Authorization: Bearer <jwt>`. A list belongs to one
@@ -308,9 +350,11 @@ src/Support/Config.php Environment-driven configuration
src/Support/Database.php PDO/SQLite connection
src/Auth/JwtService.php Issue/verify JWTs
src/Auth/AuthMiddleware.php Bearer-token authentication
src/Auth/SessionPayload.php Shared user + session JSON shape
src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier
src/Http/JsonErrorHandler.php Uniform JSON error envelope
src/Http/Controllers/ Request handlers (Auth, TodoList, TodoItem)
src/Repository/ Database access (User, TodoList, TodoItem)
src/Http/Controllers/ Request handlers (Auth, EmailVerification, TodoList, TodoItem)
src/Repository/ Database access (User, EmailVerification, TodoList, TodoItem)
src/Support/Validator.php Request-body validation helper
migrations/*.sql Schema, applied by bin/migrate.php
Dockerfile PHP 8.3 + Apache image