@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/guide/transports.md
CHANGED
|
@@ -50,9 +50,16 @@ 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. **uses `idempotencyKey` if the provider deduplicates, and ignores it
|
|
58
|
+
otherwise** — see [The idempotency key](#the-idempotency-key). It never
|
|
59
|
+
refuses a message for carrying one, and never writes it into the e-mail;
|
|
60
|
+
8. **never retries in secret**, never resolves `false`, never logs and
|
|
54
61
|
resolves;
|
|
55
|
-
|
|
62
|
+
9. **defines no error class of its own**. It throws the classes imported from
|
|
56
63
|
`@nxgt/mail`, declared as a required peer, so `error instanceof MailFailure`
|
|
57
64
|
holds in the application whichever transport threw it. `MailError` is
|
|
58
65
|
abstract, so a bare one cannot be thrown:
|
|
@@ -60,11 +67,16 @@ A transport:
|
|
|
60
67
|
```json
|
|
61
68
|
{
|
|
62
69
|
"peerDependencies": {
|
|
63
|
-
"@nxgt/mail": "^0.
|
|
70
|
+
"@nxgt/mail": "^0.3.0"
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
```
|
|
67
74
|
|
|
75
|
+
On `0.x`, a caret covers one minor: `^0.3.0` is `>=0.3.0 <0.4.0`. Declare the
|
|
76
|
+
minor whose `MailMessage` your transport reads — `0.2` is the one with
|
|
77
|
+
`attachments`, `0.3` the one with `idempotencyKey` — and release your
|
|
78
|
+
transport when `@nxgt/mail` moves to the next.
|
|
79
|
+
|
|
68
80
|
An error's `message` reports a shape, never a value: never an address, a
|
|
69
81
|
subject, a link, an API key or a connection string. What the provider said goes
|
|
70
82
|
on `cause`.
|
|
@@ -92,6 +104,17 @@ Throws `MailRefused`, naming **where** the problem is and never the value:
|
|
|
92
104
|
| a header name that is not letters, digits and hyphens | `send: a header name must be letters, digits and hyphens` |
|
|
93
105
|
| a line break in a header value | `send: header X-Ref must be a string without a line break` |
|
|
94
106
|
| 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` |
|
|
107
|
+
| `attachments` that is not an array | `send: attachments must be an array` |
|
|
108
|
+
| an attachment that is not an object | `send: attachments[0] must be an object, as { filename, content, contentType }` |
|
|
109
|
+
| 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` |
|
|
110
|
+
| 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` |
|
|
111
|
+
| 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/*` |
|
|
112
|
+
| an `idempotencyKey` that is not 1 to 256 visible ASCII characters — empty, a space, a line break, a letter outside ASCII, not a string | `send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt` |
|
|
113
|
+
|
|
114
|
+
An empty `attachments` is accepted, and is the same as none: send no
|
|
115
|
+
attachment field to the provider then. A key that passes is safe to write in
|
|
116
|
+
an HTTP header as it is: no line break, no character a header would need to
|
|
117
|
+
encode.
|
|
95
118
|
|
|
96
119
|
Two helpers turn addresses into what a provider wants:
|
|
97
120
|
|
|
@@ -115,6 +138,15 @@ export interface HttpMailerOptions {
|
|
|
115
138
|
readonly fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
116
139
|
}
|
|
117
140
|
|
|
141
|
+
/** Base64 with no Node built-in, read in slices so a large file spreads no huge argument list. */
|
|
142
|
+
function base64Of(bytes: Uint8Array): string {
|
|
143
|
+
let binary = '';
|
|
144
|
+
for (let start = 0; start < bytes.length; start += 0x8000) {
|
|
145
|
+
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
|
|
146
|
+
}
|
|
147
|
+
return btoa(binary);
|
|
148
|
+
}
|
|
149
|
+
|
|
118
150
|
export function createHttpMailer(options: HttpMailerOptions): Mailer {
|
|
119
151
|
// Wiring mistakes: a bare TypeError, now, and never the value.
|
|
120
152
|
if (!/^https?:\/\//.test(options.endpoint)) {
|
|
@@ -128,13 +160,29 @@ export function createHttpMailer(options: HttpMailerOptions): Mailer {
|
|
|
128
160
|
return {
|
|
129
161
|
async send(message) {
|
|
130
162
|
checkMessage(message);
|
|
163
|
+
// The key names the send; it is not part of the e-mail, so it stays out of the body.
|
|
164
|
+
const { idempotencyKey, ...fields } = message;
|
|
165
|
+
// A JSON API takes an attachment's bytes as base64.
|
|
166
|
+
// An empty list is none: the field is left out of the request.
|
|
167
|
+
const attachments = message.attachments?.length
|
|
168
|
+
? message.attachments.map((file) => ({
|
|
169
|
+
filename: file.filename,
|
|
170
|
+
content: base64Of(file.content),
|
|
171
|
+
contentType: file.contentType,
|
|
172
|
+
}))
|
|
173
|
+
: undefined;
|
|
131
174
|
|
|
132
175
|
let response: Response;
|
|
133
176
|
try {
|
|
134
177
|
response = await post(options.endpoint, {
|
|
135
178
|
method: 'POST',
|
|
136
|
-
headers: {
|
|
137
|
-
|
|
179
|
+
headers: {
|
|
180
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
181
|
+
'content-type': 'application/json',
|
|
182
|
+
// This provider deduplicates on a header; with one that does not, leave the key out.
|
|
183
|
+
...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }),
|
|
184
|
+
},
|
|
185
|
+
body: JSON.stringify({ ...fields, from: message.from ?? options.from, attachments }),
|
|
138
186
|
});
|
|
139
187
|
} catch (cause) {
|
|
140
188
|
throw new MailFailure('send: the provider could not be reached', { cause });
|
|
@@ -156,6 +204,51 @@ export function createHttpMailer(options: HttpMailerOptions): Mailer {
|
|
|
156
204
|
}
|
|
157
205
|
```
|
|
158
206
|
|
|
207
|
+
## The idempotency key
|
|
208
|
+
|
|
209
|
+
`idempotencyKey` names a send, so that sending the same message again — a
|
|
210
|
+
retry after a timeout, a job run twice — delivers it once. What a transport
|
|
211
|
+
does with it depends on its provider:
|
|
212
|
+
|
|
213
|
+
| The provider | The transport |
|
|
214
|
+
| --- | --- |
|
|
215
|
+
| deduplicates requests on a key — an `Idempotency-Key` header, a field of its API | passes the key there, as it is. A repeated key answers the first send's id: resolve with it, as for any hand-over. Map the provider's answers: a key reused for a **different** message is a `MailRefused` (sending it again fails again); a key whose first send is **still in progress** is a `MailFailure` (a later retry may work) |
|
|
216
|
+
| has no such mechanism — SMTP, most relays | ignores the key. It does not refuse the message, and does not emulate deduplication with state of its own: a cache in one process is not what the caller was promised |
|
|
217
|
+
|
|
218
|
+
Either way, **never put the key in the e-mail** — not in the body, not as a
|
|
219
|
+
header the recipient receives. It names the send, not the message, and it is
|
|
220
|
+
derived from what the e-mail is about (`order-42/receipt`): a caller did not
|
|
221
|
+
choose to show it. `@nxgt/mail-resend` sends it as Resend's `Idempotency-Key`;
|
|
222
|
+
`@nxgt/mail-smtp` ignores it.
|
|
223
|
+
|
|
224
|
+
Document which one yours does, and for how long the provider remembers a key:
|
|
225
|
+
past that window, a retry delivers again.
|
|
226
|
+
|
|
227
|
+
The conformance suite has no case for it — a transport that ignores the key
|
|
228
|
+
is as correct as one that honours it. Test it in your own specs: the key
|
|
229
|
+
reaches the provider where it should, and nowhere else.
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { expect, test } from 'bun:test';
|
|
233
|
+
import { sampleMessage } from '@nxgt/mail/conformance';
|
|
234
|
+
import { createHttpMailer } from './http-mailer';
|
|
235
|
+
|
|
236
|
+
test('sends the idempotency key as a header, never in the body', async () => {
|
|
237
|
+
const requests: { headers: Headers; body: Record<string, unknown> }[] = [];
|
|
238
|
+
const mailer = createHttpMailer({
|
|
239
|
+
endpoint: 'https://mail.example.test/send',
|
|
240
|
+
apiKey: 'test',
|
|
241
|
+
fetch: async (_url, init) => {
|
|
242
|
+
requests.push({ headers: new Headers(init.headers), body: JSON.parse(String(init.body)) });
|
|
243
|
+
return Response.json({ id: 'm-1' });
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
await mailer.send({ ...sampleMessage, idempotencyKey: 'order-42/receipt' });
|
|
247
|
+
expect(requests[0]?.headers.get('idempotency-key')).toBe('order-42/receipt');
|
|
248
|
+
expect('idempotencyKey' in (requests[0]?.body ?? {})).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
159
252
|
## The conformance suite
|
|
160
253
|
|
|
161
254
|
```ts
|
|
@@ -204,16 +297,19 @@ and when `skip` names a case that does not exist
|
|
|
204
297
|
| `send.deliversBytes` | the subject, the HTML and the text are delivered byte for byte: accents, an emoji, `&` in a link | no |
|
|
205
298
|
| `send.recipients` | every recipient is delivered to, written as a string or with a name | no |
|
|
206
299
|
| `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 |
|
|
300
|
+
| `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
301
|
| `send.refusesNoRecipient` | no recipient throws `MailRefused`, and nothing is delivered | no |
|
|
208
302
|
| `send.refusesLineBreakInSubject` | a line break in the subject throws `MailRefused`, and nothing is delivered | no |
|
|
209
303
|
| `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 |
|
|
304
|
+
| `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
305
|
| `send.refusesWithoutTheValue` | a refusal's `message` does not hold the refused value | no |
|
|
211
306
|
| `failure.outage` | an outage throws `MailFailure` — **the class from `@nxgt/mail`** — with code `MAIL_FAILED` and a `cause`; one attempt; nothing delivered | yes |
|
|
212
307
|
| `failure.refusal` | a provider's refusal throws `MailRefused` with code `MAIL_REFUSED` and a `cause`; one attempt | yes |
|
|
213
308
|
| `failure.recovers` | after a failure, the next send goes through | yes |
|
|
214
309
|
|
|
215
|
-
The message they send is exported as `sampleMessage`,
|
|
216
|
-
`
|
|
310
|
+
The message they send is exported as `sampleMessage`, its attachment as
|
|
311
|
+
`sampleAttachment`, and the cases as data:
|
|
312
|
+
`sendCases` (the ten `send.*`), `failureCases` (the three `failure.*`) and
|
|
217
313
|
`allMailerCases` (both, in the order above). A transport's own tests can reuse
|
|
218
314
|
them — send the sample through your transport, or run only the cases that
|
|
219
315
|
need no faults:
|
|
@@ -262,6 +358,7 @@ interface DeliveredMail {
|
|
|
262
358
|
readonly subject: string;
|
|
263
359
|
readonly html: string;
|
|
264
360
|
readonly text: string;
|
|
361
|
+
readonly attachments?: readonly MailAttachment[]; // as they arrived: [] when none did
|
|
265
362
|
}
|
|
266
363
|
|
|
267
364
|
interface MailerFaults {
|
|
@@ -274,7 +371,11 @@ interface MailerFaults {
|
|
|
274
371
|
fresh receiving end, so no case sees another's messages.
|
|
275
372
|
- `delivered()` reads back what **the receiving end** got — the test SMTP
|
|
276
373
|
server, the recorded request, the fake provider — not what the mailer was
|
|
277
|
-
asked to send.
|
|
374
|
+
asked to send. That includes each attachment, decoded back to bytes:
|
|
375
|
+
`mailparser`'s `attachments` over SMTP, the base64 `content` of a JSON body
|
|
376
|
+
otherwise. `attachments` is optional so a harness written before it still
|
|
377
|
+
compiles, but `send.attachment` **fails** on a harness that leaves it out,
|
|
378
|
+
saying so — read them back, or skip the case with its reason.
|
|
278
379
|
- `close()`, when present, is called after the case, pass or fail.
|
|
279
380
|
|
|
280
381
|
### Faults — failing the way the provider fails
|
|
@@ -290,11 +391,16 @@ translation of its provider's errors.
|
|
|
290
391
|
|
|
291
392
|
```ts
|
|
292
393
|
// fake-provider.ts
|
|
293
|
-
import { type MailMessage
|
|
394
|
+
import { addressOf, type MailMessage } from '@nxgt/mail';
|
|
294
395
|
import type { DeliveredMail, MailerFaults } from '@nxgt/mail/conformance';
|
|
295
396
|
|
|
397
|
+
/** What the HTTP mailer above posts: a message, its attachments' bytes as base64. */
|
|
398
|
+
type Posted = Omit<MailMessage, 'attachments'> & {
|
|
399
|
+
readonly attachments?: readonly { filename: string; content: string; contentType: string }[];
|
|
400
|
+
};
|
|
401
|
+
|
|
296
402
|
export function fakeProvider() {
|
|
297
|
-
const inbox:
|
|
403
|
+
const inbox: Posted[] = [];
|
|
298
404
|
let attempts = 0;
|
|
299
405
|
let next: 'outage' | 'refusal' | null = null;
|
|
300
406
|
|
|
@@ -315,18 +421,28 @@ export function fakeProvider() {
|
|
|
315
421
|
next = null;
|
|
316
422
|
if (fault === 'outage') return new Response('unavailable', { status: 503 });
|
|
317
423
|
if (fault === 'refusal') return Response.json({ error: 'malformed' }, { status: 422 });
|
|
318
|
-
inbox.push(JSON.parse(String(init.body)) as
|
|
424
|
+
inbox.push(JSON.parse(String(init.body)) as Posted);
|
|
319
425
|
return Response.json({ id: `fake-${inbox.length}` });
|
|
320
426
|
},
|
|
321
427
|
delivered(): DeliveredMail[] {
|
|
322
|
-
return inbox.map((mail) => ({
|
|
428
|
+
return inbox.map((mail) => ({
|
|
429
|
+
to: (Array.isArray(mail.to) ? mail.to : [mail.to]).map(addressOf),
|
|
430
|
+
subject: mail.subject,
|
|
431
|
+
html: mail.html,
|
|
432
|
+
text: mail.text,
|
|
433
|
+
attachments: (mail.attachments ?? []).map((file) => ({
|
|
434
|
+
filename: file.filename,
|
|
435
|
+
content: Uint8Array.from(atob(file.content), (char) => char.charCodeAt(0)),
|
|
436
|
+
contentType: file.contentType,
|
|
437
|
+
})),
|
|
438
|
+
}));
|
|
323
439
|
},
|
|
324
440
|
};
|
|
325
441
|
}
|
|
326
442
|
```
|
|
327
443
|
|
|
328
444
|
With the transport and the fake above, the example at the top of this page
|
|
329
|
-
passes all
|
|
445
|
+
passes all thirteen cases.
|
|
330
446
|
|
|
331
447
|
### Without faults
|
|
332
448
|
|
package/docs/roadmap.md
CHANGED
|
@@ -6,7 +6,15 @@ the only number.
|
|
|
6
6
|
|
|
7
7
|
## Now
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- **An idempotency key per send** — `idempotencyKey` on a `MailMessage`
|
|
10
|
+
names the send, so sending it again — a retry after a timeout, a job run
|
|
11
|
+
twice — delivers it once where the transport can deduplicate; a transport
|
|
12
|
+
that cannot ignores it. `checkMessage` refuses a key that is not 1 to 256
|
|
13
|
+
visible ASCII characters, never quoting it. The memory mailer honours it as
|
|
14
|
+
Resend does: the same message under a key it already delivered answers
|
|
15
|
+
that delivery's `messageId` and delivers nothing more, a different message
|
|
16
|
+
under it is a `MailRefused`, a failed send leaves its key free, and
|
|
17
|
+
`clear()` forgets the keys. Built, not yet published.
|
|
10
18
|
|
|
11
19
|
## Next
|
|
12
20
|
|
|
@@ -14,6 +22,8 @@ Nothing yet.
|
|
|
14
22
|
|
|
15
23
|
## Later
|
|
16
24
|
|
|
25
|
+
- **Inline images (`cid:`)** — an attachment the HTML shows by its content
|
|
26
|
+
id. Until then, an image is an `https:` URL, as `@nxgt/mail-ui`'s logo is.
|
|
17
27
|
- **More transports** — Amazon SES, Postmark and Mailgun, one package each,
|
|
18
28
|
each passing the conformance suite and throwing `@nxgt/mail`'s errors.
|
|
19
29
|
|
|
@@ -61,6 +71,16 @@ Nothing yet.
|
|
|
61
71
|
The last ten, newest first, each with the version it came in. Everything
|
|
62
72
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
63
73
|
|
|
74
|
+
- **Attachments, v0.2.0** — `attachments` on a `MailMessage`: each file's bytes as a
|
|
75
|
+
`Uint8Array`, its name and its type. Bytes only — no path, no URL, no
|
|
76
|
+
stream, so a transport never reads a file or fetches a URL for you; a large
|
|
77
|
+
or sensitive file stays a signed link in the template. `checkMessage`
|
|
78
|
+
refuses a name holding a path, a line break, a control or a format
|
|
79
|
+
character, `.` or `..`, and a type that is not `type/subtype` or is a MIME
|
|
80
|
+
container; the memory mailer keeps a copy of the
|
|
81
|
+
bytes; the conformance suite gains `send.attachment` and
|
|
82
|
+
`send.refusesAttachmentPath`, thirteen cases in all. The SMTP and Resend
|
|
83
|
+
transports send them.
|
|
64
84
|
- **The run-time core, v0.1.0** — `@nxgt/mail`, with no dependency: the `Mailer` port
|
|
65
85
|
a transport implements, the `Rendered` and `MailMessage` shapes it sends, and
|
|
66
86
|
its two errors — `MailFailure` (`MAIL_FAILED`) when the transport could not
|