diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts new file mode 100644 index 0000000..feb9814 --- /dev/null +++ b/server/src/accounts.test.ts @@ -0,0 +1,181 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; + +/** + * More than one account signed in in one browser. The session cookie is the + * account in front; the others ride in `_more`. Adding signs a second + * account in beside the first, switching swaps them, and signing out ends + * only the one in front. + */ + +const PORT = 18801; +process.env.MOCK_PORT = String(PORT); +process.env.MOCK_USER = "first@example.com"; +process.env.MOCK_PASS = "first-password"; +process.env.MOCK_SECOND_USER = "second@example.com"; +process.env.MOCK_SECOND_PASS = "second-password"; +process.env.STALWART_URL = `http://127.0.0.1:${PORT}`; +process.env.APP_SECRET = "test-secret-for-accounts"; +// Every test here signs in several times from one address +process.env.LOGIN_RATE_LIMIT = "100"; + +const mock = await import("./mock/index.js"); +const { createApp } = await import("./app.js"); +const { config } = await import("./config.js"); +const { MAX_ACCOUNTS, parseOthers, serializeOthers } = await import("./accounts.js"); + +const app = createApp(); +const FRONT = config.cookieName; +const MORE = `${config.cookieName}_more`; + +after(() => { + (mock as { server?: { close(): void } }).server?.close(); +}); + +/** A browser's cookie jar, as far as these two cookies go. */ +class Browser { + jar = new Map(); + + private take(res: Response) { + for (const line of res.headers.getSetCookie()) { + const [pair, ...attrs] = line.split(";"); + const at = pair!.indexOf("="); + const name = pair!.slice(0, at).trim(); + const value = pair!.slice(at + 1).trim(); + const expired = attrs.some((a) => /max-age=0/i.test(a) || /expires=thu, 01 jan 1970/i.test(a)); + if (expired || !value) this.jar.delete(name); + else this.jar.set(name, value); + } + } + + async call(path: string, init: { method?: string; body?: unknown } = {}): Promise<{ status: number; body: any }> { + const cookie = [...this.jar].map(([k, v]) => `${k}=${v}`).join("; "); + const res = await app.request(path, { + method: init.method ?? "GET", + headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}) }, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), + }); + this.take(res); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : null }; + } + + signIn(username: string, password: string, add = false) { + return this.call("/api/auth/login", { method: "POST", body: { username, password, ...(add ? { add: true } : {}) } }); + } + + async accounts(): Promise<{ username: string; front: boolean; id: string }[]> { + const res = await this.call("/api/auth/accounts"); + assert.equal(res.status, 200, JSON.stringify(res.body)); + return res.body.accounts; + } +} + +test("the cookie list keeps only well-formed session cookies, at most one fewer than the cap", () => { + const good = "abcdefghij.ABCDEFGHIJKLMN"; + assert.deepEqual(parseOthers(`${good}~not a cookie~${good}`), [good]); + const many = Array.from({ length: 9 }, (_, i) => `abcdefgh${i}x.ABCDEFGHIJKLMN`); + assert.equal(parseOthers(many.join("~")).length, MAX_ACCOUNTS - 1); + assert.equal(serializeOthers(["bad", good]), good); +}); + +test("a second account joins the first, and switching swaps them", async () => { + const b = new Browser(); + assert.equal((await b.signIn("first@example.com", "first-password")).status, 200); + assert.deepEqual((await b.accounts()).map((a) => a.username), ["first@example.com"]); + const first = b.jar.get(FRONT); + + const added = await b.signIn("second@example.com", "second-password", true); + assert.equal(added.status, 200, JSON.stringify(added.body)); + assert.equal(added.body.added, true); + assert.equal(b.jar.get(MORE), first, "the first account moved beside the new one"); + let accounts = await b.accounts(); + assert.deepEqual(accounts.map((a) => [a.username, a.front]), [["second@example.com", true], ["first@example.com", false]]); + + // The same account again is not a second copy + await b.signIn("first@example.com", "first-password", true); + accounts = await b.accounts(); + assert.equal(accounts.length, 2); + assert.equal(accounts[0]!.username, "first@example.com", "it came to the front instead"); + + // Switch back + const second = accounts.find((a) => !a.front)!; + assert.equal((await b.call(`/api/auth/accounts/${second.id}/front`, { method: "POST" })).status, 200); + assert.equal((await b.accounts())[0]!.username, "second@example.com"); + + // Signing out ends only the one in front; the other comes forward + const out = await b.call("/api/auth/logout", { method: "POST" }); + assert.equal(out.body.next, true); + assert.deepEqual((await b.accounts()).map((a) => a.username), ["first@example.com"]); + + // Sign out of all + await b.signIn("second@example.com", "second-password", true); + assert.equal((await b.accounts()).length, 2); + await b.call("/api/auth/logout-all", { method: "POST" }); + assert.equal(b.jar.has(FRONT), false); + assert.equal(b.jar.has(MORE), false); + assert.equal((await b.call("/api/auth/accounts")).status, 401); +}); + +test("an account in front can't switch to one it doesn't hold", async () => { + const b = new Browser(); + await b.signIn("first@example.com", "first-password"); + assert.equal((await b.call("/api/auth/accounts/not-a-session/front", { method: "POST" })).status, 404); +}); + +test("the accounts not in front report their Inbox unread count", async () => { + const b = new Browser(); + await b.signIn("first@example.com", "first-password"); + // Alone, there is nothing to report + assert.deepEqual((await b.call("/api/auth/accounts/unread")).body.accounts, []); + await b.signIn("second@example.com", "second-password", true); + const res = await b.call("/api/auth/accounts/unread"); + assert.equal(res.status, 200); + assert.equal(res.body.accounts.length, 1, "only the account not in front"); + const [other] = res.body.accounts; + const listed = (await b.accounts()).find((a) => !a.front)!; + assert.equal(other.id, listed.id); + assert.equal(typeof other.unread, "number", JSON.stringify(res.body)); +}); + +test("only push, mailboxes and marking mail reach an account not in front", async () => { + const { otherAccountCallAllowed } = await import("./app.js"); + assert.equal(otherAccountCallAllowed(["PushSubscription/get", { ids: null }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Mailbox/get", { accountId: "a" }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { "keywords/$seen": true } } }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { mailboxIds: { arch: true } } } }, "0"]), true); + // Anything else is refused + assert.equal(otherAccountCallAllowed(["Email/get", { accountId: "a" }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", destroy: ["m1"] }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", create: { x: {} } }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { subject: "x" } } }, "0"]), false); + assert.equal(otherAccountCallAllowed(["EmailSubmission/set", {}, "0"]), false); + + const b = new Browser(); + await b.signIn("first@example.com", "first-password"); + const added = await b.signIn("second@example.com", "second-password", true); + assert.equal(added.status, 200, JSON.stringify(added.body)); + const other = (await b.accounts()).find((a) => !a.front)!; + const ok = await b.call(`/api/auth/accounts/${other.id}/jmap`, { + method: "POST", + body: { using: ["urn:ietf:params:jmap:core"], methodCalls: [["PushSubscription/get", { ids: null }, "0"]] }, + }); + assert.equal(ok.status, 200, JSON.stringify(ok.body)); + assert.equal(ok.body.methodResponses[0][0], "PushSubscription/get"); + const refused = await b.call(`/api/auth/accounts/${other.id}/jmap`, { + method: "POST", + body: { using: [], methodCalls: [["Email/get", { accountId: "x", ids: null }, "0"]] }, + }); + assert.equal(refused.status, 403); + // The account in front isn't reached this way, nor a session not held here + const front = (await b.accounts()).find((a) => a.front)!; + assert.equal((await b.call(`/api/auth/accounts/${front.id}/jmap`, { method: "POST", body: { methodCalls: [] } })).status, 404); +}); + +test("each listed account says which mail account it is", async () => { + const b = new Browser(); + await b.signIn("first@example.com", "first-password"); + await b.signIn("second@example.com", "second-password", true); + const res = await b.call("/api/auth/accounts"); + for (const a of res.body.accounts) assert.equal(typeof a.mailAccountId, "string", JSON.stringify(a)); +}); diff --git a/server/src/accounts.ts b/server/src/accounts.ts new file mode 100644 index 0000000..1541e49 --- /dev/null +++ b/server/src/accounts.ts @@ -0,0 +1,43 @@ +/** + * More than one signed-in account in a browser. + * + * The session cookie is unchanged: it is the account in front, and every + * request is answered with it, so nothing else in the server has to know + * there may be others. The others ride in a second cookie, `_more`: a + * list of their own session cookies, each `id.secret` exactly as the front one + * is. Switching swaps one of them into front; adding moves the front one into + * the list. Each session stays its own -- its own sealed credential, its own + * expiry, its own "this is my device" -- and nothing about one can be read + * through another. + * + * At most `MAX_ACCOUNTS` in all, all on the same mail server. + */ + +export const MAX_ACCOUNTS = 5; + +/** A session cookie's shape: `id.secret`, both base64url. Anything else is dropped. */ +const COOKIE_SHAPE = /^[A-Za-z0-9_-]{8,128}\.[A-Za-z0-9_-]{8,256}$/; +const SEP = "~"; + +export function parseOthers(value: string | undefined): string[] { + if (!value) return []; + const seen = new Set(); + const out: string[] = []; + for (const part of value.split(SEP)) { + if (!COOKIE_SHAPE.test(part) || seen.has(part)) continue; + seen.add(part); + out.push(part); + if (out.length >= MAX_ACCOUNTS - 1) break; + } + return out; +} + +export function serializeOthers(cookies: string[]): string { + return cookies.filter((c) => COOKIE_SHAPE.test(c)).slice(0, MAX_ACCOUNTS - 1).join(SEP); +} + +/** Why an account can't be added, in words for the person. */ +export const ADD_REFUSED: Record = { + add_full: `You can have at most ${MAX_ACCOUNTS} accounts open here.`, + add_other_server: "That account is on another mail server. Only accounts on this server can be added.", +}; diff --git a/server/src/app.ts b/server/src/app.ts index 7e7e73e..04760ed 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -43,6 +43,7 @@ import { import { imageProxyHandler } from "./imageproxy.js"; import { icsProxyHandler } from "./icsproxy.js"; import { staticHandler } from "./static.js"; +import { ADD_REFUSED, MAX_ACCOUNTS, parseOthers, serializeOthers } from "./accounts.js"; type Env = { Variables: { session: LiveSession } }; @@ -263,6 +264,132 @@ function setSessionCookie(c: Context, value: string, remember: boolean) { }); } +/* + * The other signed-in accounts, beside the one in front. See + * accounts.ts. Kept across a browser restart only when every account in it + * would be. + */ +const OTHERS_COOKIE = `${config.cookieName}_more`; + +function setOthersCookie(c: Context, cookies: string[]) { + if (!cookies.length) { + deleteCookie(c, OTHERS_COOKIE, { path: cookiePath }); + return; + } + const remember = cookies.every((cookie) => sessions.resolve(cookie)?.remember === true); + setCookie(c, OTHERS_COOKIE, serializeOthers(cookies), { + httpOnly: true, + sameSite: "Lax", + secure: isSecureRequest(c), + path: cookiePath, + ...(remember ? { maxAge: config.sessionRememberTtl } : {}), + }); +} + +/** The other accounts still signed in, in their order; ended ones are left out. */ +function liveOthers(c: Context): { cookie: string; session: LiveSession }[] { + const out: { cookie: string; session: LiveSession }[] = []; + for (const cookie of parseOthers(getCookie(c, OTHERS_COOKIE))) { + const session = sessions.resolve(cookie); + if (session) out.push({ cookie, session }); + } + return out; +} + +/* + * What may be asked of a signed-in account that isn't in front. + * + * Its push subscription has to be registered, verified and renewed through + * its own session -- a JMAP push subscription belongs to whoever signs the + * request -- and a notification's Archive and Mark read act on its mail. Those + * four methods, and for Email/set only changes to keywords and mailboxes: the + * browser already holds the session, so this reaches nothing new, but it is + * kept to what the notifications need. + */ +const OTHER_ACCOUNT_METHODS = new Set(["PushSubscription/get", "PushSubscription/set", "Mailbox/get", "Email/set"]); + +export function otherAccountCallAllowed(call: unknown): boolean { + if (!Array.isArray(call) || call.length !== 3) return false; + const [method, args] = call as [unknown, unknown, unknown]; + if (typeof method !== "string" || !OTHER_ACCOUNT_METHODS.has(method)) return false; + if (typeof args !== "object" || args === null) return false; + if (method !== "Email/set") return true; + const set = args as { create?: unknown; destroy?: unknown; update?: unknown }; + if (set.create !== undefined || set.destroy !== undefined) return false; + if (typeof set.update !== "object" || set.update === null) return false; + return Object.values(set.update as Record).every( + (patch) => + typeof patch === "object" && + patch !== null && + Object.keys(patch).every((k) => k === "keywords" || k === "mailboxIds" || k.startsWith("keywords/") || k.startsWith("mailboxIds/")), + ); +} + +/** Inbox unread counts of accounts not in front, briefly kept. */ +const UNREAD_CACHE_MS = 60_000; +const unreadCache = new Map(); + +/** The Inbox's unread count for one session's account, or null when it has none. */ +async function inboxUnread(session: LiveSession): Promise { + const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); + const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"]; + if (!accountId) return null; + const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), { + method: "POST", + headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + methodCalls: [["Mailbox/get", { accountId, ids: null, properties: ["role", "unreadEmails"] }, "0"]], + }), + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (!res.ok) return null; + const body = (await res.json()) as { methodResponses?: [string, { list?: { role?: string | null; unreadEmails?: number }[] }, string][] }; + const inbox = body.methodResponses?.[0]?.[1]?.list?.find((m) => m.role === "inbox"); + return typeof inbox?.unreadEmails === "number" ? inbox.unreadEmails : null; +} + +/** Whether the account in front may have more beside it, or why not. */ +function addRefusal(c: Context): string | null { + if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full"; + return null; +} + +/** + * A session just signed in to be added beside the one in front. It + * comes to the front and the old front joins the others; or, refused, it is + * ended and the front stays. Returns the refusal, or null. + */ +function joinAccount(c: Context, created: { cookie: string; session: LiveSession }): string | null { + const frontCookie = getCookie(c, config.cookieName); + const front = sessions.resolve(frontCookie); + if (!front || !frontCookie) { + // Nobody in front any more: an ordinary sign-in + setSessionCookie(c, created.cookie, created.session.remember); + return null; + } + const others = liveOthers(c); + const end = (code: string | null) => { + sessions.destroy(created.session.id); + forgetUpstreamSession(created.session.id); + return code; + }; + if (upstreamFor(created.session.username) !== upstreamFor(front.username)) return end("add_other_server"); + // Already open: that one comes to the front instead of a second copy + if (created.session.account === front.account) return end(null); + const existing = others.find((o) => o.session.account === created.session.account); + if (existing) { + end(null); + setSessionCookie(c, existing.cookie, existing.session.remember); + setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== existing).map((o) => o.cookie)]); + return null; + } + if (1 + others.length >= MAX_ACCOUNTS) return end("add_full"); + setSessionCookie(c, created.cookie, created.session.remember); + setOthersCookie(c, [frontCookie, ...others.map((o) => o.cookie)]); + return null; +} + function upstreamFailure(c: Context, err: unknown) { if (err instanceof UpstreamError) { return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502); @@ -329,7 +456,7 @@ export function createApp(basePath = config.basePath): Hono { c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp))); return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429); } - let body: { username?: string; password?: string; totp?: string; remember?: boolean }; + let body: { username?: string; password?: string; totp?: string; remember?: boolean; add?: boolean }; try { body = await c.req.json(); } catch { @@ -392,6 +519,12 @@ export function createApp(basePath = config.basePath): Hono { userAgent: c.req.header("user-agent") ?? "", ip, }); + // Beside the account in front, when that's what was asked + if (body.add && sessions.resolve(getCookie(c, config.cookieName))) { + const refused = joinAccount(c, { cookie, session }); + if (refused) return c.json({ error: refused, message: ADD_REFUSED[refused] }, 403); + return c.json({ ok: true, added: true }); + } setSessionCookie(c, cookie, session.remember); // Start the account's push subscription now, so it is usually verified // by the time the browser opens its stream. See push.ts. @@ -462,7 +595,118 @@ export function createApp(basePath = config.basePath): Hono { sessions.destroy(session.id); forgetUpstreamSession(session.id); } + // Only this account ends; the next one comes to the front + const [next, ...rest] = liveOthers(c); + if (next) { + setSessionCookie(c, next.cookie, next.session.remember); + setOthersCookie(c, rest.map((o) => o.cookie)); + return c.json({ ok: true, next: true }); + } deleteCookie(c, config.cookieName, { path: cookiePath }); + deleteCookie(c, OTHERS_COOKIE, { path: cookiePath }); + return c.json({ ok: true }); + }); + + /* Every account signed in here ends. */ + api.post("/auth/logout-all", async (c) => { + const front = sessions.resolve(getCookie(c, config.cookieName)); + for (const session of [front, ...liveOthers(c).map((o) => o.session)]) { + if (!session) continue; + sessions.destroy(session.id); + forgetUpstreamSession(session.id); + } + deleteCookie(c, config.cookieName, { path: cookiePath }); + deleteCookie(c, OTHERS_COOKIE, { path: cookiePath }); + return c.json({ ok: true }); + }); + + /* The accounts signed in here, the one in front first, and whether one more may be added. */ + api.get("/auth/accounts", requireSession, async (c) => { + const front = c.get("session"); + const others = liveOthers(c); + if (others.length !== parseOthers(getCookie(c, OTHERS_COOKIE)).length) { + setOthersCookie(c, others.map((o) => o.cookie)); + } + const canAdd = addRefusal(c) === null; + // Each one's mail account, which its push subscription and + // a notification's buttons need + const accounts = await Promise.all( + [front, ...others.map((o) => o.session)].map(async (s, i) => { + let mailAccountId: string | null = null; + try { + const upstream = await getUpstreamSession(s.id, s.authorization, upstreamFor(s.username)); + mailAccountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? null; + } catch { + /* unknown for now: push for it waits for the next start */ + } + return { id: s.id, username: s.username, front: i === 0, mailAccountId }; + }), + ); + return c.json({ accounts, canAdd, max: MAX_ACCOUNTS }); + }); + + /* + * The Inbox unread count of each account not in front, asked + * through that account's own session, so the menu can say where new mail + * is. A minute's cache per account: the web app asks every few minutes, and + * several tabs may ask at once. + */ + api.get("/auth/accounts/unread", requireSession, async (c) => { + const answers = await Promise.all( + liveOthers(c).map(async ({ session }) => { + const cached = unreadCache.get(session.id); + if (cached && Date.now() - cached.at < UNREAD_CACHE_MS) return { id: session.id, unread: cached.unread }; + try { + const unread = await inboxUnread(session); + unreadCache.set(session.id, { unread, at: Date.now() }); + return { id: session.id, unread }; + } catch { + return { id: session.id, unread: null }; + } + }), + ); + return c.json({ accounts: answers }); + }); + + /* A narrow JMAP route to a signed-in account not in front; see OTHER_ACCOUNT_METHODS. */ + api.post("/auth/accounts/:id/jmap", requireSession, async (c) => { + const other = liveOthers(c).find((o) => o.session.id === c.req.param("id")); + if (!other) return c.json({ error: "not_found" }, 404); + let body: { using?: unknown; methodCalls?: unknown }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "bad_request" }, 400); + } + const calls = body.methodCalls; + if (!Array.isArray(calls) || calls.length === 0 || calls.length > 16 || !calls.every(otherAccountCallAllowed)) { + return c.json({ error: "forbidden", message: "Only push subscriptions, mailboxes and marking mail can be reached in another account." }, 403); + } + const using = Array.isArray(body.using) ? body.using.filter((u): u is string => typeof u === "string") : []; + const session = other.session; + try { + const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); + const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), { + method: "POST", + headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ using, methodCalls: calls }), + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (res.status === 401 || res.status === 403) return c.json({ error: "unauthenticated" }, 401); + return c.json(await res.json(), res.ok ? 200 : 502); + } catch (err) { + return upstreamFailure(c, err); + } + }); + + /* Bring another signed-in account to the front. */ + api.post("/auth/accounts/:id/front", requireSession, async (c) => { + const frontCookie = getCookie(c, config.cookieName)!; + const others = liveOthers(c); + const chosen = others.find((o) => o.session.id === c.req.param("id")); + if (!chosen) return c.json({ error: "not_found" }, 404); + setSessionCookie(c, chosen.cookie, chosen.session.remember); + setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== chosen).map((o) => o.cookie)]); return c.json({ ok: true }); }); diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 5099fec..d885a22 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -22,8 +22,24 @@ function unauthorized(res: ServerResponse) { res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" })); } +/* + * An optional second user, so the account switcher has two real + * accounts to move between. It signs in with a password only, and reads the + * same mailbox: what the tests look at is who the session says it is. + */ +const SECOND_USER = process.env.MOCK_SECOND_USER; +const SECOND_PASS = process.env.MOCK_SECOND_PASS; + +/** Who a Basic header signs in as, when it is the second user. */ +function secondUser(req: IncomingMessage): boolean { + const h = req.headers.authorization ?? ""; + if (!SECOND_USER || !h.startsWith("Basic ")) return false; + return Buffer.from(h.slice(6), "base64").toString() === `${SECOND_USER}:${SECOND_PASS}`; +} + function checkAuth(req: IncomingMessage): boolean { const h = req.headers.authorization ?? ""; + if (secondUser(req)) return true; if (!h.startsWith("Basic ")) return false; const raw = Buffer.from(h.slice(6), "base64").toString(); const sep = raw.indexOf(":"); @@ -80,7 +96,9 @@ export const server = createServer(async (req, res) => { if (!checkAuth(req)) return unauthorized(res); if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") { res.writeHead(200, { "content-type": "application/json" }); - return res.end(JSON.stringify(session())); + const answer = session() as ReturnType & { username: string }; + if (secondUser(req)) answer.username = SECOND_USER!; + return res.end(JSON.stringify(answer)); } // The account info endpoint; the only place a server reports its edition. if (url.pathname === "/api/account" && req.method === "GET") { diff --git a/web/public/sw.js b/web/public/sw.js index 843c97a..1322a03 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -384,8 +384,11 @@ async function readFacts() { * rather than swallowed. A tap that silently does nothing is the failure worth * avoiding here: the reader has already put the phone down. */ -async function jmap(methodCalls) { - const res = await fetch(`${BASE}/api/jmap`, { +async function jmap(methodCalls, sessionId) { + // An account not in front is reached through its own session, + // on the webmail server's narrow route for it + const path = sessionId ? `${BASE}/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap` : `${BASE}/api/jmap`; + const res = await fetch(path, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" }, @@ -454,6 +457,15 @@ self.addEventListener("push", (event) => { const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : []; event.waitUntil((async () => { + const facts = await readFacts(); + /* + * Whose mail this is. The payload names its account; one that + * isn't the account in front is one of the others signed in here, or one + * that has been signed out since (said plainly, with nothing to act on). + */ + const forAccount = data && data.accountId && facts && data.accountId !== facts.accountId ? data.accountId : null; + const other = forAccount ? (facts.others || []).find((o) => o.accountId === forAccount) || null : null; + if (forAccount) return showOtherAccount(emails, facts, other); /* * Someone reading the app already knows. A focused, visible window of this * app gets its new mail from its own event stream, so a notification on @@ -462,7 +474,6 @@ self.addEventListener("push", (event) => { */ const windows = await self.clients.matchAll({ type: "window" }); if (windows.some((w) => w.focused && w.visibilityState === "visible")) return; - const facts = await readFacts(); const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" }; /* * Mark the app icon, without claiming a number. @@ -500,8 +511,7 @@ self.addEventListener("push", (event) => { // be drawn. actions: email.id ? actionsFor(facts) : [], data: { - // The route names a conversation, and `m` the message in it. - url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`, + url: messageUrl(facts && facts.inboxId, email), id: email.id || null, title, accountId: facts?.accountId ?? null, @@ -513,6 +523,70 @@ self.addEventListener("push", (event) => { })()); }); +/* + * New mail for a signed-in account that isn't in front. + * + * Shown even while a tab is focused: that tab's own stream only carries the + * account in front, so nothing else would tell. The account's address is the + * title, so it can't be taken for the front account's mail; the tag carries + * the account, so two accounts' notifications don't replace each other; the + * buttons act through that account's session; and opening it brings that + * account forward before showing the message. + */ +async function showOtherAccount(emails, facts, other) { + const strings = facts.strings; + const icon = `${BASE}/img/icon-192.png?v=${BRAND_V}`; + const badge = `${BASE}/img/favicon-64.png?v=${BRAND_V}`; + if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {}); + if (!other || !emails.length) { + // Signed out since, or nothing to show: say only what is true + await self.registration.showNotification(other ? other.username : strings.newMail, { + body: other ? strings.newMail : undefined, + icon, badge, + tag: `ihasmail-other-${other ? other.accountId : "unknown"}`, + data: { url: other ? openUrl(other, `${BASE}/mail`) : `${BASE}/mail` }, + }); + return; + } + for (const email of emails.slice(0, 5)) { + const { title, body, preview } = textOf(email, strings); + const at = messageUrl(other.inboxId, email); + await self.registration.showNotification(other.username, { + body: `${title}: ${body}${preview ? `\n${preview}` : ""}`, + icon, badge, + tag: `ihasmail-${other.accountId}-${email.id || body}`, + actions: email.id ? actionsFor({ ...facts, archiveId: other.archiveId }) : [], + data: { + url: openUrl(other, at), + id: email.id || null, + title: other.username, + accountId: other.accountId, + archiveId: other.archiveId, + sessionId: other.sessionId, + failed: strings.failed ?? null, + }, + }); + } +} + +/** Where opening a notification for an account not in front goes: it comes forward first. */ +/** + * Where a notification opens. The route names a mailbox by id and then a + * conversation, and `m` the message in it. It used to say `inbox` where the id + * goes, which the app reads as a folder that no longer exists, so every click + * landed on the inbox list with "That folder no longer exists" instead of the + * message. Without an inbox id from the briefing, the inbox is the honest + * landing. + */ +function messageUrl(inboxId, email) { + if (!inboxId || !email.id || !email.threadId) return `${BASE}/mail`; + return `${BASE}/mail/${encodeURIComponent(inboxId)}/${encodeURIComponent(email.threadId)}?m=${encodeURIComponent(email.id)}`; +} + +function openUrl(other, next) { + return `${BASE}/?account=${encodeURIComponent(other.sessionId)}&next=${encodeURIComponent(next)}`; +} + /* * Do what the button said, without opening anything. * @@ -533,7 +607,7 @@ async function runAction(action, data) { : { "keywords/$seen": true }; try { if (action === "archive" && !archiveId) throw new Error("no archive mailbox"); - await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]]); + await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]], data.sessionId || null); } catch { await self.registration.showNotification(data.title || "ihasmail", { body: data.failed || "Could not do that — open ihasmail and try again", diff --git a/web/src/App.tsx b/web/src/App.tsx index a056160..17a2a9a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,7 @@ -import { Fragment, lazy, Suspense, useEffect, useState } from "react"; +import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react"; import { Route, Switch, Redirect, useLocation, Router } from "wouter"; import { useSession } from "@/store/session"; -import { useMail } from "@/store/mail"; +import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail"; import { scheduleSupported, useScheduled } from "@/store/scheduled"; import { useContacts } from "@/store/contacts"; import { useCalendar } from "@/store/calendar"; @@ -231,6 +231,8 @@ function AuthedApp() { timer = null; for (const [a, types] of pending) { if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types); + // The reader's own mail, while a shared mailbox is in view, is still announced + if (a === ownAccountAway() && types.has("Email")) void notifyOwnWhileAway(); if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types); if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types); if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types); @@ -260,10 +262,57 @@ function AuthedApp() { return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0; }); const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; + + /* + * A shared mailbox's mail, when it comes into view, and the reader's own + * when it goes back. Push carries nothing for an account only shared with + * the reader, so while one is open it is polled. + */ + const viewing = useSession((s) => s.viewing); + const viewedOnce = useRef(false); useEffect(() => { - setBaseTitle(appName); + if (!viewedOnce.current) { + viewedOnce.current = true; + if (!viewing) return; + } + const mail = useMail.getState(); + void mail.loadMailboxes(); + void mail.loadIdentities(); + if (!viewing) return; + const poll = window.setInterval(() => { + if (document.visibilityState === "visible") { + void useMail.getState().applyChanges(new Set(["Email", "Mailbox"])); + } + }, 60_000); + return () => window.clearInterval(poll); + }, [viewing]); + + // A mailbox shared or taken away while the app is open shows up when the + // reader comes back to it, without signing in again + useEffect(() => { + let last = 0; + const onVisible = () => { + if (document.visibilityState !== "visible" || Date.now() - last < 60_000) return; + last = Date.now(); + void useSession.getState().refresh(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => document.removeEventListener("visibilitychange", onVisible); + }, []); + + const viewEnded = useSession((s) => s.viewEnded); + useEffect(() => { + if (!viewEnded) return; + toast.show(t("You no longer have access to {name}. Back to your own mail.", { name: viewEnded })); + useSession.getState().clearViewEnded(); + }, [viewEnded]); + + // A shared mailbox in view is named in the tab + const viewingName = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined)); + useEffect(() => { + setBaseTitle(viewingName ? `${viewingName} · ${appName}` : appName); setUnreadBadge(inboxUnread); - }, [inboxUnread, appName]); + }, [inboxUnread, appName, viewingName]); /* * Leave the service worker its briefing. @@ -275,10 +324,14 @@ function AuthedApp() { * See lib/swFacts.ts. */ const archiveId = useMail((s) => s.roleId("archive")); + const inboxId = useMail((s) => s.roleId("inbox")); const languageVersion = useLanguageVersion(); useEffect(() => { - void publishWorkerFacts(accountId, archiveId); - }, [accountId, archiveId, languageVersion]); + // The worker acts on the reader's own mail; while a shared mailbox is in + // view, the archive folder here is its + if (viewing) return; + void publishWorkerFacts(accountId, archiveId, inboxId); + }, [accountId, archiveId, inboxId, languageVersion, viewing]); // Request notification permission lazily when enabled const notif = useSettings((s) => s.settings.desktopNotifications); diff --git a/web/src/lib/__tests__/other-accounts.test.ts b/web/src/lib/__tests__/other-accounts.test.ts new file mode 100644 index 0000000..69b0ea3 --- /dev/null +++ b/web/src/lib/__tests__/other-accounts.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** New mail in the accounts not in front. */ + +const shown: { title: string; opts: Record }[] = []; +vi.mock("@/lib/notify/notify", () => ({ + showNotification: (title: string, opts: Record) => shown.push({ title, opts }), +})); + +const { risen, pollOtherUnread, useOtherUnread } = await import("@/lib/otherAccounts"); +const { useSession } = await import("@/store/session"); +const { useSettings } = await import("@/store/settings"); + +let counts: Record; + +beforeEach(() => { + shown.length = 0; + counts = { b: 3 }; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + const body = { accounts: Object.entries(counts).map(([id, unread]) => ({ id, unread })) }; + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response; + }), + ); + useOtherUnread.getState().set({}); + useSession.setState({ + signedIn: [ + { id: "a", username: "me@example.com", front: true }, + { id: "b", username: "support@example.com", front: false }, + ], + }); + useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: true } })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("other accounts' unread mail", () => { + it("counts only a rise, and never an account seen for the first time", () => { + expect(risen({}, { b: 4 })).toEqual([]); + expect(risen({ b: 4 }, { b: 4 })).toEqual([]); + expect(risen({ b: 4 }, { b: 2 })).toEqual([]); + expect(risen({ b: 4, c: 1 }, { b: 5, c: 1 })).toEqual(["b"]); + }); + + it("keeps the counts, and names the account when new mail arrives", async () => { + await pollOtherUnread(); + expect(useOtherUnread.getState().unread).toEqual({ b: 3 }); + expect(shown).toEqual([]); + + counts = { b: 5 }; + await pollOtherUnread(); + expect(shown).toHaveLength(1); + expect(shown[0]!.title).toContain("support@example.com"); + expect(shown[0]!.opts.tag).toBe("other-account-b"); + }); + + it("stays quiet when desktop notifications are off", async () => { + useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: false } })); + await pollOtherUnread(); + counts = { b: 9 }; + await pollOtherUnread(); + expect(shown).toEqual([]); + expect(useOtherUnread.getState().unread).toEqual({ b: 9 }); + }); + + it("leaves telling to the worker where background notifications are on", async () => { + const { setDeviceTrusted } = await import("@/lib/storage"); + const { setPushEnabledHere } = await import("@/lib/notify/webpush"); + setDeviceTrusted(true); + setPushEnabledHere(true); + try { + await pollOtherUnread(); + counts = { b: 12 }; + await pollOtherUnread(); + expect(shown).toEqual([]); + expect(useOtherUnread.getState().unread).toEqual({ b: 12 }); + } finally { + setPushEnabledHere(false); + setDeviceTrusted(false); + } + }); +}); diff --git a/web/src/lib/notify/__tests__/other-account-call.test.ts b/web/src/lib/notify/__tests__/other-account-call.test.ts new file mode 100644 index 0000000..9264941 --- /dev/null +++ b/web/src/lib/notify/__tests__/other-account-call.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { otherAccountCall } from "@/lib/notify/otherAccount"; +import { listSubscriptions } from "@/lib/notify/webpush"; + +/** Push calls made as an account that isn't in front. */ + +afterEach(() => vi.unstubAllGlobals()); + +function stub(response: unknown) { + const seen: { url: string; body: any }[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) }); + return { ok: true, status: 200, json: async () => response, text: async () => JSON.stringify(response) } as Response; + }), + ); + return seen; +} + +describe("otherAccountCall", () => { + it("goes through that account's route and answers the method's result", async () => { + const seen = stub({ methodResponses: [["PushSubscription/get", { list: [{ id: "p1", deviceClientId: "d" }] }, "0"]] }); + const subs = await listSubscriptions(otherAccountCall("sess-2")); + expect(subs.map((s) => s.id)).toEqual(["p1"]); + expect(seen[0]!.url).toContain("/api/auth/accounts/sess-2/jmap"); + expect(seen[0]!.body.methodCalls[0][0]).toBe("PushSubscription/get"); + expect(seen[0]!.body.using).toContain("urn:ietf:params:jmap:core"); + }); + + it("turns a JMAP error into a thrown one", async () => { + stub({ methodResponses: [["error", { type: "forbidden" }, "0"]] }); + await expect(listSubscriptions(otherAccountCall("sess-2"))).rejects.toThrow("forbidden"); + }); +}); diff --git a/web/src/lib/notify/__tests__/push-every-account.test.ts b/web/src/lib/notify/__tests__/push-every-account.test.ts new file mode 100644 index 0000000..e4f0d97 --- /dev/null +++ b/web/src/lib/notify/__tests__/push-every-account.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { clearSignedInData, setDeviceTrusted } from "@/lib/storage"; +import { deviceClientId, registeredEndpoint, rememberEndpoint, unsubscribeAccount, type JmapCall } from "@/lib/notify/webpush"; + +/** Every signed-in account on this device has its own push subscription. */ + +beforeEach(() => localStorage.clear()); +afterEach(() => localStorage.clear()); + +describe("push for every signed-in account", () => { + it("remembers each account's endpoint, and keeps them through a switch", () => { + rememberEndpoint("https://push.example/old"); + // An account with nothing of its own yet reads the endpoint from before + expect(registeredEndpoint("acc-a")).toBe("https://push.example/old"); + rememberEndpoint("https://push.example/a", "acc-a"); + rememberEndpoint("https://push.example/b", "acc-b"); + expect(registeredEndpoint("acc-a")).toBe("https://push.example/a"); + expect(registeredEndpoint("acc-b")).toBe("https://push.example/b"); + localStorage.setItem("ihasmail:cached-mail", "x"); + clearSignedInData(); + expect(registeredEndpoint("acc-a")).toBe("https://push.example/a"); + expect(localStorage.getItem("ihasmail:cached-mail")).toBeNull(); + rememberEndpoint(null, "acc-a"); + expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull(); + }); + + it("removes only this device's subscription from one account", async () => { + // A device id is kept only where the device is trusted + setDeviceTrusted(true); + const mine = deviceClientId(); + const destroyed: string[] = []; + const call: JmapCall = async (method: string, args: Record) => { + if (method === "PushSubscription/get") { + return { list: [{ id: "p1", deviceClientId: mine }, { id: "p2", deviceClientId: "ihasmail-other-phone" }] } as T; + } + destroyed.push(...((args.destroy as string[]) ?? [])); + return {} as T; + }; + rememberEndpoint("https://push.example/a", "acc-a"); + await unsubscribeAccount(call, "acc-a"); + expect(destroyed).toEqual(["p1"]); + expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull(); + setDeviceTrusted(false); + }); +}); diff --git a/web/src/lib/notify/otherAccount.ts b/web/src/lib/notify/otherAccount.ts new file mode 100644 index 0000000..4bfd5e4 --- /dev/null +++ b/web/src/lib/notify/otherAccount.ts @@ -0,0 +1,22 @@ +/** + * JMAP calls made as a signed-in account that isn't in front, + * through the webmail server's narrow route for it + * (POST /api/auth/accounts//jmap). The server allows only what + * notifications need: push subscriptions, mailboxes, and marking or filing + * mail. + */ +import { apiFetch, CAP } from "@/jmap/client"; +import type { JmapCall } from "@/lib/notify/webpush"; + +export function otherAccountCall(sessionId: string): JmapCall { + return async (method: string, args: Record, using: string[]): Promise => { + const res = await apiFetch<{ methodResponses?: [string, unknown, string][] }>( + `/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap`, + { method: "POST", body: JSON.stringify({ using: [...new Set([CAP.core, ...using])], methodCalls: [[method, args, "0"]] }) }, + ); + const [name, out] = res.methodResponses?.[0] ?? []; + if (!name) throw new Error("The mail server sent no response."); + if (name === "error") throw new Error(String((out as { type?: string } | undefined)?.type ?? "error")); + return out as T; + }; +} diff --git a/web/src/lib/notify/webpush.ts b/web/src/lib/notify/webpush.ts index 5c6970a..39f094d 100644 --- a/web/src/lib/notify/webpush.ts +++ b/web/src/lib/notify/webpush.ts @@ -22,6 +22,15 @@ import type { GetResponse, Id, SetResponse } from "@/jmap/types"; import { isDeviceTrusted } from "@/lib/storage"; export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid"; + +/** + * Who a push call is made as. A JMAP push subscription belongs + * to whoever signs the request, so registering one for an account that isn't + * in front goes through that account's own session (lib/notify/otherAccount). + * Everything here defaults to the account in front. + */ +export type JmapCall = (method: string, args: Record, using: string[]) => Promise; +export const frontCall: JmapCall = (method, args, using) => client.call(method, args, using); export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush"; /** @@ -285,13 +294,13 @@ export function needsRenewal(subs: JmapPushSubscription[], deviceId: string, now return at - now <= RENEW_WITHIN_MS; } -export async function listSubscriptions(): Promise { - const res = await client.call>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); +export async function listSubscriptions(call: JmapCall = frontCall): Promise { + const res = await call>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); return res.list; } -export async function createSubscription(body: Record): Promise { - const res = await client.call>( +export async function createSubscription(body: Record, call: JmapCall = frontCall): Promise { + const res = await call>( "PushSubscription/set", { create: { s: body } }, [CAP.core, VAPID_CAP, EMAILPUSH_CAP], @@ -307,16 +316,16 @@ export async function createSubscription(body: Record): Promise * Seven days is JMAP's ceiling and what Stalwart grants a new one; the server * may shorten what is asked for, and whatever it keeps is what counts. */ -export async function extendSubscription(id: Id, now: number = Date.now()): Promise { +export async function extendSubscription(id: Id, now: number = Date.now(), call: JmapCall = frontCall): Promise { const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z"); - const res = await client.call>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]); + const res = await call>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]); const err = res.notUpdated?.[id]; if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type)); } -export async function destroySubscriptions(ids: Id[]): Promise { +export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall): Promise { if (!ids.length) return; - await client.call>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]); + await call>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]); } /** @@ -328,18 +337,29 @@ export async function destroySubscriptions(ids: Id[]): Promise { */ const ENDPOINT_KEY = "ihasmail:pushEndpoint"; -export function registeredEndpoint(): string | null { +/* + * One per account, since each signed-in account on this device + * has its own subscription, and kept when switching between them (see + * KEEP_ON_SIGN_OUT in lib/storage) so a switch doesn't register them afresh. + * Without an account, the key from before: the account in front. + */ +function endpointKey(accountId?: Id | null): string { + return accountId ? `${ENDPOINT_KEY}:${accountId}` : ENDPOINT_KEY; +} + +export function registeredEndpoint(accountId?: Id | null): string | null { try { - return localStorage.getItem(ENDPOINT_KEY); + return localStorage.getItem(endpointKey(accountId)) ?? (accountId ? localStorage.getItem(ENDPOINT_KEY) : null); } catch { return null; } } -export function rememberEndpoint(endpoint: string | null): void { +export function rememberEndpoint(endpoint: string | null, accountId?: Id | null): void { try { - if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint); - else localStorage.removeItem(ENDPOINT_KEY); + const key = endpointKey(accountId); + if (endpoint) localStorage.setItem(key, endpoint); + else localStorage.removeItem(key); } catch { /* private mode: every start is then a fresh registration, which still works */ } @@ -353,8 +373,8 @@ export function rememberEndpoint(endpoint: string | null): void { * the client echoes it. A subscription left unverified looks registered and is * silent, which is the confusing failure worth being explicit about. */ -export async function verifySubscription(id: Id, verificationCode: string): Promise { - const res = await client.call>( +export async function verifySubscription(id: Id, verificationCode: string, call: JmapCall = frontCall): Promise { + const res = await call>( "PushSubscription/set", { update: { [id]: { verificationCode } } }, [CAP.core, VAPID_CAP], @@ -367,6 +387,20 @@ export async function destroySubscription(id: Id): Promise { await client.call>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]); } +/** + * Remove this device's subscription in one account only, leaving + * the browser's push subscription and the switch alone -- for signing out of + * the account in front while others stay signed in and keep notifying. + */ +export async function unsubscribeAccount(call: JmapCall = frontCall, accountId?: Id | null): Promise { + try { + await destroySubscriptions(mySubscriptions(await listSubscriptions(call), deviceClientId()).map((s) => s.id), call); + } catch { + /* signing out must not fail over this */ + } + rememberEndpoint(null, accountId); +} + /** Remove every subscription this browser registered. Used when signing out. */ export async function unsubscribeThisDevice(): Promise { const mine = deviceClientId(); diff --git a/web/src/lib/notify/webpushEnable.ts b/web/src/lib/notify/webpushEnable.ts index 296c651..4bd70f4 100644 --- a/web/src/lib/notify/webpushEnable.ts +++ b/web/src/lib/notify/webpushEnable.ts @@ -5,12 +5,16 @@ * testable: everything here touches the browser's service worker and * permission prompt, none of which exists under a test runner. */ -import { CAP } from "@/jmap/client"; +import { apiFetch, CAP } from "@/jmap/client"; +import type { GetResponse, Id, Mailbox } from "@/jmap/types"; +import { otherAccountCall } from "@/lib/notify/otherAccount"; +import { setOtherAccountFacts, type OtherAccountFacts } from "@/lib/sw/swFacts"; +import type { SignedInAccount } from "@/store/session"; import { withBase } from "../basePath"; import { SW_CACHE_NAME } from "../sw/swCache"; import { isDeviceTrusted } from "@/lib/storage"; import { useSession } from "@/store/session"; -import { useMail } from "@/store/mail"; +import { ownInboxId } from "@/store/mail"; import { applicationServerKey, createSubscription, @@ -18,6 +22,8 @@ import { destroySubscriptions, deviceClientId, extendSubscription, + frontCall, + type JmapCall, findSubscription, listSubscriptions, mySubscriptions, @@ -29,6 +35,7 @@ import { roomToMake, setPushEnabledHere, subscriptionPayload, + unsubscribeAccount, unsubscribeThisDevice, verifySubscription, webPushAvailable, @@ -48,7 +55,7 @@ export function listenForVerification(): void { listening = true; navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => { const d = e.data as { type?: string; id?: string; code?: string } | undefined; - if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {}); + if (d?.type === "push-verification" && d.id && d.code) void verifyAnywhere(d.id, d.code); }); void collectStoredVerification(); } @@ -64,12 +71,44 @@ async function collectStoredVerification(): Promise { if (!hit) return; const { id, code } = (await hit.json()) as { id?: string; code?: string }; await cache.delete(key); - if (id && code) await verifySubscription(id, code); + if (id && code) await verifyAnywhere(id, code); } catch { /* nothing waiting, or no cache: not a failure */ } } +/** + * A verification code belongs to one account's subscription, and + * the worker doesn't say which: the account in front first, then each other + * signed-in account until one takes it. + */ +async function verifyAnywhere(id: Id, code: string): Promise { + try { + await verifySubscription(id, code); + return; + } catch { + /* not the front account's */ + } + for (const account of await otherAccounts()) { + try { + await verifySubscription(id, code, otherAccountCall(account.id)); + return; + } catch { + /* not this one's either */ + } + } +} + +/** The signed-in accounts not in front, as the server lists them now. */ +async function otherAccounts(): Promise { + try { + const answer = await apiFetch<{ accounts: SignedInAccount[] }>("/api/auth/accounts"); + return answer.accounts.filter((a) => !a.front); + } catch { + return []; + } +} + /** * Subscribe this browser. Safe to call again: see `registerThisBrowser`. * @@ -97,6 +136,8 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso await registerThisBrowser(key); setPushEnabledHere(true); listenForVerification(); + // And every other account signed in here + await registerOtherAccounts(key); return { ok: true }; } catch (err) { return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." }; @@ -128,7 +169,23 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso * gave up there, leaving push off for good with the switch still saying it was * on. */ -async function registerThisBrowser(key: string): Promise { +interface PushTarget { + call: JmapCall; + /** The account's mail account, which the subscription names. */ + accountId: Id | null; + inboxId: Id | null; + /** Whose remembered endpoint to compare with: the account's own. */ + endpointOf: Id | null; +} + +function frontTarget(): PushTarget { + // The reader's own inbox, never a shared mailbox's in view + const accountId = useSession.getState().ownAccountFor(CAP.mail); + return { call: frontCall, accountId, inboxId: ownInboxId(), endpointOf: accountId }; +} + +async function registerThisBrowser(key: string, target: PushTarget = frontTarget()): Promise { + const { call } = target; const reg = await navigator.serviceWorker.ready; const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({ // Web Push requires it, and Chrome refuses a subscription without it. @@ -136,34 +193,61 @@ async function registerThisBrowser(key: string): Promise { applicationServerKey: decodeApplicationServerKey(key), })); const deviceId = deviceClientId(); - const subs = await listSubscriptions(); + const subs = await listSubscriptions(call); const mine = mySubscriptions(subs, deviceId); const [newest, ...extra] = mine; - if (newest && registeredEndpoint() === sub.endpoint) { - if (extra.length) await destroySubscriptions(extra.map((s) => s.id)); + if (newest && registeredEndpoint(target.endpointOf) === sub.endpoint) { + if (extra.length) await destroySubscriptions(extra.map((s) => s.id), call); const at = newest.expires ? Date.parse(newest.expires) : Number.NaN; if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return; try { - await extendSubscription(newest.id); + await extendSubscription(newest.id, Date.now(), call); return; } catch { /* not extendable: replaced below */ } } - if (mine.length) await destroySubscriptions(mine.map((s) => s.id)); - const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), useMail.getState().roleId("inbox")); + if (mine.length) await destroySubscriptions(mine.map((s) => s.id), call); + const payload = subscriptionPayload(sub, target.accountId, target.inboxId); try { - await createSubscription(payload); + await createSubscription(payload, call); } catch (err) { if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err; const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId); if (!room.length) throw err; - await destroySubscriptions(room); - await createSubscription(payload); + await destroySubscriptions(room, call); + await createSubscription(payload, call); } - rememberEndpoint(sub.endpoint); + rememberEndpoint(sub.endpoint, target.endpointOf); +} + +/** + * Register this browser in every other account signed in here, + * each through its own session, and tell the worker who they are so their + * notifications say whose they are and their buttons act on the right mail. + * One account failing doesn't stop the rest; the next start tries it again. + */ +async function registerOtherAccounts(key: string): Promise { + const facts: OtherAccountFacts[] = []; + for (const account of await otherAccounts()) { + if (!account.mailAccountId) continue; + const call = otherAccountCall(account.id); + try { + const boxes = await call>( + "Mailbox/get", + { accountId: account.mailAccountId, ids: null, properties: ["role"] }, + [CAP.mail], + ); + const roleId = (role: string) => boxes.list.find((m) => m.role === role)?.id ?? null; + await registerThisBrowser(key, { call, accountId: account.mailAccountId, inboxId: roleId("inbox"), endpointOf: account.mailAccountId }); + facts.push({ accountId: account.mailAccountId, sessionId: account.id, username: account.username, archiveId: roleId("archive"), inboxId: roleId("inbox") }); + } catch { + /* this one waits for the next start */ + } + } + await setOtherAccountFacts(facts); } /** @@ -189,16 +273,26 @@ export async function renewWebPush(): Promise { // subscription is close to expiring, missing, or duplicated. await registerThisBrowser(key); listenForVerification(); + await registerOtherAccounts(key); } catch { /* offline, or the server said no: the next start tries again */ } } -/** Remove this browser's subscription, at the browser and at the server. */ +/** Remove this browser's subscription, at the browser and at the server, in every signed-in account. */ export async function disableWebPush(): Promise { + await unsubscribeOtherAccounts(); await unsubscribeThisDevice(); } +/** Remove this device's subscription from every account not in front. */ +export async function unsubscribeOtherAccounts(): Promise { + for (const account of await otherAccounts()) { + await unsubscribeAccount(otherAccountCall(account.id), account.mailAccountId); + } + await setOtherAccountFacts([]); +} + /** * Whether *this browser* has a subscription registered at the server. * diff --git a/web/src/lib/otherAccounts.ts b/web/src/lib/otherAccounts.ts new file mode 100644 index 0000000..43277ba --- /dev/null +++ b/web/src/lib/otherAccounts.ts @@ -0,0 +1,81 @@ +/** + * New mail in the accounts not in front. + * + * The webmail server answers each one's Inbox unread count through that + * account's own session (/api/auth/accounts/unread). The menu shows the counts; + * when one rises while the app is open, a desktop notification names the + * account, so mail for support@ isn't missed while someone works in their own. + * + * Only while a tab is open, and only where background notifications are off: + * with them on, each account has its own Web Push subscription and the worker + * notifies (lib/notify/webpushEnable, public/sw.js). + */ +import { useEffect } from "react"; +import { create } from "zustand"; +import { apiFetch } from "@/jmap/client"; +import { showNotification } from "@/lib/notify/notify"; +import { pushEnabledHere } from "@/lib/notify/webpush"; +import { t } from "@/lib/i18n"; +import { useSession } from "@/store/session"; +import { useSettings } from "@/store/settings"; + +/** How often to ask. The server keeps each answer a minute. */ +export const POLL_MS = 2 * 60_000; + +interface OtherUnreadState { + /** Unread count per session id; absent until first asked, or when unknown. */ + unread: Record; + set(unread: Record): void; +} + +export const useOtherUnread = create((set) => ({ + unread: {}, + set: (unread) => set({ unread }), +})); + +/** + * Which accounts gained unread mail since the last answer. An account seen for + * the first time is not "new": its mail was already there when it was added. + */ +export function risen(before: Record, after: Record): string[] { + return Object.entries(after) + .filter(([id, n]) => id in before && n > before[id]!) + .map(([id]) => id); +} + +export async function pollOtherUnread(): Promise { + const answer = await apiFetch<{ accounts: { id: string; unread: number | null }[] }>("/api/auth/accounts/unread"); + const next: Record = {}; + for (const a of answer.accounts) if (typeof a.unread === "number") next[a.id] = a.unread; + const before = useOtherUnread.getState().unread; + useOtherUnread.getState().set(next); + if (!useSettings.getState().settings.desktopNotifications) return; + // With background notifications on here, the worker tells about these + // accounts already: the counts stay, a second telling doesn't + if (pushEnabledHere()) return; + const names = new Map(useSession.getState().signedIn.map((a) => [a.id, a.username])); + for (const id of risen(before, next)) { + const name = names.get(id); + if (!name) continue; + showNotification(t("New mail for {name}", { name }), { + body: t("Unread in the Inbox: {count}", { count: next[id]! }), + tag: `other-account-${id}`, + onClick: () => void useSession.getState().switchTo(id), + }); + } +} + +/** Keeps the counts fresh while more than one account is signed in. */ +export function useOtherAccountsUnread(): void { + const others = useSession((s) => s.signedIn.filter((a) => !a.front).length); + useEffect(() => { + if (others === 0) { + useOtherUnread.getState().set({}); + return; + } + const tick = () => void pollOtherUnread().catch(() => undefined); + tick(); + const timer = setInterval(tick, POLL_MS); + return () => clearInterval(timer); + }, [others]); +} diff --git a/web/src/lib/sharedMail.ts b/web/src/lib/sharedMail.ts new file mode 100644 index 0000000..976e30e --- /dev/null +++ b/web/src/lib/sharedMail.ts @@ -0,0 +1,44 @@ +/** + * Mail other people let the reader into: a group's mailbox, or folders + * someone shared. + * + * Either one arrives as another account in the session, `isPersonal: false`. + * That alone proves nothing about mail -- Stalwart advertises every + * capability on any account it lists, so a colleague who shared one calendar + * shows up with mail too. What does prove it is asking: an account whose + * `Mailbox/get` answers with at least one mailbox has mail the reader can + * open, and only those are offered. + */ + +import { CAP, client } from "@/jmap/client"; +import type { GetResponse, Id, JmapSession, Mailbox } from "@/jmap/types"; + +export interface SharedMailAccount { + id: Id; + name: string; +} + +type SessionLike = Pick; + +/** Accounts that might hold mail for the reader, by name; see the note above. */ +export function sharedMailCandidates(session: SessionLike | null): SharedMailAccount[] { + if (!session) return []; + return Object.entries(session.accounts) + .filter(([, account]) => account.isPersonal === false && CAP.mail in (account.accountCapabilities ?? {})) + .map(([id, account]) => ({ id, name: account.name })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** The candidates that answer with at least one mailbox. One that fails is left out. */ +export async function findSharedMail(session: SessionLike | null): Promise { + const candidates = sharedMailCandidates(session); + const answers = await Promise.all( + candidates.map((account) => + client.call>("Mailbox/get", { accountId: account.id, ids: null, properties: ["id"] }).then( + (res) => (res.list.length > 0 ? account : null), + () => null, + ), + ), + ); + return answers.filter((a): a is SharedMailAccount => a !== null); +} diff --git a/web/src/lib/storage.ts b/web/src/lib/storage.ts index 7652163..209ed37 100644 --- a/web/src/lib/storage.ts +++ b/web/src/lib/storage.ts @@ -87,6 +87,9 @@ function ownKeys(): string[] { export function clearSignedInData(): void { for (const key of ownKeys()) { if (KEEP_ON_SIGN_OUT.includes(key)) continue; + // Each account's registered push endpoint, which is not mail + // and is cleared with its subscription (webpush.ts) + if (key.startsWith("pushEndpoint:")) continue; removeKey(key); } } diff --git a/web/src/lib/sw/__tests__/swFacts.test.ts b/web/src/lib/sw/__tests__/swFacts.test.ts index d7640ce..7a62b3d 100644 --- a/web/src/lib/sw/__tests__/swFacts.test.ts +++ b/web/src/lib/sw/__tests__/swFacts.test.ts @@ -44,6 +44,14 @@ describe("the worker's briefing", () => { expect(facts.archiveId).toBe("mb-archive"); }); + it("names the inbox a notification opens in", async () => { + // The route takes a mailbox id. The worker used to put the word `inbox` + // there, and every click landed on "That folder no longer exists". + const { store } = fakeCaches(); + await publishWorkerFacts("a1", "mb-archive", "mb-inbox"); + expect(written(store).inboxId).toBe("mb-inbox"); + }); + it("carries the worker's text in the language the tab is in", async () => { // The worker has no catalog. Everything it will say has to be said here // first, or a German reader gets English buttons on their lock screen. diff --git a/web/src/lib/sw/swFacts.ts b/web/src/lib/sw/swFacts.ts index 1dcb949..c735e68 100644 --- a/web/src/lib/sw/swFacts.ts +++ b/web/src/lib/sw/swFacts.ts @@ -28,6 +28,13 @@ export interface WorkerFacts { accountId: string; /** Where Archive files to; null where the account has no archive folder. */ archiveId: string | null; + /** The inbox a notification opens in; the route names a mailbox by id. */ + inboxId?: string | null; + /** + * The other accounts signed in here, so a push for one of them + * says whose it is and its buttons act through that account's session. + */ + others?: OtherAccountFacts[]; /** The worker's own user-visible text, in the language this tab is in. */ strings: { newMail: string; @@ -47,11 +54,31 @@ export interface WorkerFacts { * reading in a week's time. Rewriting it is one cache put; there is nothing to * gain by working out whether it differs. */ -export async function publishWorkerFacts(accountId: string | null, archiveId: string | null): Promise { +export interface OtherAccountFacts { + accountId: string; + sessionId: string; + username: string; + archiveId: string | null; + inboxId?: string | null; +} + +let lastFront: { accountId: string | null; archiveId: string | null; inboxId: string | null } = { accountId: null, archiveId: null, inboxId: null }; +let others: OtherAccountFacts[] = []; + +/** Record the other accounts and write the briefing again with them. */ +export async function setOtherAccountFacts(list: OtherAccountFacts[]): Promise { + others = list; + await publishWorkerFacts(lastFront.accountId, lastFront.archiveId, lastFront.inboxId); +} + +export async function publishWorkerFacts(accountId: string | null, archiveId: string | null, inboxId: string | null = null): Promise { + lastFront = { accountId, archiveId, inboxId }; if (typeof caches === "undefined" || !accountId) return; const facts: WorkerFacts = { accountId, archiveId, + inboxId, + others, strings: { newMail: t("New mail"), newMessage: t("New message"), diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 10e0ec9..d6e8068 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1730,6 +1730,26 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Einschätzung des Sprachmodells", "One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat", + // ── Shared and group mailboxes ── + "Mail to show": "E-Mails anzeigen von", + "My mail": "Meine E-Mails", + "Shared mailbox:": "Gemeinsames Postfach:", + "Back to my mail": "Zurück zu meinen E-Mails", + "Send or close the message you're writing first.": "Senden oder schließen Sie zuerst die Nachricht, die Sie gerade schreiben.", + "You no longer have access to {name}. Back to your own mail.": "Sie haben keinen Zugriff mehr auf {name}. Zurück zu Ihren eigenen E-Mails.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Hier können keine weiteren Konten hinzugefügt werden.", + "That account is on another mail server. Only accounts on this server can be added.": "Dieses Konto liegt auf einem anderen Mailserver. Nur Konten auf diesem Server können hinzugefügt werden.", + "That account couldn't be added.": "Dieses Konto konnte nicht hinzugefügt werden.", + "Add an account": "Konto hinzufügen", + "Working…": "Bitte warten…", + "Add account": "Konto hinzufügen", + "Both accounts stay signed in here; switch between them from this menu.": "Beide Konten bleiben hier angemeldet; wechseln Sie über dieses Menü zwischen ihnen.", + "Sign out of all accounts": "Von allen Konten abmelden", + // ── New mail in other accounts ── + "New mail for {name}": "Neue E-Mail für {name}", + "Unread in the Inbox: {count}": "Ungelesen im Posteingang: {count}", + "Account: new mail in another account": "Konto: neue E-Mails in einem anderen Konto", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 3aba8e7..d69438b 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1703,6 +1703,26 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Opinión del modelo de lenguaje", "One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta", + // ── Shared and group mailboxes ── + "Mail to show": "Correo que mostrar", + "My mail": "Mi correo", + "Shared mailbox:": "Buzón compartido:", + "Back to my mail": "Volver a mi correo", + "Send or close the message you're writing first.": "Envíe o cierre primero el mensaje que está escribiendo.", + "You no longer have access to {name}. Back to your own mail.": "Ya no tiene acceso a {name}. Vuelve a su propio correo.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "No puede añadir más cuentas aquí.", + "That account is on another mail server. Only accounts on this server can be added.": "Esa cuenta está en otro servidor de correo. Solo se pueden añadir cuentas de este servidor.", + "That account couldn't be added.": "No se pudo añadir esa cuenta.", + "Add an account": "Añadir una cuenta", + "Working…": "Procesando…", + "Add account": "Añadir cuenta", + "Both accounts stay signed in here; switch between them from this menu.": "Ambas cuentas siguen con la sesión iniciada aquí; cambie entre ellas desde este menú.", + "Sign out of all accounts": "Cerrar sesión en todas las cuentas", + // ── New mail in other accounts ── + "New mail for {name}": "Correo nuevo para {name}", + "Unread in the Inbox: {count}": "No leídos en la bandeja de entrada: {count}", + "Account: new mail in another account": "Cuenta: correo nuevo en otra cuenta", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index e893a70..b5cf8c7 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1708,6 +1708,26 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Avis du modèle de langage", "One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam", + // ── Shared and group mailboxes ── + "Mail to show": "Courrier à afficher", + "My mail": "Mon courrier", + "Shared mailbox:": "Boîte partagée :", + "Back to my mail": "Retour à mon courrier", + "Send or close the message you're writing first.": "Envoyez ou fermez d’abord le message que vous rédigez.", + "You no longer have access to {name}. Back to your own mail.": "Vous n’avez plus accès à {name}. Retour à votre propre courrier.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Vous ne pouvez pas ajouter d'autres comptes ici.", + "That account is on another mail server. Only accounts on this server can be added.": "Ce compte se trouve sur un autre serveur de messagerie. Seuls les comptes de ce serveur peuvent être ajoutés.", + "That account couldn't be added.": "Ce compte n'a pas pu être ajouté.", + "Add an account": "Ajouter un compte", + "Working…": "Traitement…", + "Add account": "Ajouter le compte", + "Both accounts stay signed in here; switch between them from this menu.": "Les deux comptes restent connectés ici ; passez de l'un à l'autre depuis ce menu.", + "Sign out of all accounts": "Se déconnecter de tous les comptes", + // ── New mail in other accounts ── + "New mail for {name}": "Nouveau courrier pour {name}", + "Unread in the Inbox: {count}": "Non lus dans la boîte de réception : {count}", + "Account: new mail in another account": "Compte : nouveau courrier dans un autre compte", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 0b1849f..1711792 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1711,6 +1711,26 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "言語モデルの見解", "One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ", + // ── Shared and group mailboxes ── + "Mail to show": "表示するメール", + "My mail": "自分のメール", + "Shared mailbox:": "共有メールボックス:", + "Back to my mail": "自分のメールに戻る", + "Send or close the message you're writing first.": "作成中のメッセージを先に送信するか閉じてください。", + "You no longer have access to {name}. Back to your own mail.": "{name} にアクセスできなくなりました。自分のメールに戻ります。", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "これ以上アカウントを追加できません。", + "That account is on another mail server. Only accounts on this server can be added.": "そのアカウントは別のメールサーバーにあります。追加できるのはこのサーバーのアカウントだけです。", + "That account couldn't be added.": "そのアカウントを追加できませんでした。", + "Add an account": "アカウントを追加", + "Working…": "処理中…", + "Add account": "アカウントを追加", + "Both accounts stay signed in here; switch between them from this menu.": "どちらのアカウントもここでサインインしたままになります。このメニューから切り替えられます。", + "Sign out of all accounts": "すべてのアカウントからサインアウト", + // ── New mail in other accounts ── + "New mail for {name}": "{name} に新着メール", + "Unread in the Inbox: {count}": "受信トレイの未読: {count}", + "Account: new mail in another account": "アカウント: 別のアカウントに新着メールがあります", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 2accb60..09176fe 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1703,6 +1703,26 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Oordeel van het taalmodel", "One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen", + // ── Shared and group mailboxes ── + "Mail to show": "Mail tonen van", + "My mail": "Mijn mail", + "Shared mailbox:": "Gedeeld postvak:", + "Back to my mail": "Terug naar mijn mail", + "Send or close the message you're writing first.": "Verzend of sluit eerst het bericht dat u schrijft.", + "You no longer have access to {name}. Back to your own mail.": "U hebt geen toegang meer tot {name}. Terug naar uw eigen mail.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "U kunt hier geen accounts meer toevoegen.", + "That account is on another mail server. Only accounts on this server can be added.": "Dat account staat op een andere mailserver. Alleen accounts op deze server kunnen worden toegevoegd.", + "That account couldn't be added.": "Dat account kon niet worden toegevoegd.", + "Add an account": "Een account toevoegen", + "Working…": "Bezig…", + "Add account": "Account toevoegen", + "Both accounts stay signed in here; switch between them from this menu.": "Beide accounts blijven hier ingelogd; wissel ertussen via dit menu.", + "Sign out of all accounts": "Uitloggen bij alle accounts", + // ── New mail in other accounts ── + "New mail for {name}": "Nieuwe mail voor {name}", + "Unread in the Inbox: {count}": "Ongelezen in Postvak IN: {count}", + "Account: new mail in another account": "Account: nieuwe mail in een ander account", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index c4149f2..17e5595 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1706,6 +1706,26 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Opinião do modelo de linguagem", "One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam", + // ── Shared and group mailboxes ── + "Mail to show": "E-mail a exibir", + "My mail": "Meu e-mail", + "Shared mailbox:": "Caixa postal compartilhada:", + "Back to my mail": "Voltar ao meu e-mail", + "Send or close the message you're writing first.": "Envie ou feche primeiro a mensagem que está escrevendo.", + "You no longer have access to {name}. Back to your own mail.": "Você não tem mais acesso a {name}. De volta ao seu próprio e-mail.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Não é possível adicionar mais contas aqui.", + "That account is on another mail server. Only accounts on this server can be added.": "Essa conta está em outro servidor de e-mail. Só é possível adicionar contas deste servidor.", + "That account couldn't be added.": "Não foi possível adicionar essa conta.", + "Add an account": "Adicionar uma conta", + "Working…": "Processando…", + "Add account": "Adicionar conta", + "Both accounts stay signed in here; switch between them from this menu.": "As duas contas continuam conectadas aqui; alterne entre elas por este menu.", + "Sign out of all accounts": "Sair de todas as contas", + // ── New mail in other accounts ── + "New mail for {name}": "E-mail novo para {name}", + "Unread in the Inbox: {count}": "Não lidas na Caixa de entrada: {count}", + "Account: new mail in another account": "Conta: e-mail novo em outra conta", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index d92fc14..53d137b 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1705,6 +1705,26 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Мнение языковой модели", "One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр", + // ── Shared and group mailboxes ── + "Mail to show": "Какую почту показать", + "My mail": "Моя почта", + "Shared mailbox:": "Общий почтовый ящик:", + "Back to my mail": "Вернуться к моей почте", + "Send or close the message you're writing first.": "Сначала отправьте или закройте письмо, которое пишете.", + "You no longer have access to {name}. Back to your own mail.": "У вас больше нет доступа к {name}. Возвращаемся к вашей почте.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Добавить здесь ещё учётные записи нельзя.", + "That account is on another mail server. Only accounts on this server can be added.": "Эта учётная запись находится на другом почтовом сервере. Добавить можно только учётные записи этого сервера.", + "That account couldn't be added.": "Не удалось добавить эту учётную запись.", + "Add an account": "Добавить учётную запись", + "Working…": "Выполняется…", + "Add account": "Добавить учётную запись", + "Both accounts stay signed in here; switch between them from this menu.": "Обе учётные записи остаются здесь в системе; переключайтесь между ними в этом меню.", + "Sign out of all accounts": "Выйти из всех учётных записей", + // ── New mail in other accounts ── + "New mail for {name}": "Новое письмо для {name}", + "Unread in the Inbox: {count}": "Непрочитанных во «Входящих»: {count}", + "Account: new mail in another account": "Учётная запись: новые письма в другой учётной записи", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index a2259a7..74a1ec8 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -1602,6 +1602,26 @@ export const catalog: Catalog = { "“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.": "“{name}” devre dışı bırakılacak (silinmeyecek) ve yeni bir “ihasmail” betiği görevi devralacaktır.", "•••": "•••", "+ New folder…": "+ Yeni klasör…", + // ── Shared and group mailboxes ── + "Mail to show": "Gösterilecek posta", + "My mail": "Postam", + "Shared mailbox:": "Paylaşılan posta kutusu:", + "Back to my mail": "Postama dön", + "Send or close the message you're writing first.": "Önce yazdığınız iletiyi gönderin veya kapatın.", + "You no longer have access to {name}. Back to your own mail.": "{name} hesabına artık erişiminiz yok. Kendi postanıza dönün.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Buraya daha fazla hesap ekleyemezsiniz.", + "That account is on another mail server. Only accounts on this server can be added.": "Bu hesap başka bir posta sunucusunda. Yalnızca bu sunucudaki hesaplar eklenebilir.", + "That account couldn't be added.": "Bu hesap eklenemedi.", + "Add an account": "Hesap ekle", + "Working…": "İşleniyor…", + "Add account": "Hesap ekle", + "Both accounts stay signed in here; switch between them from this menu.": "Her iki hesap da burada oturum açık kalır; bu menüden aralarında geçiş yapın.", + "Sign out of all accounts": "Tüm hesaplardan çıkış yap", + // ── New mail in other accounts ── + "New mail for {name}": "{name} için yeni posta", + "Unread in the Inbox: {count}": "Gelen Kutusu'nda okunmamış: {count}", + "Account: new mail in another account": "Hesap: başka bir hesapta yeni posta", }, plurals: { "({n} deleted first)": { diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index eb217b1..e5c240a 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1699,6 +1699,26 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Думка мовної моделі", "One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр", + // ── Shared and group mailboxes ── + "Mail to show": "Яку пошту показати", + "My mail": "Моя пошта", + "Shared mailbox:": "Спільна поштова скринька:", + "Back to my mail": "Повернутися до моєї пошти", + "Send or close the message you're writing first.": "Спочатку надішліть або закрийте лист, який пишете.", + "You no longer have access to {name}. Back to your own mail.": "У вас більше немає доступу до {name}. Повертаємося до вашої пошти.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Додати тут ще облікові записи не можна.", + "That account is on another mail server. Only accounts on this server can be added.": "Цей обліковий запис розміщено на іншому поштовому сервері. Додати можна лише облікові записи цього сервера.", + "That account couldn't be added.": "Не вдалося додати цей обліковий запис.", + "Add an account": "Додати обліковий запис", + "Working…": "Виконується…", + "Add account": "Додати обліковий запис", + "Both accounts stay signed in here; switch between them from this menu.": "Обидва облікові записи залишаються тут у системі; перемикайтеся між ними в цьому меню.", + "Sign out of all accounts": "Вийти з усіх облікових записів", + // ── New mail in other accounts ── + "New mail for {name}": "Новий лист для {name}", + "Unread in the Inbox: {count}": "Непрочитаних у «Вхідних»: {count}", + "Account: new mail in another account": "Обліковий запис: нові листи в іншому обліковому записі", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 9d8b4de..b1a7409 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1710,6 +1710,26 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "语言模型的判断", "One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一", + // ── Shared and group mailboxes ── + "Mail to show": "要显示的邮件", + "My mail": "我的邮件", + "Shared mailbox:": "共享邮箱:", + "Back to my mail": "返回我的邮件", + "Send or close the message you're writing first.": "请先发送或关闭您正在撰写的邮件。", + "You no longer have access to {name}. Back to your own mail.": "您已无法访问 {name}。已返回您自己的邮件。", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "无法在此添加更多账户。", + "That account is on another mail server. Only accounts on this server can be added.": "该账户位于其他邮件服务器上。只能添加此服务器上的账户。", + "That account couldn't be added.": "无法添加该账户。", + "Add an account": "添加账户", + "Working…": "正在处理…", + "Add account": "添加账户", + "Both accounts stay signed in here; switch between them from this menu.": "两个账户都会在这里保持登录;可从此菜单在它们之间切换。", + "Sign out of all accounts": "退出所有账户", + // ── New mail in other accounts ── + "New mail for {name}": "{name} 有新邮件", + "Unread in the Inbox: {count}": "收件箱中的未读邮件:{count}", + "Account: new mail in another account": "账户:其他账户有新邮件", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/store/__tests__/account-switcher.test.ts b/web/src/store/__tests__/account-switcher.test.ts new file mode 100644 index 0000000..62964f7 --- /dev/null +++ b/web/src/store/__tests__/account-switcher.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSession } from "@/store/session"; + +/** + * The accounts signed in in this browser. The server lists them + * and swaps the one in front; the web app clears what it cached for the + * previous account and reloads, and signing out of one reloads into the next. + */ + +let reload: ReturnType; +let calls: { url: string; method: string }[]; +let answers: Record; + +beforeEach(() => { + reload = vi.fn(); + Object.defineProperty(window, "location", { configurable: true, value: { ...window.location, reload } }); + calls = []; + answers = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + const path = String(url); + calls.push({ url: path, method: init?.method ?? "GET" }); + const key = Object.keys(answers).find((k) => path.endsWith(k)); + const body = key ? answers[key] : { ok: true }; + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response; + }), + ); + localStorage.setItem("ihasmail:cached-thing", "from the account in front"); + useSession.setState({ status: "authenticated", signedIn: [], canAddAccount: false }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +describe("account switcher", () => { + it("lists the accounts and whether one more may be added", async () => { + answers["/api/auth/accounts"] = { + accounts: [ + { id: "a", username: "first@example.com", front: true }, + { id: "b", username: "second@example.com", front: false }, + ], + canAdd: true, + }; + await useSession.getState().loadSignedIn(); + expect(useSession.getState().signedIn.map((a) => a.username)).toEqual(["first@example.com", "second@example.com"]); + expect(useSession.getState().canAddAccount).toBe(true); + }); + + it("switching asks the server, forgets the cache and reloads", async () => { + await useSession.getState().switchTo("b"); + expect(calls.some((c) => c.url.endsWith("/api/auth/accounts/b/front") && c.method === "POST")).toBe(true); + expect(localStorage.getItem("ihasmail:cached-thing")).toBeNull(); + expect(reload).toHaveBeenCalledOnce(); + }); + + it("signing out of one reloads into the next when there is one", async () => { + answers["/api/auth/logout"] = { ok: true, next: true }; + await useSession.getState().logout(); + expect(reload).toHaveBeenCalledOnce(); + }); + + it("signing out of the last one, or of all, ends at the sign-in page", async () => { + answers["/api/auth/logout-all"] = { ok: true }; + await useSession.getState().logoutAll(); + expect(calls.some((c) => c.url.endsWith("/api/auth/logout-all"))).toBe(true); + expect(reload).not.toHaveBeenCalled(); + expect(useSession.getState().status).toBe("anonymous"); + // The next ordinary sign-out goes back to signing out of one + useSession.setState({ status: "authenticated" }); + answers["/api/auth/logout"] = { ok: true }; + await useSession.getState().logout(); + expect(calls.filter((c) => c.url.endsWith("/api/auth/logout")).length).toBe(1); + }); +}); diff --git a/web/src/store/__tests__/shared-mail.test.ts b/web/src/store/__tests__/shared-mail.test.ts new file mode 100644 index 0000000..510220e --- /dev/null +++ b/web/src/store/__tests__/shared-mail.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import type { JmapSession } from "@/jmap/types"; +import { useSession } from "@/store/session"; +import { useMail } from "@/store/mail"; +import { findSharedMail, sharedMailCandidates } from "@/lib/sharedMail"; + +/** + * A group's mailbox, or folders someone shared, can be opened in place of the + * reader's own mail. Only accounts that answer with a mailbox are offered, + * only mail follows the switch, and losing the account takes the reader back. + */ + +const sessionWith = (extra: Record = {}) => + ({ + capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} }, + accounts: { + own: { + name: "me@example.com", + isPersonal: true, + accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} }, + }, + // A group: every capability, and mailboxes + group: { + name: "support@example.com", + isPersonal: false, + accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} }, + }, + // Someone who shared one calendar: mail is advertised, but no mailbox answers + calendarOnly: { name: "colleague@example.com", isPersonal: false, accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {} } }, + // No mail at all + filesOnly: { name: "files@example.com", isPersonal: false, accountCapabilities: { [CAP.filenode]: {} } }, + ...extra, + }, + primaryAccounts: { [CAP.mail]: "own", [CAP.calendars]: "own", [CAP.contacts]: "own", [CAP.filenode]: "own" }, + state: "s", + }) as unknown as JmapSession; + +/** Accounts whose Mailbox/get answers with a mailbox. */ +let withMailboxes = new Set(["group"]); +let nextSession: JmapSession; + +beforeEach(() => { + withMailboxes = new Set(["group"]); + nextSession = sessionWith(); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).includes("/api/auth/session")) { + return { ok: true, status: 200, json: async () => nextSession } as Response; + } + const { methodCalls } = JSON.parse((init?.body as string) ?? "{}") as { methodCalls: [string, Record, string][] }; + const methodResponses = methodCalls.map(([name, args, id]) => [ + name, + { accountId: args.accountId, state: "1", list: withMailboxes.has(String(args.accountId)) ? [{ id: "a" }] : [], notFound: [] }, + id, + ]); + return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response; + }), + ); + const session = sessionWith(); + client.session = session; + useSession.setState({ status: "authenticated", session, accountId: "own", viewing: null, sharedMail: [], viewEnded: null }); +}); + +afterEach(() => { + useSession.setState({ viewing: null, sharedMail: [] }); + vi.unstubAllGlobals(); +}); + +describe("shared mail", () => { + it("considers only other people's accounts that advertise mail", () => { + expect(sharedMailCandidates(sessionWith()).map((a) => a.id)).toEqual(["calendarOnly", "group"]); + }); + + it("offers only accounts that answer with a mailbox", async () => { + expect((await findSharedMail(sessionWith())).map((a) => a.name)).toEqual(["support@example.com"]); + }); + + it("can't be opened until it is found, and then shows mail only", async () => { + useSession.getState().view("group"); + expect(useSession.getState().viewing).toBeNull(); + + await useSession.getState().loadSharedMail(); + useSession.getState().view("group"); + expect(useSession.getState().viewing).toBe("group"); + expect(useMail.getState().accountId).toBe("group"); + // Calendars, contacts and files stay the reader's own + expect(useSession.getState().accountFor(CAP.calendars)).toBe("own"); + expect(useSession.getState().accountFor(CAP.contacts)).toBe("own"); + expect(useSession.getState().accountFor(CAP.filenode)).toBe("own"); + + // An account with no mailboxes is never offered + useSession.getState().view("calendarOnly"); + expect(useSession.getState().viewing).toBe("group"); + + // And back + useSession.getState().view(null); + expect(useMail.getState().accountId).toBe("own"); + }); + + it("takes the reader back to their own mail when the account goes away", async () => { + await useSession.getState().loadSharedMail(); + useSession.getState().view("group"); + const session = sessionWith(); + delete (session.accounts as Record).group; + nextSession = session; + withMailboxes = new Set(); + await useSession.getState().refresh(); + expect(useSession.getState().viewing).toBeNull(); + expect(useSession.getState().viewEnded).toBe("support@example.com"); + expect(useMail.getState().accountId).toBe("own"); + }); +}); diff --git a/web/src/store/mail/index.ts b/web/src/store/mail/index.ts index 59ed65c..575680f 100644 --- a/web/src/store/mail/index.ts +++ b/web/src/store/mail/index.ts @@ -105,6 +105,15 @@ export const useMail = create((set, get) => ({ setAccount(accountId) { if (accountId === get().accountId) return; + // Leaving the reader's own mail for a shared mailbox, remember where theirs + // was, so new mail there is still announced + const own = useSession.getState().accountId; + const leaving = get().accountId; + if (leaving && leaving === own && accountId && accountId !== own) { + ownWhileAway = { accountId: own, inbox: get().roleId("inbox"), state: get().emailState }; + } else if (accountId === own) { + ownWhileAway = null; + } resetBodyOrder(); snapshots.clear(); set({ @@ -1490,10 +1499,56 @@ function removeFromList(ids: Id[], set: (fn: (s: MailState) => Partial MailState) { - const s = settings(); const inbox = get().roleId("inbox"); if (!inbox) return; const emails = await get().getEmails(created); + announceNewMail(emails, inbox); +} + +/** + * The reader's own account while a shared mailbox is in view: its inbox and + * how far its mail was seen, so what arrives there meanwhile is still + * announced. + */ +let ownWhileAway: { accountId: Id; inbox: Id | null; state: string | null } | null = null; + +/** New mail in the reader's own account, while a shared mailbox is in view. */ +export async function notifyOwnWhileAway(): Promise { + const away = ownWhileAway; + if (!away?.inbox || !away.state) return; + try { + const changes = await client.call("Email/changes", { + accountId: away.accountId, + sinceState: away.state, + maxChanges: 50, + }); + if (ownWhileAway !== away) return; + away.state = changes.newState; + if (!changes.created.length) return; + const got = await client.call>("Email/get", { + accountId: away.accountId, + ids: changes.created, + properties: LIST_PROPS, + }); + announceNewMail(got.list, away.inbox); + } catch { + /* the next change tries again */ + } +} + +export function ownAccountAway(): Id | null { + return ownWhileAway?.accountId ?? null; +} + +/** The reader's own inbox, whatever account is in view. */ +export function ownInboxId(): Id | null { + const mail = useMail.getState(); + if (mail.accountId === useSession.getState().accountId) return mail.roleId("inbox"); + return ownWhileAway?.inbox ?? null; +} + +function announceNewMail(emails: Email[], inbox: Id) { + const s = settings(); const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft); if (!fresh.length) return; if (s.notificationSound) playNewMailSound(); @@ -1519,9 +1574,9 @@ async function notifyNewMail(created: Id[], get: () => MailState) { } } -/** Keep the store bound to the selected account. */ +/** Keep the store bound to the account in view: a shared mailbox while one is open, the reader's own otherwise. */ useSession.subscribe((s) => { - useMail.getState().setAccount(s.status === "authenticated" ? s.accountId : null); + useMail.getState().setAccount(s.status === "authenticated" ? (s.viewing ?? s.accountId) : null); }); diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 1132f25..97ec5ea 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -6,9 +6,11 @@ import { accountForCapability, ownAccountForCapability } from "@/lib/accountRout import { setServerLocale } from "@/lib/datetime"; import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync"; import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild"; -import { unsubscribeThisDevice } from "@/lib/notify/webpush"; +import { unsubscribeAccount, unsubscribeThisDevice } from "@/lib/notify/webpush"; import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage"; import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout"; +import { withBase } from "@/lib/basePath"; +import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail"; export type AuthStatus = "loading" | "anonymous" | "authenticated"; @@ -17,15 +19,41 @@ interface SessionState { session: JmapSession | null; /** Selected mail account (defaults to primary). */ accountId: Id | null; + /** + * A group's mailbox or someone's shared folders, shown in Mail instead of + * the reader's own. Mail only: calendars, contacts, files, settings, filters + * and push stay the reader's own. + */ + viewing: Id | null; + /** Shared and group mailboxes the reader can open; see lib/sharedMail. */ + sharedMail: SharedMailAccount[]; + /** The name of a shared mailbox the reader lost while it was in view. */ + viewEnded: string | null; + /** The accounts signed in in this browser, the one in front first. */ + signedIn: SignedInAccount[]; + /** Whether one more may be added (at most five, all on the same server). */ + canAddAccount: boolean; error: string | null; pushConnected: boolean; /** Finer than pushConnected: tells "reconnecting" from "not connected". */ pushState: PushState; bootstrap(): Promise; login(username: string, password: string, totp: string, remember: boolean): Promise; + /** Signs out of the account in front; another signed-in one comes forward. */ logout(): Promise; + /** Ends every account signed in in this browser. */ + logoutAll(): Promise; + /** Finds the other accounts signed in here. */ + loadSignedIn(): Promise; + /** Brings another signed-in account to the front, and reloads. */ + switchTo(sessionId: string): Promise; refresh(): Promise; setAccount(id: Id): void; + /** Show a shared mailbox's mail, or the reader's own with null. */ + view(id: Id | null): void; + /** Finds the shared and group mailboxes the reader can open. */ + loadSharedMail(): Promise; + clearViewEnded(): void; /** The account to read and write for a capability, honoring the account switcher. */ accountFor(cap: string): Id | null; /** The user's own account for a capability, whatever they are looking at. */ @@ -34,10 +62,50 @@ interface SessionState { let refreshing: Promise | null = null; +/** What a signed-in account looks like in the switcher. */ +export interface SignedInAccount { + id: string; + username: string; + front: boolean; + /** Its mail account, for its push subscription; null when not known yet. */ + mailAccountId?: string | null; +} + +/** + * A notification for an account not in front opens + * `?account=&next=`: bring that account forward, then go + * there. Only a path inside the app is followed. + */ +// Read as the app starts: the router sends `/` on to `/mail` without its query. +let launchParams: URLSearchParams | null = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null; + +async function openFromNotification(accounts: SignedInAccount[]): Promise { + const params = launchParams; + launchParams = null; + const wanted = params?.get("account"); + if (!params || !wanted) return; + const raw = params.get("next") ?? ""; + const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : withBase("/mail"); + const account = accounts.find((a) => a.id === wanted); + if (account && !account.front) { + await apiFetch(`/api/auth/accounts/${encodeURIComponent(wanted)}/front`, { method: "POST" }); + clearSignedInData(); + } + window.location.replace(next); +} + +/** Which sign-out: the account in front, or every one. */ +let signOutPath = "/api/auth/logout"; + export const useSession = create((set, get) => ({ status: "loading", session: null, accountId: null, + viewing: null, + sharedMail: [], + viewEnded: null, + signedIn: [], + canAddAccount: false, error: null, pushConnected: false, pushState: "disconnected", @@ -47,7 +115,7 @@ export const useSession = create((set, get) => ({ const s = await apiFetch("/api/auth/session"); applySession(s, set); } catch (err) { - if (err instanceof ApiError && err.status === 401) set({ status: "anonymous", session: null, accountId: null }); + if (err instanceof ApiError && err.status === 401) set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] }); else set({ status: "anonymous", error: (err as Error).message }); } }, @@ -75,7 +143,19 @@ export const useSession = create((set, get) => ({ // without removing it leaves this browser notifying for a mailbox nobody is // signed into. On a shared machine that is somebody else's mail. try { - await unsubscribeThisDevice(); + const everyone = signOutPath.endsWith("logout-all"); + const othersRemain = !everyone && get().signedIn.some((a) => !a.front); + if (everyone) { + // Every account's subscription goes, the others' first + const { unsubscribeOtherAccounts } = await import("@/lib/notify/webpushEnable"); + await unsubscribeOtherAccounts(); + } + if (othersRemain) { + // Only this account's; the browser keeps notifying for the rest + await unsubscribeAccount(undefined, get().ownAccountFor(CAP.mail)); + } else { + await unsubscribeThisDevice(); + } } catch { /* never block signing out over this */ } @@ -88,8 +168,11 @@ export const useSession = create((set, get) => ({ } catch { /* never block signing out over this */ } + const path = signOutPath; + signOutPath = "/api/auth/logout"; + let next = false; try { - await apiFetch("/api/auth/logout", { method: "POST" }); + next = Boolean((await apiFetch<{ next?: boolean }>(path, { method: "POST" }))?.next); } catch { /* ignore */ } @@ -99,7 +182,35 @@ export const useSession = create((set, get) => ({ // problem next -- and the address book cached here is the same argument. clearSignedInData(); client.session = null; - set({ status: "anonymous", session: null, accountId: null }); + // Another signed-in account is in front now + if (next) { + window.location.reload(); + return; + } + set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [], signedIn: [], canAddAccount: false }); + }, + + async logoutAll() { + // The same care as signing out of one, then every account ends + signOutPath = "/api/auth/logout-all"; + await get().logout(); + }, + + async loadSignedIn() { + try { + const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts"); + set({ signedIn: answer.accounts, canAddAccount: answer.canAdd }); + await openFromNotification(answer.accounts); + } catch { + set({ signedIn: [], canAddAccount: false }); + } + }, + + async switchTo(sessionId) { + await apiFetch(`/api/auth/accounts/${encodeURIComponent(sessionId)}/front`, { method: "POST" }); + // What was cached belongs to the account that was in front + clearSignedInData(); + window.location.reload(); }, refresh() { @@ -109,7 +220,15 @@ export const useSession = create((set, get) => ({ const s = await apiFetch("/api/auth/session?refresh=1"); client.session = s; setServerLocale(s.ihasmail?.userLocale); - set({ session: s }); + // A shared mailbox taken away, or a group left, takes the reader back to their own mail + const viewing = get().viewing; + if (viewing && !sharedMailCandidates(s).some((a) => a.id === viewing)) { + const name = get().session?.accounts[viewing]?.name ?? null; + set({ session: s, viewing: null, viewEnded: name }); + } else { + set({ session: s }); + } + void get().loadSharedMail(); } catch { /* ignore */ } finally { @@ -123,6 +242,24 @@ export const useSession = create((set, get) => ({ set({ accountId: id }); }, + view(id) { + if (id === get().viewing) return; + if (id && !get().sharedMail.some((a) => a.id === id)) return; + set({ viewing: id }); + }, + + async loadSharedMail() { + const session = get().session; + const found = await findSharedMail(session); + // A sign-out or another session while it was asking + if (get().session !== session) return; + set({ sharedMail: found }); + }, + + clearViewEnded() { + set({ viewEnded: null }); + }, + accountFor(cap) { return accountForCapability(get().session, get().accountId, cap); }, @@ -149,7 +286,9 @@ function applySession(s: JmapSession, set: (p: Partial) => void) { startIdleLogout(() => void useSession.getState().logout()); } const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null; - set({ status: "authenticated", session: s, accountId, error: null }); + set({ status: "authenticated", session: s, accountId, viewing: null, sharedMail: [], error: null }); + void useSession.getState().loadSharedMail(); + void useSession.getState().loadSignedIn(); } client.onUnauthenticated(() => { @@ -162,7 +301,7 @@ client.onUnauthenticated(() => { // usual reason to be signed out here, and reloading a form someone has // already started typing into would throw the password away. void reloadIfServerRebuilt().then((reloading) => { - if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null }); + if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] }); }); }); @@ -171,3 +310,10 @@ push.onConnection((state) => useSession.setState({ pushConnected: state === "con export function hasCap(cap: string): boolean { return client.hasCapability(cap); } + +/** The shared or group mailbox in view, if one is. */ +export function useViewingShared(): SharedMailAccount | null { + const viewing = useSession((s) => s.viewing); + const sharedMail = useSession((s) => s.sharedMail); + return viewing ? (sharedMail.find((a) => a.id === viewing) ?? null) : null; +} diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 4f1deac..f5d858b 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1243,11 +1243,19 @@ a.menu-item:hover { color: var(--fg); } * bar shrink instead of setting the width for everything below it. */ .app { height: 100%; display: grid; grid-template-rows: var(--topbar-h) 1fr; grid-template-columns: minmax(0, 1fr); overflow: hidden; } +/* A shared or group mailbox in view: its bar is a grid row of its own above the top bar, in the palette's accent. */ +.app.viewing-shared { grid-template-rows: auto var(--topbar-h) 1fr; } +.shared-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--accent); color: var(--accent-fg); font-size: .9em; min-width: 0; } +.shared-bar strong { font-weight: 700; } +.shared-bar button { flex: none; border: 1px solid currentColor; background: transparent; color: inherit; border-radius: 999px; padding: 3px 12px; font: inherit; font-weight: 600; cursor: pointer; } +.shared-bar button:hover, .shared-bar button:focus-visible { background: color-mix(in srgb, currentColor 15%, transparent); } .topbar { display: flex; align-items: center; gap: 8px; padding: 0 12px; background: var(--bg); position: relative; z-index: 50; } .topbar .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1.2em; letter-spacing: -.01em; color: var(--fg); text-decoration: none; padding-right: 8px; min-width: 0; } .topbar .brand img { width: 34px; height: 34px; object-fit: contain; } .topbar .brand .brand-name { color: #14b8a6; } .topbar .brand .brand-name span { color: var(--fg-muted); font-weight: 500; } +/* New mail in another signed-in account, on the avatar */ +.acct-dot { position: absolute; top: 0; right: 0; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); } /* `min-width: 0`, or the flex default of `auto` holds the search field at its own min-content and the account buttons beside it are pushed off a phone. */ .searchbar { flex: 1 1 auto; min-width: 0; max-width: 720px; margin: 0 auto; position: relative; } diff --git a/web/src/views/AddAccountDialog.tsx b/web/src/views/AddAccountDialog.tsx new file mode 100644 index 0000000..d2739b9 --- /dev/null +++ b/web/src/views/AddAccountDialog.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Dialog } from "@/ui/dialog"; +import { ApiError, apiFetch } from "@/jmap/client"; +import { clearSignedInData, isDeviceTrusted } from "@/lib/storage"; +import { t } from "@/lib/i18n"; + +/** + * Sign a second account in beside the one in front. It joins the others and + * comes to the front, and the app reloads into it. + * + * It is remembered on this device exactly as the first one was. + */ +export function AddAccountDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const remember = isDeviceTrusted(); + + useEffect(() => { + if (open) setError(null); + }, [open]); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (!username.trim() || !password) return; + setBusy(true); + setError(null); + try { + await apiFetch("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username: username.trim(), password, remember, add: true }), + }); + clearSignedInData(); + window.location.reload(); + } catch (err) { + const code = err instanceof ApiError ? err.code : ""; + setError( + code === "add_full" + ? t("You can't add more accounts here.") + : code === "add_other_server" + ? t("That account is on another mail server. Only accounts on this server can be added.") + : (err as Error).message || t("That account couldn't be added."), + ); + setBusy(false); + } + }; + + return ( + + + + + } + > +
void submit(e)}> +

{t("Both accounts stay signed in here; switch between them from this menu.")}

+
+ + setUsername(e.target.value)} autoFocus /> +
+
+ + setPassword(e.target.value)} /> +
+ {error &&

{error}

} +
+
+ ); +} diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index aafb62c..42fbf50 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -1,13 +1,17 @@ import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; -import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PanelLeftClose, PanelLeftOpen, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react"; +import { BookOpen, Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PanelLeftClose, PanelLeftOpen, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react"; import { useSession } from "@/store/session"; +import { toast } from "@/ui/toast"; +import { SharedBar } from "./SharedBar"; import { withBase } from "@/lib/basePath"; import { DEFAULT_APP_NAME } from "@/lib/brand"; import { useEffectiveTheme, useSettings } from "@/store/settings"; import { toggleTarget } from "@/lib/palette"; import { useMail } from "@/store/mail"; import { draftFromMailto, useCompose } from "@/store/compose"; +import { AddAccountDialog } from "./AddAccountDialog"; +import { useOtherAccountsUnread, useOtherUnread } from "@/lib/otherAccounts"; import { Avatar, useIsMobile } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { Splitter } from "@/ui/Splitter"; @@ -70,6 +74,23 @@ export function AppShell({ children }: { children: ReactNode }) { const pushState = useSession((s) => s.pushState); const session = useSession((s) => s.session); const logout = useSession((s) => s.logout); + // The other accounts signed in here, and adding one + const signedIn = useSession((s) => s.signedIn); + const canAddAccount = useSession((s) => s.canAddAccount); + const [addingAccount, setAddingAccount] = useState(false); + // New mail in the accounts not in front + useOtherAccountsUnread(); + const otherUnread = useOtherUnread((s) => s.unread); + const anyOtherUnread = Object.values(otherUnread).some((n) => n > 0); + const bringForward = (sessionId: string) => { + acctMenu.close(); + // A message being written belongs to the account it was started in + if (useCompose.getState().drafts.length) { + toast.show(t("Send or close the message you're writing first.")); + return; + } + void useSession.getState().switchTo(sessionId); + }; const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; const acctMenu = useMenu(); const administers = hasAdministration(usePermissions()); @@ -131,18 +152,34 @@ export function AppShell({ children }: { children: ReactNode }) { }, [openShare, navigate]); /* - * There is no account switcher any more. + * Mail other people let the reader into: a group's mailbox, or folders + * someone shared. * - * It existed to reach what other people shared, and was the wrong door: it - * moved the whole app to somebody else's account, and Stalwart advertises - * every capability on a shared account, so mail, calendar and contacts went - * with it and were refused. Shares are listed where they belong now -- in - * Files and in Contacts, beside the reader's own -- and found without anyone - * having to know an account switch was involved. + * There used to be an account switcher, and it was the wrong door: it moved + * the whole app to somebody else's account, and Stalwart advertises every + * capability on a shared account, so mail, calendar and contacts went with + * it and were refused. Calendars, contacts and files shares are listed where + * they belong, beside the reader's own. Mail is the one thing shown in place + * of the reader's, so this switches Mail alone, and offers only accounts + * that answered with mailboxes (lib/sharedMail). */ + const viewing = useSession((s) => s.viewing); + const sharedMail = useSession((s) => s.sharedMail); + const switchTo = (id: string | null) => { + acctMenu.close(); + if (id === viewing) return; + // A message being written belongs to the account it was started in + if (useCompose.getState().drafts.length) { + toast.show(t("Send or close the message you're writing first.")); + return; + } + useSession.getState().view(id); + navigate("/mail"); + }; return ( -
+
+
{/* On a wide screen the folder list is toggled from the bottom of the rail; phones keep the menu button for the drawer. */} {isMobile && ( @@ -175,8 +212,15 @@ export function AppShell({ children }: { children: ReactNode }) { )} -
@@ -189,6 +233,58 @@ export function AppShell({ children }: { children: ReactNode }) {
+ {/* Each account signed in here, the one in front ticked */} + {(signedIn.length > 1 || canAddAccount) && ( + <> + {signedIn.length > 1 &&
{t("Accounts")}
} + {signedIn.length > 1 && + signedIn.map((account) => ( + : } + label={ + + {account.username} + {!account.front && (otherUnread[account.id] ?? 0) > 0 && ( + + {otherUnread[account.id]! > 9999 ? "9999+" : otherUnread[account.id]} + + )} + + } + active={account.front} + onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))} + /> + ))} + {canAddAccount && ( + } + label={t("Add account")} + onClick={() => { + acctMenu.close(); + setAddingAccount(true); + }} + /> + )} + + + )} + {sharedMail.length > 0 && ( + <> +
{t("Mail to show")}
+ : } label={t("My mail")} active={!viewing} onClick={() => switchTo(null)} /> + {sharedMail.map((account) => ( + : } + label={{account.name}} + active={viewing === account.id} + onClick={() => switchTo(account.id)} + /> + ))} + + + )} } label={t("Documentation")} href="https://docs.ihasmail.org" external /> {/* The project site. It is linked from the login screen footer, which is a page a signed-in user never sees again -- so from inside the @@ -215,7 +311,11 @@ export function AppShell({ children }: { children: ReactNode }) { )} } label={t("Refresh")} onClick={() => window.location.reload()} /> } label={t("Sign out")} onClick={() => void logout()} /> + {signedIn.length > 1 && ( + } label={t("Sign out of all accounts")} onClick={() => void useSession.getState().logoutAll()} /> + )} + setAddingAccount(false)} />
diff --git a/web/src/views/SharedBar.tsx b/web/src/views/SharedBar.tsx new file mode 100644 index 0000000..cb4855f --- /dev/null +++ b/web/src/views/SharedBar.tsx @@ -0,0 +1,30 @@ +import { Users } from "lucide-react"; +import { useLocation } from "wouter"; +import { useSession, useViewingShared } from "@/store/session"; +import { t } from "@/lib/i18n"; + +/** + * While a shared or group mailbox is in view, a bar across the whole app says + * whose mail this is, with the way back. In the palette's accent, so it reads + * as part of the app and not as a warning. + */ +export function SharedBar() { + const shared = useViewingShared(); + const [, navigate] = useLocation(); + if (!shared) return null; + const back = () => { + useSession.getState().view(null); + navigate("/mail"); + }; + return ( +
+ + + {t("Shared mailbox:")} {shared.name} + + +
+ ); +} diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 2bd752a..adf5056 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type Reac import { Link, useLocation } from "wouter"; import { AlertOctagon, Archive, ArrowDown, ArrowUp, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react"; import { useMail } from "@/store/mail"; +import { useSession } from "@/store/session"; import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder"; import { labelTree, visibleLabels } from "@/lib/mailbox/labelTree"; import { isScheduledMailbox } from "@/store/scheduled"; @@ -99,7 +100,9 @@ export function MailboxTree() { toast.error(t("Could not move “{name}”: {reason}", { name: mailboxDisplayName(mailboxes[id]!), reason: (err as Error).message })); } }; - const shown = (m: Mailbox) => showHidden || m.isSubscribed || m.role === "inbox"; + // Subscriptions are the reader's own, and they have none in a shared mailbox, so every folder there shows. + const viewingOther = useSession((s) => s.viewing !== null); + const shown = (m: Mailbox) => showHidden || viewingOther || m.isSubscribed || m.role === "inbox"; // Tree: in `compareFolders` order at every level (Inbox, then any order the // user has dragged into place, then the special folders, then A–Z), @@ -516,6 +519,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove, onSt }; const empty = () => confirmAndEmpty({ id: m.id, name: mailboxDisplayName(m), role: m.role, totalEmails: m.totalEmails }); const isSpecial = Boolean(m.role) && m.role !== "subscribed"; + const viewingOther = useSession((s) => s.viewing !== null); const color = folderColor(colors, m.id); const setColor = (c: string | null) => { onClose(); @@ -542,7 +546,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove, onSt {/* The way to reorder without a drag: from the keyboard, and on touch. */} } label={t("Move up")} onClick={() => onStep("up")} disabled={!canStep("up")} /> } label={t("Move down")} onClick={() => onStep("down")} disabled={!canStep("down")} /> - : } label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} /> + {!viewingOther && : } label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />} {/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and stores the share, and it never reaches the other account -- its own docs list calendars, address books and files as shareable and not mail