@nxgt/mail-smtp 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # @nxgt/mail-smtp
2
+
3
+ An SMTP transport for [`@nxgt/mail`](https://github.com/softistx/nxgt-mail/tree/develop/packages/mail),
4
+ on the [`nodemailer`](https://nodemailer.com) you install and configure. It
5
+ throws the `MailFailure` and `MailRefused` of its `@nxgt/mail` peer, so
6
+ `instanceof` holds whichever transport is wired, and it passes the
7
+ `@nxgt/mail/conformance` suite against a real local SMTP server.
8
+
9
+ ```ts
10
+ import nodemailer from 'nodemailer';
11
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
12
+
13
+ const mailer = createSmtpMailer({
14
+ transporter: nodemailer.createTransport({
15
+ host: 'smtp.example.com',
16
+ port: 587,
17
+ auth: { user: 'acme', pass: process.env.SMTP_PASSWORD },
18
+ }),
19
+ from: { name: 'Acme', address: 'noreply@acme.test' },
20
+ });
21
+
22
+ const { messageId } = await mailer.send({
23
+ to: 'ada@example.com',
24
+ subject: 'Confirm your address',
25
+ html: '<p>…</p>',
26
+ text: '…',
27
+ }); // '<…@acme.test>' — or it throws
28
+ ```
29
+
30
+ > **0.x.** A minor version may still change the surface; the changelog says how.
31
+
32
+ ## Install
33
+
34
+ ```sh
35
+ bun add @nxgt/mail-smtp @nxgt/mail nodemailer
36
+ ```
37
+
38
+ Peers, all required:
39
+
40
+ - `@nxgt/mail` — the port and the errors. One copy in your tree, so
41
+ `error instanceof MailFailure` holds.
42
+ - `nodemailer` (`>=7 <11`; tested with 10). This package never imports it:
43
+ you create the transporter, with every SMTP option nodemailer has.
44
+ - `typescript` (6). Bundler resolution (`"moduleResolution": "bundler"`) is
45
+ what is supported and tested; `nodenext` is out of contract.
46
+
47
+ ## Exports
48
+
49
+ | Export | What it is |
50
+ | --- | --- |
51
+ | `createSmtpMailer(options)` | A `Mailer` that hands each message to your nodemailer transporter |
52
+ | `SmtpMailerOptions` | `{ transporter, from? }` |
53
+ | `SmtpTransporter` | The part of a nodemailer transporter it calls: `sendMail` |
54
+ | `SmtpSentInfo` | What `sendMail` resolves with: the id, and the recipients refused while others were accepted |
55
+
56
+ ## Usage
57
+
58
+ ### Options
59
+
60
+ | Option | Type | Default | |
61
+ | --- | --- | --- | --- |
62
+ | `transporter` | `SmtpTransporter` | required | What `nodemailer.createTransport(…)` answers, configured by you |
63
+ | `from` | `Address` | none | The sender of a message that names none |
64
+
65
+ ```ts
66
+ import nodemailer from 'nodemailer';
67
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
68
+
69
+ // An SMTP URL works as well as an options object.
70
+ const mailer = createSmtpMailer({
71
+ transporter: nodemailer.createTransport(process.env.SMTP_URL ?? 'smtp://localhost:1025'),
72
+ });
73
+
74
+ await mailer.send({
75
+ to: [{ name: 'Doe, John', address: 'john@example.com' }],
76
+ from: 'billing@acme.test', // required here: this mailer has no default
77
+ replyTo: 'support@acme.test',
78
+ headers: { 'List-Unsubscribe': '<https://acme.test/unsubscribe>' },
79
+ subject: 'Your invoice',
80
+ html: '<p>…</p>',
81
+ text: '…',
82
+ });
83
+ ```
84
+
85
+ - Every message is checked by `checkMessage` from `@nxgt/mail` first: the
86
+ same refusals, with the same messages, as every transport.
87
+ - A message's own `from` wins over the default.
88
+ - A name is handed to nodemailer as `{ name, address }`: nodemailer quotes and
89
+ encodes it, so `Doe, John` names one recipient.
90
+ - `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`).
93
+
94
+ ### Errors — a refusal or a failure
95
+
96
+ | When | Throws | `cause` |
97
+ | --- | --- | --- |
98
+ | 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 |
100
+ | 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
+ | 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
+ | A bad option | `TypeError` from `createSmtpMailer` | — |
103
+
104
+ ```ts
105
+ import { MailFailure, MailRefused } from '@nxgt/mail';
106
+
107
+ try {
108
+ await mailer.send(message);
109
+ } catch (error) {
110
+ if (error instanceof MailRefused) {
111
+ // sending it again unchanged fails again: fix the address or the content
112
+ } else if (error instanceof MailFailure) {
113
+ // nothing is known to have been sent: retry later, from a queue you can see
114
+ }
115
+ throw error;
116
+ }
117
+ ```
118
+
119
+ A message reports a shape, never a value: no address, no password, no
120
+ server answer. What the server said is on `cause`. Nothing is retried.
121
+ Every case is in [Errors](docs/guide/errors.md).
122
+
123
+ ### Testing
124
+
125
+ In an application's tests, use `createMemoryMailer()` from `@nxgt/mail`. To
126
+ test this transport against a real server, see [Testing](docs/guide/testing.md):
127
+ a local `smtp-server`, `mailparser` to read back what arrived, and
128
+ `describeMailer`.
129
+
130
+ ## Traps
131
+
132
+ **Close a pooled transporter when the process stops.** With `pool: true`,
133
+ nodemailer keeps connections open: call `transporter.close()` on shutdown.
134
+
135
+ **nodemailer's timeouts are yours to set.** It waits up to two minutes for a
136
+ connection by default; a send in a request handler should not. Set
137
+ `connectionTimeout`, `greetingTimeout` and `socketTimeout` on the transporter.
138
+ A timeout ends in `MailFailure`.
139
+
140
+ **A `4xx` is a failure, a `5xx` a refusal** — except authentication (`530`–
141
+ `539`) and a sender refused at `MAIL FROM`: the next message would be refused
142
+ the same way, so it is a failure of the wiring, not of the message.
143
+
144
+ **Some recipients refused still throws, after the others got it.** The
145
+ server may accept one recipient and refuse another; the message then went out
146
+ to the accepted one. Retrying it whole sends it to them twice — send to one
147
+ recipient per `send` when every result must be all or nothing.
148
+
149
+ **A custom header cannot set an address.** `headers: { Bcc: '…' }` would add
150
+ an envelope recipient no check saw: `checkMessage` refuses `To`, `Cc`, `Bcc`,
151
+ `From`, `Sender`, `Reply-To`, `Return-Path`, `Subject`, `MIME-Version` and
152
+ `Content-*` in `headers`, in any case.
153
+
154
+ **A string address is only an address.** `'Acme <noreply@acme.test>'` as
155
+ `from` is a `TypeError` at wiring and a `MailRefused` on a message; write
156
+ `{ name: 'Acme', address: 'noreply@acme.test' }`. A string holding whitespace,
157
+ `,`, `;` or `:` is refused too, and nodemailer is handed every address as
158
+ `{ name, address }`, so it never parses one.
159
+
160
+ ## Type safety, counted
161
+
162
+ **6 plausible mistakes, 6 refused** at compile time, each measured by a
163
+ `@ts-expect-error` in
164
+ [`test/types/refusals.ts`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-smtp/test/types/refusals.ts)
165
+ that fails the typecheck the moment it stops holding:
166
+
167
+ 1. No `transporter`.
168
+ 2. nodemailer's options (`{ host, port }`) as the transporter, instead of what
169
+ `createTransport` answers.
170
+ 3. The SMTP options given to `createSmtpMailer` itself.
171
+ 4. A default `from` without its `address`.
172
+ 5. A `retries` option: the transport tries once.
173
+ 6. `messageId` read as a `string`: it is `string | null`.
174
+
175
+ The same file holds the calls that must keep compiling — among them what
176
+ `nodemailer.createTransport` answers, from options or a URL, with no cast.
177
+
178
+ ## Documentation
179
+
180
+ - [The guides](docs/README.md) — setting up nodemailer, the errors, testing.
181
+ - [Troubleshooting](docs/troubleshooting.md) — an error message, its cause and
182
+ its fix.
183
+ - [Roadmap](docs/roadmap.md) — what is next, and what is deliberately not
184
+ planned.
185
+ - [Vocabulary](https://github.com/softistx/nxgt-mail/blob/develop/docs/vocabulary.md)
186
+ — the words these pages use, defined once.
187
+
188
+ ## Licence
189
+
190
+ MIT
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `@nxgt/mail-smtp` — an SMTP transport for `@nxgt/mail`, on the `nodemailer`
3
+ * the application installs and configures.
4
+ *
5
+ * ```ts
6
+ * import nodemailer from 'nodemailer';
7
+ * import { createSmtpMailer } from '@nxgt/mail-smtp';
8
+ *
9
+ * const mailer = createSmtpMailer({
10
+ * transporter: nodemailer.createTransport({ host, port: 587, auth }),
11
+ * from: { name: 'Acme', address: 'noreply@acme.test' },
12
+ * });
13
+ * ```
14
+ *
15
+ * **A failure throws** the `MailFailure` or `MailRefused` of the `@nxgt/mail`
16
+ * peer, nodemailer's error as the `cause`. Nothing is retried.
17
+ */
18
+ import { type Address, type Mailer } from '@nxgt/mail';
19
+ /**
20
+ * An address as nodemailer takes it: always an object, so nodemailer never
21
+ * parses a string — it quotes and encodes the name itself.
22
+ */
23
+ type NodemailerAddress = {
24
+ name: string;
25
+ address: string;
26
+ };
27
+ /**
28
+ * What nodemailer answers once the server took the message: its id, and the
29
+ * recipients it refused while accepting others — nodemailer resolves then.
30
+ */
31
+ export interface SmtpSentInfo {
32
+ readonly messageId?: string;
33
+ readonly accepted?: readonly unknown[] | undefined;
34
+ readonly rejected?: readonly unknown[] | undefined;
35
+ /** One nodemailer error per refused recipient, with its `responseCode`. */
36
+ readonly rejectedErrors?: readonly unknown[] | undefined;
37
+ }
38
+ /**
39
+ * The part of a nodemailer transporter this transport calls — what
40
+ * `nodemailer.createTransport(…)` answers, from nodemailer 7 on.
41
+ */
42
+ export interface SmtpTransporter {
43
+ sendMail(mail: {
44
+ from: NodemailerAddress;
45
+ to: NodemailerAddress[];
46
+ replyTo?: NodemailerAddress;
47
+ subject: string;
48
+ html: string;
49
+ text: string;
50
+ headers?: Record<string, string>;
51
+ disableFileAccess: boolean;
52
+ disableUrlAccess: boolean;
53
+ }): Promise<SmtpSentInfo>;
54
+ }
55
+ export interface SmtpMailerOptions {
56
+ /** `nodemailer.createTransport(…)`, configured by the application. */
57
+ readonly transporter: SmtpTransporter;
58
+ /** The sender of a message that names none. Without it, such a message is refused. */
59
+ readonly from?: Address;
60
+ }
61
+ /** Creates a {@link Mailer} that hands each message to `transporter`. */
62
+ export declare function createSmtpMailer(options: SmtpMailerOptions): Mailer;
63
+ export {};
64
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,93 @@
1
+ // src/index.ts
2
+ import {
3
+ checkMessage,
4
+ MailFailure,
5
+ MailRefused
6
+ } from "@nxgt/mail";
7
+ var toNodemailer = (address) => typeof address === "string" ? { name: "", address } : { name: address.name, address: address.address };
8
+ var fieldsOf = (error) => typeof error === "object" && error !== null ? error : {};
9
+ function isPermanentRefusal(error) {
10
+ const { code, responseCode, command } = fieldsOf(error);
11
+ return (code === "EENVELOPE" || code === "EMESSAGE") && command !== "MAIL FROM" && typeof responseCode === "number" && responseCode >= 500 && responseCode < 600 && (responseCode < 530 || responseCode > 539);
12
+ }
13
+ function isRefusal(error) {
14
+ const { rejectedErrors } = fieldsOf(error);
15
+ if (Array.isArray(rejectedErrors) && rejectedErrors.length > 0) {
16
+ return rejectedErrors.every(isPermanentRefusal);
17
+ }
18
+ return isPermanentRefusal(error);
19
+ }
20
+ function throwOnPartialRejection(info) {
21
+ const errors = Array.isArray(info.rejectedErrors) ? info.rejectedErrors : [];
22
+ const rejected = Array.isArray(info.rejected) ? info.rejected : [];
23
+ const refusedCount = Math.max(errors.length, rejected.length);
24
+ if (refusedCount === 0)
25
+ return;
26
+ const accepted = Array.isArray(info.accepted) ? info.accepted.length : 0;
27
+ const counted = `${refusedCount} of ${refusedCount + accepted} recipients`;
28
+ const cause = errors[0] ?? Object.assign(new Error("the SMTP server refused some recipients"), {
29
+ rejected
30
+ });
31
+ if (errors.length > 0 && errors.every(isPermanentRefusal)) {
32
+ throw new MailRefused(`send: the SMTP server refused ${counted}, and may have delivered to the others`, { cause });
33
+ }
34
+ throw new MailFailure(`send: the SMTP server could not take ${counted}, and may have delivered to the others`, { cause });
35
+ }
36
+ function createSmtpMailer(options) {
37
+ if (typeof options !== "object" || options === null) {
38
+ throw new TypeError("createSmtpMailer: options must be an object, as { transporter }");
39
+ }
40
+ const { transporter, from } = options;
41
+ if (typeof transporter !== "object" || transporter === null || typeof transporter.sendMail !== "function") {
42
+ throw new TypeError("createSmtpMailer: transporter must be what nodemailer.createTransport(…) answers");
43
+ }
44
+ if (from !== undefined) {
45
+ try {
46
+ checkMessage({ to: from, subject: "", html: "", text: "" });
47
+ } catch {
48
+ throw new TypeError("createSmtpMailer: from must be an e-mail address, as noreply@example.com or { name, address }");
49
+ }
50
+ }
51
+ return {
52
+ async send(message) {
53
+ checkMessage(message);
54
+ const sender = message.from ?? from;
55
+ if (sender === undefined) {
56
+ throw new MailRefused("send: from is missing — give the message a from, or createSmtpMailer a default one");
57
+ }
58
+ const to = Array.isArray(message.to) ? message.to : [message.to];
59
+ let info;
60
+ try {
61
+ info = await transporter.sendMail({
62
+ from: toNodemailer(sender),
63
+ to: to.map(toNodemailer),
64
+ ...message.replyTo === undefined ? {} : { replyTo: toNodemailer(message.replyTo) },
65
+ subject: message.subject,
66
+ html: message.html,
67
+ text: message.text,
68
+ ...message.headers === undefined ? {} : { headers: { ...message.headers } },
69
+ disableFileAccess: true,
70
+ disableUrlAccess: true
71
+ });
72
+ } catch (error) {
73
+ if (isRefusal(error)) {
74
+ throw new MailRefused("send: the SMTP server refused the message", {
75
+ cause: error
76
+ });
77
+ }
78
+ throw new MailFailure("send: the SMTP server could not take the message", {
79
+ cause: error
80
+ });
81
+ }
82
+ throwOnPartialRejection(info ?? {});
83
+ const messageId = typeof info?.messageId === "string" && info.messageId !== "" ? info.messageId : null;
84
+ return { messageId };
85
+ }
86
+ };
87
+ }
88
+ export {
89
+ createSmtpMailer
90
+ };
91
+
92
+ //# debugId=748DA33D57D71AF564756E2164756E21
93
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
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"
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",
9
+ "names": []
10
+ }
package/docs/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # @nxgt/mail-smtp — documentation
2
+
3
+ The [README](../README.md) shows that it works; these pages show how, one area
4
+ at a time, with an example for every option. The words they use — e-mail,
5
+ mailer, transport, hand-over, refusal, failure — are defined once, in the
6
+ [vocabulary](https://github.com/softistx/nxgt-mail/blob/develop/docs/vocabulary.md).
7
+
8
+ | Page | Read it when |
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 |
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
+ | [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
+ | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
14
+ | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -0,0 +1,142 @@
1
+ # Errors
2
+
3
+ `send` resolves only once the SMTP server has accepted the message for every
4
+ recipient. Otherwise
5
+ it rejects with one of the two classes of its `@nxgt/mail` peer — this package
6
+ defines no error class, so `error instanceof MailFailure` holds whichever
7
+ transport the application wires:
8
+
9
+ - **`MailRefused`** (`code: 'MAIL_REFUSED'`) — the message itself was
10
+ refused. Sending it again unchanged fails again.
11
+ - **`MailFailure`** (`code: 'MAIL_FAILED'`) — the server could not take it:
12
+ unreachable, busy, or the credentials refused. Nothing is known to have
13
+ been sent — after a timeout or a dropped connection the server may have
14
+ taken it all the same — and a later attempt may work.
15
+
16
+ One exception to "nothing sent" in either class: when the server refused
17
+ some recipients and accepted the others, `send` throws, and the accepted
18
+ recipients may already have the message. The message says so
19
+ ([below](#some-recipients-refused)); retrying it whole sends it to them again.
20
+
21
+ Nothing is retried. Whether and when to retry is yours to decide, where you
22
+ can see it.
23
+
24
+ ```ts
25
+ import { MailError, type MailErrorCode, type Mailer, type MailMessage } from '@nxgt/mail';
26
+
27
+ function statusOf(code: MailErrorCode): number {
28
+ switch (code) {
29
+ case 'MAIL_FAILED':
30
+ return 503;
31
+ case 'MAIL_REFUSED':
32
+ return 422;
33
+ }
34
+ }
35
+
36
+ export async function sendOrRespond(mailer: Mailer, message: MailMessage): Promise<Response> {
37
+ try {
38
+ await mailer.send(message);
39
+ return new Response(null, { status: 202 });
40
+ } catch (error) {
41
+ if (!(error instanceof MailError)) throw error;
42
+ return Response.json({ code: error.code }, { status: statusOf(error.code) });
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Which SMTP answer is which
48
+
49
+ nodemailer rejects with an error carrying a `code` (`ECONNECTION`, `ESOCKET`,
50
+ `ETIMEDOUT`, `EAUTH`, `EENVELOPE`, `EMESSAGE`…) and, when the server answered,
51
+ a `responseCode`. The transport reads both:
52
+
53
+ | What happened | nodemailer's error | Throws |
54
+ | --- | --- | --- |
55
+ | The server cannot be reached, the connection drops, a timeout | `ECONNECTION`, `ESOCKET`, `ETIMEDOUT` | `MailFailure` |
56
+ | The server closes the door (`421`), is busy or refuses for now (`4xx`) | any, with a `4xx` | `MailFailure` |
57
+ | Credentials refused (`535`), authentication required (`530`) | `EAUTH`, or `EENVELOPE` with `530`–`539` | `MailFailure` |
58
+ | The sender refused (`550`, `553` on `MAIL FROM`) | `EENVELOPE` with a `5xx` and `command: 'MAIL FROM'` | `MailFailure` |
59
+ | A recipient refused for good (`550`, `553`) | `EENVELOPE` with a `5xx` | `MailRefused` |
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` |
62
+ | Anything else | — | `MailFailure` |
63
+
64
+ Two `5xx` are failures: authentication, and a sender refused at
65
+ `MAIL FROM`. The next message would be refused the same way, whatever it
66
+ holds: it is the wiring — the credentials, the sender, the host — that is
67
+ wrong, not the message.
68
+
69
+ When every recipient is refused, nodemailer's error carries the code of the
70
+ last one only. The transport reads each of `rejectedErrors` instead: a `450`
71
+ on one and a `550` on another is a `MailFailure`, whichever came last.
72
+
73
+ ## Some recipients refused
74
+
75
+ nodemailer **resolves** when the server refuses some recipients and accepts
76
+ the others: the message went out to the accepted ones. The transport throws
77
+ all the same — a send that did not reach every recipient did not do what was
78
+ asked — with a message that counts the refusals:
79
+
80
+ | Every refusal a permanent `5xx` (not `530`–`539`) | Throws |
81
+ | --- | --- |
82
+ | yes | `MailRefused` — `send: the SMTP server refused <n> of <total> recipients, and may have delivered to the others` |
83
+ | no, or nodemailer gives no reason | `MailFailure` — `send: the SMTP server could not take <n> of <total> recipients, and may have delivered to the others` |
84
+
85
+ `cause` is nodemailer's error for the first refused recipient
86
+ (`code: 'EENVELOPE'`, its `responseCode`, and `recipient`). Retrying the
87
+ message as it was sends it again to the recipients who have it: send it to
88
+ the refused ones only, or not at all.
89
+
90
+ ## What `cause` holds
91
+
92
+ The error is nodemailer's, untouched: read `code`, `responseCode`, `command`
93
+ and `response` from it to log what the server said.
94
+
95
+ ```ts
96
+ import { MailError } from '@nxgt/mail';
97
+
98
+ try {
99
+ await mailer.send(message);
100
+ } catch (error) {
101
+ if (error instanceof MailError) {
102
+ const cause = error.cause as { code?: string; responseCode?: number };
103
+ logger.warn({ code: error.code, smtp: cause.code, reply: cause.responseCode }, error.message);
104
+ }
105
+ throw error;
106
+ }
107
+ ```
108
+
109
+ An error's `message` reports a shape, never a value: it never holds an
110
+ address, a subject, a password or the server's reply. `cause` is nodemailer's
111
+ error, and **its** message can quote the server, which can quote an address —
112
+ log `cause.code` and `cause.responseCode` rather than the whole error when
113
+ your logs must not hold one.
114
+
115
+ ## The messages
116
+
117
+ | `message` | Class | When |
118
+ | --- | --- | --- |
119
+ | `send: the SMTP server could not take the message` | `MailFailure` | Every failure in the table above |
120
+ | `send: the SMTP server refused the message` | `MailRefused` | A permanent `5xx` on every recipient or on the content |
121
+ | `send: the SMTP server refused <n> of <total> recipients, and may have delivered to the others` | `MailRefused` | Some recipients refused for good, the others accepted |
122
+ | `send: the SMTP server could not take <n> of <total> recipients, and may have delivered to the others` | `MailFailure` | Some recipients refused, one at least for now, the others accepted |
123
+ | `send: from is missing — give the message a from, or createSmtpMailer a default one` | `MailRefused` | A message without `from`, on a mailer without a default. The transporter is not called |
124
+ | `send: …` from `checkMessage` | `MailRefused` | A message no transport hands over — see [`@nxgt/mail`'s troubleshooting](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/troubleshooting.md#sending) |
125
+
126
+ ## Wiring — a `TypeError`
127
+
128
+ A bad option is a mistake in how the application was put together, thrown
129
+ when `createSmtpMailer` is called, never at the first send:
130
+
131
+ | `message` | When |
132
+ | --- | --- |
133
+ | `createSmtpMailer: options must be an object, as { transporter }` | `createSmtpMailer()` or `createSmtpMailer(transporter)` |
134
+ | `createSmtpMailer: transporter must be what nodemailer.createTransport(…) answers` | No `transporter`, or one without `sendMail` — nodemailer's options passed instead of the transporter |
135
+ | `createSmtpMailer: from must be an e-mail address, as noreply@example.com or { name, address }` | A default `from` that is not an address — `'Acme <noreply@acme.test>'` included |
136
+
137
+ ## See also
138
+
139
+ - [Troubleshooting](../troubleshooting.md) — each message, its cause and its
140
+ fix.
141
+ - [`@nxgt/mail` — sending](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/sending.md)
142
+ — the errors, from the caller's side.
@@ -0,0 +1,209 @@
1
+ # Setting up
2
+
3
+ `createSmtpMailer` wraps a nodemailer transporter that **you** create: every
4
+ SMTP option — host, port, TLS, credentials, a pool, DKIM, timeouts — is
5
+ nodemailer's, and this package passes none of its own. What it adds is the
6
+ `Mailer` contract of `@nxgt/mail`: the same refusals as every transport, the
7
+ two errors, and no retry.
8
+
9
+ ```ts
10
+ import nodemailer from 'nodemailer';
11
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
12
+
13
+ export const mailer = createSmtpMailer({
14
+ transporter: nodemailer.createTransport({
15
+ host: 'smtp.example.com',
16
+ port: 587, // STARTTLS; 465 with secure: true
17
+ auth: { user: 'acme', pass: process.env.SMTP_PASSWORD },
18
+ }),
19
+ from: { name: 'Acme', address: 'noreply@acme.test' },
20
+ });
21
+ ```
22
+
23
+ ## The signature
24
+
25
+ ```ts
26
+ function createSmtpMailer(options: SmtpMailerOptions): Mailer;
27
+
28
+ interface SmtpMailerOptions {
29
+ readonly transporter: SmtpTransporter;
30
+ readonly from?: Address;
31
+ }
32
+ ```
33
+
34
+ | Option | Type | Default | Effect |
35
+ | --- | --- | --- | --- |
36
+ | `transporter` | `SmtpTransporter` | required | Receives every message through `sendMail`. What `nodemailer.createTransport(…)` answers fits, with no cast |
37
+ | `from` | `Address` | none | The sender of a message that has no `from`. Without it, such a message is refused with `MailRefused` |
38
+
39
+ `SmtpTransporter` is the one method this package calls — so a test can pass a
40
+ hand-written object, and nodemailer is never imported:
41
+
42
+ ```ts
43
+ interface SmtpTransporter {
44
+ sendMail(mail: {
45
+ from: { name: string; address: string };
46
+ to: { name: string; address: string }[];
47
+ replyTo?: { name: string; address: string };
48
+ subject: string;
49
+ html: string;
50
+ text: string;
51
+ headers?: Record<string, string>;
52
+ disableFileAccess: boolean;
53
+ disableUrlAccess: boolean;
54
+ }): Promise<SmtpSentInfo>;
55
+ }
56
+
57
+ // What nodemailer resolves with. `rejected` and `rejectedErrors` are the
58
+ // recipients the server refused while it accepted others: `send` throws then.
59
+ interface SmtpSentInfo {
60
+ readonly messageId?: string;
61
+ readonly accepted?: readonly unknown[] | undefined;
62
+ readonly rejected?: readonly unknown[] | undefined;
63
+ readonly rejectedErrors?: readonly unknown[] | undefined;
64
+ }
65
+ ```
66
+
67
+ Options are checked when the mailer is created, and a mistake is a bare
68
+ `TypeError` — see [Errors — wiring](errors.md#wiring--a-typeerror).
69
+
70
+ ## The transporter
71
+
72
+ ### From options, or from a URL
73
+
74
+ ```ts
75
+ import nodemailer from 'nodemailer';
76
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
77
+
78
+ // Port 465: TLS from the first byte.
79
+ const implicitTls = createSmtpMailer({
80
+ transporter: nodemailer.createTransport({
81
+ host: 'smtp.example.com',
82
+ port: 465,
83
+ secure: true,
84
+ auth: { user: 'acme', pass: process.env.SMTP_PASSWORD },
85
+ }),
86
+ from: 'noreply@acme.test',
87
+ });
88
+
89
+ // A URL, as a hosting provider often gives it.
90
+ const fromUrl = createSmtpMailer({
91
+ transporter: nodemailer.createTransport(process.env.SMTP_URL ?? 'smtp://localhost:1025'),
92
+ from: 'noreply@acme.test',
93
+ });
94
+ ```
95
+
96
+ ### Timeouts
97
+
98
+ nodemailer waits up to two minutes for a connection and ten for a quiet
99
+ socket. A send awaited in a request handler should give up sooner:
100
+
101
+ ```ts
102
+ import nodemailer from 'nodemailer';
103
+
104
+ const transporter = nodemailer.createTransport({
105
+ host: 'smtp.example.com',
106
+ port: 587,
107
+ auth: { user: 'acme', pass: process.env.SMTP_PASSWORD },
108
+ connectionTimeout: 10_000,
109
+ greetingTimeout: 10_000,
110
+ socketTimeout: 20_000,
111
+ });
112
+ ```
113
+
114
+ A timeout ends in `MailFailure`, nodemailer's `ETIMEDOUT` or `ESOCKET` error
115
+ as its `cause`.
116
+
117
+ ### A pool
118
+
119
+ With `pool: true`, nodemailer keeps connections open and sends several messages
120
+ over each. Close it when the process stops, or the process waits for it:
121
+
122
+ ```ts
123
+ import nodemailer from 'nodemailer';
124
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
125
+
126
+ const transporter = nodemailer.createTransport({
127
+ pool: true,
128
+ maxConnections: 5,
129
+ host: 'smtp.example.com',
130
+ port: 587,
131
+ auth: { user: 'acme', pass: process.env.SMTP_PASSWORD },
132
+ });
133
+ export const mailer = createSmtpMailer({ transporter, from: 'noreply@acme.test' });
134
+
135
+ process.on('SIGTERM', () => transporter.close());
136
+ ```
137
+
138
+ ## The sender
139
+
140
+ A message's own `from` wins; the default is used when it has none; with
141
+ neither, the send is refused with `MailRefused` before the transporter is
142
+ called:
143
+
144
+ ```ts
145
+ import { createSmtpMailer, type SmtpTransporter } from '@nxgt/mail-smtp';
146
+
147
+ declare const transporter: SmtpTransporter;
148
+
149
+ const mailer = createSmtpMailer({ transporter, from: { name: 'Acme', address: 'noreply@acme.test' } });
150
+
151
+ await mailer.send({ to: 'ada@example.com', subject: 'Hi', html: '<p>Hi</p>', text: 'Hi' }); // from Acme
152
+ await mailer.send({
153
+ to: 'ada@example.com',
154
+ from: 'billing@acme.test', // this one wins
155
+ subject: 'Your invoice',
156
+ html: '<p>…</p>',
157
+ text: '…',
158
+ });
159
+ ```
160
+
161
+ A string is only an address: `'Acme <noreply@acme.test>'` is refused. Write
162
+ `{ name: 'Acme', address: 'noreply@acme.test' }`.
163
+
164
+ ## What a message becomes
165
+
166
+ | `MailMessage` | Handed to nodemailer as |
167
+ | --- | --- |
168
+ | `to` — one address or several | `to`, always a list |
169
+ | a string address | `{ name: '', address }`: nodemailer never parses a string, so the address it sends to is the one `checkMessage` checked |
170
+ | an `{ name, address }` | the same object: nodemailer quotes and encodes the name, so `Doe, John` or `Ada <mallory@example.test>` stays one recipient's name |
171
+ | `from`, `replyTo` | `from`, `replyTo` |
172
+ | `subject`, `html`, `text` | the same, as strings — the e-mail is `multipart/alternative` |
173
+ | `headers` | `headers`, copied |
174
+ | — | `disableFileAccess: true`, `disableUrlAccess: true`: a part is never read from a file or fetched from a URL |
175
+
176
+ Before any of it, `checkMessage` from `@nxgt/mail` refuses what no transport
177
+ hands over — no recipient, something that is not an address, a line break in
178
+ the subject or a header, a custom header that would set an address, the
179
+ subject or the MIME structure (`Bcc`, `To`, `Content-Type`…). Its messages are listed in
180
+ [`@nxgt/mail`'s troubleshooting](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/troubleshooting.md#sending).
181
+
182
+ `send` answers `{ messageId }`: nodemailer's `Message-ID` (`<…@acme.test>`), or
183
+ `null` when the transporter answers none — an absence, not a failure.
184
+
185
+ ## With the renderer
186
+
187
+ What `@nxgt/mail/renderer` answers spreads into the message:
188
+
189
+ ```ts
190
+ import { createMailRenderer } from '@nxgt/mail/renderer';
191
+ import nodemailer from 'nodemailer';
192
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
193
+
194
+ const mails = createMailRenderer({ dir: 'dist' });
195
+ const mailer = createSmtpMailer({
196
+ transporter: nodemailer.createTransport(process.env.SMTP_URL ?? 'smtp://localhost:1025'),
197
+ from: 'noreply@acme.test',
198
+ });
199
+
200
+ await mailer.send({
201
+ to: 'ada@example.com',
202
+ ...mails.render('verify-email', { name: 'Ada', link: 'https://app.example.com/verify?token=abc' }, { locale: 'fr' }),
203
+ });
204
+ ```
205
+
206
+ ## See also
207
+
208
+ - [Errors](errors.md) — what `send` throws, and when.
209
+ - [Testing](testing.md) — a local SMTP server, and the conformance suite.
@@ -0,0 +1,178 @@
1
+ # Testing
2
+
3
+ Two different things to test, with two different tools:
4
+
5
+ - **An application that sends e-mail.** Do not start an SMTP server: wire
6
+ `createMemoryMailer()` from `@nxgt/mail` in the tests, read its outbox, and
7
+ make a send fail with `failNext()`. See
8
+ [`@nxgt/mail` — testing](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/testing.md).
9
+ - **This transport, against a real SMTP server.** A local
10
+ [`smtp-server`](https://nodemailer.com/extras/smtp-server/) receives, and
11
+ [`mailparser`](https://nodemailer.com/extras/mailparser/) reads back what
12
+ arrived. That is how this package passes `@nxgt/mail/conformance`, and the
13
+ rest of this page shows it.
14
+
15
+ ```sh
16
+ bun add -d smtp-server mailparser @types/smtp-server @types/mailparser
17
+ ```
18
+
19
+ ## A local SMTP server
20
+
21
+ It keeps what it receives, and fails on demand **the way an SMTP server
22
+ fails** — a `421` for an outage, a `554` for a refusal — so the suite proves
23
+ the transport's reading of nodemailer's errors, not a wrapper's:
24
+
25
+ ```ts
26
+ // smtp-server.ts
27
+ import type { AddressInfo } from 'node:net';
28
+ import type { DeliveredMail } from '@nxgt/mail/conformance';
29
+ import { simpleParser } from 'mailparser';
30
+ import { SMTPServer } from 'smtp-server';
31
+
32
+ type Fault = 'outage' | 'refusal';
33
+
34
+ export async function startSmtpServer() {
35
+ const delivered: DeliveredMail[] = [];
36
+ const faults: Fault[] = [];
37
+ let attempts = 0;
38
+ const reply = (message: string, responseCode: number) => Object.assign(new Error(message), { responseCode });
39
+
40
+ const server = new SMTPServer({
41
+ authOptional: true,
42
+ disabledCommands: ['STARTTLS'],
43
+ logger: false,
44
+ onMailFrom(_address, _session, callback) {
45
+ attempts += 1; // one hand-over
46
+ if (faults[0] === 'outage') {
47
+ faults.shift();
48
+ return callback(reply('Service not available', 421));
49
+ }
50
+ callback();
51
+ },
52
+ onData(stream, session, callback) {
53
+ const refuse = faults[0] === 'refusal';
54
+ if (refuse) faults.shift();
55
+ simpleParser(stream).then(
56
+ (parsed) => {
57
+ if (refuse) return callback(reply('Message rejected', 554));
58
+ delivered.push({
59
+ to: session.envelope.rcptTo.map((rcpt) => rcpt.address), // what the envelope named
60
+ subject: parsed.subject ?? '',
61
+ html: typeof parsed.html === 'string' ? parsed.html : '',
62
+ text: parsed.text ?? '',
63
+ });
64
+ callback();
65
+ },
66
+ (error: Error) => callback(error),
67
+ );
68
+ },
69
+ });
70
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
71
+ const { port } = server.server.address() as AddressInfo;
72
+ return {
73
+ port,
74
+ delivered,
75
+ faults,
76
+ attempts: () => attempts,
77
+ close: () => new Promise<void>((resolve) => server.close(() => resolve())),
78
+ };
79
+ }
80
+ ```
81
+
82
+ `to` is read from the **envelope** (`RCPT TO`), not from the `To:` header: it
83
+ is who the server was asked to deliver to, and what proves a name did not
84
+ smuggle a second recipient in.
85
+
86
+ ## The conformance suite
87
+
88
+ One fresh server and transporter per case, closed after it:
89
+
90
+ ```ts
91
+ import { describe, it } from 'bun:test';
92
+ import { describeMailer } from '@nxgt/mail/conformance';
93
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
94
+ import nodemailer from 'nodemailer';
95
+ import { startSmtpServer } from './smtp-server';
96
+
97
+ describeMailer({
98
+ name: 'createSmtpMailer',
99
+ runner: { describe, it }, // bun test puts neither on globalThis
100
+ harness: {
101
+ async open() {
102
+ const server = await startSmtpServer();
103
+ const transporter = nodemailer.createTransport({ host: '127.0.0.1', port: server.port, secure: false, ignoreTLS: true });
104
+ return {
105
+ mailer: createSmtpMailer({ transporter }),
106
+ delivered: async () => [...server.delivered],
107
+ faults: {
108
+ failNext: async (kind) => {
109
+ server.faults.push(kind);
110
+ },
111
+ attempts: async () => server.attempts(),
112
+ },
113
+ async close() {
114
+ transporter.close();
115
+ await server.close();
116
+ },
117
+ };
118
+ },
119
+ },
120
+ });
121
+ ```
122
+
123
+ All eleven cases pass: a send answers `SentMail`, the message arrives byte for
124
+ 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
127
+ failure cases — an outage is a `MailFailure` with its `cause` and one attempt,
128
+ a refusal a `MailRefused`, and the next send goes through.
129
+
130
+ ## Beyond the suite
131
+
132
+ The package's own specs
133
+ ([`src/index.spec.ts`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-smtp/src/index.spec.ts))
134
+ add what the suite does not ask of every transport:
135
+
136
+ - a server that is not listening ends in `MailFailure`, `ESOCKET` on `cause`;
137
+ - a recipient refused for good (`550`) is a `MailRefused`, one refused for now
138
+ (`450`) a `MailFailure`;
139
+ - one recipient refused while another is accepted still throws — the
140
+ accepted one has the message — and every recipient refused is a
141
+ `MailRefused` only when every refusal is permanent, whatever their order;
142
+ - credentials refused (`535`, `EAUTH`), authentication required (`530`) and
143
+ a sender refused at `MAIL FROM` (`550`) are a `MailFailure`;
144
+ - a string address is handed to nodemailer as `{ name: '', address }`, so it
145
+ never parses one;
146
+ - no error message holds the password or a recipient's address;
147
+ - the default `from`, `replyTo` and `headers` reach the server, and the id is
148
+ nodemailer's;
149
+ - nodemailer is told never to read a file or a URL.
150
+
151
+ A transporter can also be a plain object, when a test only needs to see what
152
+ was handed over:
153
+
154
+ ```ts
155
+ import { expect, test } from 'bun:test';
156
+ import { sampleMessage } from '@nxgt/mail/conformance';
157
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
158
+
159
+ test('hands the parts over as strings', async () => {
160
+ const handed: unknown[] = [];
161
+ const mailer = createSmtpMailer({
162
+ transporter: {
163
+ async sendMail(mail) {
164
+ handed.push(mail);
165
+ return { messageId: '<1@test>' };
166
+ },
167
+ },
168
+ });
169
+ expect(await mailer.send(sampleMessage)).toEqual({ messageId: '<1@test>' });
170
+ expect(handed[0]).toMatchObject({ subject: sampleMessage.subject, disableUrlAccess: true });
171
+ });
172
+ ```
173
+
174
+ ## See also
175
+
176
+ - [`@nxgt/mail` — writing a transport](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/transports.md)
177
+ — the contract, the harness and every case.
178
+ - [Errors](errors.md) — the mapping these tests pin down.
@@ -0,0 +1,50 @@
1
+ # Roadmap
2
+
3
+ Where `@nxgt/mail-smtp` is heading. A direction, not a commitment: there are
4
+ no dates here, and the version something shipped in is the only number.
5
+
6
+ ## Now
7
+
8
+ Nothing between releases.
9
+
10
+ ## Next
11
+
12
+ Nothing yet.
13
+
14
+ ## Later
15
+
16
+ Nothing planned yet. Say what you need in an issue.
17
+
18
+ ## Not planned
19
+
20
+ - **SMTP options of our own** — host, port, TLS, pooling, DKIM and timeouts
21
+ are nodemailer's; wrapping them would lag behind it. You create the
22
+ transporter.
23
+ - **Silent retries** — the transport tries once. A retry is the caller's
24
+ decision, made where it can be seen; a hidden one can send the same e-mail
25
+ twice.
26
+ - **A transport's own error class** — it throws `@nxgt/mail`'s `MailFailure`
27
+ 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.
30
+ - **Bundling nodemailer** — it is a peer: one copy, the version you choose.
31
+
32
+ ## Shipped
33
+
34
+ The last ten, newest first, each with the version it came in. Everything
35
+ before is in the [CHANGELOG](../CHANGELOG.md).
36
+
37
+ - **An SMTP transport on your nodemailer, v0.1.0** — `createSmtpMailer({ transporter,
38
+ from })`: every SMTP option is nodemailer's, set where you create the
39
+ transporter. Each message is checked as every transport checks it, a name is
40
+ quoted by nodemailer so it names one recipient, and nothing is read from a
41
+ file or a URL.
42
+ - **Errors you can act on, v0.1.0** — a permanent `5xx` on the recipients or the
43
+ content (`552` for a message too large) is a `MailRefused`; an
44
+ unreachable server, a timeout, a `4xx`, refused credentials or a refused
45
+ sender is a `MailFailure`, nodemailer's error on `cause`. Some recipients
46
+ refused while others were accepted throws too, and says the others may
47
+ have the message. The classes are `@nxgt/mail`'s, so `instanceof` holds.
48
+ - **Proven against a real server, v0.1.0** — the `@nxgt/mail/conformance` suite
49
+ passes against a local `smtp-server`, what arrived read back with
50
+ `mailparser`.
@@ -0,0 +1,223 @@
1
+ # Troubleshooting `@nxgt/mail-smtp`
2
+
3
+ Each entry is headed by the message you see. Search this page for the words of
4
+ your message.
5
+
6
+ How the messages are shaped:
7
+
8
+ - **A message names where the problem is, never the value.** No address, no
9
+ subject, no password, no server reply: what the server said is on the
10
+ error's `cause`, nodemailer's error untouched.
11
+ - **Every message starts with the call you wrote**: `send: …` or
12
+ `createSmtpMailer: …`.
13
+ - **A `TypeError` is a wiring mistake**, thrown by `createSmtpMailer` when the
14
+ application starts. Fix the code; no handler should answer one.
15
+ - **A `MailError` is a refusal at call time**: a `MailFailure`
16
+ (`MAIL_FAILED`) or a `MailRefused` (`MAIL_REFUSED`), the classes of the
17
+ `@nxgt/mail` peer.
18
+
19
+ A `send: …` message not on this page comes from `checkMessage` in
20
+ `@nxgt/mail` — a message no transport hands over. See
21
+ [its troubleshooting](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/troubleshooting.md#sending).
22
+
23
+ ## Index
24
+
25
+ **Sending**
26
+ - [`send: the SMTP server could not take the message`](#send-the-smtp-server-could-not-take-the-message)
27
+ - [`send: the SMTP server refused the message`](#send-the-smtp-server-refused-the-message)
28
+ - [`send: the SMTP server refused <n> of <total> recipients, and may have delivered to the others`](#send-the-smtp-server-refused-n-of-total-recipients-and-may-have-delivered-to-the-others)
29
+ - [`send: the SMTP server could not take <n> of <total> recipients, and may have delivered to the others`](#send-the-smtp-server-could-not-take-n-of-total-recipients-and-may-have-delivered-to-the-others)
30
+ - [`send: from is missing — give the message a from, or createSmtpMailer a default one`](#send-from-is-missing--give-the-message-a-from-or-createsmtpmailer-a-default-one)
31
+
32
+ **Wiring**
33
+ - [`createSmtpMailer: options must be an object, as { transporter }`](#createsmtpmailer-options-must-be-an-object-as--transporter-)
34
+ - [`createSmtpMailer: transporter must be what nodemailer.createTransport(…) answers`](#createsmtpmailer-transporter-must-be-what-nodemailercreatetransport-answers)
35
+ - [`createSmtpMailer: from must be an e-mail address, as noreply@example.com or { name, address }`](#createsmtpmailer-from-must-be-an-e-mail-address-as-noreplyexamplecom-or--name-address-)
36
+
37
+ **Install and types**
38
+ - [`error instanceof MailFailure` is `false`](#error-instanceof-mailfailure-is-false)
39
+ - [`TS2322: Type 'string | null' is not assignable to type 'string'.`](#ts2322-type-string--null-is-not-assignable-to-type-string)
40
+
41
+ ## Sending
42
+
43
+ ### `send: the SMTP server could not take the message`
44
+
45
+ A `MailFailure`, code `MAIL_FAILED`. **Nothing is known to have been sent**:
46
+ after a timeout or a connection dropped once the message was on its way, the
47
+ server may have taken it all the same.
48
+
49
+ **When:** nodemailer could not hand the message over — the server cannot be
50
+ reached, the connection dropped or timed out, the server answered a `4xx`
51
+ (busy, try later, `421` closing), refused the credentials (`535`, `530`) or
52
+ the sender (`550` on `MAIL FROM`: `cause.command` is `'MAIL FROM'`), or
53
+ refused every recipient with at least one refusal for now (`450`).
54
+
55
+ **Why:** none of these is about the message: the same message may go through
56
+ later, or once the credentials or the sender are fixed.
57
+
58
+ **Fix:** read `cause` — nodemailer's error:
59
+
60
+ ```ts
61
+ import { MailFailure } from '@nxgt/mail';
62
+
63
+ try {
64
+ await mailer.send(message);
65
+ } catch (error) {
66
+ if (error instanceof MailFailure) {
67
+ const { code, responseCode } = error.cause as { code?: string; responseCode?: number };
68
+ // ECONNECTION / ESOCKET: host or port wrong, or the server is down
69
+ // ETIMEDOUT: the server is slow, or a firewall drops the connection
70
+ // EAUTH, 535: user or password wrong; 530: the server wants credentials
71
+ // 5xx with command 'MAIL FROM': the server refuses this sender — check from
72
+ // 4xx: the server is busy or rate-limiting — try later
73
+ console.warn(code, responseCode);
74
+ }
75
+ throw error;
76
+ }
77
+ ```
78
+
79
+ A retry is yours to decide — from a queue, with a delay. The transport never
80
+ retries in secret.
81
+
82
+ ### `send: the SMTP server refused the message`
83
+
84
+ A `MailRefused`, code `MAIL_REFUSED`.
85
+
86
+ **When:** the server answered a permanent `5xx` to every recipient (`550` no
87
+ such mailbox, `553` address not allowed) or to the content (`552` too large,
88
+ `554` rejected — as spam, for example).
89
+
90
+ **Why:** the server will refuse the same message again; retrying it
91
+ unchanged is pointless.
92
+
93
+ **Fix:** read `cause.responseCode` and `cause.response` to see which — with
94
+ every recipient refused, `cause.rejectedErrors` holds one error per
95
+ recipient; correct the address or the content. A recipient that does not
96
+ exist is usually worth telling the user about.
97
+
98
+ ### `send: the SMTP server refused <n> of <total> recipients, and may have delivered to the others`
99
+
100
+ A `MailRefused`, code `MAIL_REFUSED`. **The accepted recipients may already
101
+ have the message.**
102
+
103
+ **When:** a message to several recipients: the server refused `<n>` of them
104
+ for good (`550`, `553`) and accepted the others. nodemailer resolves then;
105
+ the transport throws, since the send did not reach everyone it named.
106
+
107
+ **Why:** a refused recipient is refused again; the accepted ones were handed
108
+ the message.
109
+
110
+ **Fix:** do not retry the message whole — it would reach the accepted
111
+ recipients twice. `cause` is nodemailer's error for the first refused
112
+ recipient: its `responseCode`, and `recipient`.
113
+
114
+ ```ts
115
+ import { MailRefused } from '@nxgt/mail';
116
+
117
+ declare function markUndeliverable(recipient: string | undefined, responseCode: number | undefined): Promise<void>;
118
+
119
+ try {
120
+ await mailer.send(message);
121
+ } catch (error) {
122
+ if (error instanceof MailRefused && error.message.includes('may have delivered to the others')) {
123
+ const { recipient, responseCode } = error.cause as { recipient?: string; responseCode?: number };
124
+ // Mark `recipient` as undeliverable, and do not send again to the others.
125
+ await markUndeliverable(recipient, responseCode);
126
+ }
127
+ throw error;
128
+ }
129
+ ```
130
+
131
+ Sending to one recipient per `send` makes every result all or nothing.
132
+
133
+ ### `send: the SMTP server could not take <n> of <total> recipients, and may have delivered to the others`
134
+
135
+ A `MailFailure`, code `MAIL_FAILED`. **The accepted recipients may already
136
+ have the message.**
137
+
138
+ **When:** a message to several recipients: the server accepted some, and
139
+ refused `<n>` with one refusal at least for now (a `4xx`, `450` mailbox
140
+ busy) or for a reason that is not the message (`530`–`539`), or nodemailer
141
+ reported the refusals without a reason.
142
+
143
+ **Why:** the refused recipients may be reachable later; the accepted ones
144
+ were handed the message.
145
+
146
+ **Fix:** retry for the refused recipients only, later — never the message
147
+ whole. `cause` is nodemailer's error for the first refused recipient.
148
+ Sending to one recipient per `send` makes every result all or nothing.
149
+
150
+ ### `send: from is missing — give the message a from, or createSmtpMailer a default one`
151
+
152
+ A `MailRefused`, thrown before the transporter is called.
153
+
154
+ **When:** the message has no `from`, and the mailer was created without one.
155
+
156
+ **Fix:** give the mailer a default sender, or the message its own:
157
+
158
+ ```ts
159
+ import nodemailer from 'nodemailer';
160
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
161
+
162
+ const mailer = createSmtpMailer({
163
+ transporter: nodemailer.createTransport(process.env.SMTP_URL ?? 'smtp://localhost:1025'),
164
+ from: { name: 'Acme', address: 'noreply@acme.test' },
165
+ });
166
+ ```
167
+
168
+ ## Wiring
169
+
170
+ ### `createSmtpMailer: options must be an object, as { transporter }`
171
+
172
+ A `TypeError`. `createSmtpMailer` was called with nothing, or with the
173
+ transporter itself.
174
+
175
+ ```ts
176
+ createSmtpMailer(transporter); // ✗
177
+ createSmtpMailer({ transporter }); // ✓
178
+ ```
179
+
180
+ ### `createSmtpMailer: transporter must be what nodemailer.createTransport(…) answers`
181
+
182
+ A `TypeError`. `transporter` is missing, or has no `sendMail` — nodemailer's
183
+ options were passed where its transporter belongs.
184
+
185
+ ```ts
186
+ import nodemailer from 'nodemailer';
187
+ import { createSmtpMailer } from '@nxgt/mail-smtp';
188
+
189
+ // ✗ createSmtpMailer({ transporter: { host: 'smtp.example.com', port: 587 } })
190
+ createSmtpMailer({ transporter: nodemailer.createTransport({ host: 'smtp.example.com', port: 587 }) }); // ✓
191
+ ```
192
+
193
+ ### `createSmtpMailer: from must be an e-mail address, as noreply@example.com or { name, address }`
194
+
195
+ A `TypeError`. The default `from` is not an address. Most often, a name
196
+ written inside the string:
197
+
198
+ ```ts
199
+ createSmtpMailer({ transporter, from: 'Acme <noreply@acme.test>' }); // ✗
200
+ createSmtpMailer({ transporter, from: { name: 'Acme', address: 'noreply@acme.test' } }); // ✓
201
+ ```
202
+
203
+ A string is only an address: the transport never parses one, so a name can
204
+ never smuggle a second address into a header.
205
+
206
+ ## Install and types
207
+
208
+ ### `error instanceof MailFailure` is `false`
209
+
210
+ Two copies of `@nxgt/mail` are installed, and the transport throws the other
211
+ one's class. `@nxgt/mail` is a **peer** of this package: list it in your own
212
+ `package.json`, in a range this package accepts, and install again. `bun pm ls
213
+ @nxgt/mail` (or `npm ls @nxgt/mail`) should show one version.
214
+
215
+ ### `TS2322: Type 'string | null' is not assignable to type 'string'.`
216
+
217
+ `messageId` is `string | null`: nodemailer may give no id, and an absence is
218
+ `null`. Decide what an absent id means where you read it:
219
+
220
+ ```ts
221
+ const { messageId } = await mailer.send(message);
222
+ const reference = messageId ?? 'none';
223
+ ```
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@nxgt/mail-smtp",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "docs",
12
+ "README.md",
13
+ "package.json",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "keywords": [
25
+ "email",
26
+ "transactional",
27
+ "mailer",
28
+ "smtp",
29
+ "nodemailer",
30
+ "typescript"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/softistx/nxgt-mail.git",
35
+ "directory": "packages/mail-smtp"
36
+ },
37
+ "publishConfig": {
38
+ "registry": "https://registry.npmjs.org",
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "build": "bun run ../../build.ts",
43
+ "test": "bun test src",
44
+ "typecheck": "tsc --noEmit"
45
+ },
46
+ "devDependencies": {
47
+ "@nxgt/mail": "0.1.0",
48
+ "@types/bun": "^1.4.2",
49
+ "@types/mailparser": "^3.4.6",
50
+ "@types/nodemailer": "^8.0.2",
51
+ "@types/smtp-server": "^3.5.13",
52
+ "mailparser": "^3.9.28",
53
+ "nodemailer": "^10.0.10",
54
+ "smtp-server": "^3.19.13"
55
+ },
56
+ "peerDependencies": {
57
+ "@nxgt/mail": "^0.1.0",
58
+ "nodemailer": ">=7.0.0 <11",
59
+ "typescript": "^6.0.3"
60
+ }
61
+ }