Feature: Change my email address from Profile, verified by a link sent to the new address As a verified RenewCMS user I want to change my email address with my current password and a link sent to the new address So that I keep control of my account address and nobody can take it over without my password or without access to the new inbox Background: Given the design of record is docs/design/screens/account.md "Change email" and "Verification landing", docs/design/routes.md "Account", the board "RenewCMS Screens Account" group "Change email" (14 states) and the board "RenewCMS Screens Auth" group "Verify new email" (7 states) And four endpoints are added under /v1/auth, each behind RequireSession, authtoken.Verifier, AuditContext, RequireSignedIn, authtoken.Authenticator, RequireCurrentToken and RequireVerifiedUser, so a missing or invalid jwt cookie gives 401 "You must be logged in to access this resource.", an API key gives 403 "This action needs a real session." and an unverified user gives 403 "You must verify your account to access this resource.": | method | path | body | success | | POST | /v1/auth/email/change | {"new_email": "...", "current_password": "..."} | 200 {"pending_email": ""} | | POST | /v1/auth/email/resend | none | 200 {"pending_email": ""} | | DELETE | /v1/auth/email/change | none | 204, no body | | POST | /v1/auth/email/confirm | {"code": ""} | 200 {"email": ""} | And POST change and POST resend share one per-user limiter of 5 attempts per 15 minutes (middleware.NewUserRateLimiter); the sixth answers 429 "Too many requests. Please try again later." with a Retry-After header; DELETE and confirm are not limited And the request struct uses validate tags checked with validator.Validate.Struct: new_email "required,email" (same email rule as the invite request, extended in validator.InitValidator) and current_password "required"; there is no hand-written validation in the handler And error bodies are plain text from http.Error, snake_case codes are the whole body: 403 "wrong_password", 400 "same_email", 404 "no_pending_email_change", 403 "invalid_email_change_code", 409 "email_unavailable"; success bodies are JSON And the user domain gains PendingEmail, EmailChangeCode (bcrypt hash of a random UUID, like the reset code) and EmailChangeExpiration on user.User and the users table (portable across SQLite, MySQL and PostgreSQL), the sentinels ErrWrongPassword, ErrSameEmail, ErrNoPendingEmailChange, ErrInvalidEmailChangeCode and ErrEmailUnavailable, and repository methods that set, clear and confirm a pending change; confirming is one atomic step that re-checks the address is free And a use case (RequestEmailChange, ResendEmailChange, CancelEmailChange, ConfirmEmailChange) owns every rule; the handler never touches a repository, GORM or the mailer And password checking uses the domain hash.Hasher (never bcrypt in the handler) And the link code lives 2 hours and works once; users.email is untouched until confirmation, so the old address keeps receiving password-reset links and the password and username keep working And GET /v1/auth/me gains "pending_email" (string or null) so the Pending state survives a reload And three mail templates are built from docs/design/source/emails and added to internal/infrastructure/mailer/templates: | template | to | data | subject | | verify-new-email | the new address | Host, Username, VerificationCode | Confirm your new RenewCMS email address | | email-change-notice| the old address | Host, Username, NewEmail | Your RenewCMS email address change was requested | And Host is PUBLIC_URL and Username is the account's username And the audit actions user.request_email_change, user.resend_email_change, user.cancel_email_change and user.confirm_email_change are added (category users, source web); request, resend and confirm are security events; no email address is written into an event And the layering rule holds and no comment is added anywhere Rule: Requesting a change Background: Given the user "marta" (id 7, verified) has email "marta@renew.example" and the password "limewash2026" Scenario: A valid request stores a pending change and sends two mails When marta sends POST /v1/auth/email/change with {"new_email": "marta.alves@atelier-renew.example", "current_password": "limewash2026"} Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"} And users.email is still "marta@renew.example" And PendingEmail is "marta.alves@atelier-renew.example", the stored code is a hash (never the raw code) and the expiration is within 5 seconds of now plus 2 hours And one mail is sent to "marta.alves@atelier-renew.example" with template "verify-new-email" and data Host, Username "marta" and the raw VerificationCode And one mail is sent to "marta@renew.example" with template "email-change-notice" and data Host, Username "marta" and NewEmail "marta.alves@atelier-renew.example" And one audit event user.request_email_change is recorded: actor marta, target user 7, success, security, with no address in its params Scenario Outline: A refused request changes nothing and sends nothing When marta sends POST /v1/auth/email/change with Then the response is with the body "" And PendingEmail is empty and no mail is sent Examples: | body | status | text | | {"new_email": "marta.alves@atelier", "current_password": "limewash2026"} | 400 | the validator message for new_email | | {"new_email": "", "current_password": "limewash2026"} | 400 | the validator message for new_email | | {"new_email": "marta.alves@atelier-renew.example", "current_password": ""} | 400 | the validator message for current_password | | not-json | 400 | The request cannot be processed due to a mismatch in the format of the body. | | {"new_email": "MARTA@renew.example", "current_password": "limewash2026"} | 400 | same_email | | {"new_email": "marta.alves@atelier-renew.example", "current_password": "nope"} | 403 | wrong_password | Scenario: A wrong password records a failed security event When marta sends the request with the password "nope" Then one audit event user.request_email_change is recorded with outcome failed, security true and failure reason "Wrong password." Scenario: An address already in use answers like success and sends no verification mail Given another user has the email "ines@renew.example" When marta sends POST /v1/auth/email/change with new_email "Ines@Renew.example" Then the status, the body and every header except Date are identical to the valid request And a pending change for "Ines@Renew.example" is stored so a reload cannot tell the difference And no mail is sent to "ines@renew.example" And the email-change-notice is still sent to "marta@renew.example" Scenario: A new request replaces the previous link Given marta requested a change to "a@renew.example" and received code 1 When she requests "b@renew.example" Then only code 2 confirms, code 1 answers 403 "invalid_email_change_code", and PendingEmail is "b@renew.example" Scenario: A failed send is logged and the response is unchanged Given the mail repository fails on Send with "smtp: connection refused" When marta sends a valid request Then the response is 200 with the same body, the pending change is stored, and the process log names the failed send and the error but not the raw code And a 500 would reveal whether the address is taken, which is why the failure is not surfaced Scenario: The sixth request in 15 minutes is rate limited Given marta made five requests or resends in the last 15 minutes When she sends one more Then the response is 429 "Too many requests. Please try again later." with a Retry-After header And no mail is sent and nothing is stored Rule: Resending and cancelling while pending Scenario: Resend issues a new code and invalidates the old one Given marta has a pending change to "marta.alves@atelier-renew.example" with code 1 When she sends POST /v1/auth/email/resend Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"} And one mail with template "verify-new-email" carries a new code, the expiration is reset to now plus 2 hours And code 1 answers 403 "invalid_email_change_code" And no email-change-notice is sent again And one audit event user.resend_email_change is recorded Scenario: Resend for a taken address sends nothing Given marta's pending address belongs to another user When she sends POST /v1/auth/email/resend Then the response is 200 with the same body and no mail is sent Scenario: Resend with nothing pending When marta sends POST /v1/auth/email/resend Then the response is 404 "no_pending_email_change" Scenario: Cancel clears the pending change Given marta has a pending change When she sends DELETE /v1/auth/email/change Then the response is 204 and PendingEmail, code and expiration are empty And the old link answers 403 "invalid_email_change_code" And one audit event user.cancel_email_change is recorded Scenario: Cancel with nothing pending is harmless When marta sends DELETE /v1/auth/email/change Then the response is 204 and no audit event is recorded Rule: Confirming from the link Scenario: A valid code changes the email and is single use Given marta has a pending change to "marta.alves@atelier-renew.example" with a live code When she sends POST /v1/auth/email/confirm with that code Then the response is 200 with {"email": "marta.alves@atelier-renew.example"} And users.email is "marta.alves@atelier-renew.example", the pending fields are empty And her session keeps working (token_version is unchanged) And one audit event user.confirm_email_change is recorded When she sends the same code again Then the response is 403 "invalid_email_change_code" Scenario Outline: A code that does not work answers one 403 Given When marta confirms with Then the response is 403 "invalid_email_change_code" and users.email is unchanged Examples: | setup | code | | nothing is pending | any code | | the code expired 1 second ago | the right code | | a pending change exists | a wrong code | | the code was issued to another user | the right code of ines | | the change was cancelled | the old code | Scenario: The address was taken in the meantime Given marta has a live code for "ines@renew.example" and the address now belongs to another user When she confirms Then the response is 409 "email_unavailable", users.email is unchanged and the pending change is cleared And the body is the same for an address that was always taken at request time Scenario: An empty code is refused before any lookup When marta sends {"code": ""} Then the response is 400 with the validator message and nothing changes Rule: Profile, the Account card (SPA) Background: Given /profile (docs/design/screens/profile.md, features/profile_page.feature) shows the Email field and a link "Change email"; the order of the cards is Display name, Account, Your role, Author profile, Password, Your data, Danger zone And the session carries pending_email from GET /v1/auth/me And the global handling of 403 in useApi passes a 403 with body "wrong_password" through to the caller; the form reads the body text and never the status alone And every new SPA string (the Account card states, toasts and errors, and the verification landing states) is added to the i18n catalogue (messages.ts, features/spa_internationalization.feature) in English and in French under the same keys, and the English wording is the design's verbatim And the French wording is an assumption: docs/design has no French copy, so the coder authors it and flags it for the user's review And the mail templates verify-new-email and email-change-notice are English only, because the existing mailer does not localise (verification, password-reset and invitation hard-code lang="en" and the subjects are fixed English strings) And every state below is one Vitest spec and one row of `task visual:check -- account`, named as in the board, using the sample data of the frame (marta, marta@renew.example, marta.alves@atelier-renew.example) Scenario Outline: Form states Given the user is on /profile When Then the state "" is visible with And Examples: | state | trigger | copy | requests | | Default | the page loads, nothing pending | the Email field and the link "Change email" | none | | Form open | "Change email" is activated | "New email address", "Current password", the hint "We'll send a verification link to the new address. Your email stays the same until you open it.", "Cancel", "Send verification link"; focus on the email field | none | | Invalid format | "marta.alves@atelier" is typed and the field blurs or is submitted | "Enter a full email address, like name@example.com." | none | | Same as current | "marta@renew.example" is submitted | "That's already your email address." | none | | Password missing | submitted with an empty password | "Enter your current password.", focus on the password | none | | Wrong password (403) | POST answers 403 "wrong_password" | "That password isn't correct. Try again." under the password, password cleared and focused | one POST | | Sending | POST has not resolved | fields and button disabled, button reads "Sending…" | one POST | | Rate limited (429) | POST answers 429 | "Too many requests. Try again in a few minutes.", "Send verification link" disabled | one POST | | Server or network error | POST answers 500 or fails | "Something went wrong on our side. Nothing was changed. Try again in a moment." and the typed values kept | one POST | Scenario: Pending, from a successful request When POST /v1/auth/email/change answers 200 with pending_email "marta.alves@atelier-renew.example" Then the form closes and the Email field still shows "marta@renew.example" And the card shows "Waiting for verification", "We sent a link to marta.alves@atelier-renew.example. Until you open it, you keep logging in with marta@renew.example.", "Resend link" and "Cancel change" And a toast "Verification link sent" / "Check marta.alves@atelier-renew.example. The link works once, within 2 hours." appears And reloading /profile with pending_email set shows the same Pending card without a toast Scenario Outline: Pending actions Given the Pending card is shown When Then Examples: | trigger | outcome | | "Resend link" is activated, in flight | state "Pending: resending": "Sending…" and the button disabled | | the resend answers 200 | state "Pending: resent": toast "Link sent again" / "The previous link no longer works." | | the resend answers 429 with Retry-After 270 | state "Pending: resend rate limited (429)": "Resend in 4:30" disabled, "Too many requests. Try again in a few minutes."; the count runs down from the header, not the wall clock | | "Cancel change" is activated | DELETE /v1/auth/email/change with no confirmation step, then state "Cancelled": the Default state and toast "Email change cancelled" / "You keep logging in with marta@renew.example." | Rule: The verification landing /account/email/confirm?c=CODE (SPA) Background: Given the route is a session route inside AuthCard; without a session it shows the inline login as /register/validate does, then continues the confirmation And the AuthCard states are those of the board group "Verify new email" Scenario Outline: Landing states Given the user opens /account/email/confirm When Then the state "" shows Examples: | query | trigger | state | copy | | ?c=abc123 | POST /v1/auth/email/confirm unresolved | Confirming | "Confirming your new email…" | | ?c=abc123 | the POST answers 200 {"email": "marta.alves@atelier-renew.example"} | Success | "Your email address has changed", "Your account now uses marta.alves@atelier-renew.example. Use it the next time you log in.", "Go to profile"; toast "Email address changed" / "Your account now uses marta.alves@atelier-renew.example."; the session email is replaced | | ?c=abc123 | the POST answers 403 | Invalid or expired (403) | "This link doesn't work", "It may have expired (links last 2 hours), been cancelled or already been used.", "Request a new link from your profile.", "Go to profile" | | ?c=abc123 | the POST answers 409 "email_unavailable" | Address unavailable (409) | "We couldn't change your email address", "This address can't be used for your account. Go back to your profile and try a different one.", "Go to profile" | | ?c=abc123 | no session (401 from /auth/me) | No session (401) | "Log in to confirm your new email", "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.", "Log in and confirm" | | | no c parameter | Missing code | "This link is incomplete", "The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.", "Go to log in"; no request is sent | | ?c=abc123 | the POST answers 500 or fails | Server error | "Couldn't confirm the change", "Something went wrong on our side. Nothing was changed. Try again in a moment.", "Try again", which sends the POST again | Scenario: Log in and confirm Given the "No session (401)" state is shown When the user logs in inline with valid credentials Then POST /v1/auth/email/confirm is sent once with the code and the Success state follows And the code is not written to the URL history or logs by the SPA Scenario: A confirmation is not repeated by reload noise Given the Success state was reached When the user reloads the page Then the POST is sent again and the "Invalid or expired (403)" state is shown, because the code is single use Rule: Mail templates Scenario Outline: Each template renders from its data When the mailer renders "