@fungi.computer/carrier

中文文档
0.2.4发布于 2026/11/08MITTypeScriptES Module近 30 天下载 1万

Durable email submission and recipient delivery tracking

原始使用文档
文档目录14 节
  1. Install or set up
  2. Quick start
  3. Core concepts
  4. Operator inspection
  5. Post
  6. Post transactions on D1 and PostgreSQL
  7. API reference
  8. Examples and checks
  9. Cloudflare Durable Object
  10. Another provider
  11. Operator inspection
  12. Checks
  13. Limits and errors
  14. Contribute and license

Carrier (@fungi.computer/carrier)

Carrier delivers things durably and tells you the truth about what happened. It has two parts.

Email. You call send() with an email and a stable ID. Carrier commits it to SQLite, dispatches it through your provider, retries temporary refusals, and records what the provider actually said. A receipt never claims more than the evidence shows. If a response is lost, the email is outcome_unknown, and Carrier does not guess and send it twice.

Post. Typed, ordered, point-to-point envelopes between named Cloudflare Durable Objects. You call send inside the same transaction as your own state change, and Post delivers the event to the addressed object with retries, dead letters and idempotent receipts.

Both run on Watchdog for durable work. The Cloudflare adapter wires Email to Durable Object SQLite, alarms and the Email Service binding, so your application keeps no delivery state machine of its own.

Install or set up

npm install @fungi.computer/carrier @fungi.computer/watchdog

The Node SQLite quick start imports Watchdog’s adapter directly.

Carrier is ESM-only. It depends on @fungi.computer/watchdog, effect, pg and @cloudflare/workers-types, which npm installs for you. The portable Email owner and SQLite Post run on any synchronous SQLite handle, such as Durable Object storage or node:sqlite in Node 24. The D1 and PostgreSQL Post adapters need a migrated database.

Entrypoints:

  • @fungi.computer/carrier and @fungi.computer/carrier/email: the Email API, createCarrier and its types.
  • @fungi.computer/carrier/cloudflare: createCloudflareCarrier for a Durable Object, and cloudflareEmailTransport.
  • @fungi.computer/carrier/inspection: read-only operator inspection.
  • @fungi.computer/carrier/post: Post over Durable Object SQLite.
  • @fungi.computer/carrier/post-d1 and @fungi.computer/carrier/post-postgres: Post over shared D1 or PostgreSQL.

Quick start

This sends one email through an in-memory node:sqlite store and a fake transport. Swap the transport for a real provider and the store for your Durable Object storage when you deploy.

import { DatabaseSync } from "node:sqlite";
import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
import { createCarrier } from "@fungi.computer/carrier";

const database = new DatabaseSync(":memory:");
const owner = fromNodeSqlite(database);

const carrier = await createCarrier({
  storage: owner,
  now: Date.now,
  host: {
    // Commit the mutation together with a durable wake. A demo needs no wake.
    commit: async (mutation) => mutation(),
    requestWake: async () => {},
  },
  transport: async (email) => {
    console.log(`provider got "${email.subject}"`);
    return { kind: "provider_accepted", providerMessageId: "demo-1" };
  },
});

const receipt = await carrier.send({
  id: "welcome:user-123:v1",
  from: { email: "[email protected]", name: "Example" },
  to: ["[email protected]"],
  subject: "Welcome",
  text: "Your account is ready.",
});
console.log(receipt.status); // "queued"

await carrier.tick(); // your wake calls this
console.log((await carrier.get(receipt.id))?.status); // "provider_accepted"

database.close();

send() only accepts the email. The provider call happens on tick(), which a real host runs from its durable wake. The Cloudflare adapter does this for you from the Durable Object alarm.

In a Durable Object, ctx.storage is already the SQLite owner.

Core concepts

Emails and Messages. One Email has shared content and an envelope: from, to, cc, bcc, subject, text, html or both, and optional headers. Each recipient becomes a separate Message with its own identity and roles. The ordinary interface is send() and get().

Acceptance. send() resolves after the Email, its recipient Messages, the queued work and the wake commit together. A queued receipt means durable local acceptance. It does not mean the provider accepted the email or delivered it.

Replay. Use a stable source ID across handoff retries. A repeated ID with identical content returns the existing receipt. Changing its content, envelope, headers or expiry produces CarrierError with code: "conflict". get() returns undefined when the ID is absent.

Dispatch evidence. Carrier records dispatch intent before it invokes the provider. A successful response records provider_accepted and its providerMessageId. That is an aggregate acknowledgment. It does not prove every recipient was accepted or delivered, so recipient Messages stay unconfirmed. A lost response or interrupted dispatch becomes outcome_unknown. Carrier never sends that email again automatically, and an unchanged-ID replay does not resend it. Sending under a new ID can duplicate delivery. Work interrupted before dispatch intent recovers safely.

Statuses. A receipt's status is one of queued, dispatching, provider_accepted, outcome_unknown, rejected, refused, expired or superseded. Terminal receipts keep identities and dispatch evidence. Carrier removes their stored content.

Provider rejections and retries. The Cloudflare transport decodes the binding's documented error code. A whole-request refusal before acceptance is provider_rejected evidence with a class:

  • temporary codes (E_RATE_LIMIT_EXCEEDED, E_DAILY_LIMIT_EXCEEDED, E_INTERNAL_SERVER_ERROR) return the Email to queued. The same owner and wake dispatch it again after exponential backoff, until the retry budget is spent.
  • permanent codes (sender, recipient, validation, size and header errors) end as rejected.

The receipt's providerRejection carries the last code and class. E_DELIVERY_FAILED, undocumented codes and exceptions without a code stay outcome_unknown, because some recipients may already have been accepted.

Per-recipient cap. Each Carrier store accepts a bounded number of Emails per normalized recipient address in a rolling window. Emails that never reached the provider (refused, rejected, expired or superseded) do not count. Over the cap, send() durably records the submission as refused and never dispatches it. A replay of that ID returns the same refused receipt.

Expiring or superseded email. Ordinary email needs no expiry or validity callback. A source that owns expiring messages can supply sendBefore, an absolute Unix timestamp in milliseconds. Carrier checks it before dispatch and records expired without calling the provider once the deadline has passed. For invitations or other replaceable notifications, construction can also take canSend: async (emailId) => boolean. It must consult the source of truth: return false for a definitely obsolete message, and throw when that truth is unavailable. false records superseded. A throw leaves the email unsent for later recovery. This is a pre-dispatch check, not an atomic fence against a concurrent source change. Carrier does not create or validate authentication tokens.

Transports. Provider and storage choices are independent. A transport takes Carrier's Email and makes one provider attempt. It returns { kind: "provider_accepted", providerMessageId } only on an actual aggregate acknowledgment, { kind: "provider_rejected", code, class } only for a documented refusal of the whole request, or { kind: "outcome_unknown" } when acceptance is uncertain. Credentials and provider configuration belong in the transport, outside send(). Carrier's local submission ID does not give you provider idempotency. Each transport handles its own SDK retry behaviour and event correlation.

Hosts. createCarrier({ storage, host, transport, now }) returns send, get, tick and hasPendingWork. Its storage is Watchdog's structural SQLite owner. host.commit(mutation) must commit the mutation and a durable wake atomically. host.requestWake() must durably schedule recovery. Call tick() from that wake, and keep the wake armed while hasPendingWork() is true. Construct exactly one owner per dedicated store and runtime lifetime. A replacement can start after its predecessor has stopped. createCloudflareCarrier does all of this for a Durable Object.

Operator inspection

@fungi.computer/carrier/inspection supplies createDeliveryInspection(sql) for hosts that have already admitted an operator. It reads existing storage without opening a delivery owner, configuring a provider, creating tables, repairing jobs or changing alarms. Carrier does not authenticate the host's caller. Do not grant this capability to models or guests.

Each call scans at most 64 rows (32 by default). Continue with after: page.nextCursor until the cursor is null. Cursors and Post receipt keys use the producer's key, without the internal post: prefix. A filtered dead-letter page can be empty and still have a continuation. These are ordered scans, not snapshots across concurrent writes.

An initialized store returns { status: "available", records, nextCursor }. A store without that delivery table returns { status: "uninitialized" }. Results include dispatch state, attempts, retry time and available provider evidence or dead-letter reason. They omit Post addresses and events, email bodies, headers and recipient addresses. Delivered Post rows keep only their key and state. Email attempts counts definite provider rejections, so an accepted or ambiguous first dispatch has zero. Post attempts counts receiver invocations. Carrier's stored Post attempts is receipt-only evidence after a terminal outcome. While delivery is pending, Watchdog's attempt_count is authoritative; pending inspection reads that count from Watchdog.

Post

Watchdog applies Post's ordinary retry policy using Almanac's backoff calculation: 2 seconds initially, doubling to a 5-minute cap, with at most 20 receiver attempts. The synchronous and SQL adapters share that default policy; explicit SQL retry policies retain their own delays. A SQLite route with deadlineMs uses its finite lifetime instead of the attempt ceiling. Almanac supplies arithmetic only, not a second scheduler or retry owner.

@fungi.computer/carrier/post delivers typed, point-to-point envelopes between named Durable Objects. definePostRoute pairs each address schema and event schema with its Durable Object namespace, name function and fixed addressKind label. A SQLite-backed route may opt into a retry lifetime of at most seven days with deadlineMs; Post stores its absolute deadline at admission and settles an undelivered row as an expired dead letter when the deadline is reached. Deadline routes retry until expiry rather than using the ordinary attempt ceiling. Omitted deadlines preserve the existing retry budget. Every event route to the same namespace shares that label, so different event kinds for one recipient keep producer order. The receiver implements deliver(envelope) and calls admitPost inside its own SQLite transaction, so the event mutation and admission receipt commit together. Keys must identify the producing event across all senders to that receiver.

Construct createPost with the producer's structural SQLite owner, typed routes, Watchdog's transactional projection, a clock, and a durable scheduleWake. Post owns its tables. Call send inside the same transaction as the producer's state change and durable initial wake. It commits the envelope and Watchdog delivery work together. Each producer's events reach a named address in order. A pending predecessor holds its successor until it is delivered or dead. Replaying the same stable key keeps the existing work or its completion receipt. Keep those receipts while the source can replay.

Wire Watchdog's retryPolicy to post.retryPolicy and its executor as (job, signal, attempt) => post.execute(job, attempt, signal). Watchdog reserves the attempt and fallback deadline before receiver I/O. A retry retains the same job and envelope key; wakeState(now) reports readiness and the next deadline. Delivery recovery never borrows the Agent crash budget. A receiver must admit repeated keys idempotently after a lost response or receipt write.

latest(kind, address) reads the newest staged event or its receipt for that recipient without scanning other recipients. A member or operator command can call retry(key) inside the producer's transaction with its awaited wake. It expedites a queued retry without resetting its attempt count, or gives a dead letter new work after the current queue while keeping its prepared fact. Queued and running jobs stay with Watchdog, and a delivered receipt is never reopened.

A route can prepare a wire fact from its staged intent with prepare: { event: wireSchema, run(address, intent, signal) }. Preparation and receiver I/O share the ten-second attempt deadline. Post persists the parsed prepared envelope before sending it. Restarts and lost responses reuse that exact fact while the original intent still identifies a repeated send. A late preparation result cannot send after its attempt expires. Use onDelivered to acknowledge a domain ledger in the same producer transaction as the Post receipt. An acknowledgment failure leaves both pending for retry. Prepared facts should carry sealed grants, never raw credentials.

A definite refusal, an exhausted retry budget or a route deadline keeps a dead-letter envelope, its reason and its attempt count. deadLetters returns letters and nextCursor, including a continuation through pages that contain only receipts.

Post transactions on D1 and PostgreSQL

@fungi.computer/carrier/post-d1 and /post-postgres bind the ordinary Post address and event registry to Watchdog's database adapters. These relational adapters use the same Watchdog attempt owner and do not support route deadlines. Constructing either adapter with a route that sets deadlineMs throws PostError with reason: "relational-deadline-unsupported" before database access. Use SQLite-backed Post for deadline routes. Apply postD1Schema or postPostgresSchema alongside the matching Watchdog schema through your migration tool.

On D1, post.send(envelope) returns the envelope and job statements. Include both in the same database.batch as the producer fact. sendFromSelect accepts a trusted native SQLite ?NNN-bound SELECT yielding post_key, envelope_json, queue_name and job_id, for conditional source facts created inside that batch. PostgreSQL post.send(client, envelope) writes both rows on the producer's existing transaction client. Neither send API commits for the producer. A conflicting replay rolls the whole transaction back.

Wire the same retryPolicy and three-argument executor as native SQLite Post. The host retains its durable cron or wake and ticks Watchdog directly. Preparation, bounded receiver calls, response parsing and settlement decisions are shared with SQLite Post. Receipts suppress source replay, failed attempts retry with the same Post policy, permanent refusal or exhaustion becomes a dead letter, and earlier pending mail blocks later mail to the same address. A receiver can return { status, receipt } with JSON receipt metadata. Post persists it with terminal settlement and exposes it on the delivered receipt or dead letter. That is how an email producer can record provider acceptance while minting credentials only during delivery. These async adapters reject synchronous SQLite receipt hooks rather than run them outside a transaction.

purgeD1Post and purgePostgresPost return parameterized statements selected by trusted producer SQL yielding queue_name. Compose all of them in the same native batch or transaction as your source erasure. They remove envelopes and every Watchdog attempt, settled attempts included. PostgreSQL locks the selected envelopes first, so an overlapping retry cannot leave an orphaned job.

API reference

The generated reference is in docs/api. It has one page per entrypoint: the Email root and email, cloudflare, inspection, post, post-d1 and post-postgres.

Examples and checks

Cloudflare Durable Object

Construct one Carrier per Durable Object instance, using that object's own storage and an Email Service binding. Forward the object's alarm:

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();
  }
}

Configure a SQLite Durable Object namespace and an Email Service binding in the host application. An application with a bound MAILBOX namespace can then submit and inspect mail:

const mailbox = env.MAILBOX.getByName("team-example");
const receipt = await mailbox.send({
  id: "welcome:user-123:v1",
  from: { email: "[email protected]", name: "Example" },
  to: ["[email protected]", "[email protected]"],
  cc: ["[email protected]"],
  bcc: ["[email protected]"],
  subject: "Welcome",
  text: "Your account is ready.",
  headers: { "List-Unsubscribe": "<https://example.com/unsubscribe/token>" },
});
const current = await mailbox.get(receipt.id);

send() needs at least one recipient across To, CC and BCC. To is optional. At least one of text or html is required. Display names and visible To and CC roles pass to the provider unchanged. BCC uses the provider's native BCC field. Receipts expose all recipient addresses, BCC included, to the trusted caller, so do not forward whole receipts to recipients or other viewers. Optional provider headers are part of the immutable submission. A replay with different headers conflicts just like a replay with different content.

Another provider

To use a different provider with Durable Object SQLite and alarms, pass transport instead of email. Exactly one of the two is required:

const carrier = await createCloudflareCarrier({
  storage: ctx.storage,
  transport: sendWithConfiguredProvider,
});

Operator inspection

const inspection = createDeliveryInspection(ctx.storage.sql);
const emails = inspection.email({ limit: 32 });
const receipts = inspection.post({ view: "receipts", limit: 32 });
const dead = inspection.post({ view: "deadLetters", limit: 32 });

Checks

Unit tests exercise the native transport with controlled I/O, Post ordering, rollback, duplicate admission and recovery over local SQLite, and operator inspection. Cloudflare runtime tests exercise SQLite, alarms, eviction, interrupted dispatch and the D1 Post adapter without a live email binding. The PostgreSQL proof starts an isolated PostgreSQL 16 instance over a private Unix socket and always stops it. Install PostgreSQL 16, or set BOTANICAL_TEST_POSTGRES_ROOT to its extracted root. The D1 and PostgreSQL proofs cover producer, envelope and job rollback and commit, replay conflicts, restart, overlapping workers and fenced lease expiry. From the package directory:

pnpm test

No email is sent by these fixtures.

Limits and errors

Errors.

  • CarrierError has a code of invalid_input, conflict or unavailable. Its message never includes email bodies, tokens or raw provider errors.
  • Post throws PostError for an unknown route or a conflicting key.
  • Inspection raises DeliveryInspectionError("invalid_query") for bad bounds and the sanitized DeliveryInspectionError("unavailable") for invalid stored evidence or a storage failure. Inspection keys are limited to 256 characters and provider receipt IDs to 1,024.

Logs. Every failed, unknown or refused email delivery emits one console.error line, "Carrier delivery failed", with the submission id, class, sanitized cause, resulting status and, for provider rejections, the attempt. No content, address or provider message is logged.

Policy numbers.

  • Recipient cap: 5 accepted Emails per normalized recipient address per rolling hour, counted per Carrier store.
  • Email retries: a temporary rejection retries after 30 seconds, doubling, capped at 15 minutes, for up to 6 provider attempts. Then the Email is rejected.
  • Post retries: 2 seconds, doubling, capped at 5 minutes, over 20 receiver attempts.
  • Post attempts: preparation and receiver I/O share a ten-second deadline.

Guarantees and gaps.

  • There is no exactly-once delivery promise. outcome_unknown is final, and a new ID for the same email can duplicate it.
  • Recipient Messages stay unconfirmed. Carrier does not ingest provider delivery events or correlate them with receipts, and it does not expire terminal receipts.
  • Run exactly one owner per store at a time. Simultaneous owners of the same store are unsupported.
  • The test suite does not exercise real mail delivery, provider MIME generation or actual BCC secrecy.

Contribute and license

Carrier is MIT licensed. See LICENSE.

版本与发布记录

@fungi.computer/carrier 共发布 8 个版本,首次发布于 2026/10/15;过去 12 个月发布了 8 个版本。

版本发布日期距今
0.2.4latest2026/11/087 天前
0.2.32026/11/087 天前
0.2.22026/11/088 天前
0.2.12026/11/0412 天前
0.2.02026/10/2620 天前
0.1.02026/10/2323 天前

关于 @fungi.computer/carrier 的常见问题

@fungi.computer/carrier 是什么?

Durable email submission and recipient delivery tracking

如何安装 @fungi.computer/carrier?

在项目目录运行 npm install @fungi.computer/carrier,或使用 pnpm add / yarn add / bun add。

@fungi.computer/carrier 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@fungi.computer/carrier 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@fungi.computer/carrier 可以商用吗?

@fungi.computer/carrier 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@fungi.computer/carrier 的下载量有多少?

根据 npm 官方下载统计,@fungi.computer/carrier 在 2026/10/16 – 2026/11/14 这 30 天内被下载 10,292 次,日均约 343 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@fungi.computer/carrier 还在维护吗?

最新版本 0.2.4 发布于 2026/11/08(7 天前);至今共发布 8 个版本,其中过去 12 个月发布了 8 个;首次发布于 2026/10/15。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。