Push by subscription: hold no upstream connection per tab

A signed-in tab held two sockets: the browser's, and one from ihasmail to
Stalwart carrying that tab's push stream. The upstream one was most of what a
tab cost, and the only reason Stalwart's connection limit applied to ihasmail
at all.

RFC 8620 section 7.2 defines the other push transport: a PushSubscription,
where the server POSTs StateChange objects to a URL the client registers.
Stalwart 0.16.20 implements it. ihasmail now registers one subscription per
account at sign-in, and when Stalwart POSTs a change, fans it out to that
account's open tabs over the browser-facing streams it already holds. A tab
opens on the relay as before and is moved to fan-out the moment its account
verifies -- the upstream request is ended, the browser stream is untouched,
and nothing keeps a reference to what was torn down. After that there is no
upstream connection at all. The shapes are the RFC's; nothing here is taken
from any other client.

Measured at a 256 MiB cap over a private plain-HTTP route, against a real
Stalwart with 6,144 accounts verifying during the ramp and no failures:

                                 tabs   client   Stalwart   system  KiB/tab
  raw relay (before)            5,000     48.2       46.4     94.6
  push by subscription          6,144     33.3        4.8     38.0
  a direct-to-server client   12,389      4.8       53.8     58.6

Descriptors per tab: one, the browser's. Stalwart pays 4.8 KiB per tab and
holds no connection for it, so its per-listener connection limit no longer
applies to ihasmail. What remains per tab on the client is Node's cost for a
held HTTP/1.1 connection.

PUSH_URL is the https origin Stalwart can reach ihasmail at. The RFC requires
https and Stalwart enforces it, so Stalwart must trust that certificate: a
public TLS front already does; a private segment needs an internal CA in
Stalwart's trust store. An account whose subscription cannot be verified
stays on the relay, so nothing breaks -- only the saving needs the
certificate. PUSH_MODE=relay disables the subscription path entirely.

/api/push/:token accepts only a JSON body under 64 KiB for a known 32-byte
token, answers 200 or 404, and echoes nothing. /api/health reports how many
accounts are verified, pending or failed and how many tabs are on each path.
This commit is contained in:
2026-09-06 13:30:34 -07:00
parent f569f2cc7a
commit 2c47c0851c
4 changed files with 384 additions and 3 deletions
+10
View File
@@ -309,6 +309,16 @@ export const config = {
apiRateLimit: int("API_RATE_LIMIT", 1200),
/* Whether JMAP responses are gzipped. Measured: see the bake-off rerun. */
compressJmap: process.env.COMPRESS_JMAP !== "0",
/*
* How push reaches the browser. "relay" holds one upstream stream per tab
* (today's behaviour). "subscribe" registers one JMAP PushSubscription per
* account and fans Stalwart's POSTs out to that account's tabs, holding no
* upstream connection at all -- see push.ts. It needs PUSH_URL: the https
* origin Stalwart can reach ihasmail at, with a certificate it trusts.
* An account that cannot be verified stays on the relay.
*/
pushMode: (process.env.PUSH_MODE === "relay" ? "relay" : "subscribe") as "relay" | "subscribe",
pushUrl: process.env.PUSH_URL || "",
/* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */
rawPushRelay: process.env.RAW_PUSH_RELAY !== "0",
/* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */