@bumail/smtp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +148 -6
  2. package/dist/chunks/index-2ybj2z1w.js +216 -0
  3. package/dist/chunks/index-2ybj2z1w.js.map +14 -0
  4. package/dist/client/content.d.ts +25 -0
  5. package/dist/client/content.d.ts.map +1 -0
  6. package/dist/client/inbox.d.ts +18 -0
  7. package/dist/client/inbox.d.ts.map +1 -0
  8. package/dist/client/index.d.ts +6 -0
  9. package/dist/client/index.d.ts.map +1 -0
  10. package/dist/client/index.js +945 -0
  11. package/dist/client/index.js.map +22 -0
  12. package/dist/client/mx.d.ts +29 -0
  13. package/dist/client/mx.d.ts.map +1 -0
  14. package/dist/client/options.d.ts +136 -0
  15. package/dist/client/options.d.ts.map +1 -0
  16. package/dist/client/refusal.d.ts +15 -0
  17. package/dist/client/refusal.d.ts.map +1 -0
  18. package/dist/client/send.d.ts +13 -0
  19. package/dist/client/send.d.ts.map +1 -0
  20. package/dist/client/session.d.ts +11 -0
  21. package/dist/client/session.d.ts.map +1 -0
  22. package/dist/client/settings.d.ts +30 -0
  23. package/dist/client/settings.d.ts.map +1 -0
  24. package/dist/client/socket.d.ts +31 -0
  25. package/dist/client/socket.d.ts.map +1 -0
  26. package/dist/client/target.d.ts +29 -0
  27. package/dist/client/target.d.ts.map +1 -0
  28. package/dist/client/transaction.d.ts +15 -0
  29. package/dist/client/transaction.d.ts.map +1 -0
  30. package/dist/errors.d.ts +28 -4
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/index.js +115 -196
  33. package/dist/index.js.map +9 -13
  34. package/dist/io/outgoing.d.ts +21 -0
  35. package/dist/io/outgoing.d.ts.map +1 -0
  36. package/dist/protocol/data-writer.d.ts +22 -0
  37. package/dist/protocol/data-writer.d.ts.map +1 -0
  38. package/dist/protocol/ehlo.d.ts +7 -0
  39. package/dist/protocol/ehlo.d.ts.map +1 -0
  40. package/dist/{server → protocol}/lines.d.ts +9 -4
  41. package/dist/protocol/lines.d.ts.map +1 -0
  42. package/dist/protocol/reply-reader.d.ts +19 -0
  43. package/dist/protocol/reply-reader.d.ts.map +1 -0
  44. package/dist/protocol/sasl.d.ts +4 -0
  45. package/dist/protocol/sasl.d.ts.map +1 -1
  46. package/dist/server/connection.d.ts +8 -0
  47. package/dist/server/connection.d.ts.map +1 -1
  48. package/dist/server/input.d.ts.map +1 -1
  49. package/dist/server/options.d.ts +2 -1
  50. package/dist/server/options.d.ts.map +1 -1
  51. package/dist/server/server.d.ts.map +1 -1
  52. package/dist/server/settings.d.ts.map +1 -1
  53. package/dist/server/transport.d.ts +21 -4
  54. package/dist/server/transport.d.ts.map +1 -1
  55. package/docs/README.md +3 -3
  56. package/docs/guide.md +381 -9
  57. package/docs/roadmap.md +43 -8
  58. package/docs/troubleshooting.md +668 -7
  59. package/package.json +12 -4
  60. package/dist/server/lines.d.ts.map +0 -1
@@ -4,6 +4,10 @@ Each entry is headed by the text you see: the message of the error thrown,
4
4
  or the reply the server sends, as a client or a log prints it. `…` stands
5
5
  for the part that varies, often the server's `hostname`.
6
6
 
7
+ What `sendMail` (`@bumail/smtp/client`) rejects with is under *Sending
8
+ mail*. There `…` is often the host the client spoke to — `host`, or the MX
9
+ host — and an error's `temporary` says whether to retry it later.
10
+
7
11
  A reply carries its enhanced status code (`5.7.1`, RFC 3463) only after
8
12
  `EHLO`. After `HELO`, or before either, the same reply comes without it:
9
13
  `554 Relay access denied`, not `554 5.7.1 Relay access denied`.
@@ -17,6 +21,7 @@ A reply carries its enhanced status code (`5.7.1`, RFC 3463) only after
17
21
  - [`SmtpError: createSmtpServer(): authenticate needs tls: { key, cert }, since AUTH is offered only once encrypted`](#smtperror-createsmtpserver-authenticate-needs-tls--key-cert--since-auth-is-offered-only-once-encrypted)
18
22
  - [`SmtpError: createSmtpServer(): localDomains must be an array of domains or a function`](#smtperror-createsmtpserver-localdomains-must-be-an-array-of-domains-or-a-function)
19
23
  - [`SmtpError: createSmtpServer(): … must be a positive integer, not …`](#smtperror-createsmtpserver--must-be-a-positive-integer-not-)
24
+ - [`SmtpError: createSmtpServer(): hookTimeout must be at most 2147483 seconds, not …`](#smtperror-createsmtpserver-hooktimeout-must-be-at-most-2147483-seconds-not-)
20
25
  - [`SmtpError: createSmtpServer(): greetingDelay must be a number of seconds, 0 or more, not …`](#smtperror-createsmtpserver-greetingdelay-must-be-a-number-of-seconds-0-or-more-not-)
21
26
  - [`SmtpError: createSmtpServer(): greetingDelay (… s) must be shorter than timeout (… s), or every client times out before the greeting`](#smtperror-createsmtpserver-greetingdelay--s-must-be-shorter-than-timeout--s-or-every-client-times-out-before-the-greeting)
22
27
  - [`SmtpError: listen(): the server is already listening on …`](#smtperror-listen-the-server-is-already-listening-on-)
@@ -81,6 +86,67 @@ A reply carries its enhanced status code (`5.7.1`, RFC 3463) only after
81
86
  - [`421 4.4.2 … Idle too long, closing`](#421-442--idle-too-long-closing)
82
87
  - [`554 … Talked before the greeting`](#554--talked-before-the-greeting)
83
88
 
89
+ **Sending mail: options**
90
+
91
+ - [`SmtpError: sendMail(): "…" is not an address (local@domain)`](#smtperror-sendmail--is-not-an-address-localdomain)
92
+ - [`SmtpError: sendMail(): to must name one recipient or more`](#smtperror-sendmail-to-must-name-one-recipient-or-more)
93
+ - [`SmtpError: sendMail(): helo "…" is not a host name`](#smtperror-sendmail-helo--is-not-a-host-name)
94
+ - [`SmtpError: sendMail(): helo is required for delivery by MX: pass your server's public name, such as helo: 'mail.example.com'`](#smtperror-sendmail-helo-is-required-for-delivery-by-mx-pass-your-servers-public-name-such-as-helo-mailexamplecom)
95
+ - [`SmtpError: sendMail(): … must be a number of seconds above 0 and at most 2147483, not …`](#smtperror-sendmail--must-be-a-number-of-seconds-above-0-and-at-most-2147483-not-)
96
+ - [`SmtpError: sendMail(): tls must be 'opportunistic', 'required' or 'none', not …`](#smtperror-sendmail-tls-must-be-opportunistic-required-or-none-not-)
97
+ - [`SmtpError: sendMail(): secure is TLS from the first byte: it cannot go with tls: 'none'`](#smtperror-sendmail-secure-is-tls-from-the-first-byte-it-cannot-go-with-tls-none)
98
+ - [`SmtpError: sendMail(): auth with tls: 'none' would send the password in clear; pass allowPlaintextAuth: true for a local test server`](#smtperror-sendmail-auth-with-tls-none-would-send-the-password-in-clear-pass-allowplaintextauth-true-for-a-local-test-server)
99
+ - [`SmtpError: sendMail(): auth with tls: 'opportunistic' would send the password to a server whose certificate is not checked; leave tls out to check it, or pass allowPlaintextAuth: true for a local test server`](#smtperror-sendmail-auth-with-tls-opportunistic-would-send-the-password-to-a-server-whose-certificate-is-not-checked-leave-tls-out-to-check-it-or-pass-allowplaintextauth-true-for-a-local-test-server)
100
+ - [`SmtpError: sendMail(): auth.username must be a non-empty string`](#smtperror-sendmail-authusername-must-be-a-non-empty-string)
101
+ - [`SmtpError: sendMail(): auth.password must be a non-empty string`](#smtperror-sendmail-authpassword-must-be-a-non-empty-string)
102
+ - [`SmtpError: sendMail(): auth.mechanism must be 'PLAIN' or 'LOGIN', not …`](#smtperror-sendmail-authmechanism-must-be-plain-or-login-not-)
103
+ - [`SmtpError: sendMail(): size must be a number of bytes, not …`](#smtperror-sendmail-size-must-be-a-number-of-bytes-not-)
104
+ - [`SmtpError: sendMail(): port must be an integer from 1 to 65535, not …`](#smtperror-sendmail-port-must-be-an-integer-from-1-to-65535-not-)
105
+ - [`SmtpError: sendMail(): host must be a host name or an address, not …`](#smtperror-sendmail-host-must-be-a-host-name-or-an-address-not-)
106
+ - [`SmtpError: sendMail(): domain must be a domain name`](#smtperror-sendmail-domain-must-be-a-domain-name)
107
+ - [`SmtpError: sendMail(): resolver must be a Resolver, such as @bumail/dns gives`](#smtperror-sendmail-resolver-must-be-a-resolver-such-as-bumaildns-gives)
108
+ - [`SmtpError: sendMail(): options must be an object`](#smtperror-sendmail-options-must-be-an-object)
109
+ - [`SmtpError: sendMail(): the message must be a Uint8Array, a string or a ReadableStream`](#smtperror-sendmail-the-message-must-be-a-uint8array-a-string-or-a-readablestream)
110
+ - [`SmtpError: sendMail(): a message stream must give Uint8Array chunks`](#smtperror-sendmail-a-message-stream-must-give-uint8array-chunks)
111
+ - [`SmtpError: sendMail(): a message stream can be sent once only`](#smtperror-sendmail-a-message-stream-can-be-sent-once-only)
112
+
113
+ **Sending mail: the message**
114
+
115
+ - [`SmtpError: sendMail(): the message holds a bare CR or LF, which a server must refuse (SMTP smuggling); end every line with CRLF, or pass normalizeLineEnds: true`](#smtperror-sendmail-the-message-holds-a-bare-cr-or-lf-which-a-server-must-refuse-smtp-smuggling-end-every-line-with-crlf-or-pass-normalizelineends-true)
116
+ - [`SmtpError: The message (… bytes) is larger than the … bytes … takes (SIZE)`](#smtperror-the-message--bytes-is-larger-than-the--bytes--takes-size)
117
+ - [`SmtpError: … does not offer 8BITMIME, which the message needs: it has 8-bit bytes`](#smtperror--does-not-offer-8bitmime-which-the-message-needs-it-has-8-bit-bytes)
118
+ - [`SmtpError: … does not offer SMTPUTF8, which an address that is not ASCII needs (or smtputf8 asked for)`](#smtperror--does-not-offer-smtputf8-which-an-address-that-is-not-ascii-needs-or-smtputf8-asked-for)
119
+
120
+ **Sending mail: connections, TLS and AUTH**
121
+
122
+ - [`SmtpError: Could not connect to … (…:…): …`](#smtperror-could-not-connect-to---)
123
+ - [`SmtpError: Timed out after … s waiting for … (…)`](#smtperror-timed-out-after--s-waiting-for--)
124
+ - [`SmtpError: The deadline of … s passed waiting for … (…)`](#smtperror-the-deadline-of--s-passed-waiting-for--)
125
+ - [`SmtpError: The connection to … closed before …`](#smtperror-the-connection-to--closed-before-)
126
+ - [`SmtpError: The connection to … closed while the message was sent`](#smtperror-the-connection-to--closed-while-the-message-was-sent)
127
+ - [`SmtpError: TLS with … failed: …`](#smtperror-tls-with--failed-)
128
+ - [`SmtpError: … does not offer STARTTLS, and tls is 'required'`](#smtperror--does-not-offer-starttls-and-tls-is-required)
129
+ - [`SmtpError: … refused STARTTLS, and tls is 'required': …`](#smtperror--refused-starttls-and-tls-is-required-)
130
+ - [`SmtpError: Refusing to send credentials to … in clear: it did not start TLS (allowPlaintextAuth is for a local test server only)`](#smtperror-refusing-to-send-credentials-to--in-clear-it-did-not-start-tls-allowplaintextauth-is-for-a-local-test-server-only)
131
+ - [`SmtpError: … offers no AUTH mechanism this client speaks (…): AUTH …`](#smtperror--offers-no-auth-mechanism-this-client-speaks--auth-)
132
+
133
+ **Sending mail: replies**
134
+
135
+ - [`SmtpError: … refused …: …`](#smtperror--refused--)
136
+ - [`SmtpError: … refused every recipient, … with …`](#smtperror--refused-every-recipient--with-)
137
+ - [`SmtpError: … sent a reply line longer than 2048 bytes`](#smtperror--sent-a-reply-line-longer-than-2048-bytes)
138
+ - [`SmtpError: … sent a reply of more than 100 lines`](#smtperror--sent-a-reply-of-more-than-100-lines)
139
+ - [`SmtpError: … sent more than 1048576 bytes of replies`](#smtperror--sent-more-than-1048576-bytes-of-replies)
140
+ - [`SmtpError: … sent a line that is not an SMTP reply: "…"`](#smtperror--sent-a-line-that-is-not-an-smtp-reply-)
141
+ - [`SmtpError: … sent a reply whose lines change code, from … to …`](#smtperror--sent-a-reply-whose-lines-change-code-from--to-)
142
+ - [`SmtpError: … sent more after its 220 to STARTTLS`](#smtperror--sent-more-after-its-220-to-starttls)
143
+
144
+ **Sending mail: DNS**
145
+
146
+ - [`SmtpError: … accepts no mail: its MX record is the null MX (RFC 7505)`](#smtperror--accepts-no-mail-its-mx-record-is-the-null-mx-rfc-7505)
147
+ - [`SmtpError: Could not look up the MX records of …: …`](#smtperror-could-not-look-up-the-mx-records-of--)
148
+ - [`SmtpError: No mail host of … has an address: …`](#smtperror-no-mail-host-of--has-an-address-)
149
+
84
150
  ## Configuration
85
151
 
86
152
  Each of these is thrown by `createSmtpServer(options)` itself, before
@@ -265,6 +331,31 @@ createSmtpServer({
265
331
  `timeout` and `hookTimeout` are in seconds: `300_000` is accepted, and
266
332
  means 83 hours.
267
333
 
334
+ ### `SmtpError: createSmtpServer(): hookTimeout must be at most 2147483 seconds, not …`
335
+
336
+ **When**: `hookTimeout` is past 2 147 483 seconds (about 24.8 days).
337
+
338
+ **Why**: the hook's deadline is a `setTimeout` of `hookTimeout × 1000`
339
+ milliseconds, and a delay past 2^31 − 1 ms fires after 1 ms instead: every
340
+ hook would time out at once, and every MAIL FROM, RCPT TO and message be
341
+ refused with `451 4.3.0`.
342
+
343
+ **Fix**: a hook that may take long still needs a bound; leave it out for
344
+ 60 seconds, or pass a few minutes:
345
+
346
+ ```ts
347
+ import { createSmtpServer } from '@bumail/smtp';
348
+
349
+ createSmtpServer({
350
+ hostname: 'mx.example.com',
351
+ localDomains: ['example.com'],
352
+ hookTimeout: 300, // seconds
353
+ onData: async (message) => {
354
+ await new Response(message.content).bytes();
355
+ },
356
+ });
357
+ ```
358
+
268
359
  ### `SmtpError: createSmtpServer(): greetingDelay must be a number of seconds, 0 or more, not …`
269
360
 
270
361
  **When**: `greetingDelay` is negative, `NaN` or `Infinity`.
@@ -546,8 +637,9 @@ createSmtpServer({
546
637
 
547
638
  ### `421 4.7.0 … Too many failed authentications, closing`
548
639
 
549
- **When**: the third `535 5.7.8` in one session. The server hangs up. `…`
550
- is the server's `hostname`.
640
+ **When**: the third `535 5.7.8` in one session. The server hangs up at
641
+ once: replies the client has not read are dropped, not waited for. `…` is
642
+ the server's `hostname`.
551
643
 
552
644
  **Why**: it bounds password guessing on one connection. The limit is
553
645
  three, and is not an option. A response that is not valid base64 gets
@@ -1211,8 +1303,9 @@ too, even to drop it.
1211
1303
 
1212
1304
  ### `421 4.3.0 Local error, closing`
1213
1305
 
1214
- **When**: during a session, then the server hangs up. The reply carries
1215
- no enhanced code before EHLO.
1306
+ **When**: during a session, then the server hangs up at once: replies
1307
+ the client has not read are dropped, not waited for. The reply carries no
1308
+ enhanced code before EHLO.
1216
1309
 
1217
1310
  **Why**: the server failed while handling a command, outside any hook:
1218
1311
  a bug in this package. `onError` gets the error.
@@ -1226,7 +1319,8 @@ error, the session transcript and the `@bumail/smtp` and Bun versions.
1226
1319
  ### `421 4.7.0 … Too many errors, closing`
1227
1320
 
1228
1321
  **When**: the `maxErrors`-th failed command of a session (10 by default).
1229
- The reply replaces the reply to that command, and the server hangs up.
1322
+ The reply replaces the reply to that command, and the server hangs up at
1323
+ once: replies the client has not read are dropped, not waited for.
1230
1324
 
1231
1325
  **Why**: a client that keeps failing is broken or probing. What counts:
1232
1326
  unknown commands, bad syntax, commands out of order, relay denials,
@@ -1252,7 +1346,12 @@ to retry later.
1252
1346
 
1253
1347
  **Fix**, as the operator: raise `maxConnections`, or look for clients that
1254
1348
  hold connections open without `QUIT`. `server.connections` gives the
1255
- number open.
1349
+ number open. A client that stopped reading holds its slot until its
1350
+ `timeout` at most: the server's own hang-ups — idle, too many errors, a
1351
+ refusal — never wait for it to read, and drop what it did not. In 0.1.0
1352
+ such a close could wait forever, and enough of those clients filled
1353
+ `maxConnections`: upgrade if `connections` stays high while no client is
1354
+ active.
1256
1355
 
1257
1356
  **Fix**, as a client: retry later, and send `QUIT` when done.
1258
1357
 
@@ -1266,7 +1365,11 @@ quiet, so that a dead client does not hold a connection forever. The time
1266
1365
  counts from the client's last byte, or from the 220 when nothing came after
1267
1366
  it, so a slow hook can run into it too — keep `hookTimeout` below
1268
1367
  `timeout`. Bun's socket timer ticks in steps of about 4 seconds, so the
1269
- hang-up can come up to that much after `timeout`.
1368
+ hang-up can come up to that much after `timeout`. The hang-up is at once
1369
+ even when the client stopped reading: replies it never read, and the 421
1370
+ itself if the socket cannot take it, are dropped, and the connection is
1371
+ counted out of `connections` then — on implicit TLS and after STARTTLS
1372
+ too, where the server does not wait for the client to answer the hang-up.
1270
1373
 
1271
1374
  **Fix**, as a client: send `QUIT` when done; a pooled connection that
1272
1375
  waits longer must reconnect, or send `NOOP` within the timeout.
@@ -1306,3 +1409,561 @@ client library does; a hand-written client or a test script may not.
1306
1409
  **Fix**, as the operator: if a legitimate sender is refused, lower
1307
1410
  `greetingDelay`, or leave it at `0`; a test client that writes at once
1308
1411
  needs it at `0`.
1412
+
1413
+ ## Sending mail: options
1414
+
1415
+ ### `SmtpError: sendMail(): "…" is not an address (local@domain)`
1416
+
1417
+ **When**: `from` or a recipient in `to` is not an RFC 5321 path: no `@`, a
1418
+ display name (`Bob <b@example.org>`), angle brackets, a space, or a CR or
1419
+ LF. For `from` the message ends `(local@domain, or '' for a bounce)`.
1420
+
1421
+ **Why**: the address goes into `MAIL FROM:<…>` or `RCPT TO:<…>` as it is;
1422
+ anything else could break the command or inject another.
1423
+
1424
+ **Fix**: pass the bare address, and `''` for the null sender of a bounce:
1425
+
1426
+ ```ts
1427
+ import { sendMail } from '@bumail/smtp/client';
1428
+
1429
+ await sendMail('Subject: hi\r\n\r\nhello\r\n', { host: 'relay.example.net', from: '', to: ['b@example.org'] });
1430
+ ```
1431
+
1432
+ ### `SmtpError: sendMail(): to must name one recipient or more`
1433
+
1434
+ **When**: `to` is an empty array, or neither a string nor an array.
1435
+
1436
+ **Why**: a message with no recipient goes nowhere.
1437
+
1438
+ **Fix**: pass one address, or an array of them.
1439
+
1440
+ ### `SmtpError: sendMail(): helo "…" is not a host name`
1441
+
1442
+ **When**: `helo` is not a host name nor an address literal such as
1443
+ `[192.0.2.1]`.
1444
+
1445
+ **Why**: it is the argument of EHLO and HELO (RFC 5321 §4.1.1.1).
1446
+
1447
+ **Fix**: pass your server's public name, the one its address resolves back
1448
+ to: `helo: 'mail.example.com'`.
1449
+
1450
+ ### `SmtpError: sendMail(): helo is required for delivery by MX: pass your server's public name, such as helo: 'mail.example.com'`
1451
+
1452
+ **When**: `{ domain, resolver }` without `helo`. From TypeScript, `tsc`
1453
+ refuses it first: `MxDestination` declares `helo: string`; the error comes
1454
+ at runtime from JavaScript, or past a cast.
1455
+
1456
+ **Why**: by MX, the receiving host reads the EHLO name. The machine's own
1457
+ name — `os.hostname()`, often `laptop.local` or a bare `web-1` — is rarely
1458
+ a fully-qualified name that resolves back to the sending address, which
1459
+ receiving hosts penalise or refuse, and it tells them the machine's name.
1460
+ So delivery by MX has no default; to a host (`{ host }`: a smarthost,
1461
+ Mailpit) the machine's name stays the default.
1462
+
1463
+ **Fix**: pass the public name of the host that sends, the name its
1464
+ address's PTR record gives:
1465
+
1466
+ ```ts
1467
+ import { nodeResolver } from '@bumail/dns';
1468
+ import { sendMail } from '@bumail/smtp/client';
1469
+
1470
+ const message = 'From: a@example.com\r\nTo: b@example.org\r\nSubject: hi\r\n\r\nhello\r\n';
1471
+ await sendMail(message, {
1472
+ domain: 'example.org',
1473
+ resolver: nodeResolver(),
1474
+ helo: 'mail.example.com',
1475
+ from: 'a@example.com',
1476
+ to: 'b@example.org',
1477
+ });
1478
+ ```
1479
+
1480
+ ### `SmtpError: sendMail(): … must be a number of seconds above 0 and at most 2147483, not …`
1481
+
1482
+ **When**: a value in `timeouts`, or `deadline`, is `0`, negative, not a
1483
+ number, or past 2 147 483 seconds.
1484
+
1485
+ **Why**: each is a `setTimeout`, which fires after 1 ms for a delay past
1486
+ 2^31 − 1 ms; and none can be switched off.
1487
+
1488
+ **Fix**: pass seconds, fractions allowed, or leave it out for its default:
1489
+
1490
+ ```ts
1491
+ import { sendMail } from '@bumail/smtp/client';
1492
+
1493
+ await sendMail('Subject: hi\r\n\r\nhello\r\n', {
1494
+ host: 'relay.example.net',
1495
+ from: 'a@example.com', to: 'b@example.org',
1496
+ timeouts: { greeting: 30, dataEnd: 120 }, // seconds
1497
+ deadline: 300,
1498
+ });
1499
+ ```
1500
+
1501
+ ### `SmtpError: sendMail(): tls must be 'opportunistic', 'required' or 'none', not …`
1502
+
1503
+ **When**: `tls` is anything else, such as `true`.
1504
+
1505
+ **Why**: these are the three modes; see the guide's TLS table.
1506
+
1507
+ **Fix**: `tls: 'required'` to check the certificate, `secure: true` for TLS
1508
+ from the first byte.
1509
+
1510
+ ### `SmtpError: sendMail(): secure is TLS from the first byte: it cannot go with tls: 'none'`
1511
+
1512
+ **When**: `secure: true` and `tls: 'none'`.
1513
+
1514
+ **Why**: the two contradict each other.
1515
+
1516
+ **Fix**: leave `tls` out (`required` is then the default), or pass
1517
+ `'opportunistic'` to skip the certificate check (not with `auth`, unless
1518
+ `allowPlaintextAuth`).
1519
+
1520
+ ### `SmtpError: sendMail(): auth with tls: 'none' would send the password in clear; pass allowPlaintextAuth: true for a local test server`
1521
+
1522
+ **When**: `auth` with `tls: 'none'`.
1523
+
1524
+ **Why**: the credentials would cross the network in base64.
1525
+
1526
+ **Fix**: leave `tls` out: with `auth` it defaults to `'required'`. For a
1527
+ local test server without TLS, such as Mailpit, pass `allowPlaintextAuth:
1528
+ true`.
1529
+
1530
+ ### `SmtpError: sendMail(): auth with tls: 'opportunistic' would send the password to a server whose certificate is not checked; leave tls out to check it, or pass allowPlaintextAuth: true for a local test server`
1531
+
1532
+ **When**: `auth` with `tls: 'opportunistic'`, explicitly.
1533
+
1534
+ **Why**: opportunistic TLS does not check the certificate (RFC 7435), so
1535
+ whoever sits on the path can present any certificate and read the
1536
+ password. Credentials go only to a server whose certificate checked out,
1537
+ unless `allowPlaintextAuth` says it is a local test server.
1538
+
1539
+ **Fix**: leave `tls` out — with `auth` it defaults to `'required'` — and
1540
+ pass `ca` if the server's certificate is from a private CA:
1541
+
1542
+ ```ts
1543
+ import { sendMail } from '@bumail/smtp/client';
1544
+
1545
+ const message = 'From: a@example.com\r\nTo: b@example.org\r\nSubject: hi\r\n\r\nhello\r\n';
1546
+ await sendMail(message, {
1547
+ host: 'smtp.example.com',
1548
+ port: 587,
1549
+ from: 'a@example.com',
1550
+ to: 'b@example.org',
1551
+ auth: { username: 'a', password: Bun.env['SMTP_PASSWORD'] ?? '' },
1552
+ ca: await Bun.file('/etc/ssl/internal-ca.pem').text(), // only for a private CA
1553
+ });
1554
+ ```
1555
+
1556
+ ### `SmtpError: sendMail(): auth.username must be a non-empty string`
1557
+
1558
+ **When**: `auth.username` is missing, empty or not a string.
1559
+
1560
+ **Why**: PLAIN and LOGIN both need one.
1561
+
1562
+ **Fix**: pass `auth: { username, password }`, or leave `auth` out.
1563
+
1564
+ ### `SmtpError: sendMail(): auth.password must be a non-empty string`
1565
+
1566
+ **When**: `auth.password` is missing, empty or not a string — often an
1567
+ environment variable that is not set.
1568
+
1569
+ **Why**: PLAIN and LOGIN both need one.
1570
+
1571
+ **Fix**: check the variable before sending: `Bun.env['SMTP_PASSWORD']`.
1572
+
1573
+ ### `SmtpError: sendMail(): auth.mechanism must be 'PLAIN' or 'LOGIN', not …`
1574
+
1575
+ **When**: `auth.mechanism` names another SASL mechanism.
1576
+
1577
+ **Why**: the client speaks PLAIN and LOGIN only.
1578
+
1579
+ **Fix**: leave it out: PLAIN when offered, else LOGIN.
1580
+
1581
+ ### `SmtpError: sendMail(): size must be a number of bytes, not …`
1582
+
1583
+ **When**: `size` is negative, a fraction or not a number.
1584
+
1585
+ **Why**: it is declared as `SIZE=` in MAIL FROM (RFC 1870).
1586
+
1587
+ **Fix**: pass the stream's length in bytes, or leave it out.
1588
+
1589
+ ### `SmtpError: sendMail(): port must be an integer from 1 to 65535, not …`
1590
+
1591
+ **When**: `port` is out of range or not an integer.
1592
+
1593
+ **Why**: it is a TCP port.
1594
+
1595
+ **Fix**: 25 for MX and relay, 587 for submission, 465 with `secure: true`,
1596
+ 1025 for Mailpit.
1597
+
1598
+ ### `SmtpError: sendMail(): host must be a host name or an address, not …`
1599
+
1600
+ **When**: `host` is empty, holds a space or a `/` — often a URL such as
1601
+ `smtp://…`.
1602
+
1603
+ **Why**: it is what the client connects to, and what TLS checks.
1604
+
1605
+ **Fix**: pass the bare name: `host: 'smtp.example.com'`.
1606
+
1607
+ ### `SmtpError: sendMail(): domain must be a domain name`
1608
+
1609
+ **When**: `domain` is empty or not a string.
1610
+
1611
+ **Why**: MX delivery looks it up.
1612
+
1613
+ **Fix**: pass the recipients' domain.
1614
+
1615
+ ### `SmtpError: sendMail(): resolver must be a Resolver, such as @bumail/dns gives`
1616
+
1617
+ **When**: `{ domain }` without a `resolver`, or one without `mx`.
1618
+
1619
+ **Why**: the client asks no DNS of its own; the resolver is injected.
1620
+
1621
+ **Fix**: install `@bumail/dns` (`bun add @bumail/dns`) and pass one of
1622
+ its resolvers, or any object with `mx`, `a` and `aaaa` (`MxResolver`):
1623
+
1624
+ ```ts
1625
+ import { nodeResolver } from '@bumail/dns';
1626
+ import { sendMail } from '@bumail/smtp/client';
1627
+
1628
+ const message = 'From: a@example.com\r\nTo: b@example.org\r\nSubject: hi\r\n\r\nhello\r\n';
1629
+ await sendMail(message, { domain: 'example.org', resolver: nodeResolver(), helo: 'mail.example.com', from: 'a@example.com', to: 'b@example.org' });
1630
+ ```
1631
+
1632
+ ### `SmtpError: sendMail(): options must be an object`
1633
+
1634
+ **When**: `options` is missing or `null`.
1635
+
1636
+ **Why**: it holds `from`, `to` and the destination.
1637
+
1638
+ **Fix**: `sendMail(message, { host, from, to })`.
1639
+
1640
+ ### `SmtpError: sendMail(): the message must be a Uint8Array, a string or a ReadableStream`
1641
+
1642
+ **When**: the message is another type: an object, a `Blob`, a `Response`.
1643
+
1644
+ **Why**: those are the three forms read.
1645
+
1646
+ **Fix**: `await blob.bytes()`, `blob.stream()` or `response.body`.
1647
+
1648
+ ### `SmtpError: sendMail(): a message stream must give Uint8Array chunks`
1649
+
1650
+ **When**: a stream gave a string or another value; the client hung up before
1651
+ the final dot.
1652
+
1653
+ **Why**: the bytes are sent as they are.
1654
+
1655
+ **Fix**: pipe it through a `TextEncoderStream`, or give bytes.
1656
+
1657
+ ### `SmtpError: sendMail(): a message stream can be sent once only`
1658
+
1659
+ **When**: the same `ReadableStream` was read twice.
1660
+
1661
+ **Why**: a stream is consumed as it is sent.
1662
+
1663
+ **Fix**: make a new stream per `sendMail`, or pass bytes.
1664
+
1665
+ ## Sending mail: the message
1666
+
1667
+ ### `SmtpError: sendMail(): the message holds a bare CR or LF, which a server must refuse (SMTP smuggling); end every line with CRLF, or pass normalizeLineEnds: true`
1668
+
1669
+ **When**: a line of the message ends with LF alone (or CR alone): text
1670
+ written with `\n`, a file saved on Unix. For a string or bytes, before
1671
+ connecting; for a stream, while sending — the client then hangs up before
1672
+ the dot, and nothing is delivered.
1673
+
1674
+ **Why**: a server that read a bare LF as a line end could be made to see two
1675
+ messages in one (SMTP smuggling); this package's server refuses such a
1676
+ message, as it should.
1677
+
1678
+ **Fix**: end every line with `\r\n`, or let the client do it:
1679
+
1680
+ ```ts
1681
+ import { sendMail } from '@bumail/smtp/client';
1682
+
1683
+ await sendMail('Subject: hi\n\nhello\n', { host: 'relay.example.net', from: 'a@example.com', to: 'b@example.org', normalizeLineEnds: true });
1684
+ ```
1685
+
1686
+ ### `SmtpError: The message (… bytes) is larger than the … bytes … takes (SIZE)`
1687
+
1688
+ **When**: the server announced a SIZE smaller than the message. Nothing was
1689
+ sent past EHLO.
1690
+
1691
+ **Why**: RFC 1870: the server would refuse it with `552` anyway.
1692
+
1693
+ **Fix**: send a smaller message — a link rather than an attachment. It is
1694
+ permanent: do not retry.
1695
+
1696
+ ### `SmtpError: … does not offer 8BITMIME, which the message needs: it has 8-bit bytes`
1697
+
1698
+ **When**: the message holds a byte above 127 (UTF-8 text, a raw attachment)
1699
+ and the server does not offer 8BITMIME. For a stream, the client hangs up
1700
+ before the dot.
1701
+
1702
+ **Why**: a server without 8BITMIME may strip the eighth bit.
1703
+
1704
+ **Fix**: encode the body: quoted-printable or base64 (`@bumail/mime` writes
1705
+ both), so the message is 7-bit.
1706
+
1707
+ ### `SmtpError: … does not offer SMTPUTF8, which an address that is not ASCII needs (or smtputf8 asked for)`
1708
+
1709
+ **When**: an address in `from` or `to` is not ASCII (`zoë@example.org`), or
1710
+ `smtputf8: true`, and the server does not offer SMTPUTF8.
1711
+
1712
+ **Why**: RFC 6531 §3.2: such a message cannot be sent to it.
1713
+
1714
+ **Fix**: use an ASCII address if the user has one; else it is permanent for
1715
+ that server.
1716
+
1717
+ ## Sending mail: connections, TLS and AUTH
1718
+
1719
+ ### `SmtpError: Could not connect to … (…:…): …`
1720
+
1721
+ **When**: the TCP connection failed: `ECONNREFUSED` (nothing listens there:
1722
+ Mailpit not started, the wrong port), `ENOTFOUND`, a firewall. Temporary.
1723
+
1724
+ **Fix**: check the server is up and the port; many networks block outbound
1725
+ port 25 — use a smarthost on 587 then.
1726
+
1727
+ ### `SmtpError: Timed out after … s waiting for … (…)`
1728
+
1729
+ **When**: a step took longer than its timeout: `the connection`, `the
1730
+ greeting`, `the reply to MAIL FROM`, `the server to take the message`…
1731
+ Temporary. `the next part of the message` is your own stream: it gave
1732
+ nothing for `timeouts.dataBlock` seconds, and the client hung up before the
1733
+ dot.
1734
+
1735
+ **Why**: every wait is bounded, so a slow or silent server cannot hold the
1736
+ delivery.
1737
+
1738
+ **Fix**: retry later; raise that step in `timeouts` if the server is known
1739
+ to be slow, such as a greylisting MX that delays its greeting.
1740
+
1741
+ ### `SmtpError: The deadline of … s passed waiting for … (…)`
1742
+
1743
+ **When**: the whole delivery, every MX host and DNS lookup together, took
1744
+ longer than `deadline`. Temporary. By MX, `the MX lookup (example.org)` or
1745
+ `the address lookup (mx1.example.org)` is a resolver that answered too late.
1746
+
1747
+ **Why**: one bound on the delivery, whatever the steps.
1748
+
1749
+ **Fix**: retry later, or raise `deadline` (1800 s by default).
1750
+
1751
+ ### `SmtpError: The connection to … closed before …`
1752
+
1753
+ **When**: the server hung up while the client waited for a reply. Temporary.
1754
+
1755
+ **Why**: a server that drops a session it will not serve, or crashed.
1756
+
1757
+ **Fix**: retry later. If it happens right after EHLO, look at the server's
1758
+ log: it may refuse the client's `helo` or address.
1759
+
1760
+ ### `SmtpError: The connection to … closed while the message was sent`
1761
+
1762
+ **When**: the server hung up during DATA. Temporary; the message was not
1763
+ delivered.
1764
+
1765
+ **Why**: often a size limit the server did not announce, or a time limit.
1766
+
1767
+ **Fix**: retry later; check the server's limits.
1768
+
1769
+ ### `SmtpError: TLS with … failed: …`
1770
+
1771
+ **When**: the TLS handshake failed. With `tls: 'required'` or `secure:
1772
+ true`, the reason is the certificate's: `DEPTH_ZERO_SELF_SIGNED_CERT`,
1773
+ `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `ERR_TLS_CERT_ALTNAME_INVALID` (a
1774
+ certificate for another name). Temporary.
1775
+
1776
+ **Why**: a certificate that does not check out against the host name may be
1777
+ an attacker's.
1778
+
1779
+ **Fix**: connect by the name the certificate is for; trust a private CA or a
1780
+ test's self-signed certificate with `ca`:
1781
+
1782
+ ```ts
1783
+ import { sendMail } from '@bumail/smtp/client';
1784
+
1785
+ await sendMail('Subject: hi\r\n\r\nhello\r\n', {
1786
+ host: 'mail.internal.example',
1787
+ port: 587,
1788
+ tls: 'required',
1789
+ ca: await Bun.file('/etc/ssl/internal-ca.pem').text(),
1790
+ from: 'a@example.com', to: 'b@example.org',
1791
+ });
1792
+ ```
1793
+
1794
+ ### `SmtpError: … does not offer STARTTLS, and tls is 'required'`
1795
+
1796
+ **When**: `tls: 'required'` — the default with `auth` — and the server's
1797
+ EHLO reply has no STARTTLS. Temporary.
1798
+
1799
+ **Why**: credentials, and a message that must be encrypted, are not sent in
1800
+ clear.
1801
+
1802
+ **Fix**: use the server's submission port (587, or 465 with `secure: true`).
1803
+ For a local test server without TLS, pass `allowPlaintextAuth: true`, or
1804
+ `tls: 'opportunistic'` without `auth`.
1805
+
1806
+ ### `SmtpError: … refused STARTTLS, and tls is 'required': …`
1807
+
1808
+ **When**: the server offers STARTTLS but answered it with another reply than
1809
+ 220, such as `454 4.7.0 TLS not available`. Temporary.
1810
+
1811
+ **Fix**: retry later; the server's TLS is broken.
1812
+
1813
+ ### `SmtpError: Refusing to send credentials to … in clear: it did not start TLS (allowPlaintextAuth is for a local test server only)`
1814
+
1815
+ **When**: `auth` and the session is not encrypted. The option checks
1816
+ already refuse every combination that leads here — `auth` with `tls:
1817
+ 'none'` or `'opportunistic'` needs `allowPlaintextAuth`, and `'required'`
1818
+ fails without TLS — so this is the last guard before the password is
1819
+ written, and should not be seen. Permanent.
1820
+
1821
+ **Why**: the password would cross the network in base64.
1822
+
1823
+ **Fix**: use TLS. For a local test server only, such as Mailpit started with
1824
+ `--smtp-auth-accept-any --smtp-auth-allow-insecure`:
1825
+
1826
+ ```ts
1827
+ import { sendMail } from '@bumail/smtp/client';
1828
+
1829
+ await sendMail('Subject: hi\r\n\r\nhello\r\n', {
1830
+ host: 'localhost',
1831
+ port: 1025,
1832
+ from: 'a@example.com', to: 'b@example.org',
1833
+ auth: { username: 'test', password: 'test' },
1834
+ allowPlaintextAuth: true,
1835
+ });
1836
+ ```
1837
+
1838
+ ### `SmtpError: … offers no AUTH mechanism this client speaks (…): AUTH …`
1839
+
1840
+ **When**: the server offers no AUTH, or only mechanisms other than PLAIN and
1841
+ LOGIN (or not the `auth.mechanism` asked). Permanent.
1842
+
1843
+ **Fix**: leave `auth.mechanism` out; if AUTH is missing, the server may
1844
+ offer it only on another port, or only after TLS.
1845
+
1846
+ ## Sending mail: replies
1847
+
1848
+ ### `SmtpError: … refused …: …`
1849
+
1850
+ **When**: the server answered the connection, EHLO, HELO, `AUTH PLAIN` or
1851
+ `AUTH LOGIN`, the sender (MAIL FROM), DATA, or the message (the final dot)
1852
+ with a 4xx or 5xx; the reply follows the colon, and is in `error.reply`. A
1853
+ 4xx is temporary, a 5xx permanent.
1854
+
1855
+ **Why**: the server's policy: `535 5.7.8` bad credentials, `550 5.7.1` a
1856
+ sender it refuses, `421` busy, `554` a spam verdict.
1857
+
1858
+ **Fix**: read the reply and its enhanced status code; this package's own
1859
+ replies are explained above. Retry a 4xx later; do not retry a 5xx.
1860
+
1861
+ ### `SmtpError: … refused every recipient, … with …`
1862
+
1863
+ **When**: every RCPT TO got another reply than 250 or 251; the first is in
1864
+ the message, all of them in `error.rejected`. Temporary if one of them was a
1865
+ 4xx.
1866
+
1867
+ **Why**: no recipient, no message: DATA is not sent.
1868
+
1869
+ **Fix**: check the addresses; retry the 4xx ones later. When only some are
1870
+ refused, `sendMail` resolves and lists them in `result.rejected`.
1871
+
1872
+ ### `SmtpError: … sent a reply line longer than 2048 bytes`
1873
+
1874
+ **When**: a line from the server, before its CRLF, is longer than 2048
1875
+ bytes. Temporary.
1876
+
1877
+ **Why**: RFC 5321 §4.5.3.1.5 bounds a reply line at 512; the client allows
1878
+ four times that, and no more, so a server cannot make it buffer without end.
1879
+
1880
+ **Fix**: none on your side; the server is broken or hostile. It is
1881
+ temporary: by MX, the next host is tried when it came before MAIL
1882
+ FROM.
1883
+
1884
+ ### `SmtpError: … sent a reply of more than 100 lines`
1885
+
1886
+ **When**: a multiline reply (`250-…`) went past 100 lines. Temporary.
1887
+
1888
+ **Why**: an EHLO reply has a dozen; a reply that never ends would hold the
1889
+ client.
1890
+
1891
+ **Fix**: none on your side; the server is broken or hostile. It is
1892
+ temporary: by MX, the next host is tried when it came before MAIL
1893
+ FROM.
1894
+
1895
+ ### `SmtpError: … sent more than 1048576 bytes of replies`
1896
+
1897
+ **When**: the server sent more than 1 MiB in one session, every reply
1898
+ together. Temporary.
1899
+
1900
+ **Why**: a bound on what a server can make the client hold.
1901
+
1902
+ **Fix**: none on your side; the server is broken or hostile. It is
1903
+ temporary: by MX, the next host is tried when it came before MAIL
1904
+ FROM.
1905
+
1906
+ ### `SmtpError: … sent a line that is not an SMTP reply: "…"`
1907
+
1908
+ **When**: a line does not start with three digits (2 to 5 first) and a
1909
+ space, a hyphen or its end: the port is not SMTP (an HTTP server, a TLS port
1910
+ spoken to in clear), or a proxy answered. Temporary.
1911
+
1912
+ **Why**: the client never guesses at what a reply means.
1913
+
1914
+ **Fix**: check the port, and `secure: true` for 465.
1915
+
1916
+ ### `SmtpError: … sent a reply whose lines change code, from … to …`
1917
+
1918
+ **When**: the lines of one multiline reply carry different codes. Temporary.
1919
+
1920
+ **Why**: RFC 5321 §4.2.1: every line of a reply has the same code.
1921
+
1922
+ **Fix**: none on your side; the server is broken. It is temporary: by MX,
1923
+ the next host is tried when it came before MAIL
1924
+ FROM.
1925
+
1926
+ ### `SmtpError: … sent more after its 220 to STARTTLS`
1927
+
1928
+ **When**: the server sent more after its `220` to STARTTLS, before the TLS
1929
+ handshake. Temporary.
1930
+
1931
+ **Why**: those bytes would be read as if they came over TLS: a man in the
1932
+ middle can inject replies there (CVE-2011-0411).
1933
+
1934
+ **Fix**: none on your side; the path to the server is not to be trusted. It
1935
+ is temporary: by MX, the next host is tried when it came before MAIL
1936
+ FROM.
1937
+
1938
+ ## Sending mail: DNS
1939
+
1940
+ ### `SmtpError: … accepts no mail: its MX record is the null MX (RFC 7505)`
1941
+
1942
+ **When**: the domain publishes `MX 0 .`. Permanent.
1943
+
1944
+ **Why**: RFC 7505: the domain says it takes no mail; a sender gives up at
1945
+ once, with no fallback to its address.
1946
+
1947
+ **Fix**: bounce the message: the address cannot receive mail.
1948
+
1949
+ ### `SmtpError: Could not look up the MX records of …: …`
1950
+
1951
+ **When**: the resolver failed for the MX query: `TEMPORARY`, `TIMEOUT`
1952
+ (temporary), or `INVALID_NAME` (permanent).
1953
+
1954
+ **Why**: the client tries no host it cannot find.
1955
+
1956
+ **Fix**: retry later; for `INVALID_NAME`, the recipient's domain is wrong.
1957
+
1958
+ ### `SmtpError: No mail host of … has an address: …`
1959
+
1960
+ **When**: neither the MX hosts nor (with no MX) the domain itself have an A
1961
+ or AAAA record. Permanent when the DNS said so — or the resolver answered
1962
+ with no record, and the message ends `no A or AAAA record` — temporary
1963
+ when a lookup failed.
1964
+
1965
+ **Why**: RFC 5321 §5.1: a domain with no MX is reached at its own address,
1966
+ and this one has none.
1967
+
1968
+ **Fix**: check the domain; a typo in the recipient's domain gives this,
1969
+ permanently.