Skip to content

feat: change email (#27) — design + backend + UI - #20

Merged
imariel2d merged 4 commits into
mainfrom
docs/feature-27-change-email
Aug 7, 2026
Merged

feat: change email (#27) — design + backend + UI#20
imariel2d merged 4 commits into
mainfrom
docs/feature-27-change-email

Conversation

@imariel2d

@imariel2d imariel2d commented Aug 4, 2026

Copy link
Copy Markdown
Owner

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:

  • Mail on → verify-before-commit. The change is staged; a confirmation link is emailed to the new address; Email swaps and becomes EmailVerified only on click. The old address keeps signing in until then, and gets a heads-up on completion.
  • Mail off → immediate, unverified. No channel to prove the new inbox, so it applies at once with EmailVerified = false.

Both re-authenticate with the current password, which keeps the classic change-email → forgot-password takeover closed via #26's EmailVerified gate.

Backend

  • EmailChangeTokens table (twin of PasswordResetTokens, one-live-per-account partial unique index) + the AddEmailChange migration.
  • POST/DELETE /api/me/email (re-auth, per-user rate limit, transactions mapping unique-index races to 409) + anon GET/POST /api/auth/confirm-email/{token}.
  • Two Cove email templates; ProfileResponse gains emailVerified/pendingEmail.
  • 205 unit tests green; the CodeRabbit-review fixes are in (concurrency 409s, typed 202 body, problem+json 429, template encoding, doc consistency).

UI

  • A Change-email card on /profile: verified/unverified badge, new-email + current-password fields, response-branched copy, a pending Resend/Cancel panel, and a mail-off note.
  • A public /confirm-email/:token page (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 → Email swapped + 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

    • Added self-service email address changes with password re-authentication and duplicate-address checks.
    • Email-enabled accounts receive expiring confirmation links; email-disabled accounts update immediately as unverified.
    • Added cancellation, pending-email and verification status, completion notifications, and a dedicated confirmation page.
    • Added rate limiting and secure, single-use confirmation links.
  • Documentation

    • Documented email-change flows, security rules, API indicators, and email-delivery behavior.
    • Updated feature status to reflect partial availability and remaining verification work.

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>
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The 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.

Changes

Change email

Layer / File(s) Summary
Change-email contracts and design
docs/feature-27-change-email.md, docs/feature-status.md, src/Api/Features/Me/MeController.cs
Defines security rules, API flows, profile state, frontend behavior, and pending validation work. ProfileResponse now includes EmailVerified and PendingEmail.
Pending email token persistence
src/Api/Domain/EmailChangeToken.cs, src/Api/Data/AppDbContext.cs, src/Api/Data/Migrations/...
Adds hashed, expiring, single-use tokens with database constraints for token uniqueness and one unused token per user.
Authenticated change-email workflow
src/Api/Features/Me/MeController.cs, src/Api/Features/Auth/EmailChangeService.cs, src/Api/Features/Auth/RateLimiterPolicies.cs, src/Api/Program.cs, src/Api/Features/Email/EmailOptions.cs
Adds re-authentication, validation, duplicate checks, cancellation, mail-disabled updates, staged mail-enabled changes, asynchronous delivery, expiry validation, and per-user rate limiting.
Token confirmation and notifications
src/Api/Features/Auth/EmailChangeController.cs, src/Api/Features/Email/EmailTemplates.cs, tests/Api.Tests/*
Adds preview and confirmation endpoints, atomic token consumption, conflict responses, old-address notifications, email templates, masking, and unit tests.
Angular profile and confirmation integration
src/ClientApp/src/app/core/*, src/ClientApp/src/app/features/email-change/*, src/ClientApp/src/app/features/profile/*, src/ClientApp/src/app/app.routes.ts
Adds profile email-management controls, a public confirmation route and page, service methods, response models, state handling, error handling, and responsive styling.

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
Loading

Possibly related PRs

  • imariel2d/keepr#12: Shares email settings, templates, and profile integration used by this workflow.
  • imariel2d/keepr#13: Provides the runtime email settings infrastructure used for delivery and expiry configuration.
  • imariel2d/keepr#17: Implements related hashed, single-use email-token and verification patterns.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 59.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the self-service email-change feature and identifies its design, backend, and UI scope.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/feature-27-change-email

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 1ac1cf9 and a69afbc.

📒 Files selected for processing (2)
  • docs/feature-27-change-email.md
  • docs/feature-status.md

Comment on lines +154 to +172
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`.

@coderabbitai coderabbitai Bot Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 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.cs

Length 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.md

Length 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.

Comment thread docs/feature-27-change-email.md Outdated
Comment thread docs/feature-27-change-email.md
Comment thread docs/feature-27-change-email.md
Comment thread docs/feature-27-change-email.md Outdated
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>
@imariel2d imariel2d changed the title docs: design feature #27 (change email) feat: change email (#27) — design + backend Aug 4, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
"&lt;script&gt;" 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

📥 Commits

Reviewing files that changed from the base of the PR and between a69afbc and a68f95a.

⛔ Files ignored due to path filters (2)
  • src/Api/Data/Migrations/20260804190908_AddEmailChange.Designer.cs is excluded by !**/Migrations/*.Designer.cs
  • src/Api/Data/Migrations/AppDbContextModelSnapshot.cs is excluded by !**/Migrations/*ModelSnapshot.cs
📒 Files selected for processing (14)
  • docs/feature-27-change-email.md
  • docs/feature-status.md
  • src/Api/Data/AppDbContext.cs
  • src/Api/Data/Migrations/20260804190908_AddEmailChange.cs
  • src/Api/Domain/EmailChangeToken.cs
  • src/Api/Features/Auth/EmailChangeController.cs
  • src/Api/Features/Auth/EmailChangeService.cs
  • src/Api/Features/Auth/RateLimiterPolicies.cs
  • src/Api/Features/Email/EmailOptions.cs
  • src/Api/Features/Email/EmailTemplates.cs
  • src/Api/Features/Me/MeController.cs
  • src/Api/Program.cs
  • tests/Api.Tests/EmailChangeTokenTests.cs
  • tests/Api.Tests/EmailTemplateTests.cs

Comment thread docs/feature-status.md Outdated
Comment thread src/Api/Features/Auth/EmailChangeController.cs
Comment thread src/Api/Features/Auth/EmailChangeController.cs
Comment thread src/Api/Features/Auth/EmailChangeController.cs
Comment thread src/Api/Features/Auth/EmailChangeService.cs Outdated
Comment thread src/Api/Features/Me/MeController.cs Outdated
Comment thread src/Api/Features/Me/MeController.cs Outdated
Comment thread src/Api/Features/Me/MeController.cs
Comment thread src/Api/Program.cs
Comment thread tests/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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lift

Move confirmation delivery into managed background work.

If the process stops after POST /api/me/email returns 202, the untracked Task.Run can end before SendConfirmationAsync sends the message. The user then has a pending token but no confirmation email. This code also passes CancellationToken.None to 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

📥 Commits

Reviewing files that changed from the base of the PR and between a68f95a and 131981c.

📒 Files selected for processing (6)
  • docs/feature-27-change-email.md
  • docs/feature-status.md
  • src/Api/Features/Auth/EmailChangeService.cs
  • src/Api/Features/Me/MeController.cs
  • src/Api/Program.cs
  • tests/Api.Tests/EmailTemplateTests.cs

Comment thread src/Api/Features/Me/MeController.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>
@imariel2d imariel2d changed the title feat: change email (#27) — design + backend feat: change email (#27) — design + backend + UI Aug 7, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 131981c and a85ba1e.

📒 Files selected for processing (11)
  • docs/feature-27-change-email.md
  • docs/feature-status.md
  • src/ClientApp/src/app/app.routes.ts
  • src/ClientApp/src/app/core/email-change.service.ts
  • src/ClientApp/src/app/core/models.ts
  • src/ClientApp/src/app/features/email-change/confirm-email.html
  • src/ClientApp/src/app/features/email-change/confirm-email.ts
  • src/ClientApp/src/app/features/email-change/email-change.scss
  • src/ClientApp/src/app/features/profile/profile.html
  • src/ClientApp/src/app/features/profile/profile.scss
  • src/ClientApp/src/app/features/profile/profile.ts

Comment thread docs/feature-27-change-email.md
Comment thread src/ClientApp/src/app/features/email-change/confirm-email.ts
Comment thread src/ClientApp/src/app/features/profile/profile.ts
@imariel2d
imariel2d merged commit f438217 into main Aug 7, 2026
3 checks passed
@imariel2d
imariel2d deleted the docs/feature-27-change-email branch August 7, 2026 16:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant