@fungi.computer/carrier 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -2
  3. package/dist/cloudflare.d.ts +32 -0
  4. package/dist/cloudflare.js +80 -0
  5. package/dist/email.d.ts +2 -0
  6. package/dist/email.js +2 -0
  7. package/dist/index.d.ts +21 -0
  8. package/dist/index.js +28 -0
  9. package/dist/inspection.d.ts +108 -0
  10. package/dist/inspection.js +188 -0
  11. package/dist/internal/cloudflare-send.d.ts +9 -0
  12. package/dist/internal/cloudflare-send.js +79 -0
  13. package/dist/internal/contracts.d.ts +94 -0
  14. package/dist/internal/contracts.js +7 -0
  15. package/dist/internal/owner.d.ts +24 -0
  16. package/dist/internal/owner.js +419 -0
  17. package/dist/internal/policy.d.ts +25 -0
  18. package/dist/internal/policy.js +18 -0
  19. package/dist/internal/post-contract.d.ts +27 -0
  20. package/dist/internal/post-contract.js +22 -0
  21. package/dist/internal/post-execution.d.ts +45 -0
  22. package/dist/internal/post-execution.js +73 -0
  23. package/dist/internal/post-sql.d.ts +51 -0
  24. package/dist/internal/post-sql.js +255 -0
  25. package/dist/internal/receipt-schema.d.ts +3 -0
  26. package/dist/internal/receipt-schema.js +12 -0
  27. package/dist/post-d1.d.ts +36 -0
  28. package/dist/post-d1.js +55 -0
  29. package/dist/post-outbox.d.ts +40 -0
  30. package/dist/post-outbox.js +99 -0
  31. package/dist/post-postgres.d.ts +25 -0
  32. package/dist/post-postgres.js +79 -0
  33. package/dist/post.d.ts +189 -0
  34. package/dist/post.js +436 -0
  35. package/dist/testing.d.ts +16 -0
  36. package/dist/testing.js +20 -0
  37. package/docs/api/README.md +20 -0
  38. package/docs/api/cloudflare.md +100 -0
  39. package/docs/api/email.md +87 -0
  40. package/docs/api/index.md +343 -0
  41. package/docs/api/inspection.md +281 -0
  42. package/docs/api/post-d1.md +184 -0
  43. package/docs/api/post-postgres.md +108 -0
  44. package/docs/api/post.md +620 -0
  45. package/docs/api/testing.md +46 -0
  46. package/package.json +94 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,458 @@
1
- # Temporary Holding Version
1
+ # Carrier (`@fungi.computer/carrier`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Carrier delivers things durably and tells you the truth about what happened. It
4
+ has two parts.
5
+
6
+ **Email.** You call `send()` with an email and a stable ID. Carrier commits it
7
+ to SQLite, dispatches it through your provider, retries temporary refusals, and
8
+ records what the provider actually said. A receipt never claims more than the
9
+ evidence shows. If a response is lost, the email is `outcome_unknown`, and
10
+ Carrier does not guess and send it twice.
11
+
12
+ **Post.** Typed, ordered, point-to-point envelopes between named Cloudflare
13
+ Durable Objects. You call `send` inside the same transaction as your own state
14
+ change, and Post delivers the event to the addressed object with retries, dead
15
+ letters and idempotent receipts.
16
+
17
+ Both run on [Watchdog](../watchdog/README.md) for durable work. The Cloudflare
18
+ adapter wires Email to Durable Object SQLite, alarms and the Email Service
19
+ binding, so your application keeps no delivery state machine of its own.
20
+
21
+ ## Install or set up
22
+
23
+ ```sh
24
+ npm install @fungi.computer/carrier @fungi.computer/watchdog
25
+ ```
26
+
27
+ The Node SQLite quick start imports Watchdog’s adapter directly.
28
+
29
+ Carrier is ESM-only. It depends on `@fungi.computer/watchdog`, `effect`, `pg`
30
+ and `@cloudflare/workers-types`, which npm installs for you. The portable Email
31
+ owner and SQLite Post run on any synchronous SQLite handle, such as Durable
32
+ Object storage or `node:sqlite` in Node 24. The D1 and PostgreSQL Post adapters
33
+ need a migrated database.
34
+
35
+ Entrypoints:
36
+
37
+ - `@fungi.computer/carrier` and `@fungi.computer/carrier/email`: the Email API,
38
+ `createCarrier` and its types.
39
+ - `@fungi.computer/carrier/cloudflare`: `createCloudflareCarrier` for a Durable
40
+ Object, and `cloudflareEmailTransport`.
41
+ - `@fungi.computer/carrier/inspection`: read-only operator inspection.
42
+ - `@fungi.computer/carrier/post`: Post over Durable Object SQLite.
43
+ - `@fungi.computer/carrier/post-d1` and `@fungi.computer/carrier/post-postgres`:
44
+ Post over shared D1 or PostgreSQL.
45
+
46
+ ## Quick start
47
+
48
+ This sends one email through an in-memory `node:sqlite` store and a fake
49
+ transport. Swap the transport for a real provider and the store for your Durable
50
+ Object storage when you deploy.
51
+
52
+ ```ts
53
+ import { DatabaseSync } from "node:sqlite";
54
+ import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
55
+ import { createCarrier } from "@fungi.computer/carrier";
56
+
57
+ const database = new DatabaseSync(":memory:");
58
+ const owner = fromNodeSqlite(database);
59
+
60
+ const carrier = await createCarrier({
61
+ storage: owner,
62
+ now: Date.now,
63
+ host: {
64
+ // Commit the mutation together with a durable wake. A demo needs no wake.
65
+ commit: async (mutation) => mutation(),
66
+ requestWake: async () => {},
67
+ },
68
+ transport: async (email) => {
69
+ console.log(`provider got "${email.subject}"`);
70
+ return { kind: "provider_accepted", providerMessageId: "demo-1" };
71
+ },
72
+ });
73
+
74
+ const receipt = await carrier.send({
75
+ id: "welcome:user-123:v1",
76
+ from: { email: "hello@example.com", name: "Example" },
77
+ to: ["ada@example.net"],
78
+ subject: "Welcome",
79
+ text: "Your account is ready.",
80
+ });
81
+ console.log(receipt.status); // "queued"
82
+
83
+ await carrier.tick(); // your wake calls this
84
+ console.log((await carrier.get(receipt.id))?.status); // "provider_accepted"
85
+
86
+ database.close();
87
+ ```
88
+
89
+ `send()` only accepts the email. The provider call happens on `tick()`, which a
90
+ real host runs from its durable wake. The Cloudflare adapter does this for you
91
+ from the Durable Object alarm.
92
+
93
+ In a Durable Object, `ctx.storage` is already the SQLite owner.
94
+
95
+ ### Memory outbox for tests
96
+
97
+ `createMemoryOutbox()` stores encoded string rows in process memory for tests
98
+ and quick starts. It provides `put`, `read`, key-ordered `list(limit, afterKey)`
99
+ and `delete`. Each instance is independent; it is not durable storage. BirdDog’s
100
+ [quick start](../bird-dog/README.md#quick-start) uses this helper.
101
+
102
+ ```ts
103
+ import { createMemoryOutbox } from "@fungi.computer/carrier/testing";
104
+
105
+ const outbox = createMemoryOutbox();
106
+ await outbox.put("mail:one", "encoded mail");
107
+ console.log(await outbox.list(1));
108
+ // [{ key: "mail:one", value: "encoded mail" }]
109
+ ```
110
+
111
+ ## Core concepts
112
+
113
+ **Emails and Messages.** One Email has shared content and an envelope: `from`,
114
+ `to`, `cc`, `bcc`, `subject`, `text`, `html` or both, and optional `headers`.
115
+ Each recipient becomes a separate Message with its own identity and roles. The
116
+ ordinary interface is `send()` and `get()`.
117
+
118
+ **Acceptance.** `send()` resolves after the Email, its recipient Messages, the
119
+ queued work and the wake commit together. A `queued` receipt means durable local
120
+ acceptance. It does not mean the provider accepted the email or delivered it.
121
+
122
+ **Replay.** Use a stable source ID across handoff retries. A repeated ID with
123
+ identical content returns the existing receipt. Changing its content, envelope,
124
+ headers or expiry produces `CarrierError` with `code: "conflict"`. `get()`
125
+ returns `undefined` when the ID is absent.
126
+
127
+ **Dispatch evidence.** Carrier records dispatch intent before it invokes the
128
+ provider. A successful response records `provider_accepted` and its
129
+ `providerMessageId`. That is an aggregate acknowledgment. It does not prove
130
+ every recipient was accepted or delivered, so recipient Messages stay
131
+ `unconfirmed`. A lost response or interrupted dispatch becomes
132
+ `outcome_unknown`. Carrier never sends that email again automatically, and an
133
+ unchanged-ID replay does not resend it. Sending under a new ID can duplicate
134
+ delivery. Work interrupted before dispatch intent recovers safely.
135
+
136
+ **Statuses.** A receipt's `status` is one of `queued`, `dispatching`,
137
+ `provider_accepted`, `outcome_unknown`, `rejected`, `refused`, `expired` or
138
+ `superseded`. Terminal receipts keep identities and dispatch evidence. Carrier
139
+ removes their stored content.
140
+
141
+ **Provider rejections and retries.** The Cloudflare transport decodes the
142
+ binding's documented error `code`. A whole-request refusal before acceptance is
143
+ `provider_rejected` evidence with a class:
144
+
145
+ - `temporary` codes (`E_RATE_LIMIT_EXCEEDED`, `E_DAILY_LIMIT_EXCEEDED`,
146
+ `E_INTERNAL_SERVER_ERROR`) return the Email to `queued`. The same owner and
147
+ wake dispatch it again after exponential backoff, until the retry budget is
148
+ spent.
149
+ - `permanent` codes (sender, recipient, validation, size and header errors) end
150
+ as `rejected`.
151
+
152
+ The receipt's `providerRejection` carries the last code and class.
153
+ `E_DELIVERY_FAILED`, undocumented codes and exceptions without a code stay
154
+ `outcome_unknown`, because some recipients may already have been accepted.
155
+
156
+ **Per-recipient cap.** Each Carrier store accepts a bounded number of Emails per
157
+ normalized recipient address in a rolling window. Emails that never reached the
158
+ provider (refused, rejected, expired or superseded) do not count. Over the cap,
159
+ `send()` durably records the submission as `refused` and never dispatches it. A
160
+ replay of that ID returns the same refused receipt.
161
+
162
+ **Expiring or superseded email.** Ordinary email needs no expiry or validity
163
+ callback. A source that owns expiring messages can supply `sendBefore`, an
164
+ absolute Unix timestamp in milliseconds. Carrier checks it before dispatch and
165
+ records `expired` without calling the provider once the deadline has passed. For
166
+ invitations or other replaceable notifications, construction can also take
167
+ `canSend: async (emailId) => boolean`. It must consult the source of truth:
168
+ return `false` for a definitely obsolete message, and throw when that truth is
169
+ unavailable. `false` records `superseded`. A throw leaves the email unsent for
170
+ later recovery. This is a pre-dispatch check, not an atomic fence against a
171
+ concurrent source change. Carrier does not create or validate authentication
172
+ tokens.
173
+
174
+ **Transports.** Provider and storage choices are independent. A transport takes
175
+ Carrier's `Email` and makes one provider attempt. It returns
176
+ `{ kind: "provider_accepted", providerMessageId }` only on an actual aggregate
177
+ acknowledgment, `{ kind: "provider_rejected", code, class }` only for a
178
+ documented refusal of the whole request, or `{ kind: "outcome_unknown" }` when
179
+ acceptance is uncertain. Credentials and provider configuration belong in the
180
+ transport, outside `send()`. Carrier's local submission ID does not give you
181
+ provider idempotency. Each transport handles its own SDK retry behaviour and
182
+ event correlation.
183
+
184
+ **Hosts.** `createCarrier({ storage, host, transport, now })` returns `send`,
185
+ `get`, `tick` and `hasPendingWork`. Its `storage` is Watchdog's structural
186
+ SQLite owner. `host.commit(mutation)` must commit the mutation and a durable
187
+ wake atomically. `host.requestWake()` must durably schedule recovery. Call
188
+ `tick()` from that wake, and keep the wake armed while `hasPendingWork()` is
189
+ true. Construct exactly one owner per dedicated store and runtime lifetime. A
190
+ replacement can start after its predecessor has stopped.
191
+ `createCloudflareCarrier` does all of this for a Durable Object.
192
+
193
+ ## Operator inspection
194
+
195
+ `@fungi.computer/carrier/inspection` supplies `createDeliveryInspection(sql)`
196
+ for hosts that have already admitted an operator. It reads existing storage
197
+ without opening a delivery owner, configuring a provider, creating tables,
198
+ repairing jobs or changing alarms. Carrier does not authenticate the host's
199
+ caller. Do not grant this capability to models or guests.
200
+
201
+ Each call scans at most 64 rows (32 by default). Continue with
202
+ `after: page.nextCursor` until the cursor is `null`. Cursors and Post receipt
203
+ keys use the producer's key, without the internal `post:` prefix. A filtered
204
+ dead-letter page can be empty and still have a continuation. These are ordered
205
+ scans, not snapshots across concurrent writes.
206
+
207
+ An initialized store returns `{ status: "available", records, nextCursor }`. A
208
+ store without that delivery table returns `{ status: "uninitialized" }`. Results
209
+ include dispatch state, attempts, retry time and available provider evidence or
210
+ dead-letter reason. They omit Post addresses and events, email bodies, headers
211
+ and recipient addresses. Delivered Post rows keep only their key and state.
212
+ Email `attempts` counts definite provider rejections, so an accepted or
213
+ ambiguous first dispatch has zero. Post `attempts` counts receiver invocations.
214
+
215
+ ## Post
216
+
217
+ `@fungi.computer/carrier/post` delivers typed, point-to-point envelopes between
218
+ named Durable Objects. `definePostRoute` pairs each address schema and event
219
+ schema with its Durable Object namespace, name function and fixed `addressKind`
220
+ label. Every event route to the same namespace shares that label, so different
221
+ event kinds for one recipient keep producer order. The receiver implements
222
+ `deliver(envelope)` and calls `admitPost` inside its own SQLite transaction, so
223
+ the event mutation and admission receipt commit together. Keys must identify the
224
+ producing event across all senders to that receiver.
225
+
226
+ Construct `createPost` with the producer's structural SQLite `owner`, typed
227
+ `routes`, Watchdog's `transactional` projection and `readJob` operation, a
228
+ clock, and a durable `scheduleWake`. Post owns its tables. Call `send` inside
229
+ the same transaction as the producer's state change and durable initial wake. It
230
+ commits the envelope and Watchdog delivery work together. Each producer's events
231
+ reach a named address in order. A pending predecessor holds its successor until
232
+ it is delivered or dead. Replaying the same stable key keeps the existing work
233
+ or its completion receipt. Keep those receipts while the source can replay.
234
+
235
+ Use `execute` as the Watchdog executor for Post jobs. It records an attempt and
236
+ arms recovery before calling the addressed Durable Object. After the owner ticks
237
+ Watchdog, call `enqueueDue` at a wake to inspect settled jobs and enqueue due
238
+ retries, then tick Watchdog for the newly queued work. Continue its bounded
239
+ pages with the returned cursor. Queued or running jobs stay with Watchdog's
240
+ lifecycle. A missing job or row is an error. After a lost response or failed
241
+ receipt write, a retry carries the same envelope key and the receiver reports
242
+ `duplicate`.
243
+
244
+ `latest(kind, address)` reads the newest staged event or its receipt for that
245
+ recipient without scanning other recipients. A member or operator command can
246
+ call `retry(key)` inside the producer's transaction with its awaited wake. It
247
+ expedites a settled failed attempt or puts a dead letter after current queued
248
+ work, keeping its prepared fact. Queued and running jobs stay with Watchdog, and
249
+ a delivered receipt is never reopened.
250
+
251
+ A route can prepare a wire fact from its staged intent with
252
+ `prepare: { event: wireSchema, run(address, intent, signal) }`. Preparation and
253
+ receiver I/O share the ten-second attempt deadline. Post persists the parsed
254
+ prepared envelope before sending it. Restarts and lost responses reuse that
255
+ exact fact while the original intent still identifies a repeated `send`. A late
256
+ preparation result cannot send after its attempt expires. Use `onDelivered` to
257
+ acknowledge a domain ledger in the same producer transaction as the Post
258
+ receipt. An acknowledgment failure leaves both pending for retry. Prepared facts
259
+ should carry sealed grants, never raw credentials.
260
+
261
+ A definite refusal or an exhausted retry budget keeps a dead-letter envelope,
262
+ its reason and its attempt count. `deadLetters` returns `letters` and
263
+ `nextCursor`, including a continuation through pages that contain only receipts.
264
+
265
+ The same retry and receipt machinery is exported as `makePostOutbox` for a
266
+ string-backed outbox. [BirdDog](../bird-dog/README.md) uses it for agent reply
267
+ delivery.
268
+
269
+ ## Post transactions on D1 and PostgreSQL
270
+
271
+ `@fungi.computer/carrier/post-d1` and `/post-postgres` bind the ordinary Post
272
+ address and event registry to Watchdog's database adapters. Apply `postD1Schema`
273
+ or `postPostgresSchema` alongside the matching Watchdog schema through your
274
+ migration tool.
275
+
276
+ On D1, `post.send(envelope)` returns the envelope and job statements. Include
277
+ both in the same `database.batch` as the producer fact. `sendFromSelect` accepts
278
+ a trusted native SQLite `?NNN`-bound SELECT yielding `post_key`,
279
+ `envelope_json`, `queue_name`, `next_attempt_at` and `job_id`, for conditional
280
+ source facts created inside that batch. PostgreSQL `post.send(client, envelope)`
281
+ writes both rows on the producer's existing transaction client. Neither send API
282
+ commits for the producer. A conflicting replay rolls the whole transaction back.
283
+
284
+ Wire Watchdog's executor as `(job, signal) => post.execute(job, signal)`.
285
+ `enqueueDue` recovers a bounded page of settled delivery attempts, and the host
286
+ keeps its durable cron or wake. Preparation, bounded receiver calls, response
287
+ parsing and settlement decisions are shared with SQLite Post. Receipts suppress
288
+ source replay, failed attempts retry with the same Post policy, permanent
289
+ refusal or exhaustion becomes a dead letter, and earlier pending mail blocks
290
+ later mail to the same address. A receiver can return `{ status, receipt }` with
291
+ JSON receipt metadata. Post persists it with terminal settlement and exposes it
292
+ on the delivered receipt or dead letter. That is how an email producer can
293
+ record provider acceptance while minting credentials only during delivery. These
294
+ async adapters reject synchronous SQLite receipt hooks rather than run them
295
+ outside a transaction.
296
+
297
+ `purgeD1Post` and `purgePostgresPost` return parameterized statements selected
298
+ by trusted producer SQL yielding `queue_name`. Compose all of them in the same
299
+ native batch or transaction as your source erasure. They remove envelopes and
300
+ every Watchdog attempt, settled attempts included. PostgreSQL locks the selected
301
+ envelopes first, so an overlapping retry cannot leave an orphaned job.
302
+
303
+ ## API reference
304
+
305
+ The generated reference is in [docs/api](docs/api/README.md). It has one page
306
+ per entrypoint: the Email root and `email`, `cloudflare`, `inspection`, `post`,
307
+ `post-d1` and `post-postgres`.
308
+
309
+ ## Examples and checks
310
+
311
+ ### Cloudflare Durable Object
312
+
313
+ Construct one Carrier per Durable Object instance, using that object's own
314
+ storage and an Email Service binding. Forward the object's alarm:
315
+
316
+ ```ts
317
+ import { DurableObject } from "cloudflare:workers";
318
+ import type { SendEmail } from "@cloudflare/workers-types";
319
+ import type { Submission } from "@fungi.computer/carrier";
320
+ import { createCloudflareCarrier } from "@fungi.computer/carrier/cloudflare";
321
+
322
+ type Env = { EMAIL: SendEmail };
323
+
324
+ export class Mailbox extends DurableObject<Env> {
325
+ private readonly carrier;
326
+
327
+ constructor(ctx: DurableObjectState, env: Env) {
328
+ super(ctx, env);
329
+ this.carrier = createCloudflareCarrier({
330
+ storage: ctx.storage,
331
+ email: env.EMAIL,
332
+ });
333
+ }
334
+
335
+ async send(email: Submission) {
336
+ return (await this.carrier).send(email);
337
+ }
338
+
339
+ async get(id: string) {
340
+ return (await this.carrier).get(id);
341
+ }
342
+
343
+ async alarm() {
344
+ await (await this.carrier).alarm();
345
+ }
346
+ }
347
+ ```
348
+
349
+ Configure a SQLite Durable Object namespace and an Email Service binding in the
350
+ host application. An application with a bound `MAILBOX` namespace can then
351
+ submit and inspect mail:
352
+
353
+ ```ts
354
+ const mailbox = env.MAILBOX.getByName("team-example");
355
+ const receipt = await mailbox.send({
356
+ id: "welcome:user-123:v1",
357
+ from: { email: "hello@example.com", name: "Example" },
358
+ to: ["alice@example.net", "bob@example.net"],
359
+ cc: ["host@example.net"],
360
+ bcc: ["archive@example.net"],
361
+ subject: "Welcome",
362
+ text: "Your account is ready.",
363
+ headers: { "List-Unsubscribe": "<https://example.com/unsubscribe/token>" },
364
+ });
365
+ const current = await mailbox.get(receipt.id);
366
+ ```
367
+
368
+ `send()` needs at least one recipient across To, CC and BCC. To is optional. At
369
+ least one of `text` or `html` is required. Display names and visible To and CC
370
+ roles pass to the provider unchanged. BCC uses the provider's native BCC field.
371
+ Receipts expose all recipient addresses, BCC included, to the trusted caller, so
372
+ do not forward whole receipts to recipients or other viewers. Optional provider
373
+ headers are part of the immutable submission. A replay with different headers
374
+ conflicts just like a replay with different content.
375
+
376
+ ### Another provider
377
+
378
+ To use a different provider with Durable Object SQLite and alarms, pass
379
+ `transport` instead of `email`. Exactly one of the two is required:
380
+
381
+ ```ts
382
+ const carrier = await createCloudflareCarrier({
383
+ storage: ctx.storage,
384
+ transport: sendWithConfiguredProvider,
385
+ });
386
+ ```
387
+
388
+ ### Operator inspection
389
+
390
+ ```ts
391
+ const inspection = createDeliveryInspection(ctx.storage.sql);
392
+ const emails = inspection.email({ limit: 32 });
393
+ const receipts = inspection.post({ view: "receipts", limit: 32 });
394
+ const dead = inspection.post({ view: "deadLetters", limit: 32 });
395
+ ```
396
+
397
+ ### Checks
398
+
399
+ Unit tests exercise the native transport with controlled I/O, Post ordering,
400
+ rollback, duplicate admission and recovery over local SQLite, and operator
401
+ inspection. Cloudflare runtime tests exercise SQLite, alarms, eviction,
402
+ interrupted dispatch and the D1 Post adapter without a live email binding. The
403
+ PostgreSQL proof starts an isolated PostgreSQL 16 instance over a private Unix
404
+ socket and always stops it. Install PostgreSQL 16, or set
405
+ `BOTANICAL_TEST_POSTGRES_ROOT` to its extracted root. The D1 and PostgreSQL
406
+ proofs cover producer, envelope and job rollback and commit, replay conflicts,
407
+ restart, overlapping workers and fenced lease expiry. From the package
408
+ directory:
409
+
410
+ ```sh
411
+ pnpm test
412
+ ```
413
+
414
+ No email is sent by these fixtures.
415
+
416
+ ## Limits and errors
417
+
418
+ **Errors.**
419
+
420
+ - `CarrierError` has a `code` of `invalid_input`, `conflict` or `unavailable`.
421
+ Its message never includes email bodies, tokens or raw provider errors.
422
+ - Post throws `PostError` for an unknown route or a conflicting key.
423
+ - Inspection raises `DeliveryInspectionError("invalid_query")` for bad bounds
424
+ and the sanitized `DeliveryInspectionError("unavailable")` for invalid stored
425
+ evidence or a storage failure. Inspection keys are limited to 256 characters
426
+ and provider receipt IDs to 1,024.
427
+
428
+ **Logs.** Every failed, unknown or refused email delivery emits one
429
+ `console.error` line, `"Carrier delivery failed"`, with the submission `id`,
430
+ `class`, sanitized `cause`, resulting `status` and, for provider rejections, the
431
+ `attempt`. No content, address or provider message is logged.
432
+
433
+ **Policy numbers.**
434
+
435
+ - Recipient cap: 5 accepted Emails per normalized recipient address per rolling
436
+ hour, counted per Carrier store.
437
+ - Email retries: a temporary rejection retries after 30 seconds, doubling,
438
+ capped at 15 minutes, for up to 6 provider attempts. Then the Email is
439
+ `rejected`.
440
+ - Post retries: 2 seconds, doubling, capped at 5 minutes, over 20 receiver
441
+ attempts.
442
+ - Post attempts: preparation and receiver I/O share a ten-second deadline.
443
+
444
+ **Guarantees and gaps.**
445
+
446
+ - There is no exactly-once delivery promise. `outcome_unknown` is final, and a
447
+ new ID for the same email can duplicate it.
448
+ - Recipient Messages stay `unconfirmed`. Carrier does not ingest provider
449
+ delivery events or correlate them with receipts, and it does not expire
450
+ terminal receipts.
451
+ - Run exactly one owner per store at a time. Simultaneous owners of the same
452
+ store are unsupported.
453
+ - The test suite does not exercise real mail delivery, provider MIME generation
454
+ or actual BCC secrecy.
455
+
456
+ ## Contribute and license
457
+
458
+ Carrier is MIT licensed. See [LICENSE](./LICENSE).
@@ -0,0 +1,32 @@
1
+ import type { DurableObjectStorage, SendEmail } from "@cloudflare/workers-types";
2
+ import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
3
+ import { type Carrier, type Options } from "./index.js";
4
+ /** Native SQLite and alarms; the Cloudflare adapter owns their atomic composition. */
5
+ export type CloudflareCarrierStorage = WatchdogSqliteOwner & Pick<DurableObjectStorage, "transaction" | "getAlarm" | "setAlarm" | "deleteAlarm">;
6
+ /** Native storage/wake composition with exactly one provider capability. */
7
+ export type CloudflareCarrierOptions = Readonly<{
8
+ storage: CloudflareCarrierStorage;
9
+ /** Return false only for a definitely obsolete source; throw when validity is unavailable. */
10
+ canSend?: (emailId: string) => Promise<boolean>;
11
+ }> & (Readonly<{
12
+ email: SendEmail;
13
+ transport?: never;
14
+ }> | Readonly<{
15
+ transport: Options["transport"];
16
+ email?: never;
17
+ }>);
18
+ /** Ordinary submission operations and the native Durable Object alarm door. */
19
+ export type CloudflareCarrier = Pick<Carrier, "send" | "get"> & Readonly<{
20
+ /** Forward the Durable Object's native alarm to dispatch and rearm pending work. */
21
+ alarm: () => Promise<void>;
22
+ }>;
23
+ /**
24
+ * Construct one owner per Durable Object. Forward its native alarm to `alarm`.
25
+ * Acceptance commits the Email, recipient Messages, Watchdog job and wake together.
26
+ */
27
+ export declare function createCloudflareCarrier(options: CloudflareCarrierOptions): Promise<CloudflareCarrier>;
28
+ /**
29
+ * The native binding as a Carrier transport. Exported so a host that holds the
30
+ * binding elsewhere (the Hub's Cloudflare edge) classifies sends identically.
31
+ */
32
+ export declare function cloudflareEmailTransport(binding: SendEmail): Options["transport"];
@@ -0,0 +1,80 @@
1
+ import { createCarrier } from "./index.js";
2
+ import { sendCloudflareEmail } from "./internal/cloudflare-send.js";
3
+ /**
4
+ * Construct one owner per Durable Object. Forward its native alarm to `alarm`.
5
+ * Acceptance commits the Email, recipient Messages, Watchdog job and wake together.
6
+ */
7
+ export async function createCloudflareCarrier(options) {
8
+ const storage = options.storage;
9
+ const arm = async () => {
10
+ const current = await storage.getAlarm();
11
+ const recoveryAt = Date.now() + 5_000;
12
+ await storage.setAlarm(current === null ? recoveryAt : Math.min(current, recoveryAt));
13
+ };
14
+ const configuration = {
15
+ storage,
16
+ now: Date.now,
17
+ transport: options.email === undefined
18
+ ? options.transport
19
+ : cloudflareEmailTransport(options.email),
20
+ host: {
21
+ commit: (mutation) => storage.transaction(async () => {
22
+ const result = mutation();
23
+ await arm();
24
+ return result;
25
+ }),
26
+ requestWake: arm,
27
+ },
28
+ };
29
+ const carrier = await createCarrier(options.canSend === undefined
30
+ ? configuration
31
+ : { ...configuration, canSend: options.canSend });
32
+ if (await carrier.hasPendingWork())
33
+ await arm();
34
+ let active;
35
+ const alarm = () => {
36
+ if (active !== undefined)
37
+ return active;
38
+ const operation = (async () => {
39
+ // Re-arm before external I/O so recovery survives an interrupted alarm.
40
+ await arm();
41
+ await carrier.tick();
42
+ await storage.transaction(async () => {
43
+ if (await carrier.hasPendingWork())
44
+ await arm();
45
+ else
46
+ await storage.deleteAlarm();
47
+ });
48
+ })();
49
+ active = operation;
50
+ return operation.finally(() => {
51
+ active = undefined;
52
+ });
53
+ };
54
+ return { send: carrier.send, get: carrier.get, alarm };
55
+ }
56
+ /**
57
+ * The native binding as a Carrier transport. Exported so a host that holds the
58
+ * binding elsewhere (the Hub's Cloudflare edge) classifies sends identically.
59
+ */
60
+ export function cloudflareEmailTransport(binding) {
61
+ return ({ to, cc, bcc, ...content }) => {
62
+ const envelope = {
63
+ ...content,
64
+ };
65
+ if (to !== undefined)
66
+ envelope.to = [...to];
67
+ if (cc !== undefined)
68
+ envelope.cc = [...cc];
69
+ if (bcc !== undefined)
70
+ envelope.bcc = [...bcc];
71
+ if (to !== undefined)
72
+ return sendCloudflareEmail(binding, { ...envelope, to: [...to] });
73
+ if (cc !== undefined)
74
+ return sendCloudflareEmail(binding, { ...envelope, cc: [...cc] });
75
+ if (bcc !== undefined)
76
+ return sendCloudflareEmail(binding, { ...envelope, bcc: [...bcc] });
77
+ // Submission decoding rejects an absent recipient envelope before persistence.
78
+ return Promise.resolve({ kind: "outcome_unknown" });
79
+ };
80
+ }
@@ -0,0 +1,2 @@
1
+ /** Email delivery's dedicated Carrier entry point. */
2
+ export * from "./index.js";
package/dist/email.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Email delivery's dedicated Carrier entry point. */
2
+ export * from "./index.js";
@@ -0,0 +1,21 @@
1
+ import type { Options, Receipt, Submission } from "./internal/contracts.js";
2
+ export { CarrierError } from "./internal/contracts.js";
3
+ export { CarrierStatusSchema } from "./internal/receipt-schema.js";
4
+ export type { Address, Email, Host, Message, Options, ProviderRejection, Receipt, SendEvidence, Submission, } from "./internal/contracts.js";
5
+ /** Durable email submission and its host-driven recovery door. */
6
+ export type Carrier = Readonly<{
7
+ /** Commit an Email and its recipient Messages durably; identical IDs replay the receipt. */
8
+ send: (submission: Submission) => Promise<Receipt>;
9
+ /** Read dispatch evidence, or undefined when this owner has no such submission. */
10
+ get: (id: string) => Promise<Receipt | undefined>;
11
+ /** Run one bounded dispatch/recovery step from the runtime's durable wake. */
12
+ tick: () => Promise<void>;
13
+ /** Report whether the runtime must retain a recovery wake. */
14
+ hasPendingWork: () => Promise<boolean>;
15
+ }>;
16
+ /**
17
+ * Construct exactly one owner per store/runtime lifetime. A replacement may be
18
+ * opened only after its predecessor has stopped; simultaneous owners of the
19
+ * same store are unsupported. Native adapters supply their runtime ownership.
20
+ */
21
+ export declare function createCarrier(options: Options): Promise<Carrier>;
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Cause from "effect/Cause";
3
+ import * as Exit from "effect/Exit";
4
+ import * as Option from "effect/Option";
5
+ import { openOwner } from "./internal/owner.js";
6
+ import { CarrierError } from "./internal/contracts.js";
7
+ export { CarrierError } from "./internal/contracts.js";
8
+ export { CarrierStatusSchema } from "./internal/receipt-schema.js";
9
+ const run = (operation) => Effect.runPromiseExit(operation).then((exit) => {
10
+ if (Exit.isSuccess(exit))
11
+ return exit.value;
12
+ throw Option.getOrElse(Cause.findErrorOption(exit.cause), () => new CarrierError("unavailable"));
13
+ });
14
+ /**
15
+ * Construct exactly one owner per store/runtime lifetime. A replacement may be
16
+ * opened only after its predecessor has stopped; simultaneous owners of the
17
+ * same store are unsupported. Native adapters supply their runtime ownership.
18
+ */
19
+ export async function createCarrier(options) {
20
+ const owner = openOwner(options);
21
+ await run(owner.initialize);
22
+ return {
23
+ send: (submission) => run(owner.send(submission)),
24
+ get: (id) => run(owner.get(id)),
25
+ tick: () => run(owner.tick),
26
+ hasPendingWork: () => run(owner.hasPendingWork),
27
+ };
28
+ }