The job model

A handler enqueues; it never sends

Every email becomes a pithy_email_jobs row first. The actual send always runs inside a Workflow — a durable job like any other in the kit.

The payload is validated against the template’s schema at enqueue, so a bad call fails while the caller is still there to hear about it — not half way through a send, days later, in a Workflow nobody is watching.

Nine statuses

StatusMeans
pendingImmediate, awaiting dispatch
scheduledA future sendAt
sendingIn flight
sentSucceeded
failedExhausted its retries
suppressedSkipped — the address is on the list
bouncedReported undeliverable
undispatchedThis composition binds no send Workflow
canceledWithdrawn before sending

undispatched is a configuration fact, not a transient one. It is what a project sees before pithy email provision has stood the host up. Nothing was started and nothing is coming while that holds — and the scheduler claims those rows as soon as a host Worker exists. A status that says your wiring is incomplete is worth more than a pending row that quietly never moves.

Two columns for the recipient, and both are needed

toAddress keeps what the caller typed. An operator diagnosing a send needs to see the string that was actually addressed.

recipientKey is that address normalized, and it is the only column anything matches a recipient on. Ada@example.com and ada@example.com are one mailbox, and matching on the typed form would make them two.

The payload is spent when the message goes out

The row keeps the validated template variables as JSON, so the send Workflow can re-render without the caller present.

For a transactional template, those variables are emptied on delivery, and payloadRedactedAt records that it happened. A magic-link token, an invitation code, a receipt’s line items — none of that needs to outlive the send, and a send log holding them is a send log worth stealing.

The emptied value is written through the same codec as every other write rather than as a literal "{}". A column the schema could not read back would turn a delivered job into a row that throws when an operator opens it.

Category and kind are two different questions

They look like the same field and they are not.

category — what the message is. transactional or marketing. It drives tracking defaults, and it makes an unsubscribe link mandatory: a marketing template cannot render at all without one.

kind — whether the recipient may refuse it. transactional or elective.

The gap between them is real. A testing-program nudge is transactional in style and elective in consent — and it is declared exactly that way.

The kind decides how the suppression list is consulted. An unsubscribe blocks elective mail only; a hard bounce or a complaint blocks everything. Which is what stops somebody who unsubscribed from a newsletter from also losing their sign-in links.

Suppression is checked at enqueue as well as at send

The send is and stays the authority — whether an address is blocked is a question about the instant of sending, and a scheduled job is enqueued days before that.

The enqueue-time check exists so the caller learns. A blocked recipient never becomes a queued send: the row is born suppressed, no Workflow starts, and the reason comes back on the result.

Without it, a three-person account whose addresses have all hard-bounced is three ordinary skips in a send log nobody reads — instead of one notice, at the moment it went out, that it reached nobody.

The kind comes from the template, never from the caller, using the same accessor the send path uses.

What retries, and what does not

Retryable:

  • email/rate_limited — the next window is a different answer.
  • email/send_failed, but only when the provider’s code was judged transient — a delivery failure, an upstream 5xx, or one bounded retry of an unrecognized code.
  • A transient D1 fault on the jobs, events or suppression tables.

Terminal:

  • core/not_found — the job row is gone, and a send cannot invent the message it was asked to send.
  • email/template_not_found and email/invalid_payload — a render that will not produce a different result on a second attempt.
  • Anything unclassified.

A suppressed recipient is neither. It is recorded and the send returns: somebody who asked not to be mailed is an outcome, not a failure.

The batch id, and the one rule around it

batchId names the send Workflow instance that is coming for this row, and nothing else. Null means none is. It is not a history of which batch touched the row last.

If you move a job into scheduled, pending or sending, you own that column in the same statement. Three places do today: an immediate enqueue, an operator retrying a failure, and the scheduler claiming a row.

This is the whole basis of the scheduler’s veto — it leaves a stale-looking row alone when the runtime says the instance it names is alive. A row naming the wrong instance breaks that in one of two directions: a dead instance while a live one works the row means two Workflows render and one person gets two copies; a live instance that is not working the row means a genuinely stranded job waits for an unrelated batch to finish.

Each dispatcher mints the id before the write that makes the row queryable, and creates the instance afterwards. So the worst an interrupted dispatch leaves behind is a row naming an instance that does not exist — which the runtime disowns, which reads as dead, which is recovered. The failure mode of this design is a duplicate render the send path short-circuits, never a duplicate send.

ESC