@bumail/smtp 0.3.0 → 0.5.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 (52) hide show
  1. package/README.md +105 -7
  2. package/dist/chunks/{index-3423jxf9.js → index-n4xwep5m.js} +10 -4
  3. package/dist/chunks/index-n4xwep5m.js.map +14 -0
  4. package/dist/client/index.js +1 -1
  5. package/dist/errors.d.ts +3 -1
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +724 -126
  10. package/dist/index.js.map +19 -9
  11. package/dist/protocol/command.d.ts +4 -2
  12. package/dist/protocol/command.d.ts.map +1 -1
  13. package/dist/protocol/path.d.ts +13 -2
  14. package/dist/protocol/path.d.ts.map +1 -1
  15. package/dist/server/admission.d.ts +29 -0
  16. package/dist/server/admission.d.ts.map +1 -0
  17. package/dist/server/client-key.d.ts +15 -0
  18. package/dist/server/client-key.d.ts.map +1 -0
  19. package/dist/server/listener.d.ts +46 -0
  20. package/dist/server/listener.d.ts.map +1 -0
  21. package/dist/server/options.d.ts +45 -1
  22. package/dist/server/options.d.ts.map +1 -1
  23. package/dist/server/proxy/address.d.ts +17 -0
  24. package/dist/server/proxy/address.d.ts.map +1 -0
  25. package/dist/server/proxy/header.d.ts +26 -0
  26. package/dist/server/proxy/header.d.ts.map +1 -0
  27. package/dist/server/proxy/reader.d.ts +15 -0
  28. package/dist/server/proxy/reader.d.ts.map +1 -0
  29. package/dist/server/proxy/tls.d.ts +50 -0
  30. package/dist/server/proxy/tls.d.ts.map +1 -0
  31. package/dist/server/proxy/trusted.d.ts +9 -0
  32. package/dist/server/proxy/trusted.d.ts.map +1 -0
  33. package/dist/server/raw-socket.d.ts +9 -0
  34. package/dist/server/raw-socket.d.ts.map +1 -0
  35. package/dist/server/received.d.ts +2 -0
  36. package/dist/server/received.d.ts.map +1 -1
  37. package/dist/server/server.d.ts.map +1 -1
  38. package/dist/server/settings.d.ts +4 -0
  39. package/dist/server/settings.d.ts.map +1 -1
  40. package/dist/server/slots.d.ts +14 -0
  41. package/dist/server/slots.d.ts.map +1 -0
  42. package/dist/server/tls-context.d.ts +16 -0
  43. package/dist/server/tls-context.d.ts.map +1 -0
  44. package/dist/server/transaction.d.ts.map +1 -1
  45. package/dist/server/transport.d.ts +2 -2
  46. package/dist/server/transport.d.ts.map +1 -1
  47. package/docs/README.md +2 -2
  48. package/docs/guide.md +472 -12
  49. package/docs/roadmap.md +57 -0
  50. package/docs/troubleshooting.md +406 -4
  51. package/package.json +2 -2
  52. package/dist/chunks/index-3423jxf9.js.map +0 -14
package/docs/guide.md CHANGED
@@ -1,19 +1,22 @@
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, sending mail
5
- with the client, and the RFCs behind each behaviour.
4
+ replies, the hooks, what a delivered message looks like, TLS, running
5
+ behind a TCP proxy, sending mail with the client, and the RFCs behind each
6
+ behaviour.
6
7
 
7
8
  - [The smallest server](#the-smallest-server)
8
9
  - [Options](#options)
9
10
  - [The session, and what each reply means](#the-session-and-what-each-reply-means)
10
11
  - [Hooks, and their order](#hooks-and-their-order)
12
+ - [`RCPT TO:<postmaster>`](#rcpt-topostmaster)
11
13
  - [Session.data](#sessiondata)
12
14
  - [What onData receives](#what-ondata-receives)
13
15
  - [Delivering into @bumail/store](#delivering-into-bumailstore)
14
16
  - [Authentication](#authentication)
15
17
  - [TLS](#tls)
16
18
  - [Limits](#limits)
19
+ - [Running behind a TCP proxy](#running-behind-a-tcp-proxy)
17
20
  - [Sending mail: the client](#sending-mail-the-client)
18
21
  - [Protocol helpers](#protocol-helpers)
19
22
  - [The RFCs implemented](#the-rfcs-implemented)
@@ -67,6 +70,9 @@ export interface SmtpServer {
67
70
  | `maxMessageSize` | `number` | 25 MiB | bytes; announced with SIZE |
68
71
  | `maxRecipients` | `number` | `100` | recipients per message |
69
72
  | `maxConnections` | `number` | `1000` | open connections at once |
73
+ | `maxConnectionsPerClient` | `number` | `10` | open connections at once from one client: an IPv4 address, or an IPv6 /64; see [Limits](#limits) |
74
+ | `handshakeTimeout` | `number` | `10` | seconds a client on implicit TLS has to complete its handshake, from the TCP connection on; past it, the socket is closed. See [TLS](#tls). With `proxyProtocol`, also the seconds a trusted proxy has to send its PROXY header; see [Running behind a TCP proxy](#running-behind-a-tcp-proxy) |
75
+ | `proxyProtocol` | `{ trusted: string[] }` | none, off | reads the PROXY protocol, v1 and v2, from the peers `trusted` lists (IPv4 and IPv6 addresses and CIDRs), so the client's address replaces the proxy's; see [Running behind a TCP proxy](#running-behind-a-tcp-proxy) |
70
76
  | `maxErrors` | `number` | `10` | failed commands before the server hangs up |
71
77
  | `timeout` | `number` | `300` | seconds since the client's last byte, or since the 220, before the server hangs up |
72
78
  | `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 |
@@ -112,9 +118,16 @@ await server.listen({ port: 25 });
112
118
  | `createSmtpServer(): hookTimeout must be at most 2147483 seconds, not <value>` | `hookTimeout` past what `setTimeout` can wait (about 24.8 days) |
113
119
  | `createSmtpServer(): greetingDelay must be a number of seconds, 0 or more, not <value>` | `greetingDelay` negative, `NaN` or `Infinity` |
114
120
  | `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 |
121
+ | `createSmtpServer(): proxyProtocol.trusted must list the addresses or CIDRs of the proxies, at least one` | `proxyProtocol` without `trusted`, with an empty one, or one that is not an array |
122
+ | `createSmtpServer(): proxyProtocol.trusted: "<entry>" is neither an IP address nor a CIDR` | an entry that is a host name, a malformed address, an address with a zone (`fe80::1%eth0`), or has more than one `/` |
123
+ | `createSmtpServer(): proxyProtocol.trusted: "<entry>" has a prefix length out of range` | a prefix past 32 for IPv4 or 128 for IPv6, not a number, or below 96 for an IPv4-mapped address (`::ffff:10.0.0.0/8`) |
124
+ | `createSmtpServer(): proxyProtocol.trusted: "<entry>" has a prefix length of 0, which trusts every peer` | `0.0.0.0/0`, `::/0`, `::ffff:0.0.0.0/96`: the option would trust anyone |
125
+ | `createSmtpServer(): proxyProtocol.trusted: <value> is not a string` | an entry that is a number, `null`, an object |
115
126
 
116
127
  `listen` a second time throws an `SmtpError` with `code:
117
- 'ALREADY_LISTENING'`: create another server for another port.
128
+ 'ALREADY_LISTENING'`: create another server for another port. A `stop()`
129
+ called before `listen` resolved makes that `listen` reject with
130
+ `code: 'STOPPED'`, and nothing is left listening.
118
131
 
119
132
  What to do about each is in [Troubleshooting](troubleshooting.md).
120
133
 
@@ -166,7 +179,7 @@ Every reply the server sends of its own:
166
179
  | `250 <hostname> greets <name>` | EHLO or HELO accepted; either one also ends a transaction in progress |
167
180
  | `501 Syntax: EHLO hostname` / `501 Syntax: HELO hostname` | the EHLO or HELO argument is missing, or is not a domain or an address literal; no enhanced code before an EHLO was accepted, `501 5.5.4 Syntax: EHLO hostname` after one |
168
181
  | `250 2.1.0 OK` | MAIL FROM accepted |
169
- | `250 2.1.5 OK` | RCPT TO accepted |
182
+ | `250 2.1.5 OK` | RCPT TO accepted; `RCPT TO:<postmaster>` with no domain is accepted without AUTH (see [below](#rcpt-topostmaster)) |
170
183
  | `354 End data with <CR><LF>.<CR><LF>` | DATA: send the message |
171
184
  | `250 2.0.0 OK queued as <id>` | the message was taken: `onData` read its content stream to the clean end and resolved without refusing |
172
185
  | `250 2.0.0 OK` | RSET (the transaction is forgotten) or NOOP |
@@ -193,6 +206,7 @@ Every reply the server sends of its own:
193
206
  | `500 5.5.2 Command unrecognized` | an unknown verb — EXPN, BDAT and the rest |
194
207
  | `500 5.5.6 Line too long` | a command line over 2048 bytes; the rest of it is skipped |
195
208
  | `421 4.3.2 <hostname> Too many connections, try later` | a connection past `maxConnections`, then the server hangs up |
209
+ | `421 4.7.0 <hostname> Too many connections from your address, try later` | a connection past `maxConnectionsPerClient` from one client, then the server hangs up |
196
210
  | `421 4.4.2 <hostname> Idle too long, closing` | nothing for `timeout` seconds |
197
211
  | `421 4.7.0 <hostname> Too many errors, closing` | `maxErrors` failed commands |
198
212
  | `421 4.3.0 Local error, closing` | the server failed itself |
@@ -234,7 +248,7 @@ In the order a session meets them:
234
248
 
235
249
  | Hook | Runs | After the server checked | A refusal |
236
250
  | --- | --- | --- | --- |
237
- | `onConnect` | when a client connects, before the greeting | the connection count | replaces the greeting, at once even with `greetingDelay`; then the server hangs up. When `onConnect` accepts, a client that talked before the greeting is refused with `554` |
251
+ | `onConnect` | when a client connects, before the greeting | the connection counts: `maxConnections`, then `maxConnectionsPerClient` | replaces the greeting, at once even with `greetingDelay`; then the server hangs up. When `onConnect` accepts, a client that talked before the greeting is refused with `554` |
238
252
  | `authenticate` | at AUTH | TLS, EHLO, no transaction in progress | see [Authentication](#authentication) |
239
253
  | `onMailFrom` | at each MAIL FROM | EHLO, AUTH in `submission`, syntax, parameters, SIZE, SMTPUTF8 | refuses the sender; the client may try another MAIL |
240
254
  | `onRcptTo` | at each RCPT TO | MAIL, syntax, SMTPUTF8, `maxRecipients`, **relaying** | refuses that recipient; the others stand |
@@ -285,12 +299,14 @@ await server.listen({ port: 25 });
285
299
 
286
300
  ```ts
287
301
  export interface Path {
288
- /** `local@domain`; `''` for the null reverse-path `<>`: a bounce. */
302
+ /** `local@domain`; `''` for the null reverse-path `<>`: a bounce; `'postmaster'` for `RCPT TO:<postmaster>`. */
289
303
  readonly address: string;
290
304
  /** As sent: case is kept. */
291
305
  readonly local: string;
292
- /** Lower case. */
306
+ /** Lower case; `''` for `<>` and `<postmaster>`. */
293
307
  readonly domain: string;
308
+ /** `true` for `RCPT TO:<postmaster>` with no domain; absent otherwise. */
309
+ readonly postmaster?: true;
294
310
  }
295
311
  ```
296
312
 
@@ -309,6 +325,49 @@ surrogates are refused anywhere in a path — the route, the local part
309
325
  quoted or not, the domain — even under SMTPUTF8. An IPv4 address literal
310
326
  takes octets up to 255: `[999.1.1.1]` is not one.
311
327
 
328
+ ## `RCPT TO:<postmaster>`
329
+
330
+ RFC 5321 §4.5.1 says every server that delivers mail must take mail for
331
+ `postmaster`, and §4.1.1.3 lets RCPT TO name it with no domain at all:
332
+ `RCPT TO:<Postmaster>`. The server takes it, in any case, as the
333
+ postmaster of this server, whatever domains it hosts:
334
+
335
+ - it is not relaying, so it needs no AUTH, and `localDomains` is not asked;
336
+ - `onRcptTo` is asked, as for any recipient, with the `Path`
337
+ `{ address: 'postmaster', local: 'postmaster', domain: '', postmaster: true }`,
338
+ so it can still refuse;
339
+ - `envelope.to` lists it as `'postmaster'`, with no `@`, and the Received
340
+ field says `for <postmaster>` when it is the only recipient.
341
+
342
+ `<postmaster@example.com>` is an ordinary path: it is checked against
343
+ `localDomains` like any other, and has no `postmaster` field. A path the
344
+ grammar would otherwise refuse stays refused: `<postmaster@>`,
345
+ `<"postmaster">` and `MAIL FROM:<postmaster>` get `501 5.5.4`.
346
+
347
+ Route it to a mailbox you keep: by `path.postmaster` in `onRcptTo`, by the
348
+ bare `'postmaster'` in `onData`.
349
+
350
+ ```ts
351
+ import { createSmtpServer, reply } from '@bumail/smtp';
352
+
353
+ const mailboxes = new Set(['alice@example.com', 'postmaster@example.com']);
354
+ const postmaster = 'postmaster@example.com';
355
+
356
+ const server = createSmtpServer({
357
+ hostname: 'mx.example.com',
358
+ localDomains: ['example.com'],
359
+ onRcptTo: (path) =>
360
+ path.postmaster || mailboxes.has(path.address.toLowerCase())
361
+ ? undefined
362
+ : reply(550, '5.1.1', 'No such user here'),
363
+ async onData(message) {
364
+ const recipients = message.envelope.to.map((to) => (to === 'postmaster' ? postmaster : to));
365
+ const bytes = await new Response(message.content).bytes();
366
+ for (const to of recipients) await Bun.write(`spool/${to}/${message.id}.eml`, bytes);
367
+ },
368
+ });
369
+ ```
370
+
312
371
  ## Session.data
313
372
 
314
373
  Every hook, and `authenticate`, receives the `Session`:
@@ -396,6 +455,7 @@ export interface ReceivedMessage {
396
455
  export interface Envelope {
397
456
  /** `''` for the null reverse-path `<>`: a bounce. */
398
457
  readonly from: string;
458
+ /** `local@domain` each; `'postmaster'`, with no domain, for `RCPT TO:<postmaster>`. */
399
459
  readonly to: readonly string[];
400
460
  /** The client asked for SMTPUTF8 (RFC 6531). */
401
461
  readonly smtputf8: boolean;
@@ -406,8 +466,9 @@ export interface Envelope {
406
466
 
407
467
  The **envelope** is what MAIL FROM and RCPT TO said — not the `From:` and
408
468
  `To:` of the message, which may differ (Bcc, mailing lists, forwarding).
409
- `to` holds only the recipients the server and `onRcptTo` accepted. Deliver
410
- to the envelope.
469
+ `to` holds only the recipients the server and `onRcptTo` accepted, each
470
+ `local@domain`, or `'postmaster'` for [`RCPT TO:<postmaster>`](#rcpt-topostmaster).
471
+ Deliver to the envelope.
411
472
 
412
473
  The **content** is the message as the client sent it, with the leading dot
413
474
  of each stuffed line removed (RFC 5321 §4.5.2), the terminating `.` line
@@ -423,7 +484,8 @@ Subject: hi
423
484
  body
424
485
  ```
425
486
 
426
- - `from` is the EHLO or HELO name, then the client's address in brackets.
487
+ - `from` is the EHLO or HELO name, then the client's address in brackets:
488
+ an IPv6 address is the literal of RFC 5321 §4.1.3, `([IPv6:2001:db8::7])`.
427
489
  Any character of the name that is not printable ASCII is written `?`.
428
490
  - `with` is the protocol of RFC 3848: `SMTP` after HELO; `ESMTP` after EHLO,
429
491
  `ESMTPS` with TLS, `ESMTPA` with AUTH, `ESMTPSA` with both.
@@ -767,6 +829,26 @@ STARTTLS, in clear, are dropped and never answered. The STARTTLS replies:
767
829
  with `tls`, usually on 465. STARTTLS is not offered there — the connection
768
830
  is already secure — and AUTH is offered at once.
769
831
 
832
+ On implicit TLS the server counts a socket from the TCP connection on, not
833
+ from the end of its handshake: a socket that has not finished its handshake
834
+ holds a slot of `maxConnections` and of `maxConnectionsPerClient`, and is
835
+ closed, without a reply, `handshakeTimeout` seconds (default 10) after it
836
+ connected, so clients that open TCP connections and never send a
837
+ ClientHello cannot fill the server. A refusal by a limit, `onConnect`, the
838
+ greeting and `greetingDelay` all wait for the handshake, so the client
839
+ reads them over TLS and `session.secure` is already `true`. A handshake
840
+ that fails is closed by Bun at once and counted out. A STARTTLS handshake
841
+ is bounded by the idle `timeout`, the connection already counted.
842
+
843
+ Bun 1.4.2 calls a TLS listener's `open` only once the handshake completed
844
+ unless the listener has a `handshake` handler; with one, `open` comes at
845
+ the TCP connection and the socket's timer runs during the handshake. The
846
+ server sets one for that reason.
847
+
848
+ Behind a TCP proxy that sends the PROXY protocol, implicit TLS starts after
849
+ the proxy's header, and runs differently: see
850
+ [Running behind a TCP proxy](#running-behind-a-tcp-proxy).
851
+
770
852
  An MX takes mail in clear as well as encrypted: senders on the Internet that
771
853
  cannot do TLS still deliver. Whether to refuse them is policy — check
772
854
  `session.secure` in a hook:
@@ -837,6 +919,8 @@ await createSmtpServer({ ...submission, implicitTls: true }).listen({ port: 465
837
919
  | `maxMessageSize` | 25 MiB | `552 5.3.4` at MAIL when `SIZE=` says more; during DATA once the message is more — the content stream errors with `MESSAGE_TOO_BIG`, the rest is read and dropped, and the session goes on |
838
920
  | `maxRecipients` | 100, the least RFC 5321 §4.5.3.1.8 asks a server to take | `452 4.5.3` for each extra recipient (RFC 5321 §4.5.3.1.10); the client sends the rest in another transaction |
839
921
  | `maxConnections` | 1000 | `421 4.3.2` and the server hangs up, before the greeting and before `onConnect` |
922
+ | `maxConnectionsPerClient` | 10 | `421 4.7.0 <hostname> Too many connections from your address, try later` and the server hangs up, before the greeting and before `onConnect` |
923
+ | `handshakeTimeout` | 10 seconds, on implicit TLS; ticks of about 4 s, as `timeout`. For a trusted proxy's PROXY header, to the millisecond | the socket is closed without a reply: there is no TLS to write one on, and a proxy's socket has no client yet |
840
924
  | `maxErrors` | 10 | `421 4.7.0` and the server hangs up |
841
925
  | `timeout` | 300 seconds, RFC 5321 §4.5.3.2.7's, counted from the client's last byte, or from the 220; Bun's socket timer ticks in steps of about 4 s, so the hang-up comes up to that much later | `421 4.4.2` and the server hangs up |
842
926
  | `hookTimeout` | 60 seconds | `451 4.3.0` for that command; `onError` gets an `SmtpError` `HOOK_TIMEOUT` |
@@ -869,6 +953,50 @@ const server = createSmtpServer({
869
953
  await server.listen({ port: 25 });
870
954
  ```
871
955
 
956
+ `maxConnectionsPerClient` keeps one host from taking every slot of
957
+ `maxConnections`. A client is counted by `clientKey(session.remoteAddress)`:
958
+
959
+ | Remote address | Counted as |
960
+ | --- | --- |
961
+ | `192.0.2.1` | `192.0.2.1` |
962
+ | `::ffff:192.0.2.1`, `::ffff:c000:201` (IPv4-mapped, as a listener on `::` sees an IPv4 client) | `192.0.2.1` |
963
+ | `64:ff9b::c000:201` (NAT64) | `192.0.2.1` |
964
+ | `2001:db8:1:2::9`, `2001:db8:1:2:ffff::1` | `2001:db8:1:2::/64` |
965
+ | `2001:db8::1`, `2001:0db8:0000::ffff` | `2001:db8:0:0::/64` |
966
+ | anything else (a Unix socket) | not limited |
967
+
968
+ An IPv6 key is always the first four groups in lower-case hex, without
969
+ leading zeros, every group written out — zeros too — then `::/64`:
970
+ `2001:db8::1` is `2001:db8:0:0::/64`, never `2001:db8::/64`. Write the
971
+ keys you compare with it that way.
972
+
973
+ An IPv6 host usually holds a whole /64, so counting each address apart
974
+ would let it open as many connections as it likes. Every close frees the
975
+ slot as it frees one of `maxConnections`: QUIT, the client hanging up, a
976
+ socket error, the idle `timeout`, `maxErrors`, a refusal from `onConnect`,
977
+ and `stop(true)`. The count lives in the server's memory, for that one
978
+ server. For a policy of your own — a list, a per-network budget — use
979
+ `onConnect` with `clientKey`:
980
+
981
+ ```ts
982
+ import { clientKey, createSmtpServer, reply } from '@bumail/smtp';
983
+
984
+ // As clientKey writes them: '2001:db8:0:0::/64', not '2001:db8::/64'.
985
+ const blocked = new Set(['203.0.113.7', '2001:db8:0:0::/64', '2001:db8:dead:beef::/64']);
986
+
987
+ const server = createSmtpServer({
988
+ hostname: 'mx.example.com',
989
+ localDomains: ['example.com'],
990
+ maxConnections: 500,
991
+ maxConnectionsPerClient: 5,
992
+ onConnect: (session) =>
993
+ blocked.has(clientKey(session.remoteAddress) ?? '') ? reply(554, '5.7.1', 'Go away') : undefined,
994
+ onData: async (message) => {
995
+ await new Response(message.content).bytes();
996
+ },
997
+ });
998
+ ```
999
+
872
1000
  Some limits are fixed: a command line is at most 2048 bytes (RFC 5321
873
1001
  §4.5.3.1.4 asks for 512 at least); a session gets three AUTH attempts; and
874
1002
  the server holds at most 64 KiB of a client's unread input, and of a
@@ -910,8 +1038,11 @@ TLS buffer. So a `221` behind replies that are never read holds the slot
910
1038
  5 seconds, not until the `timeout`.
911
1039
 
912
1040
  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.
914
- `connections` counts the clients currently connected:
1041
+ those moved to TLS by STARTTLS included, as the idle timeout does; a
1042
+ socket still in its implicit TLS handshake is reset, with no 421, having
1043
+ no TLS to write one on, and so is a socket still awaiting a proxy's PROXY
1044
+ header, nothing written and nothing passed to `onError`. `connections`
1045
+ counts the clients currently connected:
915
1046
 
916
1047
  ```ts
917
1048
  import { createSmtpServer } from '@bumail/smtp';
@@ -931,6 +1062,326 @@ process.on('SIGTERM', () => {
931
1062
  });
932
1063
  ```
933
1064
 
1065
+ ## Running behind a TCP proxy
1066
+
1067
+ `proxyProtocol` lets the server sit behind a TCP proxy — a Traefik TCP
1068
+ router, HAProxy, a cloud load balancer — and still see each client's own
1069
+ address, as the proxy reports it with the PROXY protocol (HAProxy's
1070
+ `proxy-protocol.txt`, versions 1 and 2):
1071
+
1072
+ ```ts
1073
+ import { createSmtpServer } from '@bumail/smtp';
1074
+
1075
+ const server = createSmtpServer({
1076
+ hostname: 'mx.example.com',
1077
+ localDomains: ['example.com'],
1078
+ proxyProtocol: { trusted: ['10.0.0.5'] },
1079
+ onConnect: (session) => console.log('client', session.remoteAddress), // the client's, not 10.0.0.5
1080
+ async onData(message) {
1081
+ await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
1082
+ },
1083
+ });
1084
+
1085
+ await server.listen({ port: 25 });
1086
+ ```
1087
+
1088
+ ```ts
1089
+ export interface ProxyProtocolOptions {
1090
+ /** The proxies' addresses or CIDRs, IPv4 or IPv6: `['10.0.0.5', '172.16.0.0/12']`. */
1091
+ readonly trusted: readonly string[];
1092
+ }
1093
+
1094
+ export interface SmtpServerOptions {
1095
+ // …
1096
+ readonly proxyProtocol?: ProxyProtocolOptions; // off by default
1097
+ readonly handshakeTimeout?: number; // also bounds a trusted proxy's header; default 10
1098
+ }
1099
+ ```
1100
+
1101
+ - [Who is trusted](#who-is-trusted)
1102
+ - [The header, and `handshakeTimeout`](#the-header-and-handshaketimeout)
1103
+ - [Which address the client gets](#which-address-the-client-gets)
1104
+ - [STARTTLS on 25 and 587](#starttls-on-25-and-587)
1105
+ - [Implicit TLS on 465, behind a proxy](#implicit-tls-on-465-behind-a-proxy)
1106
+ - [With Traefik](#with-traefik)
1107
+
1108
+ ### Who is trusted
1109
+
1110
+ `trusted` lists the peers whose header is read: IPv4 and IPv6 addresses,
1111
+ and CIDRs of either. A peer is the TCP address the connection comes from,
1112
+ the proxy's.
1113
+
1114
+ | Entry | Trusts |
1115
+ | --- | --- |
1116
+ | `'10.0.0.5'`, `'2001:db8::5'` | that address alone |
1117
+ | `'172.20.0.0/16'`, `'2001:db8:1::/48'` | every address in the network |
1118
+ | `'::ffff:10.0.0.5'` | `10.0.0.5`: an IPv4-mapped entry is its IPv4 address |
1119
+ | `'::ffff:10.0.0.0/104'` | `10.0.0.0/8`: a mapped CIDR counts its prefix over the whole IPv6 address, so 96 and up |
1120
+
1121
+ A peer reported IPv4-mapped (`::ffff:10.0.0.5`), as a listener on `::`
1122
+ sees an IPv4 client, matches as its IPv4 address, so `'10.0.0.5'` covers
1123
+ both. An address with a zone (`fe80::1%eth0`) is refused, as an entry and as a
1124
+ peer: a zone names an interface of this host, not a client. A prefix of 0
1125
+ (`0.0.0.0/0`, `::/0`) is refused too, since it trusts every peer. A bad entry throws an
1126
+ `SmtpError` (`INVALID_OPTION`) from `createSmtpServer`; the messages are in
1127
+ [Options](#options).
1128
+
1129
+ **List the proxies' own addresses, and nothing else.** A trusted peer can
1130
+ claim any client address it likes, and every check that uses the address
1131
+ — `maxConnectionsPerClient`, your `onConnect` policy, the Received field —
1132
+ believes it. Never list a range clients connect from, and never
1133
+ `0.0.0.0/0` or `::/0` on a port the Internet reaches. A container network
1134
+ is safe to list only when nothing but the proxy reaches the server
1135
+ through it.
1136
+
1137
+ A peer not listed is served exactly as without the option. A PROXY header
1138
+ it sends is just bad input and never sets an address: after the greeting,
1139
+ `PROXY …` is a command the server does not know (`500`); before it, the
1140
+ client talked first (`554 <hostname> Talked before the greeting`). On
1141
+ implicit TLS the header is not a ClientHello: the handshake fails and the
1142
+ socket is closed.
1143
+
1144
+ ### The header, and `handshakeTimeout`
1145
+
1146
+ A trusted peer must send a valid header first, before any byte of the
1147
+ client's, and all of it within `handshakeTimeout` seconds (default 10) of
1148
+ the TCP connection. That bound is a timer of its own, to the millisecond,
1149
+ not Bun's socket timer with its 4-second ticks; a header trickled a byte at
1150
+ a time does not stretch it. Past it, or on a header that is not valid, the
1151
+ socket is reset:
1152
+
1153
+ - nothing is written to it, not even a `421`;
1154
+ - `onConnect` is never called, and `onError` is told nothing;
1155
+ - the proxy's address is never taken for a client's.
1156
+
1157
+ While it waits for its header, a socket holds no slot of `maxConnections`
1158
+ nor of `maxConnectionsPerClient`: it is counted, by the client's address,
1159
+ once the header named it. `stop(true)` resets the sockets still awaiting
1160
+ their header, writing nothing and calling no `onError`.
1161
+
1162
+ What is read, and what is refused:
1163
+
1164
+ | Header | Refused when |
1165
+ | --- | --- |
1166
+ | v1, `PROXY TCP4 …`, `PROXY TCP6 …`, `PROXY UNKNOWN …` | the line, CRLF included, is longer than 107 bytes; it does not end in CRLF; it holds a byte that is not printable ASCII; the protocol is none of the three; an address or a port is malformed, or of the other family than `TCP4` or `TCP6` says |
1167
+ | v2, the 12-byte signature, then version 2 | the TLVs (the extensions after the addresses) announce more than 2048 bytes — refused from the first 16 bytes, before any of it is waited for; a TLV runs past the end of the block; the command, family or transport is unknown; the address block is shorter than its family needs |
1168
+
1169
+ TLVs are checked for their shape, then skipped: the server reads none,
1170
+ SSL and authority included.
1171
+
1172
+ What the client sent in the same segment as the header, behind it, is the
1173
+ client's own. On a clear port that is bytes sent before the greeting, so
1174
+ the client is refused with `554 <hostname> Talked before the greeting`, as
1175
+ it would be without a proxy.
1176
+
1177
+ ### Which address the client gets
1178
+
1179
+ | The proxy sends | `session.remoteAddress` is |
1180
+ | --- | --- |
1181
+ | v1 `PROXY TCP4` or `PROXY TCP6` | the source address |
1182
+ | v2 `PROXY`, over TCP (STREAM), IPv4 or IPv6 | the source address |
1183
+ | v2 `LOCAL`: the proxy's own connection, a health check | the peer's, the proxy's |
1184
+ | v2 `PROXY` with an UNSPEC or UNIX address, or over UDP (DGRAM) | the peer's |
1185
+ | v1 `PROXY UNKNOWN` | the peer's |
1186
+
1187
+ An IPv6 source is written in RFC 5952's form — lower case, no leading
1188
+ zeros, the longest run of zeros as `::` — and an IPv4-mapped source as its
1189
+ plain IPv4 address: `::FFFF:C633:6407` reaches your code as
1190
+ `198.51.100.7`.
1191
+
1192
+ That address is the client's everywhere the server uses one:
1193
+ `session.remoteAddress` in `onConnect`, in every hook, in `authenticate`
1194
+ and in `onError`; the Received field (`from client.example
1195
+ ([198.51.100.7])`); and `maxConnectionsPerClient`, which counts it by
1196
+ `clientKey` as any other. So a policy written for a server without a proxy
1197
+ works unchanged behind one:
1198
+
1199
+ ```ts
1200
+ import { clientKey, createSmtpServer, reply } from '@bumail/smtp';
1201
+
1202
+ const blocked = new Set(['203.0.113.7', '2001:db8:0:0::/64']);
1203
+
1204
+ const server = createSmtpServer({
1205
+ hostname: 'mx.example.com',
1206
+ localDomains: ['example.com'],
1207
+ proxyProtocol: { trusted: ['10.0.0.5', 'fd00::5'] },
1208
+ maxConnectionsPerClient: 5, // per client behind the proxy, not for the proxy
1209
+ onConnect: (session) =>
1210
+ blocked.has(clientKey(session.remoteAddress) ?? '') ? reply(554, '5.7.1', 'Go away') : undefined,
1211
+ onData: async (message) => {
1212
+ await new Response(message.content).bytes();
1213
+ },
1214
+ });
1215
+
1216
+ await server.listen({ port: 25 });
1217
+ ```
1218
+
1219
+ ### STARTTLS on 25 and 587
1220
+
1221
+ Nothing changes for STARTTLS. The header is read before the greeting; the
1222
+ session then runs in clear, `STARTTLS` upgrades it as usual, and the
1223
+ client's address carries over to the encrypted session. The proxy passes
1224
+ the TLS records through untouched: it must not terminate TLS itself.
1225
+
1226
+ ### Implicit TLS on 465, behind a proxy
1227
+
1228
+ On an `implicitTls` port the proxy's header comes first, then the client's
1229
+ ClientHello. A Bun TLS listener would take the header for a broken
1230
+ ClientHello, and Bun's `socket.upgradeTLS` reads only bytes that arrive
1231
+ after it is called, while a proxy often sends the ClientHello in the same
1232
+ TCP segment as the header (with Bun 1.4.2, the handshake then never
1233
+ completes).
1234
+
1235
+ So with `proxyProtocol` and `implicitTls` together, the port listens in
1236
+ clear, and every connection's TLS runs through `node:tls` over the raw
1237
+ socket: after the header from a trusted proxy, from the first byte from
1238
+ anyone else. For you, it changes little:
1239
+
1240
+ - `tls.key` and `tls.cert` still take a string, bytes or a `BunFile`; they
1241
+ are read when `listen()` is called.
1242
+ - Behind the header, the TLS handshake has `handshakeTimeout` again, on
1243
+ Bun's socket timer, as on implicit TLS without a proxy; meanwhile the
1244
+ socket holds its slot, counted by the client's address.
1245
+ - A refusal by a limit, `onConnect`, the greeting and `greetingDelay` wait
1246
+ for the handshake, so the client reads them over TLS and
1247
+ `session.secure` is `true`.
1248
+
1249
+ ```ts
1250
+ import { createSmtpServer } from '@bumail/smtp';
1251
+
1252
+ const server = createSmtpServer({
1253
+ hostname: 'smtp.example.com',
1254
+ mode: 'submission',
1255
+ implicitTls: true,
1256
+ localDomains: ['example.com'],
1257
+ tls: {
1258
+ key: Bun.file('/etc/ssl/smtp.example.com.key'),
1259
+ cert: Bun.file('/etc/ssl/smtp.example.com.crt'),
1260
+ },
1261
+ proxyProtocol: { trusted: ['10.0.0.5'] },
1262
+ authenticate: ({ username, password }) => username === 'alice' && password === Bun.env['ALICE_PASSWORD'],
1263
+ async onData(message) {
1264
+ await Bun.write(`queue/${message.id}.eml`, await new Response(message.content).bytes());
1265
+ },
1266
+ });
1267
+
1268
+ await server.listen({ port: 465 });
1269
+ ```
1270
+
1271
+ ### With Traefik
1272
+
1273
+ Traefik's TCP routers pass a connection through and, with a
1274
+ `serversTransport` whose `proxyProtocol.version` is 2, send a v2 header
1275
+ first. One entry
1276
+ point per port, in the static configuration:
1277
+
1278
+ ```yaml
1279
+ # traefik.yml
1280
+ entryPoints:
1281
+ smtp:
1282
+ address: ':25'
1283
+ submission:
1284
+ address: ':587'
1285
+ submissions:
1286
+ address: ':465'
1287
+ ```
1288
+
1289
+ Then, in the dynamic configuration, one router and one service each.
1290
+ ``HostSNI(`*`)`` with no `tls` section matches every connection without
1291
+ terminating TLS, so STARTTLS on 25 and 587, and the TLS of 465, pass
1292
+ through to the server, which holds the certificate:
1293
+
1294
+ ```yaml
1295
+ # dynamic.yml
1296
+ tcp:
1297
+ routers:
1298
+ smtp:
1299
+ entryPoints: [smtp]
1300
+ rule: 'HostSNI(`*`)'
1301
+ service: smtp
1302
+ submission:
1303
+ entryPoints: [submission]
1304
+ rule: 'HostSNI(`*`)'
1305
+ service: submission
1306
+ submissions:
1307
+ entryPoints: [submissions]
1308
+ rule: 'HostSNI(`*`)'
1309
+ service: submissions
1310
+ serversTransports:
1311
+ proxy-v2:
1312
+ proxyProtocol:
1313
+ version: 2
1314
+ services:
1315
+ smtp:
1316
+ loadBalancer:
1317
+ serversTransport: proxy-v2
1318
+ servers:
1319
+ - address: 'mail:25'
1320
+ submission:
1321
+ loadBalancer:
1322
+ serversTransport: proxy-v2
1323
+ servers:
1324
+ - address: 'mail:587'
1325
+ submissions:
1326
+ loadBalancer:
1327
+ serversTransport: proxy-v2
1328
+ servers:
1329
+ - address: 'mail:465'
1330
+ ```
1331
+
1332
+ This was run against Traefik v3.7 in Docker, a client reaching it with
1333
+ `openssl s_client` on 465 and with `-starttls smtp` on 25 and 587: the
1334
+ server saw the client's address in `onConnect` and every hook, Traefik's
1335
+ nowhere. Older v3 releases take the version on the service instead —
1336
+ `loadBalancer.proxyProtocol: { version: 2 }` beside `servers` — which
1337
+ v3.7 still honours, with a warning that it is deprecated in favour of the
1338
+ `serversTransport` above; the header sent is the same.
1339
+
1340
+ The matching servers, trusting Traefik's address on the network it shares
1341
+ with the mail server (`172.20.0.2` here; give the proxy a fixed address
1342
+ there):
1343
+
1344
+ ```ts
1345
+ import { createSmtpServer, type ReceivedMessage, type SmtpServerOptions } from '@bumail/smtp';
1346
+
1347
+ const tls = {
1348
+ key: await Bun.file('/etc/ssl/mail.example.com.key').text(),
1349
+ cert: await Bun.file('/etc/ssl/mail.example.com.crt').text(),
1350
+ };
1351
+ const users = new Map([['alice', await Bun.password.hash('correct horse')]]);
1352
+
1353
+ async function deliver(message: ReceivedMessage): Promise<void> {
1354
+ await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
1355
+ }
1356
+
1357
+ const shared = {
1358
+ hostname: 'mail.example.com',
1359
+ localDomains: ['example.com'],
1360
+ tls,
1361
+ // Traefik alone: never the network's whole range if anything else on it is not yours.
1362
+ proxyProtocol: { trusted: ['172.20.0.2'] },
1363
+ onData: deliver,
1364
+ } satisfies SmtpServerOptions;
1365
+
1366
+ const submission = {
1367
+ ...shared,
1368
+ mode: 'submission',
1369
+ async authenticate({ username, password }) {
1370
+ const hash = users.get(username);
1371
+ return hash !== undefined && (await Bun.password.verify(password, hash));
1372
+ },
1373
+ } satisfies SmtpServerOptions;
1374
+
1375
+ await createSmtpServer(shared).listen({ port: 25 });
1376
+ await createSmtpServer(submission).listen({ port: 587 });
1377
+ await createSmtpServer({ ...submission, implicitTls: true }).listen({ port: 465 });
1378
+ ```
1379
+
1380
+ Traefik's health checks, and any connection it opens on its own, come as
1381
+ v2 `LOCAL`: they keep the proxy's address, by design. A client that
1382
+ reaches the server without Traefik, on another network, is a peer not
1383
+ listed and is served as such.
1384
+
934
1385
  ## Sending mail: the client
935
1386
 
936
1387
  `@bumail/smtp/client` is the other side of the conversation: `sendMail`
@@ -1291,6 +1742,7 @@ a server of your own.
1291
1742
 
1292
1743
  ```ts
1293
1744
  import {
1745
+ clientKey,
1294
1746
  DataReader,
1295
1747
  decodePlain,
1296
1748
  formatReply,
@@ -1310,6 +1762,12 @@ parsePath('<Joe.Bloggs@EXAMPLE.Org>', false)?.address; // 'Joe.Bloggs@example.or
1310
1762
  parsePath('<>', true); // { address: '', local: '', domain: '' }: the null path, where allowed
1311
1763
  parsePath('<>', false); // undefined
1312
1764
 
1765
+ parsePathCommand('TO:<Postmaster>', 'TO')?.path;
1766
+ // { address: 'postmaster', local: 'postmaster', domain: '', postmaster: true }
1767
+
1768
+ clientKey('::ffff:192.0.2.1'); // '192.0.2.1'
1769
+ clientKey('2001:db8:1:2::9'); // '2001:db8:1:2::/64'
1770
+
1313
1771
  formatReply(reply(550, '5.7.1', ['a', 'b'])); // '550-5.7.1 a\r\n550 5.7.1 b\r\n'
1314
1772
  formatReply(reply(250, '2.0.0', 'OK'), false); // '250 OK\r\n': no enhanced status
1315
1773
 
@@ -1343,6 +1801,7 @@ console.log(chunk.done, reader.bareLineBreaks);
1343
1801
  | Behaviour | RFC |
1344
1802
  | --- | --- |
1345
1803
  | the session, commands, replies, paths, the Received field | RFC 5321 |
1804
+ | `RCPT TO:<postmaster>` with no domain | RFC 5321 §4.1.1.3, §4.5.1 |
1346
1805
  | SIZE | RFC 1870 |
1347
1806
  | PIPELINING | RFC 2920 |
1348
1807
  | 8BITMIME | RFC 6152 |
@@ -1350,6 +1809,7 @@ console.log(chunk.done, reader.bareLineBreaks);
1350
1809
  | ENHANCEDSTATUSCODES and the codes themselves | RFC 2034, RFC 3463 |
1351
1810
  | STARTTLS | RFC 3207 |
1352
1811
  | implicit TLS | RFC 8314 |
1812
+ | the PROXY protocol, v1 and v2 | not an RFC: HAProxy's `proxy-protocol.txt` |
1353
1813
  | AUTH | RFC 4954 |
1354
1814
  | SASL PLAIN | RFC 4616 |
1355
1815
  | submission mode | RFC 6409 |