Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

A notice to every account goes out 100 at a time, and the browser is what keeps the loop going

Sometimes Nakodo has to tell every account holder something: the terms changed, there was an outage, a plan is being renamed. That is one of those features that looks like an afternoon and turns out to be mostly about wh

Sometimes Nakodo has to tell every account holder
something: the terms changed, there was an outage, a plan is being renamed.
That is one of those features that looks like an afternoon and turns out to be
mostly about what must not happen.

The shape it ended up with: a Server Action that sends one batch of at most
100 and then stops, and a browser that calls it again with the cursor it was
handed. The server never loops.

Four modes, and keeping them apart is the point

test      One copy to the operator's own address, so the real email can be
          read in a real inbox before anyone else gets it.
direct    One email to a named address. For writing to one person who may
          not have an account, so it carries no "you have an account" footer.
accounts  Service email to every account holder. Not consent-gated and
          carries no unsubscribe link: there is nothing to leave short of
          closing the account.
marketing Product news, to the people who opted in. Every copy carries a
          one-click unsubscribe.

The split between the last two is legal rather than cosmetic. A service email
about an account that exists is not marketing, so it does not need consent and
has nothing to unsubscribe from. Product news is consent-gated, so it only ever
reaches rows that say so, and
the privacy notice commits to it in one sentence:

We send product news only if you ask for it, at sign-up or in Settings, and
every one has a one-click unsubscribe. We keep a record of your choice
against your address, and an unsubscribe is never forgotten.

In code the difference is one expression per recipient:

const unsubscribe = audience === "marketing" && r.unsubscribeToken
  ? unsubscribeUrl(r.unsubscribeToken)
  : undefined;

And because a mail client's own unsubscribe button is what somebody annoyed
enough to press "report spam" actually uses, the marketing copies carry the
RFC 8058 headers too:

headers: unsubscribe
  ? {
      "List-Unsubscribe": `<${appUrl()}/api/email/unsubscribe?token=${r.unsubscribeToken}>, <mailto:${REPLY_TO}?subject=unsubscribe>`,
      "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
    }
  : undefined,

The account audience is the interesting one. Its query joins the preferences
table and selects the token, and then throws it away:

.map((r) => ({ email: r.email, name: r.name, unsubscribeToken: null }))

A service email with an unsubscribe link would be offering something we cannot
deliver.

Why the browser holds the cursor

A single action call that walked the whole audience would outlive its
invocation, and a timeout halfway through leaves the worst possible state:
some people emailed, and nobody able to say which. So the unit of work is a
page:

export type SendResult =
  | { error: string }
  | { mode: "test" | "direct"; message: string }
  | { mode: "bulk"; sendId: string; sent: number; failed: number; nextCursor: string | null; done: boolean; error?: string };

The first call opens a row in notice_sends, and that row is the record a
later call adds to, so a half-finished send looks like a half-finished send
rather than like nothing at all. Its schema comment is the whole argument for
its existence:

Without it a send that is re-run after a browser refresh looks exactly like a
first send; with it the panel can say "you sent this 20 minutes ago to 412
people" before anyone clicks again.

Keyset, not offset

Pages are keyed, never numbered:

// Account pages are keyed on the user ID, marketing pages on the address,
// which is that table's primary key.
.where(cursor ? gt(authUsers.id, cursor) : undefined)
.orderBy(asc(authUsers.id))
.limit(limit)

A send runs across many requests, over minutes, while accounts are being
created and deleted underneath it. offset 300 in that world silently skips
people when a row above the window disappears, and sends twice when one
appears. A cursor on a unique, ordered column cannot do either: it says
"everything after this id", and whatever has happened elsewhere in the table,
that sentence stays true.

The two audiences use different keys because they are different tables, and
each uses that table's own primary key rather than a timestamp. Two accounts
created in the same millisecond are a thing that happens.

The line I thought hardest about

// The cursor advances past the page that was READ, not the page that was
// sent. A failed batch is reported so the operator decides what to do;
// retrying inside the loop is how one bad address becomes a hundred
// duplicates.

When a batch fails, the failure count and the error go onto the send row, the
loop is told the next cursor anyway, and the operator sees the error in the
panel. The instinct is to retry the batch, and the instinct is wrong: a batch
failure does not tell you that nothing in it was delivered. Re-sending
"probably nobody got it" to 100 people who did get it is a worse outcome than
100 people not getting an outage notice, and the operator is sitting right
there and can decide.

The guards around it

Three small ones, each from imagining the mistake first.

A typed confirmation. The action refuses outright without it:

if (!input.confirm) return { error: "A send to a whole audience needs the typed confirmation." };

The sender is a key, never an address.

function senderFor(key: string | undefined): string {
  const allowed: SenderKey[] = ["notices", "hello", "support"];
  return allowed.includes(key as SenderKey) ? SENDERS[key as SenderKey] : SENDERS.notices;
}

Nothing posted from a browser can nominate who an email comes from.

The test is the real email. Test mode builds the footer a recipient would
get, including an unsubscribe link with an obviously fake token:

unsubscribeUrl: input.audience === "marketing" ? `${appUrl()}/unsubscribe?token=example` : undefined,

Which is where writing this post found a bug. Hold that thought.

What the unsubscribe page does with a token

A link whose token matches nothing says so, in the page's own words. You can
see it with any well-formed token that was never issued:

$ curl -s -o /dev/null -w '%{http_code}\n' \
  'https://nakodo.app/unsubscribe?token=00000000-0000-0000-0000-000000000000'
200

That link has expired. We couldn't match it to an address, which usually
means the account it belonged to is gone. If you're still getting email you
don't want, reply to it and we'll sort it out.

Try it with no token at all and you get the
same page. Now try it with a token that is not a UUID:

$ curl -s -o /dev/null -w '%{http_code}\n' 'https://nakodo.app/unsubscribe?token=abc'
500
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  'https://nakodo.app/api/email/unsubscribe?token=abc'
500

The token column is a uuid, and Postgres raises on a malformed one rather
than returning no rows, so the lookup throws before the "that link has
expired" branch can run. Three facts stack up badly here:

  1. Real links carry real UUIDs, so this never fires in normal use.
  2. The second URL is the RFC 8058 one-click endpoint, which a mail client posts to on its own. A link rewriter that truncates the token turns a working unsubscribe into a 500, and the person stays subscribed and annoyed.
  3. The fake token in test mode is literally example, so the operator's own test email contains the one link shape that breaks.

The fix is the guard that already exists elsewhere in the codebase for exactly
this Postgres behaviour: test the shape before you query. It is one line in
front of one lookup, and it is going in before the next notice goes out.

Two more things writing this up exposed

The audit row is only written when the send finishes:

if (page.nextCursor === null) {
  await audit({ action: "admin.notice_sent", ... });
}

So a send abandoned halfway leaves a notice_sends row with a recipient count
and no audit entry at all. The operator panel can see it; the audit log, which
is the thing designed to answer "what was done here", cannot.

And the cursor lives only in the browser. notice_sends records the audience,
the subject, who sent it, how many went, how many failed and the last error.
There is no column for where the send had got to. Close the tab at batch four
and the only safe options are to stop or to start again from the top, which
means the people in batches one to four get it twice. A cursor column would
fix it, and the reason it has not been added is that every send so far has been
small enough to watch finish. That is the kind of reason that stops being true
exactly once.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.