@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/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`' `createMemoryDriver` takes, deliberately: two
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 createMemoryDriver(options: MemoryMailDriverOptions = {}): MemoryMailDriver {
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 createUnconfiguredDriver(environment: Environment): MailDriver {
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 createLogDriver(logger = rootLogger): MailDriver {
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 `app.config.ts`. One driver per process, like the job driver. */
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
  }
@@ -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
@@ -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(createMemoryDriver()) in dev, createSmtpDriver({ url: env.SMTP_URL }) live',
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
- `neither SMTP_URL nor RESEND_API_KEY is set in ${environment}, so this process has no ` +
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_...) and ' +
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 = (name: string, mailId: string): MailError =>
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: `the "${name}" header of mail "${mailId}" contains a CR or LF, which would inject headers`,
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
- `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 },
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: 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
 
@@ -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 safeUrl(value: string): string {
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
- export { MAIL_CATALOG, MAIL_CATALOG_LOCALE } from './catalog';
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 { createResendDriver } from './driver-resend';
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 { createSmtpDriver } from './driver-smtp';
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(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 { 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(safeUrl(slot.url));
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
- 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
  /**