@ultimat3/mail 22.15.0 → 24.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
@@ -35,6 +35,20 @@
35
35
  result is re-checked by `assertHeaderSafe`; a throw or a malformed result is
36
36
  `X_MAIL_TRANSFORM_FAILED` and nothing is sent. None installed ⇒ the same object, byte-identical.
37
37
 
38
+ - **`send()` enqueues through `jobsFacade().enqueue(sendMailJob, message)`, never `jobDriver().enqueue`**
39
+ (`As of 2026-10-02`). The driver call published on the pool, autocommitted, so a handler that
40
+ rolled back after `send()` still mailed — and under `{ retry }` each re-run could mail again.
41
+ Through the facade an ambient transaction STAGES the row and the relay publishes it after COMMIT.
42
+ `send-outbox.job.test.ts` proves a rollback delivers nothing, on the memory pair and on Postgres.
43
+ - **One recipient rule, inline and queued** (`As of 2026-10-02`). `addressRefusal`
44
+ (`envelope-address.ts`) is `envelopeAddress`'s rule without the throw; `renderMessage` applies it
45
+ to `to`/`cc`/`bcc`/`replyTo` after the header gate, and `mailMessageSchema` refines with it. It was
46
+ `t.email` on the queue and nothing inline, so `Jane Doe <jane@x.test>` answered `queued: true` and
47
+ the worker's parse refused the row. `unsubscribeUrl` is `t.url` on both paths for the same reason.
48
+ - `X_MAIL_HEADER_INVALID` carries `meta.reason` (`line-break` | `non-ascii`) with a cause per
49
+ reason; the non-ASCII mailbox refusal was raised with the line-break cause until 2026-10-02.
50
+ `Reply-To` takes the `address` mode in `mime.ts` (encoded phrase, refused non-ASCII mailbox), and
51
+ an ASCII phrase holding an RFC 5322 special other than `.` is quoted (`"Doe, Jane"`).
38
52
  - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a `defineMail` file
39
53
  imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on
40
54
  every access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
@@ -163,11 +177,12 @@
163
177
  data reaches `Subject`, and a break there injects headers. Refuse it, never strip it.
164
178
  - Every ENVELOPE address is checked the same way and separately (`X_MAIL_ADDRESS_INVALID`,
165
179
  `envelope-address.ts`, called by `smtpDeliver`). Two wire formats, one gate each: `bcc` is not a
166
- header, so the header gate never sees it, and on the inline send path no schema does either — a
167
- `bcc` of `ops@x.test\r\nRCPT TO:<evil@y.test>` relayed mail over the app's own authenticated
168
- connection. The refused set is control characters plus `<` and `>`; a space is deliberately
169
- allowed (quoted local-parts). Non-ASCII in the MAILBOX is refused too, and by this package:
170
- SMTPUTF8 (RFC 6531) is what makes a UTF-8 addr-spec legal and `smtp-client.ts` negotiates none.
180
+ header, so the header gate never sees it, and on the inline send path no schema did either until
181
+ `renderMessage` took `addressRefusal` (2026-10-02) — a `bcc` of
182
+ `ops@x.test\r\nRCPT TO:<evil@y.test>` relayed mail over the app's own authenticated connection.
183
+ The refused set is control characters plus `<` and `>`; a space is deliberately allowed (quoted
184
+ local-parts). Non-ASCII in the MAILBOX is refused too, and by this package: SMTPUTF8 (RFC 6531)
185
+ is what makes a UTF-8 addr-spec legal and `smtp-client.ts` negotiates none.
171
186
 
172
187
  ## Commands
173
188
 
package/README.md CHANGED
@@ -20,8 +20,10 @@ export const receiptMail = defineMail({
20
20
  await send(receiptMail, { name: user.name, url }, { to: user.email, locale: ctx.locale });
21
21
  ```
22
22
 
23
- `send` validates `data` through the mail's schema, renders, then enqueues `mail.send`. It
24
- delivers inline only when `{ sync: true }` is passed or no job driver is configured.
23
+ `send` validates `data` through the mail's schema, renders, then enqueues `mail.send` through the
24
+ jobs facade — inside a transaction the job is STAGED on it, so a handler that rolls back after
25
+ sending mails nobody. It delivers inline only when `{ sync: true }` is passed or no job driver is
26
+ configured.
25
27
 
26
28
  ### The outbound transform
27
29
 
@@ -58,6 +60,8 @@ setMailTransform(async (rendered, meta) =>
58
60
  | Every colour is a token | `MAIL_TOKENS` in `layout.ts` holds light + dark hexes; templates never see a hex |
59
61
  | Every date takes an IANA zone | `options.tz`, else `ctx.tz`, else `UTC` |
60
62
  | No CR/LF in a header-bound field | checked in `renderMessage` and again in `sendMailJob`, so every driver refuses the same message (`X_MAIL_HEADER_INVALID`). `mime.ts` keeps its own gate for the headers the SMTP transport mints itself |
63
+ | One recipient rule on both paths | `to`, `cc`, `bcc` and `replyTo` are checked in `renderMessage` by the rule the SMTP envelope applies (`addressRefusal`), and the queue schema asks the same — so a display-form `Jane Doe <jane@x.test>` is accepted everywhere, and a control character or a non-ASCII mailbox is `X_MAIL_ADDRESS_INVALID` at the `send()` call site on every driver, never `queued: true` and then a worker's parse failure. `unsubscribeUrl` is `t.url` on both paths for the same reason |
64
+ | Address headers carry 7-bit phrases | `From`, `To`, `Cc` and `Reply-To` encode a non-ASCII display name as an RFC 2047 word and quote an ASCII one holding a special (`Doe, Jane` → `"Doe, Jane"`), so the comma cannot split the list. `List-Unsubscribe` carries the url as the WHATWG serialiser writes it — punycode host, percent-encoded path and query |
61
65
  | `unsubscribeUrl` is one-click, and one-click is the supported path | it emits `List-Unsubscribe: <url>` plus `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058), and the footer links the SAME url. Make that url one page that answers both: `GET` renders a confirm button and never unsubscribes (scanners prefetch), and `POST` — the mail client's one-click, or the button's own form — runs the action: `defineRoute({ render: 'ssr', …, post: 'unsubscribe' })` (`@ultimat3/render`) binds it, with the url's query (`?t=<token>`) merged into the input. `unsubscribeOneClick: false` drops the `-Post` line for a url that still cannot take a POST; it is the fallback, not the path |
62
66
  | Sending is a job | `retry: { attempts: 5, backoff: 'exponential' }`, idempotency key `mail:<mailId>:<hash(recipients + rendered)>` — 128 bits, ASCII, under Resend's 256-character limit at any recipient count — or `(mailId, your key)` when you pass one (digested if it is not a short ASCII token) — a caller's key is scoped to its mail so two templates cannot dedupe each other away |
63
67
 
@@ -149,7 +153,7 @@ Translating them = shipping `mail.*` keys in an app catalog. Never edit a templa
149
153
  | `X_MAIL_DRIVER_UNAVAILABLE` | `setMailDriver(createMemoryDriver())` at boot — a wiring bug |
150
154
  | `X_MAIL_CREDENTIAL_MISSING` | set `SMTP_URL` (or `RESEND_API_KEY`) and `MAIL_FROM` in the deployment — an operations one |
151
155
  | `X_MAIL_HEADER_INVALID` | strip CR/LF from the interpolated value before it reaches a header |
152
- | `X_MAIL_ADDRESS_INVALID` | pass a bare `addr-spec` — an envelope address may hold no control character and no `<`/`>` |
156
+ | `X_MAIL_ADDRESS_INVALID` | a recipient may hold no control character, its mailbox no `<`/`>` and no non-ASCII byte (`meta.reason`); `Jane Doe <jane@x.test>` is fine |
153
157
  | `X_MAIL_TRANSFORM_FAILED` | the `setMailTransform` hook threw or returned no `{ subject, html, text }` — fix the hook; the mail was not sent |
154
158
  | `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 |
155
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mail",
3
- "version": "22.15.0",
3
+ "version": "24.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",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
22
23
  "CLAUDE.md",
23
24
  "README.md",
24
25
  "LICENSE"
@@ -31,10 +32,10 @@
31
32
  "test": "bun test"
32
33
  },
33
34
  "dependencies": {
34
- "@ultimat3/core": "22.15.0",
35
- "@ultimat3/i18n": "22.15.0",
36
- "@ultimat3/jobs": "22.15.0",
37
- "@ultimat3/schema": "22.15.0",
38
- "@ultimat3/time": "22.15.0"
35
+ "@ultimat3/core": "24.0.0",
36
+ "@ultimat3/i18n": "24.0.0",
37
+ "@ultimat3/jobs": "24.0.0",
38
+ "@ultimat3/schema": "24.0.0",
39
+ "@ultimat3/time": "24.0.0"
39
40
  }
40
41
  }
@@ -84,11 +84,29 @@ function parseSmtpUrl(raw: string): SmtpUrl {
84
84
  port: url.port === '' ? (tls ? IMPLICIT_TLS_PORT : SUBMISSION_PORT) : Number(url.port),
85
85
  tls,
86
86
  // Credentials are percent-encoded in a URL; a password with `@` or `/` is otherwise unusable.
87
- ...(url.username === '' ? {} : { user: decodeURIComponent(url.username) }),
88
- ...(url.password === '' ? {} : { password: decodeURIComponent(url.password) }),
87
+ ...(url.username === '' ? {} : { user: decodedCredential('user', url.username) }),
88
+ ...(url.password === '' ? {} : { password: decodedCredential('password', url.password) }),
89
89
  };
90
90
  }
91
91
 
92
+ /**
93
+ * `decodeURIComponent` throws a bare `URIError` for a `%` that starts no escape, which escaped
94
+ * `createSmtpDriver` as the one SMTP_URL mistake that was not a config error. The value is a
95
+ * credential, so neither the cause nor the fix ever carries it.
96
+ */
97
+ function decodedCredential(part: 'user' | 'password', raw: string): string {
98
+ try {
99
+ return decodeURIComponent(raw);
100
+ } catch {
101
+ throw new ConfigInvalidError({
102
+ cause: `the SMTP_URL ${part} holds a "%" that starts no percent-escape, so it cannot be decoded`,
103
+ // `prompt`, so the credential reaches neither shell history nor this message.
104
+ fix: `percent-encode the ${part} and put the output in SMTP_URL: bun -e 'console.log(encodeURIComponent(prompt("${part}:") ?? ""))'`,
105
+ meta: { part },
106
+ });
107
+ }
108
+ }
109
+
92
110
  /**
93
111
  * `poolSize: 0` is the one config mistake this driver could not report: the limiter would park
94
112
  * every send on a slot that is never handed out, with no deadline on the wait, so the job would
package/src/driver.ts CHANGED
@@ -62,7 +62,7 @@ export function messageHeaders(message: MailMessage): Readonly<Record<string, st
62
62
  const headers: Record<string, string> = { 'Auto-Submitted': 'auto-generated' };
63
63
  if (message.replyTo !== undefined) headers['Reply-To'] = message.replyTo;
64
64
  if (message.unsubscribeUrl !== undefined) {
65
- headers['List-Unsubscribe'] = `<${message.unsubscribeUrl}>`;
65
+ headers['List-Unsubscribe'] = `<${unsubscribeTarget(message.unsubscribeUrl)}>`;
66
66
  if (message.unsubscribeOneClick !== false) {
67
67
  headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
68
68
  }
@@ -70,6 +70,22 @@ export function messageHeaders(message: MailMessage): Readonly<Record<string, st
70
70
  return headers;
71
71
  }
72
72
 
73
+ /**
74
+ * The url as the WHATWG serialiser writes it: host as punycode, path, query and fragment
75
+ * percent-encoded — 7-bit, and with no bare `>` to close the angle bracket early. It went out as
76
+ * written, so a non-ASCII url was raw 8-bit octets in a header. A url holding a control character
77
+ * is copied through UNTOUCHED: the serialiser silently drops CR, LF and tab, and a header break is
78
+ * refused by the CR/LF gate, never stripped into a different url. `send()` and the queue schema
79
+ * refuse an unparseable url first; a hand-built one is copied through and stays the caller's.
80
+ */
81
+ function unsubscribeTarget(url: string): string {
82
+ if (CONTROL.test(url)) return url;
83
+ return URL.parse(url)?.href ?? url;
84
+ }
85
+
86
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the control range IS the rule.
87
+ const CONTROL = /[\u0000-\u001f\u007f]/;
88
+
73
89
  /**
74
90
  * Every address the envelope is delivered to. `Bcc` is one of them and never a header —
75
91
  * an SMTP `RCPT TO` carries it, and putting it in the message would leak the blind list.
@@ -216,7 +232,7 @@ export function createLogDriver(logger = rootLogger): MailDriver {
216
232
 
217
233
  let ambient: MailDriver | undefined;
218
234
 
219
- /** Set once at boot from `app.config.ts`. One driver per process, like the job driver. */
235
+ /** Set once at boot from the environment (`selectMailDriver`). One per process, like the job driver. */
220
236
  export function setMailDriver(driver: MailDriver): void {
221
237
  ambient = driver;
222
238
  }
@@ -4,7 +4,7 @@
4
4
  // thing only — the RFC 5322 display form down to the mailbox `RCPT TO:` can carry, always after
5
5
  // the control-character check, never before it.
6
6
 
7
- import { addressInvalid, type EnvelopeAddressField } from './errors';
7
+ import { type AddressRefusal, addressInvalid, type EnvelopeAddressField } from './errors';
8
8
  import { addressSpec, hasNonAsciiAddrSpec } from './mime';
9
9
 
10
10
  /**
@@ -39,29 +39,49 @@ function hasControlCharacter(address: string): boolean {
39
39
  return false;
40
40
  }
41
41
 
42
+ /** What `isUnsafe` and the mailbox check say of a bare addr-spec — the half both gates share. */
43
+ function specRefusal(spec: string): AddressRefusal | undefined {
44
+ if (isUnsafe(spec)) return 'injection';
45
+ // Two reasons, checked in this order: an injection is the dangerous one and an address carrying
46
+ // both should say so. The addr-spec, never the phrase — `mime.ts` has an RFC 2047 encoding for a
47
+ // display name and none for a mailbox.
48
+ return hasNonAsciiAddrSpec(spec) ? 'non-ascii' : undefined;
49
+ }
50
+
42
51
  /** Throws `X_MAIL_ADDRESS_INVALID` before a single byte of the envelope is written. */
43
52
  export function assertEnvelopeAddress(field: EnvelopeAddressField, address: string): void {
44
- if (isUnsafe(address)) throw addressInvalid(field, 'injection');
45
- // Two reasons, two `meta.reason`s, checked in this order: an injection is the dangerous one and
46
- // an address carrying both should say so. The addr-spec, never the phrase — `mime.ts` has an
47
- // RFC 2047 encoding for a display name and none for a mailbox.
48
- if (hasNonAsciiAddrSpec(address)) throw addressInvalid(field, 'non-ascii');
53
+ const refusal = specRefusal(address);
54
+ if (refusal !== undefined) throw addressInvalid(field, refusal);
49
55
  }
50
56
 
51
57
  /**
52
- * The addr-spec an envelope command may carry, from whatever form the caller wrote. `Jane Doe
53
- * <jane@x.test>` is the ordinary RFC 5322 display form — the `To:` header carries it verbatim and
54
- * the resend, memory and log drivers all accept it — but `RCPT TO:` takes a mailbox, so it is
55
- * stripped here and the gate above runs on what is left.
58
+ * Why `address` cannot be a recipient, or `undefined` when it can — `envelopeAddress`'s rule without
59
+ * the throw, so the queue schema (`job.ts`) and `renderMessage` (`mail.ts`) ask the SAME question
60
+ * the SMTP envelope does. It was `t.email` on the queue and nothing inline, so a display-form
61
+ * recipient was `queued: true` at `send()` and then refused by the worker's parse.
56
62
  *
57
63
  * The control-character half runs on the RAW value, BEFORE the strip, and the order is the whole
58
64
  * point: `ops@x.test\r\nRCPT TO:<attacker@evil.test>` strips down to a clean-looking mailbox that
59
65
  * every later check waves through, so a strip-then-check would have turned the injection into a
60
66
  * delivery to the attacker instead of a refusal.
61
67
  */
68
+ export function addressRefusal(address: string): AddressRefusal | undefined {
69
+ return hasControlCharacter(address) ? 'injection' : specRefusal(addressSpec(address));
70
+ }
71
+
72
+ /** Throws `X_MAIL_ADDRESS_INVALID` for any address `addressRefusal` refuses. */
73
+ export function assertRecipientAddress(field: EnvelopeAddressField, address: string): void {
74
+ const refusal = addressRefusal(address);
75
+ if (refusal !== undefined) throw addressInvalid(field, refusal);
76
+ }
77
+
78
+ /**
79
+ * The addr-spec an envelope command may carry, from whatever form the caller wrote. `Jane Doe
80
+ * <jane@x.test>` is the ordinary RFC 5322 display form — the `To:` header carries it verbatim and
81
+ * every driver accepts it — but `RCPT TO:` takes a mailbox, so it is stripped here once
82
+ * `addressRefusal` has passed the raw value.
83
+ */
62
84
  export function envelopeAddress(field: EnvelopeAddressField, address: string): string {
63
- if (hasControlCharacter(address)) throw addressInvalid(field, 'injection');
64
- const spec = addressSpec(address);
65
- assertEnvelopeAddress(field, spec);
66
- return spec;
85
+ assertRecipientAddress(field, address);
86
+ return addressSpec(address);
67
87
  }
package/src/errors.ts CHANGED
@@ -185,19 +185,53 @@ export const mailCredentialMissing = (environment: Environment): MailError =>
185
185
  meta: { environment, missing: ['SMTP_URL', 'RESEND_API_KEY'] },
186
186
  });
187
187
 
188
+ /**
189
+ * Why a header was refused. Two reasons, one code, as `AddressRefusal` is for the envelope: a
190
+ * non-ASCII MAILBOX in an address header was raised with the line-break cause, which named a
191
+ * character the value did not hold.
192
+ */
193
+ export type HeaderRefusal = 'line-break' | 'non-ascii';
194
+
195
+ /** The `send()` option each address header comes from; `From` is the transport's own config. */
196
+ const ADDRESS_OPTION: ReadonlyMap<string, string> = new Map([
197
+ ['To', 'to'],
198
+ ['Cc', 'cc'],
199
+ ['Reply-To', 'replyTo'],
200
+ ]);
201
+
202
+ /**
203
+ * The domain as punycode is the ASCII form; a non-ASCII LOCAL part has none and needs another
204
+ * mailbox. The example is fixed text — an address is recipient data, never echoed back.
205
+ */
206
+ const nonAsciiFix = (name: string): string => {
207
+ const option = ADDRESS_OPTION.get(name);
208
+ return option === undefined
209
+ ? 'set mail.from in app.config.ts to an ASCII mailbox, e.g. no-reply@example.test'
210
+ : `pass the mailbox's ASCII form, the domain as punycode: send(mail, data, { ${option}: 'jose@xn--exmple-cua.test', locale })`;
211
+ };
212
+
188
213
  /**
189
214
  * A CR or LF inside a header value ends the header early and lets whatever follows become new
190
215
  * headers — the recipient list, a forged `From`. Interpolated data reaches `Subject`, so this is
191
216
  * refused rather than stripped: silently rewriting a subject is its own surprise.
192
217
  */
193
- export const headerInvalid = (name: string, mailId: string): MailError =>
218
+ export const headerInvalid = (
219
+ name: string,
220
+ mailId: string,
221
+ reason: HeaderRefusal = 'line-break',
222
+ ): MailError =>
194
223
  new MailError({
195
224
  code: 'X_MAIL_HEADER_INVALID',
196
- cause: `the "${name}" header of mail "${mailId}" contains a CR or LF, which would inject headers`,
225
+ cause:
226
+ reason === 'line-break'
227
+ ? `the "${name}" header of mail "${mailId}" contains a CR or LF, which would inject headers`
228
+ : `the "${name}" header of mail "${mailId}" holds a non-ASCII byte in its mailbox, and this package negotiates no SMTPUTF8, so the address cannot be written as a header`,
197
229
  fix:
198
- `strip line breaks from the value before it reaches the header: ` +
199
- `t('mail.${mailId}.subject', { ...data, x: String(x).replace(/[\\r\\n]+/g, ' ') })`,
200
- meta: { header: name, mailId },
230
+ reason === 'line-break'
231
+ ? `strip line breaks from the value before it reaches the header: ` +
232
+ `t('mail.${mailId}.subject', { ...data, x: String(x).replace(/[\\r\\n]+/g, ' ') })`
233
+ : nonAsciiFix(name),
234
+ meta: { header: name, mailId, reason },
201
235
  });
202
236
 
203
237
  /**
package/src/html.ts CHANGED
@@ -1,19 +1,8 @@
1
- // Single responsibility: turning untrusted values into inert HTML. Mail bodies interpolate
2
- // user-supplied names, org names and URLs; an unescaped one is a phishing vector that also
3
- // renders in the recipient's client forever, so escaping lives in exactly one place.
1
+ // Single responsibility: the mail-specific half of making untrusted values inert — which URLs a
2
+ // link may carry and how a style attribute is written. The character escaping is core's
3
+ // `escapeHtml`, the one table: an unescaped name in a mail is a phishing vector forever.
4
4
 
5
- const ESCAPES: Readonly<Record<string, string>> = {
6
- '&': '&amp;',
7
- '<': '&lt;',
8
- '>': '&gt;',
9
- '"': '&quot;',
10
- "'": '&#39;',
11
- };
12
-
13
- /** Escape text content AND attribute values — the same set covers both in mail. */
14
- export function escapeHtml(value: string): string {
15
- return value.replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
16
- }
5
+ import { escapeHtml } from '@ultimat3/core';
17
6
 
18
7
  const SAFE_PROTOCOLS: readonly string[] = ['http:', 'https:', 'mailto:'];
19
8
 
package/src/index.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // Single responsibility: the public API of @ultimat3/mail. Explicit named exports only —
2
2
  // other packages call `defineMail`, `send` and the driver seam, and nothing else.
3
3
 
4
+ export { escapeHtml } from '@ultimat3/core';
4
5
  /** Re-exported so a `defineMail` file needs one import, not two. Same object as schema's. */
5
6
  export type { Infer } from '@ultimat3/schema';
6
7
  export { t } from '@ultimat3/schema';
7
8
  export type { CalloutTone, MailBlock, MailTemplate, TemplateArgs } from './blocks';
8
9
  export { blocks } from './blocks';
9
-
10
10
  export { MAIL_CATALOG, MAIL_CATALOG_LOCALE } from './catalog';
11
11
  export type {
12
12
  MailDriver,
@@ -61,8 +61,7 @@ export {
61
61
  transformFailed,
62
62
  } from './errors';
63
63
  export { assertHeaderSafe } from './header-safety';
64
-
65
- export { escapeHtml, safeUrl } from './html';
64
+ export { safeUrl } from './html';
66
65
 
67
66
  export { mailIdempotencyKey, mailMessageIdToken } from './idempotency';
68
67
  export { mailMessageSchema, sendMailJob } from './job';
package/src/job.ts CHANGED
@@ -5,21 +5,33 @@
5
5
  import { type JobHandle, job } from '@ultimat3/jobs';
6
6
  import { type StandardSchemaV1, t } from '@ultimat3/schema';
7
7
  import { type MailMessage, mailDriver, type SendResult } from './driver';
8
+ import { addressRefusal } from './envelope-address';
8
9
  import { assertHeaderSafe } from './header-safety';
9
10
  import { mailIdempotencyKey } from './idempotency';
10
11
 
12
+ /**
13
+ * A recipient by the rule the SMTP envelope applies (`addressRefusal`), never `t.email`: the
14
+ * inline path takes the RFC 5322 display form, so a stricter queue schema turned a `send()` that
15
+ * answered `queued: true` into a worker that refused the row it had just been handed.
16
+ */
17
+ const mailAddress = t.refine(t.string, {
18
+ name: 'mail-address',
19
+ message: 'a mailbox or a display-form address, with no control character or non-ASCII mailbox',
20
+ check: (address) => addressRefusal(address) === undefined,
21
+ });
22
+
11
23
  /** The queue payload is the already-rendered envelope: rendering happens once, at send time. */
12
24
  export const mailMessageSchema: StandardSchemaV1<unknown, MailMessage> = t.object({
13
25
  mailId: t.string,
14
- to: t.array(t.email),
26
+ to: t.array(mailAddress),
15
27
  subject: t.string,
16
28
  html: t.string,
17
29
  text: t.string,
18
30
  locale: t.locale,
19
31
  tz: t.timezone,
20
- replyTo: t.email.optional(),
21
- cc: t.array(t.email).optional(),
22
- bcc: t.array(t.email).optional(),
32
+ replyTo: mailAddress.optional(),
33
+ cc: t.array(mailAddress).optional(),
34
+ bcc: t.array(mailAddress).optional(),
23
35
  unsubscribeUrl: t.url.optional(),
24
36
  unsubscribeOneClick: t.boolean.optional(),
25
37
  idempotencyKey: t.string.optional(),
package/src/layout.ts CHANGED
@@ -3,8 +3,9 @@
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 { escapeHtml } from '@ultimat3/core';
6
7
  import { mailLayoutDuplicate } from './errors';
7
- import { escapeHtml, safeUrl, styleAttr } from './html';
8
+ import { safeUrl, styleAttr } from './html';
8
9
 
9
10
  export type ColorScheme = 'light' | 'dark';
10
11
 
package/src/mail.ts CHANGED
@@ -4,10 +4,11 @@
4
4
  // piece of context that a background send cannot recover after the fact.
5
5
 
6
6
  import { assert, DEFAULT_TIME_ZONE, tryUseContext } from '@ultimat3/core';
7
- import { jobDriver } from '@ultimat3/jobs';
8
- import { parse, type StandardSchemaV1 } from '@ultimat3/schema';
7
+ import { jobDriver, jobsFacade } from '@ultimat3/jobs';
8
+ import { parse, type StandardSchemaV1, t } from '@ultimat3/schema';
9
9
  import type { MailTemplate } from './blocks';
10
- import { type MailMessage, mailDriver, type SendResult } from './driver';
10
+ import { envelopeRecipients, type MailMessage, mailDriver, type SendResult } from './driver';
11
+ import { assertRecipientAddress } from './envelope-address';
11
12
  import { localeMissing, mailDuplicate, templateUnknown } from './errors';
12
13
  import { assertHeaderSafe } from './header-safety';
13
14
  import { mailIdempotencyKey } from './idempotency';
@@ -90,7 +91,9 @@ export function mailFor(id: string): AnyMailDefinition | undefined {
90
91
  }
91
92
 
92
93
  export function registeredMails(): readonly AnyMailDefinition[] {
93
- return [...registry.values()].sort((a, b) => a.id.localeCompare(b.id));
94
+ // Through `registeredMailIds`, so the two views share ONE order: the code-unit sort. It was
95
+ // `localeCompare` here, which puts `welcome` before `Zeta` where the id list puts it after.
96
+ return registeredMailIds().flatMap((id) => registry.get(id) ?? []);
94
97
  }
95
98
 
96
99
  export function registeredMailIds(): readonly string[] {
@@ -123,6 +126,11 @@ export function renderMessage<I>(
123
126
  );
124
127
 
125
128
  const input = parse(mail.input, data, `${mail.id}.input`);
129
+ // The queue schema's own rule (`t.url`), so an unparseable url is refused here rather than
130
+ // delivered inline as `List-Unsubscribe: <not a url>` and refused by a worker when queued.
131
+ if (options.unsubscribeUrl !== undefined) {
132
+ parse(t.url, options.unsubscribeUrl, `${mail.id}.unsubscribeUrl`);
133
+ }
126
134
  const tz = options.tz ?? tryUseContext()?.tz ?? DEFAULT_TIME_ZONE;
127
135
  const rendered = renderMail(mail, input, {
128
136
  locale: options.locale,
@@ -150,6 +158,13 @@ export function renderMessage<I>(
150
158
  // Checked before the queue too, so the refusal lands on the `send()` call site that made it
151
159
  // rather than on a worker three retries later.
152
160
  assertHeaderSafe(message);
161
+ // The recipient rule, after the header gate so a line break keeps its own code: what the queue
162
+ // schema refuses is refused HERE, on every driver, rather than answered `queued: true` and then
163
+ // refused by the worker's parse — one rule on both paths (`s1-t4 #3`).
164
+ const replyTo = message.replyTo === undefined ? [] : [message.replyTo];
165
+ for (const address of [...envelopeRecipients(message), ...replyTo]) {
166
+ assertRecipientAddress('recipient', address);
167
+ }
153
168
  return message;
154
169
  }
155
170
 
@@ -167,21 +182,17 @@ export async function send<I>(
167
182
  // and the job never re-runs the hook. Not in `renderMessage`, which previews call.
168
183
  const message = await applyMailTransform(renderMessage(mail, data, options));
169
184
  const key = mailIdempotencyKey(message);
170
- const queue = jobDriver();
171
185
 
172
- if (options.sync === true || queue === undefined) {
186
+ if (options.sync === true || jobDriver() === undefined) {
173
187
  // The key is reported even inline, so a caller can dedupe its own retries either way.
174
188
  return { ...(await mailDriver().send(message)), idempotencyKey: key };
175
189
  }
176
190
 
177
- const enqueued = await queue.enqueue({
178
- name: sendMailJob.name,
179
- queue: sendMailJob.queue,
180
- input: message,
181
- idempotencyKey: key,
182
- maxAttempts: sendMailJob.retry.attempts,
183
- onConflict: 'dedupe',
184
- });
191
+ // Through the facade, never `queue.enqueue`: inside a transaction the row is STAGED on the
192
+ // caller's connection and published after COMMIT, so a handler that rolls back after sending
193
+ // mails nobody (`s1-con #3`). Outside one, the facade publishes straight to the same driver.
194
+ // The key is the handle's own `mailIdempotencyKey`, and the driver dedupes on it by default.
195
+ const enqueued = await jobsFacade().enqueue(sendMailJob, message);
185
196
 
186
197
  return {
187
198
  id: enqueued.id,
package/src/mime.ts CHANGED
@@ -67,7 +67,7 @@ export function buildMimeMessage(message: MailMessage, options: MimeOptions): st
67
67
  // none for the addr-spec, and this package negotiates no SMTPUTF8 — so a UTF-8 mailbox would
68
68
  // go out as raw 8-bit octets in a header AND in the envelope beside it.
69
69
  if (mode === 'address' && hasNonAsciiAddrSpec(part))
70
- throw headerInvalid(name, message.mailId);
70
+ throw headerInvalid(name, message.mailId, 'non-ascii');
71
71
  }
72
72
  headerLines.push(foldHeaderLine(name, parts.map((part) => encodeAs(mode, part)).join(', ')));
73
73
  };
@@ -79,9 +79,12 @@ export function buildMimeMessage(message: MailMessage, options: MimeOptions): st
79
79
  header('Date', rfc5322Date(options.date));
80
80
  header('Message-ID', options.messageId);
81
81
  header('MIME-Version', '1.0');
82
- // RFC 8058 one-click unsubscribe and friends: computed once in driver.ts, emitted verbatim
83
- // here so there is exactly one place that decides which of these headers a message gets.
84
- for (const [name, value] of Object.entries(messageHeaders(message))) header(name, value);
82
+ // RFC 8058 one-click unsubscribe and friends: computed once in driver.ts so there is exactly one
83
+ // place that decides which of these headers a message gets. `Reply-To` is an ADDRESS and takes
84
+ // the address path — it went out verbatim, a non-ASCII display name as raw 8-bit octets.
85
+ for (const [name, value] of Object.entries(messageHeaders(message))) {
86
+ header(name, value, ADDRESS_HEADERS.has(name) ? 'address' : 'verbatim');
87
+ }
85
88
  header('Content-Type', `multipart/alternative; boundary="${options.boundary}"`);
86
89
 
87
90
  return `${headerLines.join(CRLF)}${CRLF}${CRLF}${buildBody(message, options.boundary)}`;
@@ -107,6 +110,9 @@ function bodyPart(contentType: string, text: string): string {
107
110
  );
108
111
  }
109
112
 
113
+ /** The computed headers (`messageHeaders`) whose value is an address, not a 7-bit token. */
114
+ const ADDRESS_HEADERS: ReadonlySet<string> = new Set(['Reply-To']);
115
+
110
116
  /**
111
117
  * How a header VALUE reaches the wire. `verbatim` is a value that is 7-bit by construction (a
112
118
  * date, a boundary, a message id); `value` is RFC 2047 over the whole string; `address` encodes
@@ -126,7 +132,7 @@ function encodeAs(mode: HeaderMode, value: string): string {
126
132
  * an 8-bit octet on a wire this package never negotiates SMTPUTF8 for, so it is the display name
127
133
  * that gets encoded — the addr-spec is copied through untouched, because a mailbox is the
128
134
  * server's to parse and an encoded word is not one. An address with no phrase, or an ASCII
129
- * phrase, comes back byte-identical.
135
+ * phrase with no special in it, comes back byte-identical; an ASCII phrase holding one is quoted.
130
136
  *
131
137
  * The MAILBOX is a different question and `hasNonAsciiAddrSpec` is where it is asked: RFC 2047
132
138
  * encoded words are legal in a phrase and nowhere else, so there is no encoding this function
@@ -136,8 +142,23 @@ export function encodeAddressPhrase(address: string): string {
136
142
  const match = ADDRESS_SPEC.exec(address);
137
143
  if (match === null) return address;
138
144
  const phrase = address.slice(0, match.index).trim();
139
- if (phrase === '' || isPureAscii(phrase)) return address;
140
- return `${encodeHeaderValue(phrase)} <${match[1] ?? ''}>`;
145
+ if (phrase === '') return address;
146
+ if (!isPureAscii(phrase)) return `${encodeHeaderValue(phrase)} <${match[1] ?? ''}>`;
147
+ if (!needsQuoting(phrase)) return address;
148
+ return `"${phrase.replace(/["\\]/g, (char) => `\\${char}`)}" <${match[1] ?? ''}>`;
149
+ }
150
+
151
+ /**
152
+ * RFC 5322 `specials` that end an atom, `.` aside (`obs-phrase` admits it, and `Jane Q. Doe` is
153
+ * every client's ordinary output). Unquoted, `Doe, Jane <jane@x.test>` is TWO list entries to the
154
+ * receiver — `Doe` and `Jane <jane@x.test>` — so the phrase is quoted rather than sent as written.
155
+ */
156
+ const PHRASE_SPECIALS = /[(),:;<>@[\]\\"]/;
157
+
158
+ /** A phrase that is already one whole quoted-string is the caller's own quoting, left alone. */
159
+ function needsQuoting(phrase: string): boolean {
160
+ if (/^"(?:[^"\\]|\\.)*"$/.test(phrase)) return false;
161
+ return PHRASE_SPECIALS.test(phrase);
141
162
  }
142
163
 
143
164
  /**
package/src/render.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // out of the HTML, so it never rots. Styling is inlined from the layout's tokens because most
4
4
  // clients drop <style>; the dark-mode block is the one exception, for clients that honour it.
5
5
 
6
+ import { escapeHtml } from '@ultimat3/core';
6
7
  import { directionOf, type TranslateVars, type Translator, translatorFor } from '@ultimat3/i18n';
7
8
  import type { CalloutTone, MailBlock, MailTemplate } from './blocks';
8
9
  // The renderer depends on the strings it renders. This is the only module that resolves a `mail.*`
@@ -13,7 +14,7 @@ import type { CalloutTone, MailBlock, MailTemplate } from './blocks';
13
14
  // `registerMailCatalog()` themselves — the exact shape of issue #249.
14
15
  import './catalog';
15
16
  import { layoutUnknown, textMissing } from './errors';
16
- import { escapeHtml, safeUrl, styleAttr } from './html';
17
+ import { safeUrl, styleAttr } from './html';
17
18
  import { layoutFor, registeredLayouts, token } from './layout';
18
19
 
19
20
  /** What the renderer needs from a mail. `MailDefinition` adds the input schema on top. */