Send email durably from a Cloudflare Durable Object
Commit email to SQLite, dispatch through an Email Service binding, and handle retries without treating an uncertain response as permission to send twice.
If a request times out after your email provider accepts it, blindly retrying
can send a duplicate. Carrier records local acceptance and provider evidence
separately. You call send() with a stable ID; Carrier commits the email to
SQLite and dispatches it from a durable wake.
This is idempotent email submission in TypeScript, not an exactly-once delivery
promise. When acceptance is uncertain, Carrier records outcome_unknown and
does not resend automatically.
Connect storage, alarms and the Email Service binding
Section titled “Connect storage, alarms and the Email Service binding”npm install @fungi.computer/carrier @fungi.computer/watchdogConfigure a SQLite Durable Object namespace and an Email Service binding in your Cloudflare application. The Cloudflare adapter composes storage and alarms; you do not need a second delivery state machine.
Illustrative: the host supplies the EMAIL binding and routes authorized
submissions to this Durable Object. This class alone is not a public mail API or
a complete Worker configuration.
import { DurableObject } from "cloudflare:workers";import type { SendEmail } from "@cloudflare/workers-types";import type { Submission } from "@fungi.computer/carrier";import { createCloudflareCarrier } from "@fungi.computer/carrier/cloudflare";
type Env = { EMAIL: SendEmail };
export class Mailbox extends DurableObject<Env> { private readonly carrier;
constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); this.carrier = createCloudflareCarrier({ storage: ctx.storage, email: env.EMAIL, }); }
async send(email: Submission) { return (await this.carrier).send(email); }
async get(id: string) { return (await this.carrier).get(id); }
async alarm() { await (await this.carrier).alarm(); }}Construct exactly one Carrier owner per dedicated store and runtime lifetime. Forward the object’s alarm to the adapter. It arms recovery before external I/O and keeps a wake while work remains.
Submit with the source event’s stable ID
Section titled “Submit with the source event’s stable ID”Illustrative: an authorized host with a bound MAILBOX namespace sends a
welcome email. Replace the example addresses with configured sender and
recipient addresses.
const mailbox = env.MAILBOX.getByName("team-example");const receipt = await mailbox.send({ id: "welcome:user-123:v1", from: { email: "hello@example.com", name: "Example" }, to: ["ada@example.net"], subject: "Welcome", text: "Your account is ready.",});const current = await mailbox.get(receipt.id);Reuse that ID across retries of the same handoff. Identical content returns the
existing receipt. A changed envelope, body, headers or expiry under the same ID
throws a CarrierError with code: "conflict". A new ID is a new submission
and can duplicate an earlier email.
send() resolves after the email, recipient messages, queued work and wake
commit together. queued means durable local acceptance, not provider
acceptance. Dispatch happens later through the alarm; an immediate get() may
still show queued.
Read receipts without overstating them
Section titled “Read receipts without overstating them”| Evidence | What it establishes |
|---|---|
queued |
The local store accepted the submission and its work. |
provider_accepted |
The provider acknowledged the aggregate request, with a provider message ID. |
outcome_unknown |
Dispatch may have happened, but acceptance is uncertain. Carrier will not resend it automatically. |
rejected |
A definite refusal ended delivery, or temporary refusals exhausted the retry budget. |
refused, expired, superseded |
Carrier declined the submission or stopped it before dispatch under its policy or the host’s validity check. |
Carrier records dispatch intent before calling the provider. A lost response or
interrupted dispatch becomes outcome_unknown; replaying the unchanged ID does
not send again. Work interrupted before dispatch intent can recover safely.
A provider acknowledgment does not establish delivery to every recipient.
Recipient messages stay unconfirmed. Carrier does not ingest provider delivery
events, and terminal receipts retain evidence rather than email content.
Retry only definite temporary refusals
Section titled “Retry only definite temporary refusals”The native transport classifies documented whole-request refusal codes.
E_RATE_LIMIT_EXCEEDED, E_DAILY_LIMIT_EXCEEDED and E_INTERNAL_SERVER_ERROR
return the email to queued: retries start after 30 seconds, double up to 15
minutes, and stop after six provider attempts. Permanent sender, recipient,
validation, size and header refusals end as rejected.
E_DELIVERY_FAILED, undocumented codes and exceptions without a code remain
outcome_unknown, because some recipients may already have been accepted.
Carrier’s local ID does not provide provider-side idempotency.
The default recipient cap is five accepted emails per normalized address per rolling hour, per store. Receipts expose recipient addresses, including BCC, to the trusted caller. Do not return whole receipts to visitors.
Related delivery paths
Section titled “Related delivery paths”- Carrier documents optional expiry, source validity checks, other transports and the full receipt contract.
- Carrier’s Post API handles ordered Durable Object-to-Durable Object messaging with retries, duplicate admission receipts and dead letters.
- BirdDog is the agent-to-agent mail sibling. It uses Carrier’s outbox machinery; it is not an email transport.