@nxgt/mail 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -4
- package/dist/chunks/{index-4h39j3n7.js → index-nkzwt8vn.js} +39 -2
- package/dist/chunks/index-nkzwt8vn.js.map +11 -0
- package/dist/chunks/index-we4n5yfz.js.map +2 -2
- package/dist/conformance/index.js +1 -1
- package/dist/errors.d.ts +2 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -2
- package/dist/index.js.map +4 -3
- package/dist/memory.d.ts +7 -1
- package/dist/memory.d.ts.map +1 -1
- package/dist/message.d.ts +2 -1
- package/dist/message.d.ts.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/unsubscribe.d.ts +43 -0
- package/dist/unsubscribe.d.ts.map +1 -0
- package/docs/README.md +3 -3
- package/docs/guide/sending.md +304 -9
- package/docs/guide/testing.md +91 -4
- package/docs/guide/transports.md +67 -8
- package/docs/roadmap.md +26 -15
- package/docs/troubleshooting.md +261 -3
- package/package.json +1 -1
- package/dist/chunks/index-4h39j3n7.js.map +0 -11
package/docs/troubleshooting.md
CHANGED
|
@@ -10,7 +10,8 @@ How the messages are shaped:
|
|
|
10
10
|
verification e-mail is a credential, and it never reaches a log through an
|
|
11
11
|
error.
|
|
12
12
|
- **Every message starts with the call you wrote**: `send: …`,
|
|
13
|
-
`
|
|
13
|
+
`listUnsubscribe: …`, `createMailRenderer: …`, `render: …`,
|
|
14
|
+
`pickLocale: …`, `describeMailer: …`.
|
|
14
15
|
A conformance case that fails starts with `conformance: …`.
|
|
15
16
|
- **A `TypeError` is a wiring mistake**: it comes from how the application
|
|
16
17
|
was put together — or, from `render`, from how the call was written —
|
|
@@ -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,
|
|
499
|
-
|
|
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.
|
|
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
|
-
}
|