@nxgt/mail 0.2.0 → 0.3.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 +57 -3
- package/dist/chunks/{index-4h39j3n7.js → index-nkzwt8vn.js} +39 -2
- package/dist/chunks/index-nkzwt8vn.js.map +11 -0
- package/dist/conformance/index.js +1 -1
- package/dist/index.js +1 -1
- package/dist/memory.d.ts +7 -1
- package/dist/memory.d.ts.map +1 -1
- package/dist/message.d.ts +2 -1
- package/dist/message.d.ts.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/README.md +3 -3
- package/docs/guide/sending.md +91 -4
- package/docs/guide/testing.md +91 -4
- package/docs/guide/transports.md +67 -8
- package/docs/roadmap.md +19 -10
- package/docs/troubleshooting.md +93 -2
- package/package.json +1 -1
- package/dist/chunks/index-4h39j3n7.js.map +0 -11
package/README.md
CHANGED
|
@@ -154,6 +154,45 @@ larger on the way.
|
|
|
154
154
|
Inline images (`cid:`) are not supported yet. See
|
|
155
155
|
[Sending — attachments](docs/guide/sending.md#attachments).
|
|
156
156
|
|
|
157
|
+
### Idempotency — a retry that delivers once
|
|
158
|
+
|
|
159
|
+
`idempotencyKey` names a send, so sending it again — a retry after a timeout,
|
|
160
|
+
a job run twice — delivers it once where the transport can deduplicate.
|
|
161
|
+
Derive it from what the e-mail is about, never from the time or a random
|
|
162
|
+
value. The memory mailer honours it, so a test can prove a retry is safe:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { expect, it } from 'bun:test';
|
|
166
|
+
import { createMemoryMailer } from '@nxgt/mail';
|
|
167
|
+
|
|
168
|
+
it('sends one receipt, however often the job runs', async () => {
|
|
169
|
+
const mailer = createMemoryMailer();
|
|
170
|
+
const receipt = {
|
|
171
|
+
to: 'ada@example.com',
|
|
172
|
+
subject: 'Your receipt',
|
|
173
|
+
html: '<p>Thank you for your order.</p>',
|
|
174
|
+
text: 'Thank you for your order.',
|
|
175
|
+
idempotencyKey: 'order-42/receipt', // 1 to 256 visible ASCII characters
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const first = await mailer.send(receipt);
|
|
179
|
+
const again = await mailer.send(receipt); // the retry
|
|
180
|
+
|
|
181
|
+
expect(again).toEqual(first); // { messageId: 'memory-1' }
|
|
182
|
+
expect(mailer.sent).toHaveLength(1); // delivered once
|
|
183
|
+
expect(mailer.attempts).toBe(2);
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
A different message under a key already delivered is refused with
|
|
188
|
+
`MailRefused` — a key names one e-mail — and a failed send leaves its key
|
|
189
|
+
free, so its retry delivers. `@nxgt/mail-resend`
|
|
190
|
+
sends the key as Resend's `Idempotency-Key`, which Resend keeps for 24 hours;
|
|
191
|
+
`@nxgt/mail-smtp` ignores it — SMTP has no such mechanism, and a message sent
|
|
192
|
+
twice is delivered twice. A key that is not 1 to 256 visible ASCII characters
|
|
193
|
+
is refused with `MailRefused`. See
|
|
194
|
+
[Sending — idempotency](docs/guide/sending.md#idempotency--sending-once).
|
|
195
|
+
|
|
157
196
|
### Errors — switch on `code`
|
|
158
197
|
|
|
159
198
|
Both errors extend `MailError`, whose `code` is a union a `switch` exhausts.
|
|
@@ -251,13 +290,19 @@ export function createHttpMailer(endpoint: string, apiKey: string): Mailer {
|
|
|
251
290
|
return {
|
|
252
291
|
async send(message) {
|
|
253
292
|
checkMessage(message); // MailRefused, naming where, never the value
|
|
293
|
+
const { idempotencyKey, ...fields } = message; // names the send: never in the body
|
|
254
294
|
const attachments = message.attachments?.length
|
|
255
295
|
? message.attachments.map((file) => ({ ...file, content: base64Of(file.content) }))
|
|
256
296
|
: undefined; // an empty list is none
|
|
257
297
|
const response = await fetch(endpoint, {
|
|
258
298
|
method: 'POST',
|
|
259
|
-
headers: {
|
|
260
|
-
|
|
299
|
+
headers: {
|
|
300
|
+
authorization: `Bearer ${apiKey}`,
|
|
301
|
+
'content-type': 'application/json',
|
|
302
|
+
// if the provider deduplicates; a transport whose provider cannot ignores the key
|
|
303
|
+
...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }),
|
|
304
|
+
},
|
|
305
|
+
body: JSON.stringify({ ...fields, to: recipientsOf(message), attachments }),
|
|
261
306
|
}).catch((cause: unknown) => {
|
|
262
307
|
throw new MailFailure('send: the provider could not be reached', { cause });
|
|
263
308
|
});
|
|
@@ -334,6 +379,10 @@ a recipient no check saw: `checkMessage` refuses `To`, `Cc`, `Bcc`, `From`,
|
|
|
334
379
|
again unchanged. Nothing in this package retries a `MAIL_FAILED`: a retry is
|
|
335
380
|
your decision, made where you can see it.
|
|
336
381
|
|
|
382
|
+
**An idempotency key from the clock or a random value protects nothing.**
|
|
383
|
+
``idempotencyKey: `receipt-${Date.now()}` `` gives the retry a new key, and
|
|
384
|
+
the e-mail goes out twice; write ``idempotencyKey: `order-${order.id}/receipt` ``.
|
|
385
|
+
|
|
337
386
|
**The locale is the recipient's, not the request's.** An administrator who
|
|
338
387
|
invites a user sends the invitation in the *user's* locale:
|
|
339
388
|
`pickLocale(invitee.locale, supported, fallback)`.
|
|
@@ -347,7 +396,7 @@ gives a test file `describe` and `it` as bare identifiers, not on `globalThis`.
|
|
|
347
396
|
|
|
348
397
|
## Type safety, counted
|
|
349
398
|
|
|
350
|
-
**
|
|
399
|
+
**22 plausible mistakes, 22 refused** at compile time, each measured by a
|
|
351
400
|
`@ts-expect-error` in
|
|
352
401
|
[`test/types/refusals.ts`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/test/types/refusals.ts)
|
|
353
402
|
that fails the typecheck the moment it stops holding:
|
|
@@ -384,6 +433,11 @@ And an attachment:
|
|
|
384
433
|
20. No `contentType`: nothing guesses it from the file name.
|
|
385
434
|
21. One attachment, not in a list.
|
|
386
435
|
|
|
436
|
+
And the idempotency key:
|
|
437
|
+
|
|
438
|
+
22. A number (`idempotencyKey: order.id`): the key is a string, as
|
|
439
|
+
`order-42/receipt`.
|
|
440
|
+
|
|
387
441
|
The same file holds the calls that must keep compiling: a refusal that refuses
|
|
388
442
|
the correct call is a bug.
|
|
389
443
|
|
|
@@ -12,6 +12,7 @@ var FILENAME_REFUSED = /[/\\\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
|
12
12
|
var DOT_NAME = /^\.\.?$/;
|
|
13
13
|
var CONTENT_TYPE = /^[!#$%&'*+.^_`{|}~0-9A-Za-z-]+\/[!#$%&'*+.^_`{|}~0-9A-Za-z-]+$/;
|
|
14
14
|
var CONTAINER_TYPE = /^(?:multipart|message)\//i;
|
|
15
|
+
var IDEMPOTENCY_KEY = /^[\x21-\x7E]{1,256}$/;
|
|
15
16
|
function recipientsOf2(message) {
|
|
16
17
|
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
17
18
|
return to.map(addressOf2);
|
|
@@ -95,6 +96,9 @@ function checkMessage2(message) {
|
|
|
95
96
|
checkAttachment(message.attachments[index], `attachments[${index}]`);
|
|
96
97
|
}
|
|
97
98
|
}
|
|
99
|
+
if (message.idempotencyKey !== undefined && (typeof message.idempotencyKey !== "string" || !IDEMPOTENCY_KEY.test(message.idempotencyKey))) {
|
|
100
|
+
throw new MailRefused2("send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt");
|
|
101
|
+
}
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
// src/memory.ts
|
|
@@ -112,11 +116,32 @@ function copyOf(message) {
|
|
|
112
116
|
}))
|
|
113
117
|
};
|
|
114
118
|
}
|
|
119
|
+
var hexOf = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
120
|
+
function fingerprintOf(message) {
|
|
121
|
+
const address = (value) => typeof value === "object" ? [value.name, value.address] : value;
|
|
122
|
+
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
123
|
+
const headers = Object.entries(message.headers ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
124
|
+
return JSON.stringify([
|
|
125
|
+
to.map(address),
|
|
126
|
+
address(message.from),
|
|
127
|
+
address(message.replyTo),
|
|
128
|
+
message.subject,
|
|
129
|
+
message.html,
|
|
130
|
+
message.text,
|
|
131
|
+
headers,
|
|
132
|
+
(message.attachments ?? []).map((file) => [
|
|
133
|
+
file.filename,
|
|
134
|
+
file.contentType,
|
|
135
|
+
hexOf(file.content)
|
|
136
|
+
])
|
|
137
|
+
]);
|
|
138
|
+
}
|
|
115
139
|
function createMemoryMailer2() {
|
|
116
140
|
let sent = [];
|
|
117
141
|
let failures = [];
|
|
118
142
|
let attempts = 0;
|
|
119
143
|
let counter = 0;
|
|
144
|
+
let keys = new Map;
|
|
120
145
|
return {
|
|
121
146
|
get sent() {
|
|
122
147
|
return sent.map((mail) => structuredClone(mail));
|
|
@@ -133,6 +158,7 @@ function createMemoryMailer2() {
|
|
|
133
158
|
sent = [];
|
|
134
159
|
failures = [];
|
|
135
160
|
attempts = 0;
|
|
161
|
+
keys = new Map;
|
|
136
162
|
},
|
|
137
163
|
async send(message) {
|
|
138
164
|
checkMessage2(message);
|
|
@@ -140,9 +166,20 @@ function createMemoryMailer2() {
|
|
|
140
166
|
const failure = failures.shift();
|
|
141
167
|
if (failure !== undefined)
|
|
142
168
|
throw failure;
|
|
169
|
+
const key = message.idempotencyKey;
|
|
170
|
+
const fingerprint = key === undefined ? "" : fingerprintOf(message);
|
|
171
|
+
const delivered = key === undefined ? undefined : keys.get(key);
|
|
172
|
+
if (delivered !== undefined) {
|
|
173
|
+
if (delivered.fingerprint !== fingerprint) {
|
|
174
|
+
throw new MailRefused2("send: idempotencyKey was already used for a different message — a key names one e-mail");
|
|
175
|
+
}
|
|
176
|
+
return { messageId: delivered.messageId };
|
|
177
|
+
}
|
|
143
178
|
counter += 1;
|
|
144
179
|
const messageId = `memory-${counter}`;
|
|
145
180
|
sent.push({ ...copyOf(message), messageId });
|
|
181
|
+
if (key !== undefined)
|
|
182
|
+
keys.set(key, { messageId, fingerprint });
|
|
146
183
|
return { messageId };
|
|
147
184
|
}
|
|
148
185
|
};
|
|
@@ -150,5 +187,5 @@ function createMemoryMailer2() {
|
|
|
150
187
|
|
|
151
188
|
export { recipientsOf2, addressOf2, checkMessage2, createMemoryMailer2 };
|
|
152
189
|
|
|
153
|
-
//# debugId=
|
|
154
|
-
//# sourceMappingURL=index-
|
|
190
|
+
//# debugId=1A74D07F2680AEF664756E2164756E21
|
|
191
|
+
//# sourceMappingURL=index-nkzwt8vn.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/message.ts", "../src/memory.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import { MailRefused } from './errors';\nimport type { Address, MailAttachment, MailMessage } from './types';\n\nconst LINE_BREAK = /[\\r\\n]/;\n// Deliberately loose: one `@`, something on each side, and none of what an\n// address list parser reads as structure — whitespace, `<` `>` (a display\n// name), `,` `;` (a second address), `:` (a group). A provider that parses\n// the string then finds one mailbox, the one checked. Whether the mailbox\n// exists is the receiving server's question.\nconst ADDRESS = /^[^\\s@<>,;:]+@[^\\s@<>,;:]+$/;\nconst HEADER_NAME = /^[A-Za-z0-9-]+$/;\n// The headers a transport writes from the message: the addresses, the subject\n// and the MIME structure. Set through `headers`, a Bcc reaches an SMTP\n// envelope unchecked, and a Content-Type rewrites how the parts are read.\nconst RESERVED_HEADER =\n\t/^(?:to|cc|bcc|from|sender|reply-to|return-path|subject|mime-version|content-.*)$/i;\n\n// A file name is shown and saved by the recipient's mail client: no path\n// separator and no `.` or `..` (a client that saves it as is writes\n// elsewhere), no line break or other control character, C1 included (a header\n// could be split on one), and no format character — a right-to-left override\n// disguises `fdp.exe` as `exe.pdf`.\nconst FILENAME_REFUSED = /[/\\\\\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}]/u;\nconst DOT_NAME = /^\\.\\.?$/;\n// RFC 2045: type \"/\" subtype, each a token — any printable ASCII but space\n// and the tspecials ()<>@,;:\\\"/[]?=. No parameters: a charset or a name\n// there would be a second, unchecked place to write the file's name.\nconst CONTENT_TYPE =\n\t/^[!#$%&'*+.^_`{|}~0-9A-Za-z-]+\\/[!#$%&'*+.^_`{|}~0-9A-Za-z-]+$/;\n// multipart/* and message/* are MIME containers, not files: nodemailer writes\n// them unencoded, and the receiving end reads back no attachment at all.\nconst CONTAINER_TYPE = /^(?:multipart|message)\\//i;\n// Written into a header by the transports that use it (Resend's\n// `Idempotency-Key`): visible ASCII only, so no line break, and Resend's length.\nconst IDEMPOTENCY_KEY = /^[\\x21-\\x7E]{1,256}$/;\n\n/** Every recipient of a message, as bare addresses, in order. */\nexport function recipientsOf(message: MailMessage): string[] {\n\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\treturn to.map(addressOf);\n}\n\n/** The bare address of an {@link Address}. */\nexport function addressOf(address: Address): string {\n\treturn typeof address === 'string' ? address : address.address;\n}\n\n/** Refuses `address` unless it is an {@link Address}. `undefined` is refused too. */\nfunction checkAddress(address: Address | undefined, where: string): void {\n\tif (typeof address === 'string') {\n\t\tif (!ADDRESS.test(address)) {\n\t\t\tthrow new MailRefused(`send: ${where} is not an e-mail address`);\n\t\t}\n\t\treturn;\n\t}\n\tif (typeof address !== 'object' || address === null) {\n\t\tthrow new MailRefused(`send: ${where} is not an e-mail address`);\n\t}\n\tif (typeof address.address !== 'string' || !ADDRESS.test(address.address)) {\n\t\tthrow new MailRefused(`send: ${where}.address is not an e-mail address`);\n\t}\n\tif (typeof address.name !== 'string' || LINE_BREAK.test(address.name)) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.name must be a string without a line break`,\n\t\t);\n\t}\n}\n\n/** Refuses `attachment` unless it is a {@link MailAttachment}. */\nfunction checkAttachment(attachment: MailAttachment, where: string): void {\n\tif (typeof attachment !== 'object' || attachment === null) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where} must be an object, as { filename, content, contentType }`,\n\t\t);\n\t}\n\tif (!(attachment.content instanceof Uint8Array)) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.content must be a Uint8Array — the file's bytes, never a path or a URL`,\n\t\t);\n\t}\n\tif (\n\t\ttypeof attachment.filename !== 'string' ||\n\t\tattachment.filename === '' ||\n\t\tDOT_NAME.test(attachment.filename) ||\n\t\tFILENAME_REFUSED.test(attachment.filename)\n\t) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.filename must be a file name — not empty, not . or .., without / or \\\\, a line break or a control character`,\n\t\t);\n\t}\n\tif (\n\t\ttypeof attachment.contentType !== 'string' ||\n\t\t!CONTENT_TYPE.test(attachment.contentType) ||\n\t\tCONTAINER_TYPE.test(attachment.contentType)\n\t) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.contentType must be a file's type/subtype, as application/pdf — never multipart/* or message/*`,\n\t\t);\n\t}\n}\n\n/**\n * Refuses a message no transport should hand over, with a {@link MailRefused}\n * that names **where** the problem is and never the value.\n *\n * A transport calls it first thing in `send`, so the refusals are the same\n * whichever transport is wired. It checks:\n *\n * - at least one recipient, each one an address;\n * - `from` and `replyTo`, when present, are addresses;\n * - `subject`, `html` and `text` are strings, and `subject` holds no line\n * break — a line break in a subject is a header injection;\n * - every header name is letters, digits and hyphens, none names what the\n * transport writes from the message (`To`, `Cc`, `Bcc`, `From`, `Sender`,\n * `Reply-To`, `Return-Path`, `Subject`, `MIME-Version`, `Content-*`, in\n * any case), and no header value holds a line break;\n * - `attachments`, when present, is an array without holes — empty is the\n * same as absent — and each entry has its bytes as a `Uint8Array`, a\n * `filename` that is not empty, `.` or `..` and holds no `/`, `\\`, line\n * break, control or format character, and a `contentType` that is a bare\n * `type/subtype`, never `multipart/*` or `message/*`;\n * - `idempotencyKey`, when present, is 1 to 256 visible ASCII characters.\n */\nexport function checkMessage(message: MailMessage): void {\n\tif (typeof message !== 'object' || message === null) {\n\t\tthrow new MailRefused('send: the message must be an object');\n\t}\n\tif (message.to === undefined || message.to === null) {\n\t\tthrow new MailRefused('send: to must hold at least one address');\n\t}\n\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\tif (to.length === 0) {\n\t\tthrow new MailRefused('send: to must hold at least one address');\n\t}\n\tto.forEach((address, index) => {\n\t\tcheckAddress(address, Array.isArray(message.to) ? `to[${index}]` : 'to');\n\t});\n\tif (message.from !== undefined) checkAddress(message.from, 'from');\n\tif (message.replyTo !== undefined) checkAddress(message.replyTo, 'replyTo');\n\n\tfor (const part of ['subject', 'html', 'text'] as const) {\n\t\tif (typeof message[part] !== 'string') {\n\t\t\tthrow new MailRefused(`send: ${part} must be a string`);\n\t\t}\n\t}\n\tif (LINE_BREAK.test(message.subject)) {\n\t\tthrow new MailRefused('send: subject must not hold a line break');\n\t}\n\n\tfor (const [name, value] of Object.entries(message.headers ?? {})) {\n\t\tif (!HEADER_NAME.test(name)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t'send: a header name must be letters, digits and hyphens',\n\t\t\t);\n\t\t}\n\t\tif (RESERVED_HEADER.test(name)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t`send: header ${name} is reserved — addresses, the subject and the MIME structure are never custom headers`,\n\t\t\t);\n\t\t}\n\t\tif (typeof value !== 'string' || LINE_BREAK.test(value)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t`send: header ${name} must be a string without a line break`,\n\t\t\t);\n\t\t}\n\t}\n\n\tif (message.attachments !== undefined) {\n\t\tif (!Array.isArray(message.attachments)) {\n\t\t\tthrow new MailRefused('send: attachments must be an array');\n\t\t}\n\t\t// Indexed, not forEach: a hole in the array is refused, not skipped.\n\t\tfor (let index = 0; index < message.attachments.length; index++) {\n\t\t\tcheckAttachment(message.attachments[index], `attachments[${index}]`);\n\t\t}\n\t}\n\n\tif (\n\t\tmessage.idempotencyKey !== undefined &&\n\t\t(typeof message.idempotencyKey !== 'string' ||\n\t\t\t!IDEMPOTENCY_KEY.test(message.idempotencyKey))\n\t) {\n\t\tthrow new MailRefused(\n\t\t\t'send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt',\n\t\t);\n\t}\n}\n",
|
|
6
|
+
"import { type MailError, MailFailure, MailRefused } from './errors';\nimport { checkMessage } from './message';\nimport type { Address, Mailer, MailMessage, SentMail } from './types';\n\n/** One message the memory mailer accepted, with the id it gave it. */\nexport interface MemoryMail extends MailMessage {\n\treadonly messageId: string;\n}\n\n/**\n * The reference transport: it keeps what it sends in memory, for tests.\n *\n * It refuses exactly what every transport refuses (it calls\n * {@link checkMessage}), and it can be told to fail, so a test can prove what\n * the application does when a send throws.\n *\n * It honours `idempotencyKey`, as Resend does: the same message again under a\n * key it already delivered resolves with that delivery's `messageId`, and is\n * not delivered again; a different message under that key is a\n * {@link MailRefused}. A send that failed delivered nothing, so its key stays\n * free.\n */\nexport interface MemoryMailer extends Mailer {\n\t/** Every message accepted so far, oldest first. A copy: mutating it changes nothing. */\n\treadonly sent: readonly MemoryMail[];\n\t/**\n\t * How many sends reached the hand-over, failed ones included. A message\n\t * refused as malformed never reaches it. A caller that retries in secret\n\t * shows up here.\n\t */\n\treadonly attempts: number;\n\t/**\n\t * Makes the next send that reaches the hand-over reject with `error`, by\n\t * default a {@link MailFailure} as an outage would. Calls queue: two calls\n\t * fail the next two sends.\n\t */\n\tfailNext(error?: MailError): void;\n\t/** Forgets what was sent, the attempts, any queued failure, and the idempotency keys. */\n\tclear(): void;\n}\n\n/**\n * A copy of `message` the caller cannot change afterwards. Each attachment's\n * bytes are copied to a plain `Uint8Array` of their own: a `Buffer` from\n * Node's pool is a view on a larger, shared buffer, which a clone would copy\n * whole.\n */\nfunction copyOf(message: MailMessage): MailMessage {\n\tconst { attachments, ...rest } = message;\n\tconst copy = structuredClone(rest);\n\tif (attachments === undefined) return copy;\n\treturn {\n\t\t...copy,\n\t\tattachments: attachments.map((attachment) => ({\n\t\t\tfilename: attachment.filename,\n\t\t\tcontent: new Uint8Array(attachment.content),\n\t\t\tcontentType: attachment.contentType,\n\t\t})),\n\t};\n}\n\n/** Bytes as hex: short to compare, whatever holds them. */\nconst hexOf = (bytes: Uint8Array) =>\n\tArray.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');\n\n/**\n * What makes two messages the same one, for an idempotency key: the fields of\n * the port, read by name as `checkMessage` reads them — so a field on a\n * prototype counts and a field the port does not know does not — in one\n * form, however the object was written: `to` as one address or a list of\n * one, no `headers` or `attachments` or an empty one, headers in any order,\n * bytes in a `Buffer` or a plain `Uint8Array`.\n */\nfunction fingerprintOf(message: MailMessage): string {\n\tconst address = (value: Address | undefined) =>\n\t\ttypeof value === 'object' ? [value.name, value.address] : value;\n\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\tconst headers = Object.entries(message.headers ?? {}).sort(([a], [b]) =>\n\t\ta < b ? -1 : a > b ? 1 : 0,\n\t);\n\treturn JSON.stringify([\n\t\tto.map(address),\n\t\taddress(message.from),\n\t\taddress(message.replyTo),\n\t\tmessage.subject,\n\t\tmessage.html,\n\t\tmessage.text,\n\t\theaders,\n\t\t(message.attachments ?? []).map((file) => [\n\t\t\tfile.filename,\n\t\t\tfile.contentType,\n\t\t\thexOf(file.content),\n\t\t]),\n\t]);\n}\n\n/** Creates a {@link MemoryMailer}. Message ids are `memory-1`, `memory-2`, … */\nexport function createMemoryMailer(): MemoryMailer {\n\tlet sent: MemoryMail[] = [];\n\tlet failures: MailError[] = [];\n\tlet attempts = 0;\n\tlet counter = 0;\n\tlet keys = new Map<string, { messageId: string; fingerprint: string }>();\n\n\treturn {\n\t\tget sent() {\n\t\t\treturn sent.map((mail) => structuredClone(mail));\n\t\t},\n\t\tget attempts() {\n\t\t\treturn attempts;\n\t\t},\n\t\tfailNext(error) {\n\t\t\tfailures.push(\n\t\t\t\terror ??\n\t\t\t\t\tnew MailFailure(\n\t\t\t\t\t\t'send: the memory mailer was told to fail this send',\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcause: new Error('memory mailer: failNext'),\n\t\t\t\t\t\t},\n\t\t\t\t\t),\n\t\t\t);\n\t\t},\n\t\tclear() {\n\t\t\tsent = [];\n\t\t\tfailures = [];\n\t\t\tattempts = 0;\n\t\t\tkeys = new Map();\n\t\t},\n\t\tasync send(message): Promise<SentMail> {\n\t\t\tcheckMessage(message);\n\t\t\tattempts += 1;\n\t\t\tconst failure = failures.shift();\n\t\t\tif (failure !== undefined) throw failure;\n\n\t\t\tconst key = message.idempotencyKey;\n\t\t\tconst fingerprint = key === undefined ? '' : fingerprintOf(message);\n\t\t\tconst delivered = key === undefined ? undefined : keys.get(key);\n\t\t\tif (delivered !== undefined) {\n\t\t\t\tif (delivered.fingerprint !== fingerprint) {\n\t\t\t\t\tthrow new MailRefused(\n\t\t\t\t\t\t'send: idempotencyKey was already used for a different message — a key names one e-mail',\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn { messageId: delivered.messageId };\n\t\t\t}\n\n\t\t\tcounter += 1;\n\t\t\tconst messageId = `memory-${counter}`;\n\t\t\tsent.push({ ...copyOf(message), messageId });\n\t\t\tif (key !== undefined) keys.set(key, { messageId, fingerprint });\n\t\t\treturn { messageId };\n\t\t},\n\t};\n}\n"
|
|
7
|
+
],
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "1A74D07F2680AEF664756E2164756E21",
|
|
10
|
+
"names": []
|
|
11
|
+
}
|
package/dist/index.js
CHANGED
package/dist/memory.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export interface MemoryMail extends MailMessage {
|
|
|
10
10
|
* It refuses exactly what every transport refuses (it calls
|
|
11
11
|
* {@link checkMessage}), and it can be told to fail, so a test can prove what
|
|
12
12
|
* the application does when a send throws.
|
|
13
|
+
*
|
|
14
|
+
* It honours `idempotencyKey`, as Resend does: the same message again under a
|
|
15
|
+
* key it already delivered resolves with that delivery's `messageId`, and is
|
|
16
|
+
* not delivered again; a different message under that key is a
|
|
17
|
+
* {@link MailRefused}. A send that failed delivered nothing, so its key stays
|
|
18
|
+
* free.
|
|
13
19
|
*/
|
|
14
20
|
export interface MemoryMailer extends Mailer {
|
|
15
21
|
/** Every message accepted so far, oldest first. A copy: mutating it changes nothing. */
|
|
@@ -26,7 +32,7 @@ export interface MemoryMailer extends Mailer {
|
|
|
26
32
|
* fail the next two sends.
|
|
27
33
|
*/
|
|
28
34
|
failNext(error?: MailError): void;
|
|
29
|
-
/** Forgets what was sent, the attempts,
|
|
35
|
+
/** Forgets what was sent, the attempts, any queued failure, and the idempotency keys. */
|
|
30
36
|
clear(): void;
|
|
31
37
|
}
|
|
32
38
|
/** Creates a {@link MemoryMailer}. Message ids are `memory-1`, `memory-2`, … */
|
package/dist/memory.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,
|
|
1
|
+
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAA4B,MAAM,UAAU,CAAC;AAEpE,OAAO,KAAK,EAAW,MAAM,EAAE,WAAW,EAAY,MAAM,SAAS,CAAC;AAEtE,sEAAsE;AACtE,MAAM,WAAW,UAAW,SAAQ,WAAW;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,YAAa,SAAQ,MAAM;IAC3C,wFAAwF;IACxF,QAAQ,CAAC,IAAI,EAAE,SAAS,UAAU,EAAE,CAAC;IACrC;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAClC,yFAAyF;IACzF,KAAK,IAAI,IAAI,CAAC;CACd;AAyDD,gFAAgF;AAChF,wBAAgB,kBAAkB,IAAI,YAAY,CAwDjD"}
|
package/dist/message.d.ts
CHANGED
|
@@ -22,7 +22,8 @@ export declare function addressOf(address: Address): string;
|
|
|
22
22
|
* same as absent — and each entry has its bytes as a `Uint8Array`, a
|
|
23
23
|
* `filename` that is not empty, `.` or `..` and holds no `/`, `\`, line
|
|
24
24
|
* break, control or format character, and a `contentType` that is a bare
|
|
25
|
-
* `type/subtype`, never `multipart/*` or `message
|
|
25
|
+
* `type/subtype`, never `multipart/*` or `message/*`;
|
|
26
|
+
* - `idempotencyKey`, when present, is 1 to 256 visible ASCII characters.
|
|
26
27
|
*/
|
|
27
28
|
export declare function checkMessage(message: MailMessage): void;
|
|
28
29
|
//# sourceMappingURL=message.d.ts.map
|
package/dist/message.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../src/message.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAkB,WAAW,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../src/message.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAkB,WAAW,EAAE,MAAM,SAAS,CAAC;AAmCpE,iEAAiE;AACjE,wBAAgB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,EAAE,CAG3D;AAED,8CAA8C;AAC9C,wBAAgB,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAElD;AAwDD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CA+DvD"}
|
package/dist/types.d.ts
CHANGED
|
@@ -62,6 +62,19 @@ export interface MailMessage extends Rendered {
|
|
|
62
62
|
* Each is bytes, checked by `checkMessage`: see {@link MailAttachment}.
|
|
63
63
|
*/
|
|
64
64
|
readonly attachments?: readonly MailAttachment[];
|
|
65
|
+
/**
|
|
66
|
+
* Names this send, so sending it again — a retry after a timeout, a job run
|
|
67
|
+
* twice — delivers it once. 1 to 256 visible ASCII characters, as
|
|
68
|
+
* `order-42/receipt`; derive it from what the e-mail is about, never from
|
|
69
|
+
* the time or a random value, or a retry carries a new one.
|
|
70
|
+
*
|
|
71
|
+
* **A transport that can deduplicate uses it; one that cannot ignores it.**
|
|
72
|
+
* Resend keeps a key for 24 hours; SMTP has no such thing, and ignores it.
|
|
73
|
+
* The memory mailer answers the same message under a key it already
|
|
74
|
+
* delivered with the same `messageId`, and delivers nothing more; a
|
|
75
|
+
* different message under that key is a `MailRefused`, as Resend's `409`.
|
|
76
|
+
*/
|
|
77
|
+
readonly idempotencyKey?: string;
|
|
65
78
|
}
|
|
66
79
|
/** What a transport answers once it has handed a message over. */
|
|
67
80
|
export interface SentMail {
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,OAAO,GAChB,MAAM,GACN;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvD;;;;;GAKG;AACH,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAY,SAAQ,QAAQ;IAC5C,QAAQ,CAAC,EAAE,EAAE,OAAO,GAAG,SAAS,OAAO,EAAE,CAAC;IAC1C,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,OAAO,GAChB,MAAM,GACN;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvD;;;;;GAKG;AACH,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAY,SAAQ,QAAQ;IAC5C,QAAQ,CAAC,EAAE,EAAE,OAAO,GAAG,SAAS,OAAO,EAAE,CAAC;IAC1C,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;IACjD;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,kEAAkE;AAClE,MAAM,WAAW,QAAQ;IACxB;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,MAAM;IACtB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC9C"}
|
package/docs/README.md
CHANGED
|
@@ -8,9 +8,9 @@ mailer, transport, hand-over, refusal, failure — are defined once, in the
|
|
|
8
8
|
| Page | Read it when |
|
|
9
9
|
| --- | --- |
|
|
10
10
|
| [Rendering](guide/rendering.md) | You are turning a Maizzle build of `@nxgt/mail-i18n` into an e-mail with `createMailRenderer`: its options, typing it with the build's `MailEmails`, choosing the locale, escaping, URL variables, deploying the build, and every error |
|
|
11
|
-
| [Sending](guide/sending.md) | You are calling `mailer.send`: the `MailMessage` shape, addresses, headers, attachments, what `send` answers, and turning `MailFailure` and `MailRefused` into a response |
|
|
12
|
-
| [Testing](guide/testing.md) | You are testing code that sends e-mail with `createMemoryMailer`: reading the outbox and its attachments, making a send fail, counting attempts |
|
|
11
|
+
| [Sending](guide/sending.md) | You are calling `mailer.send`: the `MailMessage` shape, addresses, headers, attachments, the idempotency key, what `send` answers, and turning `MailFailure` and `MailRefused` into a response |
|
|
12
|
+
| [Testing](guide/testing.md) | You are testing code that sends e-mail with `createMemoryMailer`: reading the outbox and its attachments, making a send fail, counting attempts, a retry under an idempotency key |
|
|
13
13
|
| [Locales](guide/locales.md) | You are choosing the locale an e-mail is rendered in, with `pickLocale` and `parseAcceptLanguage` |
|
|
14
|
-
| [Writing a transport](guide/transports.md) | You are implementing the `Mailer` port for a provider
|
|
14
|
+
| [Writing a transport](guide/transports.md) | You are implementing the `Mailer` port for a provider — the idempotency key included — and running `@nxgt/mail/conformance` against it |
|
|
15
15
|
| [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
|
|
16
16
|
| [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
|
package/docs/guide/sending.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Sending
|
|
2
2
|
|
|
3
3
|
This page is for calling `mailer.send`: the shape of what it takes, the
|
|
4
|
-
addresses, headers and attachments it accepts,
|
|
5
|
-
throws.
|
|
4
|
+
addresses, headers and attachments it accepts, the idempotency key that makes
|
|
5
|
+
a retry safe, what it answers, and what it throws.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
import { createMemoryMailer } from '@nxgt/mail';
|
|
@@ -75,6 +75,7 @@ interface MailMessage extends Rendered {
|
|
|
75
75
|
readonly replyTo?: Address;
|
|
76
76
|
readonly headers?: Readonly<Record<string, string>>;
|
|
77
77
|
readonly attachments?: readonly MailAttachment[];
|
|
78
|
+
readonly idempotencyKey?: string;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
interface MailAttachment {
|
|
@@ -94,6 +95,7 @@ interface MailAttachment {
|
|
|
94
95
|
| `replyTo` | `Address` | no | Where replies go |
|
|
95
96
|
| `headers` | `Record<string, string>` | no | Extra headers, such as `List-Unsubscribe` |
|
|
96
97
|
| `attachments` | `readonly MailAttachment[]` | no | Files sent with the e-mail, in order, as bytes — see [Attachments](#attachments). An empty list is the same as none |
|
|
98
|
+
| `idempotencyKey` | `string` | no | Names this send, so sending it again delivers it once where the transport can deduplicate — see [Idempotency](#idempotency--sending-once). 1 to 256 visible ASCII characters |
|
|
97
99
|
|
|
98
100
|
`Rendered` is what the renderer answers — `mails.render('verify-email', { name, link })`
|
|
99
101
|
fills the values only known at send time into a built Maizzle template — and a
|
|
@@ -302,6 +304,91 @@ carry — is a `MailRefused` from the transport (an SMTP `552`; a Resend `400`,
|
|
|
302
304
|
`413` or `422`), and sending it again unchanged fails again: send a link
|
|
303
305
|
instead.
|
|
304
306
|
|
|
307
|
+
## Idempotency — sending once
|
|
308
|
+
|
|
309
|
+
A send that fails with `MailFailure` may still have reached the provider: a
|
|
310
|
+
timeout, a dropped connection. Retrying it can deliver the e-mail twice.
|
|
311
|
+
`idempotencyKey` names the send, so a transport that can deduplicate delivers
|
|
312
|
+
it once however often it is sent:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
import type { Mailer, Rendered } from '@nxgt/mail';
|
|
316
|
+
|
|
317
|
+
export async function sendReceipt(mailer: Mailer, order: { id: string; email: string }, rendered: Rendered): Promise<void> {
|
|
318
|
+
await mailer.send({
|
|
319
|
+
...rendered,
|
|
320
|
+
to: order.email,
|
|
321
|
+
idempotencyKey: `order-${order.id}/receipt`, // the same for every retry of this receipt
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**Derive the key from what the e-mail is about** — an order id, a user id
|
|
327
|
+
and a purpose (`user-7/welcome`, `invitation-19`) — **never from the time or
|
|
328
|
+
a random value**: a retry would carry a new key, and deliver again. **A key
|
|
329
|
+
names one e-mail**: two different e-mails about the same thing need two keys
|
|
330
|
+
(`order-42/receipt`, `order-42/shipped`) — a transport that deduplicates
|
|
331
|
+
refuses a different message under a key it already delivered.
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
declare const order: { id: string };
|
|
335
|
+
declare const userId: string;
|
|
336
|
+
declare const resetRequestId: string;
|
|
337
|
+
|
|
338
|
+
const receipt = `order-${order.id}/receipt`; // one receipt per order
|
|
339
|
+
const welcome = `user-${userId}/welcome`; // one welcome per user
|
|
340
|
+
const reset = `password-reset/${resetRequestId}`; // per request, not per user: a second request is a second e-mail
|
|
341
|
+
|
|
342
|
+
const wrong = `receipt-${Date.now()}`; // a retry gets a new key, and delivers again
|
|
343
|
+
const wrongToo = crypto.randomUUID(); // the same
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**What each transport does with it:**
|
|
347
|
+
|
|
348
|
+
| Transport | Effect |
|
|
349
|
+
| --- | --- |
|
|
350
|
+
| `createMemoryMailer()` | The same message under a key it already delivered answers that delivery's `messageId`, and delivers nothing more; a different message under that key is a `MailRefused`. A send that failed leaves its key free. See [Testing — idempotency](testing.md#idempotencykey--a-retry-delivers-once) |
|
|
351
|
+
| `@nxgt/mail-resend` | Sent as Resend's `Idempotency-Key` header. Resend keeps a key for 24 hours: a retry within them answers the first send's id and delivers nothing more; the same key with a different message is refused (`MailRefused`) |
|
|
352
|
+
| `@nxgt/mail-smtp` | Ignored: SMTP has no such mechanism, so a message sent twice is delivered twice |
|
|
353
|
+
|
|
354
|
+
A transport that cannot deduplicate ignores the key; it never refuses the
|
|
355
|
+
message for carrying one. So a key is always safe to set, and only makes a
|
|
356
|
+
retry safe where the transport honours it. It is never written into the
|
|
357
|
+
e-mail itself.
|
|
358
|
+
|
|
359
|
+
`checkMessage` refuses a key that is not 1 to 256 visible ASCII characters —
|
|
360
|
+
empty, too long, holding a space, a line break or an accented letter, or not
|
|
361
|
+
a string — without quoting it:
|
|
362
|
+
|
|
363
|
+
| Written | Answer |
|
|
364
|
+
| --- | --- |
|
|
365
|
+
| `'order-42/receipt'`, `'a:b_c.d~e'`, `'k'.repeat(256)` | accepted |
|
|
366
|
+
| `''`, `'k'.repeat(257)`, `'order 42'`, `'commande-42-reçu'`, `'order-42\r\nX-Evil: 1'`, `42` | `MailRefused`: `send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt` |
|
|
367
|
+
|
|
368
|
+
A transport that deduplicates also refuses **a different message** under a
|
|
369
|
+
key it already delivered, with `MailRefused` — the memory mailer with
|
|
370
|
+
`send: idempotencyKey was already used for a different message — a key names one e-mail`,
|
|
371
|
+
Resend's transport with `send: Resend refused the message` on its `409`.
|
|
372
|
+
Sending it again fails again: give that e-mail its own key.
|
|
373
|
+
|
|
374
|
+
A retry from a queue, with the key the job carries:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
import { MailFailure, type Mailer, type MailMessage } from '@nxgt/mail';
|
|
378
|
+
|
|
379
|
+
// Yours: the queue that runs a job again later.
|
|
380
|
+
declare function retryLater(job: { message: MailMessage }, delaySeconds: number): Promise<void>;
|
|
381
|
+
|
|
382
|
+
export async function runSendJob(mailer: Mailer, job: { message: MailMessage }): Promise<void> {
|
|
383
|
+
try {
|
|
384
|
+
await mailer.send(job.message); // job.message.idempotencyKey was set when the job was queued
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (error instanceof MailFailure) return retryLater(job, 60); // the same key: delivered once, where the transport deduplicates
|
|
387
|
+
throw error; // MailRefused: sending it again fails again
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
305
392
|
## Errors
|
|
306
393
|
|
|
307
394
|
```ts
|
|
@@ -325,8 +412,8 @@ class MailRefused extends MailError {
|
|
|
325
412
|
|
|
326
413
|
| Code | Class | When | Sending it again |
|
|
327
414
|
| --- | --- | --- | --- |
|
|
328
|
-
| `MAIL_FAILED` | `MailFailure` | The transport could not hand the e-mail over: a refused connection, a timeout, a 5xx from the provider, an expired credential. The transport's error is the `cause`. **Nothing is known to have been sent**: after a timeout or a dropped connection the provider may have taken it all the same | May work later. Never report it as sent |
|
|
329
|
-
| `MAIL_REFUSED` | `MailRefused` | The e-mail itself was refused, before or by the transport: no recipient, something that is not an address, a line break in the subject or a header, a reserved header, an attachment that is not bytes or is badly named, or the provider answering that the message is malformed or too large | Fails again, unchanged |
|
|
415
|
+
| `MAIL_FAILED` | `MailFailure` | The transport could not hand the e-mail over: a refused connection, a timeout, a 5xx from the provider, an expired credential. The transport's error is the `cause`. **Nothing is known to have been sent**: after a timeout or a dropped connection the provider may have taken it all the same | May work later — with an [`idempotencyKey`](#idempotency--sending-once), without a second delivery where the transport deduplicates. Never report it as sent |
|
|
416
|
+
| `MAIL_REFUSED` | `MailRefused` | The e-mail itself was refused, before or by the transport: no recipient, something that is not an address, a line break in the subject or a header, a reserved header, an attachment that is not bytes or is badly named, a malformed idempotency key or one already used for a different message, or the provider answering that the message is malformed or too large | Fails again, unchanged |
|
|
330
417
|
|
|
331
418
|
`MailError` is **abstract**: catch it, test `instanceof MailError`, but
|
|
332
419
|
`new MailError(…)` does not compile — a bare one would pass a `code` check and
|
package/docs/guide/testing.md
CHANGED
|
@@ -143,18 +143,105 @@ await mailer
|
|
|
143
143
|
mailer.attempts; // 0 — and the failure is still queued for the next well-formed send
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
+
## `idempotencyKey` — a retry delivers once
|
|
147
|
+
|
|
148
|
+
The memory mailer honours a message's
|
|
149
|
+
[`idempotencyKey`](sending.md#idempotency--sending-once) as Resend does:
|
|
150
|
+
|
|
151
|
+
- the **same message** under a key it already delivered answers that
|
|
152
|
+
delivery's `messageId`, and nothing more reaches `sent`;
|
|
153
|
+
- a **different message** under that key — another subject, another
|
|
154
|
+
recipient, other attachment bytes, any field — is refused with
|
|
155
|
+
`MailRefused`: `send: idempotencyKey was already used for a different message — a key names one e-mail`,
|
|
156
|
+
as Resend answers `409 invalid_idempotent_request`;
|
|
157
|
+
- a send that **failed** delivered nothing, so its key stays free, and the
|
|
158
|
+
retry delivers;
|
|
159
|
+
- each send still counts in `attempts`, the answered duplicate and the
|
|
160
|
+
refused reuse included: both reached the hand-over;
|
|
161
|
+
- `clear()` forgets the keys.
|
|
162
|
+
|
|
163
|
+
A test proving that a job run twice e-mails once:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { expect, it } from 'bun:test';
|
|
167
|
+
import { createMemoryMailer, type Mailer, MailRefused } from '@nxgt/mail';
|
|
168
|
+
|
|
169
|
+
// The code under test: a job that may run more than once for the same order.
|
|
170
|
+
async function sendReceipt(mailer: Mailer, orderId: string): Promise<string | null> {
|
|
171
|
+
const { messageId } = await mailer.send({
|
|
172
|
+
to: 'ada@example.com',
|
|
173
|
+
subject: 'Your receipt',
|
|
174
|
+
html: '<p>Thank you for your order.</p>',
|
|
175
|
+
text: 'Thank you for your order.',
|
|
176
|
+
idempotencyKey: `order-${orderId}/receipt`,
|
|
177
|
+
});
|
|
178
|
+
return messageId;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
it('sends one receipt, however often the job runs', async () => {
|
|
182
|
+
const mailer = createMemoryMailer();
|
|
183
|
+
|
|
184
|
+
const first = await sendReceipt(mailer, '42');
|
|
185
|
+
const again = await sendReceipt(mailer, '42');
|
|
186
|
+
|
|
187
|
+
expect(again).toBe(first); // 'memory-1', both times
|
|
188
|
+
expect(mailer.sent).toHaveLength(1);
|
|
189
|
+
expect(mailer.attempts).toBe(2);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('refuses another e-mail under the same key', async () => {
|
|
193
|
+
const mailer = createMemoryMailer();
|
|
194
|
+
await sendReceipt(mailer, '42');
|
|
195
|
+
|
|
196
|
+
const error = await mailer
|
|
197
|
+
.send({
|
|
198
|
+
to: 'ada@example.com',
|
|
199
|
+
subject: 'Your order has shipped', // another e-mail, the receipt's key
|
|
200
|
+
html: '<p>Your order has shipped.</p>',
|
|
201
|
+
text: 'Your order has shipped.',
|
|
202
|
+
idempotencyKey: 'order-42/receipt',
|
|
203
|
+
})
|
|
204
|
+
.then(() => null, (e: unknown) => e);
|
|
205
|
+
|
|
206
|
+
expect(error).toBeInstanceOf(MailRefused); // give it its own key: order-42/shipped
|
|
207
|
+
expect(mailer.sent).toHaveLength(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('delivers the retry of a send that failed', async () => {
|
|
211
|
+
const mailer = createMemoryMailer();
|
|
212
|
+
mailer.failNext();
|
|
213
|
+
|
|
214
|
+
await sendReceipt(mailer, '42').then(() => null, (e: unknown) => e); // MailFailure
|
|
215
|
+
await sendReceipt(mailer, '42');
|
|
216
|
+
|
|
217
|
+
expect(mailer.sent).toHaveLength(1);
|
|
218
|
+
expect(mailer.sent[0]?.idempotencyKey).toBe('order-42/receipt');
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
"The same message" is what it would deliver: every field of `MailMessage`,
|
|
223
|
+
read by name, the attachments by their bytes. How the object was written
|
|
224
|
+
does not count — the order of its fields or of its headers, `to` as one
|
|
225
|
+
address or a list of one, no `headers` or `attachments` or an empty one,
|
|
226
|
+
the bytes in a `Buffer` or a plain `Uint8Array` — and neither does a field
|
|
227
|
+
`MailMessage` does not have. SMTP ignores
|
|
228
|
+
the key altogether, so this is the behaviour of a deduplicating transport,
|
|
229
|
+
not of every one. A queued `failNext` fails the next send even when its key
|
|
230
|
+
was already delivered.
|
|
231
|
+
|
|
146
232
|
## `clear()`
|
|
147
233
|
|
|
148
|
-
Forgets the outbox, the attempts,
|
|
149
|
-
going, so an id is never reused within one mailer.
|
|
234
|
+
Forgets the outbox, the attempts, any queued failure, and the idempotency
|
|
235
|
+
keys. The id counter keeps going, so an id is never reused within one mailer.
|
|
150
236
|
|
|
151
237
|
## What it refuses
|
|
152
238
|
|
|
153
239
|
Exactly what every transport refuses, because it calls
|
|
154
240
|
[`checkMessage`](transports.md#checkmessage-first) first: no recipient, something
|
|
155
241
|
that is not an address, a line break in a name, the subject or a header, a
|
|
156
|
-
missing part, an attachment that is not bytes
|
|
157
|
-
malformed
|
|
242
|
+
missing part, an attachment that is not bytes or whose file name or type is
|
|
243
|
+
malformed, or an idempotency key that is not 1 to 256 visible ASCII
|
|
244
|
+
characters. A test that passes against the memory mailer does not pass by
|
|
158
245
|
accident a message a real transport would refuse. The full list is in
|
|
159
246
|
[Sending](sending.md#addresses) and
|
|
160
247
|
[Sending — attachments](sending.md#attachments).
|
package/docs/guide/transports.md
CHANGED
|
@@ -54,9 +54,12 @@ A transport:
|
|
|
54
54
|
type — base64 in a JSON body, a MIME part over SMTP, the name encoded when
|
|
55
55
|
it is not ASCII — and **never reads a file or fetches a URL** to attach
|
|
56
56
|
one: `MailAttachment` holds bytes only;
|
|
57
|
-
7. **
|
|
57
|
+
7. **uses `idempotencyKey` if the provider deduplicates, and ignores it
|
|
58
|
+
otherwise** — see [The idempotency key](#the-idempotency-key). It never
|
|
59
|
+
refuses a message for carrying one, and never writes it into the e-mail;
|
|
60
|
+
8. **never retries in secret**, never resolves `false`, never logs and
|
|
58
61
|
resolves;
|
|
59
|
-
|
|
62
|
+
9. **defines no error class of its own**. It throws the classes imported from
|
|
60
63
|
`@nxgt/mail`, declared as a required peer, so `error instanceof MailFailure`
|
|
61
64
|
holds in the application whichever transport threw it. `MailError` is
|
|
62
65
|
abstract, so a bare one cannot be thrown:
|
|
@@ -64,14 +67,15 @@ A transport:
|
|
|
64
67
|
```json
|
|
65
68
|
{
|
|
66
69
|
"peerDependencies": {
|
|
67
|
-
"@nxgt/mail": "^0.
|
|
70
|
+
"@nxgt/mail": "^0.3.0"
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
```
|
|
71
74
|
|
|
72
|
-
On `0.x`, a caret covers one minor: `^0.
|
|
75
|
+
On `0.x`, a caret covers one minor: `^0.3.0` is `>=0.3.0 <0.4.0`. Declare the
|
|
73
76
|
minor whose `MailMessage` your transport reads — `0.2` is the one with
|
|
74
|
-
`attachments`
|
|
77
|
+
`attachments`, `0.3` the one with `idempotencyKey` — and release your
|
|
78
|
+
transport when `@nxgt/mail` moves to the next.
|
|
75
79
|
|
|
76
80
|
An error's `message` reports a shape, never a value: never an address, a
|
|
77
81
|
subject, a link, an API key or a connection string. What the provider said goes
|
|
@@ -105,9 +109,12 @@ Throws `MailRefused`, naming **where** the problem is and never the value:
|
|
|
105
109
|
| an attachment whose `content` is not a `Uint8Array` — a string, a path, an `ArrayBuffer` | `send: attachments[0].content must be a Uint8Array — the file's bytes, never a path or a URL` |
|
|
106
110
|
| a file name that is empty, `.` or `..`, or holds `/`, `\`, a line break, a control character or a format character | `send: attachments[0].filename must be a file name — not empty, not . or .., without / or \, a line break or a control character` |
|
|
107
111
|
| a content type that is not a bare `type/subtype`, or is `multipart/*` or `message/*` | `send: attachments[0].contentType must be a file's type/subtype, as application/pdf — never multipart/* or message/*` |
|
|
112
|
+
| an `idempotencyKey` that is not 1 to 256 visible ASCII characters — empty, a space, a line break, a letter outside ASCII, not a string | `send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt` |
|
|
108
113
|
|
|
109
114
|
An empty `attachments` is accepted, and is the same as none: send no
|
|
110
|
-
attachment field to the provider then.
|
|
115
|
+
attachment field to the provider then. A key that passes is safe to write in
|
|
116
|
+
an HTTP header as it is: no line break, no character a header would need to
|
|
117
|
+
encode.
|
|
111
118
|
|
|
112
119
|
Two helpers turn addresses into what a provider wants:
|
|
113
120
|
|
|
@@ -153,6 +160,8 @@ export function createHttpMailer(options: HttpMailerOptions): Mailer {
|
|
|
153
160
|
return {
|
|
154
161
|
async send(message) {
|
|
155
162
|
checkMessage(message);
|
|
163
|
+
// The key names the send; it is not part of the e-mail, so it stays out of the body.
|
|
164
|
+
const { idempotencyKey, ...fields } = message;
|
|
156
165
|
// A JSON API takes an attachment's bytes as base64.
|
|
157
166
|
// An empty list is none: the field is left out of the request.
|
|
158
167
|
const attachments = message.attachments?.length
|
|
@@ -167,8 +176,13 @@ export function createHttpMailer(options: HttpMailerOptions): Mailer {
|
|
|
167
176
|
try {
|
|
168
177
|
response = await post(options.endpoint, {
|
|
169
178
|
method: 'POST',
|
|
170
|
-
headers: {
|
|
171
|
-
|
|
179
|
+
headers: {
|
|
180
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
181
|
+
'content-type': 'application/json',
|
|
182
|
+
// This provider deduplicates on a header; with one that does not, leave the key out.
|
|
183
|
+
...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }),
|
|
184
|
+
},
|
|
185
|
+
body: JSON.stringify({ ...fields, from: message.from ?? options.from, attachments }),
|
|
172
186
|
});
|
|
173
187
|
} catch (cause) {
|
|
174
188
|
throw new MailFailure('send: the provider could not be reached', { cause });
|
|
@@ -190,6 +204,51 @@ export function createHttpMailer(options: HttpMailerOptions): Mailer {
|
|
|
190
204
|
}
|
|
191
205
|
```
|
|
192
206
|
|
|
207
|
+
## The idempotency key
|
|
208
|
+
|
|
209
|
+
`idempotencyKey` names a send, so that sending the same message again — a
|
|
210
|
+
retry after a timeout, a job run twice — delivers it once. What a transport
|
|
211
|
+
does with it depends on its provider:
|
|
212
|
+
|
|
213
|
+
| The provider | The transport |
|
|
214
|
+
| --- | --- |
|
|
215
|
+
| deduplicates requests on a key — an `Idempotency-Key` header, a field of its API | passes the key there, as it is. A repeated key answers the first send's id: resolve with it, as for any hand-over. Map the provider's answers: a key reused for a **different** message is a `MailRefused` (sending it again fails again); a key whose first send is **still in progress** is a `MailFailure` (a later retry may work) |
|
|
216
|
+
| has no such mechanism — SMTP, most relays | ignores the key. It does not refuse the message, and does not emulate deduplication with state of its own: a cache in one process is not what the caller was promised |
|
|
217
|
+
|
|
218
|
+
Either way, **never put the key in the e-mail** — not in the body, not as a
|
|
219
|
+
header the recipient receives. It names the send, not the message, and it is
|
|
220
|
+
derived from what the e-mail is about (`order-42/receipt`): a caller did not
|
|
221
|
+
choose to show it. `@nxgt/mail-resend` sends it as Resend's `Idempotency-Key`;
|
|
222
|
+
`@nxgt/mail-smtp` ignores it.
|
|
223
|
+
|
|
224
|
+
Document which one yours does, and for how long the provider remembers a key:
|
|
225
|
+
past that window, a retry delivers again.
|
|
226
|
+
|
|
227
|
+
The conformance suite has no case for it — a transport that ignores the key
|
|
228
|
+
is as correct as one that honours it. Test it in your own specs: the key
|
|
229
|
+
reaches the provider where it should, and nowhere else.
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { expect, test } from 'bun:test';
|
|
233
|
+
import { sampleMessage } from '@nxgt/mail/conformance';
|
|
234
|
+
import { createHttpMailer } from './http-mailer';
|
|
235
|
+
|
|
236
|
+
test('sends the idempotency key as a header, never in the body', async () => {
|
|
237
|
+
const requests: { headers: Headers; body: Record<string, unknown> }[] = [];
|
|
238
|
+
const mailer = createHttpMailer({
|
|
239
|
+
endpoint: 'https://mail.example.test/send',
|
|
240
|
+
apiKey: 'test',
|
|
241
|
+
fetch: async (_url, init) => {
|
|
242
|
+
requests.push({ headers: new Headers(init.headers), body: JSON.parse(String(init.body)) });
|
|
243
|
+
return Response.json({ id: 'm-1' });
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
await mailer.send({ ...sampleMessage, idempotencyKey: 'order-42/receipt' });
|
|
247
|
+
expect(requests[0]?.headers.get('idempotency-key')).toBe('order-42/receipt');
|
|
248
|
+
expect('idempotencyKey' in (requests[0]?.body ?? {})).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
193
252
|
## The conformance suite
|
|
194
253
|
|
|
195
254
|
```ts
|
package/docs/roadmap.md
CHANGED
|
@@ -6,16 +6,15 @@ the only number.
|
|
|
6
6
|
|
|
7
7
|
## Now
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
`
|
|
18
|
-
transports send them. Built, not yet published.
|
|
9
|
+
- **An idempotency key per send** — `idempotencyKey` on a `MailMessage`
|
|
10
|
+
names the send, so sending it again — a retry after a timeout, a job run
|
|
11
|
+
twice — delivers it once where the transport can deduplicate; a transport
|
|
12
|
+
that cannot ignores it. `checkMessage` refuses a key that is not 1 to 256
|
|
13
|
+
visible ASCII characters, never quoting it. The memory mailer honours it as
|
|
14
|
+
Resend does: the same message under a key it already delivered answers
|
|
15
|
+
that delivery's `messageId` and delivers nothing more, a different message
|
|
16
|
+
under it is a `MailRefused`, a failed send leaves its key free, and
|
|
17
|
+
`clear()` forgets the keys. Built, not yet published.
|
|
19
18
|
|
|
20
19
|
## Next
|
|
21
20
|
|
|
@@ -72,6 +71,16 @@ Nothing yet.
|
|
|
72
71
|
The last ten, newest first, each with the version it came in. Everything
|
|
73
72
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
74
73
|
|
|
74
|
+
- **Attachments, v0.2.0** — `attachments` on a `MailMessage`: each file's bytes as a
|
|
75
|
+
`Uint8Array`, its name and its type. Bytes only — no path, no URL, no
|
|
76
|
+
stream, so a transport never reads a file or fetches a URL for you; a large
|
|
77
|
+
or sensitive file stays a signed link in the template. `checkMessage`
|
|
78
|
+
refuses a name holding a path, a line break, a control or a format
|
|
79
|
+
character, `.` or `..`, and a type that is not `type/subtype` or is a MIME
|
|
80
|
+
container; the memory mailer keeps a copy of the
|
|
81
|
+
bytes; the conformance suite gains `send.attachment` and
|
|
82
|
+
`send.refusesAttachmentPath`, thirteen cases in all. The SMTP and Resend
|
|
83
|
+
transports send them.
|
|
75
84
|
- **The run-time core, v0.1.0** — `@nxgt/mail`, with no dependency: the `Mailer` port
|
|
76
85
|
a transport implements, the `Rendered` and `MailMessage` shapes it sends, and
|
|
77
86
|
its two errors — `MailFailure` (`MAIL_FAILED`) when the transport could not
|
package/docs/troubleshooting.md
CHANGED
|
@@ -64,6 +64,9 @@ How the messages are shaped:
|
|
|
64
64
|
- [`send: attachments[<n>].content must be a Uint8Array — the file's bytes, never a path or a URL`](#send-attachmentsncontent-must-be-a-uint8array--the-files-bytes-never-a-path-or-a-url)
|
|
65
65
|
- [`send: attachments[<n>].filename must be a file name — not empty, not . or .., without / or \, a line break or a control character`](#send-attachmentsnfilename-must-be-a-file-name--not-empty-not--or--without--or--a-line-break-or-a-control-character)
|
|
66
66
|
- [`send: attachments[<n>].contentType must be a file's type/subtype, as application/pdf — never multipart/* or message/*`](#send-attachmentsncontenttype-must-be-a-files-typesubtype-as-applicationpdf--never-multipart-or-message)
|
|
67
|
+
- [`send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt`](#send-idempotencykey-must-be-1-to-256-visible-ascii-characters-as-order-42receipt)
|
|
68
|
+
- [`send: idempotencyKey was already used for a different message — a key names one e-mail`](#send-idempotencykey-was-already-used-for-a-different-message--a-key-names-one-e-mail)
|
|
69
|
+
- [An e-mail is delivered twice although it has an `idempotencyKey`](#an-e-mail-is-delivered-twice-although-it-has-an-idempotencykey)
|
|
67
70
|
- [`send: the memory mailer was told to fail this send`](#send-the-memory-mailer-was-told-to-fail-this-send)
|
|
68
71
|
|
|
69
72
|
**Locale**
|
|
@@ -495,8 +498,11 @@ whether the e-mail is still worth sending.
|
|
|
495
498
|
the transport's message when the provider answered that the message is
|
|
496
499
|
malformed or too large.
|
|
497
500
|
**Why:** something in the message would break a header, has no valid
|
|
498
|
-
recipient,
|
|
499
|
-
|
|
501
|
+
recipient, is an attachment that is not bytes or is badly named, or is an
|
|
502
|
+
`idempotencyKey` that is malformed or already used for a different message
|
|
503
|
+
(the memory mailer's refusal, or Resend's `409 invalid_idempotent_request`) —
|
|
504
|
+
or the whole message is over the provider's size limit. Sending it again
|
|
505
|
+
unchanged fails again.
|
|
500
506
|
**Fix:** read `error.message` for where the problem is, and fix the message;
|
|
501
507
|
the entries below cover each one. Handle the code as in the
|
|
502
508
|
[`MAIL_FAILED`](#mail_failed--mailfailure-the-transport-could-not-hand-the-message-over)
|
|
@@ -756,6 +762,91 @@ const notes: MailAttachment = {
|
|
|
756
762
|
};
|
|
757
763
|
```
|
|
758
764
|
|
|
765
|
+
### `send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt`
|
|
766
|
+
|
|
767
|
+
**When:** `send`, with an `idempotencyKey` that is empty, not a string,
|
|
768
|
+
longer than 256 characters, or holds a space, a line break, a control
|
|
769
|
+
character or anything outside ASCII — an accent, an emoji.
|
|
770
|
+
The message never holds the key.
|
|
771
|
+
**Why:** a transport that deduplicates writes the key into a header (Resend's
|
|
772
|
+
`Idempotency-Key`), where only visible ASCII is safe, and Resend takes at most
|
|
773
|
+
256 characters. The key is checked the same way on every transport, even one
|
|
774
|
+
that ignores it, so switching transports never turns a working key into a
|
|
775
|
+
refusal.
|
|
776
|
+
**Fix:** build the key from what the e-mail is about, and encode what you did
|
|
777
|
+
not write. `encodeURIComponent` keeps a readable key in visible ASCII; a hash
|
|
778
|
+
bounds its length:
|
|
779
|
+
|
|
780
|
+
```ts
|
|
781
|
+
const orderRef = 'Commande n° 42'; // yours, not ASCII
|
|
782
|
+
|
|
783
|
+
// Readable, when the reference is short:
|
|
784
|
+
const key = `order-${encodeURIComponent(orderRef)}/receipt`; // order-Commande%20n%C2%B0%2042/receipt
|
|
785
|
+
|
|
786
|
+
// Always under 256, whatever the reference:
|
|
787
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(orderRef));
|
|
788
|
+
const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
789
|
+
const hashed = `order-${hex}/receipt`; // 64 hex digits, on any runtime
|
|
790
|
+
|
|
791
|
+
// Pick one, and build it the same way on every attempt:
|
|
792
|
+
await mailer.send({ ...message, idempotencyKey: hashed });
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
### `send: idempotencyKey was already used for a different message — a key names one e-mail`
|
|
796
|
+
|
|
797
|
+
**When:** a test, on a send through `createMemoryMailer()` whose
|
|
798
|
+
`idempotencyKey` the mailer already delivered, with a message that differs —
|
|
799
|
+
another recipient, subject, body, header or attachment. A `MailRefused`,
|
|
800
|
+
`code: 'MAIL_REFUSED'`; nothing is delivered. The same message again, key
|
|
801
|
+
included, is not refused: it answers the first `messageId`.
|
|
802
|
+
**Why:** a key names one e-mail. The memory mailer refuses a second, different
|
|
803
|
+
message under it as Resend does (a `409` `invalid_idempotent_request`, which
|
|
804
|
+
`@nxgt/mail-resend` throws as `send: Resend refused the message`), so the test
|
|
805
|
+
fails where production would. The usual causes: a key per user or per job
|
|
806
|
+
rather than per e-mail, or a retry that rendered the e-mail again with a
|
|
807
|
+
template or a variable that changed in between.
|
|
808
|
+
**Fix:** one key per e-mail, and the same message on every attempt at it —
|
|
809
|
+
render once, keep the result with the job, and send that on retry:
|
|
810
|
+
|
|
811
|
+
```ts
|
|
812
|
+
const message = { ...rendered, to: user.email, idempotencyKey: `order-${order.id}/receipt` };
|
|
813
|
+
|
|
814
|
+
await mailer.send(message); // the first attempt
|
|
815
|
+
await mailer.send(message); // a retry: the same message, the first messageId
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
A message meant to be different — a corrected receipt — is a new e-mail:
|
|
819
|
+
give it a new key (`order-42/receipt-2`). Between tests, `clear()` forgets the
|
|
820
|
+
keys.
|
|
821
|
+
|
|
822
|
+
### An e-mail is delivered twice although it has an `idempotencyKey`
|
|
823
|
+
|
|
824
|
+
**When:** a retry — after a `MailFailure`, a timeout, a job run twice —
|
|
825
|
+
delivers a second copy, although both sends carried an `idempotencyKey`.
|
|
826
|
+
**Why:** either the key changed between the attempts, or the transport cannot
|
|
827
|
+
deduplicate. A key built from `Date.now()` or `crypto.randomUUID()` at each
|
|
828
|
+
attempt names a new send each time, so it deduplicates nothing. SMTP has no
|
|
829
|
+
idempotency: `@nxgt/mail-smtp` ignores the key, and a message sent twice is
|
|
830
|
+
delivered twice. Resend keeps a key for 24 hours; a retry after that is a new
|
|
831
|
+
send.
|
|
832
|
+
**Fix:** derive the key from what the e-mail is about — one key per e-mail
|
|
833
|
+
the application means to send once — and compute it the same way on every
|
|
834
|
+
attempt:
|
|
835
|
+
|
|
836
|
+
```ts
|
|
837
|
+
// ✗ a new key per attempt: every retry is a new e-mail
|
|
838
|
+
await mailer.send({ ...message, idempotencyKey: crypto.randomUUID() });
|
|
839
|
+
|
|
840
|
+
// ✓ the same key for every attempt at this e-mail
|
|
841
|
+
await mailer.send({ ...message, idempotencyKey: `order-${order.id}/receipt` });
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
A key per user (`user-${user.id}`) is the opposite mistake: the second,
|
|
845
|
+
different e-mail to that user is refused — by the memory mailer, as
|
|
846
|
+
[`send: idempotencyKey was already used for a different message — a key names one e-mail`](#send-idempotencykey-was-already-used-for-a-different-message--a-key-names-one-e-mail),
|
|
847
|
+
and by Resend, as `send: Resend refused the message`. When a random key is
|
|
848
|
+
what you have, create it once, store it with the job, and reuse it on retry.
|
|
849
|
+
|
|
759
850
|
### `send: the memory mailer was told to fail this send`
|
|
760
851
|
|
|
761
852
|
**When:** a test, on a send through `createMemoryMailer()` after
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/mail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The run-time side of transactional e-mail: the renderer that fills a Maizzle build made with @nxgt/mail-i18n, the Mailer a transport implements, its errors, a memory transport and locale selection. No dependency.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../src/message.ts", "../src/memory.ts"],
|
|
4
|
-
"sourcesContent": [
|
|
5
|
-
"import { MailRefused } from './errors';\nimport type { Address, MailAttachment, MailMessage } from './types';\n\nconst LINE_BREAK = /[\\r\\n]/;\n// Deliberately loose: one `@`, something on each side, and none of what an\n// address list parser reads as structure — whitespace, `<` `>` (a display\n// name), `,` `;` (a second address), `:` (a group). A provider that parses\n// the string then finds one mailbox, the one checked. Whether the mailbox\n// exists is the receiving server's question.\nconst ADDRESS = /^[^\\s@<>,;:]+@[^\\s@<>,;:]+$/;\nconst HEADER_NAME = /^[A-Za-z0-9-]+$/;\n// The headers a transport writes from the message: the addresses, the subject\n// and the MIME structure. Set through `headers`, a Bcc reaches an SMTP\n// envelope unchecked, and a Content-Type rewrites how the parts are read.\nconst RESERVED_HEADER =\n\t/^(?:to|cc|bcc|from|sender|reply-to|return-path|subject|mime-version|content-.*)$/i;\n\n// A file name is shown and saved by the recipient's mail client: no path\n// separator and no `.` or `..` (a client that saves it as is writes\n// elsewhere), no line break or other control character, C1 included (a header\n// could be split on one), and no format character — a right-to-left override\n// disguises `fdp.exe` as `exe.pdf`.\nconst FILENAME_REFUSED = /[/\\\\\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}]/u;\nconst DOT_NAME = /^\\.\\.?$/;\n// RFC 2045: type \"/\" subtype, each a token — any printable ASCII but space\n// and the tspecials ()<>@,;:\\\"/[]?=. No parameters: a charset or a name\n// there would be a second, unchecked place to write the file's name.\nconst CONTENT_TYPE =\n\t/^[!#$%&'*+.^_`{|}~0-9A-Za-z-]+\\/[!#$%&'*+.^_`{|}~0-9A-Za-z-]+$/;\n// multipart/* and message/* are MIME containers, not files: nodemailer writes\n// them unencoded, and the receiving end reads back no attachment at all.\nconst CONTAINER_TYPE = /^(?:multipart|message)\\//i;\n\n/** Every recipient of a message, as bare addresses, in order. */\nexport function recipientsOf(message: MailMessage): string[] {\n\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\treturn to.map(addressOf);\n}\n\n/** The bare address of an {@link Address}. */\nexport function addressOf(address: Address): string {\n\treturn typeof address === 'string' ? address : address.address;\n}\n\n/** Refuses `address` unless it is an {@link Address}. `undefined` is refused too. */\nfunction checkAddress(address: Address | undefined, where: string): void {\n\tif (typeof address === 'string') {\n\t\tif (!ADDRESS.test(address)) {\n\t\t\tthrow new MailRefused(`send: ${where} is not an e-mail address`);\n\t\t}\n\t\treturn;\n\t}\n\tif (typeof address !== 'object' || address === null) {\n\t\tthrow new MailRefused(`send: ${where} is not an e-mail address`);\n\t}\n\tif (typeof address.address !== 'string' || !ADDRESS.test(address.address)) {\n\t\tthrow new MailRefused(`send: ${where}.address is not an e-mail address`);\n\t}\n\tif (typeof address.name !== 'string' || LINE_BREAK.test(address.name)) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.name must be a string without a line break`,\n\t\t);\n\t}\n}\n\n/** Refuses `attachment` unless it is a {@link MailAttachment}. */\nfunction checkAttachment(attachment: MailAttachment, where: string): void {\n\tif (typeof attachment !== 'object' || attachment === null) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where} must be an object, as { filename, content, contentType }`,\n\t\t);\n\t}\n\tif (!(attachment.content instanceof Uint8Array)) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.content must be a Uint8Array — the file's bytes, never a path or a URL`,\n\t\t);\n\t}\n\tif (\n\t\ttypeof attachment.filename !== 'string' ||\n\t\tattachment.filename === '' ||\n\t\tDOT_NAME.test(attachment.filename) ||\n\t\tFILENAME_REFUSED.test(attachment.filename)\n\t) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.filename must be a file name — not empty, not . or .., without / or \\\\, a line break or a control character`,\n\t\t);\n\t}\n\tif (\n\t\ttypeof attachment.contentType !== 'string' ||\n\t\t!CONTENT_TYPE.test(attachment.contentType) ||\n\t\tCONTAINER_TYPE.test(attachment.contentType)\n\t) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.contentType must be a file's type/subtype, as application/pdf — never multipart/* or message/*`,\n\t\t);\n\t}\n}\n\n/**\n * Refuses a message no transport should hand over, with a {@link MailRefused}\n * that names **where** the problem is and never the value.\n *\n * A transport calls it first thing in `send`, so the refusals are the same\n * whichever transport is wired. It checks:\n *\n * - at least one recipient, each one an address;\n * - `from` and `replyTo`, when present, are addresses;\n * - `subject`, `html` and `text` are strings, and `subject` holds no line\n * break — a line break in a subject is a header injection;\n * - every header name is letters, digits and hyphens, none names what the\n * transport writes from the message (`To`, `Cc`, `Bcc`, `From`, `Sender`,\n * `Reply-To`, `Return-Path`, `Subject`, `MIME-Version`, `Content-*`, in\n * any case), and no header value holds a line break;\n * - `attachments`, when present, is an array without holes — empty is the\n * same as absent — and each entry has its bytes as a `Uint8Array`, a\n * `filename` that is not empty, `.` or `..` and holds no `/`, `\\`, line\n * break, control or format character, and a `contentType` that is a bare\n * `type/subtype`, never `multipart/*` or `message/*`.\n */\nexport function checkMessage(message: MailMessage): void {\n\tif (typeof message !== 'object' || message === null) {\n\t\tthrow new MailRefused('send: the message must be an object');\n\t}\n\tif (message.to === undefined || message.to === null) {\n\t\tthrow new MailRefused('send: to must hold at least one address');\n\t}\n\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\tif (to.length === 0) {\n\t\tthrow new MailRefused('send: to must hold at least one address');\n\t}\n\tto.forEach((address, index) => {\n\t\tcheckAddress(address, Array.isArray(message.to) ? `to[${index}]` : 'to');\n\t});\n\tif (message.from !== undefined) checkAddress(message.from, 'from');\n\tif (message.replyTo !== undefined) checkAddress(message.replyTo, 'replyTo');\n\n\tfor (const part of ['subject', 'html', 'text'] as const) {\n\t\tif (typeof message[part] !== 'string') {\n\t\t\tthrow new MailRefused(`send: ${part} must be a string`);\n\t\t}\n\t}\n\tif (LINE_BREAK.test(message.subject)) {\n\t\tthrow new MailRefused('send: subject must not hold a line break');\n\t}\n\n\tfor (const [name, value] of Object.entries(message.headers ?? {})) {\n\t\tif (!HEADER_NAME.test(name)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t'send: a header name must be letters, digits and hyphens',\n\t\t\t);\n\t\t}\n\t\tif (RESERVED_HEADER.test(name)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t`send: header ${name} is reserved — addresses, the subject and the MIME structure are never custom headers`,\n\t\t\t);\n\t\t}\n\t\tif (typeof value !== 'string' || LINE_BREAK.test(value)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t`send: header ${name} must be a string without a line break`,\n\t\t\t);\n\t\t}\n\t}\n\n\tif (message.attachments !== undefined) {\n\t\tif (!Array.isArray(message.attachments)) {\n\t\t\tthrow new MailRefused('send: attachments must be an array');\n\t\t}\n\t\t// Indexed, not forEach: a hole in the array is refused, not skipped.\n\t\tfor (let index = 0; index < message.attachments.length; index++) {\n\t\t\tcheckAttachment(message.attachments[index], `attachments[${index}]`);\n\t\t}\n\t}\n}\n",
|
|
6
|
-
"import { type MailError, MailFailure } from './errors';\nimport { checkMessage } from './message';\nimport type { Mailer, MailMessage, SentMail } from './types';\n\n/** One message the memory mailer accepted, with the id it gave it. */\nexport interface MemoryMail extends MailMessage {\n\treadonly messageId: string;\n}\n\n/**\n * The reference transport: it keeps what it sends in memory, for tests.\n *\n * It refuses exactly what every transport refuses (it calls\n * {@link checkMessage}), and it can be told to fail, so a test can prove what\n * the application does when a send throws.\n */\nexport interface MemoryMailer extends Mailer {\n\t/** Every message accepted so far, oldest first. A copy: mutating it changes nothing. */\n\treadonly sent: readonly MemoryMail[];\n\t/**\n\t * How many sends reached the hand-over, failed ones included. A message\n\t * refused as malformed never reaches it. A caller that retries in secret\n\t * shows up here.\n\t */\n\treadonly attempts: number;\n\t/**\n\t * Makes the next send that reaches the hand-over reject with `error`, by\n\t * default a {@link MailFailure} as an outage would. Calls queue: two calls\n\t * fail the next two sends.\n\t */\n\tfailNext(error?: MailError): void;\n\t/** Forgets what was sent, the attempts, and any queued failure. */\n\tclear(): void;\n}\n\n/**\n * A copy of `message` the caller cannot change afterwards. Each attachment's\n * bytes are copied to a plain `Uint8Array` of their own: a `Buffer` from\n * Node's pool is a view on a larger, shared buffer, which a clone would copy\n * whole.\n */\nfunction copyOf(message: MailMessage): MailMessage {\n\tconst { attachments, ...rest } = message;\n\tconst copy = structuredClone(rest);\n\tif (attachments === undefined) return copy;\n\treturn {\n\t\t...copy,\n\t\tattachments: attachments.map((attachment) => ({\n\t\t\tfilename: attachment.filename,\n\t\t\tcontent: new Uint8Array(attachment.content),\n\t\t\tcontentType: attachment.contentType,\n\t\t})),\n\t};\n}\n\n/** Creates a {@link MemoryMailer}. Message ids are `memory-1`, `memory-2`, … */\nexport function createMemoryMailer(): MemoryMailer {\n\tlet sent: MemoryMail[] = [];\n\tlet failures: MailError[] = [];\n\tlet attempts = 0;\n\tlet counter = 0;\n\n\treturn {\n\t\tget sent() {\n\t\t\treturn sent.map((mail) => structuredClone(mail));\n\t\t},\n\t\tget attempts() {\n\t\t\treturn attempts;\n\t\t},\n\t\tfailNext(error) {\n\t\t\tfailures.push(\n\t\t\t\terror ??\n\t\t\t\t\tnew MailFailure(\n\t\t\t\t\t\t'send: the memory mailer was told to fail this send',\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcause: new Error('memory mailer: failNext'),\n\t\t\t\t\t\t},\n\t\t\t\t\t),\n\t\t\t);\n\t\t},\n\t\tclear() {\n\t\t\tsent = [];\n\t\t\tfailures = [];\n\t\t\tattempts = 0;\n\t\t},\n\t\tasync send(message): Promise<SentMail> {\n\t\t\tcheckMessage(message);\n\t\t\tattempts += 1;\n\t\t\tconst failure = failures.shift();\n\t\t\tif (failure !== undefined) throw failure;\n\n\t\t\tcounter += 1;\n\t\t\tconst messageId = `memory-${counter}`;\n\t\t\tsent.push({ ...copyOf(message), messageId });\n\t\t\treturn { messageId };\n\t\t},\n\t};\n}\n"
|
|
7
|
-
],
|
|
8
|
-
"mappings": "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",
|
|
9
|
-
"debugId": "49A96A07CA93F79964756E2164756E21",
|
|
10
|
-
"names": []
|
|
11
|
-
}
|