@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 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.2`, the version
41
- with attachments. One copy in your tree, so `error instanceof MailFailure`
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: { 'List-Unsubscribe': '<https://acme.test/unsubscribe>' },
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, and what a message becomes on the wire |
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 |
@@ -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';
@@ -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
@@ -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
- - **Attachments** — the `attachments` of a message are handed to nodemailer
9
- as bytes, a `Buffer` copied from each `Uint8Array`, with their file name
10
- (encoded by nodemailer when it is not ASCII) and their type. Never a `path`
11
- or an `href`: `disableFileAccess` and `disableUrlAccess` stay on. A message
12
- over the server's size limit (`552`) is a `MailRefused`. Needs
13
- `@nxgt/mail` 0.2. Built, not yet published.
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
@@ -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.2.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.2.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.2.0",
57
+ "@nxgt/mail": "^0.4.0",
58
58
  "nodemailer": ">=7.0.0 <11",
59
59
  "typescript": "^6.0.3"
60
60
  }