@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
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
 
@@ -866,10 +868,35 @@ Some limits are fixed: a command line is at most 2048 bytes (RFC 5321
866
868
  §4.5.3.1.4 asks for 512 at least); a session gets three AUTH attempts; and
867
869
  the server holds at most 64 KiB of a client's unread input, and of a
868
870
  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.
871
+ to a client that does not read them wait in the server while the connection
872
+ is open, none lost, and the server reads no further command until they went
873
+ out.
874
+
875
+ How the server hangs up depends on who decided. After `QUIT` it is
876
+ graceful: the `221` leaves whole, then the connection closes. When the
877
+ server decides — the idle `timeout`, `maxErrors`, three failed AUTH
878
+ attempts, a refusal from `onConnect`, a local error — it writes its reply
879
+ and closes at once: the connection is counted out of `connections` then and
880
+ there, and replies the client never read are dropped, the connection reset
881
+ if any were still waiting. So a client that pipelines commands and stops
882
+ reading cannot keep a slot of `maxConnections` past its `timeout`.
883
+
884
+ Every hang-up is bounded, on a clear socket, on implicit TLS and after
885
+ STARTTLS alike. When the server decides, it never waits for the client to
886
+ answer its hang-up: the connection is counted out at once, even when the
887
+ client has stopped reading. When nothing is left queued, a client that reads later
888
+ still gets the last reply, then the end; when replies are still queued, a
889
+ forced close drops them and resets the connection. A graceful
890
+ close — after `QUIT`, or a reply that still has to leave — waits 5 seconds
891
+ at most for what is queued to go out; past that, what is left is dropped
892
+ and the connection reset. On TLS, once such queued replies have left, the
893
+ server closes when the client answers, within the same 5 seconds: a
894
+ half-close right then could drop the end of what Bun still holds in its
895
+ TLS buffer. So a `221` behind replies that are never read holds the slot
896
+ 5 seconds, not until the `timeout`.
897
+
898
+ Stop a server with `stop()`; `stop(true)` also hangs up on every client,
899
+ those moved to TLS by STARTTLS included, as the idle timeout does.
873
900
  `connections` counts the clients currently connected:
874
901
 
875
902
  ```ts
@@ -890,6 +917,340 @@ process.on('SIGTERM', () => {
890
917
  });
891
918
  ```
892
919
 
920
+ ## Sending mail: the client
921
+
922
+ `@bumail/smtp/client` is the other side of the conversation: `sendMail`
923
+ delivers one message to one destination and tells you what became of each
924
+ recipient. It shares the server's grammar — paths, replies, dot-stuffing,
925
+ SASL — and imports nothing of `@bumail/dns`, not even its types: a
926
+ resolver is needed only for MX delivery, and you pass it in (install
927
+ `@bumail/dns` for one).
928
+
929
+ - [Destinations](#destinations)
930
+ - [The session it runs](#the-session-it-runs)
931
+ - [TLS](#tls-1)
932
+ - [AUTH](#auth)
933
+ - [The message](#the-message)
934
+ - [What it resolves to, and what it rejects with](#what-it-resolves-to-and-what-it-rejects-with)
935
+ - [Delivery by MX](#delivery-by-mx)
936
+ - [Timeouts and limits](#timeouts-and-limits)
937
+ - [Trying it with Mailpit](#trying-it-with-mailpit)
938
+
939
+ ### Destinations
940
+
941
+ ```ts
942
+ import { nodeResolver } from '@bumail/dns';
943
+ import { sendMail } from '@bumail/smtp/client';
944
+
945
+ const message = 'From: a@example.com\r\nTo: b@example.org\r\nSubject: hi\r\n\r\nhello\r\n';
946
+
947
+ // A host: a smarthost, a submission server, a local Mailpit. Port 25 by
948
+ // default, 465 with secure: true.
949
+ await sendMail(message, { host: 'relay.example.net', port: 25, from: 'a@example.com', to: 'b@example.org' });
950
+
951
+ // A domain: its MX hosts, by preference, through the resolver. Port 25,
952
+ // and helo is required: your server's public name.
953
+ await sendMail(message, { domain: 'example.org', resolver: nodeResolver(), helo: 'mail.example.com', from: 'a@example.com', to: 'b@example.org' });
954
+ ```
955
+
956
+ `from` is the envelope's reverse-path — `''` for the null sender of a
957
+ bounce — and `to` one recipient or more. Both are checked as RFC 5321 paths
958
+ before anything is sent: an address holding a CR, an LF or a `>` is refused
959
+ with `INVALID_OPTION`, so it cannot inject a command.
960
+
961
+ One call is one destination. With `{ domain }`, every recipient should be
962
+ at that domain: group them by domain first, one `sendMail` each.
963
+
964
+ ### The session it runs
965
+
966
+ | step | what the client sends | what it needs |
967
+ | --- | --- | --- |
968
+ | greeting | — | `220`; anything else is `REFUSED` |
969
+ | hello | `EHLO <helo>` | `250`; a 5xx gets `HELO <helo>` instead (RFC 5321 §4.1.4), a 4xx is `REFUSED` |
970
+ | TLS | `STARTTLS`, the handshake, `EHLO` again (RFC 3207 §4.2) | see [TLS](#tls-1) |
971
+ | AUTH | `AUTH PLAIN <initial>`, or `AUTH LOGIN` and two 334 steps | `235` |
972
+ | 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 |
973
+ | content | `DATA`, the message dot-stuffed, `.` | `354`, then `250` |
974
+ | end | `QUIT` | the `221` is waited for 5 seconds at most |
975
+
976
+ - `helo` is the name this client gives: your server's public name, the
977
+ one its address resolves back to. By MX it is required — an MX may
978
+ refuse or penalise a name that does not resolve back to your address,
979
+ and the machine's own name (`laptop.local`) is rarely one and would leak
980
+ it — so `MxDestination` declares `helo: string`, and `{ domain }`
981
+ without it does not compile; from JavaScript it is `INVALID_OPTION`. To a host
982
+ (`{ host }`: a smarthost, Mailpit) it defaults to the machine's host
983
+ name.
984
+ - `SIZE=` is sent when the server offers SIZE and the size is known: a
985
+ string or bytes, or `size` for a stream. A message larger than the
986
+ server's SIZE is refused before MAIL FROM, with `MESSAGE_TOO_BIG`.
987
+ - `BODY=8BITMIME` is sent whenever the server offers 8BITMIME (RFC 6152).
988
+ Without it, a message with a byte above 127 fails with
989
+ `EXTENSION_MISSING`: before MAIL FROM for a string or bytes, and by
990
+ hanging up before the final dot for a stream.
991
+ - `SMTPUTF8` is sent when an address is not ASCII, or `smtputf8: true` asks
992
+ for it (for UTF-8 header fields). A server without it fails the delivery
993
+ with `EXTENSION_MISSING` (RFC 6531 §3.2).
994
+ - With PIPELINING (RFC 2920), MAIL FROM and every RCPT TO go in one write,
995
+ and the replies are read in order. Without it, each waits for its reply.
996
+ DATA always waits: if every recipient was refused, no content is sent.
997
+ - A server that refuses the message during DATA (a `552` before the dot)
998
+ and hangs up is reported by its reply, `REFUSED`, not as a lost
999
+ connection.
1000
+ - After a refusal, the client says `QUIT` and hangs up. A message cut short —
1001
+ a stream that errors, a bare line break, a timeout — is never ended with
1002
+ the dot: the client hangs up, and the server drops what it had.
1003
+
1004
+ ### TLS
1005
+
1006
+ | `tls` | STARTTLS | the certificate | default when |
1007
+ | --- | --- | --- | --- |
1008
+ | `'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 |
1009
+ | `'required'` | needed: `TLS_UNAVAILABLE` without it | checked against `host`, or the MX host's name: `TLS_FAILED` | `auth` or `secure` is given |
1010
+ | `'none'` | never | — | never |
1011
+
1012
+ `secure: true` is TLS from the first byte (RFC 8314, port 465): no
1013
+ STARTTLS, and `tls` cannot be `'none'`. `ca` adds certificates to trust,
1014
+ PEM, besides the system's: a private CA, or a test's self-signed one.
1015
+
1016
+ ```ts
1017
+ import { sendMail } from '@bumail/smtp/client';
1018
+
1019
+ const result = await sendMail('Subject: hi\r\n\r\nhello\r\n', {
1020
+ host: 'mail.internal.example',
1021
+ port: 587,
1022
+ tls: 'required',
1023
+ ca: await Bun.file('/etc/ssl/internal-ca.pem').text(),
1024
+ from: 'app@example.com',
1025
+ to: 'ops@example.com',
1026
+ });
1027
+ console.log(result.tls); // { verified: true }
1028
+ ```
1029
+
1030
+ `result.tls` is `false` in clear, else `{ verified }`: `true` when the
1031
+ certificate checked out against the host name. With `'opportunistic'` it
1032
+ says so too, but a certificate that does not check out is used all the
1033
+ same — that is what opportunistic means. An opportunistic handshake that
1034
+ fails is not retried in clear: it is `TLS_FAILED`, temporary, and by MX the
1035
+ next host is tried.
1036
+
1037
+ Anything the server sends after its `220` to STARTTLS and before the
1038
+ handshake is refused with `BAD_REPLY`: it would be read as if it came over
1039
+ TLS (CVE-2011-0411).
1040
+
1041
+ ### AUTH
1042
+
1043
+ `auth: { username, password }` uses PLAIN when the server offers it, else
1044
+ LOGIN; `mechanism` picks one. The credentials go out only over TLS whose
1045
+ certificate checked out, unless `allowPlaintextAuth` says otherwise. `auth` makes `tls: 'required'` the default, so a server
1046
+ that does not offer STARTTLS fails earlier, with `TLS_UNAVAILABLE`.
1047
+
1048
+ `allowPlaintextAuth: true` lets AUTH go over a clear connection. It is for
1049
+ a local test server, such as Mailpit with `--smtp-auth-allow-insecure`:
1050
+ the password crosses the network in base64, which anyone on the path reads.
1051
+ Never set it for a server elsewhere. With it, `tls` defaults to
1052
+ `'opportunistic'`.
1053
+
1054
+ Without it, `auth` goes only with `tls: 'required'`: `tls: 'none'` would
1055
+ send the password in clear, and `tls: 'opportunistic'` to a server whose
1056
+ certificate is not checked, which an active attacker can intercept. Both
1057
+ are refused with `INVALID_OPTION` before connecting. Leave `tls` out to
1058
+ have the certificate checked, and pass `ca` for a private CA.
1059
+
1060
+ ### The message
1061
+
1062
+ A `string` (sent as UTF-8), a `Uint8Array`, or a `ReadableStream` of
1063
+ `Uint8Array` chunks: the whole RFC 5322 message, header fields first, lines
1064
+ ending in CRLF. A stream is read as the server takes it, one chunk at a
1065
+ time, so a large message never sits in memory whole.
1066
+
1067
+ - **Dot-stuffing** (RFC 5321 §4.5.2): a line starting with `.` gets one
1068
+ more; the server takes it off. A message whose last line has no CRLF gets
1069
+ one, since the terminator needs it.
1070
+ - **Bare CR or LF**: refused with `BARE_LINE_BREAK`, as the server refuses
1071
+ it (SMTP smuggling). A string or bytes are checked before connecting; a
1072
+ stream as it is sent, and the client hangs up before the dot.
1073
+ `normalizeLineEnds: true` turns each bare CR or LF into CRLF instead.
1074
+ - **Signing**: put the `DKIM-Signature` field `@bumail/auth`'s `signDkim`
1075
+ gives on top of the message before sending it. `sendMail` sends the
1076
+ bytes as they are, so a signature over them still verifies.
1077
+
1078
+ ### What it resolves to, and what it rejects with
1079
+
1080
+ `sendMail` resolves once the server took the message for one recipient at
1081
+ least:
1082
+
1083
+ ```ts
1084
+ import type { Reply } from '@bumail/smtp/client';
1085
+
1086
+ interface SendMailResult {
1087
+ accepted: { recipient: string; reply: Reply }[]; // 250 or 251 to RCPT TO
1088
+ rejected: { recipient: string; reply: Reply }[]; // any other reply
1089
+ reply: Reply; // to the final dot: { code: 250, status: '2.0.0', text: 'OK queued as …' }
1090
+ host: string; // the host, or the MX host, that took it
1091
+ port: number;
1092
+ tls: false | { verified: boolean };
1093
+ authenticated: boolean;
1094
+ }
1095
+ ```
1096
+
1097
+ A rejected recipient with a 4xx is worth trying again later; with a 5xx it
1098
+ is not. Every other outcome rejects with an `SmtpError`: its `code`, its
1099
+ `message` (each is in [troubleshooting](troubleshooting.md#sending-mail-options)),
1100
+ `temporary`, and, when a reply caused it, `reply` with its enhanced status
1101
+ code (RFC 3463).
1102
+
1103
+ | `code` | when | `temporary` |
1104
+ | --- | --- | --- |
1105
+ | `INVALID_OPTION` | an option, an address or the message cannot be used | no |
1106
+ | `BARE_LINE_BREAK` | the message holds a bare CR or LF | no |
1107
+ | `MESSAGE_TOO_BIG` | larger than the server's SIZE | no |
1108
+ | `EXTENSION_MISSING` | the message needs SMTPUTF8 or 8BITMIME, and the server lacks it | no |
1109
+ | `AUTH_UNAVAILABLE` | credentials and no TLS, or no mechanism in common | no |
1110
+ | `NULL_MX` | the domain's MX is the null MX (RFC 7505) | no |
1111
+ | `DNS_FAILED` | the MX or address lookup failed | yes, unless the DNS said there is no such name |
1112
+ | `REFUSED` | a 4xx or 5xx to the greeting, EHLO, AUTH, MAIL FROM, DATA or the dot | a 4xx yes, a 5xx no |
1113
+ | `RECIPIENTS_REFUSED` | every recipient refused; `rejected` lists them | when one of the refusals is a 4xx |
1114
+ | `CONNECTION_FAILED` | no TCP connection | yes |
1115
+ | `CONNECTION_LOST` | the server hung up half-way | yes |
1116
+ | `TIMEOUT` | a step's timeout, or the deadline | yes |
1117
+ | `BAD_REPLY` | what the server sent is not an SMTP reply, or is too long | yes |
1118
+ | `TLS_UNAVAILABLE` | `tls: 'required'` and no STARTTLS | yes |
1119
+ | `TLS_FAILED` | the handshake failed, or the certificate did not check out | yes |
1120
+
1121
+ A queue retries what is `temporary` and bounces what is not. This client is
1122
+ not one: it tries once and reports.
1123
+
1124
+ ```ts
1125
+ import { SmtpError, sendMail } from '@bumail/smtp/client';
1126
+
1127
+ try {
1128
+ await sendMail('Subject: hi\r\n\r\nhello\r\n', { host: 'relay.example.net', from: 'a@example.com', to: 'b@example.org' });
1129
+ } catch (error) {
1130
+ if (!(error instanceof SmtpError)) throw error; // your own stream's error comes through as it is
1131
+ if (error.temporary) console.log('defer', error.code, error.reply?.status);
1132
+ else console.log('bounce', error.message);
1133
+ }
1134
+ ```
1135
+
1136
+ ### Delivery by MX
1137
+
1138
+ With `{ domain, resolver }`, `resolveMx` gives the hosts in the order to
1139
+ try them (RFC 5321 §5.1):
1140
+
1141
+ - the MX records, the lowest preference first; equal ones in a random
1142
+ order, to spread the load;
1143
+ - no MX record (the DNS said `NOT_FOUND`): the domain itself, the implicit
1144
+ MX;
1145
+ - a null MX (`0 .`, RFC 7505): `NULL_MX` at once, no fallback to the
1146
+ domain's address;
1147
+ - an MX lookup that failed: `DNS_FAILED`, temporary.
1148
+
1149
+ Each host's addresses come from the resolver too (A, then AAAA), and the
1150
+ client connects to each address in turn, with TLS checked against the MX
1151
+ host's name. It moves on after a failure that is temporary and came before
1152
+ MAIL FROM — no connection, a dropped connection, a `421` greeting, a 4xx to
1153
+ EHLO, no STARTTLS when it is required, a TLS failure — and stops at a 5xx
1154
+ or once MAIL FROM was sent. It tries 10 addresses at most, and looks up
1155
+ 10 hosts at most — a host with no address counts too — so a domain with
1156
+ hundreds of MX records costs no more than 10 hosts' lookups. Every lookup,
1157
+ the MX one included, runs under `deadline`. When every host
1158
+ failed, it rejects with the last failure; when none had an address, with
1159
+ `DNS_FAILED`.
1160
+
1161
+ ```ts
1162
+ import { fixtureResolver } from '@bumail/dns';
1163
+ import { resolveMx } from '@bumail/smtp/client';
1164
+
1165
+ const resolver = fixtureResolver({
1166
+ 'example.org': {
1167
+ mx: [
1168
+ { exchange: 'mx2.example.org', priority: 20 },
1169
+ { exchange: 'mx1.example.org', priority: 10 },
1170
+ ],
1171
+ },
1172
+ });
1173
+ console.log(await resolveMx('example.org', resolver));
1174
+ // [{ host: 'mx1.example.org', priority: 10, implicit: false },
1175
+ // { host: 'mx2.example.org', priority: 20, implicit: false }]
1176
+ ```
1177
+
1178
+ Install `@bumail/dns` for MX delivery and pass one of its resolvers, or
1179
+ pass any object with `mx`, `a` and `aaaa` (`MxResolver`, declared by shape
1180
+ in this package, so its types never need `@bumail/dns`). A failure is
1181
+ read by its `DnsError` shape (`name` and `code`), as `@bumail/dns`'s
1182
+ `isTemporary` reads it: `TEMPORARY`, `TIMEOUT`, and anything that is not
1183
+ a `DnsError` are temporary. A resolver that answers with an empty array
1184
+ rather than `NOT_FOUND` is read the same way: no MX is the implicit MX, no
1185
+ address is a permanent `DNS_FAILED`.
1186
+
1187
+ ### Timeouts and limits
1188
+
1189
+ Every wait has a timeout, RFC 5321 §4.5.3.2's by default, in seconds. It
1190
+ is the whole wait — from the command to the last line of its reply — so a
1191
+ server that trickles a line at a time does not stretch it:
1192
+
1193
+ | `timeouts.` | waits for | default |
1194
+ | --- | --- | --- |
1195
+ | `connect` | the TCP connection, and each TLS handshake | 30 |
1196
+ | `greeting` | the 220 | 300 |
1197
+ | `command` | the replies to EHLO, HELO, STARTTLS, AUTH | 300 |
1198
+ | `mail` | the reply to MAIL FROM | 300 |
1199
+ | `rcpt` | the reply to each RCPT TO | 300 |
1200
+ | `dataStart` | the 354 | 120 |
1201
+ | `dataBlock` | the server to take each block of the message, and your stream to give the next one | 180 |
1202
+ | `dataEnd` | the reply to the final dot | 600 |
1203
+
1204
+ `deadline` (1800 seconds by default) bounds the whole delivery, every host
1205
+ and every DNS lookup together; each lookup is also bounded by the
1206
+ resolver's own timeout. Each value
1207
+ is a number of seconds above 0, fractions allowed, at most 2 147 483 (what
1208
+ `setTimeout` can wait). A slow server ends in `TIMEOUT`, which is
1209
+ temporary.
1210
+
1211
+ What a server sends is bounded too, whatever it sends: 2048 bytes a reply
1212
+ line, 100 lines a reply, 1 MiB of replies a session. Past any of them, or
1213
+ on a line that is not a reply, the client hangs up with `BAD_REPLY`. Each
1214
+ line is read in one pass, so no reply can make the client spin.
1215
+
1216
+ ### Trying it with Mailpit
1217
+
1218
+ [Mailpit](https://mailpit.axllent.org) is a mail server for tests: it keeps
1219
+ whatever it receives and shows it on a web page. Start it:
1220
+
1221
+ ```sh
1222
+ docker run --rm -p 8025:8025 -p 1025:1025 axllent/mailpit
1223
+ ```
1224
+
1225
+ Then send to port 1025 — it offers no STARTTLS and needs no AUTH — and open
1226
+ http://localhost:8025:
1227
+
1228
+ ```ts
1229
+ import { sendMail } from '@bumail/smtp/client';
1230
+
1231
+ await sendMail('From: a@example.com\r\nTo: b@example.org\r\nSubject: hi\r\n\r\nhello\r\n', {
1232
+ host: 'localhost',
1233
+ port: 1025,
1234
+ from: 'a@example.com',
1235
+ to: 'b@example.org',
1236
+ });
1237
+ ```
1238
+
1239
+ In the bumail repository, `packages/smtp/examples/try-mailpit.ts` does the
1240
+ same with a message signed by `@bumail/auth`'s `signDkim`, under a
1241
+ throwaway Ed25519 key it makes for the run, and prints the DNS record that
1242
+ would publish that key:
1243
+
1244
+ ```sh
1245
+ bun run build
1246
+ bun packages/smtp/examples/try-mailpit.ts --host localhost --port 1025 --from me@example.com --to you@example.org
1247
+ # or: MAILPIT_HOST=… MAILPIT_PORT=… MAIL_FROM=… MAIL_TO=… bun packages/smtp/examples/try-mailpit.ts
1248
+ ```
1249
+
1250
+ To try AUTH, start Mailpit with `--smtp-auth-accept-any
1251
+ --smtp-auth-allow-insecure` and pass `auth` with `allowPlaintextAuth:
1252
+ true`: Mailpit has no TLS by default, and that flag is for exactly this.
1253
+
893
1254
  ## Protocol helpers
894
1255
 
895
1256
  The pieces the server is built from are exported, for a test, a client, or
@@ -957,6 +1318,9 @@ console.log(chunk.done, reader.bareLineBreaks);
957
1318
  | submission mode | RFC 6409 |
958
1319
  | `with ESMTPSA` in the Received field | RFC 3848 |
959
1320
  | the Received field's date | RFC 5322 §3.3 |
1321
+ | the client's MX lookup and its order | RFC 5321 §5.1 |
1322
+ | the client's null MX | RFC 7505 |
1323
+ | the client's opportunistic TLS | RFC 7435 |
960
1324
 
961
1325
  AUTH LOGIN has no RFC; it is the de facto mechanism most clients offer.
962
1326
  The specs next to each module are built on these RFCs' own examples —
@@ -970,7 +1334,15 @@ each one comes from.
970
1334
  conforming client does not send them.
971
1335
  - **CHUNKING** (RFC 3030). `BDAT` is answered `500 5.5.2 Command
972
1336
  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).
1337
+ - **A queue.** The client tries a destination once and reports;
1338
+ retrying a temporary failure is the caller's job until `@bumail/queue`.
1339
+ - **Connection reuse.** The client opens one connection per message, and
1340
+ sends no `RSET`. Several messages to one destination over one session is
1341
+ on the [roadmap](roadmap.md).
1342
+ - **DSN in the client.** It sends no `RET`, `ENVID`, `NOTIFY` or `ORCPT`.
1343
+ - **CHUNKING in the client.** It sends DATA, never `BDAT`.
1344
+ - **DANE and MTA-STS.** By MX, TLS is opportunistic; a policy that makes
1345
+ it required for a domain is for later.
1346
+ - **Other SASL mechanisms in the client** — it speaks PLAIN and LOGIN.
975
1347
  - **Other SASL mechanisms** — CRAM-MD5, SCRAM, XOAUTH2 get `504 5.5.4`.
976
1348
  - **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;