@ultimat3/mail 23.0.0 → 25.0.0

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/CLAUDE.md CHANGED
@@ -14,8 +14,21 @@
14
14
  | `driver.ts` | `MailDriver` + memory/log/unconfigured + `resultFor` + the `setMailDriver` seam |
15
15
  | `driver-env.ts` | `selectMailDriver`: which transport an environment installs, and nothing else |
16
16
  | `header-safety.ts` | `assertHeaderSafe`: the CR/LF gate on a `MailMessage`, so every driver refuses the same one |
17
- | `driver-smtp.ts` | `createSmtpDriver`: `SMTP_URL` parsing, the pool ceiling, one send |
18
- | `driver-resend.ts` | `createResendDriver`: one `POST /emails`, status → retryable |
17
+ | `driver-smtp.ts` | `smtpMailDriver`: `SMTP_URL` parsing, the pool ceiling, one send |
18
+ | `driver-resend.ts` | `resendMailDriver`: one `POST /emails`, status → retryable |
19
+ | `driver-ses.ts` | `sesMailDriver`: one SES v2 `SendEmail`, raw MIME, signed by core's `signAwsRequest` |
20
+ | `ses-failure.ts` | SES error TYPE → retry verdict + `fix:`; the status is the fallback |
21
+ | `retain-mime.ts` | `retainMime`: the exact MIME a transport handed its provider, capped, + the `onRetained` sink |
22
+ | `delivery-event.ts` | `DeliveryEvent` / `DeliveryOutcome`, the capped body read, the ISO instant |
23
+ | `delivery-event-errors.ts` | the three receiver refusals (`X_MAIL_EVENT_*`) |
24
+ | `sns-signature.ts` | SNS message verification: topic allow-list, pinned cert URL, canonical string, RSA v1/v2 |
25
+ | `sns-certificate-cache.ts` | the downloaded SNS certificates, bounded against forgery floods |
26
+ | `x509-spki.ts` | the public key out of a PEM certificate (Web Crypto imports no certificate) |
27
+ | `ses-event-receiver.ts` | `sesEventReceiver`: body → verify → normalise; SubscriptionConfirmation |
28
+ | `delivery-event-ses.ts` | SES notification JSON (`notificationType` or `eventType`) → events |
29
+ | `resend-signature.ts` | Svix verification: HMAC over `id.timestamp.body`, constant-time, then the window |
30
+ | `resend-event-receiver.ts` | `resendEventReceiver` + the Resend payload → events |
31
+ | `events.ts` | the `@ultimat3/mail/events` subpath: the receivers, kept off the barrel |
19
32
  | `smtp-client.ts` | the conversation: greeting → EHLO → STARTTLS → AUTH → envelope → DATA |
20
33
  | `smtp-protocol.ts` | pure protocol: reply framing, capabilities, AUTH payloads, dot-stuffing |
21
34
  | `smtp-socket.ts` | the one production `SmtpStream`, over `Bun.connect` |
@@ -25,7 +38,7 @@
25
38
  | `transform.ts` | `setMailTransform`: the app's outbound hook, run once per `send()` before the key |
26
39
  | `idempotency.ts` | `mailIdempotencyKey` — apart from `job.ts` because the transports need it too |
27
40
  | `catalog.ts` | English source strings for `mail.*`. Data, not code |
28
- | `html.ts` | escaping + `safeUrl`. The only place that builds an attribute |
41
+ | `html.ts` | escaping + `safeMailHref`. The only place that builds an attribute |
29
42
 
30
43
  ## Rules
31
44
 
@@ -35,6 +48,20 @@
35
48
  result is re-checked by `assertHeaderSafe`; a throw or a malformed result is
36
49
  `X_MAIL_TRANSFORM_FAILED` and nothing is sent. None installed ⇒ the same object, byte-identical.
37
50
 
51
+ - **`send()` enqueues through `jobsFacade().enqueue(sendMailJob, message)`, never `jobDriver().enqueue`**
52
+ (`As of 2026-10-02`). The driver call published on the pool, autocommitted, so a handler that
53
+ rolled back after `send()` still mailed — and under `{ retry }` each re-run could mail again.
54
+ Through the facade an ambient transaction STAGES the row and the relay publishes it after COMMIT.
55
+ `send-outbox.job.test.ts` proves a rollback delivers nothing, on the memory pair and on Postgres.
56
+ - **One recipient rule, inline and queued** (`As of 2026-10-02`). `addressRefusal`
57
+ (`envelope-address.ts`) is `envelopeAddress`'s rule without the throw; `renderMessage` applies it
58
+ to `to`/`cc`/`bcc`/`replyTo` after the header gate, and `mailMessageSchema` refines with it. It was
59
+ `t.email` on the queue and nothing inline, so `Jane Doe <jane@x.test>` answered `queued: true` and
60
+ the worker's parse refused the row. `unsubscribeUrl` is `t.url` on both paths for the same reason.
61
+ - `X_MAIL_HEADER_INVALID` carries `meta.reason` (`line-break` | `non-ascii`) with a cause per
62
+ reason; the non-ASCII mailbox refusal was raised with the line-break cause until 2026-10-02.
63
+ `Reply-To` takes the `address` mode in `mime.ts` (encoded phrase, refused non-ASCII mailbox), and
64
+ an ASCII phrase holding an RFC 5322 special other than `.` is quoted (`"Doe, Jane"`).
38
65
  - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a `defineMail` file
39
66
  imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on
40
67
  every access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
@@ -45,7 +72,7 @@
45
72
  one `prefers-color-scheme` block keyed on short `data-x` role codes.
46
73
  - Never format a date without `options.tz`. The `Date:` header is UTC, stated as `+0000`.
47
74
  - **`timeoutMs` is refused at CONSTRUCTION, where `poolSize` already is, and its floor is 1**
48
- (`As of 2026-08-26`). `finiteCount('createSmtpDriver'|'createResendDriver', 'timeoutMs', …, 1)`.
75
+ (`As of 2026-08-26`). `finiteCount('smtpMailDriver'|'resendMailDriver', 'timeoutMs', …, 1)`.
49
76
  Zero is not "no deadline" in either driver: the value goes straight to `AbortSignal.timeout` and
50
77
  to `setTimeout(fn, timeoutMs)`, both of which fire on the next tick, so `0` and `NaN` are the same
51
78
  failure — `setTimeout(fn, NaN)` IS `setTimeout(fn, 0)`. Resend's was the worse half: the signal is
@@ -54,10 +81,10 @@
54
81
  endpoint, and the queue retried a config error to the dead-letter table. `bun run finite-bounds`
55
82
  is the ratchet; the repair is recognised by the shape of the call.
56
83
 
57
- - **Every "now" in this package comes from a `Clock`, `createMemoryDriver` included** (`As of
84
+ - **Every "now" in this package comes from a `Clock`, `memoryMailDriver` included** (`As of
58
85
  2026-08-22`). It stamped `at: new Date()`, and `SentMail.at` is what `outbox()`, `lastTo()` and
59
86
  the `/_x` panel ORDER on — so the one fact a test most needs to state was the one it could only
60
- observe, and two sends inside one millisecond tied. `createMemoryDriver({ clock })`, the same
87
+ observe, and two sends inside one millisecond tied. `memoryMailDriver({ clock })`, the same
61
88
  options shape `@ultimat3/jobs`' identically-named driver takes; omitted, it is `systemClock`.
62
89
  - New block kind: `MailBlock` + `blocks` + `htmlOf` + `textOf`, same commit.
63
90
  - A transport failure is `sendFailed({ stage, status, retryable, fix })` — never a bare throw, and
@@ -82,7 +109,7 @@
82
109
  - **No credential is answered by the ENVIRONMENT, and outside development it REFUSES** (`As of
83
110
  2026-08`). It answered the memory driver everywhere, including production — so a deploy that
84
111
  configured no transport reported `accepted` for mail that never left the process, with no error
85
- anywhere. `createUnconfiguredDriver` rejects every send with `X_MAIL_CREDENTIAL_MISSING` instead.
112
+ anywhere. `unconfiguredMailDriver` rejects every send with `X_MAIL_CREDENTIAL_MISSING` instead.
86
113
  Three parts of that are decisions, not details. It is a **driver and not a boot refusal**, so an
87
114
  app that sends no mail still deploys and one that does fails on the path that needed the
88
115
  capability. `staging` refuses too, because staging exists to fail the way production fails —
@@ -163,11 +190,49 @@
163
190
  data reaches `Subject`, and a break there injects headers. Refuse it, never strip it.
164
191
  - Every ENVELOPE address is checked the same way and separately (`X_MAIL_ADDRESS_INVALID`,
165
192
  `envelope-address.ts`, called by `smtpDeliver`). Two wire formats, one gate each: `bcc` is not a
166
- header, so the header gate never sees it, and on the inline send path no schema does either — a
167
- `bcc` of `ops@x.test\r\nRCPT TO:<evil@y.test>` relayed mail over the app's own authenticated
168
- connection. The refused set is control characters plus `<` and `>`; a space is deliberately
169
- allowed (quoted local-parts). Non-ASCII in the MAILBOX is refused too, and by this package:
170
- SMTPUTF8 (RFC 6531) is what makes a UTF-8 addr-spec legal and `smtp-client.ts` negotiates none.
193
+ header, so the header gate never sees it, and on the inline send path no schema did either until
194
+ `renderMessage` took `addressRefusal` (2026-10-02) — a `bcc` of
195
+ `ops@x.test\r\nRCPT TO:<evil@y.test>` relayed mail over the app's own authenticated connection.
196
+ The refused set is control characters plus `<` and `>`; a space is deliberately allowed (quoted
197
+ local-parts). Non-ASCII in the MAILBOX is refused too, and by this package: SMTPUTF8 (RFC 6531)
198
+ is what makes a UTF-8 addr-spec legal and `smtp-client.ts` negotiates none.
199
+
200
+ - **SES maps the error TYPE, not the status** (`As of 2026-10-06`, `ses-failure.ts`). `MessageRejected`,
201
+ `SendingPausedException` and a bad request are all 400: only throttling (`TooManyRequestsException`,
202
+ `ThrottlingException`, `Throttling`, `LimitExceededException`) is retryable; rejected, paused /
203
+ suspended, unverified MAIL FROM, every auth failure and a missing configuration set are terminal.
204
+ An unknown type falls back to core's `isRetryableStatus`. All of it is `X_MAIL_SEND_FAILED`
205
+ (`driver: 'ses'`) — no SES-specific code. SES has NO idempotency header and rewrites
206
+ `Message-ID`, so a retry after a timeout SES had accepted is a second email: SMTP's gap, pinned
207
+ as such. `Destination` is always sent, because `Bcc` is never a header.
208
+ - **`retainMime` is the bytes this process handed the provider, never a truncation** (`As of
209
+ 2026-10-06`). SMTP and SES only — Resend builds MIME on its side, so `selectMailDriver(env,
210
+ { retainMime })` REFUSES it for Resend rather than keeping nothing. Default cap 256 KiB, ceiling
211
+ 10 MiB; over the cap `kind: 'digest-only'` keeps sha256 + length and drops the bytes. It rides on
212
+ `SendResult.mime` (the inline caller) AND goes to `onRetained` (the durable half: a queued send's
213
+ result is the job's return value, which the queue does not persist). A throwing sink is logged,
214
+ never a send failure — the mail already left, and failing would have the job send it again.
215
+ - **The receivers live behind `@ultimat3/mail/events` (`src/events.ts`), never `src/index.ts`**
216
+ (`As of 2026-10-06`). Every serving role evaluates the barrel through serve-graph, and only a
217
+ webhook route needs SNS/Svix verification; the barrel keeps the `DeliveryEvent` types, type-only.
218
+ - **A delivery notification is verified BEFORE it is parsed, and every refusal is coded.**
219
+ `X_MAIL_EVENT_UNVERIFIED` (401, `meta.reason`), `X_MAIL_EVENT_INVALID` (400, `meta.problem`),
220
+ `X_MAIL_EVENT_PROVIDER_UNREACHABLE` (503, so SNS redelivers). SNS order: envelope shape → topic
221
+ in `topicArns` (any AWS account can make SNS sign for ITS topic, so the allow-list is the
222
+ authentication, and it runs before any fetch) → cert URL exactly `https://sns.<topic region>.amazonaws.com/…pem`
223
+ → `SignatureVersion` 1 (SHA-1) or 2 (SHA-256) → signature → the clock. Svix: headers → mac
224
+ (`timingSafeEqual`, every `v1,` entry, no early exit) → window. "stale" therefore always means
225
+ authentic. A SubscriptionConfirmation's URL is returned and fetched only with
226
+ `confirmSubscriptions: true`, pinned the same way, never following a redirect.
227
+ - **A pinned certificate URL is reached BEFORE any signature, so its download is bounded**
228
+ (`As of 2026-10-06`, `sns-certificate-cache.ts`). An allowed topic ARN is no secret, so a forger
229
+ can name a fresh `SimpleNotificationService-<32 hex>.pem` per request (the only path accepted,
230
+ as AWS publishes it). LRU of 8 verified certificates (a hit refreshes, so the one in use is never
231
+ evicted by a flood); a failed URL is answered from memory for 5 min; at most 2 downloads in
232
+ flight, beyond that `X_MAIL_EVENT_PROVIDER_UNREACHABLE` (503); identical lookups share one promise.
233
+ - The SNS test key and its X.509 certificate are generated per run (`delivery-event-fixture.ts`);
234
+ no private key is committed. The DER was checked once against openssl (`openssl verify`, and
235
+ `openssl x509 -pubkey` gave the SPKI `x509-spki.ts` extracts).
171
236
 
172
237
  ## Commands
173
238
 
package/README.md CHANGED
@@ -20,8 +20,10 @@ export const receiptMail = defineMail({
20
20
  await send(receiptMail, { name: user.name, url }, { to: user.email, locale: ctx.locale });
21
21
  ```
22
22
 
23
- `send` validates `data` through the mail's schema, renders, then enqueues `mail.send`. It
24
- delivers inline only when `{ sync: true }` is passed or no job driver is configured.
23
+ `send` validates `data` through the mail's schema, renders, then enqueues `mail.send` through the
24
+ jobs facade — inside a transaction the job is STAGED on it, so a handler that rolls back after
25
+ sending mails nobody. It delivers inline only when `{ sync: true }` is passed or no job driver is
26
+ configured.
25
27
 
26
28
  ### The outbound transform
27
29
 
@@ -58,6 +60,8 @@ setMailTransform(async (rendered, meta) =>
58
60
  | Every colour is a token | `MAIL_TOKENS` in `layout.ts` holds light + dark hexes; templates never see a hex |
59
61
  | Every date takes an IANA zone | `options.tz`, else `ctx.tz`, else `UTC` |
60
62
  | No CR/LF in a header-bound field | checked in `renderMessage` and again in `sendMailJob`, so every driver refuses the same message (`X_MAIL_HEADER_INVALID`). `mime.ts` keeps its own gate for the headers the SMTP transport mints itself |
63
+ | One recipient rule on both paths | `to`, `cc`, `bcc` and `replyTo` are checked in `renderMessage` by the rule the SMTP envelope applies (`addressRefusal`), and the queue schema asks the same — so a display-form `Jane Doe <jane@x.test>` is accepted everywhere, and a control character or a non-ASCII mailbox is `X_MAIL_ADDRESS_INVALID` at the `send()` call site on every driver, never `queued: true` and then a worker's parse failure. `unsubscribeUrl` is `t.url` on both paths for the same reason |
64
+ | Address headers carry 7-bit phrases | `From`, `To`, `Cc` and `Reply-To` encode a non-ASCII display name as an RFC 2047 word and quote an ASCII one holding a special (`Doe, Jane` → `"Doe, Jane"`), so the comma cannot split the list. `List-Unsubscribe` carries the url as the WHATWG serialiser writes it — punycode host, percent-encoded path and query |
61
65
  | `unsubscribeUrl` is one-click, and one-click is the supported path | it emits `List-Unsubscribe: <url>` plus `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058), and the footer links the SAME url. Make that url one page that answers both: `GET` renders a confirm button and never unsubscribes (scanners prefetch), and `POST` — the mail client's one-click, or the button's own form — runs the action: `defineRoute({ render: 'ssr', …, post: 'unsubscribe' })` (`@ultimat3/render`) binds it, with the url's query (`?t=<token>`) merged into the input. `unsubscribeOneClick: false` drops the `-Post` line for a url that still cannot take a POST; it is the fallback, not the path |
62
66
  | Sending is a job | `retry: { attempts: 5, backoff: 'exponential' }`, idempotency key `mail:<mailId>:<hash(recipients + rendered)>` — 128 bits, ASCII, under Resend's 256-character limit at any recipient count — or `(mailId, your key)` when you pass one (digested if it is not a short ASCII token) — a caller's key is scoped to its mail so two templates cannot dedupe each other away |
63
67
 
@@ -68,11 +72,12 @@ setMailTransform(async (rendered, meta) =>
68
72
 
69
73
  | Driver | Use | Behaviour |
70
74
  |---|---|---|
71
- | `createMemoryDriver({ clock? })` | dev, tests | retains messages; `outbox()` / `lastTo()` feed the `/_x` mail panel, ordered by `SentMail.at` — pass a `frozenClock()` to choose it |
72
- | `createLogDriver()` | workers without credentials | one structured line per message through core's `logger`; bodies never logged |
73
- | `createUnconfiguredDriver(env)` | a deploy that configured no transport | refuses every send with `X_MAIL_CREDENTIAL_MISSING`; delivers nothing and claims nothing |
74
- | `createSmtpDriver({ url, from })` | prod | real ESMTP over `Bun.connect`: STARTTLS, `AUTH PLAIN`/`LOGIN`, quoted-printable MIME |
75
- | `createResendDriver({ apiKey, from })` | prod | one `POST /emails`, `Idempotency-Key` on every request |
75
+ | `memoryMailDriver({ clock? })` | dev, tests | retains messages; `outbox()` / `lastTo()` feed the `/_x` mail panel, ordered by `SentMail.at` — pass a `frozenClock()` to choose it |
76
+ | `logMailDriver()` | workers without credentials | one structured line per message through core's `logger`; bodies never logged |
77
+ | `unconfiguredMailDriver(env)` | a deploy that configured no transport | refuses every send with `X_MAIL_CREDENTIAL_MISSING`; delivers nothing and claims nothing |
78
+ | `smtpMailDriver({ url, from })` | prod | real ESMTP over `Bun.connect`: STARTTLS, `AUTH PLAIN`/`LOGIN`, quoted-printable MIME |
79
+ | `resendMailDriver({ apiKey, from })` | prod | one `POST /emails`, `Idempotency-Key` on every request |
80
+ | `sesMailDriver({ region, credentials, from })` | prod | one SES v2 `SendEmail` with the raw MIME, SigV4-signed, no SDK |
76
81
 
77
82
  ### Which one a boot installs
78
83
 
@@ -81,10 +86,11 @@ the app changes between environments; the credential does.
81
86
 
82
87
  | env | driver |
83
88
  |---|---|
84
- | *(nothing set)*, `development` / `test` | `createMemoryDriver()` — caught, never sent |
85
- | *(nothing set)*, `staging` / `production` | `createUnconfiguredDriver(...)` — every send is `X_MAIL_CREDENTIAL_MISSING` |
86
- | `SMTP_URL` + `MAIL_FROM` | `createSmtpDriver(...)`, `MAIL_POOL_SIZE` optional |
87
- | `RESEND_API_KEY` + `MAIL_FROM` | `createResendDriver(...)` |
89
+ | *(nothing set)*, `development` / `test` | `memoryMailDriver()` — caught, never sent |
90
+ | *(nothing set)*, `staging` / `production` | `unconfiguredMailDriver(...)` — every send is `X_MAIL_CREDENTIAL_MISSING` |
91
+ | `SMTP_URL` + `MAIL_FROM` | `smtpMailDriver(...)`, `MAIL_POOL_SIZE` optional |
92
+ | `RESEND_API_KEY` + `MAIL_FROM` | `resendMailDriver(...)` |
93
+ | `SES_REGION` + `SES_ACCESS_KEY_ID` + `SES_SECRET_ACCESS_KEY` + `MAIL_FROM` | `sesMailDriver(...)`; `SES_SESSION_TOKEN`, `SES_ENDPOINT`, `SES_CONFIGURATION_SET` optional |
88
94
 
89
95
  **No credential outside development is a refusal, not the embedded default.** The memory driver
90
96
  there answered `accepted` for mail that never left the process — password resets, receipts and
@@ -121,6 +127,68 @@ mailIdempotencyKey(message)` — a job retry after a timeout hands Resend the id
121
127
  that header is what makes it one email. 408/409/425/429 and 5xx are retryable (`isRetryableStatus`, `@ultimat3/core`); every other non-2xx
122
128
  is a configuration problem that retrying cannot fix.
123
129
 
130
+ ### SES
131
+
132
+ `POST https://email.<region>.amazonaws.com/v2/email/outbound-emails` with `Content.Raw` — the
133
+ MIME `mime.ts` builds — and the envelope in `Destination` (`Bcc` included, never a header).
134
+
135
+ | SES says | `error.retry` |
136
+ |---|---|
137
+ | `TooManyRequestsException`, `ThrottlingException`, `LimitExceededException` | `retryable` |
138
+ | `MessageRejected`, `SendingPausedException`, `AccountSuspendedException`, `MailFromDomainNotVerifiedException`, any auth failure, `NotFoundException` | `terminal` |
139
+ | anything else | core's `isRetryableStatus(status)` |
140
+
141
+ All `X_MAIL_SEND_FAILED`, each with its own `aws sesv2 …` fix. SES has no idempotency header: a
142
+ retry after a timeout SES had already accepted is a second email, as over SMTP.
143
+
144
+ ### `retainMime`
145
+
146
+ ```ts
147
+ import { sesMailDriver } from '@ultimat3/mail';
148
+
149
+ declare const env: Record<string, string>;
150
+ declare function saveAuditRow(row: unknown): Promise<void>;
151
+
152
+ export const driver = sesMailDriver({
153
+ region: 'eu-west-1',
154
+ credentials: { accessKeyId: env['SES_ACCESS_KEY_ID'] ?? '', secretAccessKey: env['SES_SECRET_ACCESS_KEY'] ?? '' },
155
+ from: 'Postly <no-reply@postly.test>',
156
+ retainMime: { maxBytes: 262_144, onRetained: (entry) => saveAuditRow(entry) },
157
+ });
158
+ ```
159
+
160
+ SMTP and SES (the transports that build the MIME). The exact bytes handed to the provider, on
161
+ `SendResult.mime` and to `onRetained` — the durable half, because a queued send's result is not
162
+ persisted. Cap: default 256 KiB, at most 10 MiB; **over it only `{ kind: 'digest-only', sha256,
163
+ byteLength }` is kept, never a truncated message.** A throwing `onRetained` is logged
164
+ (`mail.retain_mime.failed`) and does not fail the send. `selectMailDriver(env, { retainMime })`
165
+ refuses it for Resend, which builds the MIME on its own side, and judges the cap BEFORE it picks a
166
+ transport — a cap over the ceiling is `X_CONFIG_INVALID` on every branch, memory included.
167
+ `retainMimeKey` names where the option was written in that refusal (`mail.retainMime` from an
168
+ `app.config.ts`); default `retainMime`.
169
+
170
+ ### Delivery events
171
+
172
+ Imported from **`@ultimat3/mail/events`**, never the barrel: only a webhook route needs them, and every serving role loads `@ultimat3/mail` to send. One receiver per provider, its `receive(request)` mounted as a plain HTTP route through the `routes` runtime override (`apps/<app>/runtime.ts`) — not an `api/**/route.ts`, which cannot register (Known gaps, awaiting owner decision 1); each verifies, then normalises to
173
+ `DeliveryEvent` — `delivered` · `bounced` (`bounce: 'hard' | 'soft'`) · `complained` · `delayed`,
174
+ with `messageId` (= the sending driver's `SendResult.id`), `recipient`, `at`, `eventId` and `raw`.
175
+ One event per recipient; `(eventId, recipient)` is the dedupe key.
176
+
177
+ ```ts
178
+ import { resendEventReceiver, sesEventReceiver } from '@ultimat3/mail/events';
179
+
180
+ declare const env: Record<string, string>;
181
+
182
+ export const ses = sesEventReceiver({ topicArns: [env['SES_EVENTS_TOPIC_ARN'] ?? ''] });
183
+ export const resend = resendEventReceiver({ secret: env['RESEND_WEBHOOK_SECRET'] ?? '' });
184
+ // const outcome = await ses.receive(request); // { type: 'events' | 'ignored' | 'subscription', … }
185
+ ```
186
+
187
+ | Receiver | Proves |
188
+ |---|---|
189
+ | `sesEventReceiver` | SNS: the topic is in `topicArns`, the cert URL is `https://sns.<topic region>.amazonaws.com/…pem`, `SignatureVersion` 1 (RSA-SHA1) or 2 (RSA-SHA256) over AWS's canonical string, `Timestamp` within `toleranceMs` (1 h). A `SubscriptionConfirmation` returns its `confirmUrl`; it is fetched only with `confirmSubscriptions: true` |
190
+ | `resendEventReceiver` | Svix: HMAC-SHA256 over `svix-id.svix-timestamp.body` under the `whsec_` secret, constant-time, any `v1,` entry, within `toleranceMs` (5 min) |
191
+
124
192
  ## Framework mails
125
193
 
126
194
  Registered by importing them — the import IS the registration. `FRAMEWORK_MAILS` is the list, and
@@ -146,11 +214,14 @@ Translating them = shipping `mail.*` keys in an app catalog. Never edit a templa
146
214
  | `X_MAIL_TEMPLATE_UNKNOWN` | export a `defineMail({ id })` and import it (also raised for an unregistered layout) |
147
215
  | `X_MAIL_DUPLICATE` | rename one of two `defineMail({ id })` declarations |
148
216
  | `X_MAIL_TEXT_MISSING` | add a text-bearing block to the template |
149
- | `X_MAIL_DRIVER_UNAVAILABLE` | `setMailDriver(createMemoryDriver())` at boot — a wiring bug |
217
+ | `X_MAIL_DRIVER_UNAVAILABLE` | `setMailDriver(memoryMailDriver())` at boot — a wiring bug |
150
218
  | `X_MAIL_CREDENTIAL_MISSING` | set `SMTP_URL` (or `RESEND_API_KEY`) and `MAIL_FROM` in the deployment — an operations one |
151
219
  | `X_MAIL_HEADER_INVALID` | strip CR/LF from the interpolated value before it reaches a header |
152
- | `X_MAIL_ADDRESS_INVALID` | pass a bare `addr-spec` — an envelope address may hold no control character and no `<`/`>` |
220
+ | `X_MAIL_ADDRESS_INVALID` | a recipient may hold no control character, its mailbox no `<`/`>` and no non-ASCII byte (`meta.reason`); `Jane Doe <jane@x.test>` is fine |
153
221
  | `X_MAIL_TRANSFORM_FAILED` | the `setMailTransform` hook threw or returned no `{ subject, html, text }` — fix the hook; the mail was not sent |
222
+ | `X_MAIL_EVENT_UNVERIFIED` | a delivery notification failed its signature check — `meta.reason` names which (`unsigned`, `signature`, `stale`, `topic`, `certificate-url`, `signature-version`); answer 401 |
223
+ | `X_MAIL_EVENT_INVALID` | authentic or not, the body is not the provider's notification shape (`meta.problem`); answer 400 |
224
+ | `X_MAIL_EVENT_PROVIDER_UNREACHABLE` | the SNS certificate or confirm URL did not answer; answer 503 so SNS redelivers |
154
225
  | `X_MAIL_SEND_FAILED` | the `cause` names the stage, the provider's status and whether a retry can help — and so does `error.retry`, which is what `sendMailJob` acts on: `terminal` dead-letters a 550 or a rejected credential at attempt 1 instead of sending it four more times |
155
226
 
156
227
  ### Error classes
@@ -160,7 +231,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
160
231
 
161
232
  | Class | Code | Declared in |
162
233
  |---|---|---|
163
- | `MailError` | any `MailErrorCode` — `MAIL_ERROR_CODES` | `src/errors.ts` |
234
+ | `MailError` | any `MailErrorCode` — `MAIL_ERROR_CODES` | `src/errors.ts` (the `X_MAIL_EVENT_*` factories export from `@ultimat3/mail/events`) |
164
235
 
165
236
  ## Commands
166
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mail",
3
- "version": "23.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "Transactional email as data: one template renders HTML and text, sent through a job.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,8 @@
14
14
  "provenance": true
15
15
  },
16
16
  "exports": {
17
- ".": "./src/index.ts"
17
+ ".": "./src/index.ts",
18
+ "./events": "./src/events.ts"
18
19
  },
19
20
  "files": [
20
21
  "src",
@@ -25,17 +26,17 @@
25
26
  "LICENSE"
26
27
  ],
27
28
  "engines": {
28
- "bun": ">=1.4.0"
29
+ "bun": ">=1.4.2"
29
30
  },
30
31
  "scripts": {
31
32
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
33
  "test": "bun test"
33
34
  },
34
35
  "dependencies": {
35
- "@ultimat3/core": "23.0.0",
36
- "@ultimat3/i18n": "23.0.0",
37
- "@ultimat3/jobs": "23.0.0",
38
- "@ultimat3/schema": "23.0.0",
39
- "@ultimat3/time": "23.0.0"
36
+ "@ultimat3/core": "25.0.0",
37
+ "@ultimat3/i18n": "25.0.0",
38
+ "@ultimat3/jobs": "25.0.0",
39
+ "@ultimat3/schema": "25.0.0",
40
+ "@ultimat3/time": "25.0.0"
40
41
  }
41
42
  }
package/src/catalog.ts CHANGED
@@ -3,14 +3,8 @@
3
3
  // are registered after this one and therefore win). Translating a framework mail means
4
4
  // shipping `mail.*` in an app catalog — never editing a template.
5
5
 
6
- import {
7
- type Catalog,
8
- DEFAULT_LOCALE,
9
- type Locale,
10
- loadCatalog,
11
- type NestedCatalog,
12
- registerBaseCatalog,
13
- } from '@ultimat3/i18n';
6
+ import { DEFAULT_LOCALE } from '@ultimat3/core';
7
+ import { type Catalog, loadCatalog, type NestedCatalog, registerBaseCatalog } from '@ultimat3/i18n';
14
8
 
15
9
  export const MAIL_CATALOG_SOURCE: NestedCatalog = {
16
10
  mail: {
@@ -82,9 +76,6 @@ export const MAIL_CATALOG_SOURCE: NestedCatalog = {
82
76
 
83
77
  export const MAIL_CATALOG: Catalog = loadCatalog(MAIL_CATALOG_SOURCE);
84
78
 
85
- /** The one locale these strings are written in. */
86
- export const MAIL_CATALOG_LOCALE: Locale = DEFAULT_LOCALE;
87
-
88
79
  // At module scope, and that is the whole point: importing `@ultimat3/mail` is what installs these
89
80
  // strings, so no app and no boot can forget them. `registerMailCatalog()` was the alternative and
90
81
  // it had **zero** production callers — only this package's own tests — so every `mail.*` string in
@@ -99,4 +90,4 @@ export const MAIL_CATALOG_LOCALE: Locale = DEFAULT_LOCALE;
99
90
  // Under the ONE locale these strings are written in. Seating English under `es` made `isMiss` read
100
91
  // FALSE for a subject nobody had translated — a fallback locale chain wearing registration as a
101
92
  // disguise. A non-`en` app translates the `mail.*` keys it renders into its own catalog.
102
- registerBaseCatalog(MAIL_CATALOG_LOCALE, MAIL_CATALOG);
93
+ registerBaseCatalog(DEFAULT_LOCALE, MAIL_CATALOG);
@@ -0,0 +1,148 @@
1
+ // Single responsibility: the refusals a delivery-notification receiver answers with. Three codes,
2
+ // one per audience: an unauthenticated sender (401), a signed payload this package cannot read
3
+ // (400), and a provider endpoint the check itself needed that did not answer (503, so SNS retries).
4
+ // No cause or fix here ever carries a signature, a secret, a body or a sender-chosen value.
5
+
6
+ import { MailError } from './errors';
7
+
8
+ export type DeliveryProvider = 'ses' | 'resend';
9
+
10
+ /**
11
+ * Why a notification was refused as unauthenticated. One code and a `meta.reason`, like
12
+ * `X_MAIL_HEADER_INVALID`: the receiver's answer is the same 401 for each, and the operator reads
13
+ * the reason to learn whether to fix a secret, a topic list or a clock.
14
+ */
15
+ export type DeliveryRefusal =
16
+ | 'unsigned'
17
+ | 'signature'
18
+ | 'stale'
19
+ | 'topic'
20
+ | 'certificate-url'
21
+ | 'signature-version';
22
+
23
+ const CAUSES: Readonly<Record<DeliveryRefusal, string>> = {
24
+ unsigned: 'carries no signature, so nothing proves who sent it',
25
+ signature: 'carries a signature that does not verify over the bytes that arrived',
26
+ stale:
27
+ 'is signed correctly and its timestamp is outside the tolerance window — a replay or a skewed clock',
28
+ topic:
29
+ 'names an SNS topic this receiver was not configured with — any AWS account can sign for its own topic',
30
+ 'certificate-url':
31
+ 'names a signing certificate that is not https://sns.<region>.amazonaws.com for its topic’s region',
32
+ 'signature-version': 'declares a SignatureVersion other than 1 or 2',
33
+ };
34
+
35
+ /** The three refusals a Svix-signed sender can earn; the other three are SNS envelope rules. */
36
+ export type ResendRefusal = 'unsigned' | 'signature' | 'stale';
37
+
38
+ const RESEND_FIXES: ReadonlyMap<DeliveryRefusal, string> = new Map<DeliveryRefusal, string>([
39
+ [
40
+ 'unsigned',
41
+ "curl -sS https://api.resend.com/webhooks -H 'Authorization: Bearer <RESEND_API_KEY>' — point the Resend webhook at this route; Resend signs every delivery with svix-* headers",
42
+ ],
43
+ [
44
+ 'signature',
45
+ "resendEventReceiver({ secret: env.RESEND_WEBHOOK_SECRET }) — with this endpoint's whsec_ signing secret from https://resend.com/webhooks",
46
+ ],
47
+ [
48
+ 'stale',
49
+ 'resendEventReceiver({ secret, toleranceMs: 600_000 }) — or sync this host clock: timedatectl set-ntp true',
50
+ ],
51
+ ]);
52
+
53
+ const SES_FIXES: ReadonlyMap<DeliveryRefusal, string> = new Map<DeliveryRefusal, string>([
54
+ [
55
+ 'unsigned',
56
+ 'aws sns subscribe --protocol https --topic-arn <your topic arn> --notification-endpoint <this url> — SNS signs every message it sends',
57
+ ],
58
+ [
59
+ 'signature',
60
+ 'aws sns list-subscriptions-by-topic --topic-arn <your topic arn> — and let nothing between SNS and this route re-serialise the JSON body',
61
+ ],
62
+ [
63
+ 'stale',
64
+ 'sesEventReceiver({ topicArns, toleranceMs: 7_200_000 }) — or sync this host clock: timedatectl set-ntp true',
65
+ ],
66
+ [
67
+ 'topic',
68
+ 'aws sns list-topics — then, only if the topic is yours, sesEventReceiver({ topicArns: [<your topic arn>] })',
69
+ ],
70
+ [
71
+ 'certificate-url',
72
+ 'aws sns list-topics — nothing to change if you did not send it: SNS signs only from https://sns.<region>.amazonaws.com',
73
+ ],
74
+ [
75
+ 'signature-version',
76
+ 'aws sns set-topic-attributes --topic-arn <your topic arn> --attribute-name SignatureVersion --attribute-value 2',
77
+ ],
78
+ ]);
79
+
80
+ export function deliveryEventUnverified(provider: 'resend', reason: ResendRefusal): MailError;
81
+ export function deliveryEventUnverified(provider: 'ses', reason: DeliveryRefusal): MailError;
82
+ export function deliveryEventUnverified(
83
+ provider: DeliveryProvider,
84
+ reason: DeliveryRefusal,
85
+ ): MailError {
86
+ // The overloads keep an SNS-only reason off a Resend refusal, so the fallback is never reached.
87
+ // Maps, never object lookups: `reason` is a closed union today, and a table that is not an
88
+ // object cannot answer `Object.prototype` for a key nobody declared.
89
+ const table = provider === 'resend' ? RESEND_FIXES : SES_FIXES;
90
+ const fix = table.get(reason) ?? 'aws sns list-topics — no fix is declared for this reason';
91
+ return new MailError({
92
+ code: 'X_MAIL_EVENT_UNVERIFIED',
93
+ cause: `a ${provider} delivery notification ${CAUSES[reason]}`,
94
+ fix,
95
+ meta: { provider, reason },
96
+ });
97
+ }
98
+
99
+ /** What could not be read. A closed vocabulary, so a cause never echoes the sender's bytes. */
100
+ export type DeliveryShapeProblem =
101
+ | 'too-large'
102
+ | 'not-utf8'
103
+ | 'not-json'
104
+ | 'envelope'
105
+ | 'notification'
106
+ | 'timestamp';
107
+
108
+ const SHAPE_CAUSES: Readonly<Record<DeliveryShapeProblem, string>> = {
109
+ 'too-large': 'is larger than the receiver’s maxBytes',
110
+ 'not-utf8': 'is not UTF-8 text',
111
+ 'not-json': 'is not JSON',
112
+ envelope: 'is missing a field its envelope requires',
113
+ notification: 'is missing a field its event type requires',
114
+ timestamp: 'carries a timestamp that is not an ISO-8601 instant',
115
+ };
116
+
117
+ export const deliveryEventInvalid = (
118
+ provider: DeliveryProvider,
119
+ problem: DeliveryShapeProblem,
120
+ ): MailError =>
121
+ new MailError({
122
+ code: 'X_MAIL_EVENT_INVALID',
123
+ cause: `a ${provider} delivery notification ${SHAPE_CAUSES[problem]}`,
124
+ fix:
125
+ problem === 'too-large'
126
+ ? provider === 'ses'
127
+ ? 'sesEventReceiver({ topicArns, maxBytes: 2_097_152 }) — only if SNS really sends more'
128
+ : 'resendEventReceiver({ secret, maxBytes: 2_097_152 }) — only if Resend really sends more'
129
+ : provider === 'ses'
130
+ ? 'aws sns list-subscriptions-by-topic --topic-arn <your topic arn> — only SNS should be posting to this route'
131
+ : "curl -sS https://api.resend.com/webhooks -H 'Authorization: Bearer <RESEND_API_KEY>' — only Resend should be posting to this route",
132
+ meta: { provider, problem },
133
+ });
134
+
135
+ /** The SNS signing certificate (or a subscription confirm) did not answer — transient, so 503. */
136
+ export const deliveryProviderUnreachable = (
137
+ what: 'certificate' | 'subscription',
138
+ detail: string,
139
+ ): MailError =>
140
+ new MailError({
141
+ code: 'X_MAIL_EVENT_PROVIDER_UNREACHABLE',
142
+ cause: `the SNS ${what === 'certificate' ? 'signing certificate' : 'subscription confirmation'} request failed: ${detail}`,
143
+ fix:
144
+ what === 'certificate'
145
+ ? 'curl -sS -m 5 -o /dev/null https://sns.us-east-1.amazonaws.com/ — checks egress; SNS redelivers a message this route answered 5xx'
146
+ : 'aws sns confirm-subscription --topic-arn <your topic arn> --token <the Token from the confirmation>',
147
+ meta: { provider: 'ses', what },
148
+ });
@@ -0,0 +1,101 @@
1
+ // Single responsibility: an Amazon SES notification — the JSON inside a VERIFIED SNS message —
2
+ // becomes `DeliveryEvent`s. Both SES spellings are read: identity notifications
3
+ // (`notificationType`) and configuration-set event publishing (`eventType`). Verification and the
4
+ // HTTP side are `ses-event-receiver.ts`; this file trusts its input and only translates it.
5
+
6
+ import { t, validate } from '@ultimat3/schema';
7
+ import {
8
+ type DeliveryEvent,
9
+ type DeliveryOutcome,
10
+ deliveryInstant,
11
+ parseDeliveryJson,
12
+ } from './delivery-event';
13
+ import { deliveryEventInvalid } from './delivery-event-errors';
14
+
15
+ const recipient = t.object({ emailAddress: t.string });
16
+
17
+ /** Per event type: the sub-object SES nests it in, its recipient list, and the kind it becomes. */
18
+ const SES_TYPES = {
19
+ Bounce: t.object({
20
+ bounce: t.object({
21
+ bounceType: t.string,
22
+ bouncedRecipients: t.array(recipient),
23
+ timestamp: t.string,
24
+ }),
25
+ }),
26
+ Complaint: t.object({
27
+ complaint: t.object({ complainedRecipients: t.array(recipient), timestamp: t.string }),
28
+ }),
29
+ Delivery: t.object({
30
+ delivery: t.object({ recipients: t.array(t.string), timestamp: t.string }),
31
+ }),
32
+ DeliveryDelay: t.object({
33
+ deliveryDelay: t.object({ delayedRecipients: t.array(recipient), timestamp: t.string }),
34
+ }),
35
+ } as const;
36
+
37
+ const notificationHead = t.object({
38
+ notificationType: t.string.optional(),
39
+ eventType: t.string.optional(),
40
+ mail: t.object({ messageId: t.string }).optional(),
41
+ });
42
+
43
+ type Row = { readonly recipient: string; readonly at: string };
44
+
45
+ /** SES's notification JSON (the SNS `Message`) → events; a type this does not model is ignored. */
46
+ export function sesDeliveryEvents(message: string, eventId: string): DeliveryOutcome {
47
+ const raw = parseDeliveryJson('ses', message);
48
+ const head = validate(notificationHead, raw);
49
+ if (head.issues !== undefined) throw deliveryEventInvalid('ses', 'notification');
50
+ const eventType = head.value.notificationType ?? head.value.eventType ?? '';
51
+ const messageId = head.value.mail?.messageId;
52
+ const base = (row: Row) => ({
53
+ provider: 'ses' as const,
54
+ messageId: messageId ?? '',
55
+ recipient: row.recipient,
56
+ at: deliveryInstant('ses', row.at),
57
+ eventId,
58
+ raw,
59
+ });
60
+ const read = <S extends (typeof SES_TYPES)[keyof typeof SES_TYPES]>(schema: S) => {
61
+ const parsed = validate(schema, raw);
62
+ if (parsed.issues !== undefined || messageId === undefined) {
63
+ throw deliveryEventInvalid('ses', 'notification');
64
+ }
65
+ return parsed.value;
66
+ };
67
+
68
+ let events: DeliveryEvent[];
69
+ if (eventType === 'Bounce') {
70
+ const { bounce } = read(SES_TYPES.Bounce);
71
+ // `Undetermined` is soft: suppressing an address on a verdict SES itself could not reach
72
+ // loses a real recipient, and a hard bounce, if it is one, arrives as `Permanent` next time.
73
+ const kind = bounce.bounceType === 'Permanent' ? 'hard' : 'soft';
74
+ events = bounce.bouncedRecipients.map((r) => ({
75
+ ...base({ recipient: r.emailAddress, at: bounce.timestamp }),
76
+ kind: 'bounced',
77
+ bounce: kind,
78
+ }));
79
+ } else if (eventType === 'Complaint') {
80
+ const { complaint } = read(SES_TYPES.Complaint);
81
+ events = complaint.complainedRecipients.map((r) => ({
82
+ ...base({ recipient: r.emailAddress, at: complaint.timestamp }),
83
+ kind: 'complained',
84
+ }));
85
+ } else if (eventType === 'Delivery') {
86
+ const { delivery } = read(SES_TYPES.Delivery);
87
+ events = delivery.recipients.map((address) => ({
88
+ ...base({ recipient: address, at: delivery.timestamp }),
89
+ kind: 'delivered',
90
+ }));
91
+ } else if (eventType === 'DeliveryDelay') {
92
+ const { deliveryDelay } = read(SES_TYPES.DeliveryDelay);
93
+ events = deliveryDelay.delayedRecipients.map((r) => ({
94
+ ...base({ recipient: r.emailAddress, at: deliveryDelay.timestamp }),
95
+ kind: 'delayed',
96
+ }));
97
+ } else {
98
+ return { type: 'ignored', eventType: eventType === '' ? 'unknown' : eventType };
99
+ }
100
+ return { type: 'events', events };
101
+ }