Email Delivery Workflow
Email Delivery Workflow
EmailDeliveryWorkflow is a Cloudflare Workflow (WorkflowEntrypoint) that delivers transactional email with step-level checkpointing, automatic retry, and dead-lettering.
Why a Workflow instead of a plain queue consumer?
A plain queue consumer sends the email inline: if the Worker restarts mid-send, the message is retried but the in-flight HTTP call is lost — the email might be sent twice or not at all.
A Cloudflare Workflow checkpoints each named step in durable storage. If the Worker restarts between steps, the Workflow resumes from the last completed checkpoint — not from the beginning. This means:
- Step 1 (validation) never re-runs after it passes.
- Step 2 (delivery) retries only delivery, not validation.
- Step 3 (receipt) can fail without affecting the delivery status.
This is why EmailDeliveryWorkflow exists: no duplicate sends on retry, even across Worker restarts.
Step diagram
stateDiagram-v2
[*] --> Validate : Workflow created
Validate --> Deliver : payload valid
Validate --> [*] : ZodError → workflow failed (no retry)
Deliver --> WriteReceipt : delivery succeeded
Deliver --> Deliver : transient error → backoff retry
Deliver --> DeadLetter : maxRetries exceeded
WriteReceipt --> [*] : done (non-fatal if D1/KV write fails)
DeadLetter --> [*] : workflow failed
Steps
Step 1 — Validate payload
- Zod-validates the
EmailDeliveryParamsreceived from the queue or direct trigger. - Runs with
retries: 0— a malformed payload is a programming error, not a transient failure. - If validation fails, the workflow is immediately marked
failed. No retries.
Step 2 — Deliver
- Calls
createEmailService(env, { useQueue: false }).sendEmail(payload). { useQueue: false }selects the best non-queue provider (CfEmailWorkerService) — see below.- Default retry configuration:
limit:3delay:'10 seconds'backoff:'exponential'(10 s → 20 s → 40 s)
- After all retries exhausted, the workflow is marked
failedand the message enters the dead-letter queue (EMAIL_DLQ).
Step 3 — Write delivery receipt
- Writes a row to
email_log_edge(D1) and theemail_idempotency_keysD1 table, and stores a receipt object in KV (METRICS) with a 7-day TTL. - Runs with
retries: 1and is non-fatal: a failure here does not mark the workflow as failed. The email was already sent in Step 2.
Configuration
The retry configuration is defined as constants in worker/workflows/EmailDeliveryWorkflow.ts:
| Parameter | Default | Description |
|---|---|---|
limit | 3 | Number of retry attempts for Step 2 (delivery) |
delay | '10 seconds' | Initial back-off wait after first failure |
backoff | 'exponential' | Multiplier strategy applied to the interval on each retry |
Why createEmailService(env, { useQueue: false }) not createEmailService(env)?
createEmailService(env) selects QueuedEmailService as the highest-priority provider when EMAIL_QUEUE is configured. If EmailDeliveryWorkflow called createEmailService(env), it would enqueue the message back onto EMAIL_QUEUE, which would trigger another EmailDeliveryWorkflow instance — infinite recursion.
Passing { useQueue: false } skips the queue and selects CfEmailWorkerService directly, breaking the cycle.
// Inside EmailDeliveryWorkflow.run():const mailer = createEmailService(this.env, { useQueue: false });// NOT: createEmailService(this.env) ← would cause queue→workflow→queue recursionSee also
worker/workflows/EmailDeliveryWorkflow.ts— implementationworker/handlers/email-queue.ts— queue consumer that triggers the workflowdocs/cloudflare/EMAIL_SERVICE.md— provider selection and configuration