@ultimat3/mail 19.1.3 → 19.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -122,6 +122,34 @@
122
122
  - `Bcc` is an envelope field. It reaches `RCPT TO` and Resend's body, never a header — so
123
123
  `assertHeaderSafe` deliberately does NOT check it and `envelope-address.ts` does. Two wire
124
124
  formats, one gate each; naming a `Bcc` header in a refusal would name one no message has.
125
+ - **The envelope gate NORMALISES one thing, and the order is the security property** (`As of
126
+ 2026-09`). `Jane Doe <jane@x.test>` is the ordinary RFC 5322 display form — the `To:` header
127
+ carries it verbatim and the resend, memory and log drivers accept it — while `RCPT TO:` takes an
128
+ addr-spec, so SMTP alone refused it `X_MAIL_ADDRESS_INVALID` for the angle brackets the display
129
+ form is made of. `envelopeAddress(field, address)` in `envelope-address.ts` is the one repair,
130
+ called by `smtpDeliver` for BOTH halves (never a second copy in `driver-smtp.ts`): control
131
+ characters are refused on the RAW value **before** the phrase is stripped, because
132
+ `ops@x.test\r\nRCPT TO:<attacker@evil.test>` strips down to a clean-looking mailbox — a
133
+ strip-then-check turns the injection into a delivery to the attacker.
134
+ - **`From`, `To` and `Cc` encode the display PHRASE, never the list** (`As of 2026-09`). `Subject`
135
+ was RFC 2047 encoded and the address headers were not, so `José Muñoz <jose@x.test>` went on the
136
+ wire as 8-bit UTF-8 in a session `smtp-protocol.ts` never negotiates SMTPUTF8 for. `mime.ts`'s
137
+ `encodeAddressPhrase` splits on the trailing `<addr-spec>` — the same regex `addressSpec` uses —
138
+ encodes the phrase and copies the mailbox through: encoding the whole list would base64 the
139
+ commas and brackets that make it an address list. The `header()` gate still runs on the RAW
140
+ value, and an ARRAY is checked element by element before it is joined, so a CRLF in a display
141
+ name is still `X_MAIL_HEADER_INVALID` rather than hidden inside an encoded word. An ASCII list is
142
+ byte-identical to before.
143
+ - **A non-ASCII MAILBOX is refused, at both gates** (`As of 2026-09-06`). RFC 2047 encoded words
144
+ are legal in a display phrase and nowhere else, so `encodeAddressPhrase` has no encoding for the
145
+ addr-spec — and SMTPUTF8 (RFC 6531), which is what makes a raw UTF-8 mailbox legal, is negotiated
146
+ by nothing in this package. Sending it anyway writes 8-bit octets into `To:` and into
147
+ `MAIL FROM:<…>` beside it, and reports the server's rejection as a TRANSPORT failure after the
148
+ envelope is half written. `hasNonAsciiAddrSpec` (`mime.ts`, the module that owns the addr-spec)
149
+ is the one predicate; `buildMimeMessage` raises `X_MAIL_HEADER_INVALID` and `envelopeAddress`
150
+ raises `X_MAIL_ADDRESS_INVALID` with `meta.reason: 'non-ascii'` — two wire formats, one check
151
+ each, and two reasons on one code so a `cause` is never wrong half the time. The PHRASE still
152
+ travels: `José Muñoz <jose@x.test>` is unchanged.
125
153
  - Recipient addresses stay out of logs and out of error text we write ourselves; the server's own
126
154
  reply is passed through verbatim, and that is where the refused address comes from.
127
155
  - Every header value is checked for CR/LF (`X_MAIL_HEADER_INVALID`) before folding — interpolated
@@ -131,7 +159,8 @@
131
159
  header, so the header gate never sees it, and on the inline send path no schema does either — a
132
160
  `bcc` of `ops@x.test\r\nRCPT TO:<evil@y.test>` relayed mail over the app's own authenticated
133
161
  connection. The refused set is control characters plus `<` and `>`; a space is deliberately
134
- allowed (quoted local-parts) and non-ASCII is SMTPUTF8's question, not this check's.
162
+ allowed (quoted local-parts). Non-ASCII in the MAILBOX is refused too, and by this package:
163
+ SMTPUTF8 (RFC 6531) is what makes a UTF-8 addr-spec legal and `smtp-client.ts` negotiates none.
135
164
 
136
165
  ## Commands
137
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mail",
3
- "version": "19.1.3",
3
+ "version": "19.3.1",
4
4
  "description": "Transactional email as data: one template renders HTML and text, sent through a job.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "19.1.3",
35
- "@ultimat3/i18n": "19.1.3",
36
- "@ultimat3/jobs": "19.1.3",
37
- "@ultimat3/schema": "19.1.3",
38
- "@ultimat3/time": "19.1.3"
34
+ "@ultimat3/core": "19.3.1",
35
+ "@ultimat3/i18n": "19.3.1",
36
+ "@ultimat3/jobs": "19.3.1",
37
+ "@ultimat3/schema": "19.3.1",
38
+ "@ultimat3/time": "19.3.1"
39
39
  }
40
40
  }
@@ -15,7 +15,7 @@ import type { SendResult } from './driver';
15
15
  import { envelopeRecipients, type MailDriver, type MailMessage, resultFor } from './driver';
16
16
  import { sendFailed } from './errors';
17
17
  import { mailMessageIdToken } from './idempotency';
18
- import { addressDomain, addressSpec, buildMimeMessage } from './mime';
18
+ import { addressDomain, buildMimeMessage } from './mime';
19
19
  import {
20
20
  type SmtpConnector,
21
21
  type SmtpSessionOptions,
@@ -200,7 +200,9 @@ export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
200
200
  try {
201
201
  await smtpDeliver(
202
202
  stream,
203
- { from: addressSpec(options.from), recipients: envelopeRecipients(message), data },
203
+ // Display forms in either half are normalised by `smtpDeliver` itself, which is the module
204
+ // that writes the command line — never here, where a second copy of the rule would drift.
205
+ { from: options.from, recipients: envelopeRecipients(message), data },
204
206
  session,
205
207
  );
206
208
  return resultFor('smtp', message, messageId);
@@ -1,8 +1,11 @@
1
1
  // Single responsibility: what may appear inside an SMTP `MAIL FROM:<>` or `RCPT TO:<>`. One gate
2
2
  // for the envelope, exactly as `mime.ts`'s `header()` is the one gate for the message — two wire
3
- // formats, one check each, at the module that owns the format. Refuses; never rewrites.
3
+ // formats, one check each, at the module that owns the format. It refuses, and it normalises one
4
+ // thing only — the RFC 5322 display form down to the mailbox `RCPT TO:` can carry, always after
5
+ // the control-character check, never before it.
4
6
 
5
7
  import { addressInvalid, type EnvelopeAddressField } from './errors';
8
+ import { addressSpec, hasNonAsciiAddrSpec } from './mime';
6
9
 
7
10
  /**
8
11
  * Every character that can restructure the command line: C0 controls (CR and LF above all), DEL,
@@ -11,19 +14,54 @@ import { addressInvalid, type EnvelopeAddressField } from './errors';
11
14
  *
12
15
  * A space is deliberately NOT refused: RFC 5321 allows one inside a quoted local-part, and with
13
16
  * the brackets already refused it can only produce an address the server itself rejects, never a
14
- * second command. Non-ASCII is not refused either — whether a server takes a UTF-8 mailbox is
15
- * SMTPUTF8's question and the server's answer, not a decision this check may make on its behalf.
17
+ * second command.
18
+ *
19
+ * Non-ASCII IS refused, and by this package rather than by the server: SMTPUTF8 (RFC 6531) is what
20
+ * makes a UTF-8 mailbox legal and `smtp-client.ts` negotiates none, so the alternative is writing
21
+ * raw 8-bit octets into `MAIL FROM:<…>` and hoping. That reports as a transport failure at the
22
+ * command, after the envelope is half-written — where a refusal here names the address and runs
23
+ * before a byte is sent. `mime.ts` holds the predicate, at the module that owns the addr-spec.
16
24
  */
17
25
  function isUnsafe(address: string): boolean {
18
26
  for (let index = 0; index < address.length; index += 1) {
19
27
  const code = address.charCodeAt(index);
20
- if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return true;
21
28
  if (code === 0x3c || code === 0x3e) return true; // '<' and '>'
22
29
  }
30
+ return hasControlCharacter(address);
31
+ }
32
+
33
+ /** C0 controls (CR and LF above all), DEL and the C1 range — everything that ends a command line. */
34
+ function hasControlCharacter(address: string): boolean {
35
+ for (let index = 0; index < address.length; index += 1) {
36
+ const code = address.charCodeAt(index);
37
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return true;
38
+ }
23
39
  return false;
24
40
  }
25
41
 
26
42
  /** Throws `X_MAIL_ADDRESS_INVALID` before a single byte of the envelope is written. */
27
43
  export function assertEnvelopeAddress(field: EnvelopeAddressField, address: string): void {
28
- if (isUnsafe(address)) throw addressInvalid(field);
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');
49
+ }
50
+
51
+ /**
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.
56
+ *
57
+ * The control-character half runs on the RAW value, BEFORE the strip, and the order is the whole
58
+ * point: `ops@x.test\r\nRCPT TO:<attacker@evil.test>` strips down to a clean-looking mailbox that
59
+ * every later check waves through, so a strip-then-check would have turned the injection into a
60
+ * delivery to the attacker instead of a refusal.
61
+ */
62
+ 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;
29
67
  }
package/src/errors.ts CHANGED
@@ -211,6 +211,20 @@ const ADDRESS_FIXES: Readonly<Record<EnvelopeAddressField, string>> = {
211
211
  recipient: "pass bare addresses: send(mail, data, { to: ['ada@example.test'], locale })",
212
212
  };
213
213
 
214
+ /**
215
+ * Why an envelope address was refused. Two reasons, one code: the value is recipient data either
216
+ * way, so a caller reading `meta.reason` is the only way to tell them apart — and a `cause` naming
217
+ * both would be wrong half the time, which is the misdirection axiom 4 refuses.
218
+ */
219
+ export type AddressRefusal = 'injection' | 'non-ascii';
220
+
221
+ const ADDRESS_CAUSES: Readonly<Record<AddressRefusal, string>> = {
222
+ injection:
223
+ 'holds a control character or an angle bracket, which would end the command line and inject SMTP commands',
224
+ 'non-ascii':
225
+ 'holds a non-ASCII byte in its mailbox, and this package negotiates no SMTPUTF8, so it cannot be written to the command line at all',
226
+ };
227
+
214
228
  /**
215
229
  * `MAIL FROM:<…>` and `RCPT TO:<…>` are built by interpolation, so a CR or LF in an address ends
216
230
  * the command line and lets the rest of it run as SMTP commands of its own — arbitrary relay over
@@ -219,14 +233,15 @@ const ADDRESS_FIXES: Readonly<Record<EnvelopeAddressField, string>> = {
219
233
  * The value never appears here — an address is recipient data, which this package keeps out of
220
234
  * every string it writes itself.
221
235
  */
222
- export const addressInvalid = (field: EnvelopeAddressField): MailError =>
236
+ export const addressInvalid = (
237
+ field: EnvelopeAddressField,
238
+ reason: AddressRefusal = 'injection',
239
+ ): MailError =>
223
240
  new MailError({
224
241
  code: 'X_MAIL_ADDRESS_INVALID',
225
- cause:
226
- `the SMTP envelope ${field} address holds a control character or an angle bracket, ` +
227
- 'which would end the command line and inject SMTP commands',
242
+ cause: `the SMTP envelope ${field} address ${ADDRESS_CAUSES[reason]}`,
228
243
  fix: ADDRESS_FIXES[field],
229
- meta: { field },
244
+ meta: { field, reason },
230
245
  });
231
246
 
232
247
  /**
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ export type { SmtpDriverOptions } from './driver-smtp';
38
38
  export { createSmtpDriver } from './driver-smtp';
39
39
  export { assertEnvelopeAddress } from './envelope-address';
40
40
  export type {
41
+ AddressRefusal,
41
42
  EnvelopeAddressField,
42
43
  MailErrorCode,
43
44
  MailErrorInit,
package/src/mime.ts CHANGED
@@ -53,15 +53,29 @@ export function buildMimeMessage(message: MailMessage, options: MimeOptions): st
53
53
  // whichever header is added next. The check runs on the RAW value: encoding first would let a
54
54
  // non-ASCII subject hide a line break inside an encoded word instead of refusing it, so the
55
55
  // same input would be accepted or rejected depending on whether it happened to be ASCII.
56
- const header = (name: string, value: string, encode = false): void => {
57
- if (value.includes('\r') || value.includes('\n')) throw headerInvalid(name, message.mailId);
58
- headerLines.push(foldHeaderLine(name, encode ? encodeHeaderValue(value) : value));
56
+ const header = (
57
+ name: string,
58
+ value: string | readonly string[],
59
+ mode: HeaderMode = 'verbatim',
60
+ ): void => {
61
+ // An ARRAY is checked element by element and joined here, never joined by the caller: the
62
+ // check has to see the raw address, and `', '` introduces no line break of its own.
63
+ const parts = typeof value === 'string' ? [value] : value;
64
+ for (const part of parts) {
65
+ if (part.includes('\r') || part.includes('\n')) throw headerInvalid(name, message.mailId);
66
+ // The MAILBOX, not the phrase: `encodeAs` has an RFC 2047 encoding for the display name and
67
+ // none for the addr-spec, and this package negotiates no SMTPUTF8 — so a UTF-8 mailbox would
68
+ // go out as raw 8-bit octets in a header AND in the envelope beside it.
69
+ if (mode === 'address' && hasNonAsciiAddrSpec(part))
70
+ throw headerInvalid(name, message.mailId);
71
+ }
72
+ headerLines.push(foldHeaderLine(name, parts.map((part) => encodeAs(mode, part)).join(', ')));
59
73
  };
60
74
 
61
- header('From', options.from);
62
- header('To', message.to.join(', '));
63
- if (message.cc !== undefined && message.cc.length > 0) header('Cc', message.cc.join(', '));
64
- header('Subject', message.subject, true);
75
+ header('From', options.from, 'address');
76
+ header('To', message.to, 'address');
77
+ if (message.cc !== undefined && message.cc.length > 0) header('Cc', message.cc, 'address');
78
+ header('Subject', message.subject, 'value');
65
79
  header('Date', rfc5322Date(options.date));
66
80
  header('Message-ID', options.messageId);
67
81
  header('MIME-Version', '1.0');
@@ -93,9 +107,60 @@ function bodyPart(contentType: string, text: string): string {
93
107
  );
94
108
  }
95
109
 
110
+ /**
111
+ * How a header VALUE reaches the wire. `verbatim` is a value that is 7-bit by construction (a
112
+ * date, a boundary, a message id); `value` is RFC 2047 over the whole string; `address` encodes
113
+ * only the display phrase, because the commas, angle brackets and addr-specs around it are what
114
+ * make the line an address list and a base64 blob is none of those.
115
+ */
116
+ type HeaderMode = 'verbatim' | 'value' | 'address';
117
+
118
+ function encodeAs(mode: HeaderMode, value: string): string {
119
+ if (mode === 'verbatim') return value;
120
+ if (mode === 'value') return encodeHeaderValue(value);
121
+ return encodeAddressPhrase(value);
122
+ }
123
+
124
+ /**
125
+ * `José Muñoz <jose@x.test>` -> `=?UTF-8?B?…?= <jose@x.test>`. Non-ASCII in an unencoded header is
126
+ * an 8-bit octet on a wire this package never negotiates SMTPUTF8 for, so it is the display name
127
+ * that gets encoded — the addr-spec is copied through untouched, because a mailbox is the
128
+ * 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.
130
+ *
131
+ * The MAILBOX is a different question and `hasNonAsciiAddrSpec` is where it is asked: RFC 2047
132
+ * encoded words are legal in a phrase and nowhere else, so there is no encoding this function
133
+ * could apply to it. `buildMimeMessage` refuses one before it reaches this line.
134
+ */
135
+ export function encodeAddressPhrase(address: string): string {
136
+ const match = ADDRESS_SPEC.exec(address);
137
+ if (match === null) return address;
138
+ const phrase = address.slice(0, match.index).trim();
139
+ if (phrase === '' || isPureAscii(phrase)) return address;
140
+ return `${encodeHeaderValue(phrase)} <${match[1] ?? ''}>`;
141
+ }
142
+
143
+ /**
144
+ * Whether the MAILBOX half of an address carries a byte this package cannot put on the wire.
145
+ *
146
+ * SMTPUTF8 (RFC 6531) is what makes a non-ASCII addr-spec legal, and `smtp-client.ts` negotiates
147
+ * no such thing — so a UTF-8 mailbox reaches `MAIL FROM:<…>` and `RCPT TO:<…>` as raw 8-bit
148
+ * octets, which a conforming server MAY reject. Refused rather than sent, and refused at both
149
+ * gates: the message gate (`buildMimeMessage`) and the envelope gate (`envelopeAddress`), because
150
+ * the two write different wire formats and a check on one says nothing about the other. The
151
+ * PHRASE is untouched — `encodeAddressPhrase` has an encoding for that half and this is the half
152
+ * it has none for.
153
+ */
154
+ export function hasNonAsciiAddrSpec(address: string): boolean {
155
+ return !isPureAscii(addressSpec(address));
156
+ }
157
+
158
+ /** The trailing `<addr-spec>` of an address, which is the boundary between phrase and mailbox. */
159
+ const ADDRESS_SPEC = /<([^<>]+)>\s*$/;
160
+
96
161
  /** `Postly <no-reply@postly.test>` -> `no-reply@postly.test`. A bare address is returned as-is. */
97
162
  export function addressSpec(address: string): string {
98
- const match = /<([^<>]+)>\s*$/.exec(address);
163
+ const match = ADDRESS_SPEC.exec(address);
99
164
  return match?.[1] ?? address;
100
165
  }
101
166
 
@@ -3,7 +3,7 @@
3
3
  // network. Every refusal becomes `X_MAIL_SEND_FAILED` naming the stage and the server's own reply.
4
4
 
5
5
  import { base64Utf8 } from './base64';
6
- import { assertEnvelopeAddress } from './envelope-address';
6
+ import { envelopeAddress } from './envelope-address';
7
7
  import { type MailError, type SendStage, sendFailed } from './errors';
8
8
  import {
9
9
  authPlain,
@@ -175,8 +175,10 @@ export async function smtpDeliver(
175
175
  // at all on the inline send path, and the header gate in `mime.ts` never sees it — an envelope
176
176
  // field is not a header. Checked here rather than at either caller, because this is the module
177
177
  // that builds the line, and it is the last place every present and future caller passes through.
178
- assertEnvelopeAddress('sender', envelope.from);
179
- for (const recipient of envelope.recipients) assertEnvelopeAddress('recipient', recipient);
178
+ const from = envelopeAddress('sender', envelope.from);
179
+ const recipients = envelope.recipients.map((recipient) =>
180
+ envelopeAddress('recipient', recipient),
181
+ );
180
182
 
181
183
  const talk = new Conversation(stream, options.timeoutMs);
182
184
  await talk.expect('greeting', (code) => code === 220);
@@ -210,8 +212,8 @@ export async function smtpDeliver(
210
212
 
211
213
  if (options.user !== undefined) await authenticate(talk, capabilities, options);
212
214
 
213
- await talk.say('from', `MAIL FROM:<${envelope.from}>`, isPositive);
214
- for (const recipient of envelope.recipients) {
215
+ await talk.say('from', `MAIL FROM:<${from}>`, isPositive);
216
+ for (const recipient of recipients) {
215
217
  // Fail closed on any refusal: delivering to three of four addresses and reporting success is
216
218
  // the one outcome the caller cannot detect.
217
219
  await talk.say('recipient', `RCPT TO:<${recipient}>`, isPositive);