@ultimat3/mail 23.0.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 +20 -5
- package/README.md +7 -3
- package/package.json +6 -6
- package/src/driver-smtp.ts +20 -2
- package/src/driver.ts +18 -2
- package/src/envelope-address.ts +34 -14
- package/src/errors.ts +39 -5
- package/src/html.ts +4 -15
- package/src/index.ts +2 -3
- package/src/job.ts +16 -4
- package/src/layout.ts +2 -1
- package/src/mail.ts +25 -14
- package/src/mime.ts +28 -7
- package/src/render.ts +2 -1
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
|
|
167
|
-
`
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
24
|
-
|
|
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` |
|
|
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": "
|
|
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",
|
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/i18n": "
|
|
37
|
-
"@ultimat3/jobs": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
39
|
-
"@ultimat3/time": "
|
|
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"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/driver-smtp.ts
CHANGED
|
@@ -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:
|
|
88
|
-
...(url.password === '' ? {} : { 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 `
|
|
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
|
}
|
package/src/envelope-address.ts
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* the
|
|
55
|
-
*
|
|
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
|
-
|
|
64
|
-
|
|
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 = (
|
|
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:
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
6
|
-
'&': '&',
|
|
7
|
-
'<': '<',
|
|
8
|
-
'>': '>',
|
|
9
|
-
'"': '"',
|
|
10
|
-
"'": ''',
|
|
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(
|
|
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:
|
|
21
|
-
cc: t.array(
|
|
22
|
-
bcc: t.array(
|
|
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 {
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
83
|
-
//
|
|
84
|
-
|
|
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 === ''
|
|
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 {
|
|
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. */
|