Choose the Stalwart by the domain somebody signs in with #241

Closed
opened 2026-09-02 21:19:05 +00:00 by jcoffey-dev · 0 comments
Owner

Closes #238. One ihasmail in front of several Stalwarts.

STALWART_URL stays required and stays the default, so an installation that sets nothing behaves exactly as it always has. The mapping only adds domains that go elsewhere.

-e STALWART_SERVERS_FILE=/etc/ihasmail/servers.json \
-v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro

The routing rule

An unlisted domain goes to the default. So does a bare username — Stalwart accepts one, and it has no domain to map.

A listed domain never falls back. If its server is unreachable that sign-in fails rather than retrying against the default, because falling back would authenticate somebody against a server their domain was deliberately routed away from — and if the same account name existed there, they would land in another tenant's mailbox. The fallback is a decision about unmapped domains, taken before any network call, not a recovery path.

Why it stayed small

Only four places read config.stalwartUrl, all in upstream.ts.

The upstream session now records which server issued it, because the relative URLs inside it (apiUrl, uploadUrl, eventSourceUrl) only mean anything against that server. Every route that already holds a session therefore gets the right upstream without a second lookup.

The client is untouched. It talks to one proxy and never learns there is more than one server behind it — which is exactly why this is small and several-servers-at-once is not (JMAP account ids are only unique per server; ours is literally n).

The upstream is derived from the username rather than stored on the session, so a mapping change takes effect on restart instead of being frozen into sessions that outlive it.

Validation

Boot-time, like the settings policy — and checked by hand against a real config load, not just asserted:

File Result
Good, with "Example.COM " and a trailing slash loads as example.com → https://mail.example.com
example.com and EXAMPLE.com. together refused — duplicate once normalised
"not-a-url" refused — not absolute
ftp://… refused — must be http or https
Missing file refused

The servers themselves are not contacted at boot. A mapping is a routing table, not a health check, and one customer's outage must not stop ihasmail starting for the other four. What happens when one is unreachable at sign-in is #239, fixed separately and merged.

Tests

Eight on the routing — no mapping at all, bare usernames, a mapped domain, an unmapped domain alongside mapped ones, case and trailing-dot and stray-space normalisation, and an address with an @ in the local part matching on the last one. Two more guard the shipped stalwart-servers.example.json, so an example that stops loading fails CI rather than somebody's first deployment.

npm run typecheck, npm test (992 web + 142 server), npm run build pass. README and .env.example document both forms.

Merged 2026-09-02 as coffey-labs/ihasmail@af0e8b2be2

Rebuilt from: git history, session transcript.

Closes #238. One ihasmail in front of several Stalwarts. **`STALWART_URL` stays required and stays the default**, so an installation that sets nothing behaves exactly as it always has. The mapping only adds domains that go elsewhere. ```bash -e STALWART_SERVERS_FILE=/etc/ihasmail/servers.json \ -v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro ``` ## The routing rule An **unlisted** domain goes to the default. So does a **bare username** — Stalwart accepts one, and it has no domain to map. A **listed** domain never falls back. If its server is unreachable that sign-in fails rather than retrying against the default, because falling back would authenticate somebody against a server their domain was deliberately routed away from — and if the same account name existed there, they would land in another tenant's mailbox. The fallback is a decision about *unmapped* domains, taken before any network call, not a recovery path. ## Why it stayed small Only four places read `config.stalwartUrl`, all in `upstream.ts`. The upstream session now **records which server issued it**, because the relative URLs inside it (`apiUrl`, `uploadUrl`, `eventSourceUrl`) only mean anything against that server. Every route that already holds a session therefore gets the right upstream without a second lookup. **The client is untouched.** It talks to one proxy and never learns there is more than one server behind it — which is exactly why this is small and several-servers-*at-once* is not (JMAP account ids are only unique per server; ours is literally `n`). The upstream is **derived from the username rather than stored on the session**, so a mapping change takes effect on restart instead of being frozen into sessions that outlive it. ## Validation Boot-time, like the settings policy — and checked by hand against a real config load, not just asserted: | File | Result | | --- | --- | | Good, with `"Example.COM "` and a trailing slash | loads as `example.com → https://mail.example.com` | | `example.com` and `EXAMPLE.com.` together | refused — duplicate once normalised | | `"not-a-url"` | refused — not absolute | | `ftp://…` | refused — must be http or https | | Missing file | refused | The servers themselves are **not** contacted at boot. A mapping is a routing table, not a health check, and one customer's outage must not stop ihasmail starting for the other four. What happens when one is unreachable at sign-in is #239, fixed separately and merged. ## Tests Eight on the routing — no mapping at all, bare usernames, a mapped domain, an unmapped domain alongside mapped ones, case and trailing-dot and stray-space normalisation, and an address with an `@` in the local part matching on the last one. Two more guard the shipped `stalwart-servers.example.json`, so an example that stops loading fails CI rather than somebody's first deployment. `npm run typecheck`, `npm test` (992 web + 142 server), `npm run build` pass. README and `.env.example` document both forms. **Merged** 2026-09-02 as coffey-labs/ihasmail@af0e8b2be239 <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.