@ultimat3/mail 22.3.6 → 22.5.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 +7 -0
- package/README.md +25 -0
- package/package.json +6 -6
- package/src/errors.ts +15 -0
- package/src/index.ts +3 -1
- package/src/mail.ts +4 -1
- package/src/transform.ts +108 -0
package/CLAUDE.md
CHANGED
|
@@ -22,12 +22,19 @@
|
|
|
22
22
|
| `mime.ts` | `MailMessage` → RFC 5322: header order, RFC 2047, folding, quoted-printable |
|
|
23
23
|
| `base64.ts` | base64 over UTF-8 bytes, shared by RFC 2047 and SMTP AUTH |
|
|
24
24
|
| `job.ts` | `sendMailJob` and the envelope schema |
|
|
25
|
+
| `transform.ts` | `setMailTransform`: the app's outbound hook, run once per `send()` before the key |
|
|
25
26
|
| `idempotency.ts` | `mailIdempotencyKey` — apart from `job.ts` because the transports need it too |
|
|
26
27
|
| `catalog.ts` | English source strings for `mail.*`. Data, not code |
|
|
27
28
|
| `html.ts` | escaping + `safeUrl`. The only place that builds an attribute |
|
|
28
29
|
|
|
29
30
|
## Rules
|
|
30
31
|
|
|
32
|
+
- **The transform runs in `send()` only, AFTER render and BEFORE `mailIdempotencyKey`** (`As of
|
|
33
|
+
2026-09-26`): the key, the queue row and every job retry carry the transformed bytes, and the job
|
|
34
|
+
never re-runs it. Not in `renderMessage` (previews). It rewrites `subject`/`html`/`text` only; its
|
|
35
|
+
result is re-checked by `assertHeaderSafe`; a throw or a malformed result is
|
|
36
|
+
`X_MAIL_TRANSFORM_FAILED` and nothing is sent. None installed ⇒ the same object, byte-identical.
|
|
37
|
+
|
|
31
38
|
- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a `defineMail` file
|
|
32
39
|
imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on
|
|
33
40
|
every access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
|
package/README.md
CHANGED
|
@@ -23,6 +23,30 @@ await send(receiptMail, { name: user.name, url }, { to: user.email, locale: ctx.
|
|
|
23
23
|
`send` validates `data` through the mail's schema, renders, then enqueues `mail.send`. It
|
|
24
24
|
delivers inline only when `{ sync: true }` is passed or no job driver is configured.
|
|
25
25
|
|
|
26
|
+
### The outbound transform
|
|
27
|
+
|
|
28
|
+
`setMailTransform(fn)` installs one app-level hook, run once per `send()` after render and before
|
|
29
|
+
the idempotency key is minted — so the key, the queue row and every retry carry its bytes, and a
|
|
30
|
+
job retry never re-runs it. It rewrites `{ subject, html, text }` (never recipients or headers) and
|
|
31
|
+
receives `{ mailName, to, idempotencyKey, locale }`, where `idempotencyKey` is the UNtransformed
|
|
32
|
+
key: key a tracking row on it and a re-called `send()` gets the same pixel id and dedupes. A throw
|
|
33
|
+
or a malformed result is `X_MAIL_TRANSFORM_FAILED` and nothing is sent. `setMailTransform(undefined)`
|
|
34
|
+
removes it; with none installed a send is byte-identical to before.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { type MailRendered, type MailTransformMeta, setMailTransform } from '@ultimat3/mail';
|
|
38
|
+
|
|
39
|
+
declare const TRACKED: ReadonlySet<string>;
|
|
40
|
+
declare function addPixelAndWrapLinks(
|
|
41
|
+
rendered: MailRendered,
|
|
42
|
+
meta: MailTransformMeta,
|
|
43
|
+
): Promise<MailRendered>;
|
|
44
|
+
|
|
45
|
+
setMailTransform(async (rendered, meta) =>
|
|
46
|
+
TRACKED.has(meta.mailName) ? await addPixelAndWrapLinks(rendered, meta) : rendered,
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
26
50
|
## Rules
|
|
27
51
|
|
|
28
52
|
| Rule | Why |
|
|
@@ -126,6 +150,7 @@ Translating them = shipping `mail.*` keys in an app catalog. Never edit a templa
|
|
|
126
150
|
| `X_MAIL_CREDENTIAL_MISSING` | set `SMTP_URL` (or `RESEND_API_KEY`) and `MAIL_FROM` in the deployment — an operations one |
|
|
127
151
|
| `X_MAIL_HEADER_INVALID` | strip CR/LF from the interpolated value before it reaches a header |
|
|
128
152
|
| `X_MAIL_ADDRESS_INVALID` | pass a bare `addr-spec` — an envelope address may hold no control character and no `<`/`>` |
|
|
153
|
+
| `X_MAIL_TRANSFORM_FAILED` | the `setMailTransform` hook threw or returned no `{ subject, html, text }` — fix the hook; the mail was not sent |
|
|
129
154
|
| `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 |
|
|
130
155
|
|
|
131
156
|
### Error classes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mail",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.5.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": "22.
|
|
35
|
-
"@ultimat3/i18n": "22.
|
|
36
|
-
"@ultimat3/jobs": "22.
|
|
37
|
-
"@ultimat3/schema": "22.
|
|
38
|
-
"@ultimat3/time": "22.
|
|
34
|
+
"@ultimat3/core": "22.5.0",
|
|
35
|
+
"@ultimat3/i18n": "22.5.0",
|
|
36
|
+
"@ultimat3/jobs": "22.5.0",
|
|
37
|
+
"@ultimat3/schema": "22.5.0",
|
|
38
|
+
"@ultimat3/time": "22.5.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/errors.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const MAIL_ERROR_CODES = [
|
|
|
20
20
|
'X_MAIL_HEADER_INVALID',
|
|
21
21
|
'X_MAIL_ADDRESS_INVALID',
|
|
22
22
|
'X_MAIL_SEND_FAILED',
|
|
23
|
+
'X_MAIL_TRANSFORM_FAILED',
|
|
23
24
|
] as const;
|
|
24
25
|
|
|
25
26
|
export type MailErrorCode = (typeof MAIL_ERROR_CODES)[number];
|
|
@@ -34,6 +35,7 @@ export const MAIL_ERROR_TITLES: Readonly<Record<MailErrorCode, string>> = {
|
|
|
34
35
|
X_MAIL_HEADER_INVALID: 'a header value carries a line break',
|
|
35
36
|
X_MAIL_ADDRESS_INVALID: 'an envelope address could restructure the SMTP command line',
|
|
36
37
|
X_MAIL_SEND_FAILED: 'the mail transport refused the message',
|
|
38
|
+
X_MAIL_TRANSFORM_FAILED: 'the app mail transform threw or returned no message',
|
|
37
39
|
};
|
|
38
40
|
|
|
39
41
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -302,3 +304,16 @@ export const sendFailed = (failure: SendFailure): MailError =>
|
|
|
302
304
|
...(failure.status === undefined ? {} : { status: failure.status }),
|
|
303
305
|
},
|
|
304
306
|
});
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The app's `setMailTransform` hook threw, or handed back something that is not a message. The
|
|
310
|
+
* send FAILS: delivering the untransformed mail would be a send the app believes was tracked (or
|
|
311
|
+
* rewritten) and was not, and nothing downstream could tell the difference.
|
|
312
|
+
*/
|
|
313
|
+
export const transformFailed = (mailId: string, reason: string): MailError =>
|
|
314
|
+
new MailError({
|
|
315
|
+
code: 'X_MAIL_TRANSFORM_FAILED',
|
|
316
|
+
cause: `the mail transform failed for "${mailId}": ${reason} — the mail was not sent`,
|
|
317
|
+
fix: `make the setMailTransform() hook return { subject, html, text } strings for "${mailId}" (return the rendered argument unchanged for a mail it does not track), then retry the send`,
|
|
318
|
+
meta: { mailId },
|
|
319
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -58,6 +58,7 @@ export {
|
|
|
58
58
|
sendFailed,
|
|
59
59
|
templateUnknown,
|
|
60
60
|
textMissing,
|
|
61
|
+
transformFailed,
|
|
61
62
|
} from './errors';
|
|
62
63
|
export { assertHeaderSafe } from './header-safety';
|
|
63
64
|
|
|
@@ -96,7 +97,6 @@ export {
|
|
|
96
97
|
export type { RenderableMail, RenderedMail, RenderOptions } from './render';
|
|
97
98
|
export { renderMail, textOf } from './render';
|
|
98
99
|
export type { SmtpConnector, SmtpStream } from './smtp-client';
|
|
99
|
-
|
|
100
100
|
export {
|
|
101
101
|
FRAMEWORK_MAILS,
|
|
102
102
|
type InviteInput,
|
|
@@ -119,3 +119,5 @@ export {
|
|
|
119
119
|
welcomeInput,
|
|
120
120
|
welcomeMail,
|
|
121
121
|
} from './templates';
|
|
122
|
+
export type { MailRendered, MailTransform, MailTransformMeta } from './transform';
|
|
123
|
+
export { mailTransform, setMailTransform } from './transform';
|
package/src/mail.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { mailIdempotencyKey } from './idempotency';
|
|
|
14
14
|
import { sendMailJob } from './job';
|
|
15
15
|
import { BASE_LAYOUT } from './layout';
|
|
16
16
|
import { type RenderableMail, renderMail } from './render';
|
|
17
|
+
import { applyMailTransform } from './transform';
|
|
17
18
|
|
|
18
19
|
export interface MailDefinition<I> extends RenderableMail<I> {
|
|
19
20
|
readonly id: string;
|
|
@@ -160,7 +161,9 @@ export async function send<I>(
|
|
|
160
161
|
data: I,
|
|
161
162
|
options: SendOptions,
|
|
162
163
|
): Promise<SendResult> {
|
|
163
|
-
|
|
164
|
+
// Transform BEFORE the key: the key, the queue row and every retry carry the transformed bytes,
|
|
165
|
+
// and the job never re-runs the hook. Not in `renderMessage`, which previews call.
|
|
166
|
+
const message = await applyMailTransform(renderMessage(mail, data, options));
|
|
164
167
|
const key = mailIdempotencyKey(message);
|
|
165
168
|
const queue = jobDriver();
|
|
166
169
|
|
package/src/transform.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Single responsibility: the app-level OUTBOUND hook — `setMailTransform(fn)` — run once per
|
|
2
|
+
// `send()`, after render and before the idempotency key is minted, so the key, the queue row and
|
|
3
|
+
// every retry all carry the transformed bytes. An app adds an open pixel or rewrites its own links
|
|
4
|
+
// here; the framework only guarantees WHEN it runs, what it sees, and that a failure never ships.
|
|
5
|
+
|
|
6
|
+
import type { MailMessage } from './driver';
|
|
7
|
+
import { transformFailed } from './errors';
|
|
8
|
+
import { assertHeaderSafe } from './header-safety';
|
|
9
|
+
import { mailIdempotencyKey } from './idempotency';
|
|
10
|
+
|
|
11
|
+
/** The parts a transform may rewrite. Never the recipients, the sender or the headers. */
|
|
12
|
+
export interface MailRendered {
|
|
13
|
+
readonly subject: string;
|
|
14
|
+
readonly html: string;
|
|
15
|
+
readonly text: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MailTransformMeta {
|
|
19
|
+
/** The mail's `defineMail({ id })` — an app allowlists on it (auth mail is never tracked). */
|
|
20
|
+
readonly mailName: string;
|
|
21
|
+
readonly to: readonly string[];
|
|
22
|
+
/**
|
|
23
|
+
* The key of the UNtransformed message — the caller's `idempotencyKey` when it gave one, the
|
|
24
|
+
* content digest otherwise. Stable across a re-called `send()` of the same mail, so an app keys
|
|
25
|
+
* its tracking row on it and returns the SAME pixel id: the transformed bytes, and therefore the
|
|
26
|
+
* final key the queue and the provider dedupe on, then match too.
|
|
27
|
+
*/
|
|
28
|
+
readonly idempotencyKey: string;
|
|
29
|
+
readonly locale: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic for a given `meta.idempotencyKey`, or a re-called `send()` is a second mail. May be
|
|
34
|
+
* async (an app records its tracking row). A throw fails the send with `X_MAIL_TRANSFORM_FAILED`.
|
|
35
|
+
*/
|
|
36
|
+
export type MailTransform = (
|
|
37
|
+
rendered: MailRendered,
|
|
38
|
+
meta: MailTransformMeta,
|
|
39
|
+
) => MailRendered | Promise<MailRendered>;
|
|
40
|
+
|
|
41
|
+
let ambient: MailTransform | undefined;
|
|
42
|
+
|
|
43
|
+
/** Install the app's transform; `undefined` removes it. One per process, like `setMailDriver`. */
|
|
44
|
+
export function setMailTransform(transform: MailTransform | undefined): void {
|
|
45
|
+
ambient = transform;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function mailTransform(): MailTransform | undefined {
|
|
49
|
+
return ambient;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The message `send()` keys and delivers. With no transform installed this returns the SAME
|
|
54
|
+
* object — every app that never calls `setMailTransform` is byte-identical to before.
|
|
55
|
+
*/
|
|
56
|
+
export async function applyMailTransform(message: MailMessage): Promise<MailMessage> {
|
|
57
|
+
const transform = ambient;
|
|
58
|
+
if (transform === undefined) return message;
|
|
59
|
+
const meta: MailTransformMeta = Object.freeze({
|
|
60
|
+
mailName: message.mailId,
|
|
61
|
+
to: Object.freeze([...message.to]),
|
|
62
|
+
idempotencyKey: mailIdempotencyKey(message),
|
|
63
|
+
locale: message.locale,
|
|
64
|
+
});
|
|
65
|
+
const rendered: MailRendered = Object.freeze({
|
|
66
|
+
subject: message.subject,
|
|
67
|
+
html: message.html,
|
|
68
|
+
text: message.text,
|
|
69
|
+
});
|
|
70
|
+
// Reading the result is inside the same guard as the call: a returned object whose `subject`
|
|
71
|
+
// getter throws is a failed transform too, never a raw error escaping `send()`.
|
|
72
|
+
let next: MailRendered | undefined;
|
|
73
|
+
try {
|
|
74
|
+
next = asRendered(await transform(rendered, meta));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw transformFailed(message.mailId, `it threw (${describe(error)})`);
|
|
77
|
+
}
|
|
78
|
+
if (next === undefined) {
|
|
79
|
+
throw transformFailed(message.mailId, 'it returned no { subject, html, text } strings');
|
|
80
|
+
}
|
|
81
|
+
if (next.text.trim() === '') {
|
|
82
|
+
throw transformFailed(message.mailId, 'it returned an empty text part');
|
|
83
|
+
}
|
|
84
|
+
const transformed: MailMessage = { ...message, ...next };
|
|
85
|
+
// The subject is the transform's to change, so the header rule is re-checked on what it returned.
|
|
86
|
+
assertHeaderSafe(transformed);
|
|
87
|
+
return transformed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function asRendered(value: unknown): MailRendered | undefined {
|
|
91
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
92
|
+
const { subject, html, text } = value as Record<string, unknown>;
|
|
93
|
+
if (typeof subject !== 'string' || typeof html !== 'string' || typeof text !== 'string') {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
return { subject, html, text };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What KIND of value was thrown, from a closed list — never its message and never its `name`,
|
|
101
|
+
* which are the app's strings and may carry a recipient address.
|
|
102
|
+
*/
|
|
103
|
+
const KNOWN_ERRORS = new Set(['Error', 'TypeError', 'RangeError', 'SyntaxError', 'ReferenceError']);
|
|
104
|
+
|
|
105
|
+
function describe(error: unknown): string {
|
|
106
|
+
if (error instanceof Error) return KNOWN_ERRORS.has(error.name) ? error.name : 'an Error';
|
|
107
|
+
return typeof error;
|
|
108
|
+
}
|