Happy House - Ecommerce Docs
Developer ResourcesNewsletter

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

ActorCan
Anonymous visitorEnter an address, confirm it, leave the list
Email recipientLeave the list with one click, no sign-in
Admin with Newsletter_READList and read campaigns
Admin with Newsletter_CREATECompose a draft
Admin with Newsletter_UPDATEEdit a draft, send a draft
The send workerDeliver 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 recipient

Why 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

CaseBehaviour
A@Example.com after a@example.comOne subscriber. Normalised to lowercase at the boundary; a database CHECK makes forgetting it a write failure rather than a duplicate
Whitespace around the addressTrimmed before storage
A confirmation link clicked twiceSecond 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 hourThird 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 mailStays 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

StateMeaningEditableSendable
draftBeing composedyesyes
sendingThe worker has it, or had it and diednono
sentEvery delivery reached a terminal status, and at least one succeedednono
failedEvery delivery was attempted and none succeedednono
cancelledReserved; no route sets it todaynono

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

CaseBehaviour
Send with zero confirmed subscribers409 NEWSLETTER_CAMPAIGN_NO_RECIPIENTS. The count is taken inside the send transaction
Somebody confirms after the send was scheduledThey 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 writtenThe 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 addressThat 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,000The next run resumes at 901. The 900 already-sent rows are terminal and are skipped
Two workers overlap after a stale claimThey claim disjoint rows. Whichever finishes last does the closing, and neither closes the campaign while anything is still pending
Every recipient unsubscribed before the sendZero 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

InputRenders as
Two blocks separated by a blank lineTwo paragraphs
Lines wrapped inside one blockOne paragraph — a hard-wrapped source must not become a column of fragments
## Autumn saleA 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, sentCount and failedCount off the campaign and poll while it is sending.
  • Do not offer an unsubscribe control that requires a login. The one-click link is the contract.

See Also