You need: email composed and provisioned.
It is the same call
Durable jobs is what a Workflow is here, sending email is what the call does, adding a scheduled job is the neighbouring pattern, and reading your logs is how you watch one run.
await enqueueEmail(db, { to, template, payload, locale });The send path does not care whether its caller was a request handler or another Workflow.
A durable job that needs to mail somebody enqueues exactly the way a route does.
What a Workflow does not have
No request. So no c.var — no t, no auth, no locale, no log.
Which means the locale has to be carried into the job, not resolved inside it.
// at enqueue time, in the request
await workflow.create({ params: { userId, locale: c.var.locale?.catalogLocale ?? null } });
// inside the Workflow
await enqueueEmail(db, { to, template, payload, locale: params.locale });null is the right value when nobody chose, and it means the same thing here as everywhere: render the kit’s English.
Build a logger, and bind the run
c.var.log is a request thing. In a Workflow, build one and bind the run context to it so every record carries the workflow and the instance.
Otherwise a failure is a line with no way back to which run produced it — which is exactly the situation you are in when a nightly job has failed four times this week.
Enqueue in a step
await step.do("notify-owner", async () => {
await enqueueEmail(db, { … });
});Steps are journalled, so a replay does not re-enqueue what already landed.
Derive step names deterministically. A name built from a clock or a random source makes every resume a fresh start — and a fresh start here means mailing somebody twice.
The email’s own retry is separate from yours
Enqueuing writes a row. From there the send has its own Workflow, its own retry policy and its own classification:
Retryable — rate limited, a transient delivery failure or upstream 5xx, a transient D1 fault.
Terminal — the row is gone, the template is missing, the payload will not render.
So your job does not need to retry the send. It needs to succeed at enqueuing, which is one insert.
What a caller may never do
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.
Anything that opened its own delivery path would lose the retries, the suppression list and the bounce handling in one step.
The send Worker is a separate deploy
It has no request and no access to your config, so anything it does not carry in its own bundle is stamped in as configuration.
It is built with the kit’s own email copy in every language the kit ships, so adding one of those locales costs a package upgrade and no configuration at all.
What still travels is your diff — the email/ sentences you changed, one variable per locale, and nothing if you changed none.