@bumail/smtp 0.5.0 → 0.7.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.
@@ -1 +1 @@
1
- {"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../src/server/settings.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAGnD,qDAAqD;AACrD,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,OAAO,EAAE,iBAAiB,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,IAAI,GAAG,YAAY,CAAC;IACnC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,uBAAuB,EAAE,MAAM,CAAC;IACzC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,sFAAsF;IACtF,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IAC5C,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACpD;AAkED,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,QAAQ,CA4D/D"}
1
+ {"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../src/server/settings.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAEnD,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE1C,qDAAqD;AACrD,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,OAAO,EAAE,iBAAiB,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,IAAI,GAAG,YAAY,CAAC;IACnC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,uBAAuB,EAAE,MAAM,CAAC;IACzC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,mFAAmF;IACnF,QAAQ,CAAC,GAAG,CAAC,EAAE,SAAS,CAAC;IACzB,sFAAsF;IACtF,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IAC5C,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACpD;AAkED,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,QAAQ,CA6D/D"}
@@ -1,16 +1,46 @@
1
1
  import { type SecureContext } from 'node:tls';
2
2
  /** `key` or `cert` as `Bun.listen` takes them: text, bytes or a file. */
3
3
  type TlsMaterial = string | Uint8Array | Bun.BunFile;
4
+ /** A key and certificate pair, as `Bun.listen` takes it. */
5
+ interface TlsPair {
6
+ readonly key: TlsMaterial;
7
+ readonly cert: TlsMaterial;
8
+ }
9
+ /** `tls` read whole, with the `node:tls` context made from it. */
10
+ interface ReadTls {
11
+ readonly key: string | Buffer;
12
+ readonly cert: string | Buffer;
13
+ readonly context: SecureContext;
14
+ }
4
15
  /**
5
16
  * `tls` read whole — a file included — and checked as a `node:tls` context:
6
17
  * what implicit TLS behind a proxy runs on, and, without a proxy, the check
7
18
  * that makes a key or certificate that cannot be read or used fail
8
- * `listen()` the same way. `fail` turns the reason into the package's own
9
- * error, the `node:tls` or file error as its `cause`.
19
+ * `listen()` and `setTls()` the same way. `fail` turns the reason into the
20
+ * package's own error, the `node:tls` or file error as its `cause`.
10
21
  */
11
- export declare function tlsContext(tls: {
12
- readonly key: TlsMaterial;
13
- readonly cert: TlsMaterial;
14
- }, fail: (message: string, cause: unknown) => Error): Promise<SecureContext>;
22
+ export declare function readTls(tls: TlsPair, fail: (message: string, cause: unknown) => Error, caller: string): Promise<ReadTls>;
23
+ /**
24
+ * The key and certificate the server uses now. What `listen()` or `setTls()`
25
+ * read replaces it whole, and only once checked, so a pair that cannot be
26
+ * used leaves the one in use. A STARTTLS server that has read nothing yet
27
+ * serves `tls` as it was given.
28
+ */
29
+ export declare class TlsHolder {
30
+ #private;
31
+ constructor(given: TlsPair);
32
+ /** What an upgrade to TLS, or a native listener, is given. */
33
+ get options(): TlsPair;
34
+ /** The `node:tls` context of the pair in use: only once `load` or `replace` read one. */
35
+ get context(): SecureContext;
36
+ /** Reads and checks the pair given, unless a read already replaced it. */
37
+ load(fail: (message: string, cause: unknown) => Error, caller: string): Promise<SecureContext>;
38
+ /**
39
+ * Checks `tls`, then uses it from the next connection on. Calls apply in
40
+ * the order they were made, however long each read takes; one that fails
41
+ * does not hold the next.
42
+ */
43
+ replace(tls: TlsPair, fail: (message: string, cause: unknown) => Error, caller: string): Promise<void>;
44
+ }
15
45
  export {};
16
46
  //# sourceMappingURL=tls-context.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tls-context.d.ts","sourceRoot":"","sources":["../../src/server/tls-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAEnE,yEAAyE;AACzE,KAAK,WAAW,GAAG,MAAM,GAAG,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC;AAErD;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC/B,GAAG,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;CAAE,EAC9D,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,KAAK,GAC9C,OAAO,CAAC,aAAa,CAAC,CAcxB"}
1
+ {"version":3,"file":"tls-context.d.ts","sourceRoot":"","sources":["../../src/server/tls-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAEnE,yEAAyE;AACzE,KAAK,WAAW,GAAG,MAAM,GAAG,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC;AAErD,4DAA4D;AAC5D,UAAU,OAAO;IAChB,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;CAC3B;AAED,kEAAkE;AAClE,UAAU,OAAO;IAChB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;CAChC;AAED;;;;;;GAMG;AACH,wBAAsB,OAAO,CAC5B,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,KAAK,EAChD,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAoBlB;AAED;;;;;GAKG;AACH,qBAAa,SAAS;;gBAMT,KAAK,EAAE,OAAO;IAI1B,8DAA8D;IAC9D,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,yFAAyF;IACzF,IAAI,OAAO,IAAI,aAAa,CAG3B;IAED,0EAA0E;IACpE,IAAI,CACT,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,KAAK,EAChD,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,CAAC;IAOzB;;;;OAIG;IACH,OAAO,CACN,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,KAAK,EAChD,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC;CAOhB"}
package/docs/guide.md CHANGED
@@ -15,6 +15,7 @@ behaviour.
15
15
  - [Delivering into @bumail/store](#delivering-into-bumailstore)
16
16
  - [Authentication](#authentication)
17
17
  - [TLS](#tls)
18
+ - [Renewing the certificate without a restart](#renewing-the-certificate-without-a-restart)
18
19
  - [Limits](#limits)
19
20
  - [Running behind a TCP proxy](#running-behind-a-tcp-proxy)
20
21
  - [Sending mail: the client](#sending-mail-the-client)
@@ -781,9 +782,10 @@ export interface TlsOptions {
781
782
  }
782
783
  ```
783
784
 
784
- `key` and `cert` are PEM, as `Bun.listen` takes them. Read them once, at
785
+ `key` and `cert` are PEM, as `Bun.listen` takes them. Read them at
785
786
  start-up, with `Bun.file().text()`; a missing file then fails before the
786
- server listens, not at a client's first STARTTLS:
787
+ server listens, not at a client's first STARTTLS. A renewed pair is
788
+ applied with `setTls`, [below](#renewing-the-certificate-without-a-restart):
787
789
 
788
790
  ```ts
789
791
  import { createSmtpServer } from '@bumail/smtp';
@@ -840,10 +842,13 @@ reads them over TLS and `session.secure` is already `true`. A handshake
840
842
  that fails is closed by Bun at once and counted out. A STARTTLS handshake
841
843
  is bounded by the idle `timeout`, the connection already counted.
842
844
 
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.
845
+ The listener of an implicit TLS port is a clear one, and each socket is
846
+ upgraded to TLS (`socket.upgradeTLS`) as it opens, with the pair in use
847
+ at that moment: that is what lets `setTls` apply to the next connection
848
+ (see [below](#renewing-the-certificate-without-a-restart)). The upgraded
849
+ socket calls `open` at the TCP connection, with the socket's timer
850
+ running during the handshake, which is how a stuck handshake is counted
851
+ and closed.
847
852
 
848
853
  Behind a TCP proxy that sends the PROXY protocol, implicit TLS starts after
849
854
  the proxy's header, and runs differently: see
@@ -912,6 +917,67 @@ await createSmtpServer(submission).listen({ port: 587 });
912
917
  await createSmtpServer({ ...submission, implicitTls: true }).listen({ port: 465 });
913
918
  ```
914
919
 
920
+ ### Renewing the certificate without a restart
921
+
922
+ A certificate that is renewed — by certbot, by Traefik's dump, by an ACME
923
+ client — should take effect without a restart, and without dropping the
924
+ sessions open. `setTls({ key, cert })` does that:
925
+
926
+ ```ts
927
+ const mx = createSmtpServer({ hostname: 'mx.example.com', localDomains: ['example.com'], tls, onData });
928
+ await mx.listen({ port: 25 });
929
+
930
+ // Later, once the files changed:
931
+ await mx.setTls({
932
+ key: await Bun.file('/etc/ssl/mx.example.com.key').text(),
933
+ cert: await Bun.file('/etc/ssl/mx.example.com.crt').text(),
934
+ });
935
+ ```
936
+
937
+ - **What changes**: the next STARTTLS upgrade, and on implicit TLS the next
938
+ connection, use the new pair. A session already encrypted keeps the TLS
939
+ it has until it ends, and goes on working; a handshake that began
940
+ before the call may still finish on the old pair.
941
+ - **It is checked first.** `setTls` reads both (a `Bun.file` included) and
942
+ makes a TLS context of them, as `listen()` does on implicit TLS. A pair
943
+ that cannot be read, is not PEM or is a key for another certificate
944
+ rejects with an `SmtpError`, `INVALID_OPTION`, message `setTls(): tls:
945
+ { key, cert } cannot be used: …`, the reason as its `cause`; the old
946
+ pair stays in use. It does not check that the certificate has not
947
+ expired or names the host: that is the caller's.
948
+ - **One call per server.** The MX on 25, submission on 587 and the
949
+ implicit TLS server on 465 are three servers, each with its own
950
+ `setTls`. Call it on all of them with the same pair.
951
+ - **Calls apply in order.** Two `setTls` in flight, the first reading
952
+ files, end on the second's pair: the later call wins. A key or
953
+ certificate that is empty is refused like any pair that cannot be used.
954
+ - **Before `listen()` too**: the pair set is the one served. A server made
955
+ without `tls` has none to replace: `setTls` rejects with
956
+ `INVALID_OPTION`.
957
+ - **A file read at `setTls`, not at each connection.** Passing a
958
+ `Bun.file` reads it once, when `setTls` runs: to take a later change,
959
+ call `setTls` again.
960
+
961
+ How it works on each kind of port, measured on Bun 1.4.2:
962
+
963
+ - **STARTTLS**: the options of `socket.upgradeTLS` are read for each
964
+ upgrade, so the server swaps the pair it keeps.
965
+ - **Native implicit TLS**: a `Bun.listen` TLS listener keeps the TLS
966
+ context it was created with. `listener.reload()` takes handlers only,
967
+ and a `tls` given to it is ignored (a new connection still got the old
968
+ certificate). The port listens in clear instead, and every socket is
969
+ upgraded with `socket.upgradeTLS` as it opens, with the pair in use
970
+ then, which is the STARTTLS mechanism from the first byte. The
971
+ encrypted bytes also pass through the clear socket's handler, which
972
+ drops them.
973
+ - **Implicit TLS behind `proxyProtocol`** already runs on `node:tls`: the
974
+ server swaps the secure context its new sockets are given.
975
+ - **A second listener on the same port** was not used: `Bun.listen` takes
976
+ `reusePort`, so a new listener could bind beside the old one and the old
977
+ one stop, but both must have opted in from the start, and a
978
+ connection waiting to be accepted by the old one when it stops is reset.
979
+ Upgrading in `open` has neither cost.
980
+
915
981
  ## Limits
916
982
 
917
983
  | Limit | Default | Past it |
@@ -1584,6 +1650,19 @@ is not. Every other outcome rejects with an `SmtpError`: its `code`, its
1584
1650
  `temporary`, and, when a reply caused it, `reply` with its enhanced status
1585
1651
  code (RFC 3463).
1586
1652
 
1653
+ `afterData` is `true` when the final dot was written and no reply to it came
1654
+ (a `TIMEOUT`, a `CONNECTION_LOST` or a `BAD_REPLY`): the server may already
1655
+ have taken the message (RFC 5321 §4.1.5), so sending it again, or by another
1656
+ route, can duplicate it. It is absent for a failure before the dot, which
1657
+ the server drops, and for a `REFUSED` to the dot, a 4xx or 5xx that says the
1658
+ message was not taken.
1659
+
1660
+ ```ts
1661
+ if (error.temporary && error.afterData) {
1662
+ // the message may be there already: retry the same host, not another route
1663
+ }
1664
+ ```
1665
+
1587
1666
  | `code` | when | `temporary` |
1588
1667
  | --- | --- | --- |
1589
1668
  | `INVALID_OPTION` | an option, an address or the message cannot be used | no |
package/docs/roadmap.md CHANGED
@@ -34,6 +34,12 @@ Nothing in progress.
34
34
 
35
35
  ### Unreleased — merged, not yet published
36
36
 
37
+ - **A renewed certificate without a restart**, `server.setTls({ key, cert
38
+ })`: the next STARTTLS upgrade and the next implicit TLS connection use
39
+ it, the sessions open keep their TLS, and a pair that cannot be used
40
+ throws `INVALID_OPTION` and leaves the old one. Implicit TLS without a
41
+ proxy now listens in clear and upgrades each socket as it opens, since
42
+ a native Bun TLS listener cannot swap its certificate.
37
43
  - **Behind a TCP proxy, the client's own address**, with `proxyProtocol:
38
44
  { trusted: [...] }`: the server reads the PROXY protocol, versions 1 and
39
45
  2, from the proxies listed — a Traefik TCP router, HAProxy — so
@@ -32,6 +32,8 @@ A reply carries its enhanced status code (`5.7.1`, RFC 3463) only after
32
32
  - [`SmtpError: listen(): the server is already listening on …`](#smtperror-listen-the-server-is-already-listening-on-)
33
33
  - [`SmtpError: listen(): the server is already starting to listen`](#smtperror-listen-the-server-is-already-starting-to-listen)
34
34
  - [`SmtpError: listen(): tls: { key, cert } cannot be used: …`](#smtperror-listen-tls--key-cert--cannot-be-used-)
35
+ - [`SmtpError: setTls(): tls: { key, cert } cannot be used: …`](#smtperror-settls-tls--key-cert--cannot-be-used-)
36
+ - [`SmtpError: setTls(): the server was made without tls, so it has none to replace`](#smtperror-settls-the-server-was-made-without-tls-so-it-has-none-to-replace)
35
37
  - [`SmtpError: listen(): stop() was called before the server bound its port`](#smtperror-listen-stop-was-called-before-the-server-bound-its-port)
36
38
 
37
39
  **Relaying and authentication**
@@ -625,6 +627,50 @@ try {
625
627
  }
626
628
  ```
627
629
 
630
+ ### `SmtpError: setTls(): tls: { key, cert } cannot be used: …`
631
+
632
+ **When**: `setTls` was given a renewed pair whose `key` or `cert` cannot
633
+ be read — a `Bun.file` that does not exist, or one caught half-written —
634
+ or is empty (`… the key is empty`, `… the cert is empty`), or is not a
635
+ PEM key or certificate, or a key that is not the certificate's. Its `code` is `INVALID_OPTION`; the rest of the message,
636
+ and its `cause`, are the reason from the file system or `node:tls`
637
+ (`ENOENT: no such file or directory, open '…'`, `…PEM routines…`,
638
+ `key values mismatch`). The pair in use stays, for new connections too.
639
+
640
+ **Why**: `setTls` reads both and checks them as a TLS context before it
641
+ swaps anything, so a bad pair never reaches a client.
642
+
643
+ **Fix**: write both files before calling it — a renewal that writes the key
644
+ first and the certificate after fails here, which is the right answer —
645
+ and retry:
646
+
647
+ ```ts
648
+ try {
649
+ await server.setTls({
650
+ key: Bun.file('/etc/bumail/tls/privkey.pem'),
651
+ cert: Bun.file('/etc/bumail/tls/fullchain.pem'),
652
+ });
653
+ } catch (error) {
654
+ console.error(error, (error as Error).cause); // the old pair is still served
655
+ }
656
+ ```
657
+
658
+ ### `SmtpError: setTls(): the server was made without tls, so it has none to replace`
659
+
660
+ **When**: `setTls` on a server created without the `tls` option. Its `code`
661
+ is `INVALID_OPTION`.
662
+
663
+ **Why**: TLS is what turns on STARTTLS, AUTH and implicit TLS, and each
664
+ changes what the server says: `setTls` replaces a pair, it does not turn
665
+ TLS on.
666
+
667
+ **Fix**: create the server with `tls`, the pair it starts with:
668
+
669
+ ```ts
670
+ const server = createSmtpServer({ ...options, tls: { key, cert } });
671
+ await server.setTls({ key: renewedKey, cert: renewedCert });
672
+ ```
673
+
628
674
  ## Relaying and authentication
629
675
 
630
676
  ### `554 5.7.1 Relay access denied`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bumail/smtp",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",
@@ -47,9 +47,9 @@
47
47
  ]
48
48
  },
49
49
  "devDependencies": {
50
- "@bumail/auth": "^0.3.0",
51
- "@bumail/dns": "^0.1.1",
52
- "@bumail/store": "^0.4.0",
50
+ "@bumail/auth": "^0.4.1",
51
+ "@bumail/dns": "^0.2.1",
52
+ "@bumail/store": "^0.4.1",
53
53
  "@types/bun": "^1.4.2"
54
54
  },
55
55
  "peerDependencies": {