@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 +39 -5
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +3 -3
- package/docs/guide/errors.md +1 -1
- package/docs/guide/setup.md +46 -1
- package/docs/guide/testing.md +18 -5
- package/docs/roadmap.md +9 -3
- package/docs/troubleshooting.md +18 -0
- package/package.json +3 -3
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
|
|
41
|
-
`error instanceof MailFailure`
|
|
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
|
|
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
|
|
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>;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACN,KAAK,OAAO,
|
|
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=
|
|
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;
|
|
8
|
-
"debugId": "
|
|
7
|
+
"mappings": "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",
|
|
8
|
+
"debugId": "E9820236D701396064756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
package/docs/guide/errors.md
CHANGED
|
@@ -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
|
package/docs/guide/setup.md
CHANGED
|
@@ -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
|
-
|
|
|
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:
|
package/docs/guide/testing.md
CHANGED
|
@@ -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
|
|
130
|
+
All thirteen cases pass: a send answers `SentMail`, the message arrives byte for
|
|
124
131
|
byte (accents, an emoji, `&` in a link), every recipient is delivered to,
|
|
125
|
-
a hostile name reaches only its own address,
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
57
|
+
"@nxgt/mail": "^0.2.0",
|
|
58
58
|
"nodemailer": ">=7.0.0 <11",
|
|
59
59
|
"typescript": "^6.0.3"
|
|
60
60
|
}
|