@ultimat3/mail 24.0.0 → 25.1.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
 
@@ -59,7 +72,7 @@
59
72
  one `prefers-color-scheme` block keyed on short `data-x` role codes.
60
73
  - Never format a date without `options.tz`. The `Date:` header is UTC, stated as `+0000`.
61
74
  - **`timeoutMs` is refused at CONSTRUCTION, where `poolSize` already is, and its floor is 1**
62
- (`As of 2026-08-26`). `finiteCount('createSmtpDriver'|'createResendDriver', 'timeoutMs', …, 1)`.
75
+ (`As of 2026-08-26`). `finiteCount('smtpMailDriver'|'resendMailDriver', 'timeoutMs', …, 1)`.
63
76
  Zero is not "no deadline" in either driver: the value goes straight to `AbortSignal.timeout` and
64
77
  to `setTimeout(fn, timeoutMs)`, both of which fire on the next tick, so `0` and `NaN` are the same
65
78
  failure — `setTimeout(fn, NaN)` IS `setTimeout(fn, 0)`. Resend's was the worse half: the signal is
@@ -68,10 +81,10 @@
68
81
  endpoint, and the queue retried a config error to the dead-letter table. `bun run finite-bounds`
69
82
  is the ratchet; the repair is recognised by the shape of the call.
70
83
 
71
- - **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
72
85
  2026-08-22`). It stamped `at: new Date()`, and `SentMail.at` is what `outbox()`, `lastTo()` and
73
86
  the `/_x` panel ORDER on — so the one fact a test most needs to state was the one it could only
74
- 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
75
88
  options shape `@ultimat3/jobs`' identically-named driver takes; omitted, it is `systemClock`.
76
89
  - New block kind: `MailBlock` + `blocks` + `htmlOf` + `textOf`, same commit.
77
90
  - A transport failure is `sendFailed({ stage, status, retryable, fix })` — never a bare throw, and
@@ -96,7 +109,7 @@
96
109
  - **No credential is answered by the ENVIRONMENT, and outside development it REFUSES** (`As of
97
110
  2026-08`). It answered the memory driver everywhere, including production — so a deploy that
98
111
  configured no transport reported `accepted` for mail that never left the process, with no error
99
- anywhere. `createUnconfiguredDriver` rejects every send with `X_MAIL_CREDENTIAL_MISSING` instead.
112
+ anywhere. `unconfiguredMailDriver` rejects every send with `X_MAIL_CREDENTIAL_MISSING` instead.
100
113
  Three parts of that are decisions, not details. It is a **driver and not a boot refusal**, so an
101
114
  app that sends no mail still deploys and one that does fails on the path that needed the
102
115
  capability. `staging` refuses too, because staging exists to fail the way production fails —
@@ -184,6 +197,43 @@
184
197
  local-parts). Non-ASCII in the MAILBOX is refused too, and by this package: SMTPUTF8 (RFC 6531)
185
198
  is what makes a UTF-8 addr-spec legal and `smtp-client.ts` negotiates none.
186
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).
236
+
187
237
  ## Commands
188
238
 
189
239
  ```bash
package/README.md CHANGED
@@ -72,11 +72,12 @@ setMailTransform(async (rendered, meta) =>
72
72
 
73
73
  | Driver | Use | Behaviour |
74
74
  |---|---|---|
75
- | `createMemoryDriver({ clock? })` | dev, tests | retains messages; `outbox()` / `lastTo()` feed the `/_x` mail panel, ordered by `SentMail.at` — pass a `frozenClock()` to choose it |
76
- | `createLogDriver()` | workers without credentials | one structured line per message through core's `logger`; bodies never logged |
77
- | `createUnconfiguredDriver(env)` | a deploy that configured no transport | refuses every send with `X_MAIL_CREDENTIAL_MISSING`; delivers nothing and claims nothing |
78
- | `createSmtpDriver({ url, from })` | prod | real ESMTP over `Bun.connect`: STARTTLS, `AUTH PLAIN`/`LOGIN`, quoted-printable MIME |
79
- | `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 |
80
81
 
81
82
  ### Which one a boot installs
82
83
 
@@ -85,10 +86,11 @@ the app changes between environments; the credential does.
85
86
 
86
87
  | env | driver |
87
88
  |---|---|
88
- | *(nothing set)*, `development` / `test` | `createMemoryDriver()` — caught, never sent |
89
- | *(nothing set)*, `staging` / `production` | `createUnconfiguredDriver(...)` — every send is `X_MAIL_CREDENTIAL_MISSING` |
90
- | `SMTP_URL` + `MAIL_FROM` | `createSmtpDriver(...)`, `MAIL_POOL_SIZE` optional |
91
- | `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 |
92
94
 
93
95
  **No credential outside development is a refusal, not the embedded default.** The memory driver
94
96
  there answered `accepted` for mail that never left the process — password resets, receipts and
@@ -125,6 +127,68 @@ mailIdempotencyKey(message)` — a job retry after a timeout hands Resend the id
125
127
  that header is what makes it one email. 408/409/425/429 and 5xx are retryable (`isRetryableStatus`, `@ultimat3/core`); every other non-2xx
126
128
  is a configuration problem that retrying cannot fix.
127
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
+
128
192
  ## Framework mails
129
193
 
130
194
  Registered by importing them — the import IS the registration. `FRAMEWORK_MAILS` is the list, and
@@ -150,11 +214,14 @@ Translating them = shipping `mail.*` keys in an app catalog. Never edit a templa
150
214
  | `X_MAIL_TEMPLATE_UNKNOWN` | export a `defineMail({ id })` and import it (also raised for an unregistered layout) |
151
215
  | `X_MAIL_DUPLICATE` | rename one of two `defineMail({ id })` declarations |
152
216
  | `X_MAIL_TEXT_MISSING` | add a text-bearing block to the template |
153
- | `X_MAIL_DRIVER_UNAVAILABLE` | `setMailDriver(createMemoryDriver())` at boot — a wiring bug |
217
+ | `X_MAIL_DRIVER_UNAVAILABLE` | `setMailDriver(memoryMailDriver())` at boot — a wiring bug |
154
218
  | `X_MAIL_CREDENTIAL_MISSING` | set `SMTP_URL` (or `RESEND_API_KEY`) and `MAIL_FROM` in the deployment — an operations one |
155
219
  | `X_MAIL_HEADER_INVALID` | strip CR/LF from the interpolated value before it reaches a header |
156
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 |
157
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 |
158
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 |
159
226
 
160
227
  ### Error classes
@@ -164,7 +231,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
164
231
 
165
232
  | Class | Code | Declared in |
166
233
  |---|---|---|
167
- | `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`) |
168
235
 
169
236
  ## Commands
170
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mail",
3
- "version": "24.0.0",
3
+ "version": "25.1.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": "24.0.0",
36
- "@ultimat3/i18n": "24.0.0",
37
- "@ultimat3/jobs": "24.0.0",
38
- "@ultimat3/schema": "24.0.0",
39
- "@ultimat3/time": "24.0.0"
36
+ "@ultimat3/core": "25.1.0",
37
+ "@ultimat3/i18n": "25.1.0",
38
+ "@ultimat3/jobs": "25.1.0",
39
+ "@ultimat3/schema": "25.1.0",
40
+ "@ultimat3/time": "25.1.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
+ }