@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 +21 -0
- package/README.md +190 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +10 -0
- package/docs/README.md +14 -0
- package/docs/guide/errors.md +142 -0
- package/docs/guide/setup.md +209 -0
- package/docs/guide/testing.md +178 -0
- package/docs/roadmap.md +50 -0
- package/docs/troubleshooting.md +223 -0
- package/package.json +61 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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, `&` 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.
|
package/docs/roadmap.md
ADDED
|
@@ -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
|
+
}
|