@ultimat3/mail 2.0.0 → 4.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
@@ -39,8 +39,14 @@
39
39
  - Never format a date without `options.tz`. The `Date:` header is UTC, stated as `+0000`.
40
40
  - New block kind: `MailBlock` + `blocks` + `htmlOf` + `textOf`, same commit.
41
41
  - A transport failure is `sendFailed({ stage, status, retryable, fix })` — never a bare throw, and
42
- never a `retryable` guess. `stage` is the `SendStage` union in `errors.ts`; a new step goes there
43
- first. The transient set is 4xx over SMTP, and 408/409/425/429 + 5xx over HTTP that HTTP set
42
+ never a `retryable` guess. **`retryable` becomes the error's `retry` classification, and
43
+ `X_MAIL_SEND_FAILED` is REGISTERED** (`registerErrorRetry`, `As of 2026-08`)both halves, because
44
+ `classifyThrown` honours a per-instance `terminal` only for a registered code. It rode in `meta`
45
+ alone and nothing read it, so `sendMailJob` spent all five attempts on a 401 or a 550 hard bounce
46
+ while its own `cause` said retrying cannot help. `errors.test.ts` asserts it through the QUEUE's
47
+ `nextRetryForError`, never against the table.
48
+ `stage` is the `SendStage` union in `errors.ts`; a new step goes there first.
49
+ The transient set is 4xx over SMTP, and 408/409/425/429 + 5xx over HTTP — that HTTP set
44
50
  lives in `RETRYABLE_STATUSES` (`driver-resend.ts`) and is edited there, never restated.
45
51
  - A transport is selected from the environment by `selectMailDriver`, never from an `app.config.ts`
46
52
  field — nothing loads that file's contents at runtime, so a `mail:` config block would be a
package/README.md CHANGED
@@ -125,7 +125,7 @@ Translating them = shipping `mail.*` keys in an app catalog. Never edit a templa
125
125
  | `X_MAIL_CREDENTIAL_MISSING` | set `SMTP_URL` (or `RESEND_API_KEY`) and `MAIL_FROM` in the deployment — an operations one |
126
126
  | `X_MAIL_HEADER_INVALID` | strip CR/LF from the interpolated value before it reaches a header |
127
127
  | `X_MAIL_ADDRESS_INVALID` | pass a bare `addr-spec` — an envelope address may hold no control character and no `<`/`>` |
128
- | `X_MAIL_SEND_FAILED` | the `cause` names the stage, the provider's status and whether a retry can help |
128
+ | `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 |
129
129
 
130
130
  ## Commands
131
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mail",
3
- "version": "2.0.0",
3
+ "version": "4.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",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "2.0.0",
35
- "@ultimat3/i18n": "2.0.0",
36
- "@ultimat3/jobs": "2.0.0",
37
- "@ultimat3/schema": "2.0.0",
38
- "@ultimat3/time": "2.0.0"
34
+ "@ultimat3/core": "4.0.0",
35
+ "@ultimat3/i18n": "4.0.0",
36
+ "@ultimat3/jobs": "4.0.0",
37
+ "@ultimat3/schema": "4.0.0",
38
+ "@ultimat3/time": "4.0.0"
39
39
  }
40
40
  }
package/src/catalog.ts CHANGED
@@ -82,7 +82,27 @@ export const MAIL_CATALOG_SOURCE: NestedCatalog = {
82
82
 
83
83
  export const MAIL_CATALOG: Catalog = loadCatalog(MAIL_CATALOG_SOURCE);
84
84
 
85
- /** Called at boot next to `registerFrameworkCatalog()`. Idempotent. */
86
- export function registerMailCatalog(locale: Locale = DEFAULT_LOCALE): void {
87
- registerCatalog(locale, MAIL_CATALOG);
85
+ /** The one locale these strings are written in. */
86
+ export const MAIL_CATALOG_LOCALE: Locale = DEFAULT_LOCALE;
87
+
88
+ /**
89
+ * Register the mail templates' own strings under the ONE locale they are written in.
90
+ *
91
+ * **No locale parameter, and that is the fix**: this catalog is English, so `registerMailCatalog('es')`
92
+ * seated English subjects and headings under `es` where `isMiss` then read FALSE — a fallback locale
93
+ * chain wearing registration as a disguise, the same defect `registerFrameworkCatalog` carried until
94
+ * it lost its own parameter. A locale argument is now a compile error rather than a silent one.
95
+ *
96
+ * **Call it once, at boot.** This is NOT idempotent, and the comment here claimed it was:
97
+ * `registerCatalog` merges the existing entry first and its argument second, so a second call after
98
+ * an app has overridden `mail.*` keys puts the English strings back on top of exactly the keys that
99
+ * app cared enough to translate. Guarding on "has this locale a catalog" — the shape
100
+ * `registerFrameworkCatalog` uses — cannot work here, because the framework catalog has already
101
+ * claimed this locale by the time mail registers; a guard by CONTENT needs an i18n primitive that
102
+ * does not exist, and remembering the call in a module flag goes stale the moment
103
+ * `resetCatalogs()` runs (`packages/testing/src/registry-snapshot.ts`), which would silently stop
104
+ * registering altogether. Stating the limit beats inventing a guard that fails quietly.
105
+ */
106
+ export function registerMailCatalog(): void {
107
+ registerCatalog(MAIL_CATALOG_LOCALE, MAIL_CATALOG);
88
108
  }
package/src/driver.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { type Environment, nanoid, logger as rootLogger } from '@ultimat3/core';
8
8
  import { driverUnavailable, mailCredentialMissing } from './errors';
9
- import { mailIdempotencyKey } from './idempotency';
9
+ import { mailIdempotencyKey, mailMessageIdToken } from './idempotency';
10
10
 
11
11
  /** The rendered envelope. Everything a transport needs; nothing it does not. */
12
12
  export interface MailMessage {
@@ -156,6 +156,14 @@ export function isUnconfiguredDriver(driver: MailDriver): boolean {
156
156
  /**
157
157
  * Structured log line per message through core's `logger` — the default for a worker that
158
158
  * has no credentials yet. Bodies are never logged; a mail body is user data.
159
+ *
160
+ * `to` is a COUNT and the correlation handle is the message-id TOKEN, not the idempotency key —
161
+ * and the second half is the one that was wrong. `mailIdempotencyKey` is
162
+ * `mail:<id>:<recipients joined>:<digest>`, so logging it put the `to` addresses in the line in
163
+ * plaintext, one field after the count that exists to keep them out, against
164
+ * `packages/mail/CLAUDE.md`'s "Recipient addresses stay out of logs". `mailMessageIdToken` is the
165
+ * framework's existing spelling for the same key digested — it was written for exactly this
166
+ * reason and says so — and it correlates just as well, because it is stable per message.
159
167
  */
160
168
  export function createLogDriver(logger = rootLogger): MailDriver {
161
169
  return {
@@ -167,7 +175,7 @@ export function createLogDriver(logger = rootLogger): MailDriver {
167
175
  to: message.to.length,
168
176
  subject: message.subject,
169
177
  locale: message.locale,
170
- idempotencyKey: result.idempotencyKey,
178
+ idempotencyToken: mailMessageIdToken(message),
171
179
  });
172
180
  return Promise.resolve(result);
173
181
  },
package/src/errors.ts CHANGED
@@ -2,7 +2,13 @@
2
2
  // Mail fails in production, not in tests — a wrong locale, an empty text part or an
3
3
  // unconfigured driver must name the exact call site edit that repairs it.
4
4
 
5
- import { type Environment, registerErrorCodes, UltimateError } from '@ultimat3/core';
5
+ import {
6
+ type Environment,
7
+ type ErrorRetry,
8
+ registerErrorCodes,
9
+ registerErrorRetry,
10
+ UltimateError,
11
+ } from '@ultimat3/core';
6
12
 
7
13
  export const MAIL_ERROR_CODES = [
8
14
  'X_MAIL_LOCALE_MISSING',
@@ -37,11 +43,35 @@ registerErrorCodes(
37
43
  Object.fromEntries(Object.entries(MAIL_ERROR_TITLES).map(([code, title]) => [code, { title }])),
38
44
  );
39
45
 
46
+ /**
47
+ * The one code in this package a queue may retry, and it is registered rather than merely written
48
+ * down: `classifyThrown` reads a per-instance `terminal` only for a code somebody REGISTERED, so a
49
+ * table with no `registerErrorRetry` call behind it lets `sendMailJob` spend all five attempts on
50
+ * a 401 or a 550 hard bounce — the `cause` saying "permanent, retrying cannot help" while the
51
+ * worker retries it four more times.
52
+ *
53
+ * `retryable` is the table entry because a transport that cannot say is a transport that timed out;
54
+ * the permanent half arrives as the per-instance override `sendFailed` passes below, the shape
55
+ * `@ultimat3/scraping` uses for `X_SCRAPE_HTTP_FAILED`. Every other code here is a wiring or
56
+ * content fault that fails identically forever, and takes core's `terminal` default.
57
+ */
58
+ export const MAIL_ERROR_RETRY = {
59
+ X_MAIL_SEND_FAILED: 'retryable',
60
+ } as const satisfies Readonly<Partial<Record<MailErrorCode, ErrorRetry>>>;
61
+
62
+ registerErrorRetry(MAIL_ERROR_RETRY);
63
+
40
64
  export interface MailErrorInit {
41
65
  readonly code: MailErrorCode;
42
66
  readonly cause: string;
43
67
  readonly fix: string;
44
68
  readonly meta?: Readonly<Record<string, unknown>> | undefined;
69
+ /**
70
+ * Per-instance override of the table above, for the one code that is genuinely both: a 421
71
+ * greylist and a 550 hard bounce are the same code, and only the transport that saw the reply
72
+ * knows which one it was.
73
+ */
74
+ readonly retry?: ErrorRetry | undefined;
45
75
  }
46
76
 
47
77
  export class MailError extends UltimateError {
@@ -54,6 +84,7 @@ export class MailError extends UltimateError {
54
84
  fix: init.fix,
55
85
  docs: `https://ultimate.dev/errors/${init.code}`,
56
86
  meta: init.meta,
87
+ ...(init.retry === undefined ? {} : { retry: init.retry }),
57
88
  });
58
89
  }
59
90
  }
@@ -220,6 +251,10 @@ export interface SendFailure {
220
251
  export const sendFailed = (failure: SendFailure): MailError =>
221
252
  new MailError({
222
253
  code: 'X_MAIL_SEND_FAILED',
254
+ // The classification the queue acts on. `meta.retryable` below is an operator-facing FACT and
255
+ // nothing reads it to make a decision — `nextRetryForError` reads `error.retry`, so a failure
256
+ // that only reported itself in `meta` was a failure the worker retried anyway.
257
+ retry: failure.retryable ? 'retryable' : 'terminal',
223
258
  cause:
224
259
  `${failure.driver} refused the message at ${failure.stage}` +
225
260
  `${failure.status === undefined ? '' : ` (${failure.status})`}: ${failure.detail} — ` +
package/src/index.ts CHANGED
@@ -7,7 +7,12 @@ export { t } from '@ultimat3/schema';
7
7
  export type { CalloutTone, MailBlock, MailTemplate, TemplateArgs } from './blocks';
8
8
  export { blocks } from './blocks';
9
9
 
10
- export { MAIL_CATALOG, MAIL_CATALOG_SOURCE, registerMailCatalog } from './catalog';
10
+ export {
11
+ MAIL_CATALOG,
12
+ MAIL_CATALOG_LOCALE,
13
+ MAIL_CATALOG_SOURCE,
14
+ registerMailCatalog,
15
+ } from './catalog';
11
16
  export type {
12
17
  MailDriver,
13
18
  MailMessage,
@@ -49,6 +54,7 @@ export {
49
54
  layoutUnknown,
50
55
  localeMissing,
51
56
  MAIL_ERROR_CODES,
57
+ MAIL_ERROR_RETRY,
52
58
  MAIL_ERROR_TITLES,
53
59
  MailError,
54
60
  mailCredentialMissing,