@ultimat3/mail 23.0.0 → 25.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 +77 -12
- package/README.md +85 -14
- package/package.json +9 -8
- package/src/catalog.ts +3 -12
- package/src/delivery-event-errors.ts +148 -0
- package/src/delivery-event-ses.ts +101 -0
- package/src/delivery-event.ts +103 -0
- package/src/driver-env.ts +92 -19
- package/src/driver-resend.ts +2 -2
- package/src/driver-ses.ts +205 -0
- package/src/driver-smtp.ts +30 -5
- package/src/driver.ts +28 -6
- package/src/envelope-address.ts +34 -14
- package/src/errors.ts +52 -11
- package/src/events.ts +28 -0
- package/src/html.ts +5 -16
- package/src/index.ts +20 -10
- package/src/job.ts +16 -4
- package/src/layout.ts +3 -2
- package/src/mail.ts +25 -14
- package/src/mime.ts +28 -7
- package/src/render.ts +7 -5
- package/src/resend-event-receiver.ts +111 -0
- package/src/resend-signature.ts +93 -0
- package/src/retain-mime.ts +142 -0
- package/src/ses-event-receiver.ts +141 -0
- package/src/ses-failure.ts +114 -0
- package/src/smtp-client.ts +3 -3
- package/src/smtp-socket.ts +1 -1
- package/src/sns-certificate-cache.ts +111 -0
- package/src/sns-signature.ts +187 -0
- package/src/transform.ts +13 -3
- package/src/x509-spki.ts +67 -0
package/src/driver.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from '@ultimat3/core';
|
|
14
14
|
import { driverUnavailable, mailCredentialMissing } from './errors';
|
|
15
15
|
import { mailIdempotencyKey, mailMessageIdToken } from './idempotency';
|
|
16
|
+
import type { RetainedMime } from './retain-mime';
|
|
16
17
|
|
|
17
18
|
/** The rendered envelope. Everything a transport needs; nothing it does not. */
|
|
18
19
|
export interface MailMessage {
|
|
@@ -46,6 +47,11 @@ export interface SendResult {
|
|
|
46
47
|
/** True when the message was handed to the queue instead of a transport. */
|
|
47
48
|
readonly queued: boolean;
|
|
48
49
|
readonly idempotencyKey: string;
|
|
50
|
+
/**
|
|
51
|
+
* The exact MIME this process handed the provider — present only on a transport that builds it
|
|
52
|
+
* (SMTP, SES) constructed with `retainMime`. Bounded: over the cap only the digest is kept.
|
|
53
|
+
*/
|
|
54
|
+
readonly mime?: RetainedMime | undefined;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export interface MailDriver {
|
|
@@ -62,7 +68,7 @@ export function messageHeaders(message: MailMessage): Readonly<Record<string, st
|
|
|
62
68
|
const headers: Record<string, string> = { 'Auto-Submitted': 'auto-generated' };
|
|
63
69
|
if (message.replyTo !== undefined) headers['Reply-To'] = message.replyTo;
|
|
64
70
|
if (message.unsubscribeUrl !== undefined) {
|
|
65
|
-
headers['List-Unsubscribe'] = `<${message.unsubscribeUrl}>`;
|
|
71
|
+
headers['List-Unsubscribe'] = `<${unsubscribeTarget(message.unsubscribeUrl)}>`;
|
|
66
72
|
if (message.unsubscribeOneClick !== false) {
|
|
67
73
|
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
|
|
68
74
|
}
|
|
@@ -70,6 +76,22 @@ export function messageHeaders(message: MailMessage): Readonly<Record<string, st
|
|
|
70
76
|
return headers;
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The url as the WHATWG serialiser writes it: host as punycode, path, query and fragment
|
|
81
|
+
* percent-encoded — 7-bit, and with no bare `>` to close the angle bracket early. It went out as
|
|
82
|
+
* written, so a non-ASCII url was raw 8-bit octets in a header. A url holding a control character
|
|
83
|
+
* is copied through UNTOUCHED: the serialiser silently drops CR, LF and tab, and a header break is
|
|
84
|
+
* refused by the CR/LF gate, never stripped into a different url. `send()` and the queue schema
|
|
85
|
+
* refuse an unparseable url first; a hand-built one is copied through and stays the caller's.
|
|
86
|
+
*/
|
|
87
|
+
function unsubscribeTarget(url: string): string {
|
|
88
|
+
if (CONTROL.test(url)) return url;
|
|
89
|
+
return URL.parse(url)?.href ?? url;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: the control range IS the rule.
|
|
93
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
94
|
+
|
|
73
95
|
/**
|
|
74
96
|
* Every address the envelope is delivered to. `Bcc` is one of them and never a header —
|
|
75
97
|
* an SMTP `RCPT TO` carries it, and putting it in the message would leak the blind list.
|
|
@@ -112,7 +134,7 @@ export interface MemoryMailDriver extends MailDriver {
|
|
|
112
134
|
}
|
|
113
135
|
|
|
114
136
|
/**
|
|
115
|
-
* The same `{ clock }` shape `@ultimat3/jobs`' `
|
|
137
|
+
* The same `{ clock }` shape `@ultimat3/jobs`' `memoryJobDriver` takes, deliberately: two
|
|
116
138
|
* in-memory drivers named the same thing in one test file may not want two spellings of "freeze
|
|
117
139
|
* time". An options object rather than a positional argument so the next seam is additive.
|
|
118
140
|
*/
|
|
@@ -125,7 +147,7 @@ export interface MemoryMailDriverOptions {
|
|
|
125
147
|
* `/_x` panel ORDER on, so a suite asserting which message is newest could only race the wall
|
|
126
148
|
* clock — two sends inside one millisecond tie, and nothing could state the intended order.
|
|
127
149
|
*/
|
|
128
|
-
export function
|
|
150
|
+
export function memoryMailDriver(options: MemoryMailDriverOptions = {}): MemoryMailDriver {
|
|
129
151
|
const clock = options.clock ?? systemClock;
|
|
130
152
|
const sent: SentMail[] = [];
|
|
131
153
|
return {
|
|
@@ -171,7 +193,7 @@ export const UNCONFIGURED_DRIVER_NAME = 'unconfigured';
|
|
|
171
193
|
* that does sends nothing silently. The memory driver in that position answered `accepted` for
|
|
172
194
|
* every message — a password reset reported as delivered, with no error anywhere to find it by.
|
|
173
195
|
*/
|
|
174
|
-
export function
|
|
196
|
+
export function unconfiguredMailDriver(environment: Environment): MailDriver {
|
|
175
197
|
return {
|
|
176
198
|
name: UNCONFIGURED_DRIVER_NAME,
|
|
177
199
|
// Rejected rather than thrown: `send()` and `sendMailJob` both await this, and a synchronous
|
|
@@ -197,7 +219,7 @@ export function isUnconfiguredDriver(driver: MailDriver): boolean {
|
|
|
197
219
|
* framework's existing spelling for the same key digested — it was written for exactly this
|
|
198
220
|
* reason and says so — and it correlates just as well, because it is stable per message.
|
|
199
221
|
*/
|
|
200
|
-
export function
|
|
222
|
+
export function logMailDriver(logger = rootLogger): MailDriver {
|
|
201
223
|
return {
|
|
202
224
|
name: 'log',
|
|
203
225
|
send(message: MailMessage): Promise<SendResult> {
|
|
@@ -216,7 +238,7 @@ export function createLogDriver(logger = rootLogger): MailDriver {
|
|
|
216
238
|
|
|
217
239
|
let ambient: MailDriver | undefined;
|
|
218
240
|
|
|
219
|
-
/** Set once at boot from `
|
|
241
|
+
/** Set once at boot from the environment (`selectMailDriver`). One per process, like the job driver. */
|
|
220
242
|
export function setMailDriver(driver: MailDriver): void {
|
|
221
243
|
ambient = driver;
|
|
222
244
|
}
|
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
|
@@ -21,6 +21,9 @@ export const MAIL_ERROR_CODES = [
|
|
|
21
21
|
'X_MAIL_ADDRESS_INVALID',
|
|
22
22
|
'X_MAIL_SEND_FAILED',
|
|
23
23
|
'X_MAIL_TRANSFORM_FAILED',
|
|
24
|
+
'X_MAIL_EVENT_UNVERIFIED',
|
|
25
|
+
'X_MAIL_EVENT_INVALID',
|
|
26
|
+
'X_MAIL_EVENT_PROVIDER_UNREACHABLE',
|
|
24
27
|
] as const;
|
|
25
28
|
|
|
26
29
|
export type MailErrorCode = (typeof MAIL_ERROR_CODES)[number];
|
|
@@ -36,6 +39,9 @@ export const MAIL_ERROR_TITLES: Readonly<Record<MailErrorCode, string>> = {
|
|
|
36
39
|
X_MAIL_ADDRESS_INVALID: 'an envelope address could restructure the SMTP command line',
|
|
37
40
|
X_MAIL_SEND_FAILED: 'the mail transport refused the message',
|
|
38
41
|
X_MAIL_TRANSFORM_FAILED: 'the app mail transform threw or returned no message',
|
|
42
|
+
X_MAIL_EVENT_UNVERIFIED: 'a delivery notification is not signed by its provider',
|
|
43
|
+
X_MAIL_EVENT_INVALID: 'a delivery notification is not a shape this receiver reads',
|
|
44
|
+
X_MAIL_EVENT_PROVIDER_UNREACHABLE: 'an SNS endpoint the receiver needed did not answer',
|
|
39
45
|
};
|
|
40
46
|
|
|
41
47
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -159,7 +165,7 @@ export const driverUnavailable = (what: string): MailError =>
|
|
|
159
165
|
new MailError({
|
|
160
166
|
code: 'X_MAIL_DRIVER_UNAVAILABLE',
|
|
161
167
|
cause: `${what} — nothing can deliver the message`,
|
|
162
|
-
fix: 'setMailDriver(
|
|
168
|
+
fix: 'setMailDriver(memoryMailDriver()) in dev, smtpMailDriver({ url: env.SMTP_URL }) live',
|
|
163
169
|
meta: { what },
|
|
164
170
|
});
|
|
165
171
|
|
|
@@ -177,27 +183,62 @@ export const mailCredentialMissing = (environment: Environment): MailError =>
|
|
|
177
183
|
new MailError({
|
|
178
184
|
code: 'X_MAIL_CREDENTIAL_MISSING',
|
|
179
185
|
cause:
|
|
180
|
-
`
|
|
181
|
-
'transport — the message was not delivered and was not queued anywhere',
|
|
186
|
+
`none of SMTP_URL, RESEND_API_KEY or SES_REGION is set in ${environment}, so this process ` +
|
|
187
|
+
'has no transport — the message was not delivered and was not queued anywhere',
|
|
182
188
|
fix:
|
|
183
|
-
'set SMTP_URL="smtps://user:pass@host:465" (or RESEND_API_KEY=re_
|
|
189
|
+
'set SMTP_URL="smtps://user:pass@host:465" (or RESEND_API_KEY=re_..., or SES_REGION with ' +
|
|
190
|
+
'SES_ACCESS_KEY_ID and SES_SECRET_ACCESS_KEY) and ' +
|
|
184
191
|
'MAIL_FROM="App <no-reply@yourdomain.test>" in the deployment environment, then restart',
|
|
185
|
-
meta: { environment, missing: ['SMTP_URL', 'RESEND_API_KEY'] },
|
|
192
|
+
meta: { environment, missing: ['SMTP_URL', 'RESEND_API_KEY', 'SES_REGION'] },
|
|
186
193
|
});
|
|
187
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Why a header was refused. Two reasons, one code, as `AddressRefusal` is for the envelope: a
|
|
197
|
+
* non-ASCII MAILBOX in an address header was raised with the line-break cause, which named a
|
|
198
|
+
* character the value did not hold.
|
|
199
|
+
*/
|
|
200
|
+
export type HeaderRefusal = 'line-break' | 'non-ascii';
|
|
201
|
+
|
|
202
|
+
/** The `send()` option each address header comes from; `From` is the transport's own config. */
|
|
203
|
+
const ADDRESS_OPTION: ReadonlyMap<string, string> = new Map([
|
|
204
|
+
['To', 'to'],
|
|
205
|
+
['Cc', 'cc'],
|
|
206
|
+
['Reply-To', 'replyTo'],
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The domain as punycode is the ASCII form; a non-ASCII LOCAL part has none and needs another
|
|
211
|
+
* mailbox. The example is fixed text — an address is recipient data, never echoed back.
|
|
212
|
+
*/
|
|
213
|
+
const nonAsciiFix = (name: string): string => {
|
|
214
|
+
const option = ADDRESS_OPTION.get(name);
|
|
215
|
+
return option === undefined
|
|
216
|
+
? 'set mail.from in app.config.ts to an ASCII mailbox, e.g. no-reply@example.test'
|
|
217
|
+
: `pass the mailbox's ASCII form, the domain as punycode: send(mail, data, { ${option}: 'jose@xn--exmple-cua.test', locale })`;
|
|
218
|
+
};
|
|
219
|
+
|
|
188
220
|
/**
|
|
189
221
|
* A CR or LF inside a header value ends the header early and lets whatever follows become new
|
|
190
222
|
* headers — the recipient list, a forged `From`. Interpolated data reaches `Subject`, so this is
|
|
191
223
|
* refused rather than stripped: silently rewriting a subject is its own surprise.
|
|
192
224
|
*/
|
|
193
|
-
export const headerInvalid = (
|
|
225
|
+
export const headerInvalid = (
|
|
226
|
+
name: string,
|
|
227
|
+
mailId: string,
|
|
228
|
+
reason: HeaderRefusal = 'line-break',
|
|
229
|
+
): MailError =>
|
|
194
230
|
new MailError({
|
|
195
231
|
code: 'X_MAIL_HEADER_INVALID',
|
|
196
|
-
cause:
|
|
232
|
+
cause:
|
|
233
|
+
reason === 'line-break'
|
|
234
|
+
? `the "${name}" header of mail "${mailId}" contains a CR or LF, which would inject headers`
|
|
235
|
+
: `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
236
|
fix:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
237
|
+
reason === 'line-break'
|
|
238
|
+
? `strip line breaks from the value before it reaches the header: ` +
|
|
239
|
+
`t('mail.${mailId}.subject', { ...data, x: String(x).replace(/[\\r\\n]+/g, ' ') })`
|
|
240
|
+
: nonAsciiFix(name),
|
|
241
|
+
meta: { header: name, mailId, reason },
|
|
201
242
|
});
|
|
202
243
|
|
|
203
244
|
/**
|
|
@@ -275,7 +316,7 @@ export type SendStage =
|
|
|
275
316
|
* code or an HTTP status — because "mail failed" without either is not a diagnosis.
|
|
276
317
|
*/
|
|
277
318
|
export interface SendFailure {
|
|
278
|
-
readonly driver: 'smtp' | 'resend';
|
|
319
|
+
readonly driver: 'smtp' | 'resend' | 'ses';
|
|
279
320
|
readonly stage: SendStage;
|
|
280
321
|
readonly detail: string;
|
|
281
322
|
/** SMTP reply code (4xx/5xx) or HTTP status. Absent when the socket never answered. */
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Single responsibility: the public API of `@ultimat3/mail/events` — the delivery-notification
|
|
2
|
+
// receivers and their verification. A subpath and not the barrel because only an app's webhook
|
|
3
|
+
// route uses them, and every serving role (worker, scheduler, migrate) loads the barrel to send.
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
DeliveryEvent,
|
|
7
|
+
DeliveryEventKind,
|
|
8
|
+
DeliveryOutcome,
|
|
9
|
+
DeliveryReceiver,
|
|
10
|
+
} from './delivery-event';
|
|
11
|
+
export { DEFAULT_DELIVERY_BODY_LIMIT } from './delivery-event';
|
|
12
|
+
export type {
|
|
13
|
+
DeliveryProvider,
|
|
14
|
+
DeliveryRefusal,
|
|
15
|
+
DeliveryShapeProblem,
|
|
16
|
+
ResendRefusal,
|
|
17
|
+
} from './delivery-event-errors';
|
|
18
|
+
export {
|
|
19
|
+
deliveryEventInvalid,
|
|
20
|
+
deliveryEventUnverified,
|
|
21
|
+
deliveryProviderUnreachable,
|
|
22
|
+
} from './delivery-event-errors';
|
|
23
|
+
export type { ResendEventReceiverOptions } from './resend-event-receiver';
|
|
24
|
+
export { resendEventReceiver } from './resend-event-receiver';
|
|
25
|
+
export { DEFAULT_SVIX_TOLERANCE_MS } from './resend-signature';
|
|
26
|
+
export type { SesEventReceiverOptions } from './ses-event-receiver';
|
|
27
|
+
export { DEFAULT_SNS_TOLERANCE_MS, sesEventReceiver } from './ses-event-receiver';
|
|
28
|
+
export type { SnsCertificateFetch } from './sns-certificate-cache';
|
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
|
|
|
@@ -21,7 +10,7 @@ const SAFE_PROTOCOLS: readonly string[] = ['http:', 'https:', 'mailto:'];
|
|
|
21
10
|
* `javascript:` and `data:` hrefs are stripped rather than escaped: some clients still
|
|
22
11
|
* follow them, and a link the recipient cannot trust is worse than a dead one.
|
|
23
12
|
*/
|
|
24
|
-
export function
|
|
13
|
+
export function safeMailHref(value: string): string {
|
|
25
14
|
let parsed: URL;
|
|
26
15
|
try {
|
|
27
16
|
parsed = new URL(value);
|
package/src/index.ts
CHANGED
|
@@ -6,8 +6,15 @@ export type { Infer } from '@ultimat3/schema';
|
|
|
6
6
|
export { t } from '@ultimat3/schema';
|
|
7
7
|
export type { CalloutTone, MailBlock, MailTemplate, TemplateArgs } from './blocks';
|
|
8
8
|
export { blocks } from './blocks';
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
export { MAIL_CATALOG } from './catalog';
|
|
10
|
+
// Type-only, so a sender can name an event without loading a receiver; the receivers are
|
|
11
|
+
// `@ultimat3/mail/events`, kept off the barrel every serving role evaluates.
|
|
12
|
+
export type {
|
|
13
|
+
DeliveryEvent,
|
|
14
|
+
DeliveryEventKind,
|
|
15
|
+
DeliveryOutcome,
|
|
16
|
+
DeliveryReceiver,
|
|
17
|
+
} from './delivery-event';
|
|
11
18
|
export type {
|
|
12
19
|
MailDriver,
|
|
13
20
|
MailMessage,
|
|
@@ -17,23 +24,25 @@ export type {
|
|
|
17
24
|
SentMail,
|
|
18
25
|
} from './driver';
|
|
19
26
|
export {
|
|
20
|
-
createLogDriver,
|
|
21
|
-
createMemoryDriver,
|
|
22
|
-
createUnconfiguredDriver,
|
|
23
27
|
isMemoryDriver,
|
|
24
28
|
isUnconfiguredDriver,
|
|
29
|
+
logMailDriver,
|
|
25
30
|
mailDriver,
|
|
31
|
+
memoryMailDriver,
|
|
26
32
|
messageHeaders,
|
|
27
33
|
resetMailDriver,
|
|
28
34
|
setMailDriver,
|
|
29
35
|
tryMailDriver,
|
|
36
|
+
unconfiguredMailDriver,
|
|
30
37
|
} from './driver';
|
|
31
|
-
export type { MailEnvironment, MailSelection } from './driver-env';
|
|
38
|
+
export type { MailEnvironment, MailSelection, MailSelectOptions } from './driver-env';
|
|
32
39
|
export { selectMailDriver } from './driver-env';
|
|
33
40
|
export type { MailFetch, ResendDriverOptions } from './driver-resend';
|
|
34
|
-
export {
|
|
41
|
+
export { resendMailDriver } from './driver-resend';
|
|
42
|
+
export type { SesDriverOptions } from './driver-ses';
|
|
43
|
+
export { sesEndpoint, sesMailDriver } from './driver-ses';
|
|
35
44
|
export type { SmtpDriverOptions } from './driver-smtp';
|
|
36
|
-
export {
|
|
45
|
+
export { smtpMailDriver } from './driver-smtp';
|
|
37
46
|
export { assertEnvelopeAddress } from './envelope-address';
|
|
38
47
|
export type {
|
|
39
48
|
AddressRefusal,
|
|
@@ -61,8 +70,7 @@ export {
|
|
|
61
70
|
transformFailed,
|
|
62
71
|
} from './errors';
|
|
63
72
|
export { assertHeaderSafe } from './header-safety';
|
|
64
|
-
|
|
65
|
-
export { escapeHtml, safeUrl } from './html';
|
|
73
|
+
export { safeMailHref } from './html';
|
|
66
74
|
|
|
67
75
|
export { mailIdempotencyKey, mailMessageIdToken } from './idempotency';
|
|
68
76
|
export { mailMessageSchema, sendMailJob } from './job';
|
|
@@ -96,6 +104,8 @@ export {
|
|
|
96
104
|
} from './mail';
|
|
97
105
|
export type { RenderableMail, RenderedMail, RenderOptions } from './render';
|
|
98
106
|
export { renderMail, textOf } from './render';
|
|
107
|
+
export type { RetainedMime, RetainedMimeEntry, RetainMimeOptions } from './retain-mime';
|
|
108
|
+
export { DEFAULT_RETAIN_MIME_MAX_BYTES, RETAIN_MIME_CEILING_BYTES } from './retain-mime';
|
|
99
109
|
export type { SmtpConnector, SmtpStream } from './smtp-client';
|
|
100
110
|
export {
|
|
101
111
|
FRAMEWORK_MAILS,
|
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 { safeMailHref, styleAttr } from './html';
|
|
8
9
|
|
|
9
10
|
export type ColorScheme = 'light' | 'dark';
|
|
10
11
|
|
|
@@ -199,7 +200,7 @@ function footerHtml(input: LayoutInput): string {
|
|
|
199
200
|
const lines = input.footer.map((line) => `<div>${escapeHtml(line)}</div>`);
|
|
200
201
|
const slot = input.unsubscribe;
|
|
201
202
|
if (slot !== undefined) {
|
|
202
|
-
const href = escapeHtml(
|
|
203
|
+
const href = escapeHtml(safeMailHref(slot.url));
|
|
203
204
|
const label = escapeHtml(slot.label);
|
|
204
205
|
lines.push(`<div><a data-x="a" href="${href}" ${UNSUBSCRIBE_STYLE}>${label}</a></div>`);
|
|
205
206
|
}
|
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
|
/**
|