@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/dist/types.d.ts
CHANGED
|
@@ -24,6 +24,21 @@ export interface Rendered {
|
|
|
24
24
|
readonly html: string;
|
|
25
25
|
readonly text: string;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* A file sent with an e-mail, **as bytes**: never a path or a URL for the
|
|
29
|
+
* transport to read, never a stream. A large or sensitive file is a signed
|
|
30
|
+
* link in the template instead — a URL variable.
|
|
31
|
+
*
|
|
32
|
+
* `filename` is what the recipient's mail client shows and saves it as: no
|
|
33
|
+
* path (`/`, `\`, `.`, `..`), no line break, no control or format character.
|
|
34
|
+
* `contentType` is a bare `type/subtype`, as `application/pdf`, without
|
|
35
|
+
* parameters, and never a MIME container (`multipart/*`, `message/*`).
|
|
36
|
+
*/
|
|
37
|
+
export interface MailAttachment {
|
|
38
|
+
readonly filename: string;
|
|
39
|
+
readonly content: Uint8Array;
|
|
40
|
+
readonly contentType: string;
|
|
41
|
+
}
|
|
27
42
|
/**
|
|
28
43
|
* A rendered e-mail, addressed. What a {@link Mailer} sends.
|
|
29
44
|
*
|
|
@@ -42,6 +57,11 @@ export interface MailMessage extends Rendered {
|
|
|
42
57
|
* case — is refused.
|
|
43
58
|
*/
|
|
44
59
|
readonly headers?: Readonly<Record<string, string>>;
|
|
60
|
+
/**
|
|
61
|
+
* Files sent with the e-mail, in order. An empty list is the same as none.
|
|
62
|
+
* Each is bytes, checked by `checkMessage`: see {@link MailAttachment}.
|
|
63
|
+
*/
|
|
64
|
+
readonly attachments?: readonly MailAttachment[];
|
|
45
65
|
}
|
|
46
66
|
/** What a transport answers once it has handed a message over. */
|
|
47
67
|
export interface SentMail {
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,OAAO,GAChB,MAAM,GACN;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvD;;;;;GAKG;AACH,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAY,SAAQ,QAAQ;IAC5C,QAAQ,CAAC,EAAE,EAAE,OAAO,GAAG,SAAS,OAAO,EAAE,CAAC;IAC1C,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,OAAO,GAChB,MAAM,GACN;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvD;;;;;GAKG;AACH,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAY,SAAQ,QAAQ;IAC5C,QAAQ,CAAC,EAAE,EAAE,OAAO,GAAG,SAAS,OAAO,EAAE,CAAC;IAC1C,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;CACjD;AAED,kEAAkE;AAClE,MAAM,WAAW,QAAQ;IACxB;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,MAAM;IACtB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC9C"}
|
package/docs/README.md
CHANGED
|
@@ -8,8 +8,8 @@ mailer, transport, hand-over, refusal, failure — are defined once, in the
|
|
|
8
8
|
| Page | Read it when |
|
|
9
9
|
| --- | --- |
|
|
10
10
|
| [Rendering](guide/rendering.md) | You are turning a Maizzle build of `@nxgt/mail-i18n` into an e-mail with `createMailRenderer`: its options, typing it with the build's `MailEmails`, choosing the locale, escaping, URL variables, deploying the build, and every error |
|
|
11
|
-
| [Sending](guide/sending.md) | You are calling `mailer.send`: the `MailMessage` shape, addresses, headers, what `send` answers, and turning `MailFailure` and `MailRefused` into a response |
|
|
12
|
-
| [Testing](guide/testing.md) | You are testing code that sends e-mail with `createMemoryMailer`: reading the outbox, making a send fail, counting attempts |
|
|
11
|
+
| [Sending](guide/sending.md) | You are calling `mailer.send`: the `MailMessage` shape, addresses, headers, attachments, what `send` answers, and turning `MailFailure` and `MailRefused` into a response |
|
|
12
|
+
| [Testing](guide/testing.md) | You are testing code that sends e-mail with `createMemoryMailer`: reading the outbox and its attachments, making a send fail, counting attempts |
|
|
13
13
|
| [Locales](guide/locales.md) | You are choosing the locale an e-mail is rendered in, with `pickLocale` and `parseAcceptLanguage` |
|
|
14
14
|
| [Writing a transport](guide/transports.md) | You are implementing the `Mailer` port for a provider, and running `@nxgt/mail/conformance` against it |
|
|
15
15
|
| [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
|
package/docs/guide/sending.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Sending
|
|
2
2
|
|
|
3
3
|
This page is for calling `mailer.send`: the shape of what it takes, the
|
|
4
|
-
addresses and
|
|
4
|
+
addresses, headers and attachments it accepts, what it answers, and what it
|
|
5
|
+
throws.
|
|
5
6
|
|
|
6
7
|
```ts
|
|
7
8
|
import { createMemoryMailer } from '@nxgt/mail';
|
|
@@ -73,6 +74,13 @@ interface MailMessage extends Rendered {
|
|
|
73
74
|
readonly from?: Address;
|
|
74
75
|
readonly replyTo?: Address;
|
|
75
76
|
readonly headers?: Readonly<Record<string, string>>;
|
|
77
|
+
readonly attachments?: readonly MailAttachment[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface MailAttachment {
|
|
81
|
+
readonly filename: string;
|
|
82
|
+
readonly content: Uint8Array;
|
|
83
|
+
readonly contentType: string;
|
|
76
84
|
}
|
|
77
85
|
```
|
|
78
86
|
|
|
@@ -85,6 +93,7 @@ interface MailMessage extends Rendered {
|
|
|
85
93
|
| `from` | `Address` | no | The sender. `checkMessage` does not require one: a transport is usually wired with a default sender, and one without a default may refuse a message without `from` — see its documentation |
|
|
86
94
|
| `replyTo` | `Address` | no | Where replies go |
|
|
87
95
|
| `headers` | `Record<string, string>` | no | Extra headers, such as `List-Unsubscribe` |
|
|
96
|
+
| `attachments` | `readonly MailAttachment[]` | no | Files sent with the e-mail, in order, as bytes — see [Attachments](#attachments). An empty list is the same as none |
|
|
88
97
|
|
|
89
98
|
`Rendered` is what the renderer answers — `mails.render('verify-email', { name, link })`
|
|
90
99
|
fills the values only known at send time into a built Maizzle template — and a
|
|
@@ -214,6 +223,85 @@ const message: MailMessage = {
|
|
|
214
223
|
| `{ Bcc: 'eve@example.com' }` | `MailRefused`: `send: header Bcc is reserved — addresses, the subject and the MIME structure are never custom headers` |
|
|
215
224
|
| `{ 'content-type': 'text/plain' }` | `MailRefused`: `send: header content-type is reserved — …` |
|
|
216
225
|
|
|
226
|
+
## Attachments
|
|
227
|
+
|
|
228
|
+
An attachment is a file's **bytes**, its name and its type:
|
|
229
|
+
|
|
230
|
+
| Field | Type | Effect |
|
|
231
|
+
| --- | --- | --- |
|
|
232
|
+
| `filename` | `string` | The name the recipient's mail client shows and saves it as. Not empty, not `.` or `..`; no `/` or `\`, no line break, no control or format character. Accents and spaces are fine: the transport encodes the name |
|
|
233
|
+
| `content` | `Uint8Array` | The bytes, sent as they are. A Node `Buffer` is a `Uint8Array` |
|
|
234
|
+
| `contentType` | `string` | A bare `type/subtype`, as `application/pdf` or `text/calendar` — no parameters, and never `multipart/*` or `message/*`, which are not files. Nothing guesses it from the file name |
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { readFile } from 'node:fs/promises';
|
|
238
|
+
import type { Mailer } from '@nxgt/mail';
|
|
239
|
+
import { createMailRenderer } from '@nxgt/mail/renderer';
|
|
240
|
+
|
|
241
|
+
const mails = createMailRenderer({ dir: 'dist' });
|
|
242
|
+
|
|
243
|
+
export async function sendInvoice(mailer: Mailer, to: string, name: string, number: string): Promise<void> {
|
|
244
|
+
const pdf = await readFile(`invoices/${number}.pdf`); // your storage: a Buffer
|
|
245
|
+
await mailer.send({
|
|
246
|
+
to,
|
|
247
|
+
...mails.render('invoice', { name, number }),
|
|
248
|
+
attachments: [{ filename: `invoice-${number}.pdf`, content: pdf, contentType: 'application/pdf' }],
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Bytes from anywhere fit — a file read with `readFile`, a PDF your code just
|
|
254
|
+
generated, what `fetch` answered (`new Uint8Array(await response.arrayBuffer())`),
|
|
255
|
+
or text you encoded (`new TextEncoder().encode(csv)`).
|
|
256
|
+
|
|
257
|
+
**There is no `path`, no URL and no stream.** A transport never reads a file
|
|
258
|
+
from disk or fetches a URL to attach it: a value that came from outside —
|
|
259
|
+
a file name in a request, a link in a database — can then never make an
|
|
260
|
+
e-mail carry a file it should not. The SMTP transport tells nodemailer so
|
|
261
|
+
explicitly. Read the file yourself, where you decide which files may be read.
|
|
262
|
+
|
|
263
|
+
**A large or sensitive file is a link.** Every provider caps the whole
|
|
264
|
+
message — about 25 MB sending through Gmail, 40 MB at Resend once encoded —
|
|
265
|
+
and base64, which every transport uses on the way, makes a file a third
|
|
266
|
+
larger. A file in an e-mail also stays in an inbox forever, forwarded or not.
|
|
267
|
+
Put a signed, expiring URL in the template instead; a
|
|
268
|
+
[URL variable](rendering.md) is already checked (`http:`, `https:` or
|
|
269
|
+
`mailto:` only):
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
import type { Mailer } from '@nxgt/mail';
|
|
273
|
+
import { createMailRenderer } from '@nxgt/mail/renderer';
|
|
274
|
+
|
|
275
|
+
declare function signedUrl(key: string, expiresInSeconds: number): Promise<string>; // your storage
|
|
276
|
+
|
|
277
|
+
const mails = createMailRenderer({ dir: 'dist' });
|
|
278
|
+
|
|
279
|
+
export async function sendExport(mailer: Mailer, to: string, key: string): Promise<void> {
|
|
280
|
+
const link = await signedUrl(key, 24 * 3600);
|
|
281
|
+
await mailer.send({ to, ...mails.render('export-ready', { link }) });
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Inline images — a `cid:` the HTML points at — are not supported yet: a
|
|
286
|
+
logo belongs on an `https:` URL, which is what the templates of
|
|
287
|
+
`@nxgt/mail-ui` already use.
|
|
288
|
+
|
|
289
|
+
`checkMessage` refuses, naming where and never the file's name:
|
|
290
|
+
|
|
291
|
+
| Written | Answer |
|
|
292
|
+
| --- | --- |
|
|
293
|
+
| `attachments: pdf` — one, not in a list | a compile error; at run time `MailRefused`: `send: attachments must be an array` |
|
|
294
|
+
| `[null]` | `MailRefused`: `send: attachments[0] must be an object, as { filename, content, contentType }` |
|
|
295
|
+
| `{ filename, content: '%PDF-1.7', contentType }`, or `{ filename, path, contentType }` | a compile error; at run time `MailRefused`: `send: attachments[0].content must be a Uint8Array — the file's bytes, never a path or a URL` |
|
|
296
|
+
| `filename: 'invoices/42.pdf'`, `'..\\42.pdf'`, `'..'`, `''`, or one holding a line break or a right-to-left override | `MailRefused`: `send: attachments[0].filename must be a file name — not empty, not . or .., without / or \, a line break or a control character` |
|
|
297
|
+
| `contentType: 'text/plain; charset=utf-8'`, `'pdf'`, or `'message/rfc822'` | `MailRefused`: `send: attachments[0].contentType must be a file's type/subtype, as application/pdf — never multipart/* or message/*` |
|
|
298
|
+
| `attachments: []` | accepted: the same as none |
|
|
299
|
+
|
|
300
|
+
A message the provider refuses — too large, or an attachment it will not
|
|
301
|
+
carry — is a `MailRefused` from the transport (an SMTP `552`; a Resend `400`,
|
|
302
|
+
`413` or `422`), and sending it again unchanged fails again: send a link
|
|
303
|
+
instead.
|
|
304
|
+
|
|
217
305
|
## Errors
|
|
218
306
|
|
|
219
307
|
```ts
|
|
@@ -238,7 +326,7 @@ class MailRefused extends MailError {
|
|
|
238
326
|
| Code | Class | When | Sending it again |
|
|
239
327
|
| --- | --- | --- | --- |
|
|
240
328
|
| `MAIL_FAILED` | `MailFailure` | The transport could not hand the e-mail over: a refused connection, a timeout, a 5xx from the provider, an expired credential. The transport's error is the `cause`. **Nothing is known to have been sent**: after a timeout or a dropped connection the provider may have taken it all the same | May work later. Never report it as sent |
|
|
241
|
-
| `MAIL_REFUSED` | `MailRefused` | The e-mail itself was refused, before or by the transport: no recipient, something that is not an address, a line break in the subject or a header, a reserved header, or the provider answering that the message is malformed | Fails again, unchanged |
|
|
329
|
+
| `MAIL_REFUSED` | `MailRefused` | The e-mail itself was refused, before or by the transport: no recipient, something that is not an address, a line break in the subject or a header, a reserved header, an attachment that is not bytes or is badly named, or the provider answering that the message is malformed or too large | Fails again, unchanged |
|
|
242
330
|
|
|
243
331
|
`MailError` is **abstract**: catch it, test `instanceof MailError`, but
|
|
244
332
|
`new MailError(…)` does not compile — a bare one would pass a `code` check and
|
package/docs/guide/testing.md
CHANGED
|
@@ -56,6 +56,38 @@ headers['X-Ref'] = 'changed';
|
|
|
56
56
|
mailer.sent[0]?.headers; // { 'X-Ref': 'a' }
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
### Attachments in the outbox
|
|
60
|
+
|
|
61
|
+
An attachment is kept with its bytes **copied** when it is sent: a caller that
|
|
62
|
+
reuses or fills its buffer afterwards does not change what the outbox holds,
|
|
63
|
+
and each read of `sent` hands out a fresh copy again. A Node `Buffer` comes
|
|
64
|
+
back as a plain `Uint8Array` of the same bytes — compare the bytes, not the
|
|
65
|
+
class:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { expect, it } from 'bun:test';
|
|
69
|
+
import { createMemoryMailer } from '@nxgt/mail';
|
|
70
|
+
|
|
71
|
+
it('attaches the invoice', async () => {
|
|
72
|
+
const mailer = createMemoryMailer();
|
|
73
|
+
const pdf = Buffer.from('%PDF-1.7');
|
|
74
|
+
|
|
75
|
+
await mailer.send({
|
|
76
|
+
to: 'ada@example.com',
|
|
77
|
+
subject: 'Your invoice',
|
|
78
|
+
html: '<p>Your invoice is attached.</p>',
|
|
79
|
+
text: 'Your invoice is attached.',
|
|
80
|
+
attachments: [{ filename: 'invoice-42.pdf', content: pdf, contentType: 'application/pdf' }],
|
|
81
|
+
});
|
|
82
|
+
pdf.fill(0); // changes nothing in the outbox
|
|
83
|
+
|
|
84
|
+
const [file] = mailer.sent[0]?.attachments ?? [];
|
|
85
|
+
expect(file?.filename).toBe('invoice-42.pdf');
|
|
86
|
+
expect(file?.contentType).toBe('application/pdf');
|
|
87
|
+
expect(new TextDecoder().decode(file?.content)).toBe('%PDF-1.7');
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
59
91
|
## `failNext(error?)` — making a send fail
|
|
60
92
|
|
|
61
93
|
The next send that reaches the hand-over rejects with `error` — by default a
|
|
@@ -121,9 +153,11 @@ going, so an id is never reused within one mailer.
|
|
|
121
153
|
Exactly what every transport refuses, because it calls
|
|
122
154
|
[`checkMessage`](transports.md#checkmessage-first) first: no recipient, something
|
|
123
155
|
that is not an address, a line break in a name, the subject or a header, a
|
|
124
|
-
missing part
|
|
156
|
+
missing part, an attachment that is not bytes, or whose file name or type is
|
|
157
|
+
malformed. A test that passes against the memory mailer does not pass by
|
|
125
158
|
accident a message a real transport would refuse. The full list is in
|
|
126
|
-
[Sending](sending.md#addresses)
|
|
159
|
+
[Sending](sending.md#addresses) and
|
|
160
|
+
[Sending — attachments](sending.md#attachments).
|
|
127
161
|
|
|
128
162
|
## A realistic case — the failure path of a service
|
|
129
163
|
|
package/docs/guide/transports.md
CHANGED
|
@@ -50,9 +50,13 @@ A transport:
|
|
|
50
50
|
separate field when the API has one, a quoted or encoded display name in a
|
|
51
51
|
header otherwise. A name is free text: `Ada <mallory@example.test>, "Eve"`
|
|
52
52
|
is a name, and it must reach only its own address;
|
|
53
|
-
6. **
|
|
53
|
+
6. **sends each attachment's bytes as they are**, with its file name and its
|
|
54
|
+
type — base64 in a JSON body, a MIME part over SMTP, the name encoded when
|
|
55
|
+
it is not ASCII — and **never reads a file or fetches a URL** to attach
|
|
56
|
+
one: `MailAttachment` holds bytes only;
|
|
57
|
+
7. **never retries in secret**, never resolves `false`, never logs and
|
|
54
58
|
resolves;
|
|
55
|
-
|
|
59
|
+
8. **defines no error class of its own**. It throws the classes imported from
|
|
56
60
|
`@nxgt/mail`, declared as a required peer, so `error instanceof MailFailure`
|
|
57
61
|
holds in the application whichever transport threw it. `MailError` is
|
|
58
62
|
abstract, so a bare one cannot be thrown:
|
|
@@ -60,11 +64,15 @@ A transport:
|
|
|
60
64
|
```json
|
|
61
65
|
{
|
|
62
66
|
"peerDependencies": {
|
|
63
|
-
"@nxgt/mail": "^0.
|
|
67
|
+
"@nxgt/mail": "^0.2.0"
|
|
64
68
|
}
|
|
65
69
|
}
|
|
66
70
|
```
|
|
67
71
|
|
|
72
|
+
On `0.x`, a caret covers one minor: `^0.2.0` is `>=0.2.0 <0.3.0`. Declare the
|
|
73
|
+
minor whose `MailMessage` your transport reads — `0.2` is the one with
|
|
74
|
+
`attachments` — and release your transport when `@nxgt/mail` moves to the next.
|
|
75
|
+
|
|
68
76
|
An error's `message` reports a shape, never a value: never an address, a
|
|
69
77
|
subject, a link, an API key or a connection string. What the provider said goes
|
|
70
78
|
on `cause`.
|
|
@@ -92,6 +100,14 @@ Throws `MailRefused`, naming **where** the problem is and never the value:
|
|
|
92
100
|
| a header name that is not letters, digits and hyphens | `send: a header name must be letters, digits and hyphens` |
|
|
93
101
|
| a line break in a header value | `send: header X-Ref must be a string without a line break` |
|
|
94
102
|
| a header the transport writes from the message — `To`, `Cc`, `Bcc`, `From`, `Sender`, `Reply-To`, `Return-Path`, `Subject`, `MIME-Version`, `Content-*`, in any case | `send: header Bcc is reserved — addresses, the subject and the MIME structure are never custom headers` |
|
|
103
|
+
| `attachments` that is not an array | `send: attachments must be an array` |
|
|
104
|
+
| an attachment that is not an object | `send: attachments[0] must be an object, as { filename, content, contentType }` |
|
|
105
|
+
| an attachment whose `content` is not a `Uint8Array` — a string, a path, an `ArrayBuffer` | `send: attachments[0].content must be a Uint8Array — the file's bytes, never a path or a URL` |
|
|
106
|
+
| a file name that is empty, `.` or `..`, or holds `/`, `\`, a line break, a control character or a format character | `send: attachments[0].filename must be a file name — not empty, not . or .., without / or \, a line break or a control character` |
|
|
107
|
+
| a content type that is not a bare `type/subtype`, or is `multipart/*` or `message/*` | `send: attachments[0].contentType must be a file's type/subtype, as application/pdf — never multipart/* or message/*` |
|
|
108
|
+
|
|
109
|
+
An empty `attachments` is accepted, and is the same as none: send no
|
|
110
|
+
attachment field to the provider then.
|
|
95
111
|
|
|
96
112
|
Two helpers turn addresses into what a provider wants:
|
|
97
113
|
|
|
@@ -115,6 +131,15 @@ export interface HttpMailerOptions {
|
|
|
115
131
|
readonly fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
116
132
|
}
|
|
117
133
|
|
|
134
|
+
/** Base64 with no Node built-in, read in slices so a large file spreads no huge argument list. */
|
|
135
|
+
function base64Of(bytes: Uint8Array): string {
|
|
136
|
+
let binary = '';
|
|
137
|
+
for (let start = 0; start < bytes.length; start += 0x8000) {
|
|
138
|
+
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
|
|
139
|
+
}
|
|
140
|
+
return btoa(binary);
|
|
141
|
+
}
|
|
142
|
+
|
|
118
143
|
export function createHttpMailer(options: HttpMailerOptions): Mailer {
|
|
119
144
|
// Wiring mistakes: a bare TypeError, now, and never the value.
|
|
120
145
|
if (!/^https?:\/\//.test(options.endpoint)) {
|
|
@@ -128,13 +153,22 @@ export function createHttpMailer(options: HttpMailerOptions): Mailer {
|
|
|
128
153
|
return {
|
|
129
154
|
async send(message) {
|
|
130
155
|
checkMessage(message);
|
|
156
|
+
// A JSON API takes an attachment's bytes as base64.
|
|
157
|
+
// An empty list is none: the field is left out of the request.
|
|
158
|
+
const attachments = message.attachments?.length
|
|
159
|
+
? message.attachments.map((file) => ({
|
|
160
|
+
filename: file.filename,
|
|
161
|
+
content: base64Of(file.content),
|
|
162
|
+
contentType: file.contentType,
|
|
163
|
+
}))
|
|
164
|
+
: undefined;
|
|
131
165
|
|
|
132
166
|
let response: Response;
|
|
133
167
|
try {
|
|
134
168
|
response = await post(options.endpoint, {
|
|
135
169
|
method: 'POST',
|
|
136
170
|
headers: { authorization: `Bearer ${options.apiKey}`, 'content-type': 'application/json' },
|
|
137
|
-
body: JSON.stringify({ ...message, from: message.from ?? options.from }),
|
|
171
|
+
body: JSON.stringify({ ...message, from: message.from ?? options.from, attachments }),
|
|
138
172
|
});
|
|
139
173
|
} catch (cause) {
|
|
140
174
|
throw new MailFailure('send: the provider could not be reached', { cause });
|
|
@@ -204,16 +238,19 @@ and when `skip` names a case that does not exist
|
|
|
204
238
|
| `send.deliversBytes` | the subject, the HTML and the text are delivered byte for byte: accents, an emoji, `&` in a link | no |
|
|
205
239
|
| `send.recipients` | every recipient is delivered to, written as a string or with a name | no |
|
|
206
240
|
| `send.hostileName` | a name holding `<…>`, a comma and quotes — `Ada <mallory@example.test>, "Eve" <eve@example.test>;` — reaches only its own address: quoting the name is the transport's job | no |
|
|
241
|
+
| `send.attachment` | an attachment — `sampleAttachment`, every byte from 0 to 255 named `reçu n° 42.pdf`, `application/pdf` — is delivered byte for byte, with its file name and its type (compared without case), and the parts beside it as sent | no |
|
|
207
242
|
| `send.refusesNoRecipient` | no recipient throws `MailRefused`, and nothing is delivered | no |
|
|
208
243
|
| `send.refusesLineBreakInSubject` | a line break in the subject throws `MailRefused`, and nothing is delivered | no |
|
|
209
244
|
| `send.refusesAddressHeader` | a `Bcc` among the custom headers throws `MailRefused` without the address in its message, and nothing is delivered: it would add a recipient no check saw | no |
|
|
245
|
+
| `send.refusesAttachmentPath` | an attachment named with a path (`../…/report.pdf`) throws `MailRefused` without the name in its message, and nothing is delivered: a mail client could save it elsewhere | no |
|
|
210
246
|
| `send.refusesWithoutTheValue` | a refusal's `message` does not hold the refused value | no |
|
|
211
247
|
| `failure.outage` | an outage throws `MailFailure` — **the class from `@nxgt/mail`** — with code `MAIL_FAILED` and a `cause`; one attempt; nothing delivered | yes |
|
|
212
248
|
| `failure.refusal` | a provider's refusal throws `MailRefused` with code `MAIL_REFUSED` and a `cause`; one attempt | yes |
|
|
213
249
|
| `failure.recovers` | after a failure, the next send goes through | yes |
|
|
214
250
|
|
|
215
|
-
The message they send is exported as `sampleMessage`,
|
|
216
|
-
`
|
|
251
|
+
The message they send is exported as `sampleMessage`, its attachment as
|
|
252
|
+
`sampleAttachment`, and the cases as data:
|
|
253
|
+
`sendCases` (the ten `send.*`), `failureCases` (the three `failure.*`) and
|
|
217
254
|
`allMailerCases` (both, in the order above). A transport's own tests can reuse
|
|
218
255
|
them — send the sample through your transport, or run only the cases that
|
|
219
256
|
need no faults:
|
|
@@ -262,6 +299,7 @@ interface DeliveredMail {
|
|
|
262
299
|
readonly subject: string;
|
|
263
300
|
readonly html: string;
|
|
264
301
|
readonly text: string;
|
|
302
|
+
readonly attachments?: readonly MailAttachment[]; // as they arrived: [] when none did
|
|
265
303
|
}
|
|
266
304
|
|
|
267
305
|
interface MailerFaults {
|
|
@@ -274,7 +312,11 @@ interface MailerFaults {
|
|
|
274
312
|
fresh receiving end, so no case sees another's messages.
|
|
275
313
|
- `delivered()` reads back what **the receiving end** got — the test SMTP
|
|
276
314
|
server, the recorded request, the fake provider — not what the mailer was
|
|
277
|
-
asked to send.
|
|
315
|
+
asked to send. That includes each attachment, decoded back to bytes:
|
|
316
|
+
`mailparser`'s `attachments` over SMTP, the base64 `content` of a JSON body
|
|
317
|
+
otherwise. `attachments` is optional so a harness written before it still
|
|
318
|
+
compiles, but `send.attachment` **fails** on a harness that leaves it out,
|
|
319
|
+
saying so — read them back, or skip the case with its reason.
|
|
278
320
|
- `close()`, when present, is called after the case, pass or fail.
|
|
279
321
|
|
|
280
322
|
### Faults — failing the way the provider fails
|
|
@@ -290,11 +332,16 @@ translation of its provider's errors.
|
|
|
290
332
|
|
|
291
333
|
```ts
|
|
292
334
|
// fake-provider.ts
|
|
293
|
-
import { type MailMessage
|
|
335
|
+
import { addressOf, type MailMessage } from '@nxgt/mail';
|
|
294
336
|
import type { DeliveredMail, MailerFaults } from '@nxgt/mail/conformance';
|
|
295
337
|
|
|
338
|
+
/** What the HTTP mailer above posts: a message, its attachments' bytes as base64. */
|
|
339
|
+
type Posted = Omit<MailMessage, 'attachments'> & {
|
|
340
|
+
readonly attachments?: readonly { filename: string; content: string; contentType: string }[];
|
|
341
|
+
};
|
|
342
|
+
|
|
296
343
|
export function fakeProvider() {
|
|
297
|
-
const inbox:
|
|
344
|
+
const inbox: Posted[] = [];
|
|
298
345
|
let attempts = 0;
|
|
299
346
|
let next: 'outage' | 'refusal' | null = null;
|
|
300
347
|
|
|
@@ -315,18 +362,28 @@ export function fakeProvider() {
|
|
|
315
362
|
next = null;
|
|
316
363
|
if (fault === 'outage') return new Response('unavailable', { status: 503 });
|
|
317
364
|
if (fault === 'refusal') return Response.json({ error: 'malformed' }, { status: 422 });
|
|
318
|
-
inbox.push(JSON.parse(String(init.body)) as
|
|
365
|
+
inbox.push(JSON.parse(String(init.body)) as Posted);
|
|
319
366
|
return Response.json({ id: `fake-${inbox.length}` });
|
|
320
367
|
},
|
|
321
368
|
delivered(): DeliveredMail[] {
|
|
322
|
-
return inbox.map((mail) => ({
|
|
369
|
+
return inbox.map((mail) => ({
|
|
370
|
+
to: (Array.isArray(mail.to) ? mail.to : [mail.to]).map(addressOf),
|
|
371
|
+
subject: mail.subject,
|
|
372
|
+
html: mail.html,
|
|
373
|
+
text: mail.text,
|
|
374
|
+
attachments: (mail.attachments ?? []).map((file) => ({
|
|
375
|
+
filename: file.filename,
|
|
376
|
+
content: Uint8Array.from(atob(file.content), (char) => char.charCodeAt(0)),
|
|
377
|
+
contentType: file.contentType,
|
|
378
|
+
})),
|
|
379
|
+
}));
|
|
323
380
|
},
|
|
324
381
|
};
|
|
325
382
|
}
|
|
326
383
|
```
|
|
327
384
|
|
|
328
385
|
With the transport and the fake above, the example at the top of this page
|
|
329
|
-
passes all
|
|
386
|
+
passes all thirteen cases.
|
|
330
387
|
|
|
331
388
|
### Without faults
|
|
332
389
|
|
package/docs/roadmap.md
CHANGED
|
@@ -6,7 +6,16 @@ the only number.
|
|
|
6
6
|
|
|
7
7
|
## Now
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- **Attachments** — `attachments` on a `MailMessage`: each file's bytes as a
|
|
10
|
+
`Uint8Array`, its name and its type. Bytes only — no path, no URL, no
|
|
11
|
+
stream, so a transport never reads a file or fetches a URL for you; a large
|
|
12
|
+
or sensitive file stays a signed link in the template. `checkMessage`
|
|
13
|
+
refuses a name holding a path, a line break, a control or a format
|
|
14
|
+
character, `.` or `..`, and a type that is not `type/subtype` or is a MIME
|
|
15
|
+
container; the memory mailer keeps a copy of the
|
|
16
|
+
bytes; the conformance suite gains `send.attachment` and
|
|
17
|
+
`send.refusesAttachmentPath`, thirteen cases in all. The SMTP and Resend
|
|
18
|
+
transports send them. Built, not yet published.
|
|
10
19
|
|
|
11
20
|
## Next
|
|
12
21
|
|
|
@@ -14,6 +23,8 @@ Nothing yet.
|
|
|
14
23
|
|
|
15
24
|
## Later
|
|
16
25
|
|
|
26
|
+
- **Inline images (`cid:`)** — an attachment the HTML shows by its content
|
|
27
|
+
id. Until then, an image is an `https:` URL, as `@nxgt/mail-ui`'s logo is.
|
|
17
28
|
- **More transports** — Amazon SES, Postmark and Mailgun, one package each,
|
|
18
29
|
each passing the conformance suite and throwing `@nxgt/mail`'s errors.
|
|
19
30
|
|