@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.
@@ -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. **never retries in secret**, never resolves `false`, never logs and
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
- 7. **defines no error class of its own**. It throws the classes imported from
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.1.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: { authorization: `Bearer ${options.apiKey}`, 'content-type': 'application/json' },
137
- body: JSON.stringify({ ...message, from: message.from ?? options.from }),
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, `&amp;` 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`, and the cases as data:
216
- `sendCases` (the eight `send.*`), `failureCases` (the three `failure.*`) and
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, recipientsOf } from '@nxgt/mail';
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: MailMessage[] = [];
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 MailMessage);
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) => ({ to: recipientsOf(mail), subject: mail.subject, html: mail.html, text: mail.text }));
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 eleven cases.
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
- Nothing between releases.
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