@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 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: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
260
- body: JSON.stringify({ ...message, to: recipientsOf(message), attachments }),
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
- **21 plausible mistakes, 21 refused** at compile time, each measured by a
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=49A96A07CA93F79964756E2164756E21
154
- //# sourceMappingURL=index-4h39j3n7.js.map
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": ";AAiDO,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;",
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
  }
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  recipientsOf2,
3
3
  createMemoryMailer2
4
- } from "../chunks/index-4h39j3n7.js";
4
+ } from "../chunks/index-nkzwt8vn.js";
5
5
  import {
6
6
  MailError2,
7
7
  MailFailure2,
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. Sending it again unchanged fails again.
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. */
@@ -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;;;;;;;GAOG;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"}
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
@@ -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-4h39j3n7.js";
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=F6BB19285305093D64756E2164756E21
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": "F6BB19285305093D64756E2164756E21",
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, and any queued failure. */
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`, … */
@@ -1 +1 @@
1
- {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAe,MAAM,UAAU,CAAC;AAEvD,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAY,MAAM,SAAS,CAAC;AAE7D,sEAAsE;AACtE,MAAM,WAAW,UAAW,SAAQ,WAAW;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;GAMG;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,mEAAmE;IACnE,KAAK,IAAI,IAAI,CAAC;CACd;AAsBD,gFAAgF;AAChF,wBAAgB,kBAAkB,IAAI,YAAY,CAyCjD"}
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
@@ -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;AAgCpE,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;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAqDvD"}
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 {
@@ -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;CACjD;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"}
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, and running `@nxgt/mail/conformance` against it |
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 |