@nxgt/mail 0.3.0 → 0.5.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 +54 -4
- package/dist/chunks/index-we4n5yfz.js.map +2 -2
- package/dist/conformance/cases/send.d.ts.map +1 -1
- package/dist/conformance/index.js +17 -1
- package/dist/conformance/index.js.map +3 -3
- 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 +31 -1
- package/dist/index.js.map +4 -3
- package/dist/unsubscribe.d.ts +45 -0
- package/dist/unsubscribe.d.ts.map +1 -0
- package/docs/README.md +1 -1
- package/docs/guide/rendering.md +5 -3
- package/docs/guide/sending.md +220 -8
- package/docs/guide/transports.md +7 -4
- package/docs/roadmap.md +22 -21
- package/docs/troubleshooting.md +183 -9
- package/package.json +1 -1
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
|
|
|
@@ -57,7 +57,7 @@ so a missing build fails there, not at the first send:
|
|
|
57
57
|
```ts
|
|
58
58
|
import { type Mailer, pickLocale } from '@nxgt/mail';
|
|
59
59
|
import { createMailRenderer } from '@nxgt/mail/renderer';
|
|
60
|
-
import type { MailEmails } from './generated/mail'; // written by
|
|
60
|
+
import type { MailEmails } from './generated/mail'; // written by each build, git-ignored
|
|
61
61
|
|
|
62
62
|
export const mails = createMailRenderer<MailEmails>({ dir: 'dist' }); // throws now if dist/ is missing
|
|
63
63
|
|
|
@@ -78,7 +78,8 @@ throws `MailRefused`. A missing or unknown variable, e-mail or locale throws an
|
|
|
78
78
|
`Error`.
|
|
79
79
|
|
|
80
80
|
`<MailEmails>` is optional. `@nxgt/mail-i18n` writes it after each build, in
|
|
81
|
-
`generated/mail.ts`, from the manifest;
|
|
81
|
+
`generated/mail.ts`, from the manifest; git-ignore it, and build before
|
|
82
|
+
type-checking. With it, the compiler
|
|
82
83
|
refuses what `render` would throw: an e-mail the build does not have, a
|
|
83
84
|
variable missing or unknown, and a number for a URL variable, as
|
|
84
85
|
`Argument of type '"verify-emial"' is not assignable to parameter of type
|
|
@@ -193,6 +194,48 @@ twice is delivered twice. A key that is not 1 to 256 visible ASCII characters
|
|
|
193
194
|
is refused with `MailRefused`. See
|
|
194
195
|
[Sending — idempotency](docs/guide/sending.md#idempotency--sending-once).
|
|
195
196
|
|
|
197
|
+
### One-click unsubscribe — `listUnsubscribe`
|
|
198
|
+
|
|
199
|
+
Gmail and Yahoo require bulk senders to offer one-click unsubscribe on
|
|
200
|
+
marketing mail. `listUnsubscribe` answers its two headers, to spread into
|
|
201
|
+
`headers`, with a URL per recipient:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
import { listUnsubscribe, type Mailer, type Rendered } from '@nxgt/mail';
|
|
205
|
+
|
|
206
|
+
export async function sendNewsletter(
|
|
207
|
+
mailer: Mailer,
|
|
208
|
+
rendered: Rendered,
|
|
209
|
+
subscriber: { email: string; unsubscribeToken: string },
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
await mailer.send({
|
|
212
|
+
...rendered,
|
|
213
|
+
to: subscriber.email,
|
|
214
|
+
headers: {
|
|
215
|
+
...listUnsubscribe({
|
|
216
|
+
url: `https://example.com/unsubscribe?token=${encodeURIComponent(subscriber.unsubscribeToken)}`,
|
|
217
|
+
mailto: 'unsubscribe@example.com', // optional
|
|
218
|
+
}),
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
// List-Unsubscribe: <https://example.com/unsubscribe?token=…>, <mailto:unsubscribe@example.com>
|
|
223
|
+
// List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The URL must start with `https://`, be printable ASCII, carry no user or
|
|
227
|
+
password, and hold no `<`, `>`, double quote, raw comma (percent-encode
|
|
228
|
+
it: `%2C`) or `%` that starts no escape, and `mailto` must be a bare ASCII
|
|
229
|
+
address. The URL is written as a parser reads it (`new URL(url).href`: the
|
|
230
|
+
host lowered, an empty `@` or extra slashes dropped), and checked again:
|
|
231
|
+
`https://a%2Cb.test/` writes a raw comma, so it is refused. Anything else is a
|
|
232
|
+
`MailRefused` that never quotes the URL — its token is a credential. Your
|
|
233
|
+
endpoint must unsubscribe on a `POST` with the body
|
|
234
|
+
`List-Unsubscribe=One-Click`, with no login and no confirmation. It belongs on
|
|
235
|
+
marketing and bulk mail, not on a password reset or a sign-in code. See
|
|
236
|
+
[Sending — one-click unsubscribe](docs/guide/sending.md#one-click-unsubscribe)
|
|
237
|
+
for the endpoint and DKIM.
|
|
238
|
+
|
|
196
239
|
### Errors — switch on `code`
|
|
197
240
|
|
|
198
241
|
Both errors extend `MailError`, whose `code` is a union a `switch` exhausts.
|
|
@@ -396,7 +439,7 @@ gives a test file `describe` and `it` as bare identifiers, not on `globalThis`.
|
|
|
396
439
|
|
|
397
440
|
## Type safety, counted
|
|
398
441
|
|
|
399
|
-
**
|
|
442
|
+
**23 plausible mistakes, 23 refused** at compile time, each measured by a
|
|
400
443
|
`@ts-expect-error` in
|
|
401
444
|
[`test/types/refusals.ts`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/test/types/refusals.ts)
|
|
402
445
|
that fails the typecheck the moment it stops holding:
|
|
@@ -438,6 +481,11 @@ And the idempotency key:
|
|
|
438
481
|
22. A number (`idempotencyKey: order.id`): the key is a string, as
|
|
439
482
|
`order-42/receipt`.
|
|
440
483
|
|
|
484
|
+
And one-click unsubscribe:
|
|
485
|
+
|
|
486
|
+
23. A `URL` object as `listUnsubscribe`'s `url`: the header holds text, so
|
|
487
|
+
pass `url.href`.
|
|
488
|
+
|
|
441
489
|
The same file holds the calls that must keep compiling: a refusal that refuses
|
|
442
490
|
the correct call is a bug.
|
|
443
491
|
|
|
@@ -450,6 +498,8 @@ the correct call is a bug.
|
|
|
450
498
|
planned.
|
|
451
499
|
- [Vocabulary](https://github.com/softistx/nxgt-mail/blob/develop/docs/vocabulary.md)
|
|
452
500
|
— the words these pages use, defined once.
|
|
501
|
+
- [The starter](https://github.com/softistx/nxgt-mail/tree/develop/examples/starter)
|
|
502
|
+
— a Maizzle project that builds, renders and sends one e-mail, to copy.
|
|
453
503
|
|
|
454
504
|
## Licence
|
|
455
505
|
|
|
@@ -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
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"send.d.ts","sourceRoot":"","sources":["../../../src/conformance/cases/send.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C,uDAAuD;AACvD,eAAO,MAAM,SAAS,EAAE,SAAS,UAAU,
|
|
1
|
+
{"version":3,"file":"send.d.ts","sourceRoot":"","sources":["../../../src/conformance/cases/send.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C,uDAAuD;AACvD,eAAO,MAAM,SAAS,EAAE,SAAS,UAAU,EAqQ1C,CAAC"}
|
|
@@ -162,6 +162,22 @@ var sendCases = [
|
|
|
162
162
|
check(mail.text === sampleMessage.text && mail.html === sampleMessage.html, "the parts of a message with an attachment were not delivered as sent");
|
|
163
163
|
}
|
|
164
164
|
},
|
|
165
|
+
{
|
|
166
|
+
id: "send.idempotencyKey",
|
|
167
|
+
title: "a message with an idempotency key is delivered, and the key is in none of its recipients, subject, HTML or text",
|
|
168
|
+
async run(context) {
|
|
169
|
+
const key = `conformance-${crypto.randomUUID()}`;
|
|
170
|
+
const sent = await context.mailer.send({
|
|
171
|
+
...sampleMessage,
|
|
172
|
+
idempotencyKey: key
|
|
173
|
+
});
|
|
174
|
+
check(typeof sent === "object" && sent !== null && "messageId" in sent, "a send with an idempotency key did not answer SentMail");
|
|
175
|
+
const delivered = await context.delivered();
|
|
176
|
+
check(delivered.length === 1, `expected 1 delivered message, got ${delivered.length}`);
|
|
177
|
+
const [mail] = delivered;
|
|
178
|
+
check(![mail?.subject, mail?.html, mail?.text, ...mail?.to ?? []].some((part) => part?.includes(key)), "the idempotency key was written into the e-mail");
|
|
179
|
+
}
|
|
180
|
+
},
|
|
165
181
|
{
|
|
166
182
|
id: "send.refusesNoRecipient",
|
|
167
183
|
title: "a message with no recipient is refused with MailRefused, and nothing is sent",
|
|
@@ -334,5 +350,5 @@ export {
|
|
|
334
350
|
sendCases
|
|
335
351
|
};
|
|
336
352
|
|
|
337
|
-
//# debugId=
|
|
353
|
+
//# debugId=5D29AA162B05B41464756E2164756E21
|
|
338
354
|
//# sourceMappingURL=index.js.map
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
"import type { MailerCaseContext } from './types';\n\n/** Throws when `condition` is false. The suite depends on no assertion library. */\nexport function check(condition: boolean, what: string): asserts condition {\n\tif (!condition) throw new Error(`conformance: ${what}`);\n}\n\nexport const same = (a: unknown, b: unknown) =>\n\tJSON.stringify(a) === JSON.stringify(b);\n\n/**\n * Settles an expected rejection where it is created, and answers the error —\n * or throws when the promise resolved.\n */\nexport async function rejection(\n\tpromise: Promise<unknown>,\n\twhat: string,\n): Promise<unknown> {\n\treturn promise.then(\n\t\t() => {\n\t\t\tthrow new Error(`conformance: ${what} resolved; it must reject`);\n\t\t},\n\t\t(error: unknown) => error,\n\t);\n}\n\n/** Throws when the receiving end got anything. */\nexport async function nothingDelivered(\n\tcontext: MailerCaseContext,\n\twhat: string,\n) {\n\tcheck(\n\t\t(await context.delivered()).length === 0,\n\t\t`${what}, yet something was delivered`,\n\t);\n}\n",
|
|
6
6
|
"import type { MailAttachment, MailMessage } from '../types';\n\n/** A message with the characters a transport most often mangles. */\nexport const sampleMessage: MailMessage = {\n\tto: 'ada@example.test',\n\tfrom: 'noreply@example.test',\n\tsubject: 'Réinitialisez votre mot de passe — ça expire à 23 h',\n\thtml: '<p>Bonjour Ada 👋, <a href=\"https://example.test/r?t=abc&x=1\">réinitialiser</a></p>',\n\ttext: 'Bonjour Ada 👋,\\n\\nréinitialiser : https://example.test/r?t=abc&x=1\\n',\n};\n\n/**\n * A small binary file, every byte from 0 to 255 once — a NUL, a CR and an LF\n * among them, and bytes that are not UTF-8 — with a name a header must encode.\n */\nexport const sampleAttachment: MailAttachment = {\n\tfilename: 'reçu n° 42.pdf',\n\tcontent: Uint8Array.from({ length: 256 }, (_, byte) => byte),\n\tcontentType: 'application/pdf',\n};\n",
|
|
7
7
|
"import { MailError, MailFailure, MailRefused } from '../../errors';\nimport { check, nothingDelivered, rejection } from '../assert';\nimport { sampleMessage } from '../sample';\nimport type { MailerCase } from '../types';\n\n/** What a send must do when the transport fails. Needs {@link MailerFaults}. */\nexport const failureCases: readonly MailerCase[] = [\n\t{\n\t\tid: 'failure.outage',\n\t\ttitle:\n\t\t\t'an outage throws MailFailure with the cause, and nothing is retried',\n\t\tneeds: 'faults',\n\t\tasync run(context) {\n\t\t\tconst faults = context.faults;\n\t\t\tcheck(faults !== null, 'faults are required');\n\t\t\tawait faults.failNext('outage');\n\t\t\tconst error = await rejection(\n\t\t\t\tcontext.mailer.send(sampleMessage),\n\t\t\t\t'a send during an outage',\n\t\t\t);\n\t\t\t// The class is the one imported from @nxgt/mail: a transport that\n\t\t\t// defines its own copy fails here.\n\t\t\tcheck(\n\t\t\t\terror instanceof MailFailure,\n\t\t\t\t'an outage must throw MailFailure from @nxgt/mail',\n\t\t\t);\n\t\t\tcheck(error instanceof MailError, 'MailFailure must extend MailError');\n\t\t\tcheck(\n\t\t\t\terror.code === 'MAIL_FAILED',\n\t\t\t\t'an outage must carry the code MAIL_FAILED',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror.cause !== undefined,\n\t\t\t\t\"an outage must carry the transport's error as cause\",\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\t(await faults.attempts()) === 1,\n\t\t\t\t'the transport retried a failed hand-over',\n\t\t\t);\n\t\t\tawait nothingDelivered(context, 'the hand-over failed');\n\t\t},\n\t},\n\t{\n\t\tid: 'failure.refusal',\n\t\ttitle: 'a message the provider refuses throws MailRefused with the cause',\n\t\tneeds: 'faults',\n\t\tasync run(context) {\n\t\t\tconst faults = context.faults;\n\t\t\tcheck(faults !== null, 'faults are required');\n\t\t\tawait faults.failNext('refusal');\n\t\t\tconst error = await rejection(\n\t\t\t\tcontext.mailer.send(sampleMessage),\n\t\t\t\t'a refused send',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror instanceof MailRefused,\n\t\t\t\t'a refusal must throw MailRefused from @nxgt/mail',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror.code === 'MAIL_REFUSED',\n\t\t\t\t'a refusal must carry the code MAIL_REFUSED',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror.cause !== undefined,\n\t\t\t\t\"a refusal must carry the transport's error as cause\",\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\t(await faults.attempts()) === 1,\n\t\t\t\t'the transport retried a refused message',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'failure.recovers',\n\t\ttitle: 'after a failure, the next send goes through',\n\t\tneeds: 'faults',\n\t\tasync run(context) {\n\t\t\tconst faults = context.faults;\n\t\t\tcheck(faults !== null, 'faults are required');\n\t\t\tawait faults.failNext('outage');\n\t\t\tawait rejection(\n\t\t\t\tcontext.mailer.send(sampleMessage),\n\t\t\t\t'a send during an outage',\n\t\t\t);\n\t\t\tawait context.mailer.send(sampleMessage);\n\t\t\tcheck(\n\t\t\t\t(await context.delivered()).length === 1,\n\t\t\t\t'the send after a failure was not delivered',\n\t\t\t);\n\t\t},\n\t},\n];\n",
|
|
8
|
-
"import { MailRefused } from '../../errors';\nimport { check, nothingDelivered, rejection, same } from '../assert';\nimport { sampleAttachment, sampleMessage } from '../sample';\nimport type { MailerCase } from '../types';\n\n/** What every send must do, with no fault injected. */\nexport const sendCases: readonly MailerCase[] = [\n\t{\n\t\tid: 'send.answersSentMail',\n\t\ttitle: 'a send answers SentMail, with a string id or null',\n\t\tasync run({ mailer }) {\n\t\t\tconst sent = await mailer.send(sampleMessage);\n\t\t\tcheck(\n\t\t\t\ttypeof sent === 'object' && sent !== null && 'messageId' in sent,\n\t\t\t\t'send did not answer an object with messageId',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tsent.messageId === null ||\n\t\t\t\t\t(typeof sent.messageId === 'string' && sent.messageId !== ''),\n\t\t\t\t'messageId must be a non-empty string or null',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'send.deliversBytes',\n\t\ttitle:\n\t\t\t'a message is delivered byte for byte: accents, an emoji, a text part',\n\t\tasync run(context) {\n\t\t\tawait context.mailer.send(sampleMessage);\n\t\t\tconst delivered = await context.delivered();\n\t\t\tcheck(\n\t\t\t\tdelivered.length === 1,\n\t\t\t\t`expected 1 delivered message, got ${delivered.length}`,\n\t\t\t);\n\t\t\tconst [mail] = delivered;\n\t\t\tcheck(\n\t\t\t\tmail?.subject === sampleMessage.subject,\n\t\t\t\t'the subject was not delivered as sent',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tmail?.html === sampleMessage.html,\n\t\t\t\t'the html part was not delivered as sent',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tmail?.text === sampleMessage.text,\n\t\t\t\t'the text part was not delivered as sent',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'send.recipients',\n\t\ttitle:\n\t\t\t'every recipient is delivered to, written as a string or with a name',\n\t\tasync run(context) {\n\t\t\tawait context.mailer.send({\n\t\t\t\t...sampleMessage,\n\t\t\t\tto: [\n\t\t\t\t\t'ada@example.test',\n\t\t\t\t\t{ name: 'Grace Hopper', address: 'grace@example.test' },\n\t\t\t\t],\n\t\t\t});\n\t\t\tconst [mail] = await context.delivered();\n\t\t\tcheck(\n\t\t\t\tsame(mail?.to, ['ada@example.test', 'grace@example.test']),\n\t\t\t\t'the recipients delivered are not the recipients sent',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'send.hostileName',\n\t\ttitle: 'a name holding an address and a comma reaches only its own address',\n\t\tasync run(context) {\n\t\t\t// A name is free text, and quoting it is the transport's job. One that\n\t\t\t// pastes it into a header unquoted hands mallory a copy.\n\t\t\tawait context.mailer.send({\n\t\t\t\t...sampleMessage,\n\t\t\t\tto: {\n\t\t\t\t\tname: 'Ada <mallory@example.test>, \"Eve\" <eve@example.test>;',\n\t\t\t\t\taddress: 'ada@example.test',\n\t\t\t\t},\n\t\t\t});\n\t\t\tconst [mail] = await context.delivered();\n\t\t\tcheck(\n\t\t\t\tsame(mail?.to, ['ada@example.test']),\n\t\t\t\t'a name let a second recipient through',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'send.attachment',\n\t\ttitle:\n\t\t\t'an attachment is delivered byte for byte, with its name and its type',\n\t\tasync run(context) {\n\t\t\tawait context.mailer.send({\n\t\t\t\t...sampleMessage,\n\t\t\t\tattachments: [sampleAttachment],\n\t\t\t});\n\t\t\tconst [mail] = await context.delivered();\n\t\t\tcheck(\n\t\t\t\tmail !== undefined,\n\t\t\t\t'the message with an attachment was not delivered',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tmail.attachments !== undefined,\n\t\t\t\t\"the harness's delivered() reads back no attachments — read them from the receiving end, or skip send.attachment with the reason\",\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tmail.attachments.length === 1,\n\t\t\t\t`expected 1 delivered attachment, got ${mail.attachments.length}`,\n\t\t\t);\n\t\t\tconst [file] = mail.attachments;\n\t\t\tcheck(\n\t\t\t\tfile?.filename === sampleAttachment.filename,\n\t\t\t\t'the attachment was not delivered with its file name',\n\t\t\t);\n\t\t\t// A media type is case-insensitive: a parser may lower it.\n\t\t\tcheck(\n\t\t\t\tfile.contentType.toLowerCase() === sampleAttachment.contentType,\n\t\t\t\t'the attachment was not delivered with its content type',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tfile.content instanceof Uint8Array &&\n\t\t\t\t\tsame([...file.content], [...sampleAttachment.content]),\n\t\t\t\t'the attachment was not delivered byte for byte',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tmail.text === sampleMessage.text && mail.html === sampleMessage.html,\n\t\t\t\t'the parts of a message with an attachment were not delivered as sent',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'send.refusesNoRecipient',\n\t\ttitle:\n\t\t\t'a message with no recipient is refused with MailRefused, and nothing is sent',\n\t\tasync run(context) {\n\t\t\tconst error = await rejection(\n\t\t\t\tcontext.mailer.send({ ...sampleMessage, to: [] }),\n\t\t\t\t'a send with no recipient',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror instanceof MailRefused,\n\t\t\t\t'a send with no recipient must throw MailRefused',\n\t\t\t);\n\t\t\tawait nothingDelivered(context, 'the message was refused');\n\t\t},\n\t},\n\t{\n\t\tid: 'send.refusesLineBreakInSubject',\n\t\ttitle:\n\t\t\t'a line break in the subject is refused with MailRefused: it is a header injection',\n\t\tasync run(context) {\n\t\t\tconst error = await rejection(\n\t\t\t\tcontext.mailer.send({\n\t\t\t\t\t...sampleMessage,\n\t\t\t\t\tsubject: 'Hello\\r\\nBcc: eve@example.test',\n\t\t\t\t}),\n\t\t\t\t'a send with a line break in the subject',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror instanceof MailRefused,\n\t\t\t\t'a line break in the subject must throw MailRefused',\n\t\t\t);\n\t\t\tawait nothingDelivered(context, 'the message was refused');\n\t\t},\n\t},\n\t{\n\t\tid: 'send.refusesAddressHeader',\n\t\ttitle:\n\t\t\t'a Bcc among the custom headers is refused with MailRefused: it would add an unchecked recipient',\n\t\tasync run(context) {\n\t\t\t// A custom header named Bcc, To or Cc reaches the envelope of an SMTP\n\t\t\t// transport, and writes a line no address check ever saw.\n\t\t\tconst error = await rejection(\n\t\t\t\tcontext.mailer.send({\n\t\t\t\t\t...sampleMessage,\n\t\t\t\t\t// biome-ignore lint/style/useNamingConvention: a header's name, as a mail client writes it.\n\t\t\t\t\theaders: { Bcc: 'eve@example.test' },\n\t\t\t\t}),\n\t\t\t\t'a send with a Bcc header',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror instanceof MailRefused,\n\t\t\t\t'a Bcc header must throw MailRefused',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\t!error.message.includes('eve@example.test'),\n\t\t\t\t'the refusal message holds the refused value',\n\t\t\t);\n\t\t\tawait nothingDelivered(context, 'the message was refused');\n\t\t},\n\t},\n\t{\n\t\tid: 'send.refusesAttachmentPath',\n\t\ttitle:\n\t\t\t'an attachment named with a path is refused with MailRefused: a mail client could save it elsewhere',\n\t\tasync run(context) {\n\t\t\tconst error = await rejection(\n\t\t\t\tcontext.mailer.send({\n\t\t\t\t\t...sampleMessage,\n\t\t\t\t\tattachments: [\n\t\t\t\t\t\t{ ...sampleAttachment, filename: '../secret-7f3a/report.pdf' },\n\t\t\t\t\t],\n\t\t\t\t}),\n\t\t\t\t'a send with an attachment named with a path',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror instanceof MailRefused,\n\t\t\t\t'an attachment named with a path must throw MailRefused',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\t!error.message.includes('secret-7f3a'),\n\t\t\t\t'the refusal message holds the refused value',\n\t\t\t);\n\t\t\tawait nothingDelivered(context, 'the message was refused');\n\t\t},\n\t},\n\t{\n\t\tid: 'send.refusesWithoutTheValue',\n\t\ttitle: 'a refusal names where the problem is, never the value',\n\t\tasync run({ mailer }) {\n\t\t\tconst error = await rejection(\n\t\t\t\tmailer.send({ ...sampleMessage, to: 'not-an-address-7f3a' }),\n\t\t\t\t'a send to something that is not an address',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror instanceof MailRefused,\n\t\t\t\t'a malformed address must throw MailRefused',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\t!error.message.includes('not-an-address-7f3a'),\n\t\t\t\t'the refusal message holds the refused value',\n\t\t\t);\n\t\t},\n\t},\n];\n",
|
|
8
|
+
"import { MailRefused } from '../../errors';\nimport { check, nothingDelivered, rejection, same } from '../assert';\nimport { sampleAttachment, sampleMessage } from '../sample';\nimport type { MailerCase } from '../types';\n\n/** What every send must do, with no fault injected. */\nexport const sendCases: readonly MailerCase[] = [\n\t{\n\t\tid: 'send.answersSentMail',\n\t\ttitle: 'a send answers SentMail, with a string id or null',\n\t\tasync run({ mailer }) {\n\t\t\tconst sent = await mailer.send(sampleMessage);\n\t\t\tcheck(\n\t\t\t\ttypeof sent === 'object' && sent !== null && 'messageId' in sent,\n\t\t\t\t'send did not answer an object with messageId',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tsent.messageId === null ||\n\t\t\t\t\t(typeof sent.messageId === 'string' && sent.messageId !== ''),\n\t\t\t\t'messageId must be a non-empty string or null',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'send.deliversBytes',\n\t\ttitle:\n\t\t\t'a message is delivered byte for byte: accents, an emoji, a text part',\n\t\tasync run(context) {\n\t\t\tawait context.mailer.send(sampleMessage);\n\t\t\tconst delivered = await context.delivered();\n\t\t\tcheck(\n\t\t\t\tdelivered.length === 1,\n\t\t\t\t`expected 1 delivered message, got ${delivered.length}`,\n\t\t\t);\n\t\t\tconst [mail] = delivered;\n\t\t\tcheck(\n\t\t\t\tmail?.subject === sampleMessage.subject,\n\t\t\t\t'the subject was not delivered as sent',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tmail?.html === sampleMessage.html,\n\t\t\t\t'the html part was not delivered as sent',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tmail?.text === sampleMessage.text,\n\t\t\t\t'the text part was not delivered as sent',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'send.recipients',\n\t\ttitle:\n\t\t\t'every recipient is delivered to, written as a string or with a name',\n\t\tasync run(context) {\n\t\t\tawait context.mailer.send({\n\t\t\t\t...sampleMessage,\n\t\t\t\tto: [\n\t\t\t\t\t'ada@example.test',\n\t\t\t\t\t{ name: 'Grace Hopper', address: 'grace@example.test' },\n\t\t\t\t],\n\t\t\t});\n\t\t\tconst [mail] = await context.delivered();\n\t\t\tcheck(\n\t\t\t\tsame(mail?.to, ['ada@example.test', 'grace@example.test']),\n\t\t\t\t'the recipients delivered are not the recipients sent',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'send.hostileName',\n\t\ttitle: 'a name holding an address and a comma reaches only its own address',\n\t\tasync run(context) {\n\t\t\t// A name is free text, and quoting it is the transport's job. One that\n\t\t\t// pastes it into a header unquoted hands mallory a copy.\n\t\t\tawait context.mailer.send({\n\t\t\t\t...sampleMessage,\n\t\t\t\tto: {\n\t\t\t\t\tname: 'Ada <mallory@example.test>, \"Eve\" <eve@example.test>;',\n\t\t\t\t\taddress: 'ada@example.test',\n\t\t\t\t},\n\t\t\t});\n\t\t\tconst [mail] = await context.delivered();\n\t\t\tcheck(\n\t\t\t\tsame(mail?.to, ['ada@example.test']),\n\t\t\t\t'a name let a second recipient through',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'send.attachment',\n\t\ttitle:\n\t\t\t'an attachment is delivered byte for byte, with its name and its type',\n\t\tasync run(context) {\n\t\t\tawait context.mailer.send({\n\t\t\t\t...sampleMessage,\n\t\t\t\tattachments: [sampleAttachment],\n\t\t\t});\n\t\t\tconst [mail] = await context.delivered();\n\t\t\tcheck(\n\t\t\t\tmail !== undefined,\n\t\t\t\t'the message with an attachment was not delivered',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tmail.attachments !== undefined,\n\t\t\t\t\"the harness's delivered() reads back no attachments — read them from the receiving end, or skip send.attachment with the reason\",\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tmail.attachments.length === 1,\n\t\t\t\t`expected 1 delivered attachment, got ${mail.attachments.length}`,\n\t\t\t);\n\t\t\tconst [file] = mail.attachments;\n\t\t\tcheck(\n\t\t\t\tfile?.filename === sampleAttachment.filename,\n\t\t\t\t'the attachment was not delivered with its file name',\n\t\t\t);\n\t\t\t// A media type is case-insensitive: a parser may lower it.\n\t\t\tcheck(\n\t\t\t\tfile.contentType.toLowerCase() === sampleAttachment.contentType,\n\t\t\t\t'the attachment was not delivered with its content type',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tfile.content instanceof Uint8Array &&\n\t\t\t\t\tsame([...file.content], [...sampleAttachment.content]),\n\t\t\t\t'the attachment was not delivered byte for byte',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\tmail.text === sampleMessage.text && mail.html === sampleMessage.html,\n\t\t\t\t'the parts of a message with an attachment were not delivered as sent',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'send.idempotencyKey',\n\t\ttitle:\n\t\t\t'a message with an idempotency key is delivered, and the key is in none of its recipients, subject, HTML or text',\n\t\tasync run(context) {\n\t\t\t// A transport either passes the key to a provider that deduplicates, or\n\t\t\t// ignores it. Neither refuses the message, and neither writes the key\n\t\t\t// where the reader sees it. A key per run: a harness that remembers\n\t\t\t// keys, as a provider's sandbox does, would otherwise replay the send.\n\t\t\tconst key = `conformance-${crypto.randomUUID()}`;\n\t\t\tconst sent = await context.mailer.send({\n\t\t\t\t...sampleMessage,\n\t\t\t\tidempotencyKey: key,\n\t\t\t});\n\t\t\tcheck(\n\t\t\t\ttypeof sent === 'object' && sent !== null && 'messageId' in sent,\n\t\t\t\t'a send with an idempotency key did not answer SentMail',\n\t\t\t);\n\t\t\tconst delivered = await context.delivered();\n\t\t\tcheck(\n\t\t\t\tdelivered.length === 1,\n\t\t\t\t`expected 1 delivered message, got ${delivered.length}`,\n\t\t\t);\n\t\t\tconst [mail] = delivered;\n\t\t\tcheck(\n\t\t\t\t![mail?.subject, mail?.html, mail?.text, ...(mail?.to ?? [])].some(\n\t\t\t\t\t(part) => part?.includes(key),\n\t\t\t\t),\n\t\t\t\t'the idempotency key was written into the e-mail',\n\t\t\t);\n\t\t},\n\t},\n\t{\n\t\tid: 'send.refusesNoRecipient',\n\t\ttitle:\n\t\t\t'a message with no recipient is refused with MailRefused, and nothing is sent',\n\t\tasync run(context) {\n\t\t\tconst error = await rejection(\n\t\t\t\tcontext.mailer.send({ ...sampleMessage, to: [] }),\n\t\t\t\t'a send with no recipient',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror instanceof MailRefused,\n\t\t\t\t'a send with no recipient must throw MailRefused',\n\t\t\t);\n\t\t\tawait nothingDelivered(context, 'the message was refused');\n\t\t},\n\t},\n\t{\n\t\tid: 'send.refusesLineBreakInSubject',\n\t\ttitle:\n\t\t\t'a line break in the subject is refused with MailRefused: it is a header injection',\n\t\tasync run(context) {\n\t\t\tconst error = await rejection(\n\t\t\t\tcontext.mailer.send({\n\t\t\t\t\t...sampleMessage,\n\t\t\t\t\tsubject: 'Hello\\r\\nBcc: eve@example.test',\n\t\t\t\t}),\n\t\t\t\t'a send with a line break in the subject',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror instanceof MailRefused,\n\t\t\t\t'a line break in the subject must throw MailRefused',\n\t\t\t);\n\t\t\tawait nothingDelivered(context, 'the message was refused');\n\t\t},\n\t},\n\t{\n\t\tid: 'send.refusesAddressHeader',\n\t\ttitle:\n\t\t\t'a Bcc among the custom headers is refused with MailRefused: it would add an unchecked recipient',\n\t\tasync run(context) {\n\t\t\t// A custom header named Bcc, To or Cc reaches the envelope of an SMTP\n\t\t\t// transport, and writes a line no address check ever saw.\n\t\t\tconst error = await rejection(\n\t\t\t\tcontext.mailer.send({\n\t\t\t\t\t...sampleMessage,\n\t\t\t\t\t// biome-ignore lint/style/useNamingConvention: a header's name, as a mail client writes it.\n\t\t\t\t\theaders: { Bcc: 'eve@example.test' },\n\t\t\t\t}),\n\t\t\t\t'a send with a Bcc header',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror instanceof MailRefused,\n\t\t\t\t'a Bcc header must throw MailRefused',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\t!error.message.includes('eve@example.test'),\n\t\t\t\t'the refusal message holds the refused value',\n\t\t\t);\n\t\t\tawait nothingDelivered(context, 'the message was refused');\n\t\t},\n\t},\n\t{\n\t\tid: 'send.refusesAttachmentPath',\n\t\ttitle:\n\t\t\t'an attachment named with a path is refused with MailRefused: a mail client could save it elsewhere',\n\t\tasync run(context) {\n\t\t\tconst error = await rejection(\n\t\t\t\tcontext.mailer.send({\n\t\t\t\t\t...sampleMessage,\n\t\t\t\t\tattachments: [\n\t\t\t\t\t\t{ ...sampleAttachment, filename: '../secret-7f3a/report.pdf' },\n\t\t\t\t\t],\n\t\t\t\t}),\n\t\t\t\t'a send with an attachment named with a path',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror instanceof MailRefused,\n\t\t\t\t'an attachment named with a path must throw MailRefused',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\t!error.message.includes('secret-7f3a'),\n\t\t\t\t'the refusal message holds the refused value',\n\t\t\t);\n\t\t\tawait nothingDelivered(context, 'the message was refused');\n\t\t},\n\t},\n\t{\n\t\tid: 'send.refusesWithoutTheValue',\n\t\ttitle: 'a refusal names where the problem is, never the value',\n\t\tasync run({ mailer }) {\n\t\t\tconst error = await rejection(\n\t\t\t\tmailer.send({ ...sampleMessage, to: 'not-an-address-7f3a' }),\n\t\t\t\t'a send to something that is not an address',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\terror instanceof MailRefused,\n\t\t\t\t'a malformed address must throw MailRefused',\n\t\t\t);\n\t\t\tcheck(\n\t\t\t\t!error.message.includes('not-an-address-7f3a'),\n\t\t\t\t'the refusal message holds the refused value',\n\t\t\t);\n\t\t},\n\t},\n];\n",
|
|
9
9
|
"import type { MailerCase } from '../types';\nimport { failureCases } from './failure';\nimport { sendCases } from './send';\n\nexport { failureCases } from './failure';\nexport { sendCases } from './send';\n\n/** Every case, in the order they are described. */\nexport const allMailerCases: readonly MailerCase[] = [\n\t...sendCases,\n\t...failureCases,\n];\n",
|
|
10
10
|
"import { allMailerCases } from './cases/index';\nimport type { MailerCase, MailerHarness, MailerRunner } from './types';\n\n/** Why a case did not run. A skip is always reported with its reason, never silent. */\nexport const MAILER_SKIP_REASONS = {\n\tfaults:\n\t\t'faults not provided: the failure contract is not proven for this transport',\n} as const;\n\n/**\n * Runs one case against a freshly opened transport, and closes it, pass or\n * fail. Answers the reason when the case cannot run on this harness.\n */\nexport async function runMailerCase(\n\tmailerCase: MailerCase,\n\tharness: MailerHarness,\n): Promise<{ readonly skipped: string } | { readonly passed: true }> {\n\tconst opened = await harness.open();\n\tlet outcome: { readonly skipped: string } | { readonly passed: true };\n\ttry {\n\t\tif (mailerCase.needs === 'faults' && opened.faults === undefined) {\n\t\t\toutcome = { skipped: MAILER_SKIP_REASONS.faults };\n\t\t} else {\n\t\t\tawait mailerCase.run({\n\t\t\t\tmailer: opened.mailer,\n\t\t\t\tdelivered: () => opened.delivered(),\n\t\t\t\tfaults: opened.faults ?? null,\n\t\t\t});\n\t\t\toutcome = { passed: true };\n\t\t}\n\t} catch (error) {\n\t\t// The case's failure is what the author needs to read: a close that\n\t\t// fails too must not replace it.\n\t\tawait opened.close?.().then(\n\t\t\t() => undefined,\n\t\t\t() => undefined,\n\t\t);\n\t\tthrow error;\n\t}\n\tawait opened.close?.();\n\treturn outcome;\n}\n\nfunction globalRunner(): MailerRunner {\n\tconst { describe, it } = globalThis as unknown as Partial<MailerRunner>;\n\tif (typeof describe !== 'function' || typeof it !== 'function') {\n\t\tthrow new TypeError(\n\t\t\t'describeMailer: no test runner found — pass runner: { describe, it } from your test framework',\n\t\t);\n\t}\n\treturn { describe, it };\n}\n\n/**\n * Describes every case against one transport, under bun:test, vitest or jest.\n *\n * ```ts\n * import { describe, it } from 'bun:test';\n * import { describeMailer } from '@nxgt/mail/conformance';\n *\n * describeMailer({ name: 'my transport', harness, runner: { describe, it } });\n * ```\n *\n * `runner` defaults to the global `describe` and `it`, when the framework\n * defines them.\n */\nexport function describeMailer(options: {\n\treadonly name: string;\n\treadonly harness: MailerHarness;\n\treadonly runner?: MailerRunner;\n\t/** Case ids to skip, each with the reason — reported, never silent. */\n\treadonly skip?: Readonly<Record<string, string>>;\n\t/** Declared up front, so a missing `faults` is reported before the first case runs. */\n\treadonly faults?: boolean;\n}): void {\n\tconst runner = options.runner ?? globalRunner();\n\tconst skip = options.skip ?? {};\n\tfor (const id of Object.keys(skip)) {\n\t\tif (!allMailerCases.some((c) => c.id === id)) {\n\t\t\tthrow new TypeError(`describeMailer: skip names no case: ${id}`);\n\t\t}\n\t}\n\n\trunner.describe(`${options.name} — @nxgt/mail conformance`, () => {\n\t\tfor (const mailerCase of allMailerCases) {\n\t\t\tconst title = `${mailerCase.id}: ${mailerCase.title}`;\n\t\t\tconst reason =\n\t\t\t\tskip[mailerCase.id] ??\n\t\t\t\t(mailerCase.needs === 'faults' && options.faults === false\n\t\t\t\t\t? MAILER_SKIP_REASONS.faults\n\t\t\t\t\t: undefined);\n\t\t\tif (reason !== undefined) {\n\t\t\t\trunner.it.skip(`${title} (skipped: ${reason})`, async () => {});\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\trunner.it(title, async () => {\n\t\t\t\tconst result = await runMailerCase(mailerCase, options.harness);\n\t\t\t\tif ('skipped' in result) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`conformance: ${mailerCase.id}: ${result.skipped} — pass faults: false to describeMailer to skip it on purpose`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t});\n}\n",
|
|
11
11
|
"import { MailFailure, MailRefused } from '../errors';\nimport { createMemoryMailer } from '../memory';\nimport { recipientsOf } from '../message';\nimport type { MailerHarness } from './types';\n\n/**\n * The harness of the memory mailer, the transport the suite is proven\n * against. Also a working example of a harness.\n */\nexport function referenceMailerHarness(): MailerHarness {\n\treturn {\n\t\tasync open() {\n\t\t\tconst mailer = createMemoryMailer();\n\t\t\treturn {\n\t\t\t\tmailer,\n\t\t\t\tasync delivered() {\n\t\t\t\t\treturn mailer.sent.map((mail) => ({\n\t\t\t\t\t\tto: recipientsOf(mail),\n\t\t\t\t\t\tsubject: mail.subject,\n\t\t\t\t\t\thtml: mail.html,\n\t\t\t\t\t\ttext: mail.text,\n\t\t\t\t\t\tattachments: mail.attachments ?? [],\n\t\t\t\t\t}));\n\t\t\t\t},\n\t\t\t\tfaults: {\n\t\t\t\t\tasync failNext(kind) {\n\t\t\t\t\t\tconst cause = new Error(`simulated ${kind}`);\n\t\t\t\t\t\tmailer.failNext(\n\t\t\t\t\t\t\tkind === 'outage'\n\t\t\t\t\t\t\t\t? new MailFailure('send: the transport could not be reached', {\n\t\t\t\t\t\t\t\t\t\tcause,\n\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t: new MailRefused('send: the transport refused the message', {\n\t\t\t\t\t\t\t\t\t\tcause,\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t);\n\t\t\t\t\t},\n\t\t\t\t\tasync attempts() {\n\t\t\t\t\t\treturn mailer.attempts;\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t};\n}\n"
|
|
12
12
|
],
|
|
13
|
-
"mappings": ";;;;;;;;;;;AAGO,SAAS,KAAK,CAAC,WAAoB,MAAiC;AAAA,EAC1E,IAAI,CAAC;AAAA,IAAW,MAAM,IAAI,MAAM,gBAAgB,MAAM;AAAA;AAGhD,IAAM,OAAO,CAAC,GAAY,MAChC,KAAK,UAAU,CAAC,MAAM,KAAK,UAAU,CAAC;AAMvC,eAAsB,SAAS,CAC9B,SACA,MACmB;AAAA,EACnB,OAAO,QAAQ,KACd,MAAM;AAAA,IACL,MAAM,IAAI,MAAM,gBAAgB,+BAA+B;AAAA,KAEhE,CAAC,UAAmB,KACrB;AAAA;AAID,eAAsB,gBAAgB,CACrC,SACA,MACC;AAAA,EACD,OACE,MAAM,QAAQ,UAAU,GAAG,WAAW,GACvC,GAAG,mCACJ;AAAA;;;AC/BM,IAAM,gBAA6B;AAAA,EACzC,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,SAAS;AAAA,EACT,MAAM;AAAA,EACN,MAAM;AAAA;AAAA;AAAA;AACP;AAMO,IAAM,mBAAmC;AAAA,EAC/C,UAAU;AAAA,EACV,SAAS,WAAW,KAAK,EAAE,QAAQ,IAAI,GAAG,CAAC,GAAG,SAAS,IAAI;AAAA,EAC3D,aAAa;AACd;;;ACbO,IAAM,eAAsC;AAAA,EAClD;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,IACD,OAAO;AAAA,SACD,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,SAAS,QAAQ;AAAA,MACvB,MAAM,WAAW,MAAM,qBAAqB;AAAA,MAC5C,MAAM,OAAO,SAAS,QAAQ;AAAA,MAC9B,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK,aAAa,GACjC,yBACD;AAAA,MAGA,MACC,iBAAiB,cACjB,kDACD;AAAA,MACA,MAAM,iBAAiB,YAAW,mCAAmC;AAAA,MACrE,MACC,MAAM,SAAS,eACf,2CACD;AAAA,MACA,MACC,MAAM,UAAU,WAChB,qDACD;AAAA,MACA,MACE,MAAM,OAAO,SAAS,MAAO,GAC9B,0CACD;AAAA,MACA,MAAM,iBAAiB,SAAS,sBAAsB;AAAA;AAAA,EAExD;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,OAAO;AAAA,SACD,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,SAAS,QAAQ;AAAA,MACvB,MAAM,WAAW,MAAM,qBAAqB;AAAA,MAC5C,MAAM,OAAO,SAAS,SAAS;AAAA,MAC/B,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK,aAAa,GACjC,gBACD;AAAA,MACA,MACC,iBAAiB,cACjB,kDACD;AAAA,MACA,MACC,MAAM,SAAS,gBACf,4CACD;AAAA,MACA,MACC,MAAM,UAAU,WAChB,qDACD;AAAA,MACA,MACE,MAAM,OAAO,SAAS,MAAO,GAC9B,yCACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,OAAO;AAAA,SACD,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,SAAS,QAAQ;AAAA,MACvB,MAAM,WAAW,MAAM,qBAAqB;AAAA,MAC5C,MAAM,OAAO,SAAS,QAAQ;AAAA,MAC9B,MAAM,UACL,QAAQ,OAAO,KAAK,aAAa,GACjC,yBACD;AAAA,MACA,MAAM,QAAQ,OAAO,KAAK,aAAa;AAAA,MACvC,OACE,MAAM,QAAQ,UAAU,GAAG,WAAW,GACvC,4CACD;AAAA;AAAA,EAEF;AACD;;;ACrFO,IAAM,YAAmC;AAAA,EAC/C;AAAA,IACC,IAAI;AAAA,IACJ,OAAO;AAAA,SACD,IAAG,GAAG,UAAU;AAAA,MACrB,MAAM,OAAO,MAAM,OAAO,KAAK,aAAa;AAAA,MAC5C,MACC,OAAO,SAAS,YAAY,SAAS,QAAQ,eAAe,MAC5D,8CACD;AAAA,MACA,MACC,KAAK,cAAc,QACjB,OAAO,KAAK,cAAc,YAAY,KAAK,cAAc,IAC3D,8CACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,OAAO,KAAK,aAAa;AAAA,MACvC,MAAM,YAAY,MAAM,QAAQ,UAAU;AAAA,MAC1C,MACC,UAAU,WAAW,GACrB,qCAAqC,UAAU,QAChD;AAAA,MACA,OAAO,QAAQ;AAAA,MACf,MACC,MAAM,YAAY,cAAc,SAChC,uCACD;AAAA,MACA,MACC,MAAM,SAAS,cAAc,MAC7B,yCACD;AAAA,MACA,MACC,MAAM,SAAS,cAAc,MAC7B,yCACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,OAAO,KAAK;AAAA,WACtB;AAAA,QACH,IAAI;AAAA,UACH;AAAA,UACA,EAAE,MAAM,gBAAgB,SAAS,qBAAqB;AAAA,QACvD;AAAA,MACD,CAAC;AAAA,MACD,OAAO,QAAQ,MAAM,QAAQ,UAAU;AAAA,MACvC,MACC,KAAK,MAAM,IAAI,CAAC,oBAAoB,oBAAoB,CAAC,GACzD,sDACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OAAO;AAAA,SACD,IAAG,CAAC,SAAS;AAAA,MAGlB,MAAM,QAAQ,OAAO,KAAK;AAAA,WACtB;AAAA,QACH,IAAI;AAAA,UACH,MAAM;AAAA,UACN,SAAS;AAAA,QACV;AAAA,MACD,CAAC;AAAA,MACD,OAAO,QAAQ,MAAM,QAAQ,UAAU;AAAA,MACvC,MACC,KAAK,MAAM,IAAI,CAAC,kBAAkB,CAAC,GACnC,uCACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,OAAO,KAAK;AAAA,WACtB;AAAA,QACH,aAAa,CAAC,gBAAgB;AAAA,MAC/B,CAAC;AAAA,MACD,OAAO,QAAQ,MAAM,QAAQ,UAAU;AAAA,MACvC,MACC,SAAS,WACT,kDACD;AAAA,MACA,MACC,KAAK,gBAAgB,WACrB,iIACD;AAAA,MACA,MACC,KAAK,YAAY,WAAW,GAC5B,wCAAwC,KAAK,YAAY,QAC1D;AAAA,MACA,OAAO,QAAQ,KAAK;AAAA,MACpB,MACC,MAAM,aAAa,iBAAiB,UACpC,qDACD;AAAA,MAEA,MACC,KAAK,YAAY,YAAY,MAAM,iBAAiB,aACpD,wDACD;AAAA,MACA,MACC,KAAK,mBAAmB,cACvB,KAAK,CAAC,GAAG,KAAK,OAAO,GAAG,CAAC,GAAG,iBAAiB,OAAO,CAAC,GACtD,gDACD;AAAA,MACA,MACC,KAAK,SAAS,cAAc,QAAQ,KAAK,SAAS,cAAc,MAChE,sEACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK,KAAK,eAAe,IAAI,CAAC,EAAE,CAAC,GAChD,0BACD;AAAA,MACA,MACC,iBAAiB,cACjB,iDACD;AAAA,MACA,MAAM,iBAAiB,SAAS,yBAAyB;AAAA;AAAA,EAE3D;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK;AAAA,WAChB;AAAA,QACH,SAAS;AAAA;AAAA,MACV,CAAC,GACD,yCACD;AAAA,MACA,MACC,iBAAiB,cACjB,oDACD;AAAA,MACA,MAAM,iBAAiB,SAAS,yBAAyB;AAAA;AAAA,EAE3D;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAGlB,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK;AAAA,WAChB;AAAA,QAEH,SAAS,EAAE,KAAK,mBAAmB;AAAA,MACpC,CAAC,GACD,0BACD;AAAA,MACA,MACC,iBAAiB,cACjB,qCACD;AAAA,MACA,MACC,CAAC,MAAM,QAAQ,SAAS,kBAAkB,GAC1C,6CACD;AAAA,MACA,MAAM,iBAAiB,SAAS,yBAAyB;AAAA;AAAA,EAE3D;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK;AAAA,WAChB;AAAA,QACH,aAAa;AAAA,UACZ,KAAK,kBAAkB,UAAU,4BAA4B;AAAA,QAC9D;AAAA,MACD,CAAC,GACD,6CACD;AAAA,MACA,MACC,iBAAiB,cACjB,wDACD;AAAA,MACA,MACC,CAAC,MAAM,QAAQ,SAAS,aAAa,GACrC,6CACD;AAAA,MACA,MAAM,iBAAiB,SAAS,yBAAyB;AAAA;AAAA,EAE3D;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OAAO;AAAA,SACD,IAAG,GAAG,UAAU;AAAA,MACrB,MAAM,QAAQ,MAAM,UACnB,OAAO,KAAK,KAAK,eAAe,IAAI,sBAAsB,CAAC,GAC3D,4CACD;AAAA,MACA,MACC,iBAAiB,cACjB,4CACD;AAAA,MACA,MACC,CAAC,MAAM,QAAQ,SAAS,qBAAqB,GAC7C,6CACD;AAAA;AAAA,EAEF;AACD;;;
|
|
14
|
-
"debugId": "
|
|
13
|
+
"mappings": ";;;;;;;;;;;AAGO,SAAS,KAAK,CAAC,WAAoB,MAAiC;AAAA,EAC1E,IAAI,CAAC;AAAA,IAAW,MAAM,IAAI,MAAM,gBAAgB,MAAM;AAAA;AAGhD,IAAM,OAAO,CAAC,GAAY,MAChC,KAAK,UAAU,CAAC,MAAM,KAAK,UAAU,CAAC;AAMvC,eAAsB,SAAS,CAC9B,SACA,MACmB;AAAA,EACnB,OAAO,QAAQ,KACd,MAAM;AAAA,IACL,MAAM,IAAI,MAAM,gBAAgB,+BAA+B;AAAA,KAEhE,CAAC,UAAmB,KACrB;AAAA;AAID,eAAsB,gBAAgB,CACrC,SACA,MACC;AAAA,EACD,OACE,MAAM,QAAQ,UAAU,GAAG,WAAW,GACvC,GAAG,mCACJ;AAAA;;;AC/BM,IAAM,gBAA6B;AAAA,EACzC,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,SAAS;AAAA,EACT,MAAM;AAAA,EACN,MAAM;AAAA;AAAA;AAAA;AACP;AAMO,IAAM,mBAAmC;AAAA,EAC/C,UAAU;AAAA,EACV,SAAS,WAAW,KAAK,EAAE,QAAQ,IAAI,GAAG,CAAC,GAAG,SAAS,IAAI;AAAA,EAC3D,aAAa;AACd;;;ACbO,IAAM,eAAsC;AAAA,EAClD;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,IACD,OAAO;AAAA,SACD,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,SAAS,QAAQ;AAAA,MACvB,MAAM,WAAW,MAAM,qBAAqB;AAAA,MAC5C,MAAM,OAAO,SAAS,QAAQ;AAAA,MAC9B,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK,aAAa,GACjC,yBACD;AAAA,MAGA,MACC,iBAAiB,cACjB,kDACD;AAAA,MACA,MAAM,iBAAiB,YAAW,mCAAmC;AAAA,MACrE,MACC,MAAM,SAAS,eACf,2CACD;AAAA,MACA,MACC,MAAM,UAAU,WAChB,qDACD;AAAA,MACA,MACE,MAAM,OAAO,SAAS,MAAO,GAC9B,0CACD;AAAA,MACA,MAAM,iBAAiB,SAAS,sBAAsB;AAAA;AAAA,EAExD;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,OAAO;AAAA,SACD,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,SAAS,QAAQ;AAAA,MACvB,MAAM,WAAW,MAAM,qBAAqB;AAAA,MAC5C,MAAM,OAAO,SAAS,SAAS;AAAA,MAC/B,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK,aAAa,GACjC,gBACD;AAAA,MACA,MACC,iBAAiB,cACjB,kDACD;AAAA,MACA,MACC,MAAM,SAAS,gBACf,4CACD;AAAA,MACA,MACC,MAAM,UAAU,WAChB,qDACD;AAAA,MACA,MACE,MAAM,OAAO,SAAS,MAAO,GAC9B,yCACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,OAAO;AAAA,SACD,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,SAAS,QAAQ;AAAA,MACvB,MAAM,WAAW,MAAM,qBAAqB;AAAA,MAC5C,MAAM,OAAO,SAAS,QAAQ;AAAA,MAC9B,MAAM,UACL,QAAQ,OAAO,KAAK,aAAa,GACjC,yBACD;AAAA,MACA,MAAM,QAAQ,OAAO,KAAK,aAAa;AAAA,MACvC,OACE,MAAM,QAAQ,UAAU,GAAG,WAAW,GACvC,4CACD;AAAA;AAAA,EAEF;AACD;;;ACrFO,IAAM,YAAmC;AAAA,EAC/C;AAAA,IACC,IAAI;AAAA,IACJ,OAAO;AAAA,SACD,IAAG,GAAG,UAAU;AAAA,MACrB,MAAM,OAAO,MAAM,OAAO,KAAK,aAAa;AAAA,MAC5C,MACC,OAAO,SAAS,YAAY,SAAS,QAAQ,eAAe,MAC5D,8CACD;AAAA,MACA,MACC,KAAK,cAAc,QACjB,OAAO,KAAK,cAAc,YAAY,KAAK,cAAc,IAC3D,8CACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,OAAO,KAAK,aAAa;AAAA,MACvC,MAAM,YAAY,MAAM,QAAQ,UAAU;AAAA,MAC1C,MACC,UAAU,WAAW,GACrB,qCAAqC,UAAU,QAChD;AAAA,MACA,OAAO,QAAQ;AAAA,MACf,MACC,MAAM,YAAY,cAAc,SAChC,uCACD;AAAA,MACA,MACC,MAAM,SAAS,cAAc,MAC7B,yCACD;AAAA,MACA,MACC,MAAM,SAAS,cAAc,MAC7B,yCACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,OAAO,KAAK;AAAA,WACtB;AAAA,QACH,IAAI;AAAA,UACH;AAAA,UACA,EAAE,MAAM,gBAAgB,SAAS,qBAAqB;AAAA,QACvD;AAAA,MACD,CAAC;AAAA,MACD,OAAO,QAAQ,MAAM,QAAQ,UAAU;AAAA,MACvC,MACC,KAAK,MAAM,IAAI,CAAC,oBAAoB,oBAAoB,CAAC,GACzD,sDACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OAAO;AAAA,SACD,IAAG,CAAC,SAAS;AAAA,MAGlB,MAAM,QAAQ,OAAO,KAAK;AAAA,WACtB;AAAA,QACH,IAAI;AAAA,UACH,MAAM;AAAA,UACN,SAAS;AAAA,QACV;AAAA,MACD,CAAC;AAAA,MACD,OAAO,QAAQ,MAAM,QAAQ,UAAU;AAAA,MACvC,MACC,KAAK,MAAM,IAAI,CAAC,kBAAkB,CAAC,GACnC,uCACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,OAAO,KAAK;AAAA,WACtB;AAAA,QACH,aAAa,CAAC,gBAAgB;AAAA,MAC/B,CAAC;AAAA,MACD,OAAO,QAAQ,MAAM,QAAQ,UAAU;AAAA,MACvC,MACC,SAAS,WACT,kDACD;AAAA,MACA,MACC,KAAK,gBAAgB,WACrB,iIACD;AAAA,MACA,MACC,KAAK,YAAY,WAAW,GAC5B,wCAAwC,KAAK,YAAY,QAC1D;AAAA,MACA,OAAO,QAAQ,KAAK;AAAA,MACpB,MACC,MAAM,aAAa,iBAAiB,UACpC,qDACD;AAAA,MAEA,MACC,KAAK,YAAY,YAAY,MAAM,iBAAiB,aACpD,wDACD;AAAA,MACA,MACC,KAAK,mBAAmB,cACvB,KAAK,CAAC,GAAG,KAAK,OAAO,GAAG,CAAC,GAAG,iBAAiB,OAAO,CAAC,GACtD,gDACD;AAAA,MACA,MACC,KAAK,SAAS,cAAc,QAAQ,KAAK,SAAS,cAAc,MAChE,sEACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAKlB,MAAM,MAAM,eAAe,OAAO,WAAW;AAAA,MAC7C,MAAM,OAAO,MAAM,QAAQ,OAAO,KAAK;AAAA,WACnC;AAAA,QACH,gBAAgB;AAAA,MACjB,CAAC;AAAA,MACD,MACC,OAAO,SAAS,YAAY,SAAS,QAAQ,eAAe,MAC5D,wDACD;AAAA,MACA,MAAM,YAAY,MAAM,QAAQ,UAAU;AAAA,MAC1C,MACC,UAAU,WAAW,GACrB,qCAAqC,UAAU,QAChD;AAAA,MACA,OAAO,QAAQ;AAAA,MACf,MACC,CAAC,CAAC,MAAM,SAAS,MAAM,MAAM,MAAM,MAAM,GAAI,MAAM,MAAM,CAAC,CAAE,EAAE,KAC7D,CAAC,SAAS,MAAM,SAAS,GAAG,CAC7B,GACA,iDACD;AAAA;AAAA,EAEF;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK,KAAK,eAAe,IAAI,CAAC,EAAE,CAAC,GAChD,0BACD;AAAA,MACA,MACC,iBAAiB,cACjB,iDACD;AAAA,MACA,MAAM,iBAAiB,SAAS,yBAAyB;AAAA;AAAA,EAE3D;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK;AAAA,WAChB;AAAA,QACH,SAAS;AAAA;AAAA,MACV,CAAC,GACD,yCACD;AAAA,MACA,MACC,iBAAiB,cACjB,oDACD;AAAA,MACA,MAAM,iBAAiB,SAAS,yBAAyB;AAAA;AAAA,EAE3D;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAGlB,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK;AAAA,WAChB;AAAA,QAEH,SAAS,EAAE,KAAK,mBAAmB;AAAA,MACpC,CAAC,GACD,0BACD;AAAA,MACA,MACC,iBAAiB,cACjB,qCACD;AAAA,MACA,MACC,CAAC,MAAM,QAAQ,SAAS,kBAAkB,GAC1C,6CACD;AAAA,MACA,MAAM,iBAAiB,SAAS,yBAAyB;AAAA;AAAA,EAE3D;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OACC;AAAA,SACK,IAAG,CAAC,SAAS;AAAA,MAClB,MAAM,QAAQ,MAAM,UACnB,QAAQ,OAAO,KAAK;AAAA,WAChB;AAAA,QACH,aAAa;AAAA,UACZ,KAAK,kBAAkB,UAAU,4BAA4B;AAAA,QAC9D;AAAA,MACD,CAAC,GACD,6CACD;AAAA,MACA,MACC,iBAAiB,cACjB,wDACD;AAAA,MACA,MACC,CAAC,MAAM,QAAQ,SAAS,aAAa,GACrC,6CACD;AAAA,MACA,MAAM,iBAAiB,SAAS,yBAAyB;AAAA;AAAA,EAE3D;AAAA,EACA;AAAA,IACC,IAAI;AAAA,IACJ,OAAO;AAAA,SACD,IAAG,GAAG,UAAU;AAAA,MACrB,MAAM,QAAQ,MAAM,UACnB,OAAO,KAAK,KAAK,eAAe,IAAI,sBAAsB,CAAC,GAC3D,4CACD;AAAA,MACA,MACC,iBAAiB,cACjB,4CACD;AAAA,MACA,MACC,CAAC,MAAM,QAAQ,SAAS,qBAAqB,GAC7C,6CACD;AAAA;AAAA,EAEF;AACD;;;ACnQO,IAAM,iBAAwC;AAAA,EACpD,GAAG;AAAA,EACH,GAAG;AACJ;;ACPO,IAAM,sBAAsB;AAAA,EAClC,QACC;AACF;AAMA,eAAsB,aAAa,CAClC,YACA,SACoE;AAAA,EACpE,MAAM,SAAS,MAAM,QAAQ,KAAK;AAAA,EAClC,IAAI;AAAA,EACJ,IAAI;AAAA,IACH,IAAI,WAAW,UAAU,YAAY,OAAO,WAAW,WAAW;AAAA,MACjE,UAAU,EAAE,SAAS,oBAAoB,OAAO;AAAA,IACjD,EAAO;AAAA,MACN,MAAM,WAAW,IAAI;AAAA,QACpB,QAAQ,OAAO;AAAA,QACf,WAAW,MAAM,OAAO,UAAU;AAAA,QAClC,QAAQ,OAAO,UAAU;AAAA,MAC1B,CAAC;AAAA,MACD,UAAU,EAAE,QAAQ,KAAK;AAAA;AAAA,IAEzB,OAAO,OAAO;AAAA,IAGf,MAAM,OAAO,QAAQ,EAAE,KACtB,MAAG;AAAA,MAAG;AAAA,OACN,MAAG;AAAA,MAAG;AAAA,KACP;AAAA,IACA,MAAM;AAAA;AAAA,EAEP,MAAM,OAAO,QAAQ;AAAA,EACrB,OAAO;AAAA;AAGR,SAAS,YAAY,GAAiB;AAAA,EACrC,QAAQ,UAAU,OAAO;AAAA,EACzB,IAAI,OAAO,aAAa,cAAc,OAAO,OAAO,YAAY;AAAA,IAC/D,MAAM,IAAI,UACT,+FACD;AAAA,EACD;AAAA,EACA,OAAO,EAAE,UAAU,GAAG;AAAA;AAgBhB,SAAS,cAAc,CAAC,SAQtB;AAAA,EACR,MAAM,SAAS,QAAQ,UAAU,aAAa;AAAA,EAC9C,MAAM,OAAO,QAAQ,QAAQ,CAAC;AAAA,EAC9B,WAAW,MAAM,OAAO,KAAK,IAAI,GAAG;AAAA,IACnC,IAAI,CAAC,eAAe,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG;AAAA,MAC7C,MAAM,IAAI,UAAU,uCAAuC,IAAI;AAAA,IAChE;AAAA,EACD;AAAA,EAEA,OAAO,SAAS,GAAG,QAAQ,iCAAiC,MAAM;AAAA,IACjE,WAAW,cAAc,gBAAgB;AAAA,MACxC,MAAM,QAAQ,GAAG,WAAW,OAAO,WAAW;AAAA,MAC9C,MAAM,SACL,KAAK,WAAW,QACf,WAAW,UAAU,YAAY,QAAQ,WAAW,QAClD,oBAAoB,SACpB;AAAA,MACJ,IAAI,WAAW,WAAW;AAAA,QACzB,OAAO,GAAG,KAAK,GAAG,mBAAmB,WAAW,YAAY,EAAE;AAAA,QAC9D;AAAA,MACD;AAAA,MACA,OAAO,GAAG,OAAO,YAAY;AAAA,QAC5B,MAAM,SAAS,MAAM,cAAc,YAAY,QAAQ,OAAO;AAAA,QAC9D,IAAI,aAAa,QAAQ;AAAA,UACxB,MAAM,IAAI,MACT,gBAAgB,WAAW,OAAO,OAAO,sEAC1C;AAAA,QACD;AAAA,OACA;AAAA,IACF;AAAA,GACA;AAAA;;AC/FK,SAAS,sBAAsB,GAAkB;AAAA,EACvD,OAAO;AAAA,SACA,KAAI,GAAG;AAAA,MACZ,MAAM,SAAS,oBAAmB;AAAA,MAClC,OAAO;AAAA,QACN;AAAA,aACM,UAAS,GAAG;AAAA,UACjB,OAAO,OAAO,KAAK,IAAI,CAAC,UAAU;AAAA,YACjC,IAAI,cAAa,IAAI;AAAA,YACrB,SAAS,KAAK;AAAA,YACd,MAAM,KAAK;AAAA,YACX,MAAM,KAAK;AAAA,YACX,aAAa,KAAK,eAAe,CAAC;AAAA,UACnC,EAAE;AAAA;AAAA,QAEH,QAAQ;AAAA,eACD,SAAQ,CAAC,MAAM;AAAA,YACpB,MAAM,QAAQ,IAAI,MAAM,aAAa,MAAM;AAAA,YAC3C,OAAO,SACN,SAAS,WACN,IAAI,aAAY,4CAA4C;AAAA,cAC5D;AAAA,YACD,CAAC,IACA,IAAI,aAAY,2CAA2C;AAAA,cAC3D;AAAA,YACD,CAAC,CACJ;AAAA;AAAA,eAEK,SAAQ,GAAG;AAAA,YAChB,OAAO,OAAO;AAAA;AAAA,QAEhB;AAAA,MACD;AAAA;AAAA,EAEF;AAAA;",
|
|
14
|
+
"debugId": "5D29AA162B05B41464756E2164756E21",
|
|
15
15
|
"names": []
|
|
16
16
|
}
|
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
|
@@ -13,6 +13,35 @@ import {
|
|
|
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 BAD_ESCAPE = /%(?![0-9A-Fa-f]{2})/;
|
|
20
|
+
var MAILTO = /^[A-Za-z0-9._~!$'*+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
|
|
21
|
+
var fitsTheHeader = (url) => URL_ALLOWED.test(url) && !URL_REFUSED.test(url) && !BAD_ESCAPE.test(url);
|
|
22
|
+
function listUnsubscribe(options) {
|
|
23
|
+
if (typeof options !== "object" || options === null) {
|
|
24
|
+
throw new TypeError("listUnsubscribe: options must be an object, as { url }");
|
|
25
|
+
}
|
|
26
|
+
if (typeof options.url !== "string") {
|
|
27
|
+
throw new TypeError("listUnsubscribe: url must be a string");
|
|
28
|
+
}
|
|
29
|
+
if (options.mailto !== undefined && typeof options.mailto !== "string") {
|
|
30
|
+
throw new TypeError("listUnsubscribe: mailto must be a string");
|
|
31
|
+
}
|
|
32
|
+
const parsed = URL.canParse(options.url) ? new URL(options.url) : null;
|
|
33
|
+
if (parsed === null || parsed.username !== "" || parsed.password !== "" || ![options.url, parsed.href].every(fitsTheHeader)) {
|
|
34
|
+
throw new MailRefused2("listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma");
|
|
35
|
+
}
|
|
36
|
+
if (options.mailto !== undefined && !MAILTO.test(options.mailto)) {
|
|
37
|
+
throw new MailRefused2("listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com");
|
|
38
|
+
}
|
|
39
|
+
const mailto = options.mailto === undefined ? "" : `, <mailto:${options.mailto}>`;
|
|
40
|
+
return {
|
|
41
|
+
"List-Unsubscribe": `<${parsed.href}>${mailto}`,
|
|
42
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
43
|
+
};
|
|
44
|
+
}
|
|
16
45
|
export {
|
|
17
46
|
MailError2 as MailError,
|
|
18
47
|
MailFailure2 as MailFailure,
|
|
@@ -20,10 +49,11 @@ export {
|
|
|
20
49
|
addressOf2 as addressOf,
|
|
21
50
|
checkMessage2 as checkMessage,
|
|
22
51
|
createMemoryMailer2 as createMemoryMailer,
|
|
52
|
+
listUnsubscribe,
|
|
23
53
|
parseAcceptLanguage2 as parseAcceptLanguage,
|
|
24
54
|
pickLocale2 as pickLocale,
|
|
25
55
|
recipientsOf2 as recipientsOf
|
|
26
56
|
};
|
|
27
57
|
|
|
28
|
-
//# debugId=
|
|
58
|
+
//# debugId=467299297E6B375F64756E2164756E21
|
|
29
59
|
//# 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// A `%` that does not start an escape: no URI parser reads it as intended.\nconst BAD_ESCAPE = /%(?![0-9A-Fa-f]{2})/;\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\nconst fitsTheHeader = (url: string): boolean =>\n\tURL_ALLOWED.test(url) && !URL_REFUSED.test(url) && !BAD_ESCAPE.test(url);\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, holds `<`, `>`, a double quote or a raw `,`,\n * or a `%` that starts no escape — before or after parsing — and a `mailto`\n * that is not a bare ASCII address. The URL is written as a parser reads it\n * (`new URL(url).href`: the host lowered, a `'` in the query as `%27`). It 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\t// The URL as a parser reads it: `https:///host` and an empty `@` are gone,\n\t// the host lowered. The parser also decodes a host's escapes — `a%2Cb`\n\t// comes back as `a,b` — so what is written is checked as well.\n\tconst parsed = URL.canParse(options.url) ? new URL(options.url) : null;\n\tif (\n\t\tparsed === null ||\n\t\t// A user and a password in a header every relay and recipient reads.\n\t\tparsed.username !== '' ||\n\t\tparsed.password !== '' ||\n\t\t![options.url, parsed.href].every(fitsTheHeader)\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': `<${parsed.href}>${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;AAEpB,IAAM,aAAa;AAGnB,IAAM,SAAS;AAEf,IAAM,gBAAgB,CAAC,QACtB,YAAY,KAAK,GAAG,KAAK,CAAC,YAAY,KAAK,GAAG,KAAK,CAAC,WAAW,KAAK,GAAG;AAwBjE,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,EAIA,MAAM,SAAS,IAAI,SAAS,QAAQ,GAAG,IAAI,IAAI,IAAI,QAAQ,GAAG,IAAI;AAAA,EAClE,IACC,WAAW,QAEX,OAAO,aAAa,MACpB,OAAO,aAAa,MACpB,CAAC,CAAC,QAAQ,KAAK,OAAO,IAAI,EAAE,MAAM,aAAa,GAC9C;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,OAAO,QAAQ;AAAA,IACvC,yBAAyB;AAAA,EAC1B;AAAA;",
|
|
8
|
+
"debugId": "467299297E6B375F64756E2164756E21",
|
|
8
9
|
"names": []
|
|
9
10
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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, holds `<`, `>`, a double quote or a raw `,`,
|
|
38
|
+
* or a `%` that starts no escape — before or after parsing — and a `mailto`
|
|
39
|
+
* that is not a bare ASCII address. The URL is written as a parser reads it
|
|
40
|
+
* (`new URL(url).href`: the host lowered, a `'` in the query as `%27`). It is
|
|
41
|
+
* often built from a token, and a token is a credential: the message names
|
|
42
|
+
* the rule, not the link.
|
|
43
|
+
*/
|
|
44
|
+
export declare function listUnsubscribe(options: ListUnsubscribeOptions): ListUnsubscribeHeaders;
|
|
45
|
+
//# 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;AAkBF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,eAAe,CAC9B,OAAO,EAAE,sBAAsB,GAC7B,sBAAsB,CAsCxB"}
|
package/docs/README.md
CHANGED
|
@@ -8,7 +8,7 @@ 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, the idempotency key, what `send` answers, and turning `MailFailure` and `MailRefused` into a response |
|
|
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
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
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 |
|
package/docs/guide/rendering.md
CHANGED
|
@@ -168,8 +168,9 @@ mails.render('sign-in-code', { code: 123456 }, { locale: 'fr' });
|
|
|
168
168
|
|
|
169
169
|
After each `maizzle build`, `@nxgt/mail-i18n` writes `generated/mail.ts` in
|
|
170
170
|
the project: `MailEmails`, each e-mail of the build with the variables it
|
|
171
|
-
takes.
|
|
172
|
-
|
|
171
|
+
takes. Git-ignore it — each build rewrites it — and run `maizzle build`
|
|
172
|
+
before type-checking, as `"typecheck": "maizzle build && tsc --noEmit"`
|
|
173
|
+
does; then pass it to `createMailRenderer`:
|
|
173
174
|
|
|
174
175
|
```ts
|
|
175
176
|
// generated/mail.ts — written by the build, never edited
|
|
@@ -251,7 +252,8 @@ so a helper written for any build takes a typed renderer.
|
|
|
251
252
|
|
|
252
253
|
The manifest guide also shows
|
|
253
254
|
[a test that holds the two sides together](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-i18n/docs/guide/manifest.md#checking-your-application-against-it),
|
|
254
|
-
for
|
|
255
|
+
for an application that only installs the built project, or is not
|
|
256
|
+
written in TypeScript.
|
|
255
257
|
|
|
256
258
|
## Choosing the locale
|
|
257
259
|
|
package/docs/guide/sending.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Sending
|
|
2
2
|
|
|
3
3
|
This page is for calling `mailer.send`: the shape of what it takes, the
|
|
4
|
-
addresses, headers and attachments it accepts,
|
|
5
|
-
a retry safe, what it answers, and what it throws.
|
|
4
|
+
addresses, headers and attachments it accepts, one-click unsubscribe, the
|
|
5
|
+
idempotency key that makes a retry safe, what it answers, and what it throws.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
import { createMemoryMailer } from '@nxgt/mail';
|
|
@@ -93,7 +93,7 @@ interface MailAttachment {
|
|
|
93
93
|
| `to` | `Address \| readonly Address[]` | yes | One recipient or several, at least one |
|
|
94
94
|
| `from` | `Address` | no | The sender. `checkMessage` does not require one: a transport is usually wired with a default sender, and one without a default may refuse a message without `from` — see its documentation |
|
|
95
95
|
| `replyTo` | `Address` | no | Where replies go |
|
|
96
|
-
| `headers` | `Record<string, string>` | no | Extra headers, such as `
|
|
96
|
+
| `headers` | `Record<string, string>` | no | Extra headers, such as `X-Entity-Ref-ID`, or the two of [one-click unsubscribe](#one-click-unsubscribe) |
|
|
97
97
|
| `attachments` | `readonly MailAttachment[]` | no | Files sent with the e-mail, in order, as bytes — see [Attachments](#attachments). An empty list is the same as none |
|
|
98
98
|
| `idempotencyKey` | `string` | no | Names this send, so sending it again delivers it once where the transport can deduplicate — see [Idempotency](#idempotency--sending-once). 1 to 256 visible ASCII characters |
|
|
99
99
|
|
|
@@ -211,13 +211,14 @@ declare const rendered: Rendered;
|
|
|
211
211
|
const message: MailMessage = {
|
|
212
212
|
...rendered,
|
|
213
213
|
to: 'ada@example.com',
|
|
214
|
-
headers: {
|
|
215
|
-
'List-Unsubscribe': '<https://example.com/unsubscribe?u=42>',
|
|
216
|
-
'X-Entity-Ref-ID': 'welcome-42',
|
|
217
|
-
},
|
|
214
|
+
headers: { 'X-Entity-Ref-ID': 'welcome-42' },
|
|
218
215
|
};
|
|
219
216
|
```
|
|
220
217
|
|
|
218
|
+
`List-Unsubscribe` and `List-Unsubscribe-Post` are headers like any other,
|
|
219
|
+
but write them with [`listUnsubscribe`](#one-click-unsubscribe), which checks
|
|
220
|
+
the URL.
|
|
221
|
+
|
|
221
222
|
| Written | Answer |
|
|
222
223
|
| --- | --- |
|
|
223
224
|
| `{ 'X Bad': 'v' }` | `MailRefused`: `send: a header name must be letters, digits and hyphens` |
|
|
@@ -225,6 +226,217 @@ const message: MailMessage = {
|
|
|
225
226
|
| `{ Bcc: 'eve@example.com' }` | `MailRefused`: `send: header Bcc is reserved — addresses, the subject and the MIME structure are never custom headers` |
|
|
226
227
|
| `{ 'content-type': 'text/plain' }` | `MailRefused`: `send: header content-type is reserved — …` |
|
|
227
228
|
|
|
229
|
+
## One-click unsubscribe
|
|
230
|
+
|
|
231
|
+
`listUnsubscribe` answers the two headers that give an e-mail the
|
|
232
|
+
"Unsubscribe" button Gmail and Yahoo show next to the sender (RFC 8058, with
|
|
233
|
+
RFC 2369's `List-Unsubscribe`), to spread into `headers`:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { listUnsubscribe } from '@nxgt/mail';
|
|
237
|
+
|
|
238
|
+
listUnsubscribe({ url: 'https://example.com/unsubscribe?token=s3cr3t' });
|
|
239
|
+
// {
|
|
240
|
+
// 'List-Unsubscribe': '<https://example.com/unsubscribe?token=s3cr3t>',
|
|
241
|
+
// 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
|
242
|
+
// }
|
|
243
|
+
|
|
244
|
+
listUnsubscribe({ url: 'https://example.com/unsubscribe?token=s3cr3t', mailto: 'unsubscribe@example.com' });
|
|
245
|
+
// 'List-Unsubscribe': '<https://example.com/unsubscribe?token=s3cr3t>, <mailto:unsubscribe@example.com>'
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
interface ListUnsubscribeOptions {
|
|
250
|
+
readonly url: string;
|
|
251
|
+
readonly mailto?: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A type, not an interface, so it goes into `headers` as it is.
|
|
255
|
+
type ListUnsubscribeHeaders = {
|
|
256
|
+
readonly 'List-Unsubscribe': string;
|
|
257
|
+
readonly 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click';
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
function listUnsubscribe(options: ListUnsubscribeOptions): ListUnsubscribeHeaders;
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
| Option | Type | Default | Effect |
|
|
264
|
+
| --- | --- | --- | --- |
|
|
265
|
+
| `url` | `string` | required | The `https:` URL a mail client POSTs `List-Unsubscribe=One-Click` to. One per recipient, carrying what identifies them — `https://example.com/unsubscribe?token=…`. Written first in `List-Unsubscribe` |
|
|
266
|
+
| `mailto` | `string` | none | A bare address that unsubscribes whoever writes to it, for clients that only send mail. Written after the URL as `<mailto:…>` |
|
|
267
|
+
|
|
268
|
+
The headers travel as any other: `checkMessage` accepts them, and every
|
|
269
|
+
transport sends them unchanged. Spread them into `headers` — alone, as
|
|
270
|
+
`headers: { ...listUnsubscribe({ url }) }`, or beside headers of your own:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import { listUnsubscribe, type MailMessage, type Rendered } from '@nxgt/mail';
|
|
274
|
+
|
|
275
|
+
declare const rendered: Rendered;
|
|
276
|
+
declare const token: string;
|
|
277
|
+
|
|
278
|
+
const message: MailMessage = {
|
|
279
|
+
...rendered,
|
|
280
|
+
to: 'ada@example.com',
|
|
281
|
+
headers: {
|
|
282
|
+
...listUnsubscribe({ url: `https://example.com/unsubscribe?token=${encodeURIComponent(token)}` }),
|
|
283
|
+
'X-Entity-Ref-ID': 'newsletter-2026-09',
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### What Gmail and Yahoo require
|
|
289
|
+
|
|
290
|
+
Since 2024, a sender of bulk mail to Gmail or Yahoo addresses must, on
|
|
291
|
+
marketing and subscribed mail:
|
|
292
|
+
|
|
293
|
+
- carry **both** headers, `List-Unsubscribe` with an `https:` URL and
|
|
294
|
+
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`;
|
|
295
|
+
- have them covered by a **DKIM signature** of the sending domain, so nobody
|
|
296
|
+
can add or change them on the way;
|
|
297
|
+
- honour the unsubscribe promptly — within two days.
|
|
298
|
+
|
|
299
|
+
The DKIM signature is the sender's, not this package's: `listUnsubscribe`
|
|
300
|
+
writes the headers, and whatever signs the message must include them.
|
|
301
|
+
|
|
302
|
+
| Transport | Who signs |
|
|
303
|
+
| --- | --- |
|
|
304
|
+
| `@nxgt/mail-resend` | Resend, with the DKIM key of your verified domain. Check that its `h=` names both headers in a received message's `DKIM-Signature` |
|
|
305
|
+
| `@nxgt/mail-smtp` | Your relay, or nodemailer's own `dkim` option on the transporter you create. A relay that does not DKIM-sign leaves the headers unsigned, and the message fails the requirement |
|
|
306
|
+
|
|
307
|
+
### Which e-mails carry it
|
|
308
|
+
|
|
309
|
+
**Marketing and bulk mail** — a newsletter, a digest, a product announcement,
|
|
310
|
+
anything the recipient subscribed to and can stop receiving.
|
|
311
|
+
|
|
312
|
+
**Not transactional mail** — a password reset, a sign-in code, an e-mail
|
|
313
|
+
verification, a receipt, a security alert. The recipient cannot opt out of
|
|
314
|
+
those, and an "Unsubscribe" button next to a sign-in code invites them to
|
|
315
|
+
try. Leave `headers` without it.
|
|
316
|
+
|
|
317
|
+
### The endpoint
|
|
318
|
+
|
|
319
|
+
The URL is yours. It must:
|
|
320
|
+
|
|
321
|
+
- **unsubscribe on a `POST`** whose form body is `List-Unsubscribe=One-Click`
|
|
322
|
+
— sent as `application/x-www-form-urlencoded` or `multipart/form-data`,
|
|
323
|
+
which `request.formData()` both reads;
|
|
324
|
+
- do it **with no login, no confirmation page and no redirect**, from the
|
|
325
|
+
URL alone — the mail client sends no cookie, so the endpoint takes no CSRF
|
|
326
|
+
token either — and answer **2xx**;
|
|
327
|
+
- on a **`GET`** — the same URL in the body of the e-mail, clicked by a
|
|
328
|
+
person — **show a page, never unsubscribe**: link scanners and previews
|
|
329
|
+
fetch every URL in an e-mail. The page's button can post the same form.
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
// Yours: the token store.
|
|
333
|
+
declare function unsubscribeByToken(token: string): Promise<boolean>; // false: unknown token
|
|
334
|
+
|
|
335
|
+
export async function unsubscribeHandler(request: Request): Promise<Response> {
|
|
336
|
+
const token = new URL(request.url).searchParams.get('token') ?? '';
|
|
337
|
+
|
|
338
|
+
if (request.method === 'POST') {
|
|
339
|
+
const form = await request.formData();
|
|
340
|
+
if (form.get('List-Unsubscribe') !== 'One-Click') return new Response(null, { status: 400 });
|
|
341
|
+
await unsubscribeByToken(token); // an unknown token answers 200 too: nothing to tell a mail client
|
|
342
|
+
return new Response(null, { status: 200 });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// GET: a person followed the link in the e-mail. Show, do not act.
|
|
346
|
+
const page = `<!doctype html><title>Unsubscribe</title>
|
|
347
|
+
<form method="post"><input type="hidden" name="List-Unsubscribe" value="One-Click">
|
|
348
|
+
<button>Unsubscribe</button></form>`;
|
|
349
|
+
return new Response(page, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
The form posts to its own URL, token included, so the page and the mail
|
|
354
|
+
client take the same path. In a framework, mount it at the URL you pass:
|
|
355
|
+
with Hono, `app.on(['GET', 'POST'], '/unsubscribe', (c) => unsubscribeHandler(c.req.raw))`.
|
|
356
|
+
|
|
357
|
+
### The token is a credential
|
|
358
|
+
|
|
359
|
+
Anyone holding the URL can unsubscribe that recipient. Make the token
|
|
360
|
+
unguessable (random, or signed), keep it valid for as long as the e-mail may
|
|
361
|
+
be read, and **never log it** — nor the URL that carries it, nor the query
|
|
362
|
+
string of the endpoint's access log. `listUnsubscribe` does its part: a
|
|
363
|
+
refused `url` is reported by the rule it broke, never quoted.
|
|
364
|
+
|
|
365
|
+
### Commas must be percent-encoded
|
|
366
|
+
|
|
367
|
+
`List-Unsubscribe` is a comma-separated list of URLs: a raw `,` in the URL
|
|
368
|
+
would start a second one, so it is refused. `encodeURIComponent` and
|
|
369
|
+
`URLSearchParams` both write `%2C`; a URL built with `URL` is passed as
|
|
370
|
+
`url.href` — a `URL` object is a compile error:
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
import { listUnsubscribe } from '@nxgt/mail';
|
|
374
|
+
|
|
375
|
+
declare const token: string;
|
|
376
|
+
|
|
377
|
+
const url = new URL('https://example.com/unsubscribe');
|
|
378
|
+
url.searchParams.set('token', token);
|
|
379
|
+
url.searchParams.set('lists', 'news,offers'); // written lists=news%2Coffers
|
|
380
|
+
|
|
381
|
+
listUnsubscribe({ url: url.href });
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Refusals
|
|
385
|
+
|
|
386
|
+
The URL and the address are checked when the headers are built, before
|
|
387
|
+
anything is sent, and the URL is written as a parser reads it —
|
|
388
|
+
`new URL(url).href`: the host lowered, `https:///host` or an empty `@`
|
|
389
|
+
dropped, and any character a query cannot hold as is percent-encoded
|
|
390
|
+
(a `'` in the query becomes `%27`). A host's escapes are decoded, so the URL
|
|
391
|
+
written is checked as well: `https://a%2Cb.test/` is refused. A `MailRefused` names the rule, never the value:
|
|
392
|
+
|
|
393
|
+
| Written | Answer |
|
|
394
|
+
| --- | --- |
|
|
395
|
+
| `url: 'https://example.com/u?token=…'`, `'https://example.com:8443/u?list=a%2Cb'` | accepted |
|
|
396
|
+
| `url: 'http://example.com/u'`, `'mailto:u@example.com'`, `'/unsubscribe'`, `''`, `'https://user:pass@example.com/u'`, `'https://exämple.com/u'` | `MailRefused`: `listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma` |
|
|
397
|
+
| a `url` holding a space, a tab, a line break, a character outside ASCII, `<`, `>`, a double quote, a backtick, a backslash, a brace, `|`, `^`, a raw `,`, or a `%` that starts no escape | `MailRefused`: the same message |
|
|
398
|
+
| `mailto: 'Unsub <u@example.com>'`, `'u@example.com, v@example.com'`, `'unsubscribe'`, `'mailto:u@example.com'`, `'u@example.com?subject=x'`, `'ü@example.com'` | `MailRefused`: `listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com` |
|
|
399
|
+
| `listUnsubscribe(null)` | `TypeError`: `listUnsubscribe: options must be an object, as { url }` |
|
|
400
|
+
| `url: new URL(…)` | a compile error; at run time `TypeError`: `listUnsubscribe: url must be a string` |
|
|
401
|
+
| `mailto: 42` | a compile error; at run time `TypeError`: `listUnsubscribe: mailto must be a string` |
|
|
402
|
+
|
|
403
|
+
A `TypeError` is a mistake in the code, not in the data: no request handler
|
|
404
|
+
should answer one. A `MailRefused` usually means a URL built from a value that
|
|
405
|
+
was not encoded — keep the call inside the `try` that handles `MailError`.
|
|
406
|
+
|
|
407
|
+
### A realistic case — a newsletter, one URL per subscriber
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
import { listUnsubscribe, MailError, type Mailer } from '@nxgt/mail';
|
|
411
|
+
import { createMailRenderer } from '@nxgt/mail/renderer';
|
|
412
|
+
|
|
413
|
+
// Yours: the subscriber store.
|
|
414
|
+
declare function subscribersOf(list: string): AsyncIterable<{ id: string; email: string; name: string; token: string }>;
|
|
415
|
+
|
|
416
|
+
const mails = createMailRenderer({ dir: 'dist' });
|
|
417
|
+
|
|
418
|
+
export async function sendIssue(mailer: Mailer, issue: string): Promise<{ sent: number; failed: string[] }> {
|
|
419
|
+
let sent = 0;
|
|
420
|
+
const failed: string[] = [];
|
|
421
|
+
for await (const subscriber of subscribersOf('news')) {
|
|
422
|
+
const unsubscribe = `https://example.com/unsubscribe?token=${encodeURIComponent(subscriber.token)}`;
|
|
423
|
+
try {
|
|
424
|
+
await mailer.send({
|
|
425
|
+
to: subscriber.email,
|
|
426
|
+
...mails.render('newsletter', { name: subscriber.name, unsubscribe }), // the link in the body
|
|
427
|
+
headers: { ...listUnsubscribe({ url: unsubscribe }) }, // the button in the mail client
|
|
428
|
+
idempotencyKey: `newsletter-${issue}/${subscriber.id}`,
|
|
429
|
+
});
|
|
430
|
+
sent += 1;
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (!(error instanceof MailError)) throw error;
|
|
433
|
+
failed.push(subscriber.id); // an id, never the address or the token
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { sent, failed };
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
228
440
|
## Attachments
|
|
229
441
|
|
|
230
442
|
An attachment is a file's **bytes**, its name and its type:
|
|
@@ -413,7 +625,7 @@ class MailRefused extends MailError {
|
|
|
413
625
|
| Code | Class | When | Sending it again |
|
|
414
626
|
| --- | --- | --- | --- |
|
|
415
627
|
| `MAIL_FAILED` | `MailFailure` | The transport could not hand the e-mail over: a refused connection, a timeout, a 5xx from the provider, an expired credential. The transport's error is the `cause`. **Nothing is known to have been sent**: after a timeout or a dropped connection the provider may have taken it all the same | May work later — with an [`idempotencyKey`](#idempotency--sending-once), without a second delivery where the transport deduplicates. Never report it as sent |
|
|
416
|
-
| `MAIL_REFUSED` | `MailRefused` | The e-mail itself was refused, before or by the transport: no recipient, something that is not an address, a line break in the subject or a header, a reserved header, an attachment that is not bytes or is badly named, a malformed idempotency key or one already used for a different message, or the provider answering that the message is malformed or too large | Fails again, unchanged |
|
|
628
|
+
| `MAIL_REFUSED` | `MailRefused` | The e-mail itself was refused, before or by the transport: no recipient, something that is not an address, a line break in the subject or a header, a reserved header, an attachment that is not bytes or is badly named, an unsubscribe URL that is not `https:` or holds a raw comma, a malformed idempotency key or one already used for a different message, or the provider answering that the message is malformed or too large | Fails again, unchanged |
|
|
417
629
|
|
|
418
630
|
`MailError` is **abstract**: catch it, test `instanceof MailError`, but
|
|
419
631
|
`new MailError(…)` does not compile — a bare one would pass a `code` check and
|
package/docs/guide/transports.md
CHANGED
|
@@ -224,9 +224,11 @@ choose to show it. `@nxgt/mail-resend` sends it as Resend's `Idempotency-Key`;
|
|
|
224
224
|
Document which one yours does, and for how long the provider remembers a key:
|
|
225
225
|
past that window, a retry delivers again.
|
|
226
226
|
|
|
227
|
-
The conformance suite
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
The conformance suite checks what both kinds share, in `send.idempotencyKey`:
|
|
228
|
+
a message with a key is delivered, and the key is in none of its recipients,
|
|
229
|
+
subject, HTML or text. The suite reads back no header and no provider request,
|
|
230
|
+
so where the key goes is yours to test, as is whether a retry is
|
|
231
|
+
deduplicated: the key reaches the provider where it should, and nowhere else.
|
|
230
232
|
|
|
231
233
|
```ts
|
|
232
234
|
import { expect, test } from 'bun:test';
|
|
@@ -298,6 +300,7 @@ and when `skip` names a case that does not exist
|
|
|
298
300
|
| `send.recipients` | every recipient is delivered to, written as a string or with a name | no |
|
|
299
301
|
| `send.hostileName` | a name holding `<…>`, a comma and quotes — `Ada <mallory@example.test>, "Eve" <eve@example.test>;` — reaches only its own address: quoting the name is the transport's job | no |
|
|
300
302
|
| `send.attachment` | an attachment — `sampleAttachment`, every byte from 0 to 255 named `reçu n° 42.pdf`, `application/pdf` — is delivered byte for byte, with its file name and its type (compared without case), and the parts beside it as sent | no |
|
|
303
|
+
| `send.idempotencyKey` | a message with an `idempotencyKey` is delivered — never refused for it — and the key appears in none of its recipients, its subject, its HTML or its text. A fresh key per run, so a harness that remembers keys still delivers | no |
|
|
301
304
|
| `send.refusesNoRecipient` | no recipient throws `MailRefused`, and nothing is delivered | no |
|
|
302
305
|
| `send.refusesLineBreakInSubject` | a line break in the subject throws `MailRefused`, and nothing is delivered | no |
|
|
303
306
|
| `send.refusesAddressHeader` | a `Bcc` among the custom headers throws `MailRefused` without the address in its message, and nothing is delivered: it would add a recipient no check saw | no |
|
|
@@ -442,7 +445,7 @@ export function fakeProvider() {
|
|
|
442
445
|
```
|
|
443
446
|
|
|
444
447
|
With the transport and the fake above, the example at the top of this page
|
|
445
|
-
passes all
|
|
448
|
+
passes all fourteen cases.
|
|
446
449
|
|
|
447
450
|
### Without faults
|
|
448
451
|
|
package/docs/roadmap.md
CHANGED
|
@@ -6,15 +6,13 @@ the only number.
|
|
|
6
6
|
|
|
7
7
|
## Now
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
under it is a `MailRefused`, a failed send leaves its key free, and
|
|
17
|
-
`clear()` forgets the keys. Built, not yet published.
|
|
9
|
+
- **The conformance suite checks the idempotency key** — a fourteenth case,
|
|
10
|
+
`send.idempotencyKey`: a message with a key is delivered, never refused
|
|
11
|
+
for it, and the key is written nowhere in the e-mail. Built, not yet
|
|
12
|
+
published.
|
|
13
|
+
- **`listUnsubscribe` writes the URL a parser reads** — `new URL(url).href`,
|
|
14
|
+
so the value written is the value checked, and a `%` that starts no escape
|
|
15
|
+
is refused. Built, not yet published.
|
|
18
16
|
|
|
19
17
|
## Next
|
|
20
18
|
|
|
@@ -71,6 +69,21 @@ Nothing yet.
|
|
|
71
69
|
The last ten, newest first, each with the version it came in. Everything
|
|
72
70
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
73
71
|
|
|
72
|
+
- **One-click unsubscribe, v0.4.0** — `listUnsubscribe({ url, mailto? })`
|
|
73
|
+
answers RFC 8058's `List-Unsubscribe` and `List-Unsubscribe-Post` headers,
|
|
74
|
+
to spread into a message's `headers`, so Gmail and Yahoo offer their
|
|
75
|
+
one-click unsubscribe. A `url` that is not an ASCII `https://` URL, or that
|
|
76
|
+
would break the header, and a `mailto` that is not a bare address are
|
|
77
|
+
refused with `MailRefused`, never quoting the value. No transport changes.
|
|
78
|
+
- **An idempotency key per send, v0.3.0** — `idempotencyKey` on a `MailMessage`
|
|
79
|
+
names the send, so sending it again — a retry after a timeout, a job run
|
|
80
|
+
twice — delivers it once where the transport can deduplicate; a transport
|
|
81
|
+
that cannot ignores it. `checkMessage` refuses a key that is not 1 to 256
|
|
82
|
+
visible ASCII characters, never quoting it. The memory mailer honours it as
|
|
83
|
+
Resend does: the same message under a key it already delivered answers
|
|
84
|
+
that delivery's `messageId` and delivers nothing more, a different message
|
|
85
|
+
under it is a `MailRefused`, a failed send leaves its key free, and
|
|
86
|
+
`clear()` forgets the keys.
|
|
74
87
|
- **Attachments, v0.2.0** — `attachments` on a `MailMessage`: each file's bytes as a
|
|
75
88
|
`Uint8Array`, its name and its type. Bytes only — no path, no URL, no
|
|
76
89
|
stream, so a transport never reads a file or fetches a URL for you; a large
|
|
@@ -118,15 +131,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
118
131
|
local server answering as Resend does — and throwing `@nxgt/mail`'s errors.
|
|
119
132
|
See [the SMTP roadmap](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-smtp/docs/roadmap.md)
|
|
120
133
|
and [the Resend roadmap](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-resend/docs/roadmap.md).
|
|
121
|
-
- **The Maizzle side, `@nxgt/mail-config`, `@nxgt/mail-i18n`, `@nxgt/mail-ui`
|
|
122
|
-
and `@nxgt/mail-presets` v0.1.0** — packages for a normal Maizzle 6 project:
|
|
123
|
-
`defineMailConfig({ plugins })` with every plugin's build hooks chained;
|
|
124
|
-
one template per e-mail, its text keys into ICU catalogues checked at build
|
|
125
|
-
time, one output per locale and the manifest this renderer reads; e-mail
|
|
126
|
-
components in the style of `@nxgt/material-vue`, with shared messages in
|
|
127
|
-
`en` and `fr`; and nine ready e-mails built with your own brand.
|
|
128
|
-
- **A starter that sends, with v0.1.0** — `examples/starter`'s `send.ts` renders its
|
|
129
|
-
e-mails in `en` and `fr` through `createMailRenderer<MailEmails>` and
|
|
130
|
-
hands them to `createMemoryMailer()`, run in CI:
|
|
131
|
-
[`examples/starter`](https://github.com/softistx/nxgt-mail/tree/develop/examples/starter).
|
|
132
|
-
In the repository; its README says how to start your own from npm.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -10,7 +10,8 @@ How the messages are shaped:
|
|
|
10
10
|
verification e-mail is a credential, and it never reaches a log through an
|
|
11
11
|
error.
|
|
12
12
|
- **Every message starts with the call you wrote**: `send: …`,
|
|
13
|
-
`
|
|
13
|
+
`listUnsubscribe: …`, `createMailRenderer: …`, `render: …`,
|
|
14
|
+
`pickLocale: …`, `describeMailer: …`.
|
|
14
15
|
A conformance case that fails starts with `conformance: …`.
|
|
15
16
|
- **A `TypeError` is a wiring mistake**: it comes from how the application
|
|
16
17
|
was put together — or, from `render`, from how the call was written —
|
|
@@ -69,6 +70,14 @@ How the messages are shaped:
|
|
|
69
70
|
- [An e-mail is delivered twice although it has an `idempotencyKey`](#an-e-mail-is-delivered-twice-although-it-has-an-idempotencykey)
|
|
70
71
|
- [`send: the memory mailer was told to fail this send`](#send-the-memory-mailer-was-told-to-fail-this-send)
|
|
71
72
|
|
|
73
|
+
**Unsubscribe**
|
|
74
|
+
- [`listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma`](#listunsubscribe-url-must-be-an-https-url-in-printable-ascii-without-credentials---quotes-or-a-raw-comma)
|
|
75
|
+
- [`listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com`](#listunsubscribe-mailto-must-be-a-bare-e-mail-address-as-unsubscribeexamplecom)
|
|
76
|
+
- [`listUnsubscribe: options must be an object, as { url }`](#listunsubscribe-options-must-be-an-object-as--url-)
|
|
77
|
+
- [`listUnsubscribe: url must be a string`](#listunsubscribe-url-must-be-a-string)
|
|
78
|
+
- [`listUnsubscribe: mailto must be a string`](#listunsubscribe-mailto-must-be-a-string)
|
|
79
|
+
- [Gmail shows no unsubscribe button](#gmail-shows-no-unsubscribe-button)
|
|
80
|
+
|
|
72
81
|
**Locale**
|
|
73
82
|
- [`pickLocale: supported must hold at least one locale`](#picklocale-supported-must-hold-at-least-one-locale)
|
|
74
83
|
- [`pickLocale: fallback must be one of supported`](#picklocale-fallback-must-be-one-of-supported)
|
|
@@ -329,7 +338,7 @@ that `generated/mail.ts` describes them, then fix the calls `tsc` still
|
|
|
329
338
|
reports:
|
|
330
339
|
|
|
331
340
|
```sh
|
|
332
|
-
bunx maizzle build # rewrites dist/ and generated/mail.ts
|
|
341
|
+
bunx maizzle build # rewrites dist/ and generated/mail.ts, both git-ignored
|
|
333
342
|
```
|
|
334
343
|
|
|
335
344
|
Never edit `generated/mail.ts` by hand to silence the error: the next build
|
|
@@ -341,14 +350,17 @@ rewrites it, and the deployed build is what `render` checks at run time.
|
|
|
341
350
|
fresh clone or a new project.
|
|
342
351
|
**Why:** `generated/mail.ts` is written by `@nxgt/mail-i18n` at the end of
|
|
343
352
|
`maizzle build`, in the Maizzle project, unless its `rendererTypes` option
|
|
344
|
-
moved it or turned it off (`false`). It is
|
|
345
|
-
|
|
346
|
-
**Fix:** build
|
|
347
|
-
|
|
353
|
+
moved it or turned it off (`false`). It is git-ignored, as `dist/` is, so it
|
|
354
|
+
is not there until the first build — or the import points at another folder.
|
|
355
|
+
**Fix:** build before type-checking, in the script CI runs too, and import it
|
|
356
|
+
from where `rendererTypes` writes it:
|
|
348
357
|
|
|
349
|
-
```
|
|
350
|
-
|
|
351
|
-
|
|
358
|
+
```json
|
|
359
|
+
{
|
|
360
|
+
"scripts": {
|
|
361
|
+
"typecheck": "maizzle build && tsc --noEmit"
|
|
362
|
+
}
|
|
363
|
+
}
|
|
352
364
|
```
|
|
353
365
|
|
|
354
366
|
To go without it, leave the type parameter out:
|
|
@@ -869,6 +881,166 @@ beforeEach(() => mailer.clear());
|
|
|
869
881
|
|
|
870
882
|
---
|
|
871
883
|
|
|
884
|
+
## Unsubscribe
|
|
885
|
+
|
|
886
|
+
`listUnsubscribe` checks its options **when it is called**, before any
|
|
887
|
+
`send`. A value that is text but not a usable URL or address is a
|
|
888
|
+
`MailRefused` (`code: 'MAIL_REFUSED'`), and it never quotes the value: the
|
|
889
|
+
URL usually carries a per-recipient token, and a token is a credential. A
|
|
890
|
+
value that is not text at all is a `TypeError`, a mistake in the code.
|
|
891
|
+
|
|
892
|
+
### `listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma`
|
|
893
|
+
|
|
894
|
+
A `MailRefused`, `code: 'MAIL_REFUSED'`.
|
|
895
|
+
|
|
896
|
+
**When:** `listUnsubscribe({ url })`, with a `url` that does not start with
|
|
897
|
+
`https://` (`http:`, `mailto:`, relative, empty, `HTTPS://` in capitals), that
|
|
898
|
+
carries a user or a password (`https://user:pass@…`), or that holds a space,
|
|
899
|
+
a line break, a character outside ASCII, `<`, `>`, a double quote, a backtick,
|
|
900
|
+
a backslash, a brace, `|`, `^`, a `,` as it is, or a `%` that starts no
|
|
901
|
+
escape (`%`, `%zz`) — or an escape in the host that the parser decodes into
|
|
902
|
+
one of those (`https://a%2Cb.test/`): typically a token or a list name pasted
|
|
903
|
+
into a template string without being encoded, or an `http:` URL from a
|
|
904
|
+
development configuration.
|
|
905
|
+
**Why:** RFC 8058 accepts only an `https:` URL for one-click unsubscribe, and
|
|
906
|
+
RFC 2369 an RFC 3986 URI — printable ASCII — between `<` and `>`. A
|
|
907
|
+
transport encodes a header holding anything else, and no client finds the URL
|
|
908
|
+
in it; a `>` would end the URL, and a raw comma is RFC 2369's separator
|
|
909
|
+
between two URLs, so a mail client would read the rest as a second one. A
|
|
910
|
+
user and a password would be read by every relay and recipient.
|
|
911
|
+
**Fix:** build the URL with `new URL()` and set each value with
|
|
912
|
+
`searchParams.set`, which percent-encodes it (`,` becomes `%2C`, a space
|
|
913
|
+
`+`), then pass `.href`:
|
|
914
|
+
|
|
915
|
+
```ts
|
|
916
|
+
import { listUnsubscribe } from '@nxgt/mail';
|
|
917
|
+
|
|
918
|
+
declare const token: string;
|
|
919
|
+
|
|
920
|
+
const url = new URL('https://example.com/unsubscribe');
|
|
921
|
+
url.searchParams.set('token', token);
|
|
922
|
+
|
|
923
|
+
const headers = listUnsubscribe({ url: url.href });
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
A value in the path is encoded with `encodeURIComponent` (`/u/${encodeURIComponent(list)}`).
|
|
927
|
+
In development, use an `https:` URL as well, or leave the headers out.
|
|
928
|
+
|
|
929
|
+
### `listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com`
|
|
930
|
+
|
|
931
|
+
A `MailRefused`, `code: 'MAIL_REFUSED'`.
|
|
932
|
+
|
|
933
|
+
**When:** `listUnsubscribe({ url, mailto })`, with a `mailto` that has a
|
|
934
|
+
display name (`Unsubscribe <unsubscribe@example.com>`), a `mailto:` prefix,
|
|
935
|
+
two addresses, no `@`, a domain without a dot, a character outside ASCII,
|
|
936
|
+
or `?`, `&`, `=`, `#`, `%` or a double quote (`u@example.com?subject=stop`).
|
|
937
|
+
**Why:** `mailto` is one mailbox, and `listUnsubscribe` writes the
|
|
938
|
+
`<mailto:…>` around it itself; a name, a prefix or a second address would
|
|
939
|
+
break the header or be read as something else — in a `mailto:`, `?` starts
|
|
940
|
+
header fields, and `?cc=` would add a recipient.
|
|
941
|
+
**Fix:** pass the address alone:
|
|
942
|
+
|
|
943
|
+
```ts
|
|
944
|
+
import { listUnsubscribe } from '@nxgt/mail';
|
|
945
|
+
|
|
946
|
+
declare const token: string;
|
|
947
|
+
|
|
948
|
+
const headers = listUnsubscribe({
|
|
949
|
+
url: `https://example.com/unsubscribe?token=${encodeURIComponent(token)}`,
|
|
950
|
+
mailto: 'unsubscribe@example.com', // ✗ 'mailto:unsubscribe@example.com'
|
|
951
|
+
});
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
### `listUnsubscribe: options must be an object, as { url }`
|
|
955
|
+
|
|
956
|
+
A `TypeError`.
|
|
957
|
+
|
|
958
|
+
**When:** `listUnsubscribe()` with no argument, or with the URL alone:
|
|
959
|
+
typically `listUnsubscribe(url)`.
|
|
960
|
+
**Why:** the options are one object, and `url` is required in it.
|
|
961
|
+
**Fix:** `listUnsubscribe({ url })`.
|
|
962
|
+
|
|
963
|
+
### `listUnsubscribe: url must be a string`
|
|
964
|
+
|
|
965
|
+
A `TypeError`.
|
|
966
|
+
|
|
967
|
+
**When:** `listUnsubscribe({ url })`, with a `url` that is a `URL` object,
|
|
968
|
+
`undefined` or anything else that is not text — from untyped code, since
|
|
969
|
+
TypeScript already refuses a `URL` object (`Type 'URL' is not assignable to
|
|
970
|
+
type 'string'`).
|
|
971
|
+
**Why:** the header holds text; the helper does not guess how to turn a
|
|
972
|
+
value into a URL.
|
|
973
|
+
**Fix:** pass the URL's text:
|
|
974
|
+
|
|
975
|
+
```ts
|
|
976
|
+
import { listUnsubscribe } from '@nxgt/mail';
|
|
977
|
+
|
|
978
|
+
const url = new URL('https://example.com/unsubscribe');
|
|
979
|
+
|
|
980
|
+
const headers = listUnsubscribe({ url: url.href }); // ✗ { url }
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
### `listUnsubscribe: mailto must be a string`
|
|
984
|
+
|
|
985
|
+
A `TypeError`.
|
|
986
|
+
|
|
987
|
+
**When:** `listUnsubscribe({ url, mailto })`, with a `mailto` that is set
|
|
988
|
+
but is not a string — `null`, or an `{ name, address }` object.
|
|
989
|
+
**Why:** `mailto` is one bare address, as text; leave it out for none.
|
|
990
|
+
**Fix:** `mailto: 'unsubscribe@example.com'`, or no `mailto` at all.
|
|
991
|
+
|
|
992
|
+
### Gmail shows no unsubscribe button
|
|
993
|
+
|
|
994
|
+
**When:** the message carries both headers from `listUnsubscribe`, yet
|
|
995
|
+
Gmail (or Yahoo) shows no "Unsubscribe" link next to the sender.
|
|
996
|
+
**Why:** the headers make the button possible; the mailbox provider decides
|
|
997
|
+
whether to show it. The usual causes:
|
|
998
|
+
|
|
999
|
+
- **The sender is not a bulk sender, or its reputation is low.** Gmail shows
|
|
1000
|
+
the button to senders it recognises as sending bulk mail with a good
|
|
1001
|
+
reputation; a new domain, or a handful of test messages, may never get it.
|
|
1002
|
+
- **The message is not DKIM-signed by the sending domain**, or the signature
|
|
1003
|
+
does not cover the two headers. RFC 8058 requires a valid DKIM signature
|
|
1004
|
+
whose `h=` includes `List-Unsubscribe` and `List-Unsubscribe-Post`, and
|
|
1005
|
+
Gmail and Yahoo also want it aligned with the `From` domain. Check the received message's original: the
|
|
1006
|
+
`DKIM-Signature` must have `d=` your domain and name both headers.
|
|
1007
|
+
- **The endpoint does not unsubscribe on the POST alone.** The client POSTs
|
|
1008
|
+
`List-Unsubscribe=One-Click` to the URL, with no cookie and no session. An
|
|
1009
|
+
answer that is a redirect, a login page, a confirmation page, or an error
|
|
1010
|
+
for a `POST` (a route that only answers `GET`) is a failed unsubscribe.
|
|
1011
|
+
- **The e-mail is transactional** — a sign-in code, a password reset, a
|
|
1012
|
+
receipt. Gmail does not offer to unsubscribe from those, and they should
|
|
1013
|
+
not carry the headers: nobody unsubscribes from their own password reset.
|
|
1014
|
+
|
|
1015
|
+
**Fix:** set the headers only on e-mails a recipient subscribed to, send
|
|
1016
|
+
from a domain that signs with DKIM, and make the URL unsubscribe on the
|
|
1017
|
+
`POST` itself:
|
|
1018
|
+
|
|
1019
|
+
```ts
|
|
1020
|
+
declare function unsubscribeByToken(token: string): Promise<void>; // yours
|
|
1021
|
+
|
|
1022
|
+
// POST https://example.com/unsubscribe?token=…, body List-Unsubscribe=One-Click
|
|
1023
|
+
async function unsubscribe(request: Request): Promise<Response> {
|
|
1024
|
+
const token = new URL(request.url).searchParams.get('token') ?? '';
|
|
1025
|
+
if (request.method === 'POST') {
|
|
1026
|
+
const form = await request.formData();
|
|
1027
|
+
if (form.get('List-Unsubscribe') !== 'One-Click') return new Response(null, { status: 400 });
|
|
1028
|
+
await unsubscribeByToken(token); // no login, no confirmation
|
|
1029
|
+
return new Response(null, { status: 200 }); // never a redirect
|
|
1030
|
+
}
|
|
1031
|
+
// GET: a person followed the link. Show a page that posts the same form.
|
|
1032
|
+
return new Response(
|
|
1033
|
+
'<form method="post"><input type="hidden" name="List-Unsubscribe" value="One-Click"><button>Unsubscribe</button></form>',
|
|
1034
|
+
{ headers: { 'content-type': 'text/html; charset=utf-8' } },
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
The full handler, with what the `GET` page may show, is in
|
|
1040
|
+
[the sending guide](guide/sending.md#one-click-unsubscribe).
|
|
1041
|
+
|
|
1042
|
+
---
|
|
1043
|
+
|
|
872
1044
|
## Locale
|
|
873
1045
|
|
|
874
1046
|
### `pickLocale: supported must hold at least one locale`
|
|
@@ -1632,6 +1804,8 @@ test title:
|
|
|
1632
1804
|
| `conformance: a Bcc header must throw MailRefused` | `send.refusesAddressHeader` | call `checkMessage`, from a version of `@nxgt/mail` that refuses reserved headers |
|
|
1633
1805
|
| `conformance: an attachment named with a path must throw MailRefused` | `send.refusesAttachmentPath` | call `checkMessage`, from a version of `@nxgt/mail` that checks attachments (0.2 on) |
|
|
1634
1806
|
| `conformance: the refusal message holds the refused value` | `send.refusesWithoutTheValue`, `send.refusesAddressHeader`, `send.refusesAttachmentPath` | name where the problem is, never the value |
|
|
1807
|
+
| `conformance: a send with an idempotency key did not answer SentMail` | `send.idempotencyKey` | accept the key, and resolve as for any message: a provider that cannot deduplicate is no reason to refuse |
|
|
1808
|
+
| `conformance: the idempotency key was written into the e-mail` | `send.idempotencyKey` | the recipients, subject, HTML or text read back hold the key: keep it out of what builds the e-mail, and send it as the provider's header (Resend's `Idempotency-Key`), or leave it out |
|
|
1635
1809
|
| `conformance: the message with an attachment was not delivered` | `send.attachment` | a message with attachments is a message: deliver it |
|
|
1636
1810
|
| `conformance: the attachment was not delivered with its file name` | `send.attachment` | pass the name as is; the name `reçu n° 42.pdf` needs RFC 2231 encoding in a raw header — nodemailer and a JSON API do it for you |
|
|
1637
1811
|
| `conformance: the attachment was not delivered with its content type` | `send.attachment` | pass `contentType` through; do not guess it from the name |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/mail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The run-time side of transactional e-mail: the renderer that fills a Maizzle build made with @nxgt/mail-i18n, the Mailer a transport implements, its errors, a memory transport and locale selection. No dependency.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|