@ultimat3/mail 21.0.0 → 22.1.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/README.md CHANGED
@@ -34,7 +34,7 @@ delivers inline only when `{ sync: true }` is passed or no job driver is configu
34
34
  | Every colour is a token | `MAIL_TOKENS` in `layout.ts` holds light + dark hexes; templates never see a hex |
35
35
  | Every date takes an IANA zone | `options.tz`, else `ctx.tz`, else `UTC` |
36
36
  | 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 |
37
- | Sending is a job | `retry: { attempts: 5, backoff: 'exponential' }`, idempotency key derived from `(mailId, recipients, hash(rendered))`, or `(mailId, your key)` when you pass one — a caller's key is scoped to its mail so two templates cannot dedupe each other away |
37
+ | 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 |
38
38
 
39
39
  ## Drivers
40
40
 
@@ -127,6 +127,15 @@ Translating them = shipping `mail.*` keys in an app catalog. Never edit a templa
127
127
  | `X_MAIL_ADDRESS_INVALID` | pass a bare `addr-spec` — an envelope address may hold no control character and no `<`/`>` |
128
128
  | `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 |
129
129
 
130
+ ### Error classes
131
+
132
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
133
+ a job boundary the class is gone and the `code` is what survives — match on that.
134
+
135
+ | Class | Code | Declared in |
136
+ |---|---|---|
137
+ | `MailError` | any `MailErrorCode` — `MAIL_ERROR_CODES` | `src/errors.ts` |
138
+
130
139
  ## Commands
131
140
 
132
141
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mail",
3
- "version": "21.0.0",
3
+ "version": "22.1.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",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "21.0.0",
35
- "@ultimat3/i18n": "21.0.0",
36
- "@ultimat3/jobs": "21.0.0",
37
- "@ultimat3/schema": "21.0.0",
38
- "@ultimat3/time": "21.0.0"
34
+ "@ultimat3/core": "22.1.0",
35
+ "@ultimat3/i18n": "22.1.0",
36
+ "@ultimat3/jobs": "22.1.0",
37
+ "@ultimat3/schema": "22.1.0",
38
+ "@ultimat3/time": "22.1.0"
39
39
  }
40
40
  }
@@ -2,10 +2,13 @@
2
2
  // apart from `job.ts` because the transports need it too: a job retry after a timeout hands the
3
3
  // same envelope to the provider again, and without this key on the wire that is a second email.
4
4
 
5
+ // Core's canonical form, never a private copy: it is the one injective serializer, and a second
6
+ // one is where two spellings of one payload would start hashing differently.
7
+ import { canonicalJson } from '@ultimat3/core';
5
8
  import type { MailMessage } from './driver';
6
9
 
7
10
  /**
8
- * `(mailId, recipients, hash(rendered payload))`, or `(mailId, the caller's key)` when one is
11
+ * `(mailId, hash(recipients + rendered payload))`, or `(mailId, the caller's key)` when one is
9
12
  * supplied. Content-derived on purpose: a retry of the same request produces the same key, while
10
13
  * an intentional resend with different content produces a different one.
11
14
  *
@@ -17,13 +20,19 @@ import type { MailMessage } from './driver';
17
20
  */
18
21
  export function mailIdempotencyKey(message: MailMessage): string {
19
22
  const explicit = message.idempotencyKey;
20
- if (explicit !== undefined && explicit !== '') return `mail:${message.mailId}:${explicit}`;
23
+ if (explicit !== undefined && explicit !== '') {
24
+ // Sent as written when it is a header-safe ASCII token that fits; digested otherwise, because
25
+ // a raw non-ASCII or over-long key is a `TypeError` out of `Headers` or a 400 from Resend.
26
+ return `mail:${message.mailId}:${HEADER_SAFE.test(explicit) ? explicit : contentDigest(explicit)}`;
27
+ }
21
28
  const recipients = [...message.to].map((address) => address.toLowerCase()).sort();
22
- // Every field that reaches the wire is hashed, `replyTo` included: it travels as `Reply-To` and
23
- // as Resend's `reply_to`, so two mails that differ only there are two mails, and a shared key
24
- // would have the provider drop the second one as a duplicate.
29
+ // The RECIPIENTS are hashed with the payload, never spelled out in the key: fifty addresses made
30
+ // a 2 kB `Idempotency-Key` Resend refuses (its limit is 256) — a dead letter — and one non-ASCII
31
+ // address made `Headers` throw, which the job retried as egress until it gave up. Every field
32
+ // that reaches the wire is hashed, `replyTo` included: two mails differing only there are two.
25
33
  const digest = contentDigest(
26
- stableStringify({
34
+ canonicalJson({
35
+ recipients,
27
36
  subject: message.subject,
28
37
  html: message.html,
29
38
  text: message.text,
@@ -35,9 +44,12 @@ export function mailIdempotencyKey(message: MailMessage): string {
35
44
  unsubscribeUrl: message.unsubscribeUrl ?? '',
36
45
  }),
37
46
  );
38
- return `mail:${message.mailId}:${recipients.join(',')}:${digest}`;
47
+ return `mail:${message.mailId}:${digest}`;
39
48
  }
40
49
 
50
+ /** Visible ASCII, bounded well under Resend's 256 so the `mail:<id>:` prefix still fits. */
51
+ const HEADER_SAFE = /^[\x21-\x7e]{1,200}$/;
52
+
41
53
  /**
42
54
  * The `Message-ID` token for a message, stable across every attempt of the same send.
43
55
  *
@@ -53,20 +65,6 @@ export function mailMessageIdToken(message: MailMessage): string {
53
65
  return contentDigest(mailIdempotencyKey(message));
54
66
  }
55
67
 
56
- /** Key order is normalised so two structurally equal payloads hash identically. */
57
- function stableStringify(value: unknown): string {
58
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
59
- if (value !== null && typeof value === 'object') {
60
- const entries = Object.entries(value as Record<string, unknown>)
61
- .filter(([, entry]) => entry !== undefined)
62
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
63
- .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`);
64
- return `{${entries.join(',')}}`;
65
- }
66
- if (value === undefined) return 'null';
67
- return JSON.stringify(value);
68
- }
69
-
70
68
  /** 128 bits of hex: no collision at any volume a mailer reaches, and short enough for a header. */
71
69
  const DIGEST_HEX_CHARS = 32;
72
70
 
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ export { t } from '@ultimat3/schema';
7
7
  export type { CalloutTone, MailBlock, MailTemplate, TemplateArgs } from './blocks';
8
8
  export { blocks } from './blocks';
9
9
 
10
- export { MAIL_CATALOG, MAIL_CATALOG_LOCALE, MAIL_CATALOG_SOURCE } from './catalog';
10
+ export { MAIL_CATALOG, MAIL_CATALOG_LOCALE } from './catalog';
11
11
  export type {
12
12
  MailDriver,
13
13
  MailMessage,
@@ -20,7 +20,6 @@ export {
20
20
  createLogDriver,
21
21
  createMemoryDriver,
22
22
  createUnconfiguredDriver,
23
- envelopeRecipients,
24
23
  isMemoryDriver,
25
24
  isUnconfiguredDriver,
26
25
  mailDriver,
@@ -28,12 +27,11 @@ export {
28
27
  resetMailDriver,
29
28
  setMailDriver,
30
29
  tryMailDriver,
31
- UNCONFIGURED_DRIVER_NAME,
32
30
  } from './driver';
33
31
  export type { MailEnvironment, MailSelection } from './driver-env';
34
- export { MAIL_ENV_KEYS, selectMailDriver } from './driver-env';
32
+ export { selectMailDriver } from './driver-env';
35
33
  export type { MailFetch, ResendDriverOptions } from './driver-resend';
36
- export { createResendDriver, RESEND_BASE_URL } from './driver-resend';
34
+ export { createResendDriver } from './driver-resend';
37
35
  export type { SmtpDriverOptions } from './driver-smtp';
38
36
  export { createSmtpDriver } from './driver-smtp';
39
37
  export { assertEnvelopeAddress } from './envelope-address';
@@ -78,12 +76,8 @@ export type {
78
76
  export {
79
77
  BASE_LAYOUT,
80
78
  baseLayout,
81
- DARK_RULES,
82
- darkModeCss,
83
79
  layoutFor,
84
- MAIL_FONT_STACK,
85
80
  MAIL_TOKENS,
86
- MAIL_WIDTH_PX,
87
81
  registeredLayouts,
88
82
  registerLayout,
89
83
  token,
@@ -100,7 +94,7 @@ export {
100
94
  sendById,
101
95
  } from './mail';
102
96
  export type { RenderableMail, RenderedMail, RenderOptions } from './render';
103
- export { FOOTER_KEYS, renderMail, textOf, UNSUBSCRIBE_KEY } from './render';
97
+ export { renderMail, textOf } from './render';
104
98
  export type { SmtpConnector, SmtpStream } from './smtp-client';
105
99
 
106
100
  export {
@@ -24,6 +24,11 @@ export interface SmtpStream {
24
24
  write(data: string): Promise<void>;
25
25
  /** STARTTLS: negotiate TLS in place. Everything read or written after this is encrypted. */
26
26
  startTls(): Promise<void>;
27
+ /**
28
+ * True when the server sent bytes the client has not read yet. Asked once, after the STARTTLS
29
+ * `220`: anything already waiting there is plaintext a man-in-the-middle can have appended.
30
+ */
31
+ buffered?(): boolean;
27
32
  close(): void;
28
33
  }
29
34
 
@@ -88,7 +93,7 @@ const refused = (stage: SendStage, reply: SmtpReply): MailError =>
88
93
 
89
94
  /** Reads whole replies off a chunked stream, with a deadline on every one of them. */
90
95
  class Conversation {
91
- private readonly parser = createReplyParser();
96
+ private parser = createReplyParser();
92
97
  private readonly pending: SmtpReply[] = [];
93
98
 
94
99
  constructor(
@@ -96,6 +101,26 @@ class Conversation {
96
101
  private readonly timeoutMs: number,
97
102
  ) {}
98
103
 
104
+ /**
105
+ * RFC 3207 §4.2, both halves: nothing may be buffered after the STARTTLS `220` — any byte there
106
+ * arrived in PLAINTEXT and would be read as the TLS side's EHLO reply, so an injected
107
+ * `250 AUTH …` chose how credentials were sent — and the reader starts over on the TLS side.
108
+ */
109
+ assertNothingAfterStarttls(): void {
110
+ if (this.pending.length > 0 || this.parser.hasPending() || this.stream.buffered?.() === true) {
111
+ throw sendFailed({
112
+ driver: 'smtp',
113
+ stage: 'starttls',
114
+ detail:
115
+ 'the server sent bytes after its STARTTLS 220 and before the TLS handshake — plaintext a ' +
116
+ 'man-in-the-middle can append (RFC 3207 §4.2), so the session is refused before any credential',
117
+ retryable: false,
118
+ fix: FIXES['starttls'] ?? 'set SMTP_URL in .env to smtps://host:465',
119
+ });
120
+ }
121
+ this.parser = createReplyParser();
122
+ }
123
+
99
124
  /** Sends one command line and reads the reply it expects. The line is never logged. */
100
125
  async say(stage: SendStage, line: string, wanted: (code: number) => boolean): Promise<SmtpReply> {
101
126
  await this.stream.write(`${line}\r\n`);
@@ -191,6 +216,7 @@ export async function smtpDeliver(
191
216
 
192
217
  if (!secure && capabilities.starttls) {
193
218
  await talk.say('starttls', 'STARTTLS', (code) => code === 220);
219
+ talk.assertNothingAfterStarttls();
194
220
  await stream.startTls();
195
221
  // Capabilities before TLS are not the capabilities after it: most servers only advertise AUTH
196
222
  // once the channel is encrypted, and a cleartext EHLO can be stripped in flight anyway.
@@ -66,6 +66,11 @@ class ChunkQueue {
66
66
  this.take()?.reject(error);
67
67
  }
68
68
 
69
+ /** Chunks received and not yet read — what `buffered()` reports after a STARTTLS 220. */
70
+ get size(): number {
71
+ return this.chunks.length;
72
+ }
73
+
69
74
  read(): Promise<string | undefined> {
70
75
  const next = this.chunks.shift();
71
76
  if (next !== undefined) return Promise.resolve(next);
@@ -233,6 +238,7 @@ export function smtpStreamOver(runtime: BunConnect, target: SmtpTarget): Promise
233
238
 
234
239
  return {
235
240
  read: () => queue.read(),
241
+ buffered: () => queue.size > 0,
236
242
  write: (data: string) => flush(encoder.encode(data)),
237
243
  startTls: () => {
238
244
  // Bun hands back `[raw, tls]`; every later read and write goes through the second one, and