feat: change email (#27) — design + backend + UI - #20
Conversation
Design-only doc for self-service change email, turning on whether a mail
provider is configured:
- mail on → verify-before-commit: a confirmation link to the NEW address;
the change lands and the address becomes EmailVerified only on click,
and the old address keeps signing in until then.
- mail off → immediate but EmailVerified=false: no channel to prove the
new inbox, so it applies at once and is honestly marked unverified.
Both re-authenticate with the current password. Reuses #26's EmailVerified
invariant so the change-email→reset takeover stays closed for free, and
emails the old address on completion. Adds an EmailChangeTokens table
(twin of PasswordResetTokens), POST/DELETE /api/me/email, and anon
confirm-email/{token} preview+confirm; admin direct-set deferred (Q-27-5).
Includes the E2E scenarios + expected output (Playwright journey F against
Mailpit for the mail-on path).
Move #27 to Designed in feature-status and re-sync counts
(14 done / 2 partial / 1 designed / 21 not started).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WalkthroughThe PR implements self-service email changes across the backend and Angular UI. Mail-enabled changes require confirmation. Mail-disabled changes apply immediately and become unverified. The PR adds token persistence, APIs, rate limiting, notifications, profile state, tests, and feature-status updates. ChangesChange email
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant Profile
participant MeController
participant EmailChangeService
participant Database
participant ConfirmEmail
participant EmailChangeController
participant Mailer
Client->>Profile: Submit new email and password
Profile->>MeController: Request email change
MeController->>Database: Validate account and store pending change
MeController->>EmailChangeService: Build confirmation token
EmailChangeService->>Mailer: Send confirmation email
Client->>ConfirmEmail: Open confirmation link
ConfirmEmail->>EmailChangeController: Preview and confirm token
EmailChangeController->>Database: Consume token and update email
EmailChangeController->>Mailer: Send old-address notification
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/feature-27-change-email.md`:
- Around line 329-343: Update the API delta table entry for POST /api/me/email
to include the documented 429 rate-limit response alongside 400/409, matching
the contract defined in section 5.1 and scenario E11.
- Around line 288-291: Update the feature `#27` design text to scope its claim to
email-change confirmation: state that `#27` sets EmailVerified=true only after the
new-address confirmation proves inbox control, while acknowledging that other
flows such as password reset may reassert the flag only after the account was
already verified. Keep the security invariant and references to rules `#26/`#27
consistent in both referenced sections.
- Around line 356-363: Update the template test requirements in
EmailTemplateTests so the confirmation link is required only for
ConfirmEmailChange, not EmailChanged. For EmailChanged, assert the masked new
address and the specified heads-up text instead, while preserving the existing
plain-text and expiry assertions.
- Around line 406-411: Update the feature `#27` design document to describe
journey F and the EmailChangeToken.IsUsable and email-template tests as planned
or future work, not as existing artifacts. Keep the Mailpit and Playwright
references only as planned validation unless the referenced implementations are
present elsewhere, and ensure the corresponding section around the additional
referenced lines remains consistent with docs/feature-status.md.
- Around line 154-172: Handle unique-constraint failures in both the mail-off
immediate-update path and the confirmation path around
EmailSettingsService.IsEnabledAsync and EmailChangeToken processing. Catch the
database conflict from saving User.Email and map it to 409 with code
email_in_use. Ensure a failed confirmation transaction does not consume the
token, and add concurrent-request coverage proving both paths return the
documented conflict response.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: f3a51d5f-be9b-4172-96e6-fa3e202ccf0a
📒 Files selected for processing (2)
docs/feature-27-change-email.mddocs/feature-status.md
| Then the two modes diverge on `EmailSettingsService.IsEnabledAsync`: | ||
|
|
||
| **Mail ON → stage it (verify-before-commit).** | ||
|
|
||
| 5. Supersede any prior pending change for this user, mint an `EmailChangeToken { NewEmail = newEmail }`, | ||
| **commit**. | ||
| 6. Send the confirmation email to **`newEmail`** (§11) — dispatched on a background task with its own | ||
| DI scope, like the reset send, so the request returns promptly and a transport hiccup is logged, | ||
| not surfaced. | ||
| 7. `202 Accepted { pendingEmail: "new@x" }`. `User.Email` is **unchanged**; the account still signs in | ||
| with the old address. | ||
| - *A send failure still returns `202`* (the user asked to change, the row exists, Resend supersedes | ||
| it) — consistent with self-service reset. The profile screen's *Resend* is the recovery. | ||
|
|
||
| **Mail OFF → apply immediately (unverified).** | ||
|
|
||
| 5. Set `user.Email = newEmail`; set **`user.EmailVerified = false`** (the new address is unproven and | ||
| there is no channel to prove it); `SaveChanges`. | ||
| 6. `200 ProfileResponse` with the new email and `emailVerified: false`. |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Handle the unique-index race in both commit paths.
The existence check is not atomic with the email write. If two users submit the same address at the same time in mail-off mode, one write can hit the unique Users.Email index from src/Api/Data/AppDbContext.cs:40-57.
The confirm path has the same gap. Another account can claim NewEmail after the uniqueness check and before user.Email is saved.
Map the unique-constraint failure to the documented 409 { code: "email_in_use" }. Keep the confirmation token unused when the confirm transaction rolls back. Add a concurrent request test for both paths.
As per path instructions: docs/**/*.md files are design docs; review internal consistency and broken cross-references, not prose style.
Also applies to: 198-206
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 158-158: Ordered list item prefix
Expected: 1; Actual: 5; Style: 1/2/3
(MD029, ol-prefix)
[warning] 160-160: Ordered list item prefix
Expected: 2; Actual: 6; Style: 1/2/3
(MD029, ol-prefix)
[warning] 163-163: Ordered list item prefix
Expected: 3; Actual: 7; Style: 1/2/3
(MD029, ol-prefix)
[warning] 170-170: Ordered list item prefix
Expected: 1; Actual: 5; Style: 1/2/3
(MD029, ol-prefix)
[warning] 172-172: Ordered list item prefix
Expected: 2; Actual: 6; Style: 1/2/3
(MD029, ol-prefix)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/feature-27-change-email.md` around lines 154 - 172, Handle
unique-constraint failures in both the mail-off immediate-update path and the
confirmation path around EmailSettingsService.IsEnabledAsync and
EmailChangeToken processing. Catch the database conflict from saving User.Email
and map it to 409 with code email_in_use. Ensure a failed confirmation
transaction does not consume the token, and add concurrent-request coverage
proving both paths return the documented conflict response.
Source: Path instructions
There was a problem hiding this comment.
Fixed in code: MeController.ChangeEmail now wraps both branches in a transaction and catches the DbUpdateException, returning the documented 409 (mail-off → email_in_use; mail-on → email_change_pending) instead of a 500 — the confirm path already did this, and the transaction keeps the supersede-delete atomic with the write. Documented as E13/E14. There's no DB integration harness here (unit tests are pure functions by convention), so the race is verified against the dockerised stack rather than an xUnit concurrency test.
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '== Candidate files =='
fd -a '^(MeController|EmailChangeController|EmailChangeService|AppDbContext)\.cs$|^feature-27-change-email\.md$' .
echo
echo '== Controller structure =='
ast-grep outline src/Api/Features/Me/MeController.cs --items all --view expanded
ast-grep outline src/Api/Features/Auth/EmailChangeController.cs --items all --view expanded
echo
echo '== Race handling and documented error cases =='
rg -n -C 8 -e 'ChangeEmail\s*\(' -e 'CancelEmailChange\s*\(' -e 'Confirm\s*\(' \
-e 'DbUpdateException' -e 'email_in_use' -e 'email_change_pending' \
-e 'BeginTransaction' -e 'E13|E14' \
src/Api/Features/Me/MeController.cs \
src/Api/Features/Auth/EmailChangeController.cs \
docs/feature-27-change-email.md
echo
echo '== Email uniqueness index =='
rg -n -C 6 -e 'HasIndex' -e 'Email' src/Api/Data/AppDbContext.csLength of output: 35263
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '== MeController.ChangeEmail, exact implementation =='
sed -n '161,201p' src/Api/Features/Me/MeController.cs
echo
echo '== EmailChangeService methods called by ChangeEmail =='
ast-grep outline src/Api/Features/Auth/EmailChangeService.cs --items all --view expanded
sed -n '1,180p' src/Api/Features/Auth/EmailChangeService.cs
echo
echo '== Documented edge cases E13/E14 and transaction requirements =='
sed -n '440,490p' docs/feature-27-change-email.md
sed -n '190,216p' docs/feature-27-change-email.mdLength of output: 15525
@imariel2d, I verified the current PR revision. The confirmation path is addressed: EmailChangeController.Confirm catches DbUpdateException, returns 409 { code: "email_in_use" }, and does not commit the transaction. Therefore, the token remains unused.
The request-path fix is not present in the checked revision. MeController.ChangeEmail still calls SaveChangesAsync directly in both branches. It has no transaction and no DbUpdateException catch.
A concurrent mail-off request for the same new address can therefore still fail at the unique Users.Email index and return a server error instead of 409 { code: "email_in_use" }.
Also, docs/feature-27-change-email.md currently ends its edge-case table at E12. I could not verify E13 or E14.
Please retry after the revision with the reported MeController.ChangeEmail changes and documentation is available.
You are interacting with an AI system.
Self-service change of an account's login email, branching on whether a
mail provider is configured (feature-27-change-email.md §3–§11):
- mail on → verify-before-commit: POST /api/me/email stages the change and
emails a confirmation link to the NEW address; the change lands and the
address becomes EmailVerified only when the anon confirm-email/{token}
endpoint is POSTed. The old address keeps signing in until then and gets a
heads-up on completion.
- mail off → immediate but EmailVerified=false (no channel to prove the new
inbox).
Both re-authenticate with the current password, so a stolen session alone
can't move the email — which keeps the change-email→reset takeover closed via
#26's EmailVerified gate. New EmailChangeTokens table (twin of
PasswordResetTokens, one-live-per-account partial unique index) + the
AddEmailChange migration; a per-user rate limit on the request; two Cove email
templates; ProfileResponse gains emailVerified/pendingEmail; DELETE
/api/me/email cancels a pending change.
Unit tests for token liveness, the address mask, and both templates. Verified
end-to-end against the dockerised stack with Mailpit: the mail-on journey
(request→202, confirm link, confirm→swap+verify, single-use, new email signs
in / old rejected, old-address heads-up) and the edge paths (wrong password,
unchanged, in-use, cancel). Mark #27 partial in feature-status (backend done;
Angular UI + mail-off live run remain).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 10
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/feature-status.md`:
- Around line 140-142: Update the sentence describing Change email’s reuse of
EmailVerified in docs/feature-status.md to present tense, stating that it now
reuses the flag, while preserving the existing status sequence and surrounding
wording.
In `@src/Api/Features/Auth/EmailChangeController.cs`:
- Around line 98-123: The email-change notification currently uses detached
Task.Run delivery, so it can be lost after the transaction commits. Replace
DispatchChangedNotice with a transactional outbox record created through the
same persistence transaction before CommitAsync, containing the old and new
addresses and required email-work metadata; remove the fire-and-forget scope and
logging path. Add or reuse a hosted outbox worker to send and retry the record,
ensuring every async database and email operation receives and propagates its
cancellation token.
- Around line 33-42: Update the `Preview` action to apply `Cache-Control:
no-store` to both successful `EmailChangePreview` responses and the `410 Gone`
response when `row` is null, then add integration tests covering both outcomes
and verifying the header is present.
- Around line 125-133: Update the EmailInUse method to return the email-conflict
response via the RFC 7807 Problem helper, preserving HTTP 409, the existing
detail text, and the "email_in_use" extension code.
In `@src/Api/Features/Auth/EmailChangeService.cs`:
- Around line 50-51: Update RemoveExistingAsync to delete only the user’s active
email-change tokens by adding the UsedAt-null condition, matching
MeController.CancelEmailChange and preserving completed token history. Keep the
existing userId filter and cancellation behavior unchanged.
In `@src/Api/Features/Me/MeController.cs`:
- Around line 180-199: Update the email-change handling in MeController to wrap
both mail-off and mail-on commit paths in transactions, including
RemoveExistingAsync and SaveChangesAsync, so failures roll back all changes.
Catch DbUpdateException around each transaction and return the existing
EmailInUse() 409 response, covering both Users.Email and EmailChangeTokens
uniqueness races; preserve the current success responses and only dispatch
confirmation after the mail-on transaction commits.
- Line 199: The 202 Accepted response uses an undocumented anonymous object, so
the generated API client lacks its response shape. Add the documented
PendingEmailResponse record near ChangeEmailRequest with XML comments, return it
from the email-change action instead of the anonymous object, and update its 202
ProducesResponseType declaration to reference the record.
- Around line 256-261: The Coded method should use the controller’s Problem(...)
error-response path instead of returning StatusCode(status, pd). Preserve the
existing ProblemDetails status, detail, and code extension values while
constructing and returning the response through Problem(...).
In `@src/Api/Program.cs`:
- Around line 208-217: Update the ChangeEmail rate-limit policy configuration to
provide an OnRejected handler that returns Results.Problem with HTTP 429 and a
useful detail message, ensuring the response uses application/problem+json.
Extend the existing Playwright API/E2E coverage for POST /api/me/email to verify
the rejection status, content type, and detail field.
In `@tests/Api.Tests/EmailTemplateTests.cs`:
- Around line 125-135: Extend the EmailChanged tests near
EmailChanged_names_the_masked_address_and_carries_no_link with an HTML-encoding
regression case using "<script>x</script>". Assert HtmlBody contains the encoded
"<script>" representation and does not contain live "<script>" markup,
while preserving the existing masking and no-link assertions.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: c54200c8-4f2c-4e74-9c96-c24bd4d90321
⛔ Files ignored due to path filters (2)
src/Api/Data/Migrations/20260804190908_AddEmailChange.Designer.csis excluded by!**/Migrations/*.Designer.cssrc/Api/Data/Migrations/AppDbContextModelSnapshot.csis excluded by!**/Migrations/*ModelSnapshot.cs
📒 Files selected for processing (14)
docs/feature-27-change-email.mddocs/feature-status.mdsrc/Api/Data/AppDbContext.cssrc/Api/Data/Migrations/20260804190908_AddEmailChange.cssrc/Api/Domain/EmailChangeToken.cssrc/Api/Features/Auth/EmailChangeController.cssrc/Api/Features/Auth/EmailChangeService.cssrc/Api/Features/Auth/RateLimiterPolicies.cssrc/Api/Features/Email/EmailOptions.cssrc/Api/Features/Email/EmailTemplates.cssrc/Api/Features/Me/MeController.cssrc/Api/Program.cstests/Api.Tests/EmailChangeTokenTests.cstests/Api.Tests/EmailTemplateTests.cs
- MeController.ChangeEmail: wrap both branches in a transaction and map the unique-index violation to the documented 409 instead of a raw 500 (mail-off → email_in_use on Users.Email; mail-on → email_change_pending on the one-live-token index), matching the confirm path. The transaction also keeps the supersede-delete atomic with the write. - Type the 202 body (EmailChangePendingResponse) so it appears in the OpenAPI doc instead of an anonymous object. - EmailChangeService.RemoveExistingAsync now filters UsedAt == null, so "supersede" drops only the live token (keeping confirmed history) — matching the cancel endpoint and the one-live index comment. - Rate-limiter rejections return application/problem+json with a detail (OnRejected), covering both the forgot-password and change-email limiters. - Add an EmailChanged HTML-encoding regression test. - Docs: scope the EmailVerified statement, add 429 to the API table, document the concurrent-race outcomes (E13/E14) and the 429 body, mark journey F as planned while the unit tests exist, and fix a stale future-tense line. Verified against the dockerised stack: the mail-on journey + edges (18/18) and the 429 problem+json body. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/Api/Features/Me/MeController.cs (1)
249-267: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftMove confirmation delivery into managed background work.
If the process stops after
POST /api/me/emailreturns202, the untrackedTask.Runcan end beforeSendConfirmationAsyncsends the message. The user then has a pending token but no confirmation email. This code also passesCancellationToken.Noneto the I/O call.Queue the send in a hosted background service. Pass its shutdown token to
SendConfirmationAsync. Do not use the request token after the response completes.As per path instructions, “Every async method takes a CancellationToken and passes it down to EF and I/O calls.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/Api/Features/Me/MeController.cs` around lines 249 - 267, Replace the untracked Task.Run in DispatchConfirmationEmail with enqueueing work to the application’s managed hosted background service, preserving the request-independent behavior after the 202 response. Ensure the queued worker creates its own DI scope and passes the hosted service shutdown CancellationToken to SendConfirmationAsync instead of CancellationToken.None, while retaining logged, non-fatal send failures.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/Api/Features/Me/MeController.cs`:
- Around line 231-233: Update the email-change confirmation flow around
DispatchConfirmationEmail and the Accepted response to validate the effective
PublicBaseUrl before generating or sending the confirmation link: require HTTPS
for non-loopback origins, while allowing HTTP only for explicitly recognized
local-development loopback hosts. Reject unsupported HTTP origins without
exposing or processing the raw bearer token.
---
Outside diff comments:
In `@src/Api/Features/Me/MeController.cs`:
- Around line 249-267: Replace the untracked Task.Run in
DispatchConfirmationEmail with enqueueing work to the application’s managed
hosted background service, preserving the request-independent behavior after the
202 response. Ensure the queued worker creates its own DI scope and passes the
hosted service shutdown CancellationToken to SendConfirmationAsync instead of
CancellationToken.None, while retaining logged, non-fatal send failures.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: c76f8b8b-ef0f-49b5-9237-42d93b59a1c6
📒 Files selected for processing (6)
docs/feature-27-change-email.mddocs/feature-status.mdsrc/Api/Features/Auth/EmailChangeService.cssrc/Api/Features/Me/MeController.cssrc/Api/Program.cstests/Api.Tests/EmailTemplateTests.cs
The Angular UI for change-email, on top of the verified backend: - Profile /profile gains a Change-email card: current address with a Verified/Unverified badge (text, not colour-only), new-email + current-password fields, and a submit that branches on the response (202 -> "confirm the link we sent"; 200 -> "your email is now ..."). A pending change shows a Resend (re-auth) + Cancel row; a mail-off note appears when no provider is configured. - New public /confirm-email/:token page (sibling of reset/claim): previews the target address, a single Confirm applies the change, with 410/409 dead-ends. The token in the URL is the authorization; no session touched. - EmailChangeService (client) covers capabilities, request, cancel, preview, confirm; ProfileResponse gains emailVerified/pendingEmail. - Also wrap a pre-existing long-email overflow in the profile header so the screen has no horizontal scroll on mobile. Verified live against the dockerised stack + Mailpit through the real UI: the mail-on flow end-to-end (request -> pending -> confirm link -> confirm page -> email swapped + Verified badge, pending cleared), across mobile / tablet / desktop (no horizontal scroll) and dark mode, with roles/labels in the a11y tree. ng build clean. Updates feature-status + the design-doc status. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/feature-27-change-email.md`:
- Around line 3-6: Update the implementation-status statement in the feature
document to exclude the deferred optional admin direct-set endpoint from the
§3–§11 claim, either by listing only implemented sections or explicitly stating
that the deferred admin direct-set section is excluded. Keep it consistent with
the deferred status recorded in feature-status.md.
In `@src/ClientApp/src/app/features/email-change/confirm-email.ts`:
- Around line 69-78: Update the final generic-error branch in the confirmation
catch handler to set this.error using problemDetail(e, fallback), so
server-provided problem+json detail is shown with the existing generic message
as fallback. Preserve the dedicated 410 and 409 messages unchanged.
In `@src/ClientApp/src/app/features/profile/profile.ts`:
- Around line 187-204: Update resendEmailChange() to retain the result of
emailChanges.request and branch on result.kind like changeEmail(); when the
result is immediately applied, update ProfileStore with the returned profile and
show the appropriate applied outcome instead of claiming confirmation was
resent, while preserving the existing pending-confirmation behavior.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: d1aa1cb7-5bf4-4171-8544-038ef0be087a
📒 Files selected for processing (11)
docs/feature-27-change-email.mddocs/feature-status.mdsrc/ClientApp/src/app/app.routes.tssrc/ClientApp/src/app/core/email-change.service.tssrc/ClientApp/src/app/core/models.tssrc/ClientApp/src/app/features/email-change/confirm-email.htmlsrc/ClientApp/src/app/features/email-change/confirm-email.tssrc/ClientApp/src/app/features/email-change/email-change.scsssrc/ClientApp/src/app/features/profile/profile.htmlsrc/ClientApp/src/app/features/profile/profile.scsssrc/ClientApp/src/app/features/profile/profile.ts
Feature #27 — change email, end to end: design doc, backend, and the Angular UI. Verified live throughout.
The idea path
Turns on is a mail provider configured? — that decides whether the new address can be proven before it takes effect:
Emailswaps and becomesEmailVerifiedonly on click. The old address keeps signing in until then, and gets a heads-up on completion.EmailVerified = false.Both re-authenticate with the current password, which keeps the classic change-email → forgot-password takeover closed via #26's
EmailVerifiedgate.Backend
EmailChangeTokenstable (twin ofPasswordResetTokens, one-live-per-account partial unique index) + theAddEmailChangemigration.POST/DELETE /api/me/email(re-auth, per-user rate limit, transactions mapping unique-index races to 409) + anonGET/POST /api/auth/confirm-email/{token}.ProfileResponsegainsemailVerified/pendingEmail.UI
/profile: verified/unverified badge, new-email + current-password fields, response-branched copy, a pending Resend/Cancel panel, and a mail-off note./confirm-email/:tokenpage (sibling of reset/claim): preview → single Confirm → 410/409 dead-ends.EmailChangeService(client) for capabilities/request/cancel/preview/confirm. Also fixed a pre-existing long-email header overflow on mobile.Verified live (dockerised stack + Mailpit)
The full mail-on journey through the real UI: request → pending (email unchanged) → confirm link from Mailpit → confirm page →
Emailswapped +EmailVerified=t→ profile shows the new email + Verified badge. Plus the API edge paths, the 429 problem+json body, responsive (mobile/tablet/desktop, no horizontal scroll), dark mode, and the a11y tree.Remaining (tracked, not in this PR)
The automated Playwright journey F (mail-on), the mail-off live run (manual — Mailpit is SMTP-only), and admin direct-set (deferred, Q-27-5).
feature-status.md: #27 → 🟡 (backend + UI built and verified live).
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation