@nxgt/mail-smtp 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/docs/README.md +1 -1
- package/docs/guide/errors.md +3 -1
- package/docs/guide/setup.md +48 -1
- package/docs/guide/testing.md +2 -0
- package/docs/roadmap.md +11 -6
- package/docs/troubleshooting.md +5 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -37,8 +37,8 @@ bun add @nxgt/mail-smtp @nxgt/mail nodemailer
|
|
|
37
37
|
|
|
38
38
|
Peers, all required:
|
|
39
39
|
|
|
40
|
-
- `@nxgt/mail` — the port, the errors and the checks: `^0.
|
|
41
|
-
with attachments
|
|
40
|
+
- `@nxgt/mail` — the port, the errors and the checks: `^0.3`, the version
|
|
41
|
+
with attachments and `idempotencyKey`. One copy in your tree, so `error instanceof MailFailure`
|
|
42
42
|
holds.
|
|
43
43
|
- `nodemailer` (`>=7 <11`; tested with 10). This package never imports it:
|
|
44
44
|
you create the transporter, with every SMTP option nodemailer has.
|
|
@@ -76,7 +76,7 @@ await mailer.send({
|
|
|
76
76
|
to: [{ name: 'Doe, John', address: 'john@example.com' }],
|
|
77
77
|
from: 'billing@acme.test', // required here: this mailer has no default
|
|
78
78
|
replyTo: 'support@acme.test',
|
|
79
|
-
headers: { '
|
|
79
|
+
headers: { 'X-Entity-Ref-ID': 'invoice-42' },
|
|
80
80
|
subject: 'Your invoice',
|
|
81
81
|
html: '<p>…</p>',
|
|
82
82
|
text: '…',
|
|
@@ -89,8 +89,15 @@ await mailer.send({
|
|
|
89
89
|
- A name is handed to nodemailer as `{ name, address }`: nodemailer quotes and
|
|
90
90
|
encodes it, so `Doe, John` names one recipient.
|
|
91
91
|
- `messageId` is nodemailer's id (`<…@host>`), or `null` when it gives none.
|
|
92
|
+
- For marketing mail, build `List-Unsubscribe` and `List-Unsubscribe-Post`
|
|
93
|
+
with `listUnsubscribe` from `@nxgt/mail` rather than by hand; your relay, or
|
|
94
|
+
nodemailer's `dkim` option, must DKIM-sign them — see
|
|
95
|
+
[one-click unsubscribe](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/sending.md#one-click-unsubscribe).
|
|
92
96
|
- The parts are strings and the attachments bytes: nodemailer is told never
|
|
93
97
|
to read a file or a URL (`disableFileAccess`, `disableUrlAccess`).
|
|
98
|
+
- `idempotencyKey` is ignored: SMTP has no such mechanism, so a message sent
|
|
99
|
+
twice is delivered twice. See
|
|
100
|
+
[Setting up — the idempotency key](docs/guide/setup.md#the-idempotency-key).
|
|
94
101
|
|
|
95
102
|
### Attachments
|
|
96
103
|
|
|
@@ -170,6 +177,11 @@ A timeout ends in `MailFailure`.
|
|
|
170
177
|
`539`) and a sender refused at `MAIL FROM`: the next message would be refused
|
|
171
178
|
the same way, so it is a failure of the wiring, not of the message.
|
|
172
179
|
|
|
180
|
+
**A retry after a timeout can deliver twice.** SMTP cannot deduplicate, and
|
|
181
|
+
the transport ignores `idempotencyKey`: after a `MailFailure` from a
|
|
182
|
+
timeout, the server may already have the message. Retry only what you can
|
|
183
|
+
afford to send twice.
|
|
184
|
+
|
|
173
185
|
**Some recipients refused still throws, after the others got it.** The
|
|
174
186
|
server may accept one recipient and refuse another; the message then went out
|
|
175
187
|
to the accepted one. Retrying it whole sends it to them twice — send to one
|
package/docs/README.md
CHANGED
|
@@ -7,7 +7,7 @@ mailer, transport, hand-over, refusal, failure — are defined once, in the
|
|
|
7
7
|
|
|
8
8
|
| Page | Read it when |
|
|
9
9
|
| --- | --- |
|
|
10
|
-
| [Setting up](guide/setup.md) | You are wiring `createSmtpMailer` to a nodemailer transporter: host, port and TLS, credentials, a pool, timeouts, the default sender,
|
|
10
|
+
| [Setting up](guide/setup.md) | You are wiring `createSmtpMailer` to a nodemailer transporter: host, port and TLS, credentials, a pool, timeouts, the default sender, what a message becomes on the wire, and why `idempotencyKey` is ignored (with Resend's SMTP relay header) |
|
|
11
11
|
| [Errors](guide/errors.md) | You are handling what `send` throws: which SMTP answers are a `MailRefused`, which a `MailFailure`, what is on `cause`, and every `TypeError` at wiring |
|
|
12
12
|
| [Testing](guide/testing.md) | You are testing the transport against a real SMTP server — `smtp-server`, `mailparser`, `describeMailer` — or an application that uses it |
|
|
13
13
|
| [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
|
package/docs/guide/errors.md
CHANGED
|
@@ -19,7 +19,9 @@ recipients may already have the message. The message says so
|
|
|
19
19
|
([below](#some-recipients-refused)); retrying it whole sends it to them again.
|
|
20
20
|
|
|
21
21
|
Nothing is retried. Whether and when to retry is yours to decide, where you
|
|
22
|
-
can see it.
|
|
22
|
+
can see it. SMTP cannot deduplicate — the transport ignores
|
|
23
|
+
`idempotencyKey` — so a retry after a timeout may deliver the e-mail twice
|
|
24
|
+
([Setting up — the idempotency key](setup.md#the-idempotency-key)).
|
|
23
25
|
|
|
24
26
|
```ts
|
|
25
27
|
import { MailError, type MailErrorCode, type Mailer, type MailMessage } from '@nxgt/mail';
|
package/docs/guide/setup.md
CHANGED
|
@@ -171,7 +171,8 @@ A string is only an address: `'Acme <noreply@acme.test>'` is refused. Write
|
|
|
171
171
|
| 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 |
|
|
172
172
|
| `from`, `replyTo` | `from`, `replyTo` |
|
|
173
173
|
| `subject`, `html`, `text` | the same, as strings — the e-mail is `multipart/alternative` |
|
|
174
|
-
| `headers` | `headers`, copied |
|
|
174
|
+
| `headers` | `headers`, copied — `List-Unsubscribe` from [`listUnsubscribe`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/sending.md#one-click-unsubscribe) included, which your relay or nodemailer's `dkim` option must DKIM-sign |
|
|
175
|
+
| `idempotencyKey` | nothing: ignored — see [below](#the-idempotency-key) |
|
|
175
176
|
| `attachments`, each `{ filename, content, contentType }` | `attachments`, each `{ filename, content: Buffer, contentType }` — the bytes copied into a `Buffer`, never a `path` or an `href`; the e-mail is then `multipart/mixed`. Left out when the list is empty |
|
|
176
177
|
| — | `disableFileAccess: true`, `disableUrlAccess: true`: a part or an attachment is never read from a file or fetched from a URL |
|
|
177
178
|
|
|
@@ -184,6 +185,52 @@ subject or the MIME structure (`Bcc`, `To`, `Content-Type`…). Its messages are
|
|
|
184
185
|
`send` answers `{ messageId }`: nodemailer's `Message-ID` (`<…@acme.test>`), or
|
|
185
186
|
`null` when the transporter answers none — an absence, not a failure.
|
|
186
187
|
|
|
188
|
+
## The idempotency key
|
|
189
|
+
|
|
190
|
+
SMTP has no idempotency: a server takes every message it is handed, and
|
|
191
|
+
cannot tell a retry from a new e-mail. The transport therefore **ignores**
|
|
192
|
+
`idempotencyKey` — it neither refuses the message nor writes the key into it
|
|
193
|
+
— and a message sent twice is delivered twice:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
const once = {
|
|
197
|
+
to: 'ada@example.com',
|
|
198
|
+
subject: 'Your receipt',
|
|
199
|
+
html: '<p>Thank you for your order.</p>',
|
|
200
|
+
text: 'Thank you for your order.',
|
|
201
|
+
idempotencyKey: 'order-42/receipt', // still checked by checkMessage; not sent
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
await mailer.send(once);
|
|
205
|
+
await mailer.send(once); // a second e-mail
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Setting the key is still worth it when the same code may run on a
|
|
209
|
+
transport that deduplicates, as `@nxgt/mail-resend`. Over SMTP, a retry
|
|
210
|
+
after a `MailFailure` from a timeout or a dropped connection may deliver
|
|
211
|
+
twice: the server may have taken the message before the connection ended.
|
|
212
|
+
|
|
213
|
+
**Relaying through Resend's SMTP server** (`smtp.resend.com`)? Resend reads
|
|
214
|
+
its own `Resend-Idempotency-Key` header there. The transport does not set it
|
|
215
|
+
from `idempotencyKey`; set it yourself among the headers, with the same
|
|
216
|
+
value:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const key = 'order-42/receipt';
|
|
220
|
+
|
|
221
|
+
await mailer.send({
|
|
222
|
+
to: 'ada@example.com',
|
|
223
|
+
subject: 'Your receipt',
|
|
224
|
+
html: '<p>Thank you for your order.</p>',
|
|
225
|
+
text: 'Thank you for your order.',
|
|
226
|
+
idempotencyKey: key,
|
|
227
|
+
headers: { 'Resend-Idempotency-Key': key }, // read by Resend's relay; any other server passes it on as a header
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
On any other server, that header travels with the e-mail to the recipient:
|
|
232
|
+
set it only when the relay is Resend's.
|
|
233
|
+
|
|
187
234
|
## Attachments
|
|
188
235
|
|
|
189
236
|
```ts
|
package/docs/guide/testing.md
CHANGED
|
@@ -155,6 +155,8 @@ add what the suite does not ask of every transport:
|
|
|
155
155
|
- the default `from`, `replyTo` and `headers` reach the server, and the id is
|
|
156
156
|
nodemailer's;
|
|
157
157
|
- nodemailer is told never to read a file or a URL;
|
|
158
|
+
- `idempotencyKey` is ignored: the same message sent twice is handed over
|
|
159
|
+
twice, and the key appears nowhere in what nodemailer receives;
|
|
158
160
|
- attachments are handed over as `{ filename, content, contentType }` with a
|
|
159
161
|
`Buffer` copied from the bytes — a change to the caller's array during the
|
|
160
162
|
send reaches no one — and an empty list sends none;
|
package/docs/roadmap.md
CHANGED
|
@@ -5,12 +5,7 @@ no dates here, and the version something shipped in is the only number.
|
|
|
5
5
|
|
|
6
6
|
## Now
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
as bytes, a `Buffer` copied from each `Uint8Array`, with their file name
|
|
10
|
-
(encoded by nodemailer when it is not ASCII) and their type. Never a `path`
|
|
11
|
-
or an `href`: `disableFileAccess` and `disableUrlAccess` stay on. A message
|
|
12
|
-
over the server's size limit (`552`) is a `MailRefused`. Needs
|
|
13
|
-
`@nxgt/mail` 0.2. Built, not yet published.
|
|
8
|
+
Nothing yet.
|
|
14
9
|
|
|
15
10
|
## Next
|
|
16
11
|
|
|
@@ -40,6 +35,16 @@ Nothing planned yet. Say what you need in an issue.
|
|
|
40
35
|
The last ten, newest first, each with the version it came in. Everything
|
|
41
36
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
42
37
|
|
|
38
|
+
- **Messages with an idempotency key, v0.3.0** — a message that carries an
|
|
39
|
+
`idempotencyKey` is accepted, and the key is ignored: SMTP has no
|
|
40
|
+
idempotency, so a message sent twice is delivered twice. Needs `@nxgt/mail`
|
|
41
|
+
0.3.
|
|
42
|
+
- **Attachments, v0.2.0** — the `attachments` of a message are handed to nodemailer
|
|
43
|
+
as bytes, a `Buffer` copied from each `Uint8Array`, with their file name
|
|
44
|
+
(encoded by nodemailer when it is not ASCII) and their type. Never a `path`
|
|
45
|
+
or an `href`: `disableFileAccess` and `disableUrlAccess` stay on. A message
|
|
46
|
+
over the server's size limit (`552`) is a `MailRefused`. Needs
|
|
47
|
+
`@nxgt/mail` 0.2.
|
|
43
48
|
- **An SMTP transport on your nodemailer, v0.1.0** — `createSmtpMailer({ transporter,
|
|
44
49
|
from })`: every SMTP option is nodemailer's, set where you create the
|
|
45
50
|
transporter. Each message is checked as every transport checks it, a name is
|
package/docs/troubleshooting.md
CHANGED
|
@@ -79,6 +79,11 @@ try {
|
|
|
79
79
|
A retry is yours to decide — from a queue, with a delay. The transport never
|
|
80
80
|
retries in secret.
|
|
81
81
|
|
|
82
|
+
An `idempotencyKey` does not make that retry safe here: SMTP has no
|
|
83
|
+
idempotency, and this transport ignores the key. If the first attempt went
|
|
84
|
+
through after all, the retry delivers a second copy — weigh that before
|
|
85
|
+
retrying after a timeout.
|
|
86
|
+
|
|
82
87
|
### `send: the SMTP server refused the message`
|
|
83
88
|
|
|
84
89
|
A `MailRefused`, code `MAIL_REFUSED`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/mail-smtp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "An SMTP transport for @nxgt/mail, on the nodemailer you install: it throws the MailFailure and MailRefused of its @nxgt/mail peer, and passes the conformance suite.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"typecheck": "tsc --noEmit"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@nxgt/mail": "0.
|
|
47
|
+
"@nxgt/mail": "0.4.0",
|
|
48
48
|
"@types/bun": "^1.4.2",
|
|
49
49
|
"@types/mailparser": "^3.4.6",
|
|
50
50
|
"@types/nodemailer": "^8.0.2",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"smtp-server": "^3.19.13"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@nxgt/mail": "^0.
|
|
57
|
+
"@nxgt/mail": "^0.4.0",
|
|
58
58
|
"nodemailer": ">=7.0.0 <11",
|
|
59
59
|
"typescript": "^6.0.3"
|
|
60
60
|
}
|