@ultimat3/mail 9.0.0 → 11.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 +17 -1
- package/package.json +6 -6
- package/src/errors.ts +21 -1
- package/src/header-safety.ts +18 -0
- package/src/index.ts +1 -0
- package/src/layout.ts +9 -0
package/CLAUDE.md
CHANGED
|
@@ -90,7 +90,23 @@
|
|
|
90
90
|
what each did rather than a proxy for it, and neither side can move alone. The memory driver joins
|
|
91
91
|
the cases about the MESSAGE and none about a wire: it dials nothing, maps no status and carries no
|
|
92
92
|
idempotency header, and that is documented difference, not defect.
|
|
93
|
-
- `
|
|
93
|
+
- **`assertHeaderSafe` gates the ADDRESS LISTS too — `to` and `cc`** (`As of 2026-08-23`). It
|
|
94
|
+
checked `subject` and `Object.entries(messageHeaders(message))`, and `messageHeaders` emits only
|
|
95
|
+
`Auto-Submitted`, `Reply-To` and the two `List-Unsubscribe` lines — while `to` and `cc` become
|
|
96
|
+
`To:` and `Cc:` in `mime.ts` and went through neither gate. `renderMessage` ACCEPTED
|
|
97
|
+
`to: ['victim@x.test\r\nBcc: attacker@evil.test']` and `SendResult.accepted` handed it back;
|
|
98
|
+
SMTP refuses it one layer down (`envelope-address.ts`), the memory driver and Resend do not.
|
|
99
|
+
Exactly the three-answers split this rule was lifted out of `mime.ts` to close.
|
|
100
|
+
- **`registerLayout` refuses a second claim on a name** (`X_MAIL_DUPLICATE`, `mailLayoutDuplicate`).
|
|
101
|
+
`layouts.set` answered whichever registration ran LAST, `base` included, so a dependency that
|
|
102
|
+
registered `base` silently re-shelled every framework mail with no error anywhere and nothing to
|
|
103
|
+
grep for. `defineMail` refuses a duplicate id one file over for the identical reason, and
|
|
104
|
+
`@ultimat3/mcp`'s `ResourceRegistry.register` states the general rule. **Breaking for an app that
|
|
105
|
+
registered one name twice** — the old behaviour was pinned by a test called "last writer wins",
|
|
106
|
+
which is what kept it.
|
|
107
|
+
- `Bcc` is an envelope field. It reaches `RCPT TO` and Resend's body, never a header — so
|
|
108
|
+
`assertHeaderSafe` deliberately does NOT check it and `envelope-address.ts` does. Two wire
|
|
109
|
+
formats, one gate each; naming a `Bcc` header in a refusal would name one no message has.
|
|
94
110
|
- Recipient addresses stay out of logs and out of error text we write ourselves; the server's own
|
|
95
111
|
reply is passed through verbatim, and that is where the refused address comes from.
|
|
96
112
|
- Every header value is checked for CR/LF (`X_MAIL_HEADER_INVALID`) before folding — interpolated
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mail",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.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": "11.0.0",
|
|
35
|
+
"@ultimat3/i18n": "11.0.0",
|
|
36
|
+
"@ultimat3/jobs": "11.0.0",
|
|
37
|
+
"@ultimat3/schema": "11.0.0",
|
|
38
|
+
"@ultimat3/time": "11.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/errors.ts
CHANGED
|
@@ -77,12 +77,16 @@ export interface MailErrorInit {
|
|
|
77
77
|
export class MailError extends UltimateError {
|
|
78
78
|
override readonly name = 'MailError';
|
|
79
79
|
|
|
80
|
+
// No `docs:`. `UltimateError` fills it from `describeErrorCode(code).docs`, which is
|
|
81
|
+
// `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
|
|
82
|
+
// `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE
|
|
83
|
+
// ROW, which has no anchor. The `https://ultimate.dev/errors/<code>` link this class built until
|
|
84
|
+
// 9.x answered 404, host included, on every error mail has ever thrown.
|
|
80
85
|
constructor(init: MailErrorInit) {
|
|
81
86
|
super({
|
|
82
87
|
code: init.code,
|
|
83
88
|
cause: init.cause,
|
|
84
89
|
fix: init.fix,
|
|
85
|
-
docs: `https://ultimate.dev/errors/${init.code}`,
|
|
86
90
|
meta: init.meta,
|
|
87
91
|
...(init.retry === undefined ? {} : { retry: init.retry }),
|
|
88
92
|
});
|
|
@@ -125,6 +129,22 @@ export const mailDuplicate = (mailId: string): MailError =>
|
|
|
125
129
|
meta: { mailId },
|
|
126
130
|
});
|
|
127
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A layout name claimed twice. Same code as a duplicate mail id, because it is the same failure —
|
|
134
|
+
* a name this package addresses something by, taken by two declarations — and the cause names
|
|
135
|
+
* which of the two spellings the reader is looking at.
|
|
136
|
+
*
|
|
137
|
+
* `layouts.set(name, layout)` used to answer whichever registration ran LAST: a package
|
|
138
|
+
* registering `base` silently re-shelled every framework mail with nothing reported anywhere.
|
|
139
|
+
*/
|
|
140
|
+
export const mailLayoutDuplicate = (name: string): MailError =>
|
|
141
|
+
new MailError({
|
|
142
|
+
code: 'X_MAIL_DUPLICATE',
|
|
143
|
+
cause: `mail layout "${name}" is already registered, so a second registerLayout() would replace it`,
|
|
144
|
+
fix: `pick a name of your own: registerLayout('${name}-custom', myLayout) — a layout name is the key defineMail({ layout }) addresses a shell by`,
|
|
145
|
+
meta: { layout: name },
|
|
146
|
+
});
|
|
147
|
+
|
|
128
148
|
export const textMissing = (mailId: string): MailError =>
|
|
129
149
|
new MailError({
|
|
130
150
|
code: 'X_MAIL_TEXT_MISSING',
|
package/src/header-safety.ts
CHANGED
|
@@ -19,6 +19,24 @@ const BREAK = /[\r\n]/;
|
|
|
19
19
|
*/
|
|
20
20
|
export function assertHeaderSafe(message: MailMessage): void {
|
|
21
21
|
if (BREAK.test(message.subject)) throw headerInvalid('Subject', message.mailId);
|
|
22
|
+
// The ADDRESS LISTS, which went through no gate at all: `messageHeaders` emits `Auto-Submitted`,
|
|
23
|
+
// `Reply-To` and the two `List-Unsubscribe` lines and nothing else, while `to` and `cc` become
|
|
24
|
+
// `To:` and `Cc:` in `mime.ts`. A recipient carrying `\r\nBcc: attacker@evil.test` was accepted
|
|
25
|
+
// by `renderMessage` and came back in `SendResult.accepted`; SMTP refuses it one layer down, the
|
|
26
|
+
// memory driver and Resend do not — the exact split this rule was lifted out of `mime.ts` to
|
|
27
|
+
// close.
|
|
28
|
+
//
|
|
29
|
+
// `bcc` is deliberately NOT here: it is an envelope field and never a header, so it is gated by
|
|
30
|
+
// `envelope-address.ts` on the wire that carries it (`X_MAIL_ADDRESS_INVALID`) — two wire
|
|
31
|
+
// formats, one gate each. Naming it here would report a `Bcc` header that no message has.
|
|
32
|
+
for (const [header, addresses] of [
|
|
33
|
+
['To', message.to],
|
|
34
|
+
['Cc', message.cc],
|
|
35
|
+
] as const) {
|
|
36
|
+
for (const address of addresses ?? []) {
|
|
37
|
+
if (BREAK.test(address)) throw headerInvalid(header, message.mailId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
22
40
|
for (const [name, value] of Object.entries(messageHeaders(message))) {
|
|
23
41
|
if (BREAK.test(value)) throw headerInvalid(name, message.mailId);
|
|
24
42
|
}
|
package/src/index.ts
CHANGED
package/src/layout.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// renderer, because email clients drop <style> and templates must never carry a raw hex.
|
|
4
4
|
// Table-based and 600px wide: Outlook still renders with Word's HTML engine.
|
|
5
5
|
|
|
6
|
+
import { mailLayoutDuplicate } from './errors';
|
|
6
7
|
import { escapeHtml, safeUrl, styleAttr } from './html';
|
|
7
8
|
|
|
8
9
|
export type ColorScheme = 'light' | 'dark';
|
|
@@ -207,7 +208,15 @@ function footerHtml(input: LayoutInput): string {
|
|
|
207
208
|
|
|
208
209
|
const layouts = new Map<string, MailLayout>([[BASE_LAYOUT, baseLayout]]);
|
|
209
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Claim a layout name. Once, ever — a second claim is refused rather than resolved.
|
|
213
|
+
*
|
|
214
|
+
* `layouts.set` answered whichever registration ran last, `base` included, so a dependency that
|
|
215
|
+
* registered `base` re-shelled every framework mail with no error anywhere and nothing to grep
|
|
216
|
+
* for. `defineMail` refuses a duplicate id one file over for the identical reason.
|
|
217
|
+
*/
|
|
210
218
|
export function registerLayout(name: string, layout: MailLayout): void {
|
|
219
|
+
if (layouts.has(name)) throw mailLayoutDuplicate(name);
|
|
211
220
|
layouts.set(name, layout);
|
|
212
221
|
}
|
|
213
222
|
|