@nxgt/mail 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,8 @@ How the messages are shaped:
10
10
  verification e-mail is a credential, and it never reaches a log through an
11
11
  error.
12
12
  - **Every message starts with the call you wrote**: `send: …`,
13
- `createMailRenderer: …`, `render: …`, `pickLocale: …`, `describeMailer: …`.
13
+ `listUnsubscribe: …`, `createMailRenderer: …`, `render: …`,
14
+ `pickLocale: …`, `describeMailer: …`.
14
15
  A conformance case that fails starts with `conformance: …`.
15
16
  - **A `TypeError` is a wiring mistake**: it comes from how the application
16
17
  was put together — or, from `render`, from how the call was written —
@@ -64,8 +65,19 @@ How the messages are shaped:
64
65
  - [`send: attachments[<n>].content must be a Uint8Array — the file's bytes, never a path or a URL`](#send-attachmentsncontent-must-be-a-uint8array--the-files-bytes-never-a-path-or-a-url)
65
66
  - [`send: attachments[<n>].filename must be a file name — not empty, not . or .., without / or \, a line break or a control character`](#send-attachmentsnfilename-must-be-a-file-name--not-empty-not--or--without--or--a-line-break-or-a-control-character)
66
67
  - [`send: attachments[<n>].contentType must be a file's type/subtype, as application/pdf — never multipart/* or message/*`](#send-attachmentsncontenttype-must-be-a-files-typesubtype-as-applicationpdf--never-multipart-or-message)
68
+ - [`send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt`](#send-idempotencykey-must-be-1-to-256-visible-ascii-characters-as-order-42receipt)
69
+ - [`send: idempotencyKey was already used for a different message — a key names one e-mail`](#send-idempotencykey-was-already-used-for-a-different-message--a-key-names-one-e-mail)
70
+ - [An e-mail is delivered twice although it has an `idempotencyKey`](#an-e-mail-is-delivered-twice-although-it-has-an-idempotencykey)
67
71
  - [`send: the memory mailer was told to fail this send`](#send-the-memory-mailer-was-told-to-fail-this-send)
68
72
 
73
+ **Unsubscribe**
74
+ - [`listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma`](#listunsubscribe-url-must-be-an-https-url-in-printable-ascii-without-credentials---quotes-or-a-raw-comma)
75
+ - [`listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com`](#listunsubscribe-mailto-must-be-a-bare-e-mail-address-as-unsubscribeexamplecom)
76
+ - [`listUnsubscribe: options must be an object, as { url }`](#listunsubscribe-options-must-be-an-object-as--url-)
77
+ - [`listUnsubscribe: url must be a string`](#listunsubscribe-url-must-be-a-string)
78
+ - [`listUnsubscribe: mailto must be a string`](#listunsubscribe-mailto-must-be-a-string)
79
+ - [Gmail shows no unsubscribe button](#gmail-shows-no-unsubscribe-button)
80
+
69
81
  **Locale**
70
82
  - [`pickLocale: supported must hold at least one locale`](#picklocale-supported-must-hold-at-least-one-locale)
71
83
  - [`pickLocale: fallback must be one of supported`](#picklocale-fallback-must-be-one-of-supported)
@@ -495,8 +507,11 @@ whether the e-mail is still worth sending.
495
507
  the transport's message when the provider answered that the message is
496
508
  malformed or too large.
497
509
  **Why:** something in the message would break a header, has no valid
498
- recipient, or is an attachment that is not bytes or is badly named — or the
499
- whole message is over the provider's size limit. Sending it again unchanged fails again.
510
+ recipient, is an attachment that is not bytes or is badly named, or is an
511
+ `idempotencyKey` that is malformed or already used for a different message
512
+ (the memory mailer's refusal, or Resend's `409 invalid_idempotent_request`) —
513
+ or the whole message is over the provider's size limit. Sending it again
514
+ unchanged fails again.
500
515
  **Fix:** read `error.message` for where the problem is, and fix the message;
501
516
  the entries below cover each one. Handle the code as in the
502
517
  [`MAIL_FAILED`](#mail_failed--mailfailure-the-transport-could-not-hand-the-message-over)
@@ -756,6 +771,91 @@ const notes: MailAttachment = {
756
771
  };
757
772
  ```
758
773
 
774
+ ### `send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt`
775
+
776
+ **When:** `send`, with an `idempotencyKey` that is empty, not a string,
777
+ longer than 256 characters, or holds a space, a line break, a control
778
+ character or anything outside ASCII — an accent, an emoji.
779
+ The message never holds the key.
780
+ **Why:** a transport that deduplicates writes the key into a header (Resend's
781
+ `Idempotency-Key`), where only visible ASCII is safe, and Resend takes at most
782
+ 256 characters. The key is checked the same way on every transport, even one
783
+ that ignores it, so switching transports never turns a working key into a
784
+ refusal.
785
+ **Fix:** build the key from what the e-mail is about, and encode what you did
786
+ not write. `encodeURIComponent` keeps a readable key in visible ASCII; a hash
787
+ bounds its length:
788
+
789
+ ```ts
790
+ const orderRef = 'Commande n° 42'; // yours, not ASCII
791
+
792
+ // Readable, when the reference is short:
793
+ const key = `order-${encodeURIComponent(orderRef)}/receipt`; // order-Commande%20n%C2%B0%2042/receipt
794
+
795
+ // Always under 256, whatever the reference:
796
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(orderRef));
797
+ const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
798
+ const hashed = `order-${hex}/receipt`; // 64 hex digits, on any runtime
799
+
800
+ // Pick one, and build it the same way on every attempt:
801
+ await mailer.send({ ...message, idempotencyKey: hashed });
802
+ ```
803
+
804
+ ### `send: idempotencyKey was already used for a different message — a key names one e-mail`
805
+
806
+ **When:** a test, on a send through `createMemoryMailer()` whose
807
+ `idempotencyKey` the mailer already delivered, with a message that differs —
808
+ another recipient, subject, body, header or attachment. A `MailRefused`,
809
+ `code: 'MAIL_REFUSED'`; nothing is delivered. The same message again, key
810
+ included, is not refused: it answers the first `messageId`.
811
+ **Why:** a key names one e-mail. The memory mailer refuses a second, different
812
+ message under it as Resend does (a `409` `invalid_idempotent_request`, which
813
+ `@nxgt/mail-resend` throws as `send: Resend refused the message`), so the test
814
+ fails where production would. The usual causes: a key per user or per job
815
+ rather than per e-mail, or a retry that rendered the e-mail again with a
816
+ template or a variable that changed in between.
817
+ **Fix:** one key per e-mail, and the same message on every attempt at it —
818
+ render once, keep the result with the job, and send that on retry:
819
+
820
+ ```ts
821
+ const message = { ...rendered, to: user.email, idempotencyKey: `order-${order.id}/receipt` };
822
+
823
+ await mailer.send(message); // the first attempt
824
+ await mailer.send(message); // a retry: the same message, the first messageId
825
+ ```
826
+
827
+ A message meant to be different — a corrected receipt — is a new e-mail:
828
+ give it a new key (`order-42/receipt-2`). Between tests, `clear()` forgets the
829
+ keys.
830
+
831
+ ### An e-mail is delivered twice although it has an `idempotencyKey`
832
+
833
+ **When:** a retry — after a `MailFailure`, a timeout, a job run twice —
834
+ delivers a second copy, although both sends carried an `idempotencyKey`.
835
+ **Why:** either the key changed between the attempts, or the transport cannot
836
+ deduplicate. A key built from `Date.now()` or `crypto.randomUUID()` at each
837
+ attempt names a new send each time, so it deduplicates nothing. SMTP has no
838
+ idempotency: `@nxgt/mail-smtp` ignores the key, and a message sent twice is
839
+ delivered twice. Resend keeps a key for 24 hours; a retry after that is a new
840
+ send.
841
+ **Fix:** derive the key from what the e-mail is about — one key per e-mail
842
+ the application means to send once — and compute it the same way on every
843
+ attempt:
844
+
845
+ ```ts
846
+ // ✗ a new key per attempt: every retry is a new e-mail
847
+ await mailer.send({ ...message, idempotencyKey: crypto.randomUUID() });
848
+
849
+ // ✓ the same key for every attempt at this e-mail
850
+ await mailer.send({ ...message, idempotencyKey: `order-${order.id}/receipt` });
851
+ ```
852
+
853
+ A key per user (`user-${user.id}`) is the opposite mistake: the second,
854
+ different e-mail to that user is refused — by the memory mailer, as
855
+ [`send: idempotencyKey was already used for a different message — a key names one e-mail`](#send-idempotencykey-was-already-used-for-a-different-message--a-key-names-one-e-mail),
856
+ and by Resend, as `send: Resend refused the message`. When a random key is
857
+ what you have, create it once, store it with the job, and reuse it on retry.
858
+
759
859
  ### `send: the memory mailer was told to fail this send`
760
860
 
761
861
  **When:** a test, on a send through `createMemoryMailer()` after
@@ -778,6 +878,164 @@ beforeEach(() => mailer.clear());
778
878
 
779
879
  ---
780
880
 
881
+ ## Unsubscribe
882
+
883
+ `listUnsubscribe` checks its options **when it is called**, before any
884
+ `send`. A value that is text but not a usable URL or address is a
885
+ `MailRefused` (`code: 'MAIL_REFUSED'`), and it never quotes the value: the
886
+ URL usually carries a per-recipient token, and a token is a credential. A
887
+ value that is not text at all is a `TypeError`, a mistake in the code.
888
+
889
+ ### `listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma`
890
+
891
+ A `MailRefused`, `code: 'MAIL_REFUSED'`.
892
+
893
+ **When:** `listUnsubscribe({ url })`, with a `url` that does not start with
894
+ `https://` (`http:`, `mailto:`, relative, empty, `HTTPS://` in capitals), that
895
+ 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
898
+ into a template string without being encoded, or an `http:` URL from a
899
+ development configuration.
900
+ **Why:** RFC 8058 accepts only an `https:` URL for one-click unsubscribe, and
901
+ RFC 2369 an RFC 3986 URI — printable ASCII — between `<` and `>`. A
902
+ transport encodes a header holding anything else, and no client finds the URL
903
+ in it; a `>` would end the URL, and a raw comma is RFC 2369's separator
904
+ between two URLs, so a mail client would read the rest as a second one. A
905
+ user and a password would be read by every relay and recipient.
906
+ **Fix:** build the URL with `new URL()` and set each value with
907
+ `searchParams.set`, which percent-encodes it (`,` becomes `%2C`, a space
908
+ `+`), then pass `.href`:
909
+
910
+ ```ts
911
+ import { listUnsubscribe } from '@nxgt/mail';
912
+
913
+ declare const token: string;
914
+
915
+ const url = new URL('https://example.com/unsubscribe');
916
+ url.searchParams.set('token', token);
917
+
918
+ const headers = listUnsubscribe({ url: url.href });
919
+ ```
920
+
921
+ A value in the path is encoded with `encodeURIComponent` (`/u/${encodeURIComponent(list)}`).
922
+ In development, use an `https:` URL as well, or leave the headers out.
923
+
924
+ ### `listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com`
925
+
926
+ A `MailRefused`, `code: 'MAIL_REFUSED'`.
927
+
928
+ **When:** `listUnsubscribe({ url, mailto })`, with a `mailto` that has a
929
+ display name (`Unsubscribe <unsubscribe@example.com>`), a `mailto:` prefix,
930
+ two addresses, no `@`, a domain without a dot, a character outside ASCII,
931
+ or `?`, `&`, `=`, `#`, `%` or a double quote (`u@example.com?subject=stop`).
932
+ **Why:** `mailto` is one mailbox, and `listUnsubscribe` writes the
933
+ `<mailto:…>` around it itself; a name, a prefix or a second address would
934
+ break the header or be read as something else — in a `mailto:`, `?` starts
935
+ header fields, and `?cc=` would add a recipient.
936
+ **Fix:** pass the address alone:
937
+
938
+ ```ts
939
+ import { listUnsubscribe } from '@nxgt/mail';
940
+
941
+ declare const token: string;
942
+
943
+ const headers = listUnsubscribe({
944
+ url: `https://example.com/unsubscribe?token=${encodeURIComponent(token)}`,
945
+ mailto: 'unsubscribe@example.com', // ✗ 'mailto:unsubscribe@example.com'
946
+ });
947
+ ```
948
+
949
+ ### `listUnsubscribe: options must be an object, as { url }`
950
+
951
+ A `TypeError`.
952
+
953
+ **When:** `listUnsubscribe()` with no argument, or with the URL alone:
954
+ typically `listUnsubscribe(url)`.
955
+ **Why:** the options are one object, and `url` is required in it.
956
+ **Fix:** `listUnsubscribe({ url })`.
957
+
958
+ ### `listUnsubscribe: url must be a string`
959
+
960
+ A `TypeError`.
961
+
962
+ **When:** `listUnsubscribe({ url })`, with a `url` that is a `URL` object,
963
+ `undefined` or anything else that is not text — from untyped code, since
964
+ TypeScript already refuses a `URL` object (`Type 'URL' is not assignable to
965
+ type 'string'`).
966
+ **Why:** the header holds text; the helper does not guess how to turn a
967
+ value into a URL.
968
+ **Fix:** pass the URL's text:
969
+
970
+ ```ts
971
+ import { listUnsubscribe } from '@nxgt/mail';
972
+
973
+ const url = new URL('https://example.com/unsubscribe');
974
+
975
+ const headers = listUnsubscribe({ url: url.href }); // ✗ { url }
976
+ ```
977
+
978
+ ### `listUnsubscribe: mailto must be a string`
979
+
980
+ A `TypeError`.
981
+
982
+ **When:** `listUnsubscribe({ url, mailto })`, with a `mailto` that is set
983
+ but is not a string — `null`, or an `{ name, address }` object.
984
+ **Why:** `mailto` is one bare address, as text; leave it out for none.
985
+ **Fix:** `mailto: 'unsubscribe@example.com'`, or no `mailto` at all.
986
+
987
+ ### Gmail shows no unsubscribe button
988
+
989
+ **When:** the message carries both headers from `listUnsubscribe`, yet
990
+ Gmail (or Yahoo) shows no "Unsubscribe" link next to the sender.
991
+ **Why:** the headers make the button possible; the mailbox provider decides
992
+ whether to show it. The usual causes:
993
+
994
+ - **The sender is not a bulk sender, or its reputation is low.** Gmail shows
995
+ the button to senders it recognises as sending bulk mail with a good
996
+ reputation; a new domain, or a handful of test messages, may never get it.
997
+ - **The message is not DKIM-signed by the sending domain**, or the signature
998
+ does not cover the two headers. RFC 8058 requires a valid DKIM signature
999
+ whose `h=` includes `List-Unsubscribe` and `List-Unsubscribe-Post`, and
1000
+ Gmail and Yahoo also want it aligned with the `From` domain. Check the received message's original: the
1001
+ `DKIM-Signature` must have `d=` your domain and name both headers.
1002
+ - **The endpoint does not unsubscribe on the POST alone.** The client POSTs
1003
+ `List-Unsubscribe=One-Click` to the URL, with no cookie and no session. An
1004
+ answer that is a redirect, a login page, a confirmation page, or an error
1005
+ for a `POST` (a route that only answers `GET`) is a failed unsubscribe.
1006
+ - **The e-mail is transactional** — a sign-in code, a password reset, a
1007
+ receipt. Gmail does not offer to unsubscribe from those, and they should
1008
+ not carry the headers: nobody unsubscribes from their own password reset.
1009
+
1010
+ **Fix:** set the headers only on e-mails a recipient subscribed to, send
1011
+ from a domain that signs with DKIM, and make the URL unsubscribe on the
1012
+ `POST` itself:
1013
+
1014
+ ```ts
1015
+ declare function unsubscribeByToken(token: string): Promise<void>; // yours
1016
+
1017
+ // POST https://example.com/unsubscribe?token=…, body List-Unsubscribe=One-Click
1018
+ async function unsubscribe(request: Request): Promise<Response> {
1019
+ const token = new URL(request.url).searchParams.get('token') ?? '';
1020
+ if (request.method === 'POST') {
1021
+ const form = await request.formData();
1022
+ if (form.get('List-Unsubscribe') !== 'One-Click') return new Response(null, { status: 400 });
1023
+ await unsubscribeByToken(token); // no login, no confirmation
1024
+ return new Response(null, { status: 200 }); // never a redirect
1025
+ }
1026
+ // GET: a person followed the link. Show a page that posts the same form.
1027
+ return new Response(
1028
+ '<form method="post"><input type="hidden" name="List-Unsubscribe" value="One-Click"><button>Unsubscribe</button></form>',
1029
+ { headers: { 'content-type': 'text/html; charset=utf-8' } },
1030
+ );
1031
+ }
1032
+ ```
1033
+
1034
+ The full handler, with what the `GET` page may show, is in
1035
+ [the sending guide](guide/sending.md#one-click-unsubscribe).
1036
+
1037
+ ---
1038
+
781
1039
  ## Locale
782
1040
 
783
1041
  ### `pickLocale: supported must hold at least one locale`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/mail",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "The run-time side of transactional e-mail: the renderer that fills a Maizzle build made with @nxgt/mail-i18n, the Mailer a transport implements, its errors, a memory transport and locale selection. No dependency.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,11 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../src/message.ts", "../src/memory.ts"],
4
- "sourcesContent": [
5
- "import { MailRefused } from './errors';\nimport type { Address, MailAttachment, MailMessage } from './types';\n\nconst LINE_BREAK = /[\\r\\n]/;\n// Deliberately loose: one `@`, something on each side, and none of what an\n// address list parser reads as structure — whitespace, `<` `>` (a display\n// name), `,` `;` (a second address), `:` (a group). A provider that parses\n// the string then finds one mailbox, the one checked. Whether the mailbox\n// exists is the receiving server's question.\nconst ADDRESS = /^[^\\s@<>,;:]+@[^\\s@<>,;:]+$/;\nconst HEADER_NAME = /^[A-Za-z0-9-]+$/;\n// The headers a transport writes from the message: the addresses, the subject\n// and the MIME structure. Set through `headers`, a Bcc reaches an SMTP\n// envelope unchecked, and a Content-Type rewrites how the parts are read.\nconst RESERVED_HEADER =\n\t/^(?:to|cc|bcc|from|sender|reply-to|return-path|subject|mime-version|content-.*)$/i;\n\n// A file name is shown and saved by the recipient's mail client: no path\n// separator and no `.` or `..` (a client that saves it as is writes\n// elsewhere), no line break or other control character, C1 included (a header\n// could be split on one), and no format character — a right-to-left override\n// disguises `fdp.exe` as `exe.pdf`.\nconst FILENAME_REFUSED = /[/\\\\\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}]/u;\nconst DOT_NAME = /^\\.\\.?$/;\n// RFC 2045: type \"/\" subtype, each a token — any printable ASCII but space\n// and the tspecials ()<>@,;:\\\"/[]?=. No parameters: a charset or a name\n// there would be a second, unchecked place to write the file's name.\nconst CONTENT_TYPE =\n\t/^[!#$%&'*+.^_`{|}~0-9A-Za-z-]+\\/[!#$%&'*+.^_`{|}~0-9A-Za-z-]+$/;\n// multipart/* and message/* are MIME containers, not files: nodemailer writes\n// them unencoded, and the receiving end reads back no attachment at all.\nconst CONTAINER_TYPE = /^(?:multipart|message)\\//i;\n\n/** Every recipient of a message, as bare addresses, in order. */\nexport function recipientsOf(message: MailMessage): string[] {\n\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\treturn to.map(addressOf);\n}\n\n/** The bare address of an {@link Address}. */\nexport function addressOf(address: Address): string {\n\treturn typeof address === 'string' ? address : address.address;\n}\n\n/** Refuses `address` unless it is an {@link Address}. `undefined` is refused too. */\nfunction checkAddress(address: Address | undefined, where: string): void {\n\tif (typeof address === 'string') {\n\t\tif (!ADDRESS.test(address)) {\n\t\t\tthrow new MailRefused(`send: ${where} is not an e-mail address`);\n\t\t}\n\t\treturn;\n\t}\n\tif (typeof address !== 'object' || address === null) {\n\t\tthrow new MailRefused(`send: ${where} is not an e-mail address`);\n\t}\n\tif (typeof address.address !== 'string' || !ADDRESS.test(address.address)) {\n\t\tthrow new MailRefused(`send: ${where}.address is not an e-mail address`);\n\t}\n\tif (typeof address.name !== 'string' || LINE_BREAK.test(address.name)) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.name must be a string without a line break`,\n\t\t);\n\t}\n}\n\n/** Refuses `attachment` unless it is a {@link MailAttachment}. */\nfunction checkAttachment(attachment: MailAttachment, where: string): void {\n\tif (typeof attachment !== 'object' || attachment === null) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where} must be an object, as { filename, content, contentType }`,\n\t\t);\n\t}\n\tif (!(attachment.content instanceof Uint8Array)) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.content must be a Uint8Array — the file's bytes, never a path or a URL`,\n\t\t);\n\t}\n\tif (\n\t\ttypeof attachment.filename !== 'string' ||\n\t\tattachment.filename === '' ||\n\t\tDOT_NAME.test(attachment.filename) ||\n\t\tFILENAME_REFUSED.test(attachment.filename)\n\t) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.filename must be a file name — not empty, not . or .., without / or \\\\, a line break or a control character`,\n\t\t);\n\t}\n\tif (\n\t\ttypeof attachment.contentType !== 'string' ||\n\t\t!CONTENT_TYPE.test(attachment.contentType) ||\n\t\tCONTAINER_TYPE.test(attachment.contentType)\n\t) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.contentType must be a file's type/subtype, as application/pdf — never multipart/* or message/*`,\n\t\t);\n\t}\n}\n\n/**\n * Refuses a message no transport should hand over, with a {@link MailRefused}\n * that names **where** the problem is and never the value.\n *\n * A transport calls it first thing in `send`, so the refusals are the same\n * whichever transport is wired. It checks:\n *\n * - at least one recipient, each one an address;\n * - `from` and `replyTo`, when present, are addresses;\n * - `subject`, `html` and `text` are strings, and `subject` holds no line\n * break — a line break in a subject is a header injection;\n * - every header name is letters, digits and hyphens, none names what the\n * transport writes from the message (`To`, `Cc`, `Bcc`, `From`, `Sender`,\n * `Reply-To`, `Return-Path`, `Subject`, `MIME-Version`, `Content-*`, in\n * any case), and no header value holds a line break;\n * - `attachments`, when present, is an array without holes — empty is the\n * same as absent — and each entry has its bytes as a `Uint8Array`, a\n * `filename` that is not empty, `.` or `..` and holds no `/`, `\\`, line\n * break, control or format character, and a `contentType` that is a bare\n * `type/subtype`, never `multipart/*` or `message/*`.\n */\nexport function checkMessage(message: MailMessage): void {\n\tif (typeof message !== 'object' || message === null) {\n\t\tthrow new MailRefused('send: the message must be an object');\n\t}\n\tif (message.to === undefined || message.to === null) {\n\t\tthrow new MailRefused('send: to must hold at least one address');\n\t}\n\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\tif (to.length === 0) {\n\t\tthrow new MailRefused('send: to must hold at least one address');\n\t}\n\tto.forEach((address, index) => {\n\t\tcheckAddress(address, Array.isArray(message.to) ? `to[${index}]` : 'to');\n\t});\n\tif (message.from !== undefined) checkAddress(message.from, 'from');\n\tif (message.replyTo !== undefined) checkAddress(message.replyTo, 'replyTo');\n\n\tfor (const part of ['subject', 'html', 'text'] as const) {\n\t\tif (typeof message[part] !== 'string') {\n\t\t\tthrow new MailRefused(`send: ${part} must be a string`);\n\t\t}\n\t}\n\tif (LINE_BREAK.test(message.subject)) {\n\t\tthrow new MailRefused('send: subject must not hold a line break');\n\t}\n\n\tfor (const [name, value] of Object.entries(message.headers ?? {})) {\n\t\tif (!HEADER_NAME.test(name)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t'send: a header name must be letters, digits and hyphens',\n\t\t\t);\n\t\t}\n\t\tif (RESERVED_HEADER.test(name)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t`send: header ${name} is reserved — addresses, the subject and the MIME structure are never custom headers`,\n\t\t\t);\n\t\t}\n\t\tif (typeof value !== 'string' || LINE_BREAK.test(value)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t`send: header ${name} must be a string without a line break`,\n\t\t\t);\n\t\t}\n\t}\n\n\tif (message.attachments !== undefined) {\n\t\tif (!Array.isArray(message.attachments)) {\n\t\t\tthrow new MailRefused('send: attachments must be an array');\n\t\t}\n\t\t// Indexed, not forEach: a hole in the array is refused, not skipped.\n\t\tfor (let index = 0; index < message.attachments.length; index++) {\n\t\t\tcheckAttachment(message.attachments[index], `attachments[${index}]`);\n\t\t}\n\t}\n}\n",
6
- "import { type MailError, MailFailure } from './errors';\nimport { checkMessage } from './message';\nimport type { Mailer, MailMessage, SentMail } from './types';\n\n/** One message the memory mailer accepted, with the id it gave it. */\nexport interface MemoryMail extends MailMessage {\n\treadonly messageId: string;\n}\n\n/**\n * The reference transport: it keeps what it sends in memory, for tests.\n *\n * It refuses exactly what every transport refuses (it calls\n * {@link checkMessage}), and it can be told to fail, so a test can prove what\n * the application does when a send throws.\n */\nexport interface MemoryMailer extends Mailer {\n\t/** Every message accepted so far, oldest first. A copy: mutating it changes nothing. */\n\treadonly sent: readonly MemoryMail[];\n\t/**\n\t * How many sends reached the hand-over, failed ones included. A message\n\t * refused as malformed never reaches it. A caller that retries in secret\n\t * shows up here.\n\t */\n\treadonly attempts: number;\n\t/**\n\t * Makes the next send that reaches the hand-over reject with `error`, by\n\t * default a {@link MailFailure} as an outage would. Calls queue: two calls\n\t * fail the next two sends.\n\t */\n\tfailNext(error?: MailError): void;\n\t/** Forgets what was sent, the attempts, and any queued failure. */\n\tclear(): void;\n}\n\n/**\n * A copy of `message` the caller cannot change afterwards. Each attachment's\n * bytes are copied to a plain `Uint8Array` of their own: a `Buffer` from\n * Node's pool is a view on a larger, shared buffer, which a clone would copy\n * whole.\n */\nfunction copyOf(message: MailMessage): MailMessage {\n\tconst { attachments, ...rest } = message;\n\tconst copy = structuredClone(rest);\n\tif (attachments === undefined) return copy;\n\treturn {\n\t\t...copy,\n\t\tattachments: attachments.map((attachment) => ({\n\t\t\tfilename: attachment.filename,\n\t\t\tcontent: new Uint8Array(attachment.content),\n\t\t\tcontentType: attachment.contentType,\n\t\t})),\n\t};\n}\n\n/** Creates a {@link MemoryMailer}. Message ids are `memory-1`, `memory-2`, … */\nexport function createMemoryMailer(): MemoryMailer {\n\tlet sent: MemoryMail[] = [];\n\tlet failures: MailError[] = [];\n\tlet attempts = 0;\n\tlet counter = 0;\n\n\treturn {\n\t\tget sent() {\n\t\t\treturn sent.map((mail) => structuredClone(mail));\n\t\t},\n\t\tget attempts() {\n\t\t\treturn attempts;\n\t\t},\n\t\tfailNext(error) {\n\t\t\tfailures.push(\n\t\t\t\terror ??\n\t\t\t\t\tnew MailFailure(\n\t\t\t\t\t\t'send: the memory mailer was told to fail this send',\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcause: new Error('memory mailer: failNext'),\n\t\t\t\t\t\t},\n\t\t\t\t\t),\n\t\t\t);\n\t\t},\n\t\tclear() {\n\t\t\tsent = [];\n\t\t\tfailures = [];\n\t\t\tattempts = 0;\n\t\t},\n\t\tasync send(message): Promise<SentMail> {\n\t\t\tcheckMessage(message);\n\t\t\tattempts += 1;\n\t\t\tconst failure = failures.shift();\n\t\t\tif (failure !== undefined) throw failure;\n\n\t\t\tcounter += 1;\n\t\t\tconst messageId = `memory-${counter}`;\n\t\t\tsent.push({ ...copyOf(message), messageId });\n\t\t\treturn { messageId };\n\t\t},\n\t};\n}\n"
7
- ],
8
- "mappings": "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",
9
- "debugId": "49A96A07CA93F79964756E2164756E21",
10
- "names": []
11
- }