@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/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 {
@@ -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;CACpD;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"}
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 |
@@ -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 headers it accepts, what it answers, and what it throws.
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
@@ -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. A test that passes against the memory mailer does not pass by
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
 
@@ -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. **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. **never retries in secret**, never resolves `false`, never logs and
54
58
  resolves;
55
- 7. **defines no error class of its own**. It throws the classes imported from
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.1.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, `&amp;` 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`, and the cases as data:
216
- `sendCases` (the eight `send.*`), `failureCases` (the three `failure.*`) and
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, recipientsOf } from '@nxgt/mail';
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: MailMessage[] = [];
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 MailMessage);
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) => ({ to: recipientsOf(mail), subject: mail.subject, html: mail.html, text: mail.text }));
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 eleven cases.
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
- Nothing between releases.
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