Newsletter Features and Flows
Subscribing, confirming, leaving, and sending a campaign — with the edge cases each rule exists for.
Audience: Product owners, QA, frontend developers Scope: Behaviour. No SQL, no file paths — those are on the backend page.
Newsletter - Features and Flows
1. Actors
| Actor | Can |
|---|---|
| Anonymous visitor | Enter an address, confirm it, leave the list |
| Email recipient | Leave the list with one click, no sign-in |
Admin with Newsletter_READ | List and read campaigns |
Admin with Newsletter_CREATE | Compose a draft |
Admin with Newsletter_UPDATE | Edit a draft, send a draft |
| The send worker | Deliver a campaign and record what happened |
There is no Newsletter_DELETE. Nothing in this module deletes anything.
2. Flow — joining the list
Visitor types an address in the footer
│
▼
POST /api/newsletter/subscribe ── rate limited: 3 per hour, per ip+device
│
├─ new address → row created, unconfirmed, fresh confirmation token
├─ already unconfirmed → FRESH confirmation token issued
├─ previously unsubscribed→ revived: unsubscribed_at cleared, confirmed_at CLEARED,
│ fresh confirmation token
└─ already confirmed → nothing happens
│
▼
The response is IDENTICAL in all four cases
│
▼
A confirmation email is queued — but only in the first three
│
▼
Recipient clicks the link → GET /api/newsletter/confirm?token=…
│
▼
confirmed_at is set, the token is CLEARED, and they are now a recipientWhy the response never varies
A public form that answered differently for "already on the list" would tell whoever typed an address whether that person is a customer of this shop. The person typing is frequently not the person whose address it is.
The frontend must therefore not build UI that claims to know which branch happened — no "you're already subscribed!" state, because the API will not tell it.
Why reviving requires a fresh opt-in
Someone who unsubscribed and later has their address typed in again — by themselves or by anyone —
gets a new confirmation email, and their old confirmed_at is cleared. Silently re-adding a
person who asked to be left alone, on the strength of a form submission, is exactly what the
unsubscribe record exists to prevent.
The row is the same row, keeping the original creation date and the suppression history.
Edge cases
| Case | Behaviour |
|---|---|
A@Example.com after a@example.com | One subscriber. Normalised to lowercase at the boundary; a database CHECK makes forgetting it a write failure rather than a duplicate |
| Whitespace around the address | Trimmed before storage |
| A confirmation link clicked twice | Second click returns NEWSLETTER_CONFIRMATION_TOKEN_INVALID — the token is consumed on use, so a link cannot re-confirm an address the person later left |
| Subscribing three times in an hour | Third succeeds, fourth is rate-limited. Each attempt issues a new token, so the newest email is always the one that works |
| An address that cannot receive mail | Stays unconfirmed forever, and is never sent to. The email arriving is the validity check; the format check is deliberately loose because strict email regexes reject valid addresses |
3. Flow — leaving the list
Recipient clicks Unsubscribe in any email
│
▼
GET /api/newsletter/unsubscribe?token=…
│
├─ token matches → unsubscribed_at set
└─ token unknown → logged, and the SAME success response
│
▼
"You have been unsubscribed."The token is stable and permanent, so a two-year-old email still unsubscribes correctly. It is deliberately not the public id: a public id appears in admin URLs and logs, and a secret that grants an action must never double as a value used for identification.
Unsubscribing twice is a success both times.
4. Flow — sending a campaign
Admin composes POST /admin/newsletter/campaigns → draft
Admin edits PATCH /admin/newsletter/campaigns/:id → draft only
Admin sends POST /admin/newsletter/campaigns/:id/send
│
▼
In ONE transaction: count confirmed subscribers, freeze it as recipient_count,
flip draft → sending, and record the outbox event
│
▼
The worker picks it up
│
├─ 1. materialise one delivery row per recipient, capped at recipient_count
├─ 2. claim + send in batches; each row settles as sent | failed | suppressed
└─ 3. finalise only once nothing is pending → sent (or failed)The campaign lifecycle
| State | Meaning | Editable | Sendable |
|---|---|---|---|
draft | Being composed | yes | yes |
sending | The worker has it, or had it and died | no | no |
sent | Every delivery reached a terminal status, and at least one succeeded | no | no |
failed | Every delivery was attempted and none succeeded | no | no |
cancelled | Reserved; no route sets it today | no | no |
A crashed send leaves the campaign in sending, and that is what makes resuming and starting
indistinguishable to the worker — deliberately.
Why only a draft is editable
Editing mid-send would change the copy for the recipients who have not been reached yet, so half the list receives a different email from the other half, and the campaign record describes neither.
Why double-send is impossible
POST /send re-asserts status = 'draft' inside the UPDATE's WHERE clause, not only in a prior
check. Two concurrent send requests both pass the check; only one matches a row. The loser gets
NEWSLETTER_CAMPAIGN_NOT_DRAFT.
Edge cases
| Case | Behaviour |
|---|---|
| Send with zero confirmed subscribers | 409 NEWSLETTER_CAMPAIGN_NO_RECIPIENTS. The count is taken inside the send transaction |
| Somebody confirms after the send was scheduled | They are not included. recipient_count was frozen, and the materialisation is capped at it — they get the next campaign, which is the right answer since they were not on the list when it went out |
| Somebody unsubscribes after the delivery row was written | The row is marked suppressed and no email is sent. Consent is re-checked per recipient at send time, not only when the list was materialised |
| The mail provider rejects one address | That row records failed with the provider's reason. The campaign continues — one dead mailbox must not stop the other 999 people receiving anything, and retrying the whole campaign would hit the same address again |
| The worker dies at recipient 900 of 1,000 | The next run resumes at 901. The 900 already-sent rows are terminal and are skipped |
| Two workers overlap after a stale claim | They claim disjoint rows. Whichever finishes last does the closing, and neither closes the campaign while anything is still pending |
| Every recipient unsubscribed before the send | Zero sent, zero failed, all suppressed. The campaign closes as sent — it ran, and there is nothing to report as broken |
5. What a recipient receives
Two emails exist, both rendered through the shared design system rather than a bespoke template.
The confirmation email carries one link and no unsubscribe link. An unconfirmed address is not subscribed to anything, and offering to unsubscribe would imply otherwise. The copy tells a recipient who did not sign up that ignoring it is sufficient — the likeliest recipient of a wrong one is the victim of somebody else's typo.
The campaign email carries the admin's body, a divider, a line explaining why they are receiving it, and an unsubscribe button. The unsubscribe URL carries that subscriber's own token, so the same campaign renders differently for every recipient and the render cannot be cached across the list. That is the cost of a one-click unsubscribe that works without a login.
What the composer can and cannot do
| Input | Renders as |
|---|---|
| Two blocks separated by a blank line | Two paragraphs |
| Lines wrapped inside one block | One paragraph — a hard-wrapped source must not become a column of fragments |
## Autumn sale | A subheading |
**bold**, [link](url), <b>x</b> | Literal text. Nothing else is interpreted, and HTML is escaped |
The admin UI should say so plainly. Markdown that silently does nothing is worse than markdown that is documented as unsupported — and text that survives verbatim is visible in a test send, which is a failure mode a writer can notice and correct.
6. What the frontend must not do
- Do not branch on the subscribe response to guess whether the address was new.
- Do not build a "resend confirmation" button that assumes the previous token still works — each attempt issues a new one, and only the newest is valid.
- Do not show a campaign's progress by recomputing the recipient count; read
recipientCount,sentCountandfailedCountoff the campaign and poll while it issending. - Do not offer an unsubscribe control that requires a login. The one-click link is the contract.