@nxgt/mail 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,7 @@ import without extensions, so `nodenext` is not supported.
42
42
  | Import | What it holds |
43
43
  | --- | --- |
44
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
- | `@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` |
45
+ | `@nxgt/mail/renderer` | The renderer: `createMailRenderer`, `MailRenderer`, `MailRendererOptions`, `RenderOptions`, `MailVariables`, the types that type it with a build's `MailEmails` (`MailEmailsOf`, `AnyMailEmails`, `RenderArguments`), and `MANIFEST_FORMAT`, the newest manifest format it reads. 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
 
48
48
  ## Usage
@@ -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 the build, committed
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; commit it. With it, the compiler
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
@@ -87,6 +88,15 @@ variable missing or unknown, and a number for a URL variable, as
87
88
  [Rendering](docs/guide/rendering.md) for the options, typing the renderer,
88
89
  the locale chosen through `getLanguage`, and every error.
89
90
 
91
+ A renderer reads every manifest format up to its `MANIFEST_FORMAT`, within
92
+ 0.x: a build from any earlier `@nxgt/mail-i18n` 0.x keeps working with a newer
93
+ `@nxgt/mail`, so a package that ships a prebuilt format-1 build can peer
94
+ `@nxgt/mail` `>=0.1.0 <1` — the lower bound is the first `@nxgt/mail` that
95
+ reads the build's format. A build in a newer format fails at start-up with
96
+ `… is manifest format 2, newer than this @nxgt/mail reads (1) — upgrade
97
+ @nxgt/mail`. See
98
+ [Rendering — which builds it reads](docs/guide/rendering.md#which-builds-it-reads--manifest_format).
99
+
90
100
  ### Sending — the port and `MailMessage`
91
101
 
92
102
  A `MailMessage` is a rendered e-mail — `subject`, `html`, `text` — plus its
@@ -223,8 +233,11 @@ export async function sendNewsletter(
223
233
  ```
224
234
 
225
235
  The URL must start with `https://`, be printable ASCII, carry no user or
226
- password, and hold no `<`, `>`, double quote or raw comma (percent-encode
227
- it: `%2C`), and `mailto` must be a bare ASCII address; anything else is a
236
+ password, and hold no `<`, `>`, double quote, raw comma (percent-encode
237
+ it: `%2C`) or `%` that starts no escape, and `mailto` must be a bare ASCII
238
+ address. The URL is written as a parser reads it (`new URL(url).href`: the
239
+ host lowered, an empty `@` or extra slashes dropped), and checked again:
240
+ `https://a%2Cb.test/` writes a raw comma, so it is refused. Anything else is a
228
241
  `MailRefused` that never quotes the URL — its token is a credential. Your
229
242
  endpoint must unsubscribe on a `POST` with the body
230
243
  `List-Unsubscribe=One-Click`, with no login and no confirmation. It belongs on
@@ -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,EAqO1C,CAAC"}
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=D8FC228C191C6BF264756E2164756E21
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&amp;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": "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",
14
- "debugId": "D8FC228C191C6BF264756E2164756E21",
13
+ "mappings": "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",
14
+ "debugId": "5D29AA162B05B41464756E2164756E21",
15
15
  "names": []
16
16
  }
package/dist/index.js CHANGED
@@ -16,7 +16,9 @@ import {
16
16
  // src/unsubscribe.ts
17
17
  var URL_ALLOWED = /^https:\/\/[\x21-\x7E]+$/;
18
18
  var URL_REFUSED = /[<>,"`\\{}|^]/;
19
+ var BAD_ESCAPE = /%(?![0-9A-Fa-f]{2})/;
19
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);
20
22
  function listUnsubscribe(options) {
21
23
  if (typeof options !== "object" || options === null) {
22
24
  throw new TypeError("listUnsubscribe: options must be an object, as { url }");
@@ -27,7 +29,8 @@ function listUnsubscribe(options) {
27
29
  if (options.mailto !== undefined && typeof options.mailto !== "string") {
28
30
  throw new TypeError("listUnsubscribe: mailto must be a string");
29
31
  }
30
- if (!URL_ALLOWED.test(options.url) || URL_REFUSED.test(options.url) || !URL.canParse(options.url) || new URL(options.url).username !== "" || new URL(options.url).password !== "") {
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)) {
31
34
  throw new MailRefused2("listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma");
32
35
  }
33
36
  if (options.mailto !== undefined && !MAILTO.test(options.mailto)) {
@@ -35,7 +38,7 @@ function listUnsubscribe(options) {
35
38
  }
36
39
  const mailto = options.mailto === undefined ? "" : `, <mailto:${options.mailto}>`;
37
40
  return {
38
- "List-Unsubscribe": `<${options.url}>${mailto}`,
41
+ "List-Unsubscribe": `<${parsed.href}>${mailto}`,
39
42
  "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
40
43
  };
41
44
  }
@@ -52,5 +55,5 @@ export {
52
55
  recipientsOf2 as recipientsOf
53
56
  };
54
57
 
55
- //# debugId=AB5848B04CC61C7064756E2164756E21
58
+ //# debugId=467299297E6B375F64756E2164756E21
56
59
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/unsubscribe.ts"],
4
4
  "sourcesContent": [
5
- "import { MailRefused } from './errors';\n\n/** Where a recipient unsubscribes: the one-click URL, and an address as well. */\nexport interface ListUnsubscribeOptions {\n\t/**\n\t * The `https:` URL a mail client POSTs `List-Unsubscribe=One-Click` to —\n\t * one per recipient, carrying what identifies them, as\n\t * `https://example.com/unsubscribe?token=…`. It unsubscribes on that POST\n\t * alone: no login, no confirmation page, no redirect.\n\t */\n\treadonly url: string;\n\t/** An address that unsubscribes whoever writes to it, for clients that only send mail. */\n\treadonly mailto?: string;\n}\n\n/**\n * The two headers of RFC 8058's one-click unsubscribe. A type, not an\n * interface: an interface has no index signature, and would not go into\n * `headers` as it is.\n */\nexport type ListUnsubscribeHeaders = {\n\treadonly 'List-Unsubscribe': string;\n\treadonly 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click';\n};\n\n// RFC 2369 wants an RFC 3986 URI inside `<…>`: printable ASCII only — a\n// transport would encode a header holding anything else, and no client\n// would find the URL in it — and none of what ends the URL (`<`, `>`), what\n// RFC 2369 reads as the next one (`,`), or what no URI holds as is (a double\n// quote, a backtick, a backslash, braces, `|`, `^`). Percent-encode it.\nconst URL_ALLOWED = /^https:\\/\\/[\\x21-\\x7E]+$/;\nconst URL_REFUSED = /[<>,\"`\\\\{}|^]/;\n// RFC 6068 reads `?`, `&`, `=`, `#` and `%` inside a mailto: as structure — a\n// subject, a second recipient — so the address is plain ASCII without them.\nconst MAILTO = /^[A-Za-z0-9._~!$'*+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)+$/;\n\n/**\n * The headers that give an e-mail Gmail's and Yahoo's one-click unsubscribe\n * (RFC 8058, with RFC 2369's `List-Unsubscribe`), to spread into a message's\n * `headers`:\n *\n * ```ts\n * await mailer.send({\n * ...rendered,\n * to: user.email,\n * headers: listUnsubscribe({ url: `https://example.com/unsubscribe?token=${token}` }),\n * });\n * ```\n *\n * Refuses, with a {@link MailRefused} that never quotes the value, a `url`\n * that is not `https://` — RFC 8058 requires it — or is not printable ASCII,\n * carries a user or a password, or holds `<`, `>`, a double quote or a raw `,`; and\n * a `mailto` that is not a bare ASCII address. The URL is\n * often built from a token, and a token is a credential: the message names\n * the rule, not the link.\n */\nexport function listUnsubscribe(\n\toptions: ListUnsubscribeOptions,\n): ListUnsubscribeHeaders {\n\tif (typeof options !== 'object' || options === null) {\n\t\tthrow new TypeError(\n\t\t\t'listUnsubscribe: options must be an object, as { url }',\n\t\t);\n\t}\n\tif (typeof options.url !== 'string') {\n\t\tthrow new TypeError('listUnsubscribe: url must be a string');\n\t}\n\tif (options.mailto !== undefined && typeof options.mailto !== 'string') {\n\t\tthrow new TypeError('listUnsubscribe: mailto must be a string');\n\t}\n\tif (\n\t\t!URL_ALLOWED.test(options.url) ||\n\t\tURL_REFUSED.test(options.url) ||\n\t\t!URL.canParse(options.url) ||\n\t\t// A user and a password in a header every relay and recipient reads.\n\t\tnew URL(options.url).username !== '' ||\n\t\tnew URL(options.url).password !== ''\n\t) {\n\t\tthrow new MailRefused(\n\t\t\t'listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma',\n\t\t);\n\t}\n\tif (options.mailto !== undefined && !MAILTO.test(options.mailto)) {\n\t\tthrow new MailRefused(\n\t\t\t'listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com',\n\t\t);\n\t}\n\tconst mailto =\n\t\toptions.mailto === undefined ? '' : `, <mailto:${options.mailto}>`;\n\treturn {\n\t\t'List-Unsubscribe': `<${options.url}>${mailto}`,\n\t\t'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n\t};\n}\n"
5
+ "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"
6
6
  ],
7
- "mappings": ";;;;;;;;;;;;;;;;AA8BA,IAAM,cAAc;AACpB,IAAM,cAAc;AAGpB,IAAM,SAAS;AAsBR,SAAS,eAAe,CAC9B,SACyB;AAAA,EACzB,IAAI,OAAO,YAAY,YAAY,YAAY,MAAM;AAAA,IACpD,MAAM,IAAI,UACT,wDACD;AAAA,EACD;AAAA,EACA,IAAI,OAAO,QAAQ,QAAQ,UAAU;AAAA,IACpC,MAAM,IAAI,UAAU,uCAAuC;AAAA,EAC5D;AAAA,EACA,IAAI,QAAQ,WAAW,aAAa,OAAO,QAAQ,WAAW,UAAU;AAAA,IACvE,MAAM,IAAI,UAAU,0CAA0C;AAAA,EAC/D;AAAA,EACA,IACC,CAAC,YAAY,KAAK,QAAQ,GAAG,KAC7B,YAAY,KAAK,QAAQ,GAAG,KAC5B,CAAC,IAAI,SAAS,QAAQ,GAAG,KAEzB,IAAI,IAAI,QAAQ,GAAG,EAAE,aAAa,MAClC,IAAI,IAAI,QAAQ,GAAG,EAAE,aAAa,IACjC;AAAA,IACD,MAAM,IAAI,aACT,mHACD;AAAA,EACD;AAAA,EACA,IAAI,QAAQ,WAAW,aAAa,CAAC,OAAO,KAAK,QAAQ,MAAM,GAAG;AAAA,IACjE,MAAM,IAAI,aACT,mFACD;AAAA,EACD;AAAA,EACA,MAAM,SACL,QAAQ,WAAW,YAAY,KAAK,aAAa,QAAQ;AAAA,EAC1D,OAAO;AAAA,IACN,oBAAoB,IAAI,QAAQ,OAAO;AAAA,IACvC,yBAAyB;AAAA,EAC1B;AAAA;",
8
- "debugId": "AB5848B04CC61C7064756E2164756E21",
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",
9
9
  "names": []
10
10
  }
@@ -5,6 +5,14 @@
5
5
  */
6
6
  import { type WantedLocales } from './locale';
7
7
  import type { Rendered } from './types';
8
+ /**
9
+ * The newest manifest format this renderer reads. It reads every format up
10
+ * to this one, within 0.x: a build from any earlier `@nxgt/mail-i18n` 0.x
11
+ * keeps working. A manifest without `formatVersion` is format 1, as
12
+ * `@nxgt/mail-i18n` 0.1 and 0.2 wrote it. Copied in
13
+ * packages/mail-i18n/src/manifest.ts: change both.
14
+ */
15
+ export declare const MANIFEST_FORMAT = 1;
8
16
  /**
9
17
  * A value only known at send time. A number is written as `String(n)`; any
10
18
  * other type is refused, so an object never renders as `[object Object]`.
@@ -1 +1 @@
1
- {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,OAAO,EAAc,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAC1D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAKxC;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC;AAEtE;;;GAGG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,IAAI;IAAE,QAAQ,EAAE,CAAC,IAAI,MAAM,CAAC,GAAG,aAAa;CAAE,CAAC;AAEzE,6EAA6E;AAC7E,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC;AAEpE;;;;;GAKG;AACH,MAAM,MAAM,eAAe,CAAC,CAAC,IAC5B,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,SAAS,CAAC,GACtC,CAAC,SAAS,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,GACxC,CAAC,SAAS,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,CAAC;AAE5C,MAAM,WAAW,mBAAmB;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,aAAa,CAAC;IAC3C,oEAAoE;IACpE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,MAAM,WAAW,aAAa;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,YAAY,CAAC,CAAC,CAAC,GAAG,aAAa;IACtE,iFAAiF;IACjF,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC,EAAE,CAAC;IAC/C,gCAAgC;IAChC,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC;;;;;OAKG;IACH,MAAM,CAAC,CAAC,SAAS,MAAM,CAAC,GAAG,MAAM,EAChC,KAAK,EAAE,CAAC,EACR,GAAG,IAAI,EAAE,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAC5B,QAAQ,CAAC;CACZ;AA8ND;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,YAAY,CAAC,CAAC,CAAC,GAAG,aAAa,EAC3E,OAAO,EAAE,mBAAmB,GAC1B,YAAY,CAAC,CAAC,CAAC,CAmDjB"}
1
+ {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,OAAO,EAAc,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAC1D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAKxC;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,IAAI,CAAC;AAEjC;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC;AAEtE;;;GAGG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,IAAI;IAAE,QAAQ,EAAE,CAAC,IAAI,MAAM,CAAC,GAAG,aAAa;CAAE,CAAC;AAEzE,6EAA6E;AAC7E,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC;AAEpE;;;;;GAKG;AACH,MAAM,MAAM,eAAe,CAAC,CAAC,IAC5B,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,SAAS,CAAC,GACtC,CAAC,SAAS,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,GACxC,CAAC,SAAS,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,CAAC;AAE5C,MAAM,WAAW,mBAAmB;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,aAAa,CAAC;IAC3C,oEAAoE;IACpE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,MAAM,WAAW,aAAa;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,YAAY,CAAC,CAAC,CAAC,GAAG,aAAa;IACtE,iFAAiF;IACjF,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC,EAAE,CAAC;IAC/C,gCAAgC;IAChC,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC;;;;;OAKG;IACH,MAAM,CAAC,CAAC,SAAS,MAAM,CAAC,GAAG,MAAM,EAChC,KAAK,EAAE,CAAC,EACR,GAAG,IAAI,EAAE,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAC5B,QAAQ,CAAC;CACZ;AAgPD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,YAAY,CAAC,CAAC,CAAC,GAAG,aAAa,EAC3E,OAAO,EAAE,mBAAmB,GAC1B,YAAY,CAAC,CAAC,CAAC,CAmDjB"}
package/dist/renderer.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import { readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  var MANIFEST_FILE = "mail-manifest.json";
12
+ var MANIFEST_FORMAT = 1;
12
13
  var PLACEHOLDER = /\{\{\s*([a-z][a-zA-Z0-9]*)\s*\}\}/g;
13
14
  var LINE_BREAKS = /[\r\n\v\f\u0085\u2028\u2029]+/g;
14
15
  var SAFE_URL = /^(?:https?:\/\/|mailto:)/i;
@@ -57,8 +58,18 @@ function readManifest(dir) {
57
58
  }
58
59
  throw error;
59
60
  }
60
- if (!isObject(manifest) || !isStringList(manifest.locales) || manifest.locales.length === 0 || typeof manifest.fallbackLocale !== "string" || !isObject(manifest.emails)) {
61
- throw new Error(`createMailRenderer: ${file} is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin`);
61
+ const notManifest = () => new Error(`createMailRenderer: ${file} is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin`);
62
+ if (!isObject(manifest))
63
+ throw notManifest();
64
+ const format = "formatVersion" in manifest ? manifest.formatVersion : 1;
65
+ if (typeof format !== "number" || !Number.isSafeInteger(format) || format < 1) {
66
+ throw notManifest();
67
+ }
68
+ if (format > MANIFEST_FORMAT) {
69
+ throw new Error(`createMailRenderer: ${file} is manifest format ${format}, newer than this @nxgt/mail reads (${MANIFEST_FORMAT}) — upgrade @nxgt/mail`);
70
+ }
71
+ if (!isStringList(manifest.locales) || manifest.locales.length === 0 || typeof manifest.fallbackLocale !== "string" || !isObject(manifest.emails)) {
72
+ throw notManifest();
62
73
  }
63
74
  return {
64
75
  locales: manifest.locales,
@@ -67,7 +78,7 @@ function readManifest(dir) {
67
78
  };
68
79
  }
69
80
  function readEmail(dir, name, entry, locales) {
70
- const broken = () => new Error(`createMailRenderer: ${MANIFEST_FILE} describes ${name} in a shape this version does not read — rebuild with the same version of @nxgt/mail-i18n`);
81
+ const broken = () => new Error(`createMailRenderer: ${MANIFEST_FILE} describes ${name} in a shape its format does not have — it was changed after the build; run maizzle build again`);
71
82
  if (!isObject(entry) || !isStringList(entry.variables) || !isStringList(entry.urlVariables) || !isObject(entry.subject) || !isObject(entry.files)) {
72
83
  throw broken();
73
84
  }
@@ -162,8 +173,9 @@ function createMailRenderer(options) {
162
173
  return renderer;
163
174
  }
164
175
  export {
176
+ MANIFEST_FORMAT,
165
177
  createMailRenderer
166
178
  };
167
179
 
168
- //# debugId=74229904C7DC09F264756E2164756E21
180
+ //# debugId=3F107BDB6620800264756E2164756E21
169
181
  //# sourceMappingURL=renderer.js.map
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/renderer.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * `@nxgt/mail/renderer` — fills the files `maizzle build` wrote with\n * `@nxgt/mail-i18n`. Its own entry because it reads them with `node:fs`: the\n * main entry, which every transport imports, stays free of Node built-ins.\n */\n\nimport { readFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { MailRefused } from './errors';\nimport { pickLocale, type WantedLocales } from './locale';\nimport type { Rendered } from './types';\n\n/** The manifest's name in the build's output folder, as `@nxgt/mail-i18n` writes it. */\nconst MANIFEST_FILE = 'mail-manifest.json';\n\n/**\n * A value only known at send time. A number is written as `String(n)`; any\n * other type is refused, so an object never renders as `[object Object]`.\n */\nexport type MailVariables = Readonly<Record<string, string | number>>;\n\n/**\n * The e-mails of a build, each with the variables it takes: the `MailEmails`\n * that `@nxgt/mail-i18n` writes in `generated/mail.ts`.\n */\nexport type MailEmailsOf<E> = { readonly [K in keyof E]: MailVariables };\n\n/** What any build takes, when the renderer is not given its `MailEmails`. */\nexport type AnyMailEmails = Readonly<Record<string, MailVariables>>;\n\n/**\n * `render`'s arguments after the e-mail's name: its variables, which may be\n * left out when it takes none, and the options. An e-mail without variables\n * is `Readonly<Record<string, never>>`, as `@nxgt/mail-i18n`'s\n * `rendererTypes()` writes it: change both together.\n */\nexport type RenderArguments<V> =\n\tReadonly<Record<string, never>> extends V\n\t\t? [variables?: V, options?: RenderOptions]\n\t\t: [variables: V, options?: RenderOptions];\n\nexport interface MailRendererOptions {\n\t/** The build's output folder — where `mail-manifest.json` is — as `dist`. */\n\treadonly dir: string;\n\t/**\n\t * The locales wanted, most wanted first, asked at each render — as\n\t * `@nxgt/i18n`'s language provider: `() => user.locale`, or a Hono\n\t * handler's `() => c.get('language')`. Picked with `pickLocale`. Default:\n\t * none, so the fallback locale.\n\t */\n\treadonly getLanguage?: () => WantedLocales;\n\t/** The locale when none wanted is built. Default the manifest's. */\n\treadonly fallbackLocale?: string;\n}\n\nexport interface RenderOptions {\n\t/** Render in this locale, one the build wrote, rather than asking `getLanguage`. */\n\treadonly locale?: string;\n}\n\n/**\n * Given the build's `MailEmails`, an unknown e-mail, a missing or unknown\n * variable, or a number for a URL is a compile error; without it, any name\n * and any variables compile, and the same mistakes throw.\n */\nexport interface MailRenderer<E extends MailEmailsOf<E> = AnyMailEmails> {\n\t/** The e-mails of the build, sorted, as `['reset-password', 'verify-email']`. */\n\treadonly emails: readonly (keyof E & string)[];\n\t/** The locales of the build. */\n\treadonly locales: readonly string[];\n\t/**\n\t * `email` in the wanted locale, every `{{ variable }}` filled: escaped in\n\t * `html`, as is in `text` and the subject. A missing or unknown variable,\n\t * an unknown e-mail or locale **throws** an `Error`; a URL variable that\n\t * is not an `http:`, `https:` or `mailto:` URL throws `MailRefused`.\n\t */\n\trender<N extends keyof E & string>(\n\t\temail: N,\n\t\t...rest: RenderArguments<E[N]>\n\t): Rendered;\n}\n\n/** One e-mail in one locale, read. */\ninterface Parts {\n\treadonly subject: string;\n\treadonly html: string;\n\treadonly text: string;\n}\n\ninterface Email {\n\treadonly variables: ReadonlySet<string>;\n\treadonly urlVariables: ReadonlySet<string>;\n\treadonly locales: ReadonlyMap<string, Parts>;\n}\n\n/**\n * `{{ name }}` — what `@nxgt/mail-i18n`'s `placeholder('name')` writes. A\n * copy of `PLACEHOLDER` in `packages/mail-i18n/src/manifest.ts`, as is the\n * manifest's shape below: change both, or a declared variable goes unfilled.\n */\nconst PLACEHOLDER = /\\{\\{\\s*([a-z][a-zA-Z0-9]*)\\s*\\}\\}/g;\n\n/** A line break a header would split on: each run becomes one space. */\nconst LINE_BREAKS = /[\\r\\n\\v\\f\\u0085\\u2028\\u2029]+/g;\n\n/** What a URL variable may hold: a scheme a mail client cannot run. */\nconst SAFE_URL = /^(?:https?:\\/\\/|mailto:)/i;\n\n/** Whitespace, a control, a quote or a bracket: what no URL holds as is. */\nconst hasUnsafeUrlChar = (value: string) =>\n\t[...value].some((char) => {\n\t\tconst code = char.codePointAt(0) ?? 0;\n\t\treturn code < 0x21 || code === 0x7f || /[\\s\"'<>`]/.test(char);\n\t});\n\nconst HTML_ESCAPES: Readonly<Record<string, string>> = {\n\t'&': '&amp;',\n\t'<': '&lt;',\n\t'>': '&gt;',\n\t'\"': '&quot;',\n\t\"'\": '&#39;',\n};\n\nconst escapeHtml = (value: string) =>\n\tvalue.replace(/[&<>\"']/g, (char) => HTML_ESCAPES[char] ?? char);\n\nconst isObject = (value: unknown): value is Record<string, unknown> =>\n\ttypeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst isStringList = (value: unknown): value is string[] =>\n\tArray.isArray(value) && value.every((item) => typeof item === 'string');\n\nfunction checkOptions(options: MailRendererOptions): void {\n\tif (!isObject(options)) {\n\t\tthrow new TypeError(\n\t\t\t\"createMailRenderer: options must be an object, as { dir: 'dist' }\",\n\t\t);\n\t}\n\tif (typeof options.dir !== 'string' || options.dir.trim() === '') {\n\t\tthrow new TypeError(\n\t\t\t'createMailRenderer: dir must be the folder maizzle build wrote, as dist',\n\t\t);\n\t}\n\tif (\n\t\toptions.getLanguage !== undefined &&\n\t\ttypeof options.getLanguage !== 'function'\n\t) {\n\t\tthrow new TypeError(\n\t\t\t'createMailRenderer: getLanguage must be a function that answers the wanted locales, as () => user.locale',\n\t\t);\n\t}\n}\n\n/** Reads `dir/path`; a missing file **throws**, naming it. */\nfunction readBuilt(dir: string, path: string): string {\n\ttry {\n\t\treturn readFileSync(join(dir, path), 'utf8');\n\t} catch (error) {\n\t\tthrow new Error(\n\t\t\t`createMailRenderer: ${join(dir, path)} cannot be read — run maizzle build, and deploy its output folder`,\n\t\t\t{ cause: error },\n\t\t);\n\t}\n}\n\n/** The manifest, its shape checked: a broken one **throws**. */\nfunction readManifest(dir: string) {\n\tconst file = join(dir, MANIFEST_FILE);\n\tlet manifest: unknown;\n\ttry {\n\t\tmanifest = JSON.parse(readBuilt(dir, MANIFEST_FILE));\n\t} catch (error) {\n\t\tif (error instanceof SyntaxError) {\n\t\t\tthrow new Error(`createMailRenderer: ${file} is not valid JSON`, {\n\t\t\t\tcause: error,\n\t\t\t});\n\t\t}\n\t\tthrow error;\n\t}\n\tif (\n\t\t!isObject(manifest) ||\n\t\t!isStringList(manifest.locales) ||\n\t\tmanifest.locales.length === 0 ||\n\t\ttypeof manifest.fallbackLocale !== 'string' ||\n\t\t!isObject(manifest.emails)\n\t) {\n\t\tthrow new Error(\n\t\t\t`createMailRenderer: ${file} is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin`,\n\t\t);\n\t}\n\treturn {\n\t\tlocales: manifest.locales,\n\t\tfallbackLocale: manifest.fallbackLocale,\n\t\temails: manifest.emails,\n\t};\n}\n\n/** One e-mail of the manifest, with its files read in every locale. */\nfunction readEmail(\n\tdir: string,\n\tname: string,\n\tentry: unknown,\n\tlocales: readonly string[],\n): Email {\n\tconst broken = () =>\n\t\tnew Error(\n\t\t\t`createMailRenderer: ${MANIFEST_FILE} describes ${name} in a shape this version does not read — rebuild with the same version of @nxgt/mail-i18n`,\n\t\t);\n\tif (\n\t\t!isObject(entry) ||\n\t\t!isStringList(entry.variables) ||\n\t\t!isStringList(entry.urlVariables) ||\n\t\t!isObject(entry.subject) ||\n\t\t!isObject(entry.files)\n\t) {\n\t\tthrow broken();\n\t}\n\tconst parts = new Map<string, Parts>();\n\tfor (const locale of locales) {\n\t\tconst subject = entry.subject[locale];\n\t\tconst files = entry.files[locale];\n\t\tif (\n\t\t\ttypeof subject !== 'string' ||\n\t\t\t!isObject(files) ||\n\t\t\ttypeof files.html !== 'string'\n\t\t) {\n\t\t\tthrow broken();\n\t\t}\n\t\tif (typeof files.text !== 'string') {\n\t\t\tthrow new Error(\n\t\t\t\t`createMailRenderer: ${name} has no text part in ${locale} — keep Maizzle's plaintext on, as @nxgt/mail-config sets it`,\n\t\t\t);\n\t\t}\n\t\tparts.set(locale, {\n\t\t\tsubject,\n\t\t\thtml: readBuilt(dir, files.html),\n\t\t\ttext: readBuilt(dir, files.text),\n\t\t});\n\t}\n\treturn {\n\t\tvariables: new Set(entry.variables),\n\t\turlVariables: new Set(entry.urlVariables),\n\t\tlocales: parts,\n\t};\n}\n\n/** Each variable of `email` as a string, checked against what it declares. */\nfunction checkVariables(\n\tname: string,\n\temail: Email,\n\tvariables: unknown,\n): Map<string, string> {\n\tif (!isObject(variables)) {\n\t\tthrow new TypeError(\n\t\t\t`render: the variables of ${name} must be an object, as { name: 'Ada' }`,\n\t\t);\n\t}\n\tconst values = new Map<string, string>();\n\tfor (const [key, value] of Object.entries(variables)) {\n\t\tif (!email.variables.has(key)) {\n\t\t\tthrow new Error(\n\t\t\t\t`render: ${name} has no variable ${key} — it takes ${[...email.variables].join(', ') || 'none'}`,\n\t\t\t);\n\t\t}\n\t\tif (typeof value === 'number' && Number.isFinite(value)) {\n\t\t\tvalues.set(key, String(value));\n\t\t} else if (typeof value === 'string') {\n\t\t\tvalues.set(key, value);\n\t\t} else {\n\t\t\tthrow new TypeError(\n\t\t\t\t`render: ${name}: ${key} must be a string or a finite number`,\n\t\t\t);\n\t\t}\n\t}\n\tfor (const key of email.variables) {\n\t\tconst value = values.get(key);\n\t\tif (value === undefined) {\n\t\t\tthrow new Error(`render: ${name} needs the variable ${key}`);\n\t\t}\n\t\tif (\n\t\t\temail.urlVariables.has(key) &&\n\t\t\t(!SAFE_URL.test(value) || hasUnsafeUrlChar(value) || !URL.canParse(value))\n\t\t) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t`render: ${name}: ${key} must be an http:, https: or mailto: URL`,\n\t\t\t);\n\t\t}\n\t}\n\treturn values;\n}\n\n/** `source` with each placeholder replaced, in one pass: a value is never read again. */\nconst fill = (\n\tsource: string,\n\tvalues: ReadonlyMap<string, string>,\n\twrite: (value: string) => string,\n) =>\n\tsource.replace(PLACEHOLDER, (mark, key: string) => {\n\t\tconst value = values.get(key);\n\t\treturn value === undefined ? mark : write(value);\n\t});\n\n/**\n * The run-time renderer: the files `maizzle build` wrote with\n * `@nxgt/mail-i18n`, filled with the values of one send.\n *\n * ```ts\n * import type { MailEmails } from './generated/mail';\n *\n * const mails = createMailRenderer<MailEmails>({ dir: 'dist', getLanguage: () => user.locale });\n * await mailer.send({ to: user.email, ...mails.render('verify-email', { name, link }) });\n * ```\n *\n * Reads the manifest and every file once, here: a missing or broken build\n * **throws** when the renderer is created, not at the first send. A wrong\n * option is a `TypeError`.\n */\nexport function createMailRenderer<E extends MailEmailsOf<E> = AnyMailEmails>(\n\toptions: MailRendererOptions,\n): MailRenderer<E> {\n\tcheckOptions(options);\n\tconst { dir } = options;\n\tconst manifest = readManifest(dir);\n\tconst fallback = options.fallbackLocale ?? manifest.fallbackLocale;\n\tif (!manifest.locales.includes(fallback)) {\n\t\tthrow new TypeError(\n\t\t\t`createMailRenderer: fallbackLocale must be one of the build's locales, ${manifest.locales.join(', ')}`,\n\t\t);\n\t}\n\tconst emails = new Map<string, Email>();\n\tfor (const [name, entry] of Object.entries(manifest.emails)) {\n\t\temails.set(name, readEmail(dir, name, entry, manifest.locales));\n\t}\n\tconst getLanguage = options.getLanguage;\n\n\tconst renderer: MailRenderer = Object.freeze({\n\t\temails: Object.freeze([...emails.keys()].sort()),\n\t\tlocales: Object.freeze([...manifest.locales]),\n\t\trender(\n\t\t\tname: string,\n\t\t\tvariables: MailVariables = {},\n\t\t\trenderOptions: RenderOptions = {},\n\t\t): Rendered {\n\t\t\tconst email = emails.get(name);\n\t\t\tif (email === undefined) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`render: ${String(name)} is not an e-mail of the build — one of ${[...emails.keys()].sort().join(', ')}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst locale =\n\t\t\t\trenderOptions.locale ??\n\t\t\t\tpickLocale(getLanguage?.(), manifest.locales, fallback);\n\t\t\tconst parts = email.locales.get(locale);\n\t\t\tif (parts === undefined) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`render: the locale asked for is not one of the build's, ${manifest.locales.join(', ')}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst values = checkVariables(name, email, variables);\n\t\t\treturn {\n\t\t\t\tsubject: fill(parts.subject, values, (value) => value)\n\t\t\t\t\t.replace(LINE_BREAKS, ' ')\n\t\t\t\t\t.trim(),\n\t\t\t\thtml: fill(parts.html, values, escapeHtml),\n\t\t\t\ttext: fill(parts.text, values, (value) => value),\n\t\t\t};\n\t\t},\n\t});\n\t// The types only narrow what the same checks refuse at run time.\n\treturn renderer as unknown as MailRenderer<E>;\n}\n"
5
+ "/**\n * `@nxgt/mail/renderer` — fills the files `maizzle build` wrote with\n * `@nxgt/mail-i18n`. Its own entry because it reads them with `node:fs`: the\n * main entry, which every transport imports, stays free of Node built-ins.\n */\n\nimport { readFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { MailRefused } from './errors';\nimport { pickLocale, type WantedLocales } from './locale';\nimport type { Rendered } from './types';\n\n/** The manifest's name in the build's output folder, as `@nxgt/mail-i18n` writes it. */\nconst MANIFEST_FILE = 'mail-manifest.json';\n\n/**\n * The newest manifest format this renderer reads. It reads every format up\n * to this one, within 0.x: a build from any earlier `@nxgt/mail-i18n` 0.x\n * keeps working. A manifest without `formatVersion` is format 1, as\n * `@nxgt/mail-i18n` 0.1 and 0.2 wrote it. Copied in\n * packages/mail-i18n/src/manifest.ts: change both.\n */\nexport const MANIFEST_FORMAT = 1;\n\n/**\n * A value only known at send time. A number is written as `String(n)`; any\n * other type is refused, so an object never renders as `[object Object]`.\n */\nexport type MailVariables = Readonly<Record<string, string | number>>;\n\n/**\n * The e-mails of a build, each with the variables it takes: the `MailEmails`\n * that `@nxgt/mail-i18n` writes in `generated/mail.ts`.\n */\nexport type MailEmailsOf<E> = { readonly [K in keyof E]: MailVariables };\n\n/** What any build takes, when the renderer is not given its `MailEmails`. */\nexport type AnyMailEmails = Readonly<Record<string, MailVariables>>;\n\n/**\n * `render`'s arguments after the e-mail's name: its variables, which may be\n * left out when it takes none, and the options. An e-mail without variables\n * is `Readonly<Record<string, never>>`, as `@nxgt/mail-i18n`'s\n * `rendererTypes()` writes it: change both together.\n */\nexport type RenderArguments<V> =\n\tReadonly<Record<string, never>> extends V\n\t\t? [variables?: V, options?: RenderOptions]\n\t\t: [variables: V, options?: RenderOptions];\n\nexport interface MailRendererOptions {\n\t/** The build's output folder — where `mail-manifest.json` is — as `dist`. */\n\treadonly dir: string;\n\t/**\n\t * The locales wanted, most wanted first, asked at each render — as\n\t * `@nxgt/i18n`'s language provider: `() => user.locale`, or a Hono\n\t * handler's `() => c.get('language')`. Picked with `pickLocale`. Default:\n\t * none, so the fallback locale.\n\t */\n\treadonly getLanguage?: () => WantedLocales;\n\t/** The locale when none wanted is built. Default the manifest's. */\n\treadonly fallbackLocale?: string;\n}\n\nexport interface RenderOptions {\n\t/** Render in this locale, one the build wrote, rather than asking `getLanguage`. */\n\treadonly locale?: string;\n}\n\n/**\n * Given the build's `MailEmails`, an unknown e-mail, a missing or unknown\n * variable, or a number for a URL is a compile error; without it, any name\n * and any variables compile, and the same mistakes throw.\n */\nexport interface MailRenderer<E extends MailEmailsOf<E> = AnyMailEmails> {\n\t/** The e-mails of the build, sorted, as `['reset-password', 'verify-email']`. */\n\treadonly emails: readonly (keyof E & string)[];\n\t/** The locales of the build. */\n\treadonly locales: readonly string[];\n\t/**\n\t * `email` in the wanted locale, every `{{ variable }}` filled: escaped in\n\t * `html`, as is in `text` and the subject. A missing or unknown variable,\n\t * an unknown e-mail or locale **throws** an `Error`; a URL variable that\n\t * is not an `http:`, `https:` or `mailto:` URL throws `MailRefused`.\n\t */\n\trender<N extends keyof E & string>(\n\t\temail: N,\n\t\t...rest: RenderArguments<E[N]>\n\t): Rendered;\n}\n\n/** One e-mail in one locale, read. */\ninterface Parts {\n\treadonly subject: string;\n\treadonly html: string;\n\treadonly text: string;\n}\n\ninterface Email {\n\treadonly variables: ReadonlySet<string>;\n\treadonly urlVariables: ReadonlySet<string>;\n\treadonly locales: ReadonlyMap<string, Parts>;\n}\n\n/**\n * `{{ name }}` — what `@nxgt/mail-i18n`'s `placeholder('name')` writes. A\n * copy of `PLACEHOLDER` in `packages/mail-i18n/src/manifest.ts`, as is the\n * manifest's shape below: change both, or a declared variable goes unfilled.\n */\nconst PLACEHOLDER = /\\{\\{\\s*([a-z][a-zA-Z0-9]*)\\s*\\}\\}/g;\n\n/** A line break a header would split on: each run becomes one space. */\nconst LINE_BREAKS = /[\\r\\n\\v\\f\\u0085\\u2028\\u2029]+/g;\n\n/** What a URL variable may hold: a scheme a mail client cannot run. */\nconst SAFE_URL = /^(?:https?:\\/\\/|mailto:)/i;\n\n/** Whitespace, a control, a quote or a bracket: what no URL holds as is. */\nconst hasUnsafeUrlChar = (value: string) =>\n\t[...value].some((char) => {\n\t\tconst code = char.codePointAt(0) ?? 0;\n\t\treturn code < 0x21 || code === 0x7f || /[\\s\"'<>`]/.test(char);\n\t});\n\nconst HTML_ESCAPES: Readonly<Record<string, string>> = {\n\t'&': '&amp;',\n\t'<': '&lt;',\n\t'>': '&gt;',\n\t'\"': '&quot;',\n\t\"'\": '&#39;',\n};\n\nconst escapeHtml = (value: string) =>\n\tvalue.replace(/[&<>\"']/g, (char) => HTML_ESCAPES[char] ?? char);\n\nconst isObject = (value: unknown): value is Record<string, unknown> =>\n\ttypeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst isStringList = (value: unknown): value is string[] =>\n\tArray.isArray(value) && value.every((item) => typeof item === 'string');\n\nfunction checkOptions(options: MailRendererOptions): void {\n\tif (!isObject(options)) {\n\t\tthrow new TypeError(\n\t\t\t\"createMailRenderer: options must be an object, as { dir: 'dist' }\",\n\t\t);\n\t}\n\tif (typeof options.dir !== 'string' || options.dir.trim() === '') {\n\t\tthrow new TypeError(\n\t\t\t'createMailRenderer: dir must be the folder maizzle build wrote, as dist',\n\t\t);\n\t}\n\tif (\n\t\toptions.getLanguage !== undefined &&\n\t\ttypeof options.getLanguage !== 'function'\n\t) {\n\t\tthrow new TypeError(\n\t\t\t'createMailRenderer: getLanguage must be a function that answers the wanted locales, as () => user.locale',\n\t\t);\n\t}\n}\n\n/** Reads `dir/path`; a missing file **throws**, naming it. */\nfunction readBuilt(dir: string, path: string): string {\n\ttry {\n\t\treturn readFileSync(join(dir, path), 'utf8');\n\t} catch (error) {\n\t\tthrow new Error(\n\t\t\t`createMailRenderer: ${join(dir, path)} cannot be read — run maizzle build, and deploy its output folder`,\n\t\t\t{ cause: error },\n\t\t);\n\t}\n}\n\n/** The manifest, its shape checked: a broken one **throws**. */\nfunction readManifest(dir: string) {\n\tconst file = join(dir, MANIFEST_FILE);\n\tlet manifest: unknown;\n\ttry {\n\t\tmanifest = JSON.parse(readBuilt(dir, MANIFEST_FILE));\n\t} catch (error) {\n\t\tif (error instanceof SyntaxError) {\n\t\t\tthrow new Error(`createMailRenderer: ${file} is not valid JSON`, {\n\t\t\t\tcause: error,\n\t\t\t});\n\t\t}\n\t\tthrow error;\n\t}\n\tconst notManifest = () =>\n\t\tnew Error(\n\t\t\t`createMailRenderer: ${file} is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin`,\n\t\t);\n\tif (!isObject(manifest)) throw notManifest();\n\t// The format first, before any field it may change: a newer format is\n\t// refused as newer, whatever its shape. Absent: format 1, written before\n\t// the field was.\n\tconst format = 'formatVersion' in manifest ? manifest.formatVersion : 1;\n\tif (\n\t\ttypeof format !== 'number' ||\n\t\t!Number.isSafeInteger(format) ||\n\t\tformat < 1\n\t) {\n\t\tthrow notManifest();\n\t}\n\tif (format > MANIFEST_FORMAT) {\n\t\tthrow new Error(\n\t\t\t`createMailRenderer: ${file} is manifest format ${format}, newer than this @nxgt/mail reads (${MANIFEST_FORMAT}) — upgrade @nxgt/mail`,\n\t\t);\n\t}\n\tif (\n\t\t!isStringList(manifest.locales) ||\n\t\tmanifest.locales.length === 0 ||\n\t\ttypeof manifest.fallbackLocale !== 'string' ||\n\t\t!isObject(manifest.emails)\n\t) {\n\t\tthrow notManifest();\n\t}\n\treturn {\n\t\tlocales: manifest.locales,\n\t\tfallbackLocale: manifest.fallbackLocale,\n\t\temails: manifest.emails,\n\t};\n}\n\n/** One e-mail of the manifest, with its files read in every locale. */\nfunction readEmail(\n\tdir: string,\n\tname: string,\n\tentry: unknown,\n\tlocales: readonly string[],\n): Email {\n\tconst broken = () =>\n\t\tnew Error(\n\t\t\t`createMailRenderer: ${MANIFEST_FILE} describes ${name} in a shape its format does not have — it was changed after the build; run maizzle build again`,\n\t\t);\n\tif (\n\t\t!isObject(entry) ||\n\t\t!isStringList(entry.variables) ||\n\t\t!isStringList(entry.urlVariables) ||\n\t\t!isObject(entry.subject) ||\n\t\t!isObject(entry.files)\n\t) {\n\t\tthrow broken();\n\t}\n\tconst parts = new Map<string, Parts>();\n\tfor (const locale of locales) {\n\t\tconst subject = entry.subject[locale];\n\t\tconst files = entry.files[locale];\n\t\tif (\n\t\t\ttypeof subject !== 'string' ||\n\t\t\t!isObject(files) ||\n\t\t\ttypeof files.html !== 'string'\n\t\t) {\n\t\t\tthrow broken();\n\t\t}\n\t\tif (typeof files.text !== 'string') {\n\t\t\tthrow new Error(\n\t\t\t\t`createMailRenderer: ${name} has no text part in ${locale} — keep Maizzle's plaintext on, as @nxgt/mail-config sets it`,\n\t\t\t);\n\t\t}\n\t\tparts.set(locale, {\n\t\t\tsubject,\n\t\t\thtml: readBuilt(dir, files.html),\n\t\t\ttext: readBuilt(dir, files.text),\n\t\t});\n\t}\n\treturn {\n\t\tvariables: new Set(entry.variables),\n\t\turlVariables: new Set(entry.urlVariables),\n\t\tlocales: parts,\n\t};\n}\n\n/** Each variable of `email` as a string, checked against what it declares. */\nfunction checkVariables(\n\tname: string,\n\temail: Email,\n\tvariables: unknown,\n): Map<string, string> {\n\tif (!isObject(variables)) {\n\t\tthrow new TypeError(\n\t\t\t`render: the variables of ${name} must be an object, as { name: 'Ada' }`,\n\t\t);\n\t}\n\tconst values = new Map<string, string>();\n\tfor (const [key, value] of Object.entries(variables)) {\n\t\tif (!email.variables.has(key)) {\n\t\t\tthrow new Error(\n\t\t\t\t`render: ${name} has no variable ${key} — it takes ${[...email.variables].join(', ') || 'none'}`,\n\t\t\t);\n\t\t}\n\t\tif (typeof value === 'number' && Number.isFinite(value)) {\n\t\t\tvalues.set(key, String(value));\n\t\t} else if (typeof value === 'string') {\n\t\t\tvalues.set(key, value);\n\t\t} else {\n\t\t\tthrow new TypeError(\n\t\t\t\t`render: ${name}: ${key} must be a string or a finite number`,\n\t\t\t);\n\t\t}\n\t}\n\tfor (const key of email.variables) {\n\t\tconst value = values.get(key);\n\t\tif (value === undefined) {\n\t\t\tthrow new Error(`render: ${name} needs the variable ${key}`);\n\t\t}\n\t\tif (\n\t\t\temail.urlVariables.has(key) &&\n\t\t\t(!SAFE_URL.test(value) || hasUnsafeUrlChar(value) || !URL.canParse(value))\n\t\t) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t`render: ${name}: ${key} must be an http:, https: or mailto: URL`,\n\t\t\t);\n\t\t}\n\t}\n\treturn values;\n}\n\n/** `source` with each placeholder replaced, in one pass: a value is never read again. */\nconst fill = (\n\tsource: string,\n\tvalues: ReadonlyMap<string, string>,\n\twrite: (value: string) => string,\n) =>\n\tsource.replace(PLACEHOLDER, (mark, key: string) => {\n\t\tconst value = values.get(key);\n\t\treturn value === undefined ? mark : write(value);\n\t});\n\n/**\n * The run-time renderer: the files `maizzle build` wrote with\n * `@nxgt/mail-i18n`, filled with the values of one send.\n *\n * ```ts\n * import type { MailEmails } from './generated/mail';\n *\n * const mails = createMailRenderer<MailEmails>({ dir: 'dist', getLanguage: () => user.locale });\n * await mailer.send({ to: user.email, ...mails.render('verify-email', { name, link }) });\n * ```\n *\n * Reads the manifest and every file once, here: a missing or broken build\n * **throws** when the renderer is created, not at the first send. A wrong\n * option is a `TypeError`.\n */\nexport function createMailRenderer<E extends MailEmailsOf<E> = AnyMailEmails>(\n\toptions: MailRendererOptions,\n): MailRenderer<E> {\n\tcheckOptions(options);\n\tconst { dir } = options;\n\tconst manifest = readManifest(dir);\n\tconst fallback = options.fallbackLocale ?? manifest.fallbackLocale;\n\tif (!manifest.locales.includes(fallback)) {\n\t\tthrow new TypeError(\n\t\t\t`createMailRenderer: fallbackLocale must be one of the build's locales, ${manifest.locales.join(', ')}`,\n\t\t);\n\t}\n\tconst emails = new Map<string, Email>();\n\tfor (const [name, entry] of Object.entries(manifest.emails)) {\n\t\temails.set(name, readEmail(dir, name, entry, manifest.locales));\n\t}\n\tconst getLanguage = options.getLanguage;\n\n\tconst renderer: MailRenderer = Object.freeze({\n\t\temails: Object.freeze([...emails.keys()].sort()),\n\t\tlocales: Object.freeze([...manifest.locales]),\n\t\trender(\n\t\t\tname: string,\n\t\t\tvariables: MailVariables = {},\n\t\t\trenderOptions: RenderOptions = {},\n\t\t): Rendered {\n\t\t\tconst email = emails.get(name);\n\t\t\tif (email === undefined) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`render: ${String(name)} is not an e-mail of the build — one of ${[...emails.keys()].sort().join(', ')}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst locale =\n\t\t\t\trenderOptions.locale ??\n\t\t\t\tpickLocale(getLanguage?.(), manifest.locales, fallback);\n\t\t\tconst parts = email.locales.get(locale);\n\t\t\tif (parts === undefined) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`render: the locale asked for is not one of the build's, ${manifest.locales.join(', ')}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst values = checkVariables(name, email, variables);\n\t\t\treturn {\n\t\t\t\tsubject: fill(parts.subject, values, (value) => value)\n\t\t\t\t\t.replace(LINE_BREAKS, ' ')\n\t\t\t\t\t.trim(),\n\t\t\t\thtml: fill(parts.html, values, escapeHtml),\n\t\t\t\ttext: fill(parts.text, values, (value) => value),\n\t\t\t};\n\t\t},\n\t});\n\t// The types only narrow what the same checks refuse at run time.\n\treturn renderer as unknown as MailRenderer<E>;\n}\n"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "74229904C7DC09F264756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "3F107BDB6620800264756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -34,8 +34,10 @@ export type ListUnsubscribeHeaders = {
34
34
  *
35
35
  * Refuses, with a {@link MailRefused} that never quotes the value, a `url`
36
36
  * that is not `https://` — RFC 8058 requires it — or is not printable ASCII,
37
- * carries a user or a password, or holds `<`, `>`, a double quote or a raw `,`; and
38
- * a `mailto` that is not a bare ASCII address. The URL is
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
39
41
  * often built from a token, and a token is a credential: the message names
40
42
  * the rule, not the link.
41
43
  */
@@ -1 +1 @@
1
- {"version":3,"file":"unsubscribe.d.ts","sourceRoot":"","sources":["../src/unsubscribe.ts"],"names":[],"mappings":"AAEA,iFAAiF;AACjF,MAAM,WAAW,sBAAsB;IACtC;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,0FAA0F;IAC1F,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACpC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,uBAAuB,EAAE,4BAA4B,CAAC;CAC/D,CAAC;AAaF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAC9B,OAAO,EAAE,sBAAsB,GAC7B,sBAAsB,CAmCxB"}
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
@@ -7,7 +7,7 @@ mailer, transport, hand-over, refusal, failure — are defined once, in the
7
7
 
8
8
  | Page | Read it when |
9
9
  | --- | --- |
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 |
10
+ | [Rendering](guide/rendering.md) | You are turning a Maizzle build of `@nxgt/mail-i18n` into an e-mail with `createMailRenderer`: its options, which builds it reads (`MANIFEST_FORMAT`), typing it with the build's `MailEmails`, choosing the locale, escaping, URL variables, deploying the build, and every error |
11
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` |
@@ -49,6 +49,50 @@ Nothing is evaluated at send time: no template engine and no Maizzle run in
49
49
  your server. `render` replaces each `{{ name }}` the build left in place, and
50
50
  nothing else.
51
51
 
52
+ ### Which builds it reads — `MANIFEST_FORMAT`
53
+
54
+ The manifest carries its format, `formatVersion`, and the renderer exports the
55
+ newest format it reads:
56
+
57
+ ```ts
58
+ import { MANIFEST_FORMAT } from '@nxgt/mail/renderer';
59
+
60
+ MANIFEST_FORMAT; // 1 — the newest manifest format this @nxgt/mail reads
61
+ ```
62
+
63
+ ```ts
64
+ const MANIFEST_FORMAT = 1;
65
+ ```
66
+
67
+ The promise, within 0.x:
68
+
69
+ - **A renderer reads every format up to its own.** A build from any earlier
70
+ `@nxgt/mail-i18n` 0.x keeps working with a newer `@nxgt/mail`: a package
71
+ that ships a prebuilt format-1 build can peer `@nxgt/mail` `>=0.1.0 <1`.
72
+ The peer's lower bound is the first `@nxgt/mail` that reads the build's
73
+ format.
74
+ - **A manifest without `formatVersion` is format 1**, as `@nxgt/mail-i18n`
75
+ 0.1 and 0.2 wrote it.
76
+ - **The format changes only when the manifest's shape does.** A new
77
+ `@nxgt/mail-i18n` that writes the same shape writes the same format.
78
+
79
+ So upgrade `@nxgt/mail` no later than `@nxgt/mail-i18n`. From 0.5.1, a
80
+ renderer refuses a newer format when it starts, before reading any other
81
+ field. 0.1.0 to 0.5.0 know no format and read only format 1: a build in a
82
+ later format must peer at least the first `@nxgt/mail` that reads it.
83
+
84
+ | The manifest's `formatVersion` | At start-up |
85
+ | --- | --- |
86
+ | absent | Read as format 1 |
87
+ | `1` to `MANIFEST_FORMAT` | Read |
88
+ | an integer above `MANIFEST_FORMAT` | `Error`: `createMailRenderer: dist/mail-manifest.json is manifest format 2, newer than this @nxgt/mail reads (1) — upgrade @nxgt/mail` |
89
+ | anything else — `0`, `1.5`, `'1'`, `null` | `Error`: `createMailRenderer: dist/mail-manifest.json is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin` |
90
+
91
+ `@nxgt/mail` 0.5.0 and earlier do not export `MANIFEST_FORMAT` and ignore
92
+ `formatVersion`; they read format 1. A package that ships its build checks the
93
+ format it wrote against the renderers it supports when it builds: see
94
+ [the manifest guide — shipping a build in a package](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-i18n/docs/guide/manifest.md#shipping-a-build-in-a-package).
95
+
52
96
  ## `createMailRenderer`
53
97
 
54
98
  ```ts
@@ -168,8 +212,9 @@ mails.render('sign-in-code', { code: 123456 }, { locale: 'fr' });
168
212
 
169
213
  After each `maizzle build`, `@nxgt/mail-i18n` writes `generated/mail.ts` in
170
214
  the project: `MailEmails`, each e-mail of the build with the variables it
171
- takes. Commit it, so the code that sends type-checks without running a build,
172
- and pass it to `createMailRenderer`:
215
+ takes. Git-ignore it — each build rewrites it — and run `maizzle build`
216
+ before type-checking, as `"typecheck": "maizzle build && tsc --noEmit"`
217
+ does; then pass it to `createMailRenderer`:
173
218
 
174
219
  ```ts
175
220
  // generated/mail.ts — written by the build, never edited
@@ -251,7 +296,8 @@ so a helper written for any build takes a typed renderer.
251
296
 
252
297
  The manifest guide also shows
253
298
  [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 a project that does not commit `generated/mail.ts`.
299
+ for an application that only installs the built project, or is not
300
+ written in TypeScript.
255
301
 
256
302
  ## Choosing the locale
257
303
 
@@ -400,9 +446,10 @@ read. The paths are `dir` joined with the file, as you passed `dir`.
400
446
  | `createMailRenderer: getLanguage must be a function that answers the wanted locales, as () => user.locale` | `TypeError` | `getLanguage` given as a locale rather than a function |
401
447
  | `createMailRenderer: dist/mail-manifest.json cannot be read — run maizzle build, and deploy its output folder` | `Error` | No build at `dir`: not built, not deployed, or `dir` read from another working directory |
402
448
  | `createMailRenderer: dist/mail-manifest.json is not valid JSON` | `Error` | The manifest was cut or edited |
403
- | `createMailRenderer: dist/mail-manifest.json is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin` | `Error` | A JSON file without `locales`, `fallbackLocale` and `emails` |
449
+ | `createMailRenderer: dist/mail-manifest.json is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin` | `Error` | A JSON file without `locales`, `fallbackLocale` and `emails`, or with a `formatVersion` that is not a positive integer |
450
+ | `createMailRenderer: dist/mail-manifest.json is manifest format 2, newer than this @nxgt/mail reads (1) — upgrade @nxgt/mail` | `Error` | A build from a newer `@nxgt/mail-i18n`, whose manifest format this renderer predates — see [which builds it reads](#which-builds-it-reads--manifest_format) |
404
451
  | `createMailRenderer: fallbackLocale must be one of the build's locales, en, fr` | `TypeError` | `fallbackLocale` names a locale the build does not have |
405
- | `createMailRenderer: mail-manifest.json describes verify-email in a shape this version does not read — rebuild with the same version of @nxgt/mail-i18n` | `Error` | An entry of the manifest lacks a field, or a locale — usually a build made with another version |
452
+ | `createMailRenderer: mail-manifest.json describes verify-email in a shape its format does not have — it was changed after the build; run maizzle build again` | `Error` | An entry of the manifest lacks a field, or a locale: every format has them, so the file was edited, merged or cut after `maizzle build` wrote it |
406
453
  | `createMailRenderer: verify-email has no text part in fr — keep Maizzle's plaintext on, as @nxgt/mail-config sets it` | `Error` | The project turned Maizzle's `plaintext` off; every e-mail sent has a text part |
407
454
  | `createMailRenderer: dist/fr/verify-email.txt cannot be read — run maizzle build, and deploy its output folder` | `Error` | A file the manifest lists is gone: only part of the build was deployed |
408
455
 
@@ -384,13 +384,17 @@ listUnsubscribe({ url: url.href });
384
384
  ### Refusals
385
385
 
386
386
  The URL and the address are checked when the headers are built, before
387
- anything is sent. A `MailRefused` names the rule, never the value:
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:
388
392
 
389
393
  | Written | Answer |
390
394
  | --- | --- |
391
395
  | `url: 'https://example.com/u?token=…'`, `'https://example.com:8443/u?list=a%2Cb'` | accepted |
392
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` |
393
- | a `url` holding a space, a tab, a line break, a character outside ASCII, `<`, `>`, a double quote, a backtick, a backslash, a brace, `|`, `^` or a raw `,` | `MailRefused`: the same message |
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 |
394
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` |
395
399
  | `listUnsubscribe(null)` | `TypeError`: `listUnsubscribe: options must be an object, as { url }` |
396
400
  | `url: new URL(…)` | a compile error; at run time `TypeError`: `listUnsubscribe: url must be a string` |
@@ -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 has no case for it — a transport that ignores the key
228
- is as correct as one that honours it. Test it in your own specs: the key
229
- reaches the provider where it should, and nowhere else.
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 thirteen cases.
448
+ passes all fourteen cases.
446
449
 
447
450
  ### Without faults
448
451
 
package/docs/roadmap.md CHANGED
@@ -6,13 +6,12 @@ the only number.
6
6
 
7
7
  ## Now
8
8
 
9
- - **One-click unsubscribe** — `listUnsubscribe({ url, mailto? })` answers
10
- RFC 8058's `List-Unsubscribe` and `List-Unsubscribe-Post` headers, to
11
- spread into a message's `headers`, so Gmail and Yahoo offer their
12
- one-click unsubscribe. A `url` that is not `https:`, or that would break
13
- the header, and a `mailto` that is not a bare address are refused with
14
- `MailRefused`, never quoting the value. No transport changes: the headers
15
- travel as any other. Built, not yet published.
9
+ - **A build read by any later renderer** — within 0.x, `createMailRenderer`
10
+ reads every manifest format up to its own (`MANIFEST_FORMAT`, exported from
11
+ `@nxgt/mail/renderer`), so a build from any earlier `@nxgt/mail-i18n` 0.x
12
+ keeps working, and a package that ships a prebuilt format-1 build can peer
13
+ `@nxgt/mail` `>=0.1.0 <1`. A newer format is refused at start-up. Built,
14
+ not yet published.
16
15
 
17
16
  ## Next
18
17
 
@@ -69,6 +68,19 @@ Nothing yet.
69
68
  The last ten, newest first, each with the version it came in. Everything
70
69
  before is in the [CHANGELOG](../CHANGELOG.md).
71
70
 
71
+ - **The idempotency key in the conformance suite, and the unsubscribe URL as
72
+ written, v0.5.0** — a fourteenth case, `send.idempotencyKey`, delivers a
73
+ message with a fresh key and expects it never refused for it, the key in
74
+ none of its recipients, subject, HTML or text. `listUnsubscribe` writes
75
+ `new URL(url).href` and checks it as well as what it was given: a `%` that
76
+ starts no escape, or a host escape decoded into a refused character, is a
77
+ `MailRefused`. SMTP and Resend move their peer to `^0.5.0`.
78
+ - **One-click unsubscribe, v0.4.0** — `listUnsubscribe({ url, mailto? })`
79
+ answers RFC 8058's `List-Unsubscribe` and `List-Unsubscribe-Post` headers,
80
+ to spread into a message's `headers`, so Gmail and Yahoo offer their
81
+ one-click unsubscribe. A `url` that is not an ASCII `https://` URL, or that
82
+ would break the header, and a `mailto` that is not a bare address are
83
+ refused with `MailRefused`, never quoting the value. No transport changes.
72
84
  - **An idempotency key per send, v0.3.0** — `idempotencyKey` on a `MailMessage`
73
85
  names the send, so sending it again — a retry after a timeout, a job run
74
86
  twice — delivers it once where the transport can deduplicate; a transport
@@ -119,16 +131,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
119
131
  call written out. The
120
132
  type parameter is optional; untyped, the renderer is unchanged, and the
121
133
  run-time checks hold either way.
122
- - **Two transports, `@nxgt/mail-smtp` and `@nxgt/mail-resend` v0.1.0** —
123
- SMTP on the `nodemailer` you install, and Resend over `fetch` with no SDK,
124
- each passing the conformance suite — against a local SMTP server, and a
125
- local server answering as Resend does — and throwing `@nxgt/mail`'s errors.
126
- See [the SMTP roadmap](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-smtp/docs/roadmap.md)
127
- and [the Resend roadmap](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-resend/docs/roadmap.md).
128
- - **The Maizzle side, `@nxgt/mail-config`, `@nxgt/mail-i18n`, `@nxgt/mail-ui`
129
- and `@nxgt/mail-presets` v0.1.0** — packages for a normal Maizzle 6 project:
130
- `defineMailConfig({ plugins })` with every plugin's build hooks chained;
131
- one template per e-mail, its text keys into ICU catalogues checked at build
132
- time, one output per locale and the manifest this renderer reads; e-mail
133
- components in the style of `@nxgt/material-vue`, with shared messages in
134
- `en` and `fr`; and nine ready e-mails built with your own brand.
@@ -91,7 +91,8 @@ How the messages are shaped:
91
91
  - [`createMailRenderer: <dir>/<locale>/<email>.html cannot be read — run maizzle build, and deploy its output folder`](#createmailrenderer-dirlocaleemailhtml-cannot-be-read--run-maizzle-build-and-deploy-its-output-folder)
92
92
  - [`createMailRenderer: <dir>/mail-manifest.json is not valid JSON`](#createmailrenderer-dirmail-manifestjson-is-not-valid-json)
93
93
  - [`createMailRenderer: <dir>/mail-manifest.json is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin`](#createmailrenderer-dirmail-manifestjson-is-not-a-manifest-of-nxgtmail-i18n--build-with-its-i18n-plugin)
94
- - [`createMailRenderer: mail-manifest.json describes <email> in a shape this version does not read — rebuild with the same version of @nxgt/mail-i18n`](#createmailrenderer-mail-manifestjson-describes-email-in-a-shape-this-version-does-not-read--rebuild-with-the-same-version-of-nxgtmail-i18n)
94
+ - [`createMailRenderer: <dir>/mail-manifest.json is manifest format <format>, newer than this @nxgt/mail reads (<newest>) — upgrade @nxgt/mail`](#createmailrenderer-dirmail-manifestjson-is-manifest-format-format-newer-than-this-nxgtmail-reads-newest--upgrade-nxgtmail)
95
+ - [`createMailRenderer: mail-manifest.json describes <email> in a shape its format does not have — it was changed after the build; run maizzle build again`](#createmailrenderer-mail-manifestjson-describes-email-in-a-shape-its-format-does-not-have--it-was-changed-after-the-build-run-maizzle-build-again)
95
96
  - [`createMailRenderer: <email> has no text part in <locale> — keep Maizzle's plaintext on, as @nxgt/mail-config sets it`](#createmailrenderer-email-has-no-text-part-in-locale--keep-maizzles-plaintext-on-as-nxgtmail-config-sets-it)
96
97
  - [`Could not resolve "node:fs"`, or `No such module "node:fs"`, on an edge runtime](#could-not-resolve-nodefs-or-no-such-module-nodefs-on-an-edge-runtime)
97
98
 
@@ -338,7 +339,7 @@ that `generated/mail.ts` describes them, then fix the calls `tsc` still
338
339
  reports:
339
340
 
340
341
  ```sh
341
- bunx maizzle build # rewrites dist/ and generated/mail.ts; commit the new generated/mail.ts
342
+ bunx maizzle build # rewrites dist/ and generated/mail.ts, both git-ignored
342
343
  ```
343
344
 
344
345
  Never edit `generated/mail.ts` by hand to silence the error: the next build
@@ -350,14 +351,17 @@ rewrites it, and the deployed build is what `render` checks at run time.
350
351
  fresh clone or a new project.
351
352
  **Why:** `generated/mail.ts` is written by `@nxgt/mail-i18n` at the end of
352
353
  `maizzle build`, in the Maizzle project, unless its `rendererTypes` option
353
- moved it or turned it off (`false`). It is not there until the first build,
354
- or it was not committed, or the import points at another folder.
355
- **Fix:** build once and commit the file, so the code that sends type-checks
356
- without a build; import it from where `rendererTypes` writes it:
354
+ moved it or turned it off (`false`). It is git-ignored, as `dist/` is, so it
355
+ is not there until the first build — or the import points at another folder.
356
+ **Fix:** build before type-checking, in the script CI runs too, and import it
357
+ from where `rendererTypes` writes it:
357
358
 
358
- ```sh
359
- bunx maizzle build
360
- git add generated/mail.ts
359
+ ```json
360
+ {
361
+ "scripts": {
362
+ "typecheck": "maizzle build && tsc --noEmit"
363
+ }
364
+ }
361
365
  ```
362
366
 
363
367
  To go without it, leave the type parameter out:
@@ -893,8 +897,10 @@ A `MailRefused`, `code: 'MAIL_REFUSED'`.
893
897
  **When:** `listUnsubscribe({ url })`, with a `url` that does not start with
894
898
  `https://` (`http:`, `mailto:`, relative, empty, `HTTPS://` in capitals), that
895
899
  carries a user or a password (`https://user:pass@…`), or that holds a space,
896
- a line break, a character outside ASCII, `<`, `>`, a double quote, a backtick, a backslash, a
897
- brace, `|`, `^` or a `,` as it is: typically a token or a list name pasted
900
+ a line break, a character outside ASCII, `<`, `>`, a double quote, a backtick,
901
+ a backslash, a brace, `|`, `^`, a `,` as it is, or a `%` that starts no
902
+ escape (`%`, `%zz`) — or an escape in the host that the parser decodes into
903
+ one of those (`https://a%2Cb.test/`): typically a token or a list name pasted
898
904
  into a template string without being encoded, or an `http:` URL from a
899
905
  development configuration.
900
906
  **Why:** RFC 8058 accepts only an `https:` URL for one-click unsubscribe, and
@@ -1221,7 +1227,8 @@ An `Error`.
1221
1227
 
1222
1228
  **When:** start-up, when `dir` points at a folder whose `mail-manifest.json`
1223
1229
  has no `locales` list (or an empty one), no `fallbackLocale` or no `emails`
1224
- object: typically a `mail-manifest.json` written by something else.
1230
+ object, or a `formatVersion` that is not a positive integer (`0`, `1.5`,
1231
+ `"1"`, `null`): typically a `mail-manifest.json` written by something else.
1225
1232
  **Why:** the renderer reads only the manifest the `i18n()` plugin of
1226
1233
  `@nxgt/mail-i18n` writes. A Maizzle build without that plugin writes no
1227
1234
  manifest at all, and fails with
@@ -1239,19 +1246,51 @@ export default defineMailConfig({
1239
1246
  });
1240
1247
  ```
1241
1248
 
1242
- ### `createMailRenderer: mail-manifest.json describes <email> in a shape this version does not read — rebuild with the same version of @nxgt/mail-i18n`
1249
+ ### `createMailRenderer: <dir>/mail-manifest.json is manifest format <format>, newer than this @nxgt/mail reads (<newest>) — upgrade @nxgt/mail`
1250
+
1251
+ An `Error`, as `… is manifest format 2, newer than this @nxgt/mail reads (1)
1252
+ — upgrade @nxgt/mail`.
1253
+
1254
+ **When:** start-up, when the build was made with a `@nxgt/mail-i18n` that
1255
+ writes a newer manifest format than the installed `@nxgt/mail` reads: the
1256
+ build tool was upgraded and the server was not, or a package that ships a
1257
+ prebuilt build needs a newer `@nxgt/mail` than the one installed.
1258
+ **Why:** a renderer reads every manifest format up to its `MANIFEST_FORMAT`,
1259
+ and refuses a newer one rather than misread it. The format only changes when
1260
+ the manifest's shape does.
1261
+ **Fix:** upgrade `@nxgt/mail` in the server that renders, to a version whose
1262
+ `MANIFEST_FORMAT` is at least the format in the message:
1263
+
1264
+ ```sh
1265
+ bun add @nxgt/mail@latest
1266
+ ```
1267
+
1268
+ ```ts
1269
+ import { MANIFEST_FORMAT } from '@nxgt/mail/renderer';
1270
+
1271
+ MANIFEST_FORMAT; // must be >= the manifest's formatVersion
1272
+ ```
1273
+
1274
+ A package that ships its build states the lowest `@nxgt/mail` it needs in its
1275
+ peer range; see
1276
+ [Rendering — which builds it reads](guide/rendering.md#which-builds-it-reads--manifest_format).
1277
+
1278
+ ### `createMailRenderer: mail-manifest.json describes <email> in a shape its format does not have — it was changed after the build; run maizzle build again`
1243
1279
 
1244
1280
  An `Error`.
1245
1281
 
1246
1282
  **When:** start-up, when an e-mail's entry in the manifest lacks
1247
1283
  `variables`, `urlVariables`, `subject` or `files`, or has no subject or HTML
1248
1284
  file for one of the build's locales.
1249
- **Why:** the build and the server use versions of `@nxgt/mail-i18n` and
1250
- `@nxgt/mail` that do not agree on the manifest: a build committed or cached
1251
- from an older version, read by a newer server, or the reverse.
1252
- **Fix:** upgrade `@nxgt/mail-i18n` and `@nxgt/mail` together, then run
1253
- `maizzle build` again and deploy its output. If both are current and the
1254
- build is fresh, it is a [bug in this package](#a-bug-in-nxgtmail-itself).
1285
+ **Why:** every manifest format has those fields, for every e-mail and every
1286
+ locale, and `@nxgt/mail-i18n` writes them all. An entry without one was
1287
+ changed after `maizzle build` wrote it: a hand edit, a merge conflict resolved
1288
+ in a committed build, a script that rewrote the file, a copy cut short. It is
1289
+ not a version mismatch: within 0.x, a renderer reads every format up to its
1290
+ own, so a build from an earlier `@nxgt/mail-i18n` 0.x keeps working.
1291
+ **Fix:** run `maizzle build` again, and deploy its output without editing
1292
+ `mail-manifest.json`. If the build is fresh and untouched, it is a
1293
+ [bug in this package](#a-bug-in-nxgtmail-itself).
1255
1294
 
1256
1295
  ### `createMailRenderer: <email> has no text part in <locale> — keep Maizzle's plaintext on, as @nxgt/mail-config sets it`
1257
1296
 
@@ -1263,8 +1302,11 @@ comes after `@nxgt/mail-config`'s base.
1263
1302
  **Why:** every e-mail is sent with a text part — `Rendered` and `MailMessage`
1264
1303
  require one — and the renderer only fills it; it does not derive it from the
1265
1304
  HTML at send time.
1266
- **Fix:** remove the `plaintext: false`. `defineMailConfig` sets
1267
- `plaintext: true`; keep it, then run `maizzle build` again.
1305
+ **Fix:** remove the `plaintext: false`, then run `maizzle build` again.
1306
+ `defineMailConfig`'s base turns the text part on, laid out in paragraphs.
1307
+ Leave `plaintext` out rather than writing `plaintext: true`: `true` replaces
1308
+ the base's options, and the text part runs onto one line again — see
1309
+ [@nxgt/mail-config's troubleshooting](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-config/docs/troubleshooting.md#the-plain-text-part-is-one-long-line-again).
1268
1310
 
1269
1311
  ### `Could not resolve "node:fs"`, or `No such module "node:fs"`, on an edge runtime
1270
1312
 
@@ -1799,6 +1841,8 @@ test title:
1799
1841
  | `conformance: a Bcc header must throw MailRefused` | `send.refusesAddressHeader` | call `checkMessage`, from a version of `@nxgt/mail` that refuses reserved headers |
1800
1842
  | `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) |
1801
1843
  | `conformance: the refusal message holds the refused value` | `send.refusesWithoutTheValue`, `send.refusesAddressHeader`, `send.refusesAttachmentPath` | name where the problem is, never the value |
1844
+ | `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 |
1845
+ | `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 |
1802
1846
  | `conformance: the message with an attachment was not delivered` | `send.attachment` | a message with attachments is a message: deliver it |
1803
1847
  | `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 |
1804
1848
  | `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.4.0",
3
+ "version": "0.5.1",
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",