Merge pull request #241 from Coffey-Labs/feat/domain-server-mapping
Choose the Stalwart by the domain somebody signs in with
This commit is contained in:
@@ -93,3 +93,17 @@ SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
|
|||||||
#
|
#
|
||||||
# Read once at startup: editing a policy means restarting the container.
|
# Read once at startup: editing a policy means restarting the container.
|
||||||
# Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides
|
# Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides
|
||||||
|
|
||||||
|
# ---- Several Stalwart servers (optional) ----
|
||||||
|
#
|
||||||
|
# Choose the upstream by the domain someone signs in with. STALWART_URL above
|
||||||
|
# stays required and stays the default; this only adds domains that go
|
||||||
|
# elsewhere. See the shipped stalwart-servers.example.json, and mount it
|
||||||
|
# read-only:
|
||||||
|
#
|
||||||
|
# -v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
|
||||||
|
#
|
||||||
|
# STALWART_SERVERS_FILE=/etc/ihasmail/servers.json
|
||||||
|
#
|
||||||
|
# An unlisted domain, or a username with no domain, goes to STALWART_URL. A
|
||||||
|
# listed domain never falls back. Read once at startup: editing means a restart.
|
||||||
|
|||||||
@@ -117,6 +117,47 @@ nowhere to live across a restart. Removing it means moving the session upstream
|
|||||||
into a token Stalwart itself issues and can revoke, which is what the OAuth work
|
into a token Stalwart itself issues and can revoke, which is what the OAuth work
|
||||||
in [ROADMAP.md](ROADMAP.md) is for.
|
in [ROADMAP.md](ROADMAP.md) is for.
|
||||||
|
|
||||||
|
### Several Stalwart servers
|
||||||
|
|
||||||
|
One ihasmail can front more than one Stalwart, choosing by the domain somebody
|
||||||
|
signs in with. **`STALWART_URL` stays required and stays the default**, so an
|
||||||
|
installation that sets nothing else behaves exactly as it always has.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-e STALWART_SERVERS_FILE=/etc/ihasmail/servers.json \
|
||||||
|
-v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"example.com": "https://mail.example.com",
|
||||||
|
"customer-b.test": "https://jmap.customer-b.test"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[`stalwart-servers.example.json`](stalwart-servers.example.json) is that file
|
||||||
|
with the rules written in it.
|
||||||
|
|
||||||
|
A domain nobody listed — and a bare username, which Stalwart accepts and which
|
||||||
|
has no domain at all — goes to `STALWART_URL`. **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; if the same account name
|
||||||
|
existed there they would land in another tenant's mailbox.
|
||||||
|
|
||||||
|
Read once at startup, so editing it means restarting the container. Malformed
|
||||||
|
JSON, a duplicate domain once lower-cased, or a value that is not an `http(s)`
|
||||||
|
URL stops the server rather than failing quietly at somebody's sign-in. 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 everybody else.
|
||||||
|
|
||||||
|
This is one server per *person*, chosen at sign-in. Several servers at once for
|
||||||
|
one person, with unified or cross-account views, is not supported: JMAP account
|
||||||
|
ids are only unique within a server, so it would mean namespacing ids through
|
||||||
|
the proxy. Reading somebody else's mail, calendars or files on the *same* server
|
||||||
|
already works through JMAP sharing.
|
||||||
|
|
||||||
### Settings the installation decides
|
### Settings the installation decides
|
||||||
|
|
||||||
A deployment can seed and lock user settings, which is what a school wanting
|
A deployment can seed and lock user settings, which is what a school wanting
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function accountId(ctx: Ctx): string {
|
|||||||
type Invocation = [string, Record<string, unknown>, string];
|
type Invocation = [string, Record<string, unknown>, string];
|
||||||
|
|
||||||
async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodResponses?: [string, unknown, string][] }> {
|
async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodResponses?: [string, unknown, string][] }> {
|
||||||
const res = await fetch(absoluteUpstream(ctx.session.apiUrl), {
|
const res = await fetch(absoluteUpstream(ctx.session.apiUrl, ctx.session.baseUrl), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
||||||
body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }),
|
body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }),
|
||||||
|
|||||||
+11
-10
@@ -16,6 +16,7 @@ import {
|
|||||||
forgetUpstreamSession,
|
forgetUpstreamSession,
|
||||||
getAccountInfo,
|
getAccountInfo,
|
||||||
getUpstreamSession,
|
getUpstreamSession,
|
||||||
|
upstreamFor,
|
||||||
localizeSession,
|
localizeSession,
|
||||||
} from "./upstream.js";
|
} from "./upstream.js";
|
||||||
import {
|
import {
|
||||||
@@ -237,7 +238,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
const effectivePassword = totp ? `${password}$${totp}` : password;
|
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||||
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
|
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
|
||||||
try {
|
try {
|
||||||
const upstream = await fetchUpstreamSession(authorization);
|
const upstream = await fetchUpstreamSession(authorization, upstreamFor(username));
|
||||||
// ihasmail requires Stalwart 0.16 or newer. Refuse here, once and
|
// ihasmail requires Stalwart 0.16 or newer. Refuse here, once and
|
||||||
// clearly, rather than signing someone in and letting Files, the account
|
// clearly, rather than signing someone in and letting Files, the account
|
||||||
// locale and self-service credentials each fail in their own way with
|
// locale and self-service credentials each fail in their own way with
|
||||||
@@ -311,7 +312,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
api.get("/auth/session", requireSession, async (c) => {
|
api.get("/auth/session", requireSession, async (c) => {
|
||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1");
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username), c.req.query("refresh") === "1");
|
||||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -528,8 +529,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
return c.json({ error: "unsupported_media_type" }, 415);
|
return c.json({ error: "unsupported_media_type" }, 415);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
const res = await fetch(absoluteUpstream(upstream.apiUrl), {
|
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
authorization: session.authorization,
|
authorization: session.authorization,
|
||||||
@@ -562,8 +563,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
// suggestion; count the bytes as they go past.
|
// suggestion; count the bytes as they go past.
|
||||||
const body = c.req.raw.body ? c.req.raw.body.pipeThrough(byteCap(config.maxUploadBytes)) : null;
|
const body = c.req.raw.body ? c.req.raw.body.pipeThrough(byteCap(config.maxUploadBytes)) : null;
|
||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }));
|
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }), upstream.baseUrl);
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -588,8 +589,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
const accept = c.req.query("accept") ?? "application/octet-stream";
|
const accept = c.req.query("accept") ?? "application/octet-stream";
|
||||||
const inline = c.req.query("inline") === "1";
|
const inline = c.req.query("inline") === "1";
|
||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }));
|
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||||
// on our behalf and hand back a decompressed body whose content-length
|
// on our behalf and hand back a decompressed body whose content-length
|
||||||
@@ -639,8 +640,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
const closeafter = c.req.query("closeafter") ?? "no";
|
const closeafter = c.req.query("closeafter") ?? "no";
|
||||||
const ping = c.req.query("ping") ?? "30";
|
const ping = c.req.query("ping") ?? "30";
|
||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }));
|
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
|
|||||||
@@ -187,6 +187,59 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which Stalwart a domain signs in to.
|
||||||
|
*
|
||||||
|
* `STALWART_URL` stays required and stays the default; this only adds domains
|
||||||
|
* that go somewhere else (#238). An installation that sets nothing behaves
|
||||||
|
* exactly as it always has.
|
||||||
|
*
|
||||||
|
* Read once at boot and never written, so it mounts read-only and costs
|
||||||
|
* nothing in immutability -- the same shape as the settings policy.
|
||||||
|
*
|
||||||
|
* Servers are deliberately **not** probed here. A mapping is a routing table,
|
||||||
|
* not a health check, and refusing to boot because one of five customers is
|
||||||
|
* having an outage would take the other four down with it. What happens when
|
||||||
|
* one is unreachable is a sign-in question, answered in #239.
|
||||||
|
*/
|
||||||
|
function readStalwartServers(): Record<string, string> {
|
||||||
|
const file = process.env.STALWART_SERVERS_FILE;
|
||||||
|
if (!file) return {};
|
||||||
|
if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`);
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(readFileSync(file, "utf8"));
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [rawDomain, rawUrl] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
/* Lower-cased and stripped of the root dot, because that is how a domain
|
||||||
|
taken off a username will arrive and comparing them any other way means
|
||||||
|
a mapping that silently never matches. */
|
||||||
|
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`);
|
||||||
|
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalised`);
|
||||||
|
if (typeof rawUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`);
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not an absolute URL`);
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" must be http or https`);
|
||||||
|
}
|
||||||
|
out[domain] = rawUrl.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
isProd,
|
isProd,
|
||||||
appName: env("APP_NAME", "ihasmail"),
|
appName: env("APP_NAME", "ihasmail"),
|
||||||
@@ -222,6 +275,7 @@ export const config = {
|
|||||||
*/
|
*/
|
||||||
basePath: normalizeBasePath(process.env.BASE_PATH),
|
basePath: normalizeBasePath(process.env.BASE_PATH),
|
||||||
stalwartUrl,
|
stalwartUrl,
|
||||||
|
stalwartServers: readStalwartServers(),
|
||||||
appSecret,
|
appSecret,
|
||||||
trustProxy: bool("TRUST_PROXY", true),
|
trustProxy: bool("TRUST_PROXY", true),
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
process.env.STALWART_URL = "https://default.example";
|
||||||
|
|
||||||
|
const { upstreamFor } = await import("./upstream.js");
|
||||||
|
const { config } = await import("./config.js");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which Stalwart a username goes to (#238).
|
||||||
|
*
|
||||||
|
* `STALWART_URL` is required and is the default. The mapping only adds domains
|
||||||
|
* that go elsewhere, so an installation with no mapping behaves exactly as it
|
||||||
|
* always has -- which is what these first cases pin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test("with no mapping at all, everything goes to the default", () => {
|
||||||
|
assert.deepEqual(config.stalwartServers, {});
|
||||||
|
assert.equal(upstreamFor("[email protected]"), "https://default.example");
|
||||||
|
assert.equal(upstreamFor("[email protected]"), "https://default.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bare username has no domain to map, so it goes to the default", () => {
|
||||||
|
// Stalwart accepts a login with no domain at all.
|
||||||
|
assert.equal(upstreamFor("demo"), "https://default.example");
|
||||||
|
assert.equal(upstreamFor(""), "https://default.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a mapped domain goes to its own server", () => {
|
||||||
|
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
|
||||||
|
try {
|
||||||
|
assert.equal(upstreamFor("[email protected]"), "https://mail.mapped.test");
|
||||||
|
} finally {
|
||||||
|
delete config.stalwartServers["mapped.test"];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unmapped domain still goes to the default while others are mapped", () => {
|
||||||
|
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
|
||||||
|
try {
|
||||||
|
assert.equal(upstreamFor("[email protected]"), "https://default.example");
|
||||||
|
} finally {
|
||||||
|
delete config.stalwartServers["mapped.test"];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the domain is matched however it was typed", () => {
|
||||||
|
// Keys are normalised on load; the username has to be normalised the same
|
||||||
|
// way or a mapping silently never matches.
|
||||||
|
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
|
||||||
|
try {
|
||||||
|
assert.equal(upstreamFor("[email protected]"), "https://mail.mapped.test");
|
||||||
|
assert.equal(upstreamFor("[email protected]."), "https://mail.mapped.test", "root dot");
|
||||||
|
assert.equal(upstreamFor("someone@ mapped.test "), "https://mail.mapped.test", "stray spaces");
|
||||||
|
} finally {
|
||||||
|
delete config.stalwartServers["mapped.test"];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an address with an @ in the local part maps on the last one", () => {
|
||||||
|
config.stalwartServers["mapped.test"] = "https://mail.mapped.test";
|
||||||
|
try {
|
||||||
|
assert.equal(upstreamFor('"odd@name"@mapped.test'), "https://mail.mapped.test");
|
||||||
|
} finally {
|
||||||
|
delete config.stalwartServers["mapped.test"];
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -53,3 +53,30 @@ test("the example's commentary cannot be mistaken for a section", () => {
|
|||||||
assert.ok(real.has(key) || key.startsWith("_"), `unexpected top-level key ${key}`);
|
assert.ok(real.has(key) || key.startsWith("_"), `unexpected top-level key ${key}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shipped server-mapping example, checked the same way and for the same
|
||||||
|
* reason: an example that no longer loads is worse than no example, because
|
||||||
|
* the first experience of the feature is a server that refuses to start.
|
||||||
|
*/
|
||||||
|
const SERVERS = fileURLToPath(new URL("../../stalwart-servers.example.json", import.meta.url));
|
||||||
|
|
||||||
|
test("the example server mapping is valid JSON", () => {
|
||||||
|
assert.doesNotThrow(() => JSON.parse(readFileSync(SERVERS, "utf8")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every entry in the example mapping is a domain and an http(s) URL", () => {
|
||||||
|
const m = JSON.parse(readFileSync(SERVERS, "utf8")) as Record<string, unknown>;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const [key, value] of Object.entries(m)) {
|
||||||
|
if (key.startsWith("_")) continue;
|
||||||
|
const domain = key.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
assert.ok(domain, "a domain key is empty");
|
||||||
|
assert.ok(!seen.has(domain), `${domain} appears twice once normalised`);
|
||||||
|
seen.add(domain);
|
||||||
|
assert.equal(typeof value, "string", `${domain} is not a string`);
|
||||||
|
const url = new URL(value as string);
|
||||||
|
assert.ok(url.protocol === "http:" || url.protocol === "https:", `${domain} must be http or https`);
|
||||||
|
}
|
||||||
|
assert.ok(seen.size > 0, "the example should show at least one mapping");
|
||||||
|
});
|
||||||
|
|||||||
+42
-12
@@ -10,6 +10,15 @@ export interface UpstreamSession {
|
|||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
eventSourceUrl: string;
|
eventSourceUrl: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
/**
|
||||||
|
* Which Stalwart this document came from.
|
||||||
|
*
|
||||||
|
* Recorded rather than looked up again, because the relative URLs inside it
|
||||||
|
* -- apiUrl, uploadUrl and the rest -- only mean anything against the server
|
||||||
|
* that issued them. Anything holding a session already knows where to send
|
||||||
|
* the next request. Not part of the JMAP session resource; ours.
|
||||||
|
*/
|
||||||
|
baseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpstreamError extends Error {
|
export class UpstreamError extends Error {
|
||||||
@@ -24,16 +33,37 @@ export class UpstreamError extends Error {
|
|||||||
const sessionCache = new Map<string, { session: UpstreamSession; fetchedAt: number }>();
|
const sessionCache = new Map<string, { session: UpstreamSession; fetchedAt: number }>();
|
||||||
const SESSION_CACHE_MS = 5 * 60_000;
|
const SESSION_CACHE_MS = 5 * 60_000;
|
||||||
|
|
||||||
export function wellKnownUrl(): string {
|
/**
|
||||||
return `${config.stalwartUrl}/.well-known/jmap`;
|
* The Stalwart a username belongs to.
|
||||||
|
*
|
||||||
|
* `STALWART_URL` is the default and is always the answer for a domain nobody
|
||||||
|
* mapped -- and for a bare username, which Stalwart accepts and which has no
|
||||||
|
* domain to map (#238).
|
||||||
|
*
|
||||||
|
* A *mapped* domain never falls back. If its server is unreachable that
|
||||||
|
* sign-in fails, because falling back would authenticate somebody against a
|
||||||
|
* server their domain was deliberately routed away from -- and if the same
|
||||||
|
* account name exists 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.
|
||||||
|
*/
|
||||||
|
export function upstreamFor(username: string): string {
|
||||||
|
const at = username.lastIndexOf("@");
|
||||||
|
if (at < 0) return config.stalwartUrl;
|
||||||
|
const domain = username.slice(at + 1).trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
return config.stalwartServers[domain] ?? config.stalwartUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wellKnownUrl(base: string = config.stalwartUrl): string {
|
||||||
|
return `${base}/.well-known/jmap`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the JMAP session resource from Stalwart using the given Authorization
|
* Fetch the JMAP session resource from Stalwart using the given Authorization
|
||||||
* header. Throws UpstreamError(401) on bad credentials.
|
* header. Throws UpstreamError(401) on bad credentials.
|
||||||
*/
|
*/
|
||||||
export async function fetchUpstreamSession(authorization: string): Promise<UpstreamSession> {
|
export async function fetchUpstreamSession(authorization: string, base: string = config.stalwartUrl): Promise<UpstreamSession> {
|
||||||
const res = await fetch(wellKnownUrl(), {
|
const res = await fetch(wellKnownUrl(base), {
|
||||||
headers: { authorization, accept: "application/json" },
|
headers: { authorization, accept: "application/json" },
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
@@ -46,13 +76,13 @@ export async function fetchUpstreamSession(authorization: string): Promise<Upstr
|
|||||||
}
|
}
|
||||||
const session = (await res.json()) as UpstreamSession;
|
const session = (await res.json()) as UpstreamSession;
|
||||||
if (!session.apiUrl) throw new UpstreamError("Upstream returned an invalid JMAP session", 502);
|
if (!session.apiUrl) throw new UpstreamError("Upstream returned an invalid JMAP session", 502);
|
||||||
return session;
|
return { ...session, baseUrl: base };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUpstreamSession(sessionId: string, authorization: string, force = false) {
|
export async function getUpstreamSession(sessionId: string, authorization: string, base: string = config.stalwartUrl, force = false) {
|
||||||
const cached = sessionCache.get(sessionId);
|
const cached = sessionCache.get(sessionId);
|
||||||
if (!force && cached && Date.now() - cached.fetchedAt < SESSION_CACHE_MS) return cached.session;
|
if (!force && cached && Date.now() - cached.fetchedAt < SESSION_CACHE_MS) return cached.session;
|
||||||
const session = await fetchUpstreamSession(authorization);
|
const session = await fetchUpstreamSession(authorization, base);
|
||||||
sessionCache.set(sessionId, { session, fetchedAt: Date.now() });
|
sessionCache.set(sessionId, { session, fetchedAt: Date.now() });
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
@@ -210,9 +240,9 @@ function localeOf(call: [string, Record<string, unknown>, string] | undefined):
|
|||||||
* Which edition the server is running. Stalwart deliberately does not publish
|
* Which edition the server is running. Stalwart deliberately does not publish
|
||||||
* its version number to clients, but 0.16 does report its edition here.
|
* its version number to clients, but 0.16 does report its edition here.
|
||||||
*/
|
*/
|
||||||
async function fetchEdition(authorization: string): Promise<string | null> {
|
async function fetchEdition(authorization: string, base: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${config.stalwartUrl}/api/account`, {
|
const res = await fetch(`${base}/api/account`, {
|
||||||
headers: { authorization, accept: "application/json" },
|
headers: { authorization, accept: "application/json" },
|
||||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
});
|
});
|
||||||
@@ -230,7 +260,7 @@ export async function getAccountInfo(sessionId: string, authorization: string, s
|
|||||||
let info = EMPTY_INFO;
|
let info = EMPTY_INFO;
|
||||||
try {
|
try {
|
||||||
info = await fetchAccountInfo(authorization, session);
|
info = await fetchAccountInfo(authorization, session);
|
||||||
info = { ...info, edition: await fetchEdition(authorization) };
|
info = { ...info, edition: await fetchEdition(authorization, session.baseUrl) };
|
||||||
} catch {
|
} catch {
|
||||||
/* all of this is a nicety - never fail the session over it */
|
/* all of this is a nicety - never fail the session over it */
|
||||||
}
|
}
|
||||||
@@ -258,9 +288,9 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
|
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
|
||||||
export function absoluteUpstream(url: string): string {
|
export function absoluteUpstream(url: string, base: string = config.stalwartUrl): string {
|
||||||
try {
|
try {
|
||||||
return new URL(url, config.stalwartUrl).toString();
|
return new URL(url, base).toString();
|
||||||
} catch {
|
} catch {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"_comment": [
|
||||||
|
"Optional: which Stalwart a domain signs in to.",
|
||||||
|
"",
|
||||||
|
"STALWART_URL stays required and stays the default. This file only adds",
|
||||||
|
"domains that go somewhere else -- delete it and nothing changes.",
|
||||||
|
"",
|
||||||
|
"Point at it with STALWART_SERVERS_FILE=/etc/ihasmail/servers.json and mount",
|
||||||
|
"it read-only. Read once at startup, so editing it means restarting.",
|
||||||
|
"",
|
||||||
|
"A domain that is not listed here, and a bare username with no domain at",
|
||||||
|
"all, go to STALWART_URL. A domain that IS listed never falls back: if its",
|
||||||
|
"server is unreachable that sign-in fails, because falling back would",
|
||||||
|
"authenticate somebody against a server their domain was routed away from.",
|
||||||
|
"",
|
||||||
|
"Keys are lower-cased and stripped of a trailing dot when read. Malformed",
|
||||||
|
"JSON, a duplicate domain, or a value that is not an http(s) URL stops the",
|
||||||
|
"server at startup rather than failing quietly at somebody's sign-in.",
|
||||||
|
"",
|
||||||
|
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
|
||||||
|
],
|
||||||
|
|
||||||
|
"example.com": "https://mail.example.com",
|
||||||
|
"customer-b.test": "https://jmap.customer-b.test"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user