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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #238. One ihasmail in front of several Stalwarts.
STALWART_URLstays 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.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 inupstream.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:
"Example.COM "and a trailing slashexample.com → https://mail.example.comexample.comandEXAMPLE.com.together"not-a-url"ftp://…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 shippedstalwart-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 buildpass. README and.env.exampledocument both forms.Merged 2026-09-02 as coffey-labs/ihasmail@af0e8b2be2
Rebuilt from: git history, session transcript.