@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.
- package/LICENSE +21 -0
- package/README.md +457 -2
- package/dist/cloudflare.d.ts +32 -0
- package/dist/cloudflare.js +80 -0
- package/dist/email.d.ts +2 -0
- package/dist/email.js +2 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +28 -0
- package/dist/inspection.d.ts +108 -0
- package/dist/inspection.js +188 -0
- package/dist/internal/cloudflare-send.d.ts +9 -0
- package/dist/internal/cloudflare-send.js +79 -0
- package/dist/internal/contracts.d.ts +94 -0
- package/dist/internal/contracts.js +7 -0
- package/dist/internal/owner.d.ts +24 -0
- package/dist/internal/owner.js +419 -0
- package/dist/internal/policy.d.ts +25 -0
- package/dist/internal/policy.js +18 -0
- package/dist/internal/post-contract.d.ts +27 -0
- package/dist/internal/post-contract.js +22 -0
- package/dist/internal/post-execution.d.ts +45 -0
- package/dist/internal/post-execution.js +73 -0
- package/dist/internal/post-sql.d.ts +51 -0
- package/dist/internal/post-sql.js +255 -0
- package/dist/internal/receipt-schema.d.ts +3 -0
- package/dist/internal/receipt-schema.js +12 -0
- package/dist/post-d1.d.ts +36 -0
- package/dist/post-d1.js +55 -0
- package/dist/post-outbox.d.ts +40 -0
- package/dist/post-outbox.js +99 -0
- package/dist/post-postgres.d.ts +25 -0
- package/dist/post-postgres.js +79 -0
- package/dist/post.d.ts +189 -0
- package/dist/post.js +436 -0
- package/dist/testing.d.ts +16 -0
- package/dist/testing.js +20 -0
- package/docs/api/README.md +20 -0
- package/docs/api/cloudflare.md +100 -0
- package/docs/api/email.md +87 -0
- package/docs/api/index.md +343 -0
- package/docs/api/inspection.md +281 -0
- package/docs/api/post-d1.md +184 -0
- package/docs/api/post-postgres.md +108 -0
- package/docs/api/post.md +620 -0
- package/docs/api/testing.md +46 -0
- 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
|
-
#
|
|
1
|
+
# Carrier (`@fungi.computer/carrier`)
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/email.d.ts
ADDED
package/dist/email.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|