The third step of the only recovery route a customer who has lost their mailbox has. Steps one and two are contacting the shop and being verified against order history. Step three had no implementation, so the answer was a hand-written database edit that left no record of who did it or why.
The premise everything follows from
This operation and an account takeover are the same operation. They differ only in whether the verification was sound, and nothing in the software can check that. What the software can do is make the change recorded, announced, and complete in its effects.
Recorded
The endpoint refuses without a written reason, and stores it against the account in a new customer_email_changes table. That row is the only thing that tells a genuine recovery from a takeover afterwards, which is why a hand edit was never acceptable, and why the reason is required by the server rather than merely collected by the form.
It is never shown to the customer. It is a note about how somebody was verified and can name things the customer should not be handed back.
There is no column for who. Admin access is one shared gate secret in front of a single operator, so such a column could only hold a constant, and a constant dressed up as an identity is worse than an honest absence.
Announced
A notice goes to the address being replaced. If the recovery was sound it reaches nobody, which costs nothing. If it was not, it reaches the real owner — the only person who can say so, and the only reason this endpoint is safe to have.
Its own template rather than reusing emailChanged. That copy says "if you did not make this change, contact us", and here somebody already did: the sentence would be addressed to the customer who just did the thing it asks for, while the person who needs to act on it did nothing.
The new address is marked unverified and sent a confirmation link, because reading an address out over the phone is not a demonstration of receiving mail at it.
Complete in its effects
The move signs the customer out everywhere, removes every passkey, and cancels reset links already sent. That is the conclusion #42 reached for password reset, applied here with more force: somebody the system cannot identify asked for this, so a surviving session or credential is one the new owner cannot see or revoke, and a reset link in the old mailbox would let whoever reads it take the account straight back.
The password is left alone. What the customer lost was the mailbox.
Also in here
issueVerificationEmail moved into its own module, for the reason session creation moved out for passkeys. This path runs perhaps once a year, so it is exactly the one that would rot if written twice.
The admin drawer gains the action next to the address rather than among the account controls, leading with the warning rather than burying it, plus the history of moves — which renders nothing at all for customers who have never been moved.
docs/ops/account-recovery.md updated, since the gap it described is now filled.
Testing
Fourteen integration tests covering the record, the refusals, the eviction, the token cancellation, and the history listing.
Verified locally: backend tsc clean for src and tests, 526 unit tests pass, lint clean apart from warnings that predate this branch; frontend tsc, lint and build clean.
CI cannot verify this right now. Run 917 has been hung since it started and 24 runs are queued behind it. That hang is the same one #154 identifies as the source of the leftover Postgres containers, and clearing it needs NAS access.
Closes #337.
The third step of the only recovery route a customer who has lost their mailbox has. Steps one and two are contacting the shop and being verified against order history. Step three had no implementation, so the answer was a hand-written database edit that left no record of who did it or why.
## The premise everything follows from
**This operation and an account takeover are the same operation.** They differ only in whether the verification was sound, and nothing in the software can check that. What the software can do is make the change recorded, announced, and complete in its effects.
## Recorded
The endpoint refuses without a written reason, and stores it against the account in a new `customer_email_changes` table. That row is the only thing that tells a genuine recovery from a takeover afterwards, which is why a hand edit was never acceptable, and why the reason is required by the server rather than merely collected by the form.
It is never shown to the customer. It is a note about how somebody was verified and can name things the customer should not be handed back.
There is no column for *who*. Admin access is one shared gate secret in front of a single operator, so such a column could only hold a constant, and a constant dressed up as an identity is worse than an honest absence.
## Announced
A notice goes to the address being replaced. If the recovery was sound it reaches nobody, which costs nothing. If it was not, it reaches the real owner — the only person who can say so, and the only reason this endpoint is safe to have.
Its own template rather than reusing `emailChanged`. That copy says "if you did not make this change, contact us", and here somebody already did: the sentence would be addressed to the customer who just did the thing it asks for, while the person who needs to act on it did nothing.
The new address is marked unverified and sent a confirmation link, because reading an address out over the phone is not a demonstration of receiving mail at it.
## Complete in its effects
The move signs the customer out everywhere, removes every passkey, and cancels reset links already sent. That is the conclusion #42 reached for password reset, applied here with more force: somebody the system cannot identify asked for this, so a surviving session or credential is one the new owner cannot see or revoke, and a reset link in the old mailbox would let whoever reads it take the account straight back.
The password is left alone. What the customer lost was the mailbox.
## Also in here
- `issueVerificationEmail` moved into its own module, for the reason session creation moved out for passkeys. This path runs perhaps once a year, so it is exactly the one that would rot if written twice.
- The admin drawer gains the action next to the address rather than among the account controls, leading with the warning rather than burying it, plus the history of moves — which renders nothing at all for customers who have never been moved.
- `docs/ops/account-recovery.md` updated, since the gap it described is now filled.
## Testing
Fourteen integration tests covering the record, the refusals, the eviction, the token cancellation, and the history listing.
Verified locally: backend `tsc` clean for src and tests, 526 unit tests pass, lint clean apart from warnings that predate this branch; frontend `tsc`, lint and build clean.
**CI cannot verify this right now.** Run 917 has been hung since it started and 24 runs are queued behind it. That hang is the same one #154 identifies as the source of the leftover Postgres containers, and clearing it needs NAS access.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The third step of the only recovery route a customer who has lost their mailbox has. The first two are contacting the shop and being verified against order history. The third had no implementation, so the answer was a hand-written database edit that left no record of who did it or why.
The thing to say plainly, because everything here follows from it: this operation and an account takeover are the same operation. They differ only in whether the verification was sound, and nothing in the software can check that. What the software can do is make the change recorded, announced, and complete in its effects.
Recorded. The endpoint refuses without a written reason, and the reason is stored against the account. That row is the only thing that tells a genuine recovery from a takeover afterwards, which is why a hand edit was never acceptable and why the field is required by the server rather than merely collected by the form. It is never shown to the customer: it is a note about how somebody was verified and can name things the customer should not be handed back.
There is no column for who did it. Admin access is one shared gate secret in front of a single operator, so such a column could only ever hold a constant, and a constant dressed up as an identity is worse than an honest absence.
Announced, to the address being replaced. If the recovery was sound that reaches nobody and costs nothing. If it was not, it reaches the real owner, who is the only person in the world who can say so, and that is the only reason this endpoint is safe to have at all. Its own template rather than the self-service one, because that copy says to contact us if you did not make this change, and here somebody already did — the sentence would be addressed to the customer who just did the thing it asks for, while the person who needs to act on it did nothing.
The new address is marked unverified and sent a confirmation link. Somebody reading an address out over the phone has not demonstrated they can receive mail at it, and that is the commonest way this goes wrong harmlessly.
Complete in its effects. The move signs the customer out everywhere, removes every passkey, and cancels reset links already sent. That is the conclusion #42 reached for password reset, and it applies here with more force: somebody the system cannot identify asked for this change, so a session or a credential surviving it is one the new owner cannot see and cannot revoke, and a reset link sitting in the mailbox being taken away would let whoever still reads it take the account straight back.
The password is left alone. What the customer lost was the mailbox, so demanding a new one adds a step for no gain.
The verification-email helper moved out of the customers route into its own module, for the reason session creation moved out for passkeys: two implementations that agree today are two that can be changed one at a time, and the one that gets forgotten is whichever the manual testing does not exercise. This path runs perhaps once a year, so it is exactly the one that would rot.
The admin drawer gains the action next to the address rather than among the account controls, because it is a thing done to that field by someone already looking at it. It leads with the warning instead of burying it. The history of moves sits on the same drawer and renders nothing at all for the overwhelming majority of customers, who have never been moved.
Verified: backend tsc clean for src and tests, 526 unit tests pass, lint clean apart from warnings that predate this branch; frontend tsc, lint and build clean. The integration suite needs a database this machine has no Docker for. It also cannot be proven by CI right now — run 917 has been hung since it started and 24 runs are queued behind it, which is the same hang #154 identifies as the source of the leftover Postgres containers.
Closes#337
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #337.
The third step of the only recovery route a customer who has lost their mailbox has. Steps one and two are contacting the shop and being verified against order history. Step three had no implementation, so the answer was a hand-written database edit that left no record of who did it or why.
The premise everything follows from
This operation and an account takeover are the same operation. They differ only in whether the verification was sound, and nothing in the software can check that. What the software can do is make the change recorded, announced, and complete in its effects.
Recorded
The endpoint refuses without a written reason, and stores it against the account in a new
customer_email_changestable. That row is the only thing that tells a genuine recovery from a takeover afterwards, which is why a hand edit was never acceptable, and why the reason is required by the server rather than merely collected by the form.It is never shown to the customer. It is a note about how somebody was verified and can name things the customer should not be handed back.
There is no column for who. Admin access is one shared gate secret in front of a single operator, so such a column could only hold a constant, and a constant dressed up as an identity is worse than an honest absence.
Announced
A notice goes to the address being replaced. If the recovery was sound it reaches nobody, which costs nothing. If it was not, it reaches the real owner — the only person who can say so, and the only reason this endpoint is safe to have.
Its own template rather than reusing
emailChanged. That copy says "if you did not make this change, contact us", and here somebody already did: the sentence would be addressed to the customer who just did the thing it asks for, while the person who needs to act on it did nothing.The new address is marked unverified and sent a confirmation link, because reading an address out over the phone is not a demonstration of receiving mail at it.
Complete in its effects
The move signs the customer out everywhere, removes every passkey, and cancels reset links already sent. That is the conclusion #42 reached for password reset, applied here with more force: somebody the system cannot identify asked for this, so a surviving session or credential is one the new owner cannot see or revoke, and a reset link in the old mailbox would let whoever reads it take the account straight back.
The password is left alone. What the customer lost was the mailbox.
Also in here
issueVerificationEmailmoved into its own module, for the reason session creation moved out for passkeys. This path runs perhaps once a year, so it is exactly the one that would rot if written twice.docs/ops/account-recovery.mdupdated, since the gap it described is now filled.Testing
Fourteen integration tests covering the record, the refusals, the eviction, the token cancellation, and the history listing.
Verified locally: backend
tscclean for src and tests, 526 unit tests pass, lint clean apart from warnings that predate this branch; frontendtsc, lint and build clean.CI cannot verify this right now. Run 917 has been hung since it started and 24 runs are queued behind it. That hang is the same one #154 identifies as the source of the leftover Postgres containers, and clearing it needs NAS access.
🤖 Generated with Claude Code