@bumail/smtp 0.4.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 (42) hide show
  1. package/README.md +44 -5
  2. package/dist/chunks/{index-753q702a.js → index-n4xwep5m.js} +3 -3
  3. package/dist/chunks/{index-753q702a.js.map → index-n4xwep5m.js.map} +3 -3
  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 +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +550 -70
  10. package/dist/index.js.map +15 -8
  11. package/dist/server/admission.d.ts +29 -0
  12. package/dist/server/admission.d.ts.map +1 -0
  13. package/dist/server/listener.d.ts +17 -8
  14. package/dist/server/listener.d.ts.map +1 -1
  15. package/dist/server/options.d.ts +21 -1
  16. package/dist/server/options.d.ts.map +1 -1
  17. package/dist/server/proxy/address.d.ts +17 -0
  18. package/dist/server/proxy/address.d.ts.map +1 -0
  19. package/dist/server/proxy/header.d.ts +26 -0
  20. package/dist/server/proxy/header.d.ts.map +1 -0
  21. package/dist/server/proxy/reader.d.ts +15 -0
  22. package/dist/server/proxy/reader.d.ts.map +1 -0
  23. package/dist/server/proxy/tls.d.ts +50 -0
  24. package/dist/server/proxy/tls.d.ts.map +1 -0
  25. package/dist/server/proxy/trusted.d.ts +9 -0
  26. package/dist/server/proxy/trusted.d.ts.map +1 -0
  27. package/dist/server/raw-socket.d.ts +9 -0
  28. package/dist/server/raw-socket.d.ts.map +1 -0
  29. package/dist/server/received.d.ts +2 -0
  30. package/dist/server/received.d.ts.map +1 -1
  31. package/dist/server/server.d.ts.map +1 -1
  32. package/dist/server/settings.d.ts +2 -0
  33. package/dist/server/settings.d.ts.map +1 -1
  34. package/dist/server/tls-context.d.ts +16 -0
  35. package/dist/server/tls-context.d.ts.map +1 -0
  36. package/dist/server/transport.d.ts +2 -2
  37. package/dist/server/transport.d.ts.map +1 -1
  38. package/docs/README.md +2 -2
  39. package/docs/guide.md +345 -7
  40. package/docs/roadmap.md +13 -0
  41. package/docs/troubleshooting.md +322 -1
  42. package/package.json +1 -1
package/docs/guide.md CHANGED
@@ -1,8 +1,9 @@
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)
@@ -15,6 +16,7 @@ with the client, and the RFCs behind each behaviour.
15
16
  - [Authentication](#authentication)
16
17
  - [TLS](#tls)
17
18
  - [Limits](#limits)
19
+ - [Running behind a TCP proxy](#running-behind-a-tcp-proxy)
18
20
  - [Sending mail: the client](#sending-mail-the-client)
19
21
  - [Protocol helpers](#protocol-helpers)
20
22
  - [The RFCs implemented](#the-rfcs-implemented)
@@ -69,7 +71,8 @@ export interface SmtpServer {
69
71
  | `maxRecipients` | `number` | `100` | recipients per message |
70
72
  | `maxConnections` | `number` | `1000` | open connections at once |
71
73
  | `maxConnectionsPerClient` | `number` | `10` | open connections at once from one client: an IPv4 address, or an IPv6 /64; see [Limits](#limits) |
72
- | `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) |
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) |
73
76
  | `maxErrors` | `number` | `10` | failed commands before the server hangs up |
74
77
  | `timeout` | `number` | `300` | seconds since the client's last byte, or since the 220, before the server hangs up |
75
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 |
@@ -115,9 +118,16 @@ await server.listen({ port: 25 });
115
118
  | `createSmtpServer(): hookTimeout must be at most 2147483 seconds, not <value>` | `hookTimeout` past what `setTimeout` can wait (about 24.8 days) |
116
119
  | `createSmtpServer(): greetingDelay must be a number of seconds, 0 or more, not <value>` | `greetingDelay` negative, `NaN` or `Infinity` |
117
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 |
118
126
 
119
127
  `listen` a second time throws an `SmtpError` with `code:
120
- '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.
121
131
 
122
132
  What to do about each is in [Troubleshooting](troubleshooting.md).
123
133
 
@@ -474,7 +484,8 @@ Subject: hi
474
484
  body
475
485
  ```
476
486
 
477
- - `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])`.
478
489
  Any character of the name that is not printable ASCII is written `?`.
479
490
  - `with` is the protocol of RFC 3848: `SMTP` after HELO; `ESMTP` after EHLO,
480
491
  `ESMTPS` with TLS, `ESMTPA` with AUTH, `ESMTPSA` with both.
@@ -834,6 +845,10 @@ unless the listener has a `handshake` handler; with one, `open` comes at
834
845
  the TCP connection and the socket's timer runs during the handshake. The
835
846
  server sets one for that reason.
836
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
+
837
852
  An MX takes mail in clear as well as encrypted: senders on the Internet that
838
853
  cannot do TLS still deliver. Whether to refuse them is policy — check
839
854
  `session.secure` in a hook:
@@ -905,7 +920,7 @@ await createSmtpServer({ ...submission, implicitTls: true }).listen({ port: 465
905
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 |
906
921
  | `maxConnections` | 1000 | `421 4.3.2` and the server hangs up, before the greeting and before `onConnect` |
907
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` |
908
- | `handshakeTimeout` | 10 seconds, on implicit TLS; ticks of about 4 s, as `timeout` | the socket is closed without a reply: there is no TLS to write one on |
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 |
909
924
  | `maxErrors` | 10 | `421 4.7.0` and the server hangs up |
910
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 |
911
926
  | `hookTimeout` | 60 seconds | `451 4.3.0` for that command; `onError` gets an `SmtpError` `HOOK_TIMEOUT` |
@@ -1025,7 +1040,9 @@ TLS buffer. So a `221` behind replies that are never read holds the slot
1025
1040
  Stop a server with `stop()`; `stop(true)` also hangs up on every client,
1026
1041
  those moved to TLS by STARTTLS included, as the idle timeout does; a
1027
1042
  socket still in its implicit TLS handshake is reset, with no 421, having
1028
- no TLS to write one on. `connections` counts the clients currently connected:
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:
1029
1046
 
1030
1047
  ```ts
1031
1048
  import { createSmtpServer } from '@bumail/smtp';
@@ -1045,6 +1062,326 @@ process.on('SIGTERM', () => {
1045
1062
  });
1046
1063
  ```
1047
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
+
1048
1385
  ## Sending mail: the client
1049
1386
 
1050
1387
  `@bumail/smtp/client` is the other side of the conversation: `sendMail`
@@ -1472,6 +1809,7 @@ console.log(chunk.done, reader.bareLineBreaks);
1472
1809
  | ENHANCEDSTATUSCODES and the codes themselves | RFC 2034, RFC 3463 |
1473
1810
  | STARTTLS | RFC 3207 |
1474
1811
  | implicit TLS | RFC 8314 |
1812
+ | the PROXY protocol, v1 and v2 | not an RFC: HAProxy's `proxy-protocol.txt` |
1475
1813
  | AUTH | RFC 4954 |
1476
1814
  | SASL PLAIN | RFC 4616 |
1477
1815
  | submission mode | RFC 6409 |
package/docs/roadmap.md CHANGED
@@ -34,6 +34,19 @@ Nothing in progress.
34
34
 
35
35
  ### Unreleased — merged, not yet published
36
36
 
37
+ - **Behind a TCP proxy, the client's own address**, with `proxyProtocol:
38
+ { trusted: [...] }`: the server reads the PROXY protocol, versions 1 and
39
+ 2, from the proxies listed — a Traefik TCP router, HAProxy — so
40
+ `session.remoteAddress`, every hook, the Received field and
41
+ `maxConnectionsPerClient` see the client, not the proxy. It works on 25
42
+ and 587 with STARTTLS and on 465 with implicit TLS. A listed peer that
43
+ sends no valid header within `handshakeTimeout` is reset without a word
44
+ and holds no slot while it waits; a peer not listed is served as before,
45
+ and a header it sends never sets an address. `ProxyProtocolOptions` is
46
+ exported.
47
+
48
+ ### 0.4.0
49
+
37
50
  - **A connection limit per client**, `maxConnectionsPerClient` (default
38
51
  10): one host can no longer take every slot of `maxConnections` on an
39
52
  MX. A client is an IPv4 address, or an IPv6 address by its /64, and an