@ultimat3/mail 21.0.0 → 22.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/README.md +10 -1
- package/package.json +6 -6
- package/src/idempotency.ts +19 -21
- package/src/index.ts +4 -10
- package/src/smtp-client.ts +27 -1
- package/src/smtp-socket.ts +6 -0
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
|
|
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": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"description": "Transactional email as data: one template renders HTML and text, sent through a job.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/i18n": "
|
|
36
|
-
"@ultimat3/jobs": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "22.0.0",
|
|
35
|
+
"@ultimat3/i18n": "22.0.0",
|
|
36
|
+
"@ultimat3/jobs": "22.0.0",
|
|
37
|
+
"@ultimat3/schema": "22.0.0",
|
|
38
|
+
"@ultimat3/time": "22.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/idempotency.ts
CHANGED
|
@@ -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
|
|
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 !== '')
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
-
|
|
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}:${
|
|
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
|
|
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 {
|
|
32
|
+
export { selectMailDriver } from './driver-env';
|
|
35
33
|
export type { MailFetch, ResendDriverOptions } from './driver-resend';
|
|
36
|
-
export { createResendDriver
|
|
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 {
|
|
97
|
+
export { renderMail, textOf } from './render';
|
|
104
98
|
export type { SmtpConnector, SmtpStream } from './smtp-client';
|
|
105
99
|
|
|
106
100
|
export {
|
package/src/smtp-client.ts
CHANGED
|
@@ -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
|
|
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.
|
package/src/smtp-socket.ts
CHANGED
|
@@ -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
|