@bumail/smtp 0.1.0 → 0.2.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.
Files changed (65) hide show
  1. package/README.md +149 -7
  2. package/dist/chunks/index-3423jxf9.js +249 -0
  3. package/dist/chunks/index-3423jxf9.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 +948 -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.d.ts +1 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +164 -197
  35. package/dist/index.js.map +9 -13
  36. package/dist/io/outgoing.d.ts +21 -0
  37. package/dist/io/outgoing.d.ts.map +1 -0
  38. package/dist/protocol/data-writer.d.ts +22 -0
  39. package/dist/protocol/data-writer.d.ts.map +1 -0
  40. package/dist/protocol/ehlo.d.ts +7 -0
  41. package/dist/protocol/ehlo.d.ts.map +1 -0
  42. package/dist/{server → protocol}/lines.d.ts +9 -4
  43. package/dist/protocol/lines.d.ts.map +1 -0
  44. package/dist/protocol/path.d.ts +24 -3
  45. package/dist/protocol/path.d.ts.map +1 -1
  46. package/dist/protocol/reply-reader.d.ts +19 -0
  47. package/dist/protocol/reply-reader.d.ts.map +1 -0
  48. package/dist/protocol/sasl.d.ts +4 -0
  49. package/dist/protocol/sasl.d.ts.map +1 -1
  50. package/dist/server/commands.d.ts.map +1 -1
  51. package/dist/server/connection.d.ts +8 -0
  52. package/dist/server/connection.d.ts.map +1 -1
  53. package/dist/server/input.d.ts.map +1 -1
  54. package/dist/server/options.d.ts +2 -1
  55. package/dist/server/options.d.ts.map +1 -1
  56. package/dist/server/server.d.ts.map +1 -1
  57. package/dist/server/settings.d.ts.map +1 -1
  58. package/dist/server/transport.d.ts +40 -4
  59. package/dist/server/transport.d.ts.map +1 -1
  60. package/docs/README.md +3 -3
  61. package/docs/guide.md +407 -13
  62. package/docs/roadmap.md +43 -8
  63. package/docs/troubleshooting.md +706 -9
  64. package/package.json +12 -4
  65. package/dist/server/lines.d.ts.map +0 -1
package/docs/guide.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Guide
2
2
 
3
3
  How to run `@bumail/smtp` and what it does on the wire: the session and its
4
- replies, the hooks, what a delivered message looks like, TLS, and the RFCs
5
- behind each behaviour.
4
+ replies, the hooks, what a delivered message looks like, TLS, sending mail
5
+ with the client, and the RFCs behind each behaviour.
6
6
 
7
7
  - [The smallest server](#the-smallest-server)
8
8
  - [Options](#options)
@@ -14,6 +14,7 @@ behind each behaviour.
14
14
  - [Authentication](#authentication)
15
15
  - [TLS](#tls)
16
16
  - [Limits](#limits)
17
+ - [Sending mail: the client](#sending-mail-the-client)
17
18
  - [Protocol helpers](#protocol-helpers)
18
19
  - [The RFCs implemented](#the-rfcs-implemented)
19
20
  - [What is not implemented](#what-is-not-implemented)
@@ -68,7 +69,7 @@ export interface SmtpServer {
68
69
  | `maxConnections` | `number` | `1000` | open connections at once |
69
70
  | `maxErrors` | `number` | `10` | failed commands before the server hangs up |
70
71
  | `timeout` | `number` | `300` | seconds since the client's last byte, or since the 220, before the server hangs up |
71
- | `hookTimeout` | `number` | `60` | seconds a hook, `authenticate` or `localDomains` has to settle, and `onData` to read on; past it, `451 4.3.0` |
72
+ | `hookTimeout` | `number` | `60` | seconds a hook, `authenticate` or `localDomains` has to settle, and `onData` to read on; past it, `451 4.3.0`. At most 2 147 483, what a timer can wait |
72
73
  | `greetingDelay` | `number` | `0` | seconds the server waits, once `onConnect` accepted, before its 220; a client that talks meanwhile gets `554` and is hung up on. Fractions are allowed |
73
74
  | `onConnect`, `onMailFrom`, `onRcptTo` | hooks | none | see [Hooks](#hooks-and-their-order) |
74
75
  | `onData` | hook | required | receives each message, as a stream |
@@ -108,6 +109,7 @@ await server.listen({ port: 25 });
108
109
  | `createSmtpServer(): authenticate needs tls: { key, cert }, since AUTH is offered only once encrypted` | `authenticate` without `tls` |
109
110
  | `createSmtpServer(): localDomains must be an array of domains or a function` | `localDomains` missing, a string, or an array holding something else |
110
111
  | `createSmtpServer(): <limit> must be a positive integer, not <value>` | a limit or `hookTimeout` that is `0`, negative, fractional or `NaN` |
112
+ | `createSmtpServer(): hookTimeout must be at most 2147483 seconds, not <value>` | `hookTimeout` past what `setTimeout` can wait (about 24.8 days) |
111
113
  | `createSmtpServer(): greetingDelay must be a number of seconds, 0 or more, not <value>` | `greetingDelay` negative, `NaN` or `Infinity` |
112
114
  | `createSmtpServer(): greetingDelay (<n> s) must be shorter than timeout (<n> s), or every client times out before the greeting` | `greetingDelay` as long as `timeout`, or longer |
113
115
 
@@ -293,14 +295,19 @@ export interface Path {
293
295
  ```
294
296
 
295
297
  A source route (`<@a.example:b@c.example>`, RFC 5321 Appendix C) is dropped:
296
- the path is `b@c.example`.
298
+ the path is `b@c.example`. It is taken only as `@domain(,@domain)*:`, each
299
+ hop a valid domain or address literal; any other route — an empty hop, a
300
+ hop without its `@`, a hop that is not a domain — is a `501 5.5.4 Syntax`.
297
301
 
298
302
  `local` is kept as written, quotes included. `<"v@x.example"@example.com>`
299
303
  and `<v%x.example@example.com>` are local parts of `example.com`, and
300
304
  passed the relay check as such: code that delivers must take the domain
301
305
  after the last `@` — `path.domain` — and never split `address` on its
302
- first `@`. C1 controls, Unicode format characters (zero-width, bidi, BOM)
303
- and U+2028/2029 are refused in a path, even under SMTPUTF8.
306
+ first `@`. C0 controls (CR, LF, NUL, tab…), DEL and `>`, C1 controls,
307
+ Unicode format characters (zero-width, bidi, BOM), U+2028/2029 and lone
308
+ surrogates are refused anywhere in a path — the route, the local part
309
+ quoted or not, the domain — even under SMTPUTF8. An IPv4 address literal
310
+ takes octets up to 255: `[999.1.1.1]` is not one.
304
311
 
305
312
  ## Session.data
306
313
 
@@ -866,10 +873,44 @@ Some limits are fixed: a command line is at most 2048 bytes (RFC 5321
866
873
  §4.5.3.1.4 asks for 512 at least); a session gets three AUTH attempts; and
867
874
  the server holds at most 64 KiB of a client's unread input, and of a
868
875
  message `onData` has not read, before it stops reading that client. Replies
869
- to a client that does not read them wait in the server, and the server reads
870
- no further command until they went out.
871
-
872
- Stop a server with `stop()`; `stop(true)` also hangs up on every client.
876
+ to a client that does not read them wait in the server while the connection
877
+ is open, none lost, and the server reads no further command until they went
878
+ out.
879
+
880
+ How the server hangs up depends on who decided. After `QUIT` it is
881
+ graceful: the `221` leaves whole, then the connection closes. When the
882
+ server decides — the idle `timeout`, `maxErrors`, three failed AUTH
883
+ attempts, a refusal from `onConnect`, a local error — it writes its reply
884
+ and closes at once: the connection is counted out of `connections` then and
885
+ there, and replies the client never read are dropped, the connection reset
886
+ if any were still waiting. When the server had stopped reading a client
887
+ that sent more than it could take, any hang-up, `QUIT`'s included, reads
888
+ again first, dropping whatever comes, and half-closes once the client's
889
+ input stops for 20 ms: a half-close does not complete over input left
890
+ unread, and on Linux a close over unread input is a reset that loses the
891
+ last reply. A client whose input went quiet for 20 ms within 500 ms of
892
+ the hang-up then still reads the last reply and the end. It waits 500 ms
893
+ at most: input not quiet for 20 ms by then, a client still sending or one
894
+ that stopped in the last 20 ms, is reset, so its slot is free by then too. So a client that
895
+ pipelines commands and stops reading cannot keep a slot of
896
+ `maxConnections` past its `timeout`.
897
+
898
+ Every hang-up is bounded, on a clear socket, on implicit TLS and after
899
+ STARTTLS alike. When the server decides, it never waits for the client to
900
+ answer its hang-up: the connection is counted out at once, even when the
901
+ client has stopped reading. When nothing is left queued, a client that reads later
902
+ still gets the last reply, then the end; when replies are still queued, a
903
+ forced close drops them and resets the connection. A graceful
904
+ close — after `QUIT`, or a reply that still has to leave — waits 5 seconds
905
+ at most for what is queued to go out; past that, what is left is dropped
906
+ and the connection reset. On TLS, once such queued replies have left, the
907
+ server closes when the client answers, within the same 5 seconds: a
908
+ half-close right then could drop the end of what Bun still holds in its
909
+ TLS buffer. So a `221` behind replies that are never read holds the slot
910
+ 5 seconds, not until the `timeout`.
911
+
912
+ Stop a server with `stop()`; `stop(true)` also hangs up on every client,
913
+ those moved to TLS by STARTTLS included, as the idle timeout does.
873
914
  `connections` counts the clients currently connected:
874
915
 
875
916
  ```ts
@@ -890,6 +931,344 @@ process.on('SIGTERM', () => {
890
931
  });
891
932
  ```
892
933
 
934
+ ## Sending mail: the client
935
+
936
+ `@bumail/smtp/client` is the other side of the conversation: `sendMail`
937
+ delivers one message to one destination and tells you what became of each
938
+ recipient. It shares the server's grammar — paths, replies, dot-stuffing,
939
+ SASL — and imports nothing of `@bumail/dns`, not even its types: a
940
+ resolver is needed only for MX delivery, and you pass it in (install
941
+ `@bumail/dns` for one).
942
+
943
+ - [Destinations](#destinations)
944
+ - [The session it runs](#the-session-it-runs)
945
+ - [TLS](#tls-1)
946
+ - [AUTH](#auth)
947
+ - [The message](#the-message)
948
+ - [What it resolves to, and what it rejects with](#what-it-resolves-to-and-what-it-rejects-with)
949
+ - [Delivery by MX](#delivery-by-mx)
950
+ - [Timeouts and limits](#timeouts-and-limits)
951
+ - [Trying it with Mailpit](#trying-it-with-mailpit)
952
+
953
+ ### Destinations
954
+
955
+ ```ts
956
+ import { nodeResolver } from '@bumail/dns';
957
+ import { sendMail } from '@bumail/smtp/client';
958
+
959
+ const message = 'From: a@example.com\r\nTo: b@example.org\r\nSubject: hi\r\n\r\nhello\r\n';
960
+
961
+ // A host: a smarthost, a submission server, a local Mailpit. Port 25 by
962
+ // default, 465 with secure: true.
963
+ await sendMail(message, { host: 'relay.example.net', port: 25, from: 'a@example.com', to: 'b@example.org' });
964
+
965
+ // A domain: its MX hosts, by preference, through the resolver. Port 25,
966
+ // and helo is required: your server's public name.
967
+ await sendMail(message, { domain: 'example.org', resolver: nodeResolver(), helo: 'mail.example.com', from: 'a@example.com', to: 'b@example.org' });
968
+ ```
969
+
970
+ `from` is the envelope's reverse-path — `''` for the null sender of a
971
+ bounce — and `to` one recipient or more. Both are checked as RFC 5321 paths
972
+ before anything is sent, before the client even connects: an address
973
+ holding a CR, an LF, a NUL, any other control character or a `>`, in any
974
+ part, is refused with `INVALID_OPTION`, so it cannot inject a command. A
975
+ source route (`@a.example,@b.example:x@c.example`) is refused too, valid
976
+ or not: RFC 5321 §4.1.1.3 says a client should not send one, and the
977
+ address is written as it is given.
978
+
979
+ One call is one destination. With `{ domain }`, every recipient should be
980
+ at that domain: group them by domain first, one `sendMail` each.
981
+
982
+ ### The session it runs
983
+
984
+ | step | what the client sends | what it needs |
985
+ | --- | --- | --- |
986
+ | greeting | — | `220`; anything else is `REFUSED` |
987
+ | hello | `EHLO <helo>` | `250`; a 5xx gets `HELO <helo>` instead (RFC 5321 §4.1.4), a 4xx is `REFUSED` |
988
+ | TLS | `STARTTLS`, the handshake, `EHLO` again (RFC 3207 §4.2) | see [TLS](#tls-1) |
989
+ | AUTH | `AUTH PLAIN <initial>`, or `AUTH LOGIN` and two 334 steps | `235` |
990
+ | envelope | `MAIL FROM:<from> SIZE=n BODY=8BITMIME SMTPUTF8`, `RCPT TO:<to>` each | `250` to MAIL; `250` or `251` to a RCPT, any other reply rejects that recipient |
991
+ | content | `DATA`, the message dot-stuffed, `.` | `354`, then `250` |
992
+ | end | `QUIT` | the `221` is waited for 5 seconds at most |
993
+
994
+ - `helo` is the name this client gives: your server's public name, the
995
+ one its address resolves back to. By MX it is required — an MX may
996
+ refuse or penalise a name that does not resolve back to your address,
997
+ and the machine's own name (`laptop.local`) is rarely one and would leak
998
+ it — so `MxDestination` declares `helo: string`, and `{ domain }`
999
+ without it does not compile; from JavaScript it is `INVALID_OPTION`. To a host
1000
+ (`{ host }`: a smarthost, Mailpit) it defaults to the machine's host
1001
+ name.
1002
+ - `SIZE=` is sent when the server offers SIZE and the size is known: a
1003
+ string or bytes, or `size` for a stream. A message larger than the
1004
+ server's SIZE is refused before MAIL FROM, with `MESSAGE_TOO_BIG`.
1005
+ - `BODY=8BITMIME` is sent whenever the server offers 8BITMIME (RFC 6152).
1006
+ Without it, a message with a byte above 127 fails with
1007
+ `EXTENSION_MISSING`: before MAIL FROM for a string or bytes, and by
1008
+ hanging up before the final dot for a stream.
1009
+ - `SMTPUTF8` is sent when an address is not ASCII, or `smtputf8: true` asks
1010
+ for it (for UTF-8 header fields). A server without it fails the delivery
1011
+ with `EXTENSION_MISSING` (RFC 6531 §3.2).
1012
+ - With PIPELINING (RFC 2920), MAIL FROM and every RCPT TO go in one write,
1013
+ and the replies are read in order. Without it, each waits for its reply.
1014
+ DATA always waits: if every recipient was refused, no content is sent.
1015
+ - A server that refuses the message during DATA (a `552` before the dot)
1016
+ and hangs up is reported by its reply, `REFUSED`, not as a lost
1017
+ connection.
1018
+ - After a refusal, the client says `QUIT` and hangs up. A message cut short —
1019
+ a stream that errors, a bare line break, a timeout — is never ended with
1020
+ the dot: the client hangs up, and the server drops what it had.
1021
+
1022
+ ### TLS
1023
+
1024
+ | `tls` | STARTTLS | the certificate | default when |
1025
+ | --- | --- | --- | --- |
1026
+ | `'opportunistic'` | when offered; in clear otherwise, or when the server answers STARTTLS with anything but `220` | not checked (RFC 7435) | neither `auth` nor `secure` is given |
1027
+ | `'required'` | needed: `TLS_UNAVAILABLE` without it | checked against `host`, or the MX host's name: `TLS_FAILED` | `auth` or `secure` is given |
1028
+ | `'none'` | never | — | never |
1029
+
1030
+ `secure: true` is TLS from the first byte (RFC 8314, port 465): no
1031
+ STARTTLS, and `tls` cannot be `'none'`. `ca` adds certificates to trust,
1032
+ PEM, besides the system's: a private CA, or a test's self-signed one.
1033
+
1034
+ ```ts
1035
+ import { sendMail } from '@bumail/smtp/client';
1036
+
1037
+ const result = await sendMail('Subject: hi\r\n\r\nhello\r\n', {
1038
+ host: 'mail.internal.example',
1039
+ port: 587,
1040
+ tls: 'required',
1041
+ ca: await Bun.file('/etc/ssl/internal-ca.pem').text(),
1042
+ from: 'app@example.com',
1043
+ to: 'ops@example.com',
1044
+ });
1045
+ console.log(result.tls); // { verified: true }
1046
+ ```
1047
+
1048
+ `result.tls` is `false` in clear, else `{ verified }`: `true` when the
1049
+ certificate checked out against the host name. With `'opportunistic'` it
1050
+ says so too, but a certificate that does not check out is used all the
1051
+ same — that is what opportunistic means. An opportunistic handshake that
1052
+ fails is not retried in clear: it is `TLS_FAILED`, temporary, and by MX the
1053
+ next host is tried.
1054
+
1055
+ Anything the server sends after its `220` to STARTTLS and before the
1056
+ handshake is refused with `BAD_REPLY`: it would be read as if it came over
1057
+ TLS (CVE-2011-0411).
1058
+
1059
+ ### AUTH
1060
+
1061
+ `auth: { username, password }` uses PLAIN when the server offers it, else
1062
+ LOGIN; `mechanism` picks one. The credentials go out only over TLS whose
1063
+ certificate checked out, unless `allowPlaintextAuth` says otherwise. `auth` makes `tls: 'required'` the default, so a server
1064
+ that does not offer STARTTLS fails earlier, with `TLS_UNAVAILABLE`.
1065
+
1066
+ `allowPlaintextAuth: true` lets AUTH go over a clear connection. It is for
1067
+ a local test server, such as Mailpit with `--smtp-auth-allow-insecure`:
1068
+ the password crosses the network in base64, which anyone on the path reads.
1069
+ Never set it for a server elsewhere. With it, `tls` defaults to
1070
+ `'opportunistic'`.
1071
+
1072
+ Without it, `auth` goes only with `tls: 'required'`: `tls: 'none'` would
1073
+ send the password in clear, and `tls: 'opportunistic'` to a server whose
1074
+ certificate is not checked, which an active attacker can intercept. Both
1075
+ are refused with `INVALID_OPTION` before connecting. Leave `tls` out to
1076
+ have the certificate checked, and pass `ca` for a private CA.
1077
+
1078
+ ### The message
1079
+
1080
+ A `string` (sent as UTF-8), a `Uint8Array`, or a `ReadableStream` of
1081
+ `Uint8Array` chunks: the whole RFC 5322 message, header fields first, lines
1082
+ ending in CRLF. A stream is read as the server takes it, one chunk at a
1083
+ time, so a large message never sits in memory whole.
1084
+
1085
+ - **Dot-stuffing** (RFC 5321 §4.5.2): a line starting with `.` gets one
1086
+ more; the server takes it off. A message whose last line has no CRLF gets
1087
+ one, since the terminator needs it.
1088
+ - **Bare CR or LF**: refused with `BARE_LINE_BREAK`, as the server refuses
1089
+ it (SMTP smuggling). A string or bytes are checked before connecting; a
1090
+ stream as it is sent, and the client hangs up before the dot.
1091
+ `normalizeLineEnds: true` turns each bare CR or LF into CRLF instead.
1092
+ - **Signing**: put the `DKIM-Signature` field `@bumail/auth`'s `signDkim`
1093
+ gives on top of the message before sending it. `sendMail` sends the
1094
+ bytes as they are, so a signature over them still verifies.
1095
+
1096
+ ### What it resolves to, and what it rejects with
1097
+
1098
+ `sendMail` resolves once the server took the message for one recipient at
1099
+ least:
1100
+
1101
+ ```ts
1102
+ import type { Reply } from '@bumail/smtp/client';
1103
+
1104
+ interface SendMailResult {
1105
+ accepted: { recipient: string; reply: Reply }[]; // 250 or 251 to RCPT TO
1106
+ rejected: { recipient: string; reply: Reply }[]; // any other reply
1107
+ reply: Reply; // to the final dot: { code: 250, status: '2.0.0', text: 'OK queued as …' }
1108
+ host: string; // the host, or the MX host, that took it
1109
+ port: number;
1110
+ tls: false | { verified: boolean };
1111
+ authenticated: boolean;
1112
+ }
1113
+ ```
1114
+
1115
+ A rejected recipient with a 4xx is worth trying again later; with a 5xx it
1116
+ is not. Every other outcome rejects with an `SmtpError`: its `code`, its
1117
+ `message` (each is in [troubleshooting](troubleshooting.md#sending-mail-options)),
1118
+ `temporary`, and, when a reply caused it, `reply` with its enhanced status
1119
+ code (RFC 3463).
1120
+
1121
+ | `code` | when | `temporary` |
1122
+ | --- | --- | --- |
1123
+ | `INVALID_OPTION` | an option, an address or the message cannot be used | no |
1124
+ | `BARE_LINE_BREAK` | the message holds a bare CR or LF | no |
1125
+ | `MESSAGE_TOO_BIG` | larger than the server's SIZE | no |
1126
+ | `EXTENSION_MISSING` | the message needs SMTPUTF8 or 8BITMIME, and the server lacks it | no |
1127
+ | `AUTH_UNAVAILABLE` | credentials and no TLS, or no mechanism in common | no |
1128
+ | `NULL_MX` | the domain's MX is the null MX (RFC 7505) | no |
1129
+ | `DNS_FAILED` | the MX or address lookup failed | yes, unless the DNS said there is no such name |
1130
+ | `REFUSED` | a 4xx or 5xx to the greeting, EHLO, AUTH, MAIL FROM, DATA or the dot | a 4xx yes, a 5xx no |
1131
+ | `RECIPIENTS_REFUSED` | every recipient refused; `rejected` lists them | when one of the refusals is a 4xx |
1132
+ | `CONNECTION_FAILED` | no TCP connection | yes |
1133
+ | `CONNECTION_LOST` | the server hung up half-way | yes |
1134
+ | `TIMEOUT` | a step's timeout, or the deadline | yes |
1135
+ | `BAD_REPLY` | what the server sent is not an SMTP reply, or is too long | yes |
1136
+ | `TLS_UNAVAILABLE` | `tls: 'required'` and no STARTTLS | yes |
1137
+ | `TLS_FAILED` | the handshake failed, or the certificate did not check out | yes |
1138
+
1139
+ A queue retries what is `temporary` and bounces what is not. This client is
1140
+ not one: it tries once and reports.
1141
+
1142
+ ```ts
1143
+ import { SmtpError, sendMail } from '@bumail/smtp/client';
1144
+
1145
+ try {
1146
+ await sendMail('Subject: hi\r\n\r\nhello\r\n', { host: 'relay.example.net', from: 'a@example.com', to: 'b@example.org' });
1147
+ } catch (error) {
1148
+ if (!(error instanceof SmtpError)) throw error; // your own stream's error comes through as it is
1149
+ if (error.temporary) console.log('defer', error.code, error.reply?.status);
1150
+ else console.log('bounce', error.message);
1151
+ }
1152
+ ```
1153
+
1154
+ ### Delivery by MX
1155
+
1156
+ With `{ domain, resolver }`, `resolveMx` gives the hosts in the order to
1157
+ try them (RFC 5321 §5.1):
1158
+
1159
+ - the MX records, the lowest preference first; equal ones in a random
1160
+ order, to spread the load;
1161
+ - no MX record (the DNS said `NOT_FOUND`): the domain itself, the implicit
1162
+ MX;
1163
+ - a null MX (`0 .`, RFC 7505): `NULL_MX` at once, no fallback to the
1164
+ domain's address;
1165
+ - an MX lookup that failed: `DNS_FAILED`, temporary.
1166
+
1167
+ Each host's addresses come from the resolver too (A, then AAAA), and the
1168
+ client connects to each address in turn, with TLS checked against the MX
1169
+ host's name. It moves on after a failure that is temporary and came before
1170
+ MAIL FROM — no connection, a dropped connection, a `421` greeting, a 4xx to
1171
+ EHLO, no STARTTLS when it is required, a TLS failure — and stops at a 5xx
1172
+ or once MAIL FROM was sent. It tries 10 addresses at most, and looks up
1173
+ 10 hosts at most — a host with no address counts too — so a domain with
1174
+ hundreds of MX records costs no more than 10 hosts' lookups. Every lookup,
1175
+ the MX one included, runs under `deadline`. When every host
1176
+ failed, it rejects with the last failure; when none had an address, with
1177
+ `DNS_FAILED`.
1178
+
1179
+ ```ts
1180
+ import { fixtureResolver } from '@bumail/dns';
1181
+ import { resolveMx } from '@bumail/smtp/client';
1182
+
1183
+ const resolver = fixtureResolver({
1184
+ 'example.org': {
1185
+ mx: [
1186
+ { exchange: 'mx2.example.org', priority: 20 },
1187
+ { exchange: 'mx1.example.org', priority: 10 },
1188
+ ],
1189
+ },
1190
+ });
1191
+ console.log(await resolveMx('example.org', resolver));
1192
+ // [{ host: 'mx1.example.org', priority: 10, implicit: false },
1193
+ // { host: 'mx2.example.org', priority: 20, implicit: false }]
1194
+ ```
1195
+
1196
+ Install `@bumail/dns` for MX delivery and pass one of its resolvers, or
1197
+ pass any object with `mx`, `a` and `aaaa` (`MxResolver`, declared by shape
1198
+ in this package, so its types never need `@bumail/dns`). A failure is
1199
+ read by its `DnsError` shape (`name` and `code`), as `@bumail/dns`'s
1200
+ `isTemporary` reads it: `TEMPORARY`, `TIMEOUT`, and anything that is not
1201
+ a `DnsError` are temporary. A resolver that answers with an empty array
1202
+ rather than `NOT_FOUND` is read the same way: no MX is the implicit MX, no
1203
+ address is a permanent `DNS_FAILED`.
1204
+
1205
+ ### Timeouts and limits
1206
+
1207
+ Every wait has a timeout, RFC 5321 §4.5.3.2's by default, in seconds. It
1208
+ is the whole wait — from the command to the last line of its reply — so a
1209
+ server that trickles a line at a time does not stretch it:
1210
+
1211
+ | `timeouts.` | waits for | default |
1212
+ | --- | --- | --- |
1213
+ | `connect` | the TCP connection, and each TLS handshake | 30 |
1214
+ | `greeting` | the 220 | 300 |
1215
+ | `command` | the replies to EHLO, HELO, STARTTLS, AUTH | 300 |
1216
+ | `mail` | the reply to MAIL FROM | 300 |
1217
+ | `rcpt` | the reply to each RCPT TO | 300 |
1218
+ | `dataStart` | the 354 | 120 |
1219
+ | `dataBlock` | the server to take each block of the message, and your stream to give the next one | 180 |
1220
+ | `dataEnd` | the reply to the final dot | 600 |
1221
+
1222
+ `deadline` (1800 seconds by default) bounds the whole delivery, every host
1223
+ and every DNS lookup together; each lookup is also bounded by the
1224
+ resolver's own timeout. Each value
1225
+ is a number of seconds above 0, fractions allowed, at most 2 147 483 (what
1226
+ `setTimeout` can wait). A slow server ends in `TIMEOUT`, which is
1227
+ temporary.
1228
+
1229
+ What a server sends is bounded too, whatever it sends: 2048 bytes a reply
1230
+ line, 100 lines a reply, 1 MiB of replies a session. Past any of them, or
1231
+ on a line that is not a reply, the client hangs up with `BAD_REPLY`. Each
1232
+ line is read in one pass, so no reply can make the client spin.
1233
+
1234
+ ### Trying it with Mailpit
1235
+
1236
+ [Mailpit](https://mailpit.axllent.org) is a mail server for tests: it keeps
1237
+ whatever it receives and shows it on a web page. Start it:
1238
+
1239
+ ```sh
1240
+ docker run --rm -p 8025:8025 -p 1025:1025 axllent/mailpit
1241
+ ```
1242
+
1243
+ Then send to port 1025 — it offers no STARTTLS and needs no AUTH — and open
1244
+ http://localhost:8025:
1245
+
1246
+ ```ts
1247
+ import { sendMail } from '@bumail/smtp/client';
1248
+
1249
+ await sendMail('From: a@example.com\r\nTo: b@example.org\r\nSubject: hi\r\n\r\nhello\r\n', {
1250
+ host: 'localhost',
1251
+ port: 1025,
1252
+ from: 'a@example.com',
1253
+ to: 'b@example.org',
1254
+ });
1255
+ ```
1256
+
1257
+ In the bumail repository, `packages/smtp/examples/try-mailpit.ts` does the
1258
+ same with a message signed by `@bumail/auth`'s `signDkim`, under a
1259
+ throwaway Ed25519 key it makes for the run, and prints the DNS record that
1260
+ would publish that key:
1261
+
1262
+ ```sh
1263
+ bun run build
1264
+ bun packages/smtp/examples/try-mailpit.ts --host localhost --port 1025 --from me@example.com --to you@example.org
1265
+ # or: MAILPIT_HOST=… MAILPIT_PORT=… MAIL_FROM=… MAIL_TO=… bun packages/smtp/examples/try-mailpit.ts
1266
+ ```
1267
+
1268
+ To try AUTH, start Mailpit with `--smtp-auth-accept-any
1269
+ --smtp-auth-allow-insecure` and pass `auth` with `allowPlaintextAuth:
1270
+ true`: Mailpit has no TLS by default, and that flag is for exactly this.
1271
+
893
1272
  ## Protocol helpers
894
1273
 
895
1274
  The pieces the server is built from are exported, for a test, a client, or
@@ -930,7 +1309,11 @@ console.log(chunk.done, reader.bareLineBreaks);
930
1309
 
931
1310
  - `parsePathCommand` and `parsePath` return `undefined` when the syntax is
932
1311
  wrong. `parsePath` takes UTF-8 addresses; whether a session may use one
933
- is SMTPUTF8's question.
1312
+ is SMTPUTF8's question. A control character or a `>` anywhere is wrong.
1313
+ Its third argument says what to do with a source route: `'discard'`, the
1314
+ default and what a server does, drops a valid `@domain(,@domain)*:`;
1315
+ `'refuse'`, what a client does, returns `undefined` for any route:
1316
+ `parsePath('<@a,@b:x@c.com>', false, 'refuse')` is `undefined`.
934
1317
  - `formatReply` replaces a CR or LF in the text with a space, so a reply
935
1318
  text cannot start a second reply.
936
1319
  - `DataReader.write(chunk)` returns the unstuffed bytes, whether the
@@ -957,6 +1340,9 @@ console.log(chunk.done, reader.bareLineBreaks);
957
1340
  | submission mode | RFC 6409 |
958
1341
  | `with ESMTPSA` in the Received field | RFC 3848 |
959
1342
  | the Received field's date | RFC 5322 §3.3 |
1343
+ | the client's MX lookup and its order | RFC 5321 §5.1 |
1344
+ | the client's null MX | RFC 7505 |
1345
+ | the client's opportunistic TLS | RFC 7435 |
960
1346
 
961
1347
  AUTH LOGIN has no RFC; it is the de facto mechanism most clients offer.
962
1348
  The specs next to each module are built on these RFCs' own examples —
@@ -970,7 +1356,15 @@ each one comes from.
970
1356
  conforming client does not send them.
971
1357
  - **CHUNKING** (RFC 3030). `BDAT` is answered `500 5.5.2 Command
972
1358
  unrecognized`; a client sends DATA instead.
973
- - **The client.** This package receives mail; it does not send it to other
974
- servers. Outbound delivery is on the [roadmap](roadmap.md).
1359
+ - **A queue.** The client tries a destination once and reports;
1360
+ retrying a temporary failure is the caller's job until `@bumail/queue`.
1361
+ - **Connection reuse.** The client opens one connection per message, and
1362
+ sends no `RSET`. Several messages to one destination over one session is
1363
+ on the [roadmap](roadmap.md).
1364
+ - **DSN in the client.** It sends no `RET`, `ENVID`, `NOTIFY` or `ORCPT`.
1365
+ - **CHUNKING in the client.** It sends DATA, never `BDAT`.
1366
+ - **DANE and MTA-STS.** By MX, TLS is opportunistic; a policy that makes
1367
+ it required for a domain is for later.
1368
+ - **Other SASL mechanisms in the client** — it speaks PLAIN and LOGIN.
975
1369
  - **Other SASL mechanisms** — CRAM-MD5, SCRAM, XOAUTH2 get `504 5.5.4`.
976
1370
  - **EXPN**, which gives away a list's members: `500 5.5.2`.
package/docs/roadmap.md CHANGED
@@ -6,18 +6,17 @@ number on it.
6
6
 
7
7
  ## Now
8
8
 
9
- Nothing in progress: the server below is merged and waits for its first
10
- release.
9
+ Nothing in progress.
11
10
 
12
11
  ## Next
13
12
 
14
- - **The client, on its own subpath** — outbound delivery: MX lookup,
15
- opportunistic STARTTLS, connection reuse per destination. In this
16
- package, beside the server, since both share the command and reply
17
- grammar.
13
+ - **Connection reuse in the client** — several messages to one
14
+ destination over one session (`RSET` between them), for a queue that
15
+ delivers in batches. Today each `sendMail` opens its own connection.
18
16
  - **DSN** (RFC 3461) — `RET`, `ENVID`, `NOTIFY` and `ORCPT` accepted and
19
17
  handed to the app, so a sender can ask for delivery status
20
- notifications. Today they are refused with `555 5.5.4`.
18
+ notifications. Today they are refused with `555 5.5.4`, and the client
19
+ sends none.
21
20
  - **Delivery into `@bumail/store`, ready-made** — an `onData` that puts
22
21
  each message in its recipients' inboxes, instead of the few lines the
23
22
  [guide](guide.md#delivering-into-bumailstore) shows today. The store
@@ -33,7 +32,43 @@ release.
33
32
 
34
33
  ## Shipped
35
34
 
36
- Merged, not yet published:
35
+ ### Unreleased — merged, not yet published
36
+
37
+ - **A client that stops reading no longer holds a connection slot.** When
38
+ the server hangs up on its own — the idle `timeout`, `maxErrors`, failed
39
+ AUTH, a refusal — it no longer waits for the client to read what is
40
+ queued: the connection is counted out at once, and what was never read
41
+ is dropped. Such a close used to wait forever, so enough clients that
42
+ pipelined commands and never read could fill `maxConnections`. The
43
+ same holds on implicit TLS and after STARTTLS, where a client that paused
44
+ and never answered the hang-up used to keep its slot even with nothing
45
+ queued. `QUIT` still sends its `221` whole before hanging up, within a
46
+ 5-second grace when the client does not read it.
47
+
48
+ - **`stop(true)` closes sessions moved to TLS by STARTTLS.** They used to
49
+ stay open, each holding its slot, because the listener no longer held
50
+ them. A hang-up on TLS right after queued replies left no longer drops
51
+ the end of them.
52
+
53
+ - **The client, as `@bumail/smtp/client`** — `sendMail(message, options)`
54
+ delivers one message to a host (a smarthost, submission on 587 or 465,
55
+ a local Mailpit) or to a domain's MX hosts by preference, through a
56
+ resolver — `@bumail/dns`'s, or any with `mx`, `a` and `aaaa` — the
57
+ domain's own address without MX, and a null MX refused; `helo` required
58
+ by MX, by the type itself. STARTTLS opportunistic by default for
59
+ MX, required — the certificate checked — with AUTH; implicit TLS; AUTH
60
+ PLAIN and LOGIN only once the certificate checked out; PIPELINING, SIZE, 8BITMIME and
61
+ SMTPUTF8; the message a string, bytes or a stream, dot-stuffed, a bare
62
+ CR or LF refused. It resolves with each recipient's reply, and rejects
63
+ with an `SmtpError` whose `temporary` tells a retry from a bounce. RFC
64
+ 5321's timeouts per step and a deadline; reply lines, replies and their
65
+ total bounded against a hostile server.
66
+
67
+ - **`hookTimeout` bounded to what a timer can wait.** A value past
68
+ 2 147 483 seconds is refused, where it used to fire after a millisecond
69
+ and time every hook out.
70
+
71
+ ### 0.1.0
37
72
 
38
73
  - **The server** — RFC 5321 on `Bun.listen`: EHLO with PIPELINING, SIZE,
39
74
  8BITMIME, SMTPUTF8 and ENHANCEDSTATUSCODES; STARTTLS and implicit TLS;