Shared mailboxes, several accounts at once, and notifications for each #60

Merged
jcoffey-dev merged 5 commits from feature/shared-and-multi-account into main 2026-10-20 20:54:16 +00:00
36 changed files with 1974 additions and 69 deletions

No files matched your search

+181
View File
@@ -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 `<name>_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 = "[email protected]";
process.env.MOCK_PASS = "first-password";
process.env.MOCK_SECOND_USER = "[email protected]";
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<string, string>();
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("[email protected]", "first-password")).status, 200);
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"]);
const first = b.jar.get(FRONT);
const added = await b.signIn("[email protected]", "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]), [["[email protected]", true], ["[email protected]", false]]);
// The same account again is not a second copy
await b.signIn("[email protected]", "first-password", true);
accounts = await b.accounts();
assert.equal(accounts.length, 2);
assert.equal(accounts[0]!.username, "[email protected]", "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, "[email protected]");
// 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), ["[email protected]"]);
// Sign out of all
await b.signIn("[email protected]", "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("[email protected]", "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("[email protected]", "first-password");
// Alone, there is nothing to report
assert.deepEqual((await b.call("/api/auth/accounts/unread")).body.accounts, []);
await b.signIn("[email protected]", "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("[email protected]", "first-password");
const added = await b.signIn("[email protected]", "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("[email protected]", "first-password");
await b.signIn("[email protected]", "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));
});
+43
View File
@@ -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, `<name>_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<string>();
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<string, string> = {
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.",
};
+245 -1
View File
@@ -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<string, unknown>).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<string, { unread: number | null; at: number }>();
/** The Inbox's unread count for one session's account, or null when it has none. */
async function inboxUnread(session: LiveSession): Promise<number | null> {
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<Env> {
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<Env> {
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<Env> {
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 });
});
+19 -1
View File
@@ -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<typeof session> & { 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") {
+80 -6
View File
@@ -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",
+59 -6
View File
@@ -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);
@@ -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<string, unknown> }[] = [];
vi.mock("@/lib/notify/notify", () => ({
showNotification: (title: string, opts: Record<string, unknown>) => 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<string, number>;
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: "[email protected]", front: true },
{ id: "b", username: "[email protected]", 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("[email protected]");
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);
}
});
});
@@ -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");
});
});
@@ -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 <T,>(method: string, args: Record<string, unknown>) => {
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);
});
});
+22
View File
@@ -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/<sessionId>/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 <T,>(method: string, args: Record<string, unknown>, using: string[]): Promise<T> => {
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;
};
}
+49 -15
View File
@@ -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 = <T>(method: string, args: Record<string, unknown>, using: string[]) => Promise<T>;
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<JmapPushSubscription[]> {
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
export async function listSubscriptions(call: JmapCall = frontCall): Promise<JmapPushSubscription[]> {
const res = await call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
return res.list;
}
export async function createSubscription(body: Record<string, unknown>): Promise<Id | null> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
export async function createSubscription(body: Record<string, unknown>, call: JmapCall = frontCall): Promise<Id | null> {
const res = await call<SetResponse<JmapPushSubscription>>(
"PushSubscription/set",
{ create: { s: body } },
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
@@ -307,16 +316,16 @@ export async function createSubscription(body: Record<string, unknown>): 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<void> {
export async function extendSubscription(id: Id, now: number = Date.now(), call: JmapCall = frontCall): Promise<void> {
const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z");
const res = await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
const res = await call<SetResponse<JmapPushSubscription>>("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<void> {
export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall): Promise<void> {
if (!ids.length) return;
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
await call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
}
/**
@@ -328,18 +337,29 @@ export async function destroySubscriptions(ids: Id[]): Promise<void> {
*/
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<void> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
export async function verifySubscription(id: Id, verificationCode: string, call: JmapCall = frontCall): Promise<void> {
const res = await call<SetResponse<JmapPushSubscription>>(
"PushSubscription/set",
{ update: { [id]: { verificationCode } } },
[CAP.core, VAPID_CAP],
@@ -367,6 +387,20 @@ export async function destroySubscription(id: Id): Promise<void> {
await client.call<SetResponse<JmapPushSubscription>>("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<void> {
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<void> {
const mine = deviceClientId();
+110 -16
View File
@@ -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<void> {
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<void> {
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<SignedInAccount[]> {
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<void> {
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<void> {
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<void> {
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<void> {
const facts: OtherAccountFacts[] = [];
for (const account of await otherAccounts()) {
if (!account.mailAccountId) continue;
const call = otherAccountCall(account.id);
try {
const boxes = await call<GetResponse<Mailbox>>(
"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<void> {
// 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<void> {
await unsubscribeOtherAccounts();
await unsubscribeThisDevice();
}
/** Remove this device's subscription from every account not in front. */
export async function unsubscribeOtherAccounts(): Promise<void> {
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.
*
+81
View File
@@ -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<string, number>;
set(unread: Record<string, number>): void;
}
export const useOtherUnread = create<OtherUnreadState>((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<string, number>, after: Record<string, number>): string[] {
return Object.entries(after)
.filter(([id, n]) => id in before && n > before[id]!)
.map(([id]) => id);
}
export async function pollOtherUnread(): Promise<void> {
const answer = await apiFetch<{ accounts: { id: string; unread: number | null }[] }>("/api/auth/accounts/unread");
const next: Record<string, number> = {};
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]);
}
+44
View File
@@ -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<JmapSession, "accounts">;
/** 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<SharedMailAccount[]> {
const candidates = sharedMailCandidates(session);
const answers = await Promise.all(
candidates.map((account) =>
client.call<GetResponse<Mailbox>>("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);
}
+3
View File
@@ -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);
}
}
+8
View File
@@ -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.
+28 -1
View File
@@ -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<void> {
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<void> {
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<void> {
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"),
+20
View File
@@ -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 ────────────────────────────────────
+20
View File
@@ -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 ────────────────────────────────────
+20
View File
@@ -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 ────────────────────────────────────
+20
View File
@@ -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 ────────────────────────────────────
+20
View File
@@ -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 ────────────────────────────────────
+20
View File
@@ -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 ────────────────────────────────────
+20
View File
@@ -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 ────────────────────────────────────
+20
View File
@@ -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)": {
+20
View File
@@ -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 ────────────────────────────────────
+20
View File
@@ -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 ────────────────────────────────────
@@ -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<typeof vi.fn>;
let calls: { url: string; method: string }[];
let answers: Record<string, unknown>;
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: "[email protected]", front: true },
{ id: "b", username: "[email protected]", front: false },
],
canAdd: true,
};
await useSession.getState().loadSignedIn();
expect(useSession.getState().signedIn.map((a) => a.username)).toEqual(["[email protected]", "[email protected]"]);
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);
});
});
+114
View File
@@ -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<string, unknown> = {}) =>
({
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} },
accounts: {
own: {
name: "[email protected]",
isPersonal: true,
accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} },
},
// A group: every capability, and mailboxes
group: {
name: "[email protected]",
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: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {} } },
// No mail at all
filesOnly: { name: "[email protected]", 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, unknown>, 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(["[email protected]"]);
});
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<string, unknown>).group;
nextSession = session;
withMailboxes = new Set();
await useSession.getState().refresh();
expect(useSession.getState().viewing).toBeNull();
expect(useSession.getState().viewEnded).toBe("[email protected]");
expect(useMail.getState().accountId).toBe("own");
});
});
+58 -3
View File
@@ -105,6 +105,15 @@ export const useMail = create<MailState>((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
}
async function notifyNewMail(created: Id[], get: () => 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<void> {
const away = ownWhileAway;
if (!away?.inbox || !away.state) return;
try {
const changes = await client.call<ChangesResponse>("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<GetResponse<Email>>("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);
});
+154 -8
View File
@@ -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<void>;
login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
/** Signs out of the account in front; another signed-in one comes forward. */
logout(): Promise<void>;
/** Ends every account signed in in this browser. */
logoutAll(): Promise<void>;
/** Finds the other accounts signed in here. */
loadSignedIn(): Promise<void>;
/** Brings another signed-in account to the front, and reloads. */
switchTo(sessionId: string): Promise<void>;
refresh(): Promise<void>;
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<void>;
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<void> | 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=<session>&next=<where>`: 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<void> {
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<SessionState>((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<SessionState>((set, get) => ({
const s = await apiFetch<JmapSession>("/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<SessionState>((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<SessionState>((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<SessionState>((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<SessionState>((set, get) => ({
const s = await apiFetch<JmapSession>("/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<SessionState>((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<SessionState>) => 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;
}
+8
View File
@@ -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; }
+80
View File
@@ -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<string | null>(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 (
<Dialog
open={open}
onClose={onClose}
title={t("Add an account")}
size="sm"
footer={
<>
<button className="btn btn-ghost" onClick={onClose} disabled={busy}>
{t("Cancel")}
</button>
<button className="btn btn-primary" form="add-account" type="submit" disabled={busy || !username.trim() || !password}>
{busy ? t("Working…") : t("Add account")}
</button>
</>
}
>
<form id="add-account" onSubmit={(e) => void submit(e)}>
<p className="hint">{t("Both accounts stay signed in here; switch between them from this menu.")}</p>
<div className="field">
<label htmlFor="add-account-user">{t("Email address")}</label>
<input id="add-account-user" autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
</div>
<div className="field">
<label htmlFor="add-account-pw">{t("Password")}</label>
<input id="add-account-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{error && <p className="error">{error}</p>}
</form>
</Dialog>
);
}
+110 -10
View File
@@ -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 (
<div className="app">
<div className={`app ${viewing ? "viewing-shared" : ""}`}>
<SharedBar />
<header className="topbar">
{/* 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 }) {
<Settings size={21} />
</Link>
)}
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
<button
className="icon-btn"
style={{ width: "auto", padding: "0 2px", borderRadius: 999, position: "relative" }}
onClick={acctMenu.open}
aria-label={anyOtherUnread ? t("Account: new mail in another account") : t("Account")}
>
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
{/* Another signed-in account has unread mail */}
{anyOtherUnread && <span className="acct-dot" aria-hidden />}
</button>
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
<div style={{ padding: "10px 10px 6px", display: "flex", gap: 10, alignItems: "center" }}>
@@ -189,6 +233,58 @@ export function AppShell({ children }: { children: ReactNode }) {
</div>
</div>
<MenuSep />
{/* Each account signed in here, the one in front ticked */}
{(signedIn.length > 1 || canAddAccount) && (
<>
{signedIn.length > 1 && <div className="hint" style={{ padding: "4px 10px" }}>{t("Accounts")}</div>}
{signedIn.length > 1 &&
signedIn.map((account) => (
<MenuItem
key={account.id}
icon={account.front ? <Check size={16} /> : <Mail size={16} />}
label={
<span className="row" style={{ gap: 8, alignItems: "center" }}>
<span className="notranslate grow truncate" translate="no">{account.username}</span>
{!account.front && (otherUnread[account.id] ?? 0) > 0 && (
<span className="nav-count" aria-label={t("Unread in the Inbox: {count}", { count: otherUnread[account.id]! })}>
{otherUnread[account.id]! > 9999 ? "9999+" : otherUnread[account.id]}
</span>
)}
</span>
}
active={account.front}
onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))}
/>
))}
{canAddAccount && (
<MenuItem
icon={<Plus size={16} />}
label={t("Add account")}
onClick={() => {
acctMenu.close();
setAddingAccount(true);
}}
/>
)}
<MenuSep />
</>
)}
{sharedMail.length > 0 && (
<>
<div className="hint" style={{ padding: "4px 10px" }}>{t("Mail to show")}</div>
<MenuItem icon={viewing ? <Mail size={16} /> : <Check size={16} />} label={t("My mail")} active={!viewing} onClick={() => switchTo(null)} />
{sharedMail.map((account) => (
<MenuItem
key={account.id}
icon={viewing === account.id ? <Check size={16} /> : <Users size={16} />}
label={<span className="notranslate" translate="no">{account.name}</span>}
active={viewing === account.id}
onClick={() => switchTo(account.id)}
/>
))}
<MenuSep />
</>
)}
<MenuItem icon={<BookOpen size={16} />} 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 }) {
)}
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
{signedIn.length > 1 && (
<MenuItem icon={<LogOut size={16} />} label={t("Sign out of all accounts")} onClick={() => void useSession.getState().logoutAll()} />
)}
</Popover>
<AddAccountDialog open={addingAccount} onClose={() => setAddingAccount(false)} />
</div>
</header>
+30
View File
@@ -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 (
<div className="shared-bar" role="status">
<Users size={15} aria-hidden />
<span className="grow truncate">
{t("Shared mailbox:")} <strong className="notranslate" translate="no">{shared.name}</strong>
</span>
<button type="button" onClick={back}>
{t("Back to my mail")}
</button>
</div>
);
}
+6 -2
View File
@@ -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. */}
<MenuItem icon={<ArrowUp size={16} />} label={t("Move up")} onClick={() => onStep("up")} disabled={!canStep("up")} />
<MenuItem icon={<ArrowDown size={16} />} label={t("Move down")} onClick={() => onStep("down")} disabled={!canStep("down")} />
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} 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 && <MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} 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