@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.
@@ -1,4 +1,4 @@
1
- import type { Mailer } from '../types';
1
+ import type { MailAttachment, Mailer } from '../types';
2
2
  /**
3
3
  * What the receiving end got, as the harness reads it back: from the test
4
4
  * SMTP server, from the recorded HTTP request, from the memory outbox.
@@ -9,6 +9,16 @@ export interface DeliveredMail {
9
9
  readonly subject: string;
10
10
  readonly html: string;
11
11
  readonly text: string;
12
+ /**
13
+ * The files that arrived with it, in order, each with its bytes, its name
14
+ * and its type as the receiving end read them — `[]` when none did.
15
+ *
16
+ * Optional, so a harness written before attachments still compiles; but
17
+ * **its absence is reported, never passed over**: `send.attachment` fails
18
+ * on a harness that leaves it out, until it reads them back or skips the
19
+ * case with a reason.
20
+ */
21
+ readonly attachments?: readonly MailAttachment[];
12
22
  }
13
23
  /**
14
24
  * How the suite makes a transport fail **the way its provider fails**: a
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/conformance/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEvC;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC7B,uDAAuD;IACvD,QAAQ,CAAC,EAAE,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAC5B,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD;;;OAGG;IACH,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED,0CAA0C;AAC1C,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,8DAA8D;IAC9D,SAAS,IAAI,OAAO,CAAC,SAAS,aAAa,EAAE,CAAC,CAAC;IAC/C,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B,2CAA2C;IAC3C,KAAK,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC7B,IAAI,IAAI,OAAO,CAAC,YAAY,CAAC,CAAC;CAC9B;AAED,gCAAgC;AAChC,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,SAAS,IAAI,OAAO,CAAC,SAAS,aAAa,EAAE,CAAC,CAAC;IAC/C,QAAQ,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC;CACrC;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IAC1B,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,2CAA2C;IAC3C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,uEAAuE;IACvE,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC;IAC1B,GAAG,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/C;AAED,mGAAmG;AACnG,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IAC/C,EAAE,EAAE;QACH,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;QAChD,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;KACpD,CAAC;CACF"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/conformance/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEvD;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC7B,uDAAuD;IACvD,QAAQ,CAAC,EAAE,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;CACjD;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAC5B,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD;;;OAGG;IACH,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED,0CAA0C;AAC1C,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,8DAA8D;IAC9D,SAAS,IAAI,OAAO,CAAC,SAAS,aAAa,EAAE,CAAC,CAAC;IAC/C,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B,2CAA2C;IAC3C,KAAK,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC7B,IAAI,IAAI,OAAO,CAAC,YAAY,CAAC,CAAC;CAC9B;AAED,gCAAgC;AAChC,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,SAAS,IAAI,OAAO,CAAC,SAAS,aAAa,EAAE,CAAC,CAAC;IAC/C,QAAQ,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC;CACrC;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IAC1B,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,2CAA2C;IAC3C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,uEAAuE;IACvE,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC;IAC1B,GAAG,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/C;AAED,mGAAmG;AACnG,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IAC/C,EAAE,EAAE;QACH,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;QAChD,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;KACpD,CAAC;CACF"}
package/dist/errors.d.ts CHANGED
@@ -22,10 +22,10 @@ export type MailErrorCode =
22
22
  /**
23
23
  * The message itself was refused, before or by the transport: no
24
24
  * recipient, something that is not an address, a line break in the
25
- * subject or a header, a provider answering that the message is
26
- * malformed, or — from `@nxgt/mail/renderer` — a URL variable that is not
27
- * an `http:`, `https:` or `mailto:` URL. Sending it again unchanged fails
28
- * again.
25
+ * subject or a header, an attachment that is not bytes or is badly named,
26
+ * a provider answering that the message is malformed or too large, or —
27
+ * from `@nxgt/mail/renderer` — a URL variable that is not an `http:`,
28
+ * `https:` or `mailto:` URL. Sending it again unchanged fails again.
29
29
  */
30
30
  | 'MAIL_REFUSED';
31
31
  /** Options every error of this package accepts. */
package/dist/index.d.ts CHANGED
@@ -17,5 +17,5 @@ export { MailError, type MailErrorCode, type MailErrorOptions, MailFailure, Mail
17
17
  export { parseAcceptLanguage, pickLocale, type WantedLocales } from './locale';
18
18
  export { createMemoryMailer, type MemoryMail, type MemoryMailer, } from './memory';
19
19
  export { addressOf, checkMessage, recipientsOf } from './message';
20
- export type { Address, Mailer, MailMessage, Rendered, SentMail } from './types';
20
+ export type { Address, MailAttachment, Mailer, MailMessage, Rendered, SentMail, } from './types';
21
21
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EACN,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,WAAW,EACX,WAAW,GACX,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,mBAAmB,EAAE,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAC/E,OAAO,EACN,kBAAkB,EAClB,KAAK,UAAU,EACf,KAAK,YAAY,GACjB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAClE,YAAY,EAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EACN,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,WAAW,EACX,WAAW,GACX,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,mBAAmB,EAAE,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAC/E,OAAO,EACN,kBAAkB,EAClB,KAAK,UAAU,EACf,KAAK,YAAY,GACjB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAClE,YAAY,EACX,OAAO,EACP,cAAc,EACd,MAAM,EACN,WAAW,EACX,QAAQ,EACR,QAAQ,GACR,MAAM,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  addressOf2,
8
8
  checkMessage2,
9
9
  createMemoryMailer2
10
- } from "./chunks/index-0f7kdb8k.js";
10
+ } from "./chunks/index-nkzwt8vn.js";
11
11
  import {
12
12
  MailError2,
13
13
  MailFailure2,
package/dist/memory.d.ts CHANGED
@@ -10,6 +10,12 @@ export interface MemoryMail extends MailMessage {
10
10
  * It refuses exactly what every transport refuses (it calls
11
11
  * {@link checkMessage}), and it can be told to fail, so a test can prove what
12
12
  * the application does when a send throws.
13
+ *
14
+ * It honours `idempotencyKey`, as Resend does: the same message again under a
15
+ * key it already delivered resolves with that delivery's `messageId`, and is
16
+ * not delivered again; a different message under that key is a
17
+ * {@link MailRefused}. A send that failed delivered nothing, so its key stays
18
+ * free.
13
19
  */
14
20
  export interface MemoryMailer extends Mailer {
15
21
  /** Every message accepted so far, oldest first. A copy: mutating it changes nothing. */
@@ -26,7 +32,7 @@ export interface MemoryMailer extends Mailer {
26
32
  * fail the next two sends.
27
33
  */
28
34
  failNext(error?: MailError): void;
29
- /** Forgets what was sent, the attempts, and any queued failure. */
35
+ /** Forgets what was sent, the attempts, any queued failure, and the idempotency keys. */
30
36
  clear(): void;
31
37
  }
32
38
  /** Creates a {@link MemoryMailer}. Message ids are `memory-1`, `memory-2`, … */
@@ -1 +1 @@
1
- {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAe,MAAM,UAAU,CAAC;AAEvD,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAY,MAAM,SAAS,CAAC;AAE7D,sEAAsE;AACtE,MAAM,WAAW,UAAW,SAAQ,WAAW;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAa,SAAQ,MAAM;IAC3C,wFAAwF;IACxF,QAAQ,CAAC,IAAI,EAAE,SAAS,UAAU,EAAE,CAAC;IACrC;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAClC,mEAAmE;IACnE,KAAK,IAAI,IAAI,CAAC;CACd;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,IAAI,YAAY,CAyCjD"}
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAA4B,MAAM,UAAU,CAAC;AAEpE,OAAO,KAAK,EAAW,MAAM,EAAE,WAAW,EAAY,MAAM,SAAS,CAAC;AAEtE,sEAAsE;AACtE,MAAM,WAAW,UAAW,SAAQ,WAAW;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,YAAa,SAAQ,MAAM;IAC3C,wFAAwF;IACxF,QAAQ,CAAC,IAAI,EAAE,SAAS,UAAU,EAAE,CAAC;IACrC;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAClC,yFAAyF;IACzF,KAAK,IAAI,IAAI,CAAC;CACd;AAyDD,gFAAgF;AAChF,wBAAgB,kBAAkB,IAAI,YAAY,CAwDjD"}
package/dist/message.d.ts CHANGED
@@ -17,7 +17,13 @@ export declare function addressOf(address: Address): string;
17
17
  * - every header name is letters, digits and hyphens, none names what the
18
18
  * transport writes from the message (`To`, `Cc`, `Bcc`, `From`, `Sender`,
19
19
  * `Reply-To`, `Return-Path`, `Subject`, `MIME-Version`, `Content-*`, in
20
- * any case), and no header value holds a line break.
20
+ * any case), and no header value holds a line break;
21
+ * - `attachments`, when present, is an array without holes — empty is the
22
+ * same as absent — and each entry has its bytes as a `Uint8Array`, a
23
+ * `filename` that is not empty, `.` or `..` and holds no `/`, `\`, line
24
+ * break, control or format character, and a `contentType` that is a bare
25
+ * `type/subtype`, never `multipart/*` or `message/*`;
26
+ * - `idempotencyKey`, when present, is 1 to 256 visible ASCII characters.
21
27
  */
22
28
  export declare function checkMessage(message: MailMessage): void;
23
29
  //# sourceMappingURL=message.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../src/message.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAgBpD,iEAAiE;AACjE,wBAAgB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,EAAE,CAG3D;AAED,8CAA8C;AAC9C,wBAAgB,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAElD;AAuBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CA2CvD"}
1
+ {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../src/message.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAkB,WAAW,EAAE,MAAM,SAAS,CAAC;AAmCpE,iEAAiE;AACjE,wBAAgB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,EAAE,CAG3D;AAED,8CAA8C;AAC9C,wBAAgB,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAElD;AAwDD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CA+DvD"}
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,24 @@ 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[];
65
+ /**
66
+ * Names this send, so sending it again — a retry after a timeout, a job run
67
+ * twice — delivers it once. 1 to 256 visible ASCII characters, as
68
+ * `order-42/receipt`; derive it from what the e-mail is about, never from
69
+ * the time or a random value, or a retry carries a new one.
70
+ *
71
+ * **A transport that can deduplicate uses it; one that cannot ignores it.**
72
+ * Resend keeps a key for 24 hours; SMTP has no such thing, and ignores it.
73
+ * The memory mailer answers the same message under a key it already
74
+ * delivered with the same `messageId`, and delivers nothing more; a
75
+ * different message under that key is a `MailRefused`, as Resend's `409`.
76
+ */
77
+ readonly idempotencyKey?: string;
45
78
  }
46
79
  /** What a transport answers once it has handed a message over. */
47
80
  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;IACjD;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CACjC;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,9 +8,9 @@ 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, the idempotency key, 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, a retry under an idempotency key |
13
13
  | [Locales](guide/locales.md) | You are choosing the locale an e-mail is rendered in, with `pickLocale` and `parseAcceptLanguage` |
14
- | [Writing a transport](guide/transports.md) | You are implementing the `Mailer` port for a provider, and running `@nxgt/mail/conformance` against it |
14
+ | [Writing a transport](guide/transports.md) | You are implementing the `Mailer` port for a provider — the idempotency key included — and running `@nxgt/mail/conformance` against it |
15
15
  | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
16
16
  | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -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, the idempotency key that makes
5
+ a retry safe, what it answers, and what it throws.
5
6
 
6
7
  ```ts
7
8
  import { createMemoryMailer } from '@nxgt/mail';
@@ -73,6 +74,14 @@ 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
+ readonly idempotencyKey?: string;
79
+ }
80
+
81
+ interface MailAttachment {
82
+ readonly filename: string;
83
+ readonly content: Uint8Array;
84
+ readonly contentType: string;
76
85
  }
77
86
  ```
78
87
 
@@ -85,6 +94,8 @@ interface MailMessage extends Rendered {
85
94
  | `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
95
  | `replyTo` | `Address` | no | Where replies go |
87
96
  | `headers` | `Record<string, string>` | no | Extra headers, such as `List-Unsubscribe` |
97
+ | `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 |
98
+ | `idempotencyKey` | `string` | no | Names this send, so sending it again delivers it once where the transport can deduplicate — see [Idempotency](#idempotency--sending-once). 1 to 256 visible ASCII characters |
88
99
 
89
100
  `Rendered` is what the renderer answers — `mails.render('verify-email', { name, link })`
90
101
  fills the values only known at send time into a built Maizzle template — and a
@@ -214,6 +225,170 @@ const message: MailMessage = {
214
225
  | `{ Bcc: 'eve@example.com' }` | `MailRefused`: `send: header Bcc is reserved — addresses, the subject and the MIME structure are never custom headers` |
215
226
  | `{ 'content-type': 'text/plain' }` | `MailRefused`: `send: header content-type is reserved — …` |
216
227
 
228
+ ## Attachments
229
+
230
+ An attachment is a file's **bytes**, its name and its type:
231
+
232
+ | Field | Type | Effect |
233
+ | --- | --- | --- |
234
+ | `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 |
235
+ | `content` | `Uint8Array` | The bytes, sent as they are. A Node `Buffer` is a `Uint8Array` |
236
+ | `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 |
237
+
238
+ ```ts
239
+ import { readFile } from 'node:fs/promises';
240
+ import type { Mailer } from '@nxgt/mail';
241
+ import { createMailRenderer } from '@nxgt/mail/renderer';
242
+
243
+ const mails = createMailRenderer({ dir: 'dist' });
244
+
245
+ export async function sendInvoice(mailer: Mailer, to: string, name: string, number: string): Promise<void> {
246
+ const pdf = await readFile(`invoices/${number}.pdf`); // your storage: a Buffer
247
+ await mailer.send({
248
+ to,
249
+ ...mails.render('invoice', { name, number }),
250
+ attachments: [{ filename: `invoice-${number}.pdf`, content: pdf, contentType: 'application/pdf' }],
251
+ });
252
+ }
253
+ ```
254
+
255
+ Bytes from anywhere fit — a file read with `readFile`, a PDF your code just
256
+ generated, what `fetch` answered (`new Uint8Array(await response.arrayBuffer())`),
257
+ or text you encoded (`new TextEncoder().encode(csv)`).
258
+
259
+ **There is no `path`, no URL and no stream.** A transport never reads a file
260
+ from disk or fetches a URL to attach it: a value that came from outside —
261
+ a file name in a request, a link in a database — can then never make an
262
+ e-mail carry a file it should not. The SMTP transport tells nodemailer so
263
+ explicitly. Read the file yourself, where you decide which files may be read.
264
+
265
+ **A large or sensitive file is a link.** Every provider caps the whole
266
+ message — about 25 MB sending through Gmail, 40 MB at Resend once encoded —
267
+ and base64, which every transport uses on the way, makes a file a third
268
+ larger. A file in an e-mail also stays in an inbox forever, forwarded or not.
269
+ Put a signed, expiring URL in the template instead; a
270
+ [URL variable](rendering.md) is already checked (`http:`, `https:` or
271
+ `mailto:` only):
272
+
273
+ ```ts
274
+ import type { Mailer } from '@nxgt/mail';
275
+ import { createMailRenderer } from '@nxgt/mail/renderer';
276
+
277
+ declare function signedUrl(key: string, expiresInSeconds: number): Promise<string>; // your storage
278
+
279
+ const mails = createMailRenderer({ dir: 'dist' });
280
+
281
+ export async function sendExport(mailer: Mailer, to: string, key: string): Promise<void> {
282
+ const link = await signedUrl(key, 24 * 3600);
283
+ await mailer.send({ to, ...mails.render('export-ready', { link }) });
284
+ }
285
+ ```
286
+
287
+ Inline images — a `cid:` the HTML points at — are not supported yet: a
288
+ logo belongs on an `https:` URL, which is what the templates of
289
+ `@nxgt/mail-ui` already use.
290
+
291
+ `checkMessage` refuses, naming where and never the file's name:
292
+
293
+ | Written | Answer |
294
+ | --- | --- |
295
+ | `attachments: pdf` — one, not in a list | a compile error; at run time `MailRefused`: `send: attachments must be an array` |
296
+ | `[null]` | `MailRefused`: `send: attachments[0] must be an object, as { filename, content, contentType }` |
297
+ | `{ 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` |
298
+ | `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` |
299
+ | `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/*` |
300
+ | `attachments: []` | accepted: the same as none |
301
+
302
+ A message the provider refuses — too large, or an attachment it will not
303
+ carry — is a `MailRefused` from the transport (an SMTP `552`; a Resend `400`,
304
+ `413` or `422`), and sending it again unchanged fails again: send a link
305
+ instead.
306
+
307
+ ## Idempotency — sending once
308
+
309
+ A send that fails with `MailFailure` may still have reached the provider: a
310
+ timeout, a dropped connection. Retrying it can deliver the e-mail twice.
311
+ `idempotencyKey` names the send, so a transport that can deduplicate delivers
312
+ it once however often it is sent:
313
+
314
+ ```ts
315
+ import type { Mailer, Rendered } from '@nxgt/mail';
316
+
317
+ export async function sendReceipt(mailer: Mailer, order: { id: string; email: string }, rendered: Rendered): Promise<void> {
318
+ await mailer.send({
319
+ ...rendered,
320
+ to: order.email,
321
+ idempotencyKey: `order-${order.id}/receipt`, // the same for every retry of this receipt
322
+ });
323
+ }
324
+ ```
325
+
326
+ **Derive the key from what the e-mail is about** — an order id, a user id
327
+ and a purpose (`user-7/welcome`, `invitation-19`) — **never from the time or
328
+ a random value**: a retry would carry a new key, and deliver again. **A key
329
+ names one e-mail**: two different e-mails about the same thing need two keys
330
+ (`order-42/receipt`, `order-42/shipped`) — a transport that deduplicates
331
+ refuses a different message under a key it already delivered.
332
+
333
+ ```ts
334
+ declare const order: { id: string };
335
+ declare const userId: string;
336
+ declare const resetRequestId: string;
337
+
338
+ const receipt = `order-${order.id}/receipt`; // one receipt per order
339
+ const welcome = `user-${userId}/welcome`; // one welcome per user
340
+ const reset = `password-reset/${resetRequestId}`; // per request, not per user: a second request is a second e-mail
341
+
342
+ const wrong = `receipt-${Date.now()}`; // a retry gets a new key, and delivers again
343
+ const wrongToo = crypto.randomUUID(); // the same
344
+ ```
345
+
346
+ **What each transport does with it:**
347
+
348
+ | Transport | Effect |
349
+ | --- | --- |
350
+ | `createMemoryMailer()` | The same message under a key it already delivered answers that delivery's `messageId`, and delivers nothing more; a different message under that key is a `MailRefused`. A send that failed leaves its key free. See [Testing — idempotency](testing.md#idempotencykey--a-retry-delivers-once) |
351
+ | `@nxgt/mail-resend` | Sent as Resend's `Idempotency-Key` header. Resend keeps a key for 24 hours: a retry within them answers the first send's id and delivers nothing more; the same key with a different message is refused (`MailRefused`) |
352
+ | `@nxgt/mail-smtp` | Ignored: SMTP has no such mechanism, so a message sent twice is delivered twice |
353
+
354
+ A transport that cannot deduplicate ignores the key; it never refuses the
355
+ message for carrying one. So a key is always safe to set, and only makes a
356
+ retry safe where the transport honours it. It is never written into the
357
+ e-mail itself.
358
+
359
+ `checkMessage` refuses a key that is not 1 to 256 visible ASCII characters —
360
+ empty, too long, holding a space, a line break or an accented letter, or not
361
+ a string — without quoting it:
362
+
363
+ | Written | Answer |
364
+ | --- | --- |
365
+ | `'order-42/receipt'`, `'a:b_c.d~e'`, `'k'.repeat(256)` | accepted |
366
+ | `''`, `'k'.repeat(257)`, `'order 42'`, `'commande-42-reçu'`, `'order-42\r\nX-Evil: 1'`, `42` | `MailRefused`: `send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt` |
367
+
368
+ A transport that deduplicates also refuses **a different message** under a
369
+ key it already delivered, with `MailRefused` — the memory mailer with
370
+ `send: idempotencyKey was already used for a different message — a key names one e-mail`,
371
+ Resend's transport with `send: Resend refused the message` on its `409`.
372
+ Sending it again fails again: give that e-mail its own key.
373
+
374
+ A retry from a queue, with the key the job carries:
375
+
376
+ ```ts
377
+ import { MailFailure, type Mailer, type MailMessage } from '@nxgt/mail';
378
+
379
+ // Yours: the queue that runs a job again later.
380
+ declare function retryLater(job: { message: MailMessage }, delaySeconds: number): Promise<void>;
381
+
382
+ export async function runSendJob(mailer: Mailer, job: { message: MailMessage }): Promise<void> {
383
+ try {
384
+ await mailer.send(job.message); // job.message.idempotencyKey was set when the job was queued
385
+ } catch (error) {
386
+ if (error instanceof MailFailure) return retryLater(job, 60); // the same key: delivered once, where the transport deduplicates
387
+ throw error; // MailRefused: sending it again fails again
388
+ }
389
+ }
390
+ ```
391
+
217
392
  ## Errors
218
393
 
219
394
  ```ts
@@ -237,8 +412,8 @@ class MailRefused extends MailError {
237
412
 
238
413
  | Code | Class | When | Sending it again |
239
414
  | --- | --- | --- | --- |
240
- | `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 |
415
+ | `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 — with an [`idempotencyKey`](#idempotency--sending-once), without a second delivery where the transport deduplicates. Never report it as sent |
416
+ | `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, a malformed idempotency key or one already used for a different message, or the provider answering that the message is malformed or too large | Fails again, unchanged |
242
417
 
243
418
  `MailError` is **abstract**: catch it, test `instanceof MailError`, but
244
419
  `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
@@ -111,19 +143,108 @@ await mailer
111
143
  mailer.attempts; // 0 — and the failure is still queued for the next well-formed send
112
144
  ```
113
145
 
146
+ ## `idempotencyKey` — a retry delivers once
147
+
148
+ The memory mailer honours a message's
149
+ [`idempotencyKey`](sending.md#idempotency--sending-once) as Resend does:
150
+
151
+ - the **same message** under a key it already delivered answers that
152
+ delivery's `messageId`, and nothing more reaches `sent`;
153
+ - a **different message** under that key — another subject, another
154
+ recipient, other attachment bytes, any field — is refused with
155
+ `MailRefused`: `send: idempotencyKey was already used for a different message — a key names one e-mail`,
156
+ as Resend answers `409 invalid_idempotent_request`;
157
+ - a send that **failed** delivered nothing, so its key stays free, and the
158
+ retry delivers;
159
+ - each send still counts in `attempts`, the answered duplicate and the
160
+ refused reuse included: both reached the hand-over;
161
+ - `clear()` forgets the keys.
162
+
163
+ A test proving that a job run twice e-mails once:
164
+
165
+ ```ts
166
+ import { expect, it } from 'bun:test';
167
+ import { createMemoryMailer, type Mailer, MailRefused } from '@nxgt/mail';
168
+
169
+ // The code under test: a job that may run more than once for the same order.
170
+ async function sendReceipt(mailer: Mailer, orderId: string): Promise<string | null> {
171
+ const { messageId } = await mailer.send({
172
+ to: 'ada@example.com',
173
+ subject: 'Your receipt',
174
+ html: '<p>Thank you for your order.</p>',
175
+ text: 'Thank you for your order.',
176
+ idempotencyKey: `order-${orderId}/receipt`,
177
+ });
178
+ return messageId;
179
+ }
180
+
181
+ it('sends one receipt, however often the job runs', async () => {
182
+ const mailer = createMemoryMailer();
183
+
184
+ const first = await sendReceipt(mailer, '42');
185
+ const again = await sendReceipt(mailer, '42');
186
+
187
+ expect(again).toBe(first); // 'memory-1', both times
188
+ expect(mailer.sent).toHaveLength(1);
189
+ expect(mailer.attempts).toBe(2);
190
+ });
191
+
192
+ it('refuses another e-mail under the same key', async () => {
193
+ const mailer = createMemoryMailer();
194
+ await sendReceipt(mailer, '42');
195
+
196
+ const error = await mailer
197
+ .send({
198
+ to: 'ada@example.com',
199
+ subject: 'Your order has shipped', // another e-mail, the receipt's key
200
+ html: '<p>Your order has shipped.</p>',
201
+ text: 'Your order has shipped.',
202
+ idempotencyKey: 'order-42/receipt',
203
+ })
204
+ .then(() => null, (e: unknown) => e);
205
+
206
+ expect(error).toBeInstanceOf(MailRefused); // give it its own key: order-42/shipped
207
+ expect(mailer.sent).toHaveLength(1);
208
+ });
209
+
210
+ it('delivers the retry of a send that failed', async () => {
211
+ const mailer = createMemoryMailer();
212
+ mailer.failNext();
213
+
214
+ await sendReceipt(mailer, '42').then(() => null, (e: unknown) => e); // MailFailure
215
+ await sendReceipt(mailer, '42');
216
+
217
+ expect(mailer.sent).toHaveLength(1);
218
+ expect(mailer.sent[0]?.idempotencyKey).toBe('order-42/receipt');
219
+ });
220
+ ```
221
+
222
+ "The same message" is what it would deliver: every field of `MailMessage`,
223
+ read by name, the attachments by their bytes. How the object was written
224
+ does not count — the order of its fields or of its headers, `to` as one
225
+ address or a list of one, no `headers` or `attachments` or an empty one,
226
+ the bytes in a `Buffer` or a plain `Uint8Array` — and neither does a field
227
+ `MailMessage` does not have. SMTP ignores
228
+ the key altogether, so this is the behaviour of a deduplicating transport,
229
+ not of every one. A queued `failNext` fails the next send even when its key
230
+ was already delivered.
231
+
114
232
  ## `clear()`
115
233
 
116
- Forgets the outbox, the attempts, and any queued failure. The id counter keeps
117
- going, so an id is never reused within one mailer.
234
+ Forgets the outbox, the attempts, any queued failure, and the idempotency
235
+ keys. The id counter keeps going, so an id is never reused within one mailer.
118
236
 
119
237
  ## What it refuses
120
238
 
121
239
  Exactly what every transport refuses, because it calls
122
240
  [`checkMessage`](transports.md#checkmessage-first) first: no recipient, something
123
241
  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
242
+ missing part, an attachment that is not bytes or whose file name or type is
243
+ malformed, or an idempotency key that is not 1 to 256 visible ASCII
244
+ characters. A test that passes against the memory mailer does not pass by
125
245
  accident a message a real transport would refuse. The full list is in
126
- [Sending](sending.md#addresses).
246
+ [Sending](sending.md#addresses) and
247
+ [Sending — attachments](sending.md#attachments).
127
248
 
128
249
  ## A realistic case — the failure path of a service
129
250