@ultimat3/mail 9.0.0 → 10.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
@@ -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
- - `Bcc` is an envelope field. It reaches `RCPT TO` and Resend's body, never a header.
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": "9.0.0",
3
+ "version": "10.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": "9.0.0",
35
- "@ultimat3/i18n": "9.0.0",
36
- "@ultimat3/jobs": "9.0.0",
37
- "@ultimat3/schema": "9.0.0",
38
- "@ultimat3/time": "9.0.0"
34
+ "@ultimat3/core": "10.0.0",
35
+ "@ultimat3/i18n": "10.0.0",
36
+ "@ultimat3/jobs": "10.0.0",
37
+ "@ultimat3/schema": "10.0.0",
38
+ "@ultimat3/time": "10.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',
@@ -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
@@ -55,6 +55,7 @@ export {
55
55
  MailError,
56
56
  mailCredentialMissing,
57
57
  mailDuplicate,
58
+ mailLayoutDuplicate,
58
59
  sendFailed,
59
60
  templateUnknown,
60
61
  textMissing,
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