@nxgt/mail 0.1.0 → 0.3.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 +120 -5
- package/dist/chunks/{index-0f7kdb8k.js → index-nkzwt8vn.js} +80 -3
- package/dist/chunks/index-nkzwt8vn.js.map +11 -0
- package/dist/chunks/index-we4n5yfz.js.map +1 -1
- package/dist/conformance/cases/send.d.ts.map +1 -1
- package/dist/conformance/index.d.ts +1 -1
- package/dist/conformance/index.d.ts.map +1 -1
- package/dist/conformance/index.js +44 -3
- package/dist/conformance/index.js.map +5 -5
- package/dist/conformance/reference.d.ts.map +1 -1
- package/dist/conformance/sample.d.ts +6 -1
- package/dist/conformance/sample.d.ts.map +1 -1
- package/dist/conformance/types.d.ts +11 -1
- package/dist/conformance/types.d.ts.map +1 -1
- package/dist/errors.d.ts +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/memory.d.ts +7 -1
- package/dist/memory.d.ts.map +1 -1
- package/dist/message.d.ts +7 -1
- package/dist/message.d.ts.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/README.md +3 -3
- package/docs/guide/sending.md +178 -3
- package/docs/guide/testing.md +125 -4
- package/docs/guide/transports.md +129 -13
- package/docs/roadmap.md +21 -1
- package/docs/troubleshooting.md +324 -5
- package/package.json +1 -1
- package/dist/chunks/index-0f7kdb8k.js.map +0 -11
package/docs/troubleshooting.md
CHANGED
|
@@ -40,6 +40,10 @@ How the messages are shaped:
|
|
|
40
40
|
- [`TS2511: Cannot create an instance of an abstract class.`](#ts2511-cannot-create-an-instance-of-an-abstract-class)
|
|
41
41
|
- [`TS2345: Argument of type '"verify-emial"' is not assignable to parameter of type '"sign-in-code" | "verify-email"'.`](#ts2345-argument-of-type-verify-emial-is-not-assignable-to-parameter-of-type-sign-in-code--verify-email)
|
|
42
42
|
- [`TS2307: Cannot find module './generated/mail' or its corresponding type declarations.`](#ts2307-cannot-find-module-generatedmail-or-its-corresponding-type-declarations)
|
|
43
|
+
- [`TS2322: Type 'string' is not assignable to type 'Uint8Array<ArrayBufferLike>'.`](#ts2322-type-string-is-not-assignable-to-type-uint8arrayarraybufferlike)
|
|
44
|
+
- [`TS2353: Object literal may only specify known properties, and 'path' does not exist in type 'MailAttachment'.`](#ts2353-object-literal-may-only-specify-known-properties-and-path-does-not-exist-in-type-mailattachment)
|
|
45
|
+
- [`TS2741: Property 'contentType' is missing in type '…' but required in type 'MailAttachment'.`](#ts2741-property-contenttype-is-missing-in-type--but-required-in-type-mailattachment)
|
|
46
|
+
- [`TS2740: Type 'MailAttachment' is missing the following properties from type 'readonly MailAttachment[]': length, concat, join, slice, and 26 more.`](#ts2740-type-mailattachment-is-missing-the-following-properties-from-type-readonly-mailattachment-length-concat-join-slice-and-26-more)
|
|
43
47
|
- [`error instanceof MailFailure` is `false` for an outage](#error-instanceof-mailfailure-is-false-for-an-outage)
|
|
44
48
|
|
|
45
49
|
**Sending**
|
|
@@ -55,6 +59,14 @@ How the messages are shaped:
|
|
|
55
59
|
- [`send: a header name must be letters, digits and hyphens`](#send-a-header-name-must-be-letters-digits-and-hyphens)
|
|
56
60
|
- [`send: header <name> must be a string without a line break`](#send-header-name-must-be-a-string-without-a-line-break)
|
|
57
61
|
- [`send: header <name> is reserved — addresses, the subject and the MIME structure are never custom headers`](#send-header-name-is-reserved--addresses-the-subject-and-the-mime-structure-are-never-custom-headers)
|
|
62
|
+
- [`send: attachments must be an array`](#send-attachments-must-be-an-array)
|
|
63
|
+
- [`send: attachments[<n>] must be an object, as { filename, content, contentType }`](#send-attachmentsn-must-be-an-object-as--filename-content-contenttype-)
|
|
64
|
+
- [`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
|
+
- [`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
|
+
- [`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)
|
|
67
|
+
- [`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)
|
|
68
|
+
- [`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)
|
|
69
|
+
- [An e-mail is delivered twice although it has an `idempotencyKey`](#an-e-mail-is-delivered-twice-although-it-has-an-idempotencykey)
|
|
58
70
|
- [`send: the memory mailer was told to fail this send`](#send-the-memory-mailer-was-told-to-fail-this-send)
|
|
59
71
|
|
|
60
72
|
**Locale**
|
|
@@ -96,6 +108,9 @@ How the messages are shaped:
|
|
|
96
108
|
- [`conformance: the transport retried a failed hand-over`](#conformance-the-transport-retried-a-failed-hand-over)
|
|
97
109
|
- [`conformance: a name let a second recipient through`](#conformance-a-name-let-a-second-recipient-through)
|
|
98
110
|
- [`conformance: <what>, yet something was delivered`](#conformance-what-yet-something-was-delivered)
|
|
111
|
+
- [`conformance: the harness's delivered() reads back no attachments — read them from the receiving end, or skip send.attachment with the reason`](#conformance-the-harnesss-delivered-reads-back-no-attachments--read-them-from-the-receiving-end-or-skip-sendattachment-with-the-reason)
|
|
112
|
+
- [`conformance: expected 1 delivered attachment, got <n>`](#conformance-expected-1-delivered-attachment-got-n)
|
|
113
|
+
- [`conformance: the attachment was not delivered byte for byte`](#conformance-the-attachment-was-not-delivered-byte-for-byte)
|
|
99
114
|
- [Other `conformance:` messages](#other-conformance-messages)
|
|
100
115
|
- [A bug in `@nxgt/mail` itself](#a-bug-in-nxgtmail-itself)
|
|
101
116
|
|
|
@@ -340,6 +355,64 @@ To go without it, leave the type parameter out:
|
|
|
340
355
|
`createMailRenderer({ dir: 'dist' })` takes any name and any
|
|
341
356
|
`MailVariables`, checked at run time only.
|
|
342
357
|
|
|
358
|
+
### `TS2322: Type 'string' is not assignable to type 'Uint8Array<ArrayBufferLike>'.`
|
|
359
|
+
|
|
360
|
+
**When:** `tsc`, on an attachment whose `content` is text:
|
|
361
|
+
`{ filename: 'notes.txt', content: 'notes', contentType: 'text/plain' }`.
|
|
362
|
+
**Why:** an attachment is bytes. A string would be sent in whatever encoding
|
|
363
|
+
the transport picked; bytes are sent as they are.
|
|
364
|
+
**Fix:** encode the text yourself, in the encoding you mean:
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
import type { MailAttachment } from '@nxgt/mail';
|
|
368
|
+
|
|
369
|
+
declare const csv: string;
|
|
370
|
+
|
|
371
|
+
const report: MailAttachment = {
|
|
372
|
+
filename: 'report.csv',
|
|
373
|
+
content: new TextEncoder().encode(csv), // UTF-8
|
|
374
|
+
contentType: 'text/csv',
|
|
375
|
+
};
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### `TS2353: Object literal may only specify known properties, and 'path' does not exist in type 'MailAttachment'.`
|
|
379
|
+
|
|
380
|
+
**When:** `tsc`, on an attachment written as nodemailer's, with a `path` (or
|
|
381
|
+
an `href`) for the transport to read.
|
|
382
|
+
**Why:** no transport reads a file or fetches a URL to attach it — a value
|
|
383
|
+
from outside could then make an e-mail carry any file the server can read.
|
|
384
|
+
`MailAttachment` holds the bytes.
|
|
385
|
+
**Fix:** read the file where your code decides which files may be read, and
|
|
386
|
+
pass its bytes:
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
import { readFile } from 'node:fs/promises';
|
|
390
|
+
import type { MailAttachment } from '@nxgt/mail';
|
|
391
|
+
|
|
392
|
+
const invoice: MailAttachment = {
|
|
393
|
+
filename: 'invoice-42.pdf',
|
|
394
|
+
content: await readFile('/srv/invoices/42.pdf'),
|
|
395
|
+
contentType: 'application/pdf',
|
|
396
|
+
};
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
A file too large to hold in memory is too large for an e-mail: send a signed
|
|
400
|
+
link instead — [Sending — attachments](guide/sending.md#attachments).
|
|
401
|
+
|
|
402
|
+
### `TS2741: Property 'contentType' is missing in type '…' but required in type 'MailAttachment'.`
|
|
403
|
+
|
|
404
|
+
**When:** `tsc`, on an attachment without its `contentType`.
|
|
405
|
+
**Why:** nothing guesses the type from the file name: a guess can be wrong,
|
|
406
|
+
and a mail client opens a file by its type.
|
|
407
|
+
**Fix:** name it — `application/pdf`, `text/calendar`, `image/png`, or
|
|
408
|
+
`application/octet-stream` for bytes of no particular type.
|
|
409
|
+
|
|
410
|
+
### `TS2740: Type 'MailAttachment' is missing the following properties from type 'readonly MailAttachment[]': length, concat, join, slice, and 26 more.`
|
|
411
|
+
|
|
412
|
+
**When:** `tsc`, on `attachments: invoice` — one attachment, not in a list.
|
|
413
|
+
**Why:** `attachments` is a list, even of one.
|
|
414
|
+
**Fix:** `attachments: [invoice]`.
|
|
415
|
+
|
|
343
416
|
### `error instanceof MailFailure` is `false` for an outage
|
|
344
417
|
|
|
345
418
|
**When:** at run time, with a transport from another package or your own. An
|
|
@@ -423,9 +496,13 @@ whether the e-mail is still worth sending.
|
|
|
423
496
|
**When:** `await mailer.send(message)` rejects, either with one of the
|
|
424
497
|
`send: …` messages below (the message was refused before it left), or with
|
|
425
498
|
the transport's message when the provider answered that the message is
|
|
426
|
-
malformed.
|
|
427
|
-
**Why:** something in the message would break a header
|
|
428
|
-
recipient
|
|
499
|
+
malformed or too large.
|
|
500
|
+
**Why:** something in the message would break a header, has no valid
|
|
501
|
+
recipient, is an attachment that is not bytes or is badly named, or is an
|
|
502
|
+
`idempotencyKey` that is malformed or already used for a different message
|
|
503
|
+
(the memory mailer's refusal, or Resend's `409 invalid_idempotent_request`) —
|
|
504
|
+
or the whole message is over the provider's size limit. Sending it again
|
|
505
|
+
unchanged fails again.
|
|
429
506
|
**Fix:** read `error.message` for where the problem is, and fix the message;
|
|
430
507
|
the entries below cover each one. Handle the code as in the
|
|
431
508
|
[`MAIL_FAILED`](#mail_failed--mailfailure-the-transport-could-not-hand-the-message-over)
|
|
@@ -592,6 +669,184 @@ await mailer.send({ ...rendered, to: 'ada@example.com' });
|
|
|
592
669
|
await mailer.send({ ...rendered, to: 'audit@example.com' }); // ✓ the copy, on its own
|
|
593
670
|
```
|
|
594
671
|
|
|
672
|
+
### `send: attachments must be an array`
|
|
673
|
+
|
|
674
|
+
**When:** `send`, with `attachments` that is not a list — one attachment on
|
|
675
|
+
its own, or `null`.
|
|
676
|
+
**Why:** the attachments are a list, in order, even of one.
|
|
677
|
+
**Fix:** `attachments: [invoice]`; leave the field out, or pass `[]`, for none.
|
|
678
|
+
|
|
679
|
+
### `send: attachments[<n>] must be an object, as { filename, content, contentType }`
|
|
680
|
+
|
|
681
|
+
**When:** `send`, with an entry of `attachments` that is not an object —
|
|
682
|
+
`undefined` from a lookup that found nothing, `null`, or a hole in the list
|
|
683
|
+
(`[, pdf]`, `new Array(2)`). `<n>` is its index.
|
|
684
|
+
**Why:** each entry is one file: its name, its bytes and its type.
|
|
685
|
+
**Fix:** filter the list before sending, or refuse to send when a file you
|
|
686
|
+
meant to attach is missing — an e-mail that says "attached" with nothing
|
|
687
|
+
attached is worse than an error.
|
|
688
|
+
|
|
689
|
+
### `send: attachments[<n>].content must be a Uint8Array — the file's bytes, never a path or a URL`
|
|
690
|
+
|
|
691
|
+
**When:** `send`, with an attachment whose `content` is not a `Uint8Array`:
|
|
692
|
+
a string, an `ArrayBuffer`, a stream, or no `content` at all because the
|
|
693
|
+
attachment was written with a `path` or an `href`, as nodemailer takes them.
|
|
694
|
+
A Node `Buffer` is a `Uint8Array`, and accepted.
|
|
695
|
+
**Why:** an attachment is bytes the application already holds. No
|
|
696
|
+
transport reads a file or fetches a URL to attach it, so a value from outside
|
|
697
|
+
can never make an e-mail carry a file it should not.
|
|
698
|
+
**Fix:** read or convert it first:
|
|
699
|
+
|
|
700
|
+
```ts
|
|
701
|
+
import { readFile } from 'node:fs/promises';
|
|
702
|
+
|
|
703
|
+
declare const csv: string;
|
|
704
|
+
declare const response: Response;
|
|
705
|
+
|
|
706
|
+
const fromDisk = await readFile('/srv/invoices/42.pdf'); // a Buffer
|
|
707
|
+
const fromText = new TextEncoder().encode(csv);
|
|
708
|
+
const fromFetch = new Uint8Array(await response.arrayBuffer());
|
|
709
|
+
const fromArrayBuffer = new Uint8Array(new ArrayBuffer(8));
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
A file too large to read into memory is too large for an e-mail: send a
|
|
713
|
+
signed link instead.
|
|
714
|
+
|
|
715
|
+
### `send: attachments[<n>].filename must be a file name — not empty, not . or .., without / or \, a line break or a control character`
|
|
716
|
+
|
|
717
|
+
**When:** `send`, with an attachment whose `filename` is empty, is not a
|
|
718
|
+
string, is `.` or `..`, or holds `/`, `\`, a line break (U+2028 and U+2029
|
|
719
|
+
included), a NUL or another control character, or a format character such
|
|
720
|
+
as a right-to-left override.
|
|
721
|
+
The message never holds the name.
|
|
722
|
+
**Why:** the recipient's mail client shows the name and saves the file under
|
|
723
|
+
it. A path — `../…`, `invoices/42.pdf`, `C:\…`, `..` — asks it to save
|
|
724
|
+
elsewhere, a line break or a control character can split the header the name
|
|
725
|
+
is written in, and a right-to-left override shows `invoice\u202Efdp.exe` as
|
|
726
|
+
`invoiceexe.pdf`.
|
|
727
|
+
**Fix:** a bare file name. Accents, spaces and parentheses are fine — the
|
|
728
|
+
transport encodes them. From a name you did not write, keep the last segment
|
|
729
|
+
of the path and drop the control characters:
|
|
730
|
+
|
|
731
|
+
```ts
|
|
732
|
+
const safeName = (name: string) =>
|
|
733
|
+
name.split(/[\\/]/).pop()?.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '').trim().replace(/^\.\.?$/, '') ||
|
|
734
|
+
'attachment';
|
|
735
|
+
|
|
736
|
+
safeName('../uploads/Relevé\nmars.pdf'); // 'Relevémars.pdf'
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
### `send: attachments[<n>].contentType must be a file's type/subtype, as application/pdf — never multipart/* or message/*`
|
|
740
|
+
|
|
741
|
+
**When:** `send`, with an attachment whose `contentType` is not a bare
|
|
742
|
+
`type/subtype`: an extension (`pdf`), a type with parameters
|
|
743
|
+
(`text/plain; charset=utf-8`), a space or a line break — or a MIME container,
|
|
744
|
+
`multipart/*` or `message/*`, in any case.
|
|
745
|
+
**Why:** the type is written into the attachment's `Content-Type` header; a
|
|
746
|
+
parameter there is a second, unchecked place for a name or a charset. The
|
|
747
|
+
transport writes the parameters it needs. A container is not a file: SMTP
|
|
748
|
+
writes `message/rfc822` and `multipart/mixed` unencoded, as parts of the
|
|
749
|
+
message itself, and the recipient gets no attachment while `send` resolves.
|
|
750
|
+
To forward an e-mail, attach it as `application/octet-stream` with a `.eml`
|
|
751
|
+
name.
|
|
752
|
+
**Fix:** the bare type — `text/plain`, and encode the text as UTF-8, which is
|
|
753
|
+
what a mail client assumes:
|
|
754
|
+
|
|
755
|
+
```ts
|
|
756
|
+
import type { MailAttachment } from '@nxgt/mail';
|
|
757
|
+
|
|
758
|
+
const notes: MailAttachment = {
|
|
759
|
+
filename: 'notes.txt',
|
|
760
|
+
content: new TextEncoder().encode('Hello'),
|
|
761
|
+
contentType: 'text/plain',
|
|
762
|
+
};
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
### `send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt`
|
|
766
|
+
|
|
767
|
+
**When:** `send`, with an `idempotencyKey` that is empty, not a string,
|
|
768
|
+
longer than 256 characters, or holds a space, a line break, a control
|
|
769
|
+
character or anything outside ASCII — an accent, an emoji.
|
|
770
|
+
The message never holds the key.
|
|
771
|
+
**Why:** a transport that deduplicates writes the key into a header (Resend's
|
|
772
|
+
`Idempotency-Key`), where only visible ASCII is safe, and Resend takes at most
|
|
773
|
+
256 characters. The key is checked the same way on every transport, even one
|
|
774
|
+
that ignores it, so switching transports never turns a working key into a
|
|
775
|
+
refusal.
|
|
776
|
+
**Fix:** build the key from what the e-mail is about, and encode what you did
|
|
777
|
+
not write. `encodeURIComponent` keeps a readable key in visible ASCII; a hash
|
|
778
|
+
bounds its length:
|
|
779
|
+
|
|
780
|
+
```ts
|
|
781
|
+
const orderRef = 'Commande n° 42'; // yours, not ASCII
|
|
782
|
+
|
|
783
|
+
// Readable, when the reference is short:
|
|
784
|
+
const key = `order-${encodeURIComponent(orderRef)}/receipt`; // order-Commande%20n%C2%B0%2042/receipt
|
|
785
|
+
|
|
786
|
+
// Always under 256, whatever the reference:
|
|
787
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(orderRef));
|
|
788
|
+
const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
789
|
+
const hashed = `order-${hex}/receipt`; // 64 hex digits, on any runtime
|
|
790
|
+
|
|
791
|
+
// Pick one, and build it the same way on every attempt:
|
|
792
|
+
await mailer.send({ ...message, idempotencyKey: hashed });
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
### `send: idempotencyKey was already used for a different message — a key names one e-mail`
|
|
796
|
+
|
|
797
|
+
**When:** a test, on a send through `createMemoryMailer()` whose
|
|
798
|
+
`idempotencyKey` the mailer already delivered, with a message that differs —
|
|
799
|
+
another recipient, subject, body, header or attachment. A `MailRefused`,
|
|
800
|
+
`code: 'MAIL_REFUSED'`; nothing is delivered. The same message again, key
|
|
801
|
+
included, is not refused: it answers the first `messageId`.
|
|
802
|
+
**Why:** a key names one e-mail. The memory mailer refuses a second, different
|
|
803
|
+
message under it as Resend does (a `409` `invalid_idempotent_request`, which
|
|
804
|
+
`@nxgt/mail-resend` throws as `send: Resend refused the message`), so the test
|
|
805
|
+
fails where production would. The usual causes: a key per user or per job
|
|
806
|
+
rather than per e-mail, or a retry that rendered the e-mail again with a
|
|
807
|
+
template or a variable that changed in between.
|
|
808
|
+
**Fix:** one key per e-mail, and the same message on every attempt at it —
|
|
809
|
+
render once, keep the result with the job, and send that on retry:
|
|
810
|
+
|
|
811
|
+
```ts
|
|
812
|
+
const message = { ...rendered, to: user.email, idempotencyKey: `order-${order.id}/receipt` };
|
|
813
|
+
|
|
814
|
+
await mailer.send(message); // the first attempt
|
|
815
|
+
await mailer.send(message); // a retry: the same message, the first messageId
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
A message meant to be different — a corrected receipt — is a new e-mail:
|
|
819
|
+
give it a new key (`order-42/receipt-2`). Between tests, `clear()` forgets the
|
|
820
|
+
keys.
|
|
821
|
+
|
|
822
|
+
### An e-mail is delivered twice although it has an `idempotencyKey`
|
|
823
|
+
|
|
824
|
+
**When:** a retry — after a `MailFailure`, a timeout, a job run twice —
|
|
825
|
+
delivers a second copy, although both sends carried an `idempotencyKey`.
|
|
826
|
+
**Why:** either the key changed between the attempts, or the transport cannot
|
|
827
|
+
deduplicate. A key built from `Date.now()` or `crypto.randomUUID()` at each
|
|
828
|
+
attempt names a new send each time, so it deduplicates nothing. SMTP has no
|
|
829
|
+
idempotency: `@nxgt/mail-smtp` ignores the key, and a message sent twice is
|
|
830
|
+
delivered twice. Resend keeps a key for 24 hours; a retry after that is a new
|
|
831
|
+
send.
|
|
832
|
+
**Fix:** derive the key from what the e-mail is about — one key per e-mail
|
|
833
|
+
the application means to send once — and compute it the same way on every
|
|
834
|
+
attempt:
|
|
835
|
+
|
|
836
|
+
```ts
|
|
837
|
+
// ✗ a new key per attempt: every retry is a new e-mail
|
|
838
|
+
await mailer.send({ ...message, idempotencyKey: crypto.randomUUID() });
|
|
839
|
+
|
|
840
|
+
// ✓ the same key for every attempt at this e-mail
|
|
841
|
+
await mailer.send({ ...message, idempotencyKey: `order-${order.id}/receipt` });
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
A key per user (`user-${user.id}`) is the opposite mistake: the second,
|
|
845
|
+
different e-mail to that user is refused — by the memory mailer, as
|
|
846
|
+
[`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),
|
|
847
|
+
and by Resend, as `send: Resend refused the message`. When a random key is
|
|
848
|
+
what you have, create it once, store it with the job, and reuse it on retry.
|
|
849
|
+
|
|
595
850
|
### `send: the memory mailer was told to fail this send`
|
|
596
851
|
|
|
597
852
|
**When:** a test, on a send through `createMemoryMailer()` after
|
|
@@ -1188,7 +1443,8 @@ simulated, so the skip is on purpose and visible.
|
|
|
1188
1443
|
|
|
1189
1444
|
`<send>` is, for example, `a send during an outage`, `a refused send`,
|
|
1190
1445
|
`a send with no recipient`, `a send with a line break in the subject`,
|
|
1191
|
-
`a send with a Bcc header
|
|
1446
|
+
`a send with a Bcc header`, `a send with an attachment named with a path` or
|
|
1447
|
+
`a send to something that is not an address`.
|
|
1192
1448
|
|
|
1193
1449
|
**When:** a `failure.*` or `send.refuses*` case.
|
|
1194
1450
|
**Why:** the transport answered where it had to throw: it caught the
|
|
@@ -1298,6 +1554,64 @@ told "not sent" retries, and the e-mail arrives twice.
|
|
|
1298
1554
|
**Fix:** call `checkMessage(message)` **before** the hand-over, and throw only
|
|
1299
1555
|
for a hand-over that did not succeed.
|
|
1300
1556
|
|
|
1557
|
+
### `conformance: the harness's delivered() reads back no attachments — read them from the receiving end, or skip send.attachment with the reason`
|
|
1558
|
+
|
|
1559
|
+
**When:** `send.attachment`, on a harness whose `delivered()` answers
|
|
1560
|
+
messages without an `attachments` field — typically one written before
|
|
1561
|
+
`@nxgt/mail` had attachments.
|
|
1562
|
+
**Why:** a missing field is not "no attachment arrived": the case cannot tell
|
|
1563
|
+
a transport that drops files from a harness that does not look. It fails
|
|
1564
|
+
rather than pass a transport it did not check.
|
|
1565
|
+
**Fix:** read the attachments back from the receiving end, decoded to bytes
|
|
1566
|
+
— `[]` when none arrived:
|
|
1567
|
+
|
|
1568
|
+
```ts
|
|
1569
|
+
import type { DeliveredMail } from '@nxgt/mail/conformance';
|
|
1570
|
+
import type { ParsedMail } from 'mailparser';
|
|
1571
|
+
|
|
1572
|
+
// Over SMTP, from what mailparser parsed:
|
|
1573
|
+
const fromSmtp = (parsed: ParsedMail): DeliveredMail['attachments'] =>
|
|
1574
|
+
parsed.attachments.map((file) => ({
|
|
1575
|
+
filename: file.filename ?? '',
|
|
1576
|
+
content: new Uint8Array(file.content),
|
|
1577
|
+
contentType: file.contentType,
|
|
1578
|
+
}));
|
|
1579
|
+
|
|
1580
|
+
// From a JSON body, where the content is base64:
|
|
1581
|
+
const fromJson = (files: { filename: string; content: string; contentType: string }[]) =>
|
|
1582
|
+
files.map((file) => ({
|
|
1583
|
+
...file,
|
|
1584
|
+
content: Uint8Array.from(atob(file.content), (char) => char.charCodeAt(0)),
|
|
1585
|
+
}));
|
|
1586
|
+
```
|
|
1587
|
+
|
|
1588
|
+
If the provider cannot carry attachments at all, say so:
|
|
1589
|
+
`skip: { 'send.attachment': 'the provider takes no attachments' }`.
|
|
1590
|
+
|
|
1591
|
+
### `conformance: expected 1 delivered attachment, got <n>`
|
|
1592
|
+
|
|
1593
|
+
**When:** `send.attachment`: the message arrived with no attachment, or
|
|
1594
|
+
with more than the one sent.
|
|
1595
|
+
**Why:** the transport left `message.attachments` out of what it handed
|
|
1596
|
+
over — the usual cause when a transport builds the provider's request field
|
|
1597
|
+
by field — or the harness reads the parts of the body as attachments too.
|
|
1598
|
+
**Fix:** map each attachment into the provider's request, as in
|
|
1599
|
+
[the transports guide](guide/transports.md#a-transport-over-http); in the
|
|
1600
|
+
harness, read only the parts the provider marks as attachments.
|
|
1601
|
+
|
|
1602
|
+
### `conformance: the attachment was not delivered byte for byte`
|
|
1603
|
+
|
|
1604
|
+
**When:** `send.attachment`, whose file holds every byte from 0 to 255.
|
|
1605
|
+
**Why:** the bytes were read as text on the way — decoded as UTF-8 (every
|
|
1606
|
+
byte above 127 changes), a NUL cut short, or line breaks rewritten — or
|
|
1607
|
+
base64 was encoded from a string instead of the bytes. Over JSON, the usual
|
|
1608
|
+
cause is a `Uint8Array` handed to `JSON.stringify` as it is: it becomes an
|
|
1609
|
+
object keyed by index (`{"0":0,"1":1,…}`), never base64, so the harness's
|
|
1610
|
+
`atob` throws or decodes something else.
|
|
1611
|
+
**Fix:** encode the bytes, never a string made of them: base64 from the
|
|
1612
|
+
`Uint8Array` for a JSON API, a `Buffer` of the same bytes for nodemailer.
|
|
1613
|
+
In the harness, decode base64 back to bytes, not to a string.
|
|
1614
|
+
|
|
1301
1615
|
### Other `conformance:` messages
|
|
1302
1616
|
|
|
1303
1617
|
Each names what the transport did not do, in the case whose id is in the
|
|
@@ -1316,7 +1630,12 @@ test title:
|
|
|
1316
1630
|
| `conformance: a line break in the subject must throw MailRefused` | `send.refusesLineBreakInSubject` | call `checkMessage` |
|
|
1317
1631
|
| `conformance: a malformed address must throw MailRefused` | `send.refusesWithoutTheValue` | call `checkMessage` |
|
|
1318
1632
|
| `conformance: a Bcc header must throw MailRefused` | `send.refusesAddressHeader` | call `checkMessage`, from a version of `@nxgt/mail` that refuses reserved headers |
|
|
1319
|
-
| `conformance:
|
|
1633
|
+
| `conformance: an attachment named with a path must throw MailRefused` | `send.refusesAttachmentPath` | call `checkMessage`, from a version of `@nxgt/mail` that checks attachments (0.2 on) |
|
|
1634
|
+
| `conformance: the refusal message holds the refused value` | `send.refusesWithoutTheValue`, `send.refusesAddressHeader`, `send.refusesAttachmentPath` | name where the problem is, never the value |
|
|
1635
|
+
| `conformance: the message with an attachment was not delivered` | `send.attachment` | a message with attachments is a message: deliver it |
|
|
1636
|
+
| `conformance: the attachment was not delivered with its file name` | `send.attachment` | pass the name as is; the name `reçu n° 42.pdf` needs RFC 2231 encoding in a raw header — nodemailer and a JSON API do it for you |
|
|
1637
|
+
| `conformance: the attachment was not delivered with its content type` | `send.attachment` | pass `contentType` through; do not guess it from the name |
|
|
1638
|
+
| `conformance: the parts of a message with an attachment were not delivered as sent` | `send.attachment` | keep the HTML and the text parts beside the attachment — `multipart/mixed` around `multipart/alternative` |
|
|
1320
1639
|
| `conformance: the send after a failure was not delivered` | `failure.recovers` | do not leave the transport broken after a failure: reopen the connection on the next send |
|
|
1321
1640
|
| `conformance: faults are required` | a `failure.*` case whose `run` you called yourself | pass `faults` in the context, or go through `runMailerCase`, which skips the case instead |
|
|
1322
1641
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/mail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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, 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/** 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/**\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 */\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",
|
|
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/** 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({ ...structuredClone(message), messageId });\n\t\t\treturn { messageId };\n\t\t},\n\t};\n}\n"
|
|
7
|
-
],
|
|
8
|
-
"mappings": "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",
|
|
9
|
-
"debugId": "44B7D62837EFA8BB64756E2164756E21",
|
|
10
|
-
"names": []
|
|
11
|
-
}
|