@nxgt/mail 0.1.0 → 0.2.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 +65 -4
- package/dist/chunks/{index-0f7kdb8k.js → index-4h39j3n7.js} +43 -3
- package/dist/chunks/index-4h39j3n7.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.map +1 -1
- package/dist/message.d.ts +6 -1
- package/dist/message.d.ts.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/README.md +2 -2
- package/docs/guide/sending.md +90 -2
- package/docs/guide/testing.md +36 -2
- package/docs/guide/transports.md +69 -12
- package/docs/roadmap.md +12 -1
- package/docs/troubleshooting.md +233 -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,11 @@ 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)
|
|
58
67
|
- [`send: the memory mailer was told to fail this send`](#send-the-memory-mailer-was-told-to-fail-this-send)
|
|
59
68
|
|
|
60
69
|
**Locale**
|
|
@@ -96,6 +105,9 @@ How the messages are shaped:
|
|
|
96
105
|
- [`conformance: the transport retried a failed hand-over`](#conformance-the-transport-retried-a-failed-hand-over)
|
|
97
106
|
- [`conformance: a name let a second recipient through`](#conformance-a-name-let-a-second-recipient-through)
|
|
98
107
|
- [`conformance: <what>, yet something was delivered`](#conformance-what-yet-something-was-delivered)
|
|
108
|
+
- [`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)
|
|
109
|
+
- [`conformance: expected 1 delivered attachment, got <n>`](#conformance-expected-1-delivered-attachment-got-n)
|
|
110
|
+
- [`conformance: the attachment was not delivered byte for byte`](#conformance-the-attachment-was-not-delivered-byte-for-byte)
|
|
99
111
|
- [Other `conformance:` messages](#other-conformance-messages)
|
|
100
112
|
- [A bug in `@nxgt/mail` itself](#a-bug-in-nxgtmail-itself)
|
|
101
113
|
|
|
@@ -340,6 +352,64 @@ To go without it, leave the type parameter out:
|
|
|
340
352
|
`createMailRenderer({ dir: 'dist' })` takes any name and any
|
|
341
353
|
`MailVariables`, checked at run time only.
|
|
342
354
|
|
|
355
|
+
### `TS2322: Type 'string' is not assignable to type 'Uint8Array<ArrayBufferLike>'.`
|
|
356
|
+
|
|
357
|
+
**When:** `tsc`, on an attachment whose `content` is text:
|
|
358
|
+
`{ filename: 'notes.txt', content: 'notes', contentType: 'text/plain' }`.
|
|
359
|
+
**Why:** an attachment is bytes. A string would be sent in whatever encoding
|
|
360
|
+
the transport picked; bytes are sent as they are.
|
|
361
|
+
**Fix:** encode the text yourself, in the encoding you mean:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
import type { MailAttachment } from '@nxgt/mail';
|
|
365
|
+
|
|
366
|
+
declare const csv: string;
|
|
367
|
+
|
|
368
|
+
const report: MailAttachment = {
|
|
369
|
+
filename: 'report.csv',
|
|
370
|
+
content: new TextEncoder().encode(csv), // UTF-8
|
|
371
|
+
contentType: 'text/csv',
|
|
372
|
+
};
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### `TS2353: Object literal may only specify known properties, and 'path' does not exist in type 'MailAttachment'.`
|
|
376
|
+
|
|
377
|
+
**When:** `tsc`, on an attachment written as nodemailer's, with a `path` (or
|
|
378
|
+
an `href`) for the transport to read.
|
|
379
|
+
**Why:** no transport reads a file or fetches a URL to attach it — a value
|
|
380
|
+
from outside could then make an e-mail carry any file the server can read.
|
|
381
|
+
`MailAttachment` holds the bytes.
|
|
382
|
+
**Fix:** read the file where your code decides which files may be read, and
|
|
383
|
+
pass its bytes:
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
import { readFile } from 'node:fs/promises';
|
|
387
|
+
import type { MailAttachment } from '@nxgt/mail';
|
|
388
|
+
|
|
389
|
+
const invoice: MailAttachment = {
|
|
390
|
+
filename: 'invoice-42.pdf',
|
|
391
|
+
content: await readFile('/srv/invoices/42.pdf'),
|
|
392
|
+
contentType: 'application/pdf',
|
|
393
|
+
};
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
A file too large to hold in memory is too large for an e-mail: send a signed
|
|
397
|
+
link instead — [Sending — attachments](guide/sending.md#attachments).
|
|
398
|
+
|
|
399
|
+
### `TS2741: Property 'contentType' is missing in type '…' but required in type 'MailAttachment'.`
|
|
400
|
+
|
|
401
|
+
**When:** `tsc`, on an attachment without its `contentType`.
|
|
402
|
+
**Why:** nothing guesses the type from the file name: a guess can be wrong,
|
|
403
|
+
and a mail client opens a file by its type.
|
|
404
|
+
**Fix:** name it — `application/pdf`, `text/calendar`, `image/png`, or
|
|
405
|
+
`application/octet-stream` for bytes of no particular type.
|
|
406
|
+
|
|
407
|
+
### `TS2740: Type 'MailAttachment' is missing the following properties from type 'readonly MailAttachment[]': length, concat, join, slice, and 26 more.`
|
|
408
|
+
|
|
409
|
+
**When:** `tsc`, on `attachments: invoice` — one attachment, not in a list.
|
|
410
|
+
**Why:** `attachments` is a list, even of one.
|
|
411
|
+
**Fix:** `attachments: [invoice]`.
|
|
412
|
+
|
|
343
413
|
### `error instanceof MailFailure` is `false` for an outage
|
|
344
414
|
|
|
345
415
|
**When:** at run time, with a transport from another package or your own. An
|
|
@@ -423,9 +493,10 @@ whether the e-mail is still worth sending.
|
|
|
423
493
|
**When:** `await mailer.send(message)` rejects, either with one of the
|
|
424
494
|
`send: …` messages below (the message was refused before it left), or with
|
|
425
495
|
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
|
|
496
|
+
malformed or too large.
|
|
497
|
+
**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.
|
|
429
500
|
**Fix:** read `error.message` for where the problem is, and fix the message;
|
|
430
501
|
the entries below cover each one. Handle the code as in the
|
|
431
502
|
[`MAIL_FAILED`](#mail_failed--mailfailure-the-transport-could-not-hand-the-message-over)
|
|
@@ -592,6 +663,99 @@ await mailer.send({ ...rendered, to: 'ada@example.com' });
|
|
|
592
663
|
await mailer.send({ ...rendered, to: 'audit@example.com' }); // ✓ the copy, on its own
|
|
593
664
|
```
|
|
594
665
|
|
|
666
|
+
### `send: attachments must be an array`
|
|
667
|
+
|
|
668
|
+
**When:** `send`, with `attachments` that is not a list — one attachment on
|
|
669
|
+
its own, or `null`.
|
|
670
|
+
**Why:** the attachments are a list, in order, even of one.
|
|
671
|
+
**Fix:** `attachments: [invoice]`; leave the field out, or pass `[]`, for none.
|
|
672
|
+
|
|
673
|
+
### `send: attachments[<n>] must be an object, as { filename, content, contentType }`
|
|
674
|
+
|
|
675
|
+
**When:** `send`, with an entry of `attachments` that is not an object —
|
|
676
|
+
`undefined` from a lookup that found nothing, `null`, or a hole in the list
|
|
677
|
+
(`[, pdf]`, `new Array(2)`). `<n>` is its index.
|
|
678
|
+
**Why:** each entry is one file: its name, its bytes and its type.
|
|
679
|
+
**Fix:** filter the list before sending, or refuse to send when a file you
|
|
680
|
+
meant to attach is missing — an e-mail that says "attached" with nothing
|
|
681
|
+
attached is worse than an error.
|
|
682
|
+
|
|
683
|
+
### `send: attachments[<n>].content must be a Uint8Array — the file's bytes, never a path or a URL`
|
|
684
|
+
|
|
685
|
+
**When:** `send`, with an attachment whose `content` is not a `Uint8Array`:
|
|
686
|
+
a string, an `ArrayBuffer`, a stream, or no `content` at all because the
|
|
687
|
+
attachment was written with a `path` or an `href`, as nodemailer takes them.
|
|
688
|
+
A Node `Buffer` is a `Uint8Array`, and accepted.
|
|
689
|
+
**Why:** an attachment is bytes the application already holds. No
|
|
690
|
+
transport reads a file or fetches a URL to attach it, so a value from outside
|
|
691
|
+
can never make an e-mail carry a file it should not.
|
|
692
|
+
**Fix:** read or convert it first:
|
|
693
|
+
|
|
694
|
+
```ts
|
|
695
|
+
import { readFile } from 'node:fs/promises';
|
|
696
|
+
|
|
697
|
+
declare const csv: string;
|
|
698
|
+
declare const response: Response;
|
|
699
|
+
|
|
700
|
+
const fromDisk = await readFile('/srv/invoices/42.pdf'); // a Buffer
|
|
701
|
+
const fromText = new TextEncoder().encode(csv);
|
|
702
|
+
const fromFetch = new Uint8Array(await response.arrayBuffer());
|
|
703
|
+
const fromArrayBuffer = new Uint8Array(new ArrayBuffer(8));
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
A file too large to read into memory is too large for an e-mail: send a
|
|
707
|
+
signed link instead.
|
|
708
|
+
|
|
709
|
+
### `send: attachments[<n>].filename must be a file name — not empty, not . or .., without / or \, a line break or a control character`
|
|
710
|
+
|
|
711
|
+
**When:** `send`, with an attachment whose `filename` is empty, is not a
|
|
712
|
+
string, is `.` or `..`, or holds `/`, `\`, a line break (U+2028 and U+2029
|
|
713
|
+
included), a NUL or another control character, or a format character such
|
|
714
|
+
as a right-to-left override.
|
|
715
|
+
The message never holds the name.
|
|
716
|
+
**Why:** the recipient's mail client shows the name and saves the file under
|
|
717
|
+
it. A path — `../…`, `invoices/42.pdf`, `C:\…`, `..` — asks it to save
|
|
718
|
+
elsewhere, a line break or a control character can split the header the name
|
|
719
|
+
is written in, and a right-to-left override shows `invoice\u202Efdp.exe` as
|
|
720
|
+
`invoiceexe.pdf`.
|
|
721
|
+
**Fix:** a bare file name. Accents, spaces and parentheses are fine — the
|
|
722
|
+
transport encodes them. From a name you did not write, keep the last segment
|
|
723
|
+
of the path and drop the control characters:
|
|
724
|
+
|
|
725
|
+
```ts
|
|
726
|
+
const safeName = (name: string) =>
|
|
727
|
+
name.split(/[\\/]/).pop()?.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '').trim().replace(/^\.\.?$/, '') ||
|
|
728
|
+
'attachment';
|
|
729
|
+
|
|
730
|
+
safeName('../uploads/Relevé\nmars.pdf'); // 'Relevémars.pdf'
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
### `send: attachments[<n>].contentType must be a file's type/subtype, as application/pdf — never multipart/* or message/*`
|
|
734
|
+
|
|
735
|
+
**When:** `send`, with an attachment whose `contentType` is not a bare
|
|
736
|
+
`type/subtype`: an extension (`pdf`), a type with parameters
|
|
737
|
+
(`text/plain; charset=utf-8`), a space or a line break — or a MIME container,
|
|
738
|
+
`multipart/*` or `message/*`, in any case.
|
|
739
|
+
**Why:** the type is written into the attachment's `Content-Type` header; a
|
|
740
|
+
parameter there is a second, unchecked place for a name or a charset. The
|
|
741
|
+
transport writes the parameters it needs. A container is not a file: SMTP
|
|
742
|
+
writes `message/rfc822` and `multipart/mixed` unencoded, as parts of the
|
|
743
|
+
message itself, and the recipient gets no attachment while `send` resolves.
|
|
744
|
+
To forward an e-mail, attach it as `application/octet-stream` with a `.eml`
|
|
745
|
+
name.
|
|
746
|
+
**Fix:** the bare type — `text/plain`, and encode the text as UTF-8, which is
|
|
747
|
+
what a mail client assumes:
|
|
748
|
+
|
|
749
|
+
```ts
|
|
750
|
+
import type { MailAttachment } from '@nxgt/mail';
|
|
751
|
+
|
|
752
|
+
const notes: MailAttachment = {
|
|
753
|
+
filename: 'notes.txt',
|
|
754
|
+
content: new TextEncoder().encode('Hello'),
|
|
755
|
+
contentType: 'text/plain',
|
|
756
|
+
};
|
|
757
|
+
```
|
|
758
|
+
|
|
595
759
|
### `send: the memory mailer was told to fail this send`
|
|
596
760
|
|
|
597
761
|
**When:** a test, on a send through `createMemoryMailer()` after
|
|
@@ -1188,7 +1352,8 @@ simulated, so the skip is on purpose and visible.
|
|
|
1188
1352
|
|
|
1189
1353
|
`<send>` is, for example, `a send during an outage`, `a refused send`,
|
|
1190
1354
|
`a send with no recipient`, `a send with a line break in the subject`,
|
|
1191
|
-
`a send with a Bcc header
|
|
1355
|
+
`a send with a Bcc header`, `a send with an attachment named with a path` or
|
|
1356
|
+
`a send to something that is not an address`.
|
|
1192
1357
|
|
|
1193
1358
|
**When:** a `failure.*` or `send.refuses*` case.
|
|
1194
1359
|
**Why:** the transport answered where it had to throw: it caught the
|
|
@@ -1298,6 +1463,64 @@ told "not sent" retries, and the e-mail arrives twice.
|
|
|
1298
1463
|
**Fix:** call `checkMessage(message)` **before** the hand-over, and throw only
|
|
1299
1464
|
for a hand-over that did not succeed.
|
|
1300
1465
|
|
|
1466
|
+
### `conformance: the harness's delivered() reads back no attachments — read them from the receiving end, or skip send.attachment with the reason`
|
|
1467
|
+
|
|
1468
|
+
**When:** `send.attachment`, on a harness whose `delivered()` answers
|
|
1469
|
+
messages without an `attachments` field — typically one written before
|
|
1470
|
+
`@nxgt/mail` had attachments.
|
|
1471
|
+
**Why:** a missing field is not "no attachment arrived": the case cannot tell
|
|
1472
|
+
a transport that drops files from a harness that does not look. It fails
|
|
1473
|
+
rather than pass a transport it did not check.
|
|
1474
|
+
**Fix:** read the attachments back from the receiving end, decoded to bytes
|
|
1475
|
+
— `[]` when none arrived:
|
|
1476
|
+
|
|
1477
|
+
```ts
|
|
1478
|
+
import type { DeliveredMail } from '@nxgt/mail/conformance';
|
|
1479
|
+
import type { ParsedMail } from 'mailparser';
|
|
1480
|
+
|
|
1481
|
+
// Over SMTP, from what mailparser parsed:
|
|
1482
|
+
const fromSmtp = (parsed: ParsedMail): DeliveredMail['attachments'] =>
|
|
1483
|
+
parsed.attachments.map((file) => ({
|
|
1484
|
+
filename: file.filename ?? '',
|
|
1485
|
+
content: new Uint8Array(file.content),
|
|
1486
|
+
contentType: file.contentType,
|
|
1487
|
+
}));
|
|
1488
|
+
|
|
1489
|
+
// From a JSON body, where the content is base64:
|
|
1490
|
+
const fromJson = (files: { filename: string; content: string; contentType: string }[]) =>
|
|
1491
|
+
files.map((file) => ({
|
|
1492
|
+
...file,
|
|
1493
|
+
content: Uint8Array.from(atob(file.content), (char) => char.charCodeAt(0)),
|
|
1494
|
+
}));
|
|
1495
|
+
```
|
|
1496
|
+
|
|
1497
|
+
If the provider cannot carry attachments at all, say so:
|
|
1498
|
+
`skip: { 'send.attachment': 'the provider takes no attachments' }`.
|
|
1499
|
+
|
|
1500
|
+
### `conformance: expected 1 delivered attachment, got <n>`
|
|
1501
|
+
|
|
1502
|
+
**When:** `send.attachment`: the message arrived with no attachment, or
|
|
1503
|
+
with more than the one sent.
|
|
1504
|
+
**Why:** the transport left `message.attachments` out of what it handed
|
|
1505
|
+
over — the usual cause when a transport builds the provider's request field
|
|
1506
|
+
by field — or the harness reads the parts of the body as attachments too.
|
|
1507
|
+
**Fix:** map each attachment into the provider's request, as in
|
|
1508
|
+
[the transports guide](guide/transports.md#a-transport-over-http); in the
|
|
1509
|
+
harness, read only the parts the provider marks as attachments.
|
|
1510
|
+
|
|
1511
|
+
### `conformance: the attachment was not delivered byte for byte`
|
|
1512
|
+
|
|
1513
|
+
**When:** `send.attachment`, whose file holds every byte from 0 to 255.
|
|
1514
|
+
**Why:** the bytes were read as text on the way — decoded as UTF-8 (every
|
|
1515
|
+
byte above 127 changes), a NUL cut short, or line breaks rewritten — or
|
|
1516
|
+
base64 was encoded from a string instead of the bytes. Over JSON, the usual
|
|
1517
|
+
cause is a `Uint8Array` handed to `JSON.stringify` as it is: it becomes an
|
|
1518
|
+
object keyed by index (`{"0":0,"1":1,…}`), never base64, so the harness's
|
|
1519
|
+
`atob` throws or decodes something else.
|
|
1520
|
+
**Fix:** encode the bytes, never a string made of them: base64 from the
|
|
1521
|
+
`Uint8Array` for a JSON API, a `Buffer` of the same bytes for nodemailer.
|
|
1522
|
+
In the harness, decode base64 back to bytes, not to a string.
|
|
1523
|
+
|
|
1301
1524
|
### Other `conformance:` messages
|
|
1302
1525
|
|
|
1303
1526
|
Each names what the transport did not do, in the case whose id is in the
|
|
@@ -1316,7 +1539,12 @@ test title:
|
|
|
1316
1539
|
| `conformance: a line break in the subject must throw MailRefused` | `send.refusesLineBreakInSubject` | call `checkMessage` |
|
|
1317
1540
|
| `conformance: a malformed address must throw MailRefused` | `send.refusesWithoutTheValue` | call `checkMessage` |
|
|
1318
1541
|
| `conformance: a Bcc header must throw MailRefused` | `send.refusesAddressHeader` | call `checkMessage`, from a version of `@nxgt/mail` that refuses reserved headers |
|
|
1319
|
-
| `conformance:
|
|
1542
|
+
| `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) |
|
|
1543
|
+
| `conformance: the refusal message holds the refused value` | `send.refusesWithoutTheValue`, `send.refusesAddressHeader`, `send.refusesAttachmentPath` | name where the problem is, never the value |
|
|
1544
|
+
| `conformance: the message with an attachment was not delivered` | `send.attachment` | a message with attachments is a message: deliver it |
|
|
1545
|
+
| `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 |
|
|
1546
|
+
| `conformance: the attachment was not delivered with its content type` | `send.attachment` | pass `contentType` through; do not guess it from the name |
|
|
1547
|
+
| `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
1548
|
| `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
1549
|
| `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
1550
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/mail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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
|
-
}
|