@nxgt/mail 0.2.0 → 0.4.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 +104 -4
- package/dist/chunks/{index-4h39j3n7.js → index-nkzwt8vn.js} +39 -2
- package/dist/chunks/index-nkzwt8vn.js.map +11 -0
- package/dist/chunks/index-we4n5yfz.js.map +2 -2
- package/dist/conformance/index.js +1 -1
- package/dist/errors.d.ts +2 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -2
- package/dist/index.js.map +4 -3
- 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/dist/unsubscribe.d.ts +43 -0
- package/dist/unsubscribe.d.ts.map +1 -0
- package/docs/README.md +3 -3
- package/docs/guide/sending.md +304 -9
- package/docs/guide/testing.md +91 -4
- package/docs/guide/transports.md +67 -8
- package/docs/roadmap.md +26 -15
- package/docs/troubleshooting.md +261 -3
- package/package.json +1 -1
- package/dist/chunks/index-4h39j3n7.js.map +0 -11
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ import without extensions, so `nodenext` is not supported.
|
|
|
41
41
|
|
|
42
42
|
| Import | What it holds |
|
|
43
43
|
| --- | --- |
|
|
44
|
-
| `@nxgt/mail` | The port (`Mailer`, `MailMessage`, `Rendered`, `SentMail`, `Address`, `MailAttachment`), the errors (`MailError`, `MailFailure`, `MailRefused`), `createMemoryMailer`, `pickLocale` and `parseAcceptLanguage`, and what a transport calls first: `checkMessage`, `recipientsOf`, `addressOf`. No Node built-in: it runs anywhere |
|
|
44
|
+
| `@nxgt/mail` | The port (`Mailer`, `MailMessage`, `Rendered`, `SentMail`, `Address`, `MailAttachment`), the errors (`MailError`, `MailFailure`, `MailRefused`), `createMemoryMailer`, `pickLocale` and `parseAcceptLanguage`, `listUnsubscribe` with `ListUnsubscribeOptions` and `ListUnsubscribeHeaders`, and what a transport calls first: `checkMessage`, `recipientsOf`, `addressOf`. No Node built-in: it runs anywhere |
|
|
45
45
|
| `@nxgt/mail/renderer` | The renderer: `createMailRenderer`, `MailRenderer`, `MailRendererOptions`, `RenderOptions`, `MailVariables`, and the types that type it with a build's `MailEmails` (`MailEmailsOf`, `AnyMailEmails`, `RenderArguments`). Reads the build with `node:fs` |
|
|
46
46
|
| `@nxgt/mail/conformance` | **For transport authors**: `describeMailer`, its cases as data, `runMailerCase`, the messages they send (`sampleMessage`, `sampleAttachment`), and the memory mailer's harness as a worked example |
|
|
47
47
|
|
|
@@ -154,6 +154,84 @@ 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
|
+
|
|
196
|
+
### One-click unsubscribe — `listUnsubscribe`
|
|
197
|
+
|
|
198
|
+
Gmail and Yahoo require bulk senders to offer one-click unsubscribe on
|
|
199
|
+
marketing mail. `listUnsubscribe` answers its two headers, to spread into
|
|
200
|
+
`headers`, with a URL per recipient:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { listUnsubscribe, type Mailer, type Rendered } from '@nxgt/mail';
|
|
204
|
+
|
|
205
|
+
export async function sendNewsletter(
|
|
206
|
+
mailer: Mailer,
|
|
207
|
+
rendered: Rendered,
|
|
208
|
+
subscriber: { email: string; unsubscribeToken: string },
|
|
209
|
+
): Promise<void> {
|
|
210
|
+
await mailer.send({
|
|
211
|
+
...rendered,
|
|
212
|
+
to: subscriber.email,
|
|
213
|
+
headers: {
|
|
214
|
+
...listUnsubscribe({
|
|
215
|
+
url: `https://example.com/unsubscribe?token=${encodeURIComponent(subscriber.unsubscribeToken)}`,
|
|
216
|
+
mailto: 'unsubscribe@example.com', // optional
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
// List-Unsubscribe: <https://example.com/unsubscribe?token=…>, <mailto:unsubscribe@example.com>
|
|
222
|
+
// List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The URL must start with `https://`, be printable ASCII, carry no user or
|
|
226
|
+
password, and hold no `<`, `>`, double quote or raw comma (percent-encode
|
|
227
|
+
it: `%2C`), and `mailto` must be a bare ASCII address; anything else is a
|
|
228
|
+
`MailRefused` that never quotes the URL — its token is a credential. Your
|
|
229
|
+
endpoint must unsubscribe on a `POST` with the body
|
|
230
|
+
`List-Unsubscribe=One-Click`, with no login and no confirmation. It belongs on
|
|
231
|
+
marketing and bulk mail, not on a password reset or a sign-in code. See
|
|
232
|
+
[Sending — one-click unsubscribe](docs/guide/sending.md#one-click-unsubscribe)
|
|
233
|
+
for the endpoint and DKIM.
|
|
234
|
+
|
|
157
235
|
### Errors — switch on `code`
|
|
158
236
|
|
|
159
237
|
Both errors extend `MailError`, whose `code` is a union a `switch` exhausts.
|
|
@@ -251,13 +329,19 @@ export function createHttpMailer(endpoint: string, apiKey: string): Mailer {
|
|
|
251
329
|
return {
|
|
252
330
|
async send(message) {
|
|
253
331
|
checkMessage(message); // MailRefused, naming where, never the value
|
|
332
|
+
const { idempotencyKey, ...fields } = message; // names the send: never in the body
|
|
254
333
|
const attachments = message.attachments?.length
|
|
255
334
|
? message.attachments.map((file) => ({ ...file, content: base64Of(file.content) }))
|
|
256
335
|
: undefined; // an empty list is none
|
|
257
336
|
const response = await fetch(endpoint, {
|
|
258
337
|
method: 'POST',
|
|
259
|
-
headers: {
|
|
260
|
-
|
|
338
|
+
headers: {
|
|
339
|
+
authorization: `Bearer ${apiKey}`,
|
|
340
|
+
'content-type': 'application/json',
|
|
341
|
+
// if the provider deduplicates; a transport whose provider cannot ignores the key
|
|
342
|
+
...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }),
|
|
343
|
+
},
|
|
344
|
+
body: JSON.stringify({ ...fields, to: recipientsOf(message), attachments }),
|
|
261
345
|
}).catch((cause: unknown) => {
|
|
262
346
|
throw new MailFailure('send: the provider could not be reached', { cause });
|
|
263
347
|
});
|
|
@@ -334,6 +418,10 @@ a recipient no check saw: `checkMessage` refuses `To`, `Cc`, `Bcc`, `From`,
|
|
|
334
418
|
again unchanged. Nothing in this package retries a `MAIL_FAILED`: a retry is
|
|
335
419
|
your decision, made where you can see it.
|
|
336
420
|
|
|
421
|
+
**An idempotency key from the clock or a random value protects nothing.**
|
|
422
|
+
``idempotencyKey: `receipt-${Date.now()}` `` gives the retry a new key, and
|
|
423
|
+
the e-mail goes out twice; write ``idempotencyKey: `order-${order.id}/receipt` ``.
|
|
424
|
+
|
|
337
425
|
**The locale is the recipient's, not the request's.** An administrator who
|
|
338
426
|
invites a user sends the invitation in the *user's* locale:
|
|
339
427
|
`pickLocale(invitee.locale, supported, fallback)`.
|
|
@@ -347,7 +435,7 @@ gives a test file `describe` and `it` as bare identifiers, not on `globalThis`.
|
|
|
347
435
|
|
|
348
436
|
## Type safety, counted
|
|
349
437
|
|
|
350
|
-
**
|
|
438
|
+
**23 plausible mistakes, 23 refused** at compile time, each measured by a
|
|
351
439
|
`@ts-expect-error` in
|
|
352
440
|
[`test/types/refusals.ts`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/test/types/refusals.ts)
|
|
353
441
|
that fails the typecheck the moment it stops holding:
|
|
@@ -384,6 +472,16 @@ And an attachment:
|
|
|
384
472
|
20. No `contentType`: nothing guesses it from the file name.
|
|
385
473
|
21. One attachment, not in a list.
|
|
386
474
|
|
|
475
|
+
And the idempotency key:
|
|
476
|
+
|
|
477
|
+
22. A number (`idempotencyKey: order.id`): the key is a string, as
|
|
478
|
+
`order-42/receipt`.
|
|
479
|
+
|
|
480
|
+
And one-click unsubscribe:
|
|
481
|
+
|
|
482
|
+
23. A `URL` object as `listUnsubscribe`'s `url`: the header holds text, so
|
|
483
|
+
pass `url.href`.
|
|
484
|
+
|
|
387
485
|
The same file holds the calls that must keep compiling: a refusal that refuses
|
|
388
486
|
the correct call is a bug.
|
|
389
487
|
|
|
@@ -396,6 +494,8 @@ the correct call is a bug.
|
|
|
396
494
|
planned.
|
|
397
495
|
- [Vocabulary](https://github.com/softistx/nxgt-mail/blob/develop/docs/vocabulary.md)
|
|
398
496
|
— the words these pages use, defined once.
|
|
497
|
+
- [The starter](https://github.com/softistx/nxgt-mail/tree/develop/examples/starter)
|
|
498
|
+
— a Maizzle project that builds, renders and sends one e-mail, to copy.
|
|
399
499
|
|
|
400
500
|
## Licence
|
|
401
501
|
|
|
@@ -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
|
+
}
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/errors.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * What a transport refuses, as a string a caller can switch on.\n *\n * Every code is a **refusal at call time**. A refusal that can only come from\n * how the application was wired — a bad option passed to a factory — is a\n * bare `TypeError` instead: no handler should ever answer one.\n *\n * The codes are `SCREAMING_SNAKE` because they are data values, not API\n * identifiers. Every key in this package is `camelCase`.\n */\nexport type MailErrorCode =\n\t/**\n\t * The transport could not hand the message over: a refused connection, a\n\t * timeout, a 5xx from the provider, an expired credential. The transport's\n\t * own error is the `cause`.\n\t *\n\t * **Nothing is known to have been sent** — after a timeout or a dropped\n\t * connection, the provider may have taken it all the same. Retry later,\n\t * or tell the user it failed. Never report it as sent.\n\t */\n\t| 'MAIL_FAILED'\n\t/**\n\t * The message itself was refused, before or by the transport: no\n\t * recipient, something that is not an address, a line break in the\n\t * subject or a header, an attachment that is not bytes or is badly named,\n\t * a provider answering that the message is malformed or too large, or —\n\t * from `@nxgt/mail/renderer` — a URL variable that is not an `http:`,\n\t * `https:` or `mailto:` URL. Sending it again unchanged fails again.\n\t */\n\t| 'MAIL_REFUSED';\n\n/** Options every error of this package accepts. */\nexport interface MailErrorOptions {\n\t/** The error that caused this one, typically the transport's. */\n\treadonly cause?: unknown;\n}\n\n/**\n * The base class of every error this package throws at call time. It is\n * abstract: a transport throws {@link MailFailure} or {@link MailRefused}.\n *\n * **There is exactly one definition of this class.** A transport defines no\n * error class of its own and throws these, imported from its `@nxgt/mail`\n * peer, so `error instanceof MailFailure` holds whatever transport threw it.\n *\n * A message reports **a shape, never a value**: never a recipient address,\n * never a subject, never a link — the link in a verification e-mail is a\n * credential.\n */\nexport abstract class MailError extends Error {\n\toverride name = 'MailError';\n\t/**\n\t * Abstract, so a transport cannot throw a bare `MailError` that passes a\n\t * `code` check and fails `instanceof MailFailure`: it throws one of the two\n\t * subclasses.\n\t */\n\tabstract readonly code: MailErrorCode;\n\n\tconstructor(message: string, options?: MailErrorOptions) {\n\t\tsuper(message, { cause: options?.cause });\n\t}\n}\n\n/** The transport could not hand the message over. Code `MAIL_FAILED`. */\nexport class MailFailure extends MailError {\n\toverride name = 'MailFailure';\n\toverride readonly code = 'MAIL_FAILED' as const;\n}\n\n/** The message was refused as malformed. Code `MAIL_REFUSED`. */\nexport class MailRefused extends MailError {\n\toverride name = 'MailRefused';\n\toverride readonly code = 'MAIL_REFUSED' as const;\n}\n"
|
|
5
|
+
"/**\n * What a transport refuses, as a string a caller can switch on.\n *\n * Every code is a **refusal at call time**. A refusal that can only come from\n * how the application was wired — a bad option passed to a factory — is a\n * bare `TypeError` instead: no handler should ever answer one.\n *\n * The codes are `SCREAMING_SNAKE` because they are data values, not API\n * identifiers. Every key in this package is `camelCase`.\n */\nexport type MailErrorCode =\n\t/**\n\t * The transport could not hand the message over: a refused connection, a\n\t * timeout, a 5xx from the provider, an expired credential. The transport's\n\t * own error is the `cause`.\n\t *\n\t * **Nothing is known to have been sent** — after a timeout or a dropped\n\t * connection, the provider may have taken it all the same. Retry later,\n\t * or tell the user it failed. Never report it as sent.\n\t */\n\t| 'MAIL_FAILED'\n\t/**\n\t * The message itself was refused, before or by the transport: no\n\t * recipient, something that is not an address, a line break in the\n\t * subject or a header, an attachment that is not bytes or is badly named,\n\t * a provider answering that the message is malformed or too large, or —\n\t * from `@nxgt/mail/renderer` — a URL variable that is not an `http:`,\n\t * `https:` or `mailto:` URL, or, from `listUnsubscribe`, an unsubscribe URL\n\t * or address it will not write. Sending it again unchanged fails again.\n\t */\n\t| 'MAIL_REFUSED';\n\n/** Options every error of this package accepts. */\nexport interface MailErrorOptions {\n\t/** The error that caused this one, typically the transport's. */\n\treadonly cause?: unknown;\n}\n\n/**\n * The base class of every error this package throws at call time. It is\n * abstract: a transport throws {@link MailFailure} or {@link MailRefused}.\n *\n * **There is exactly one definition of this class.** A transport defines no\n * error class of its own and throws these, imported from its `@nxgt/mail`\n * peer, so `error instanceof MailFailure` holds whatever transport threw it.\n *\n * A message reports **a shape, never a value**: never a recipient address,\n * never a subject, never a link — the link in a verification e-mail is a\n * credential.\n */\nexport abstract class MailError extends Error {\n\toverride name = 'MailError';\n\t/**\n\t * Abstract, so a transport cannot throw a bare `MailError` that passes a\n\t * `code` check and fails `instanceof MailFailure`: it throws one of the two\n\t * subclasses.\n\t */\n\tabstract readonly code: MailErrorCode;\n\n\tconstructor(message: string, options?: MailErrorOptions) {\n\t\tsuper(message, { cause: options?.cause });\n\t}\n}\n\n/** The transport could not hand the message over. Code `MAIL_FAILED`. */\nexport class MailFailure extends MailError {\n\toverride name = 'MailFailure';\n\toverride readonly code = 'MAIL_FAILED' as const;\n}\n\n/** The message was refused as malformed. Code `MAIL_REFUSED`. */\nexport class MailRefused extends MailError {\n\toverride name = 'MailRefused';\n\toverride readonly code = 'MAIL_REFUSED' as const;\n}\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": ";
|
|
7
|
+
"mappings": ";AAkDO,MAAe,mBAAkB,MAAM;AAAA,EAS7C,WAAW,CAAC,SAAiB,SAA4B;AAAA,IACxD,MAAM,SAAS,EAAE,OAAO,SAAS,MAAM,CAAC;AAAA,IAThC,YAAO;AAAA;AAWjB;AAAA;AAGO,MAAM,qBAAoB,WAAU;AAAA;AAAA;AAAA,IACjC,YAAO;AAAA,IACE,YAAO;AAAA;AAC1B;AAAA;AAGO,MAAM,qBAAoB,WAAU;AAAA;AAAA;AAAA,IACjC,YAAO;AAAA,IACE,YAAO;AAAA;AAC1B;",
|
|
8
8
|
"debugId": "5753AA1D988C668964756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -25,7 +25,8 @@ export type MailErrorCode =
|
|
|
25
25
|
* subject or a header, an attachment that is not bytes or is badly named,
|
|
26
26
|
* a provider answering that the message is malformed or too large, or —
|
|
27
27
|
* from `@nxgt/mail/renderer` — a URL variable that is not an `http:`,
|
|
28
|
-
* `https:` or `mailto:` URL
|
|
28
|
+
* `https:` or `mailto:` URL, or, from `listUnsubscribe`, an unsubscribe URL
|
|
29
|
+
* or address it will not write. Sending it again unchanged fails again.
|
|
29
30
|
*/
|
|
30
31
|
| 'MAIL_REFUSED';
|
|
31
32
|
/** Options every error of this package accepts. */
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,MAAM,aAAa;AACxB;;;;;;;;GAQG;AACD,aAAa;AACf
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,MAAM,aAAa;AACxB;;;;;;;;GAQG;AACD,aAAa;AACf;;;;;;;;GAQG;GACD,cAAc,CAAC;AAElB,mDAAmD;AACnD,MAAM,WAAW,gBAAgB;IAChC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,8BAAsB,SAAU,SAAQ,KAAK;IACnC,IAAI,SAAe;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;gBAE1B,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB;CAGvD;AAED,yEAAyE;AACzE,qBAAa,WAAY,SAAQ,SAAS;IAChC,IAAI,SAAiB;IAC9B,SAAkB,IAAI,EAAG,aAAa,CAAU;CAChD;AAED,iEAAiE;AACjE,qBAAa,WAAY,SAAQ,SAAS;IAChC,IAAI,SAAiB;IAC9B,SAAkB,IAAI,EAAG,cAAc,CAAU;CACjD"}
|
package/dist/index.d.ts
CHANGED
|
@@ -18,4 +18,5 @@ export { parseAcceptLanguage, pickLocale, type WantedLocales } from './locale';
|
|
|
18
18
|
export { createMemoryMailer, type MemoryMail, type MemoryMailer, } from './memory';
|
|
19
19
|
export { addressOf, checkMessage, recipientsOf } from './message';
|
|
20
20
|
export type { Address, MailAttachment, Mailer, MailMessage, Rendered, SentMail, } from './types';
|
|
21
|
+
export { type ListUnsubscribeHeaders, type ListUnsubscribeOptions, listUnsubscribe, } from './unsubscribe';
|
|
21
22
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EACN,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,WAAW,EACX,WAAW,GACX,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,mBAAmB,EAAE,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAC/E,OAAO,EACN,kBAAkB,EAClB,KAAK,UAAU,EACf,KAAK,YAAY,GACjB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAClE,YAAY,EACX,OAAO,EACP,cAAc,EACd,MAAM,EACN,WAAW,EACX,QAAQ,EACR,QAAQ,GACR,MAAM,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EACN,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,WAAW,EACX,WAAW,GACX,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,mBAAmB,EAAE,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAC/E,OAAO,EACN,kBAAkB,EAClB,KAAK,UAAU,EACf,KAAK,YAAY,GACjB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAClE,YAAY,EACX,OAAO,EACP,cAAc,EACd,MAAM,EACN,WAAW,EACX,QAAQ,EACR,QAAQ,GACR,MAAM,SAAS,CAAC;AACjB,OAAO,EACN,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAC3B,eAAe,GACf,MAAM,eAAe,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -7,12 +7,38 @@ import {
|
|
|
7
7
|
addressOf2,
|
|
8
8
|
checkMessage2,
|
|
9
9
|
createMemoryMailer2
|
|
10
|
-
} from "./chunks/index-
|
|
10
|
+
} from "./chunks/index-nkzwt8vn.js";
|
|
11
11
|
import {
|
|
12
12
|
MailError2,
|
|
13
13
|
MailFailure2,
|
|
14
14
|
MailRefused2
|
|
15
15
|
} from "./chunks/index-we4n5yfz.js";
|
|
16
|
+
// src/unsubscribe.ts
|
|
17
|
+
var URL_ALLOWED = /^https:\/\/[\x21-\x7E]+$/;
|
|
18
|
+
var URL_REFUSED = /[<>,"`\\{}|^]/;
|
|
19
|
+
var MAILTO = /^[A-Za-z0-9._~!$'*+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
|
|
20
|
+
function listUnsubscribe(options) {
|
|
21
|
+
if (typeof options !== "object" || options === null) {
|
|
22
|
+
throw new TypeError("listUnsubscribe: options must be an object, as { url }");
|
|
23
|
+
}
|
|
24
|
+
if (typeof options.url !== "string") {
|
|
25
|
+
throw new TypeError("listUnsubscribe: url must be a string");
|
|
26
|
+
}
|
|
27
|
+
if (options.mailto !== undefined && typeof options.mailto !== "string") {
|
|
28
|
+
throw new TypeError("listUnsubscribe: mailto must be a string");
|
|
29
|
+
}
|
|
30
|
+
if (!URL_ALLOWED.test(options.url) || URL_REFUSED.test(options.url) || !URL.canParse(options.url) || new URL(options.url).username !== "" || new URL(options.url).password !== "") {
|
|
31
|
+
throw new MailRefused2("listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma");
|
|
32
|
+
}
|
|
33
|
+
if (options.mailto !== undefined && !MAILTO.test(options.mailto)) {
|
|
34
|
+
throw new MailRefused2("listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com");
|
|
35
|
+
}
|
|
36
|
+
const mailto = options.mailto === undefined ? "" : `, <mailto:${options.mailto}>`;
|
|
37
|
+
return {
|
|
38
|
+
"List-Unsubscribe": `<${options.url}>${mailto}`,
|
|
39
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
40
|
+
};
|
|
41
|
+
}
|
|
16
42
|
export {
|
|
17
43
|
MailError2 as MailError,
|
|
18
44
|
MailFailure2 as MailFailure,
|
|
@@ -20,10 +46,11 @@ export {
|
|
|
20
46
|
addressOf2 as addressOf,
|
|
21
47
|
checkMessage2 as checkMessage,
|
|
22
48
|
createMemoryMailer2 as createMemoryMailer,
|
|
49
|
+
listUnsubscribe,
|
|
23
50
|
parseAcceptLanguage2 as parseAcceptLanguage,
|
|
24
51
|
pickLocale2 as pickLocale,
|
|
25
52
|
recipientsOf2 as recipientsOf
|
|
26
53
|
};
|
|
27
54
|
|
|
28
|
-
//# debugId=
|
|
55
|
+
//# debugId=AB5848B04CC61C7064756E2164756E21
|
|
29
56
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": [],
|
|
3
|
+
"sources": ["../src/unsubscribe.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
+
"import { MailRefused } from './errors';\n\n/** Where a recipient unsubscribes: the one-click URL, and an address as well. */\nexport interface ListUnsubscribeOptions {\n\t/**\n\t * The `https:` URL a mail client POSTs `List-Unsubscribe=One-Click` to —\n\t * one per recipient, carrying what identifies them, as\n\t * `https://example.com/unsubscribe?token=…`. It unsubscribes on that POST\n\t * alone: no login, no confirmation page, no redirect.\n\t */\n\treadonly url: string;\n\t/** An address that unsubscribes whoever writes to it, for clients that only send mail. */\n\treadonly mailto?: string;\n}\n\n/**\n * The two headers of RFC 8058's one-click unsubscribe. A type, not an\n * interface: an interface has no index signature, and would not go into\n * `headers` as it is.\n */\nexport type ListUnsubscribeHeaders = {\n\treadonly 'List-Unsubscribe': string;\n\treadonly 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click';\n};\n\n// RFC 2369 wants an RFC 3986 URI inside `<…>`: printable ASCII only — a\n// transport would encode a header holding anything else, and no client\n// would find the URL in it — and none of what ends the URL (`<`, `>`), what\n// RFC 2369 reads as the next one (`,`), or what no URI holds as is (a double\n// quote, a backtick, a backslash, braces, `|`, `^`). Percent-encode it.\nconst URL_ALLOWED = /^https:\\/\\/[\\x21-\\x7E]+$/;\nconst URL_REFUSED = /[<>,\"`\\\\{}|^]/;\n// RFC 6068 reads `?`, `&`, `=`, `#` and `%` inside a mailto: as structure — a\n// subject, a second recipient — so the address is plain ASCII without them.\nconst MAILTO = /^[A-Za-z0-9._~!$'*+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)+$/;\n\n/**\n * The headers that give an e-mail Gmail's and Yahoo's one-click unsubscribe\n * (RFC 8058, with RFC 2369's `List-Unsubscribe`), to spread into a message's\n * `headers`:\n *\n * ```ts\n * await mailer.send({\n * ...rendered,\n * to: user.email,\n * headers: listUnsubscribe({ url: `https://example.com/unsubscribe?token=${token}` }),\n * });\n * ```\n *\n * Refuses, with a {@link MailRefused} that never quotes the value, a `url`\n * that is not `https://` — RFC 8058 requires it — or is not printable ASCII,\n * carries a user or a password, or holds `<`, `>`, a double quote or a raw `,`; and\n * a `mailto` that is not a bare ASCII address. The URL is\n * often built from a token, and a token is a credential: the message names\n * the rule, not the link.\n */\nexport function listUnsubscribe(\n\toptions: ListUnsubscribeOptions,\n): ListUnsubscribeHeaders {\n\tif (typeof options !== 'object' || options === null) {\n\t\tthrow new TypeError(\n\t\t\t'listUnsubscribe: options must be an object, as { url }',\n\t\t);\n\t}\n\tif (typeof options.url !== 'string') {\n\t\tthrow new TypeError('listUnsubscribe: url must be a string');\n\t}\n\tif (options.mailto !== undefined && typeof options.mailto !== 'string') {\n\t\tthrow new TypeError('listUnsubscribe: mailto must be a string');\n\t}\n\tif (\n\t\t!URL_ALLOWED.test(options.url) ||\n\t\tURL_REFUSED.test(options.url) ||\n\t\t!URL.canParse(options.url) ||\n\t\t// A user and a password in a header every relay and recipient reads.\n\t\tnew URL(options.url).username !== '' ||\n\t\tnew URL(options.url).password !== ''\n\t) {\n\t\tthrow new MailRefused(\n\t\t\t'listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma',\n\t\t);\n\t}\n\tif (options.mailto !== undefined && !MAILTO.test(options.mailto)) {\n\t\tthrow new MailRefused(\n\t\t\t'listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com',\n\t\t);\n\t}\n\tconst mailto =\n\t\toptions.mailto === undefined ? '' : `, <mailto:${options.mailto}>`;\n\treturn {\n\t\t'List-Unsubscribe': `<${options.url}>${mailto}`,\n\t\t'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n\t};\n}\n"
|
|
5
6
|
],
|
|
6
|
-
"mappings": "",
|
|
7
|
-
"debugId": "
|
|
7
|
+
"mappings": ";;;;;;;;;;;;;;;;AA8BA,IAAM,cAAc;AACpB,IAAM,cAAc;AAGpB,IAAM,SAAS;AAsBR,SAAS,eAAe,CAC9B,SACyB;AAAA,EACzB,IAAI,OAAO,YAAY,YAAY,YAAY,MAAM;AAAA,IACpD,MAAM,IAAI,UACT,wDACD;AAAA,EACD;AAAA,EACA,IAAI,OAAO,QAAQ,QAAQ,UAAU;AAAA,IACpC,MAAM,IAAI,UAAU,uCAAuC;AAAA,EAC5D;AAAA,EACA,IAAI,QAAQ,WAAW,aAAa,OAAO,QAAQ,WAAW,UAAU;AAAA,IACvE,MAAM,IAAI,UAAU,0CAA0C;AAAA,EAC/D;AAAA,EACA,IACC,CAAC,YAAY,KAAK,QAAQ,GAAG,KAC7B,YAAY,KAAK,QAAQ,GAAG,KAC5B,CAAC,IAAI,SAAS,QAAQ,GAAG,KAEzB,IAAI,IAAI,QAAQ,GAAG,EAAE,aAAa,MAClC,IAAI,IAAI,QAAQ,GAAG,EAAE,aAAa,IACjC;AAAA,IACD,MAAM,IAAI,aACT,mHACD;AAAA,EACD;AAAA,EACA,IAAI,QAAQ,WAAW,aAAa,CAAC,OAAO,KAAK,QAAQ,MAAM,GAAG;AAAA,IACjE,MAAM,IAAI,aACT,mFACD;AAAA,EACD;AAAA,EACA,MAAM,SACL,QAAQ,WAAW,YAAY,KAAK,aAAa,QAAQ;AAAA,EAC1D,OAAO;AAAA,IACN,oBAAoB,IAAI,QAAQ,OAAO;AAAA,IACvC,yBAAyB;AAAA,EAC1B;AAAA;",
|
|
8
|
+
"debugId": "AB5848B04CC61C7064756E2164756E21",
|
|
8
9
|
"names": []
|
|
9
10
|
}
|
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"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Where a recipient unsubscribes: the one-click URL, and an address as well. */
|
|
2
|
+
export interface ListUnsubscribeOptions {
|
|
3
|
+
/**
|
|
4
|
+
* The `https:` URL a mail client POSTs `List-Unsubscribe=One-Click` to —
|
|
5
|
+
* one per recipient, carrying what identifies them, as
|
|
6
|
+
* `https://example.com/unsubscribe?token=…`. It unsubscribes on that POST
|
|
7
|
+
* alone: no login, no confirmation page, no redirect.
|
|
8
|
+
*/
|
|
9
|
+
readonly url: string;
|
|
10
|
+
/** An address that unsubscribes whoever writes to it, for clients that only send mail. */
|
|
11
|
+
readonly mailto?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The two headers of RFC 8058's one-click unsubscribe. A type, not an
|
|
15
|
+
* interface: an interface has no index signature, and would not go into
|
|
16
|
+
* `headers` as it is.
|
|
17
|
+
*/
|
|
18
|
+
export type ListUnsubscribeHeaders = {
|
|
19
|
+
readonly 'List-Unsubscribe': string;
|
|
20
|
+
readonly 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click';
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The headers that give an e-mail Gmail's and Yahoo's one-click unsubscribe
|
|
24
|
+
* (RFC 8058, with RFC 2369's `List-Unsubscribe`), to spread into a message's
|
|
25
|
+
* `headers`:
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* await mailer.send({
|
|
29
|
+
* ...rendered,
|
|
30
|
+
* to: user.email,
|
|
31
|
+
* headers: listUnsubscribe({ url: `https://example.com/unsubscribe?token=${token}` }),
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Refuses, with a {@link MailRefused} that never quotes the value, a `url`
|
|
36
|
+
* that is not `https://` — RFC 8058 requires it — or is not printable ASCII,
|
|
37
|
+
* carries a user or a password, or holds `<`, `>`, a double quote or a raw `,`; and
|
|
38
|
+
* a `mailto` that is not a bare ASCII address. The URL is
|
|
39
|
+
* often built from a token, and a token is a credential: the message names
|
|
40
|
+
* the rule, not the link.
|
|
41
|
+
*/
|
|
42
|
+
export declare function listUnsubscribe(options: ListUnsubscribeOptions): ListUnsubscribeHeaders;
|
|
43
|
+
//# sourceMappingURL=unsubscribe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unsubscribe.d.ts","sourceRoot":"","sources":["../src/unsubscribe.ts"],"names":[],"mappings":"AAEA,iFAAiF;AACjF,MAAM,WAAW,sBAAsB;IACtC;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,0FAA0F;IAC1F,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACpC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,uBAAuB,EAAE,4BAA4B,CAAC;CAC/D,CAAC;AAaF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAC9B,OAAO,EAAE,sBAAsB,GAC7B,sBAAsB,CAmCxB"}
|
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, one-click unsubscribe with `listUnsubscribe` (the headers, DKIM, the endpoint), 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 |
|