@nxgt/mail-smtp 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 CHANGED
@@ -37,8 +37,9 @@ bun add @nxgt/mail-smtp @nxgt/mail nodemailer
37
37
 
38
38
  Peers, all required:
39
39
 
40
- - `@nxgt/mail` — the port and the errors. One copy in your tree, so
41
- `error instanceof MailFailure` holds.
40
+ - `@nxgt/mail` — the port, the errors and the checks: `^0.3`, the version
41
+ with attachments and `idempotencyKey`. One copy in your tree, so `error instanceof MailFailure`
42
+ holds.
42
43
  - `nodemailer` (`>=7 <11`; tested with 10). This package never imports it:
43
44
  you create the transporter, with every SMTP option nodemailer has.
44
45
  - `typescript` (6). Bundler resolution (`"moduleResolution": "bundler"`) is
@@ -88,15 +89,46 @@ await mailer.send({
88
89
  - A name is handed to nodemailer as `{ name, address }`: nodemailer quotes and
89
90
  encodes it, so `Doe, John` names one recipient.
90
91
  - `messageId` is nodemailer's id (`<…@host>`), or `null` when it gives none.
91
- - The parts are strings: nodemailer is told never to read a file or a URL
92
- (`disableFileAccess`, `disableUrlAccess`).
92
+ - The parts are strings and the attachments bytes: nodemailer is told never
93
+ to read a file or a URL (`disableFileAccess`, `disableUrlAccess`).
94
+ - `idempotencyKey` is ignored: SMTP has no such mechanism, so a message sent
95
+ twice is delivered twice. See
96
+ [Setting up — the idempotency key](docs/guide/setup.md#the-idempotency-key).
97
+
98
+ ### Attachments
99
+
100
+ `attachments` on the message are handed to nodemailer as bytes — a `Buffer`
101
+ copied from each `Uint8Array` — with their file name and type:
102
+
103
+ ```ts
104
+ import { readFile } from 'node:fs/promises';
105
+
106
+ await mailer.send({
107
+ to: 'ada@example.com',
108
+ subject: 'Your invoice',
109
+ html: '<p>Your invoice is attached.</p>',
110
+ text: 'Your invoice is attached.',
111
+ attachments: [
112
+ { filename: 'invoice-42.pdf', content: await readFile('/srv/invoices/42.pdf'), contentType: 'application/pdf' },
113
+ ],
114
+ });
115
+ ```
116
+
117
+ There is no `path` or `href`, as nodemailer would take them: read the file
118
+ yourself, where your code decides which files may be read. A file name
119
+ outside ASCII is encoded by nodemailer. The server caps the whole message,
120
+ attachments in base64 included — a third larger than the files — at about
121
+ 25 MB sending through Gmail, often 10 to 50 MB elsewhere; over it, the
122
+ server answers `552` and `send` throws `MailRefused`. **A large or sensitive
123
+ file is a signed link in the template**, not an attachment. See
124
+ [Setting up — attachments](docs/guide/setup.md#attachments).
93
125
 
94
126
  ### Errors — a refusal or a failure
95
127
 
96
128
  | When | Throws | `cause` |
97
129
  | --- | --- | --- |
98
130
  | The server cannot be reached, a timeout, a `4xx` (try later), credentials refused (`530`–`539`), the sender refused (`5xx` on `MAIL FROM`) | `MailFailure` — `send: the SMTP server could not take the message` | nodemailer's error, with its `code` and `responseCode` |
99
- | A permanent `5xx` on every recipient or on the content (`550`, `552` too large, `554`) | `MailRefused` — `send: the SMTP server refused the message` | nodemailer's error |
131
+ | A permanent `5xx` on every recipient or on the content (`550`; `552` too large, attachments included; `554`) | `MailRefused` — `send: the SMTP server refused the message` | nodemailer's error |
100
132
  | Some recipients refused, the others accepted — **they may have the message** | `MailRefused` — `send: the SMTP server refused <n> of <total> recipients, and may have delivered to the others` — or `MailFailure` — `send: the SMTP server could not take <n> of <total> recipients, …` when a refusal is not permanent, or nodemailer gives no reason | nodemailer's error for the first refused recipient |
101
133
  | No sender, on the message or as a default | `MailRefused` — `send: from is missing — give the message a from, or createSmtpMailer a default one` | — |
102
134
  | A bad option | `TypeError` from `createSmtpMailer` | — |
@@ -141,11 +173,21 @@ A timeout ends in `MailFailure`.
141
173
  `539`) and a sender refused at `MAIL FROM`: the next message would be refused
142
174
  the same way, so it is a failure of the wiring, not of the message.
143
175
 
176
+ **A retry after a timeout can deliver twice.** SMTP cannot deduplicate, and
177
+ the transport ignores `idempotencyKey`: after a `MailFailure` from a
178
+ timeout, the server may already have the message. Retry only what you can
179
+ afford to send twice.
180
+
144
181
  **Some recipients refused still throws, after the others got it.** The
145
182
  server may accept one recipient and refuse another; the message then went out
146
183
  to the accepted one. Retrying it whole sends it to them twice — send to one
147
184
  recipient per `send` when every result must be all or nothing.
148
185
 
186
+ **An attachment is held in memory, whole, and grows a third on the way.**
187
+ nodemailer encodes it in base64 into the message it streams; a file of tens
188
+ of megabytes is refused by most servers (`552`, `MailRefused`) after it was
189
+ read. Send a signed link instead.
190
+
149
191
  **A custom header cannot set an address.** `headers: { Bcc: '…' }` would add
150
192
  an envelope recipient no check saw: `checkMessage` refuses `To`, `Cc`, `Bcc`,
151
193
  `From`, `Sender`, `Reply-To`, `Return-Path`, `Subject`, `MIME-Version` and
package/dist/index.d.ts CHANGED
@@ -48,6 +48,15 @@ export interface SmtpTransporter {
48
48
  html: string;
49
49
  text: string;
50
50
  headers?: Record<string, string>;
51
+ /**
52
+ * A `Buffer`, not a `Uint8Array`: nodemailer's own types want one, and
53
+ * what `nodemailer.createTransport(…)` answers then fits with no cast.
54
+ */
55
+ attachments?: {
56
+ filename: string;
57
+ content: Buffer;
58
+ contentType: string;
59
+ }[];
51
60
  disableFileAccess: boolean;
52
61
  disableUrlAccess: boolean;
53
62
  }): Promise<SmtpSentInfo>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACN,KAAK,OAAO,EAEZ,KAAK,MAAM,EAKX,MAAM,YAAY,CAAC;AAEpB;;;GAGG;AACH,KAAK,iBAAiB,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3D;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,SAAS,CAAC;IACnD,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,SAAS,CAAC;IACnD,2EAA2E;IAC3E,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,SAAS,CAAC;CACzD;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,IAAI,EAAE;QACd,IAAI,EAAE,iBAAiB,CAAC;QACxB,EAAE,EAAE,iBAAiB,EAAE,CAAC;QACxB,OAAO,CAAC,EAAE,iBAAiB,CAAC;QAC5B,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACjC,iBAAiB,EAAE,OAAO,CAAC;QAC3B,gBAAgB,EAAE,OAAO,CAAC;KAC1B,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IACjC,sEAAsE;IACtE,QAAQ,CAAC,WAAW,EAAE,eAAe,CAAC;IACtC,sFAAsF;IACtF,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;CACxB;AA+ED,yEAAyE;AACzE,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,GAAG,MAAM,CA4EnE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACN,KAAK,OAAO,EAGZ,KAAK,MAAM,EAKX,MAAM,YAAY,CAAC;AAEpB;;;GAGG;AACH,KAAK,iBAAiB,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3D;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,SAAS,CAAC;IACnD,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,SAAS,CAAC;IACnD,2EAA2E;IAC3E,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,SAAS,CAAC;CACzD;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,IAAI,EAAE;QACd,IAAI,EAAE,iBAAiB,CAAC;QACxB,EAAE,EAAE,iBAAiB,EAAE,CAAC;QACxB,OAAO,CAAC,EAAE,iBAAiB,CAAC;QAC5B,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACjC;;;WAGG;QACH,WAAW,CAAC,EAAE;YAAE,QAAQ,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAC;YAAC,WAAW,EAAE,MAAM,CAAA;SAAE,EAAE,CAAC;QAC3E,iBAAiB,EAAE,OAAO,CAAC;QAC3B,gBAAgB,EAAE,OAAO,CAAC;KAC1B,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IACjC,sEAAsE;IACtE,QAAQ,CAAC,WAAW,EAAE,eAAe,CAAC;IACtC,sFAAsF;IACtF,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;CACxB;AA0FD,yEAAyE;AACzE,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,GAAG,MAAM,CAiFnE"}
package/dist/index.js CHANGED
@@ -5,6 +5,11 @@ import {
5
5
  MailRefused
6
6
  } from "@nxgt/mail";
7
7
  var toNodemailer = (address) => typeof address === "string" ? { name: "", address } : { name: address.name, address: address.address };
8
+ var toNodemailerAttachment = (attachment) => ({
9
+ filename: attachment.filename,
10
+ content: Buffer.from(attachment.content),
11
+ contentType: attachment.contentType
12
+ });
8
13
  var fieldsOf = (error) => typeof error === "object" && error !== null ? error : {};
9
14
  function isPermanentRefusal(error) {
10
15
  const { code, responseCode, command } = fieldsOf(error);
@@ -66,6 +71,7 @@ function createSmtpMailer(options) {
66
71
  html: message.html,
67
72
  text: message.text,
68
73
  ...message.headers === undefined ? {} : { headers: { ...message.headers } },
74
+ ...message.attachments === undefined || message.attachments.length === 0 ? {} : { attachments: message.attachments.map(toNodemailerAttachment) },
69
75
  disableFileAccess: true,
70
76
  disableUrlAccess: true
71
77
  });
@@ -89,5 +95,5 @@ export {
89
95
  createSmtpMailer
90
96
  };
91
97
 
92
- //# debugId=748DA33D57D71AF564756E2164756E21
98
+ //# debugId=E9820236D701396064756E2164756E21
93
99
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * `@nxgt/mail-smtp` — an SMTP transport for `@nxgt/mail`, on the `nodemailer`\n * the application installs and configures.\n *\n * ```ts\n * import nodemailer from 'nodemailer';\n * import { createSmtpMailer } from '@nxgt/mail-smtp';\n *\n * const mailer = createSmtpMailer({\n * transporter: nodemailer.createTransport({ host, port: 587, auth }),\n * from: { name: 'Acme', address: 'noreply@acme.test' },\n * });\n * ```\n *\n * **A failure throws** the `MailFailure` or `MailRefused` of the `@nxgt/mail`\n * peer, nodemailer's error as the `cause`. Nothing is retried.\n */\n\nimport {\n\ttype Address,\n\tcheckMessage,\n\ttype Mailer,\n\tMailFailure,\n\ttype MailMessage,\n\tMailRefused,\n\ttype SentMail,\n} from '@nxgt/mail';\n\n/**\n * An address as nodemailer takes it: always an object, so nodemailer never\n * parses a string — it quotes and encodes the name itself.\n */\ntype NodemailerAddress = { name: string; address: string };\n\n/**\n * What nodemailer answers once the server took the message: its id, and the\n * recipients it refused while accepting others — nodemailer resolves then.\n */\nexport interface SmtpSentInfo {\n\treadonly messageId?: string;\n\treadonly accepted?: readonly unknown[] | undefined;\n\treadonly rejected?: readonly unknown[] | undefined;\n\t/** One nodemailer error per refused recipient, with its `responseCode`. */\n\treadonly rejectedErrors?: readonly unknown[] | undefined;\n}\n\n/**\n * The part of a nodemailer transporter this transport calls — what\n * `nodemailer.createTransport(…)` answers, from nodemailer 7 on.\n */\nexport interface SmtpTransporter {\n\tsendMail(mail: {\n\t\tfrom: NodemailerAddress;\n\t\tto: NodemailerAddress[];\n\t\treplyTo?: NodemailerAddress;\n\t\tsubject: string;\n\t\thtml: string;\n\t\ttext: string;\n\t\theaders?: Record<string, string>;\n\t\tdisableFileAccess: boolean;\n\t\tdisableUrlAccess: boolean;\n\t}): Promise<SmtpSentInfo>;\n}\n\nexport interface SmtpMailerOptions {\n\t/** `nodemailer.createTransport(…)`, configured by the application. */\n\treadonly transporter: SmtpTransporter;\n\t/** The sender of a message that names none. Without it, such a message is refused. */\n\treadonly from?: Address;\n}\n\nconst toNodemailer = (address: Address): NodemailerAddress =>\n\ttypeof address === 'string'\n\t\t? { name: '', address }\n\t\t: { name: address.name, address: address.address };\n\ninterface SmtpError {\n\treadonly code?: unknown;\n\treadonly responseCode?: unknown;\n\treadonly command?: unknown;\n\treadonly message?: unknown;\n\treadonly rejectedErrors?: unknown;\n}\n\nconst fieldsOf = (error: unknown): SmtpError =>\n\ttypeof error === 'object' && error !== null ? (error as SmtpError) : {};\n\n/**\n * Whether one nodemailer error is the server refusing **this message** for\n * good — a permanent `5xx` on a recipient or on the content (`552` for a\n * message too large) — rather\n * than the server, the network or the wiring failing. Two `5xx` are\n * failures: authentication (`530`–`539`), and a sender refused at\n * `MAIL FROM` — the next message would be refused the same way.\n */\nfunction isPermanentRefusal(error: unknown): boolean {\n\tconst { code, responseCode, command } = fieldsOf(error);\n\treturn (\n\t\t(code === 'EENVELOPE' || code === 'EMESSAGE') &&\n\t\tcommand !== 'MAIL FROM' &&\n\t\ttypeof responseCode === 'number' &&\n\t\tresponseCode >= 500 &&\n\t\tresponseCode < 600 &&\n\t\t(responseCode < 530 || responseCode > 539)\n\t);\n}\n\n/**\n * Whether a rejected send is a refusal of the message. When every recipient\n * was refused, nodemailer's error carries the code of the **last** one only:\n * each refusal is read instead, and it is a refusal only if every one is.\n */\nfunction isRefusal(error: unknown): boolean {\n\tconst { rejectedErrors } = fieldsOf(error);\n\tif (Array.isArray(rejectedErrors) && rejectedErrors.length > 0) {\n\t\treturn rejectedErrors.every(isPermanentRefusal);\n\t}\n\treturn isPermanentRefusal(error);\n}\n\n/**\n * Throws when nodemailer resolved with some recipients refused: the server\n * took the message for the others, so it may already have reached them.\n */\nfunction throwOnPartialRejection(info: SmtpSentInfo): void {\n\tconst errors = Array.isArray(info.rejectedErrors) ? info.rejectedErrors : [];\n\tconst rejected = Array.isArray(info.rejected) ? info.rejected : [];\n\tconst refusedCount = Math.max(errors.length, rejected.length);\n\tif (refusedCount === 0) return;\n\tconst accepted = Array.isArray(info.accepted) ? info.accepted.length : 0;\n\tconst counted = `${refusedCount} of ${refusedCount + accepted} recipients`;\n\tconst cause =\n\t\terrors[0] ??\n\t\tObject.assign(new Error('the SMTP server refused some recipients'), {\n\t\t\trejected,\n\t\t});\n\tif (errors.length > 0 && errors.every(isPermanentRefusal)) {\n\t\tthrow new MailRefused(\n\t\t\t`send: the SMTP server refused ${counted}, and may have delivered to the others`,\n\t\t\t{ cause },\n\t\t);\n\t}\n\tthrow new MailFailure(\n\t\t`send: the SMTP server could not take ${counted}, and may have delivered to the others`,\n\t\t{ cause },\n\t);\n}\n\n/** Creates a {@link Mailer} that hands each message to `transporter`. */\nexport function createSmtpMailer(options: SmtpMailerOptions): Mailer {\n\tif (typeof options !== 'object' || options === null) {\n\t\tthrow new TypeError(\n\t\t\t'createSmtpMailer: options must be an object, as { transporter }',\n\t\t);\n\t}\n\tconst { transporter, from } = options;\n\tif (\n\t\ttypeof transporter !== 'object' ||\n\t\ttransporter === null ||\n\t\ttypeof transporter.sendMail !== 'function'\n\t) {\n\t\tthrow new TypeError(\n\t\t\t'createSmtpMailer: transporter must be what nodemailer.createTransport(…) answers',\n\t\t);\n\t}\n\tif (from !== undefined) {\n\t\t// Checked once, as the message's own sender is: a wiring mistake.\n\t\ttry {\n\t\t\tcheckMessage({ to: from, subject: '', html: '', text: '' });\n\t\t} catch {\n\t\t\tthrow new TypeError(\n\t\t\t\t'createSmtpMailer: from must be an e-mail address, as noreply@example.com or { name, address }',\n\t\t\t);\n\t\t}\n\t}\n\n\treturn {\n\t\tasync send(message: MailMessage): Promise<SentMail> {\n\t\t\tcheckMessage(message);\n\t\t\tconst sender = message.from ?? from;\n\t\t\tif (sender === undefined) {\n\t\t\t\tthrow new MailRefused(\n\t\t\t\t\t'send: from is missing — give the message a from, or createSmtpMailer a default one',\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\t\t\tlet info: SmtpSentInfo;\n\t\t\ttry {\n\t\t\t\tinfo = await transporter.sendMail({\n\t\t\t\t\tfrom: toNodemailer(sender),\n\t\t\t\t\tto: to.map(toNodemailer),\n\t\t\t\t\t...(message.replyTo === undefined\n\t\t\t\t\t\t? {}\n\t\t\t\t\t\t: { replyTo: toNodemailer(message.replyTo) }),\n\t\t\t\t\tsubject: message.subject,\n\t\t\t\t\thtml: message.html,\n\t\t\t\t\ttext: message.text,\n\t\t\t\t\t...(message.headers === undefined\n\t\t\t\t\t\t? {}\n\t\t\t\t\t\t: { headers: { ...message.headers } }),\n\t\t\t\t\t// The parts are strings: nothing is ever read from a file or a URL.\n\t\t\t\t\tdisableFileAccess: true,\n\t\t\t\t\tdisableUrlAccess: true,\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tif (isRefusal(error)) {\n\t\t\t\t\tthrow new MailRefused('send: the SMTP server refused the message', {\n\t\t\t\t\t\tcause: error,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tthrow new MailFailure(\n\t\t\t\t\t'send: the SMTP server could not take the message',\n\t\t\t\t\t{\n\t\t\t\t\t\tcause: error,\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t}\n\t\t\tthrowOnPartialRejection(info ?? {});\n\t\t\tconst messageId =\n\t\t\t\ttypeof info?.messageId === 'string' && info.messageId !== ''\n\t\t\t\t\t? info.messageId\n\t\t\t\t\t: null;\n\t\t\treturn { messageId };\n\t\t},\n\t};\n}\n"
5
+ "/**\n * `@nxgt/mail-smtp` — an SMTP transport for `@nxgt/mail`, on the `nodemailer`\n * the application installs and configures.\n *\n * ```ts\n * import nodemailer from 'nodemailer';\n * import { createSmtpMailer } from '@nxgt/mail-smtp';\n *\n * const mailer = createSmtpMailer({\n * transporter: nodemailer.createTransport({ host, port: 587, auth }),\n * from: { name: 'Acme', address: 'noreply@acme.test' },\n * });\n * ```\n *\n * **A failure throws** the `MailFailure` or `MailRefused` of the `@nxgt/mail`\n * peer, nodemailer's error as the `cause`. Nothing is retried.\n */\n\nimport {\n\ttype Address,\n\tcheckMessage,\n\ttype MailAttachment,\n\ttype Mailer,\n\tMailFailure,\n\ttype MailMessage,\n\tMailRefused,\n\ttype SentMail,\n} from '@nxgt/mail';\n\n/**\n * An address as nodemailer takes it: always an object, so nodemailer never\n * parses a string — it quotes and encodes the name itself.\n */\ntype NodemailerAddress = { name: string; address: string };\n\n/**\n * What nodemailer answers once the server took the message: its id, and the\n * recipients it refused while accepting others — nodemailer resolves then.\n */\nexport interface SmtpSentInfo {\n\treadonly messageId?: string;\n\treadonly accepted?: readonly unknown[] | undefined;\n\treadonly rejected?: readonly unknown[] | undefined;\n\t/** One nodemailer error per refused recipient, with its `responseCode`. */\n\treadonly rejectedErrors?: readonly unknown[] | undefined;\n}\n\n/**\n * The part of a nodemailer transporter this transport calls — what\n * `nodemailer.createTransport(…)` answers, from nodemailer 7 on.\n */\nexport interface SmtpTransporter {\n\tsendMail(mail: {\n\t\tfrom: NodemailerAddress;\n\t\tto: NodemailerAddress[];\n\t\treplyTo?: NodemailerAddress;\n\t\tsubject: string;\n\t\thtml: string;\n\t\ttext: string;\n\t\theaders?: Record<string, string>;\n\t\t/**\n\t\t * A `Buffer`, not a `Uint8Array`: nodemailer's own types want one, and\n\t\t * what `nodemailer.createTransport(…)` answers then fits with no cast.\n\t\t */\n\t\tattachments?: { filename: string; content: Buffer; contentType: string }[];\n\t\tdisableFileAccess: boolean;\n\t\tdisableUrlAccess: boolean;\n\t}): Promise<SmtpSentInfo>;\n}\n\nexport interface SmtpMailerOptions {\n\t/** `nodemailer.createTransport(…)`, configured by the application. */\n\treadonly transporter: SmtpTransporter;\n\t/** The sender of a message that names none. Without it, such a message is refused. */\n\treadonly from?: Address;\n}\n\nconst toNodemailer = (address: Address): NodemailerAddress =>\n\ttypeof address === 'string'\n\t\t? { name: '', address }\n\t\t: { name: address.name, address: address.address };\n\n/**\n * An attachment as nodemailer takes it: its bytes as a `Buffer` — copied, so\n * a change the caller makes during the send reaches no one — and never a\n * `path` or an `href`, which nodemailer would read or fetch.\n */\nconst toNodemailerAttachment = (attachment: MailAttachment) => ({\n\tfilename: attachment.filename,\n\tcontent: Buffer.from(attachment.content),\n\tcontentType: attachment.contentType,\n});\n\ninterface SmtpError {\n\treadonly code?: unknown;\n\treadonly responseCode?: unknown;\n\treadonly command?: unknown;\n\treadonly message?: unknown;\n\treadonly rejectedErrors?: unknown;\n}\n\nconst fieldsOf = (error: unknown): SmtpError =>\n\ttypeof error === 'object' && error !== null ? (error as SmtpError) : {};\n\n/**\n * Whether one nodemailer error is the server refusing **this message** for\n * good — a permanent `5xx` on a recipient or on the content (`552` for a\n * message too large) — rather\n * than the server, the network or the wiring failing. Two `5xx` are\n * failures: authentication (`530`–`539`), and a sender refused at\n * `MAIL FROM` — the next message would be refused the same way.\n */\nfunction isPermanentRefusal(error: unknown): boolean {\n\tconst { code, responseCode, command } = fieldsOf(error);\n\treturn (\n\t\t(code === 'EENVELOPE' || code === 'EMESSAGE') &&\n\t\tcommand !== 'MAIL FROM' &&\n\t\ttypeof responseCode === 'number' &&\n\t\tresponseCode >= 500 &&\n\t\tresponseCode < 600 &&\n\t\t(responseCode < 530 || responseCode > 539)\n\t);\n}\n\n/**\n * Whether a rejected send is a refusal of the message. When every recipient\n * was refused, nodemailer's error carries the code of the **last** one only:\n * each refusal is read instead, and it is a refusal only if every one is.\n */\nfunction isRefusal(error: unknown): boolean {\n\tconst { rejectedErrors } = fieldsOf(error);\n\tif (Array.isArray(rejectedErrors) && rejectedErrors.length > 0) {\n\t\treturn rejectedErrors.every(isPermanentRefusal);\n\t}\n\treturn isPermanentRefusal(error);\n}\n\n/**\n * Throws when nodemailer resolved with some recipients refused: the server\n * took the message for the others, so it may already have reached them.\n */\nfunction throwOnPartialRejection(info: SmtpSentInfo): void {\n\tconst errors = Array.isArray(info.rejectedErrors) ? info.rejectedErrors : [];\n\tconst rejected = Array.isArray(info.rejected) ? info.rejected : [];\n\tconst refusedCount = Math.max(errors.length, rejected.length);\n\tif (refusedCount === 0) return;\n\tconst accepted = Array.isArray(info.accepted) ? info.accepted.length : 0;\n\tconst counted = `${refusedCount} of ${refusedCount + accepted} recipients`;\n\tconst cause =\n\t\terrors[0] ??\n\t\tObject.assign(new Error('the SMTP server refused some recipients'), {\n\t\t\trejected,\n\t\t});\n\tif (errors.length > 0 && errors.every(isPermanentRefusal)) {\n\t\tthrow new MailRefused(\n\t\t\t`send: the SMTP server refused ${counted}, and may have delivered to the others`,\n\t\t\t{ cause },\n\t\t);\n\t}\n\tthrow new MailFailure(\n\t\t`send: the SMTP server could not take ${counted}, and may have delivered to the others`,\n\t\t{ cause },\n\t);\n}\n\n/** Creates a {@link Mailer} that hands each message to `transporter`. */\nexport function createSmtpMailer(options: SmtpMailerOptions): Mailer {\n\tif (typeof options !== 'object' || options === null) {\n\t\tthrow new TypeError(\n\t\t\t'createSmtpMailer: options must be an object, as { transporter }',\n\t\t);\n\t}\n\tconst { transporter, from } = options;\n\tif (\n\t\ttypeof transporter !== 'object' ||\n\t\ttransporter === null ||\n\t\ttypeof transporter.sendMail !== 'function'\n\t) {\n\t\tthrow new TypeError(\n\t\t\t'createSmtpMailer: transporter must be what nodemailer.createTransport(…) answers',\n\t\t);\n\t}\n\tif (from !== undefined) {\n\t\t// Checked once, as the message's own sender is: a wiring mistake.\n\t\ttry {\n\t\t\tcheckMessage({ to: from, subject: '', html: '', text: '' });\n\t\t} catch {\n\t\t\tthrow new TypeError(\n\t\t\t\t'createSmtpMailer: from must be an e-mail address, as noreply@example.com or { name, address }',\n\t\t\t);\n\t\t}\n\t}\n\n\treturn {\n\t\tasync send(message: MailMessage): Promise<SentMail> {\n\t\t\tcheckMessage(message);\n\t\t\tconst sender = message.from ?? from;\n\t\t\tif (sender === undefined) {\n\t\t\t\tthrow new MailRefused(\n\t\t\t\t\t'send: from is missing — give the message a from, or createSmtpMailer a default one',\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\t\t\tlet info: SmtpSentInfo;\n\t\t\ttry {\n\t\t\t\tinfo = await transporter.sendMail({\n\t\t\t\t\tfrom: toNodemailer(sender),\n\t\t\t\t\tto: to.map(toNodemailer),\n\t\t\t\t\t...(message.replyTo === undefined\n\t\t\t\t\t\t? {}\n\t\t\t\t\t\t: { replyTo: toNodemailer(message.replyTo) }),\n\t\t\t\t\tsubject: message.subject,\n\t\t\t\t\thtml: message.html,\n\t\t\t\t\ttext: message.text,\n\t\t\t\t\t...(message.headers === undefined\n\t\t\t\t\t\t? {}\n\t\t\t\t\t\t: { headers: { ...message.headers } }),\n\t\t\t\t\t...(message.attachments === undefined ||\n\t\t\t\t\tmessage.attachments.length === 0\n\t\t\t\t\t\t? {}\n\t\t\t\t\t\t: { attachments: message.attachments.map(toNodemailerAttachment) }),\n\t\t\t\t\t// The parts are strings and the attachments bytes: nothing is ever\n\t\t\t\t\t// read from a file or a URL.\n\t\t\t\t\tdisableFileAccess: true,\n\t\t\t\t\tdisableUrlAccess: true,\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tif (isRefusal(error)) {\n\t\t\t\t\tthrow new MailRefused('send: the SMTP server refused the message', {\n\t\t\t\t\t\tcause: error,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tthrow new MailFailure(\n\t\t\t\t\t'send: the SMTP server could not take the message',\n\t\t\t\t\t{\n\t\t\t\t\t\tcause: error,\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t}\n\t\t\tthrowOnPartialRejection(info ?? {});\n\t\t\tconst messageId =\n\t\t\t\ttypeof info?.messageId === 'string' && info.messageId !== ''\n\t\t\t\t\t? info.messageId\n\t\t\t\t\t: null;\n\t\t\treturn { messageId };\n\t\t},\n\t};\n}\n"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "748DA33D57D71AF564756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "E9820236D701396064756E2164756E21",
9
9
  "names": []
10
10
  }
package/docs/README.md CHANGED
@@ -7,7 +7,7 @@ mailer, transport, hand-over, refusal, failure — are defined once, in the
7
7
 
8
8
  | Page | Read it when |
9
9
  | --- | --- |
10
- | [Setting up](guide/setup.md) | You are wiring `createSmtpMailer` to a nodemailer transporter: host, port and TLS, credentials, a pool, timeouts, the default sender, and what a message becomes on the wire |
10
+ | [Setting up](guide/setup.md) | You are wiring `createSmtpMailer` to a nodemailer transporter: host, port and TLS, credentials, a pool, timeouts, the default sender, what a message becomes on the wire, and why `idempotencyKey` is ignored (with Resend's SMTP relay header) |
11
11
  | [Errors](guide/errors.md) | You are handling what `send` throws: which SMTP answers are a `MailRefused`, which a `MailFailure`, what is on `cause`, and every `TypeError` at wiring |
12
12
  | [Testing](guide/testing.md) | You are testing the transport against a real SMTP server — `smtp-server`, `mailparser`, `describeMailer` — or an application that uses it |
13
13
  | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
@@ -19,7 +19,9 @@ recipients may already have the message. The message says so
19
19
  ([below](#some-recipients-refused)); retrying it whole sends it to them again.
20
20
 
21
21
  Nothing is retried. Whether and when to retry is yours to decide, where you
22
- can see it.
22
+ can see it. SMTP cannot deduplicate — the transport ignores
23
+ `idempotencyKey` — so a retry after a timeout may deliver the e-mail twice
24
+ ([Setting up — the idempotency key](setup.md#the-idempotency-key)).
23
25
 
24
26
  ```ts
25
27
  import { MailError, type MailErrorCode, type Mailer, type MailMessage } from '@nxgt/mail';
@@ -58,7 +60,7 @@ a `responseCode`. The transport reads both:
58
60
  | The sender refused (`550`, `553` on `MAIL FROM`) | `EENVELOPE` with a `5xx` and `command: 'MAIL FROM'` | `MailFailure` |
59
61
  | A recipient refused for good (`550`, `553`) | `EENVELOPE` with a `5xx` | `MailRefused` |
60
62
  | Every recipient refused | `EENVELOPE`, one error per recipient on `rejectedErrors` | `MailRefused` if every one is a permanent `5xx` (not `530`–`539`), else `MailFailure` |
61
- | The content refused for good (`552` too large, `554` rejected) | `EMESSAGE` with a `5xx` | `MailRefused` |
63
+ | The content refused for good (`552` too large, attachments included; `554` rejected) | `EMESSAGE` with a `5xx` | `MailRefused` |
62
64
  | Anything else | — | `MailFailure` |
63
65
 
64
66
  Two `5xx` are failures: authentication, and a sender refused at
@@ -49,6 +49,7 @@ interface SmtpTransporter {
49
49
  html: string;
50
50
  text: string;
51
51
  headers?: Record<string, string>;
52
+ attachments?: { filename: string; content: Buffer; contentType: string }[];
52
53
  disableFileAccess: boolean;
53
54
  disableUrlAccess: boolean;
54
55
  }): Promise<SmtpSentInfo>;
@@ -171,7 +172,9 @@ A string is only an address: `'Acme <noreply@acme.test>'` is refused. Write
171
172
  | `from`, `replyTo` | `from`, `replyTo` |
172
173
  | `subject`, `html`, `text` | the same, as strings — the e-mail is `multipart/alternative` |
173
174
  | `headers` | `headers`, copied |
174
- | — | `disableFileAccess: true`, `disableUrlAccess: true`: a part is never read from a file or fetched from a URL |
175
+ | `idempotencyKey` | nothing: ignored — see [below](#the-idempotency-key) |
176
+ | `attachments`, each `{ filename, content, contentType }` | `attachments`, each `{ filename, content: Buffer, contentType }` — the bytes copied into a `Buffer`, never a `path` or an `href`; the e-mail is then `multipart/mixed`. Left out when the list is empty |
177
+ | — | `disableFileAccess: true`, `disableUrlAccess: true`: a part or an attachment is never read from a file or fetched from a URL |
175
178
 
176
179
  Before any of it, `checkMessage` from `@nxgt/mail` refuses what no transport
177
180
  hands over — no recipient, something that is not an address, a line break in
@@ -182,6 +185,95 @@ subject or the MIME structure (`Bcc`, `To`, `Content-Type`…). Its messages are
182
185
  `send` answers `{ messageId }`: nodemailer's `Message-ID` (`<…@acme.test>`), or
183
186
  `null` when the transporter answers none — an absence, not a failure.
184
187
 
188
+ ## The idempotency key
189
+
190
+ SMTP has no idempotency: a server takes every message it is handed, and
191
+ cannot tell a retry from a new e-mail. The transport therefore **ignores**
192
+ `idempotencyKey` — it neither refuses the message nor writes the key into it
193
+ — and a message sent twice is delivered twice:
194
+
195
+ ```ts
196
+ const once = {
197
+ to: 'ada@example.com',
198
+ subject: 'Your receipt',
199
+ html: '<p>Thank you for your order.</p>',
200
+ text: 'Thank you for your order.',
201
+ idempotencyKey: 'order-42/receipt', // still checked by checkMessage; not sent
202
+ };
203
+
204
+ await mailer.send(once);
205
+ await mailer.send(once); // a second e-mail
206
+ ```
207
+
208
+ Setting the key is still worth it when the same code may run on a
209
+ transport that deduplicates, as `@nxgt/mail-resend`. Over SMTP, a retry
210
+ after a `MailFailure` from a timeout or a dropped connection may deliver
211
+ twice: the server may have taken the message before the connection ended.
212
+
213
+ **Relaying through Resend's SMTP server** (`smtp.resend.com`)? Resend reads
214
+ its own `Resend-Idempotency-Key` header there. The transport does not set it
215
+ from `idempotencyKey`; set it yourself among the headers, with the same
216
+ value:
217
+
218
+ ```ts
219
+ const key = 'order-42/receipt';
220
+
221
+ await mailer.send({
222
+ to: 'ada@example.com',
223
+ subject: 'Your receipt',
224
+ html: '<p>Thank you for your order.</p>',
225
+ text: 'Thank you for your order.',
226
+ idempotencyKey: key,
227
+ headers: { 'Resend-Idempotency-Key': key }, // read by Resend's relay; any other server passes it on as a header
228
+ });
229
+ ```
230
+
231
+ On any other server, that header travels with the e-mail to the recipient:
232
+ set it only when the relay is Resend's.
233
+
234
+ ## Attachments
235
+
236
+ ```ts
237
+ import { readFile } from 'node:fs/promises';
238
+ import nodemailer from 'nodemailer';
239
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
240
+
241
+ const mailer = createSmtpMailer({
242
+ transporter: nodemailer.createTransport(process.env.SMTP_URL ?? 'smtp://localhost:1025'),
243
+ from: 'billing@acme.test',
244
+ });
245
+
246
+ await mailer.send({
247
+ to: 'ada@example.com',
248
+ subject: 'Your invoice',
249
+ html: '<p>Your invoice is attached.</p>',
250
+ text: 'Your invoice is attached.',
251
+ attachments: [
252
+ { filename: 'facture n° 42.pdf', content: await readFile('/srv/invoices/42.pdf'), contentType: 'application/pdf' },
253
+ { filename: 'invoice.ics', content: new TextEncoder().encode('BEGIN:VCALENDAR…'), contentType: 'text/calendar' },
254
+ ],
255
+ });
256
+ ```
257
+
258
+ - Each attachment is checked by `checkMessage` first: bytes as a
259
+ `Uint8Array`, a file name not empty, not `.` or `..`, without `/`, `\`, a line break (U+2028 and U+2029 included), a control character or a format character such as a right-to-left override, a `type/subtype` that is not `multipart/*` or `message/*` (which
260
+ nodemailer would write unencoded, as parts of the message) — see
261
+ [`@nxgt/mail` — attachments](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/sending.md#attachments).
262
+ - The bytes are **copied** into a `Buffer` when `send` is called: changing
263
+ your array while the message is on its way changes nothing.
264
+ - nodemailer writes each one as a base64 MIME part, the name encoded
265
+ (RFC 2231) when it is not ASCII.
266
+ - nodemailer's `path`, `href`, `raw` and streams are never used, and
267
+ `disableFileAccess` and `disableUrlAccess` stay on: an attachment is bytes
268
+ your code already holds.
269
+
270
+ **Size.** The server caps the whole message after encoding — base64 makes a
271
+ file a third larger. Gmail's SMTP takes about 25 MB; other servers advertise
272
+ their limit in `EHLO` (`SIZE`), often 10 to 50 MB. Over it, the server
273
+ answers `552` once the message is sent, and `send` throws `MailRefused`:
274
+ [Errors](errors.md#which-smtp-answer-is-which). A large or sensitive file is a
275
+ signed, expiring link in the template instead, which never sits in an inbox.
276
+
185
277
  ## With the renderer
186
278
 
187
279
  What `@nxgt/mail/renderer` answers spreads into the message:
@@ -60,6 +60,11 @@ export async function startSmtpServer() {
60
60
  subject: parsed.subject ?? '',
61
61
  html: typeof parsed.html === 'string' ? parsed.html : '',
62
62
  text: parsed.text ?? '',
63
+ attachments: parsed.attachments.map((file) => ({
64
+ filename: file.filename ?? '',
65
+ content: new Uint8Array(file.content), // a Buffer, read back as bytes
66
+ contentType: file.contentType,
67
+ })),
63
68
  });
64
69
  callback();
65
70
  },
@@ -81,7 +86,9 @@ export async function startSmtpServer() {
81
86
 
82
87
  `to` is read from the **envelope** (`RCPT TO`), not from the `To:` header: it
83
88
  is who the server was asked to deliver to, and what proves a name did not
84
- smuggle a second recipient in.
89
+ smuggle a second recipient in. `attachments` is what `mailparser` decoded,
90
+ the file name included: `send.attachment` fails on a harness that leaves it
91
+ out.
85
92
 
86
93
  ## The conformance suite
87
94
 
@@ -120,10 +127,11 @@ describeMailer({
120
127
  });
121
128
  ```
122
129
 
123
- All eleven cases pass: a send answers `SentMail`, the message arrives byte for
130
+ All thirteen cases pass: a send answers `SentMail`, the message arrives byte for
124
131
  byte (accents, an emoji, `&amp;` in a link), every recipient is delivered to,
125
- a hostile name reaches only its own address, the refusals — a `Bcc` among the
126
- custom headers included — and the three
132
+ a hostile name reaches only its own address, an attachment arrives byte for
133
+ byte with its name and type, the refusals — a `Bcc` among the custom headers
134
+ and an attachment named with a path included — and the three
127
135
  failure cases — an outage is a `MailFailure` with its `cause` and one attempt,
128
136
  a refusal a `MailRefused`, and the next send goes through.
129
137
 
@@ -146,7 +154,14 @@ add what the suite does not ask of every transport:
146
154
  - no error message holds the password or a recipient's address;
147
155
  - the default `from`, `replyTo` and `headers` reach the server, and the id is
148
156
  nodemailer's;
149
- - nodemailer is told never to read a file or a URL.
157
+ - nodemailer is told never to read a file or a URL;
158
+ - `idempotencyKey` is ignored: the same message sent twice is handed over
159
+ twice, and the key appears nowhere in what nodemailer receives;
160
+ - attachments are handed over as `{ filename, content, contentType }` with a
161
+ `Buffer` copied from the bytes — a change to the caller's array during the
162
+ send reaches no one — and an empty list sends none;
163
+ - a message over the server's size limit (`552`, from `smtp-server`'s `size`)
164
+ is a `MailRefused`, and nothing is delivered.
150
165
 
151
166
  A transporter can also be a plain object, when a test only needs to see what
152
167
  was handed over:
package/docs/roadmap.md CHANGED
@@ -5,7 +5,10 @@ no dates here, and the version something shipped in is the only number.
5
5
 
6
6
  ## Now
7
7
 
8
- Nothing between releases.
8
+ - **Messages with an idempotency key** — a message that carries an
9
+ `idempotencyKey` is accepted, and the key is ignored: SMTP has no
10
+ idempotency, so a message sent twice is delivered twice. Needs `@nxgt/mail`
11
+ 0.3. Built, not yet published.
9
12
 
10
13
  ## Next
11
14
 
@@ -25,8 +28,9 @@ Nothing planned yet. Say what you need in an issue.
25
28
  twice.
26
29
  - **A transport's own error class** — it throws `@nxgt/mail`'s `MailFailure`
27
30
  and `MailRefused`, so `instanceof` holds whichever transport you wire.
28
- - **Attachments and files read by nodemailer** — a message is three strings;
29
- `disableFileAccess` and `disableUrlAccess` stay on.
31
+ - **Files and URLs read by nodemailer** — an attachment is bytes your code
32
+ already holds; `disableFileAccess` and `disableUrlAccess` stay on, so no
33
+ value from outside can make nodemailer read a file or fetch a URL.
30
34
  - **Bundling nodemailer** — it is a peer: one copy, the version you choose.
31
35
 
32
36
  ## Shipped
@@ -34,6 +38,12 @@ Nothing planned yet. Say what you need in an issue.
34
38
  The last ten, newest first, each with the version it came in. Everything
35
39
  before is in the [CHANGELOG](../CHANGELOG.md).
36
40
 
41
+ - **Attachments, v0.2.0** — the `attachments` of a message are handed to nodemailer
42
+ as bytes, a `Buffer` copied from each `Uint8Array`, with their file name
43
+ (encoded by nodemailer when it is not ASCII) and their type. Never a `path`
44
+ or an `href`: `disableFileAccess` and `disableUrlAccess` stay on. A message
45
+ over the server's size limit (`552`) is a `MailRefused`. Needs
46
+ `@nxgt/mail` 0.2.
37
47
  - **An SMTP transport on your nodemailer, v0.1.0** — `createSmtpMailer({ transporter,
38
48
  from })`: every SMTP option is nodemailer's, set where you create the
39
49
  transporter. Each message is checked as every transport checks it, a name is
@@ -79,6 +79,11 @@ try {
79
79
  A retry is yours to decide — from a queue, with a delay. The transport never
80
80
  retries in secret.
81
81
 
82
+ An `idempotencyKey` does not make that retry safe here: SMTP has no
83
+ idempotency, and this transport ignores the key. If the first attempt went
84
+ through after all, the retry delivers a second copy — weigh that before
85
+ retrying after a timeout.
86
+
82
87
  ### `send: the SMTP server refused the message`
83
88
 
84
89
  A `MailRefused`, code `MAIL_REFUSED`.
@@ -95,6 +100,24 @@ every recipient refused, `cause.rejectedErrors` holds one error per
95
100
  recipient; correct the address or the content. A recipient that does not
96
101
  exist is usually worth telling the user about.
97
102
 
103
+ A `552` on a message with attachments is the server's size limit — the
104
+ whole message, after base64 has made each file a third larger. Sending it
105
+ again fails again: send the file as a signed link in the template instead.
106
+
107
+ ```ts
108
+ import { MailRefused } from '@nxgt/mail';
109
+
110
+ try {
111
+ await mailer.send(message);
112
+ } catch (error) {
113
+ const cause = error instanceof MailRefused ? (error.cause as { responseCode?: number }) : null;
114
+ if (cause?.responseCode === 552 && message.attachments?.length) {
115
+ // too large: resend with a link to the file rather than the file
116
+ }
117
+ throw error;
118
+ }
119
+ ```
120
+
98
121
  ### `send: the SMTP server refused <n> of <total> recipients, and may have delivered to the others`
99
122
 
100
123
  A `MailRefused`, code `MAIL_REFUSED`. **The accepted recipients may already
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/mail-smtp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "An SMTP transport for @nxgt/mail, on the nodemailer you install: it throws the MailFailure and MailRefused of its @nxgt/mail peer, and passes the conformance suite.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,7 +44,7 @@
44
44
  "typecheck": "tsc --noEmit"
45
45
  },
46
46
  "devDependencies": {
47
- "@nxgt/mail": "0.1.0",
47
+ "@nxgt/mail": "0.3.0",
48
48
  "@types/bun": "^1.4.2",
49
49
  "@types/mailparser": "^3.4.6",
50
50
  "@types/nodemailer": "^8.0.2",
@@ -54,7 +54,7 @@
54
54
  "smtp-server": "^3.19.13"
55
55
  },
56
56
  "peerDependencies": {
57
- "@nxgt/mail": "^0.1.0",
57
+ "@nxgt/mail": "^0.3.0",
58
58
  "nodemailer": ">=7.0.0 <11",
59
59
  "typescript": "^6.0.3"
60
60
  }