@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 +8 -2
- package/README.md +1 -1
- package/package.json +6 -6
- package/src/catalog.ts +23 -3
- package/src/driver.ts +10 -2
- package/src/errors.ts +36 -1
- package/src/index.ts +7 -1
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. `
|
|
43
|
-
|
|
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/i18n": "
|
|
36
|
-
"@ultimat3/jobs": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
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
|
-
/**
|
|
86
|
-
export
|
|
87
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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,
|