Using Email

A request never sends mail

const { jobId, status, subject } = await enqueue(env, {
  template: "welcome",
  to: user.email,
  locale: user.locale,
  payload: { name: user.name },
});

It writes a row and returns. A Workflow delivers it, with retries and durable state that survive a Worker restart. Sending a transactional email is this call with the surrounding setup.

The request that asked returns in milliseconds and does not care whether the message has left.

Three ways to schedule

Immediate. A pending row, and the Workflow starts now.

Scheduled. A row with an absolute time.

Per-recipient-local. Somebody’s local time-of-day in their own timezone, resolved to an absolute time — so 9am wherever they are is one row per recipient with different absolute times, rather than one job reasoning about zones while it runs.

An every-minute scheduler finds what is due and fans it into batches sized to the volume.

What the result carries, and the trap in it

The job id, the status, and the rendered subject.

That last one is there so a caller with an administrative trail does not have to re-render it — which would mean restating your theme and the layer stack, then pinning your copy against the kit’s to notice when its wording moves.

But it is the enqueue-time render. The send renders again at the moment the message leaves and rewrites the row from that — so a scheduled job whose catalog was retranslated in between delivers the new sentence while your recorded value keeps the old one.

And the gap is not only the wait in the queue: the send Workflow is its own deploy carrying its own copy, so a send Worker whose copy has drifted from the Worker that enqueued stands until the two are back in step. An immediate send lands inside that window like any other.

Record what you queued. A trail that must reflect delivery reads the row back afterwards.

The body is deliberately not in the result. It is large, it is the thing this capability is careful never to log, and it is rendered inside the Workflow at the moment it leaves.

Sending from your own Workflow

A route reaches the enqueue function through the composition hook, and should keep doing that — it is typed and explicit.

A Workflow class has no such route. The runtime constructs it with the environment and nothing else; the function is a closure rather than a binding, and Workflow parameters are serialized, so a closure cannot travel in one either.

The wrong fix is to rebuild the send identity inside the step, which puts your sending address in a second place free to drift from your config.

The right one is an environment-only entry point, which resolves the same composed identity from what the runtime did hand you.

Templates

Seven are the kit’s own copy and are translated with it: magic link, one-time code, welcome, security alert, invite, password changed, lead capture.

Five carry your words as payload: tester nudge, support reply, operational notice, newsletter, marketing campaign.

The shell follows the job’s locale; a payload-carrying template is only as localized as your own copy. Mail in the reader’s language is where that line falls in practice. Translate your summary yourself, through your own domain, before you enqueue — the kit cannot translate a sentence it has never seen.

Transactional versus marketing is decided by the template

A transactional message renders with no unsubscribe link and no unsubscribe header, whatever context it is sent in. A marketing template forces both.

The kind is declared by the template rather than passed at the call site — so there is no context that could add an unsubscribe footer to a sign-in message, and none that could take one off a campaign.

Tracking

Links in a tracked email are signed callbacks on your own origin, at a fixed prefix — because those URLs are minted into mail nobody can recall, so they cannot move with your base path.

A click records and redirects. An open loads a pixel. An unsubscribe records.

Every token carries its key version, so a link in a months-old email still verifies after a rotation.

Suppression is automatic, and the database is yours

EventWhat it does
Hard bounceBlock. The mailbox does not exist, and hammering dead addresses damages the sending domain for every other sender on it
ComplaintBlock. Continuing after a spam report is how a domain gets blocked outright
ManualBlock. An operator’s deliberate act
UnsubscribeBlocks marketing, not sign-in. An opt-out is a statement about mail somebody chose to receive. A sign-in link is not that

That last row is the one worth understanding. Somebody who unsubscribes from your newsletter still needs to be able to log in.

The list is one per project rather than one per environment: an unsubscribe in production has to stop staging too.

A delivered job stops holding its inputs

Once a message is sent, its inputs are dropped — because sent is the one status a retry is already refused for. Retrying a delivered job is a duplicate email to a real person, so nothing can ever need those inputs again.

Everything else keeps them: a failed job is exactly what the retry route exists for and it re-renders from the payload; a suppressed or bounced one delivered nothing; a pending one has not happened yet.

Before the host exists

Mail enqueued before provisioning is delayed, not lost.

It sits in a truthful status — never a grave — and the day the host is deployed, its first tick claims those rows exactly as it claims any stranded one. A tick running at all is the host existing.

Reading the job log

Six control-plane routes — the reference names them — every one default-denied. There is no session surface here at all — a recipient’s only routes are the three callbacks.

Every call is audited, reads included. A block is silent to everyone it affects, so the trail is the only record it happened.

ESC