@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
@@ -24,7 +24,15 @@ A reply carries its enhanced status code (`5.7.1`, RFC 3463) only after
24
24
  - [`SmtpError: createSmtpServer(): hookTimeout must be at most 2147483 seconds, not …`](#smtperror-createsmtpserver-hooktimeout-must-be-at-most-2147483-seconds-not-)
25
25
  - [`SmtpError: createSmtpServer(): greetingDelay must be a number of seconds, 0 or more, not …`](#smtperror-createsmtpserver-greetingdelay-must-be-a-number-of-seconds-0-or-more-not-)
26
26
  - [`SmtpError: createSmtpServer(): greetingDelay (… s) must be shorter than timeout (… s), or every client times out before the greeting`](#smtperror-createsmtpserver-greetingdelay--s-must-be-shorter-than-timeout--s-or-every-client-times-out-before-the-greeting)
27
+ - [`SmtpError: createSmtpServer(): proxyProtocol.trusted must list the addresses or CIDRs of the proxies, at least one`](#smtperror-createsmtpserver-proxyprotocoltrusted-must-list-the-addresses-or-cidrs-of-the-proxies-at-least-one)
28
+ - [`SmtpError: createSmtpServer(): proxyProtocol.trusted: "…" is neither an IP address nor a CIDR`](#smtperror-createsmtpserver-proxyprotocoltrusted--is-neither-an-ip-address-nor-a-cidr)
29
+ - [`SmtpError: createSmtpServer(): proxyProtocol.trusted: "…" has a prefix length out of range`](#smtperror-createsmtpserver-proxyprotocoltrusted--has-a-prefix-length-out-of-range)
30
+ - [`SmtpError: createSmtpServer(): proxyProtocol.trusted: "…" has a prefix length of 0, which trusts every peer`](#smtperror-createsmtpserver-proxyprotocoltrusted--has-a-prefix-length-of-0-which-trusts-every-peer)
31
+ - [`SmtpError: createSmtpServer(): proxyProtocol.trusted: … is not a string`](#smtperror-createsmtpserver-proxyprotocoltrusted--is-not-a-string)
27
32
  - [`SmtpError: listen(): the server is already listening on …`](#smtperror-listen-the-server-is-already-listening-on-)
33
+ - [`SmtpError: listen(): the server is already starting to listen`](#smtperror-listen-the-server-is-already-starting-to-listen)
34
+ - [`SmtpError: listen(): tls: { key, cert } cannot be used: …`](#smtperror-listen-tls--key-cert--cannot-be-used-)
35
+ - [`SmtpError: listen(): stop() was called before the server bound its port`](#smtperror-listen-stop-was-called-before-the-server-bound-its-port)
28
36
 
29
37
  **Relaying and authentication**
30
38
 
@@ -85,6 +93,10 @@ A reply carries its enhanced status code (`5.7.1`, RFC 3463) only after
85
93
  - [`421 4.3.2 … Too many connections, try later`](#421-432--too-many-connections-try-later)
86
94
  - [`421 4.7.0 … Too many connections from your address, try later`](#421-470--too-many-connections-from-your-address-try-later)
87
95
  - [On implicit TLS, the connection closes before any greeting](#on-implicit-tls-the-connection-closes-before-any-greeting)
96
+ - [Connections through the proxy close at once, with no greeting](#connections-through-the-proxy-close-at-once-with-no-greeting)
97
+ - [Every client through the proxy is refused with `554 … Talked before the greeting`](#every-client-through-the-proxy-is-refused-with-554--talked-before-the-greeting)
98
+ - [Every client shows the proxy's address](#every-client-shows-the-proxys-address)
99
+ - [Health checks from the proxy show its own address](#health-checks-from-the-proxy-show-its-own-address)
88
100
  - [`421 4.4.2 … Idle too long, closing`](#421-442--idle-too-long-closing)
89
101
  - [`554 … Talked before the greeting`](#554--talked-before-the-greeting)
90
102
 
@@ -409,6 +421,124 @@ createSmtpServer({
409
421
  });
410
422
  ```
411
423
 
424
+ ### `SmtpError: createSmtpServer(): proxyProtocol.trusted must list the addresses or CIDRs of the proxies, at least one`
425
+
426
+ **When**: `proxyProtocol` is set without `trusted`, with an empty array, or
427
+ with something that is not an array — a single string included.
428
+
429
+ **Why**: `trusted` names the only peers whose PROXY header is read. Empty,
430
+ it would trust nobody, which is no proxy at all; a server not behind a
431
+ proxy leaves `proxyProtocol` out.
432
+
433
+ **Fix**: list the proxy's own address, in an array:
434
+
435
+ ```ts
436
+ import { createSmtpServer } from '@bumail/smtp';
437
+
438
+ createSmtpServer({
439
+ hostname: 'mx.example.com',
440
+ localDomains: ['example.com'],
441
+ proxyProtocol: { trusted: ['10.0.0.5'] }, // not trusted: '10.0.0.5'
442
+ onData: async (message) => {
443
+ await new Response(message.content).bytes();
444
+ },
445
+ });
446
+ ```
447
+
448
+ ### `SmtpError: createSmtpServer(): proxyProtocol.trusted: "…" is neither an IP address nor a CIDR`
449
+
450
+ **When**: an entry of `trusted` is a host name (`traefik`), an address with
451
+ a port (`10.0.0.5:25`), a malformed address, an address with a zone
452
+ (`fe80::1%eth0`: a zone names an interface of this host, never a client),
453
+ or holds more than one `/`.
454
+ `…` is the entry.
455
+
456
+ **Why**: the server matches the TCP address a connection comes from; it
457
+ resolves no name, since a name could resolve to anything later.
458
+
459
+ **Fix**: give the proxy's IP address, or its network as a CIDR. In a
460
+ container network, give the proxy a fixed address and list that:
461
+
462
+ ```ts
463
+ import { createSmtpServer } from '@bumail/smtp';
464
+
465
+ createSmtpServer({
466
+ hostname: 'mx.example.com',
467
+ localDomains: ['example.com'],
468
+ proxyProtocol: { trusted: ['172.20.0.2', 'fd00:20::2'] },
469
+ onData: async (message) => {
470
+ await new Response(message.content).bytes();
471
+ },
472
+ });
473
+ ```
474
+
475
+ ### `SmtpError: createSmtpServer(): proxyProtocol.trusted: "…" has a prefix length out of range`
476
+
477
+ **When**: an entry's prefix is past 32 for IPv4 (`10.0.0.0/33`) or 128 for
478
+ IPv6, is empty or not a number (`10.0.0.0/`, `10.0.0.0/8a`), or is below
479
+ 96 for an IPv4-mapped address (`::ffff:10.0.0.0/8`).
480
+
481
+ **Why**: an IPv4-mapped entry is taken as its IPv4 address, and its
482
+ prefix counts over the whole IPv6 address, the 96 bits of `::ffff:`
483
+ first: `::ffff:10.0.0.0/104` is `10.0.0.0/8`.
484
+
485
+ **Fix**: write an IPv4 network as IPv4; it matches mapped peers too:
486
+
487
+ ```ts
488
+ import { createSmtpServer } from '@bumail/smtp';
489
+
490
+ createSmtpServer({
491
+ hostname: 'mx.example.com',
492
+ localDomains: ['example.com'],
493
+ proxyProtocol: { trusted: ['10.0.0.0/24'] }, // matches ::ffff:10.0.0.5 as well
494
+ onData: async (message) => {
495
+ await new Response(message.content).bytes();
496
+ },
497
+ });
498
+ ```
499
+
500
+ ### `SmtpError: createSmtpServer(): proxyProtocol.trusted: "…" has a prefix length of 0, which trusts every peer`
501
+
502
+ **When**: an entry has a prefix of 0: `0.0.0.0/0`, `::/0`, or
503
+ `::ffff:0.0.0.0/96`, which is the same. `…` is the entry.
504
+
505
+ **Why**: such an entry matches every peer, so any client could send a
506
+ PROXY header and claim any address: the option would defend nothing.
507
+
508
+ **Fix**: list the proxy's own address, or the smallest network it is in:
509
+
510
+ ```ts
511
+ proxyProtocol: { trusted: ['172.20.0.2'] }, // not '0.0.0.0/0'
512
+ ```
513
+
514
+ ### `SmtpError: createSmtpServer(): proxyProtocol.trusted: … is not a string`
515
+
516
+ **When**: an entry of `trusted` is not a string: a number, `null`,
517
+ `undefined`, an object. `…` is the value.
518
+
519
+ **Why**: each entry is an address or a CIDR, written as text.
520
+
521
+ **Fix**: write each entry as a string, and filter out what is not set when
522
+ the list comes from the environment:
523
+
524
+ ```ts
525
+ import { createSmtpServer } from '@bumail/smtp';
526
+
527
+ const trusted = (Bun.env['SMTP_TRUSTED_PROXIES'] ?? '')
528
+ .split(',')
529
+ .map((entry) => entry.trim())
530
+ .filter((entry) => entry !== '');
531
+
532
+ createSmtpServer({
533
+ hostname: 'mx.example.com',
534
+ localDomains: ['example.com'],
535
+ ...(trusted.length > 0 ? { proxyProtocol: { trusted } } : {}),
536
+ onData: async (message) => {
537
+ await new Response(message.content).bytes();
538
+ },
539
+ });
540
+ ```
541
+
412
542
  ### `SmtpError: listen(): the server is already listening on …`
413
543
 
414
544
  **When**: `listen` on a server that is already listening. Its `code` is
@@ -434,6 +564,67 @@ await createSmtpServer(options).listen({ port: 25 });
434
564
  await createSmtpServer(options).listen({ port: 2525 });
435
565
  ```
436
566
 
567
+ ### `SmtpError: listen(): the server is already starting to listen`
568
+
569
+ **When**: a second `listen` on the same server while the first has not
570
+ resolved yet. With `implicitTls`, `listen` reads the key and certificate
571
+ before it binds, so two calls not awaited in turn meet here. Its `code` is
572
+ `ALREADY_LISTENING`; the first call binds, this one binds nothing.
573
+
574
+ **Fix**: await `listen` once per server, and create one server per port:
575
+
576
+ ```ts
577
+ const server = createSmtpServer({ ...options, tls, implicitTls: true });
578
+ await server.listen({ port: 465 }); // not Promise.all([server.listen(…), server.listen(…)])
579
+ ```
580
+
581
+ ### `SmtpError: listen(): stop() was called before the server bound its port`
582
+
583
+ **When**: `stop()` ran before `listen` resolved: on an `implicitTls` server
584
+ while it reads the TLS key and certificate, on any other at once, since
585
+ `listen` resolves a tick after it binds. `listen` rejects with
586
+ `code: 'STOPPED'` and leaves nothing listening; a later `listen` binds as
587
+ usual.
588
+
589
+ **Why**: `stop()` means the server is not to serve; binding afterwards would
590
+ leave a listener nothing could stop.
591
+
592
+ **Fix**: nothing is wrong if the stop was meant. To start again, call
593
+ `listen` again:
594
+
595
+ ```ts
596
+ await server.listen({ port: 465 }).catch((error) => {
597
+ if (error.code !== 'STOPPED') throw error;
598
+ });
599
+ ```
600
+
601
+ ### `SmtpError: listen(): tls: { key, cert } cannot be used: …`
602
+
603
+ **When**: `listen` on an `implicitTls` server whose `tls.key` or
604
+ `tls.cert` cannot be read — a `Bun.file` that does not exist — or is not
605
+ a PEM key or certificate. The same error, word for word, with
606
+ `proxyProtocol` or without. Its `code` is `INVALID_OPTION`; the rest of
607
+ the message, and its `cause`, are the reason from the file system or
608
+ `node:tls` (`ENOENT: no such file or directory, open '…'`,
609
+ `…PEM routines…NO_START_LINE`). Nothing is bound.
610
+
611
+ **Why**: `listen` reads both and checks them as a TLS context before it
612
+ binds, so a bad key fails at start, not at the first client.
613
+
614
+ **Fix**: pass the PEM text, or a `Bun.file` of a path that exists:
615
+
616
+ ```ts
617
+ const tls = {
618
+ key: Bun.file('/etc/bumail/tls/privkey.pem'),
619
+ cert: Bun.file('/etc/bumail/tls/fullchain.pem'),
620
+ };
621
+ try {
622
+ await createSmtpServer({ ...options, tls, implicitTls: true }).listen({ port: 465 });
623
+ } catch (error) {
624
+ console.error(error, (error as Error).cause);
625
+ }
626
+ ```
627
+
437
628
  ## Relaying and authentication
438
629
 
439
630
  ### `554 5.7.1 Relay access denied`
@@ -1443,6 +1634,134 @@ const server = createSmtpServer({
1443
1634
  });
1444
1635
  ```
1445
1636
 
1637
+ ### Connections through the proxy close at once, with no greeting
1638
+
1639
+ **When**: a client connecting through a TCP proxy gets no `220`, and the
1640
+ connection closes without a reply — at once, or `handshakeTimeout` seconds
1641
+ (10 by default) after it opened. `onConnect` is not called, `onError` is
1642
+ told nothing, and `connections` does not count the socket.
1643
+
1644
+ **Why**: the server trusts the proxy (`proxyProtocol.trusted` lists it),
1645
+ so it waits for a PROXY header first, and resets a socket whose header is
1646
+ missing, late or not valid, writing nothing. Usually:
1647
+
1648
+ - the proxy is not configured to send the header at all, so the client's
1649
+ bytes, or nothing, come first;
1650
+ - it sends a version 1 line longer than 107 bytes, or a version 2 header
1651
+ with more than 2048 bytes of TLVs;
1652
+ - something other than the proxy connects from a listed address, a health
1653
+ checker that opens a bare TCP connection for instance.
1654
+
1655
+ On an `implicitTls` port, the reverse also closes at once: the proxy sends
1656
+ a header to a server that does not trust it, and the header is not a TLS
1657
+ ClientHello, so the handshake fails.
1658
+
1659
+ **Fix**: send the header from the proxy (with Traefik, a TCP
1660
+ `serversTransport` with `proxyProtocol: { version: 2 }`, named by the
1661
+ service), and list the proxy on the server. Both
1662
+ sides together, or neither:
1663
+
1664
+ ```ts
1665
+ import { createSmtpServer } from '@bumail/smtp';
1666
+
1667
+ createSmtpServer({
1668
+ hostname: 'mx.example.com',
1669
+ localDomains: ['example.com'],
1670
+ proxyProtocol: { trusted: ['10.0.0.5'] }, // the address the proxy connects from
1671
+ handshakeTimeout: 10, // the header must arrive within it
1672
+ onData: async (message) => {
1673
+ await new Response(message.content).bytes();
1674
+ },
1675
+ });
1676
+ ```
1677
+
1678
+ The [guide](guide.md#with-traefik) has a Traefik configuration.
1679
+
1680
+ ### Every client through the proxy is refused with `554 … Talked before the greeting`
1681
+
1682
+ **When**: on a clear port (25, 587), every connection through the proxy
1683
+ gets `554 … Talked before the greeting`, and the server sees every client
1684
+ with the proxy's address. When the header came after the greeting, the
1685
+ client gets replies to input it never sent instead: `500` to a version 1
1686
+ line starting `PROXY`, error replies to the binary of a version 2 header.
1687
+
1688
+ **Why**: the proxy sends a PROXY header, but the server does not trust it:
1689
+ `proxyProtocol` is not set, or `trusted` does not list the address the
1690
+ proxy connects from. To a peer not listed, the header is the client's own
1691
+ input, sent before the greeting, and it never sets an address.
1692
+
1693
+ **Fix**: list the proxy's address in `proxyProtocol.trusted`, as the
1694
+ address the server sees it connect from; see the next entry for how to
1695
+ find it.
1696
+
1697
+ ### Every client shows the proxy's address
1698
+
1699
+ **When**: `session.remoteAddress`, the Received field and
1700
+ `maxConnectionsPerClient` see one address, the proxy's, for every client:
1701
+ one more client past `maxConnectionsPerClient` is refused with `421 4.7.0
1702
+ … Too many connections from your address, try later`.
1703
+
1704
+ **Why**: the server is not reading a PROXY header from that peer. Either
1705
+ the proxy sends none — the server then serves its connections as any
1706
+ other's — or the server does not list it, and the header it sends is
1707
+ refused as bad input (the entry above). A common case is a proxy that
1708
+ connects from another address than the one listed: another container
1709
+ network, an IPv6 address, or a new address after a restart. An
1710
+ IPv4-mapped peer (`::ffff:172.20.0.2`) matches an IPv4 entry; it needs no
1711
+ entry of its own.
1712
+
1713
+ **Fix**: log the peer the server sees, then list exactly that address —
1714
+ not the range clients come from:
1715
+
1716
+ ```ts
1717
+ import { createSmtpServer } from '@bumail/smtp';
1718
+
1719
+ createSmtpServer({
1720
+ hostname: 'mx.example.com',
1721
+ localDomains: ['example.com'],
1722
+ // Without proxyProtocol, every connection through the proxy shows its address.
1723
+ onConnect: (session) => console.log('peer', session.remoteAddress),
1724
+ onData: async (message) => {
1725
+ await new Response(message.content).bytes();
1726
+ },
1727
+ });
1728
+ ```
1729
+
1730
+ Then configure the proxy to send the header, and set
1731
+ `proxyProtocol: { trusted: ['<that address>'] }`.
1732
+
1733
+ ### Health checks from the proxy show its own address
1734
+
1735
+ **When**: with `proxyProtocol` set and working, some connections still have
1736
+ the proxy's address in `session.remoteAddress`: they open and close
1737
+ without a transaction, at a steady pace.
1738
+
1739
+ **Why**: by design. The proxy's own connections — health checks — come
1740
+ with a version 2 `LOCAL` header, which names no client, so the server
1741
+ keeps the peer's address, the proxy's. So do a v1 `PROXY UNKNOWN` and a
1742
+ v2 header with an UNSPEC or UNIX address, or over UDP.
1743
+
1744
+ **Fix**: none needed. To keep them out of your logs or your policy, skip
1745
+ the proxy's address in `onConnect`:
1746
+
1747
+ ```ts
1748
+ import { createSmtpServer } from '@bumail/smtp';
1749
+
1750
+ const proxies = ['10.0.0.5'];
1751
+
1752
+ createSmtpServer({
1753
+ hostname: 'mx.example.com',
1754
+ localDomains: ['example.com'],
1755
+ proxyProtocol: { trusted: proxies },
1756
+ onConnect: (session) => {
1757
+ if (!proxies.includes(session.remoteAddress)) console.log('client', session.remoteAddress);
1758
+ },
1759
+ onData: async (message) => {
1760
+ await new Response(message.content).bytes();
1761
+ },
1762
+ });
1763
+ ```
1764
+
1446
1765
  ### `421 4.4.2 … Idle too long, closing`
1447
1766
 
1448
1767
  **When**: nothing came from the client for `timeout` seconds (300 by
@@ -1496,7 +1815,9 @@ client library does; a hand-written client or a test script may not.
1496
1815
 
1497
1816
  **Fix**, as the operator: if a legitimate sender is refused, lower
1498
1817
  `greetingDelay`, or leave it at `0`; a test client that writes at once
1499
- needs it at `0`.
1818
+ needs it at `0`. If every client through a TCP proxy gets it, the proxy
1819
+ sends a PROXY header the server does not trust: see
1820
+ [that entry](#every-client-through-the-proxy-is-refused-with-554--talked-before-the-greeting).
1500
1821
 
1501
1822
  ## Sending mail: options
1502
1823
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bumail/smtp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "An SMTP server on Bun.listen — RFC 5321 with PIPELINING, SIZE, 8BITMIME, SMTPUTF8, STARTTLS and AUTH after TLS, policy hooks, and no open relay — and a client that delivers a message to a smarthost or by MX",
5
5
  "license": "MIT",
6
6
  "type": "module",