@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.
- package/README.md +148 -6
- package/dist/chunks/index-2ybj2z1w.js +216 -0
- package/dist/chunks/index-2ybj2z1w.js.map +14 -0
- package/dist/client/content.d.ts +25 -0
- package/dist/client/content.d.ts.map +1 -0
- package/dist/client/inbox.d.ts +18 -0
- package/dist/client/inbox.d.ts.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +945 -0
- package/dist/client/index.js.map +22 -0
- package/dist/client/mx.d.ts +29 -0
- package/dist/client/mx.d.ts.map +1 -0
- package/dist/client/options.d.ts +136 -0
- package/dist/client/options.d.ts.map +1 -0
- package/dist/client/refusal.d.ts +15 -0
- package/dist/client/refusal.d.ts.map +1 -0
- package/dist/client/send.d.ts +13 -0
- package/dist/client/send.d.ts.map +1 -0
- package/dist/client/session.d.ts +11 -0
- package/dist/client/session.d.ts.map +1 -0
- package/dist/client/settings.d.ts +30 -0
- package/dist/client/settings.d.ts.map +1 -0
- package/dist/client/socket.d.ts +31 -0
- package/dist/client/socket.d.ts.map +1 -0
- package/dist/client/target.d.ts +29 -0
- package/dist/client/target.d.ts.map +1 -0
- package/dist/client/transaction.d.ts +15 -0
- package/dist/client/transaction.d.ts.map +1 -0
- package/dist/errors.d.ts +28 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.js +115 -196
- package/dist/index.js.map +9 -13
- package/dist/io/outgoing.d.ts +21 -0
- package/dist/io/outgoing.d.ts.map +1 -0
- package/dist/protocol/data-writer.d.ts +22 -0
- package/dist/protocol/data-writer.d.ts.map +1 -0
- package/dist/protocol/ehlo.d.ts +7 -0
- package/dist/protocol/ehlo.d.ts.map +1 -0
- package/dist/{server → protocol}/lines.d.ts +9 -4
- package/dist/protocol/lines.d.ts.map +1 -0
- package/dist/protocol/reply-reader.d.ts +19 -0
- package/dist/protocol/reply-reader.d.ts.map +1 -0
- package/dist/protocol/sasl.d.ts +4 -0
- package/dist/protocol/sasl.d.ts.map +1 -1
- package/dist/server/connection.d.ts +8 -0
- package/dist/server/connection.d.ts.map +1 -1
- package/dist/server/input.d.ts.map +1 -1
- package/dist/server/options.d.ts +2 -1
- package/dist/server/options.d.ts.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/settings.d.ts.map +1 -1
- package/dist/server/transport.d.ts +21 -4
- package/dist/server/transport.d.ts.map +1 -1
- package/docs/README.md +3 -3
- package/docs/guide.md +381 -9
- package/docs/roadmap.md +43 -8
- package/docs/troubleshooting.md +668 -7
- package/package.json +12 -4
- 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,
|
|
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
|
|
870
|
-
no further command until they went
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
- **
|
|
974
|
-
|
|
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
|
|
10
|
-
release.
|
|
9
|
+
Nothing in progress.
|
|
11
10
|
|
|
12
11
|
## Next
|
|
13
12
|
|
|
14
|
-
- **
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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;
|