Sending email

Three modes

The field that selects one, and everything else a job carries, is in the reference.

ModeWhen it sendsWhat you pass
immediateNowNothing extra
scheduledA fixed absolute instantsendAt
timezoneThe recipient’s local time-of-daylocalTime and timezone

immediate is the default.

What differs between them is who starts the Workflow

An immediate job kicks the send Workflow at enqueue — lowest latency, and the row is pending for as long as it takes the instance to pick it up.

A scheduled or timezone job is left for the every-minute scheduler. The row sits scheduled with its resolved sendAt, and a tick claims it when its time comes.

Both paths end in the same Workflow doing the same work, rendering the same template. The difference is only in who dispatched it.

Timezone mode resolves to an absolute instant at enqueue

You give it 10:00 and Europe/Lisbon. It stores the next UTC instant at which the wall clock in Lisbon reads 10:00.

The math uses Intl and nothing else. No timezone library, no IANA database bundled into the Worker: the zone’s offset is measured at a candidate instant and the candidate corrected for it. If the local time has already passed today, the candidate advances a day.

Two details that only matter when they bite:

  • HH:MM, and it is validated. A malformed localTime is email/invalid_payload at enqueue, with the offending string in the detail.
  • The zone is normalized and checked. An IANA name the platform does not recognize is refused the same way, rather than silently becoming UTC.

A resolved sendAt does not chase a rule change. Once stored, it is an instant; if a government moves a DST boundary between enqueue and send, the job goes at the instant that was computed. For mail scheduled days out this is almost always what you want, and it is worth knowing that it is the choice being made.

The scheduler is a claim, not a poll of everything

Each tick queries the rows in a status it owns — scheduled, pending, sending — and claims what is due by minting a batch id, writing it, and creating the Workflow instance under it.

A row whose named instance the runtime says is alive is left alone. That veto is what keeps a slow batch from being re-driven into a duplicate send, and it works only because the batch id means the instance coming for this row rather than the last batch that touched it.

Failures come back classified

The provider’s own error vocabulary is mapped once, and the durable step reads the result rather than re-deciding:

  • Rate limited — the next window admits it. Retry.
  • A delivery failure or an upstream 5xx — transient. Retry.
  • An unrecognized code — one bounded retry, then terminal.
  • A validation, sender or content code — terminal immediately. It will not render differently the second time.

The two classifications are held against each other by a test rather than by a comment, which matters because they are written in different files and would otherwise drift.

Sending from a Workflow

The send path does not care whether its caller was a request handler or another durable job. A durable job that needs to mail somebody enqueues exactly the way a route does.

What a caller may not do is send inline. There is no path that skips the row — which is what makes the send log complete, and what lets a retry exist at all.

ESC