You need: email composed and provisioned.
Three modes
Sending email is where all three are defined, the job model is the row each becomes, localized mail is the other per-recipient decision, and two locales is why a reader’s language and their formatting can differ.
| Mode | Sends | You pass |
|---|---|---|
immediate | Now | Nothing |
scheduled | A fixed absolute instant | sendAt |
timezone | The recipient’s local time of day | localTime and timezone |
Timezone mode
await enqueueEmail(db, {
to: "ada@example.com",
template: "newsletter",
payload: { … },
mode: "timezone",
localTime: "10:00",
timezone: "Europe/Lisbon",
locale: "pt",
});It stores the next UTC instant at which the wall clock in Lisbon reads 10:00. If that has already passed today, the candidate advances a day.
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.
Two details that only matter when they bite:
HH:MM, validated. A malformed value is email/invalid_payload at enqueue, with the offending string in the detail.
The zone is normalized and checked. A name the platform does not recognize is refused the same way, rather than silently becoming UTC.
A resolved instant does not chase a rule change
Once stored, sendAt 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 that is almost always what you want, and it is worth knowing it is the choice being made.
Who dispatches what
An immediate job kicks the Workflow at enqueue — lowest latency.
A scheduled or timezone job waits for the every-minute scheduler, which claims it when its time comes.
Both end in the same Workflow doing the same work.
Where the timezone comes from
Not from the locale. A locale is a language; a timezone is a place, and es does not tell you which one.
Store it where you store other display preferences — a preferences table is the right home for a time zone, a date format, a 24-hour clock.
Language is the exception: it belongs on the user row, and putting it in your own table is how a magic link arrives in the wrong language with nothing failing to say so.
Pass the locale too
null means nobody chose — render the kit’s English.
A timezone send with no locale sends English at ten in the morning, which is half the job.