Skip to content
Build with Fungi

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”
TERMINAL
npm install @fungi.computer/carrier @fungi.computer/watchdog

Configure 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.

TS
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.

TS
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.

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.

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.

  • 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.