@nxgt/mail-smtp 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/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.2`, the version
41
+ with attachments. 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,43 @@ 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
+
95
+ ### Attachments
96
+
97
+ `attachments` on the message are handed to nodemailer as bytes — a `Buffer`
98
+ copied from each `Uint8Array` — with their file name and type:
99
+
100
+ ```ts
101
+ import { readFile } from 'node:fs/promises';
102
+
103
+ await mailer.send({
104
+ to: 'ada@example.com',
105
+ subject: 'Your invoice',
106
+ html: '<p>Your invoice is attached.</p>',
107
+ text: 'Your invoice is attached.',
108
+ attachments: [
109
+ { filename: 'invoice-42.pdf', content: await readFile('/srv/invoices/42.pdf'), contentType: 'application/pdf' },
110
+ ],
111
+ });
112
+ ```
113
+
114
+ There is no `path` or `href`, as nodemailer would take them: read the file
115
+ yourself, where your code decides which files may be read. A file name
116
+ outside ASCII is encoded by nodemailer. The server caps the whole message,
117
+ attachments in base64 included — a third larger than the files — at about
118
+ 25 MB sending through Gmail, often 10 to 50 MB elsewhere; over it, the
119
+ server answers `552` and `send` throws `MailRefused`. **A large or sensitive
120
+ file is a signed link in the template**, not an attachment. See
121
+ [Setting up — attachments](docs/guide/setup.md#attachments).
93
122
 
94
123
  ### Errors — a refusal or a failure
95
124
 
96
125
  | When | Throws | `cause` |
97
126
  | --- | --- | --- |
98
127
  | 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 |
128
+ | 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
129
  | 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
130
  | 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
131
  | A bad option | `TypeError` from `createSmtpMailer` | — |
@@ -146,6 +175,11 @@ server may accept one recipient and refuse another; the message then went out
146
175
  to the accepted one. Retrying it whole sends it to them twice — send to one
147
176
  recipient per `send` when every result must be all or nothing.
148
177
 
178
+ **An attachment is held in memory, whole, and grows a third on the way.**
179
+ nodemailer encodes it in base64 into the message it streams; a file of tens
180
+ of megabytes is refused by most servers (`552`, `MailRefused`) after it was
181
+ read. Send a signed link instead.
182
+
149
183
  **A custom header cannot set an address.** `headers: { Bcc: '…' }` would add
150
184
  an envelope recipient no check saw: `checkMessage` refuses `To`, `Cc`, `Bcc`,
151
185
  `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": ";AAkBA;AAAA;AAAA;AAAA;AAAA;AAqDA,IAAM,eAAe,CAAC,YACrB,OAAO,YAAY,WAChB,EAAE,MAAM,IAAI,QAAQ,IACpB,EAAE,MAAM,QAAQ,MAAM,SAAS,QAAQ,QAAQ;AAUnD,IAAM,WAAW,CAAC,UACjB,OAAO,UAAU,YAAY,UAAU,OAAQ,QAAsB,CAAC;AAUvE,SAAS,kBAAkB,CAAC,OAAyB;AAAA,EACpD,QAAQ,MAAM,cAAc,YAAY,SAAS,KAAK;AAAA,EACtD,QACE,SAAS,eAAe,SAAS,eAClC,YAAY,eACZ,OAAO,iBAAiB,YACxB,gBAAgB,OAChB,eAAe,QACd,eAAe,OAAO,eAAe;AAAA;AASxC,SAAS,SAAS,CAAC,OAAyB;AAAA,EAC3C,QAAQ,mBAAmB,SAAS,KAAK;AAAA,EACzC,IAAI,MAAM,QAAQ,cAAc,KAAK,eAAe,SAAS,GAAG;AAAA,IAC/D,OAAO,eAAe,MAAM,kBAAkB;AAAA,EAC/C;AAAA,EACA,OAAO,mBAAmB,KAAK;AAAA;AAOhC,SAAS,uBAAuB,CAAC,MAA0B;AAAA,EAC1D,MAAM,SAAS,MAAM,QAAQ,KAAK,cAAc,IAAI,KAAK,iBAAiB,CAAC;AAAA,EAC3E,MAAM,WAAW,MAAM,QAAQ,KAAK,QAAQ,IAAI,KAAK,WAAW,CAAC;AAAA,EACjE,MAAM,eAAe,KAAK,IAAI,OAAO,QAAQ,SAAS,MAAM;AAAA,EAC5D,IAAI,iBAAiB;AAAA,IAAG;AAAA,EACxB,MAAM,WAAW,MAAM,QAAQ,KAAK,QAAQ,IAAI,KAAK,SAAS,SAAS;AAAA,EACvE,MAAM,UAAU,GAAG,mBAAmB,eAAe;AAAA,EACrD,MAAM,QACL,OAAO,MACP,OAAO,OAAO,IAAI,MAAM,yCAAyC,GAAG;AAAA,IACnE;AAAA,EACD,CAAC;AAAA,EACF,IAAI,OAAO,SAAS,KAAK,OAAO,MAAM,kBAAkB,GAAG;AAAA,IAC1D,MAAM,IAAI,YACT,iCAAiC,iDACjC,EAAE,MAAM,CACT;AAAA,EACD;AAAA,EACA,MAAM,IAAI,YACT,wCAAwC,iDACxC,EAAE,MAAM,CACT;AAAA;AAIM,SAAS,gBAAgB,CAAC,SAAoC;AAAA,EACpE,IAAI,OAAO,YAAY,YAAY,YAAY,MAAM;AAAA,IACpD,MAAM,IAAI,UACT,iEACD;AAAA,EACD;AAAA,EACA,QAAQ,aAAa,SAAS;AAAA,EAC9B,IACC,OAAO,gBAAgB,YACvB,gBAAgB,QAChB,OAAO,YAAY,aAAa,YAC/B;AAAA,IACD,MAAM,IAAI,UACT,kFACD;AAAA,EACD;AAAA,EACA,IAAI,SAAS,WAAW;AAAA,IAEvB,IAAI;AAAA,MACH,aAAa,EAAE,IAAI,MAAM,SAAS,IAAI,MAAM,IAAI,MAAM,GAAG,CAAC;AAAA,MACzD,MAAM;AAAA,MACP,MAAM,IAAI,UACT,+FACD;AAAA;AAAA,EAEF;AAAA,EAEA,OAAO;AAAA,SACA,KAAI,CAAC,SAAyC;AAAA,MACnD,aAAa,OAAO;AAAA,MACpB,MAAM,SAAS,QAAQ,QAAQ;AAAA,MAC/B,IAAI,WAAW,WAAW;AAAA,QACzB,MAAM,IAAI,YACT,oFACD;AAAA,MACD;AAAA,MACA,MAAM,KAAK,MAAM,QAAQ,QAAQ,EAAE,IAAI,QAAQ,KAAK,CAAC,QAAQ,EAAE;AAAA,MAC/D,IAAI;AAAA,MACJ,IAAI;AAAA,QACH,OAAO,MAAM,YAAY,SAAS;AAAA,UACjC,MAAM,aAAa,MAAM;AAAA,UACzB,IAAI,GAAG,IAAI,YAAY;AAAA,aACnB,QAAQ,YAAY,YACrB,CAAC,IACD,EAAE,SAAS,aAAa,QAAQ,OAAO,EAAE;AAAA,UAC5C,SAAS,QAAQ;AAAA,UACjB,MAAM,QAAQ;AAAA,UACd,MAAM,QAAQ;AAAA,aACV,QAAQ,YAAY,YACrB,CAAC,IACD,EAAE,SAAS,KAAK,QAAQ,QAAQ,EAAE;AAAA,UAErC,mBAAmB;AAAA,UACnB,kBAAkB;AAAA,QACnB,CAAC;AAAA,QACA,OAAO,OAAO;AAAA,QACf,IAAI,UAAU,KAAK,GAAG;AAAA,UACrB,MAAM,IAAI,YAAY,6CAA6C;AAAA,YAClE,OAAO;AAAA,UACR,CAAC;AAAA,QACF;AAAA,QACA,MAAM,IAAI,YACT,oDACA;AAAA,UACC,OAAO;AAAA,QACR,CACD;AAAA;AAAA,MAED,wBAAwB,QAAQ,CAAC,CAAC;AAAA,MAClC,MAAM,YACL,OAAO,MAAM,cAAc,YAAY,KAAK,cAAc,KACvD,KAAK,YACL;AAAA,MACJ,OAAO,EAAE,UAAU;AAAA;AAAA,EAErB;AAAA;",
8
- "debugId": "748DA33D57D71AF564756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "E9820236D701396064756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -58,7 +58,7 @@ a `responseCode`. The transport reads both:
58
58
  | The sender refused (`550`, `553` on `MAIL FROM`) | `EENVELOPE` with a `5xx` and `command: 'MAIL FROM'` | `MailFailure` |
59
59
  | A recipient refused for good (`550`, `553`) | `EENVELOPE` with a `5xx` | `MailRefused` |
60
60
  | 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` |
61
+ | The content refused for good (`552` too large, attachments included; `554` rejected) | `EMESSAGE` with a `5xx` | `MailRefused` |
62
62
  | Anything else | — | `MailFailure` |
63
63
 
64
64
  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,8 @@ 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
+ | `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 |
176
+ | — | `disableFileAccess: true`, `disableUrlAccess: true`: a part or an attachment is never read from a file or fetched from a URL |
175
177
 
176
178
  Before any of it, `checkMessage` from `@nxgt/mail` refuses what no transport
177
179
  hands over — no recipient, something that is not an address, a line break in
@@ -182,6 +184,49 @@ subject or the MIME structure (`Bcc`, `To`, `Content-Type`…). Its messages are
182
184
  `send` answers `{ messageId }`: nodemailer's `Message-ID` (`<…@acme.test>`), or
183
185
  `null` when the transporter answers none — an absence, not a failure.
184
186
 
187
+ ## Attachments
188
+
189
+ ```ts
190
+ import { readFile } from 'node:fs/promises';
191
+ import nodemailer from 'nodemailer';
192
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
193
+
194
+ const mailer = createSmtpMailer({
195
+ transporter: nodemailer.createTransport(process.env.SMTP_URL ?? 'smtp://localhost:1025'),
196
+ from: 'billing@acme.test',
197
+ });
198
+
199
+ await mailer.send({
200
+ to: 'ada@example.com',
201
+ subject: 'Your invoice',
202
+ html: '<p>Your invoice is attached.</p>',
203
+ text: 'Your invoice is attached.',
204
+ attachments: [
205
+ { filename: 'facture n° 42.pdf', content: await readFile('/srv/invoices/42.pdf'), contentType: 'application/pdf' },
206
+ { filename: 'invoice.ics', content: new TextEncoder().encode('BEGIN:VCALENDAR…'), contentType: 'text/calendar' },
207
+ ],
208
+ });
209
+ ```
210
+
211
+ - Each attachment is checked by `checkMessage` first: bytes as a
212
+ `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
213
+ nodemailer would write unencoded, as parts of the message) — see
214
+ [`@nxgt/mail` — attachments](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/sending.md#attachments).
215
+ - The bytes are **copied** into a `Buffer` when `send` is called: changing
216
+ your array while the message is on its way changes nothing.
217
+ - nodemailer writes each one as a base64 MIME part, the name encoded
218
+ (RFC 2231) when it is not ASCII.
219
+ - nodemailer's `path`, `href`, `raw` and streams are never used, and
220
+ `disableFileAccess` and `disableUrlAccess` stay on: an attachment is bytes
221
+ your code already holds.
222
+
223
+ **Size.** The server caps the whole message after encoding — base64 makes a
224
+ file a third larger. Gmail's SMTP takes about 25 MB; other servers advertise
225
+ their limit in `EHLO` (`SIZE`), often 10 to 50 MB. Over it, the server
226
+ answers `552` once the message is sent, and `send` throws `MailRefused`:
227
+ [Errors](errors.md#which-smtp-answer-is-which). A large or sensitive file is a
228
+ signed, expiring link in the template instead, which never sits in an inbox.
229
+
185
230
  ## With the renderer
186
231
 
187
232
  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,12 @@ 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
+ - attachments are handed over as `{ filename, content, contentType }` with a
159
+ `Buffer` copied from the bytes — a change to the caller's array during the
160
+ send reaches no one — and an empty list sends none;
161
+ - a message over the server's size limit (`552`, from `smtp-server`'s `size`)
162
+ is a `MailRefused`, and nothing is delivered.
150
163
 
151
164
  A transporter can also be a plain object, when a test only needs to see what
152
165
  was handed over:
package/docs/roadmap.md CHANGED
@@ -5,7 +5,12 @@ 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
+ - **Attachments** — the `attachments` of a message are handed to nodemailer
9
+ as bytes, a `Buffer` copied from each `Uint8Array`, with their file name
10
+ (encoded by nodemailer when it is not ASCII) and their type. Never a `path`
11
+ or an `href`: `disableFileAccess` and `disableUrlAccess` stay on. A message
12
+ over the server's size limit (`552`) is a `MailRefused`. Needs
13
+ `@nxgt/mail` 0.2. Built, not yet published.
9
14
 
10
15
  ## Next
11
16
 
@@ -25,8 +30,9 @@ Nothing planned yet. Say what you need in an issue.
25
30
  twice.
26
31
  - **A transport's own error class** — it throws `@nxgt/mail`'s `MailFailure`
27
32
  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.
33
+ - **Files and URLs read by nodemailer** — an attachment is bytes your code
34
+ already holds; `disableFileAccess` and `disableUrlAccess` stay on, so no
35
+ value from outside can make nodemailer read a file or fetch a URL.
30
36
  - **Bundling nodemailer** — it is a peer: one copy, the version you choose.
31
37
 
32
38
  ## Shipped
@@ -95,6 +95,24 @@ every recipient refused, `cause.rejectedErrors` holds one error per
95
95
  recipient; correct the address or the content. A recipient that does not
96
96
  exist is usually worth telling the user about.
97
97
 
98
+ A `552` on a message with attachments is the server's size limit — the
99
+ whole message, after base64 has made each file a third larger. Sending it
100
+ again fails again: send the file as a signed link in the template instead.
101
+
102
+ ```ts
103
+ import { MailRefused } from '@nxgt/mail';
104
+
105
+ try {
106
+ await mailer.send(message);
107
+ } catch (error) {
108
+ const cause = error instanceof MailRefused ? (error.cause as { responseCode?: number }) : null;
109
+ if (cause?.responseCode === 552 && message.attachments?.length) {
110
+ // too large: resend with a link to the file rather than the file
111
+ }
112
+ throw error;
113
+ }
114
+ ```
115
+
98
116
  ### `send: the SMTP server refused <n> of <total> recipients, and may have delivered to the others`
99
117
 
100
118
  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.2.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.2.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.2.0",
58
58
  "nodemailer": ">=7.0.0 <11",
59
59
  "typescript": "^6.0.3"
60
60
  }