diff --git a/server/src/accountinfo.test.ts b/server/src/accountinfo.test.ts index 11d23ea..b5f31a4 100644 --- a/server/src/accountinfo.test.ts +++ b/server/src/accountinfo.test.ts @@ -110,3 +110,32 @@ test("a shared account carrying the capability is enough to recognise the server true, ); }); + +/** + * With a domain mapped to its own Stalwart (#238), everything asked about the + * account has to go to that server. The locale lookup resolved Stalwart's + * `apiUrl` against the default server instead, so a mapped account's locale + * was requested from a server that had never heard of it. + */ +test("account info is asked of the server that issued the session", async () => { + const seen: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + seen.push(String(input instanceof Request ? input.url : input)); + return new Response(JSON.stringify({ methodResponses: [], edition: "oss" }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + try { + const session = { + capabilities: baseCaps, + accounts: { a1: { accountCapabilities: { [STALWART]: {} } } }, + primaryAccounts: { [STALWART]: "a1" }, + apiUrl: "https://mail.mapped.test/jmap/", + baseUrl: "https://mail.mapped.test", + }; + await getAccountInfo("session-mapped-domain", "Basic x", session as never); + } finally { + globalThis.fetch = realFetch; + } + assert.ok(seen.length >= 2, "asks for both the locale and the edition"); + for (const url of seen) assert.ok(url.startsWith("https://mail.mapped.test/"), `${url} went to the wrong server`); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 29521b5..75e620b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -459,7 +459,11 @@ export function createApp(basePath = config.basePath): Hono { */ const accountCtx = async (c: Context) => { const session = c.get("session"); - const upstream = await getUpstreamSession(session.id, session.authorization); + // The account's own server. Without it, the first fetch after the cached + // session expires goes to STALWART_URL -- which, for a domain mapped + // elsewhere, either refuses the password or knows a different account by + // the same name (#238). + const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); return { authorization: session.authorization, session: upstream, username: session.username }; }; diff --git a/server/src/upstream.ts b/server/src/upstream.ts index 40eebf8..7bb1a6f 100644 --- a/server/src/upstream.ts +++ b/server/src/upstream.ts @@ -198,7 +198,9 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession) session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? Object.keys(session.accounts ?? {})[0]; if (!accountId) return EMPTY_INFO; - const res = await fetch(absoluteUpstream(session.apiUrl), { + // Against the server that issued this session, not the default: with a + // domain mapped elsewhere, the default has never heard of the account. + const res = await fetch(absoluteUpstream(session.apiUrl, session.baseUrl), { method: "POST", headers: { authorization, "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({