Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2174ccb7b3 | ||
|
|
0580812216 | ||
|
|
06ea28dade | ||
|
|
20ac83c8df | ||
|
|
a58ea326d9 | ||
|
|
9fcf4812f3 | ||
|
|
13c9b8ef91 | ||
|
|
6b979c5ac7 | ||
|
|
8acd30e8a1 | ||
|
|
5f27923ce6 | ||
|
|
68c91a1ad4 | ||
|
|
9e77fb3f47 | ||
|
|
351a5aa01d | ||
|
|
34baca4365 | ||
|
|
9f53759462 | ||
|
|
c66a8aadd7 | ||
|
|
7f12a7d8ee | ||
|
|
722a68432c |
No files matched your search
@@ -14,7 +14,7 @@
|
||||
## Translations
|
||||
|
||||
<!--
|
||||
Nine languages ship alongside English, and a missing key silently renders
|
||||
Ten languages ship alongside English, and a missing key silently renders
|
||||
its English source -- so an untranslated string is invisible until somebody
|
||||
reading that language finds it. Say which this PR is, explicitly:
|
||||
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**johnellisATlinuxDOTcom**.
|
||||
**communityATcoffeylabsDOTorg**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
+6
-5
@@ -73,13 +73,14 @@ start it, and neither will closing and reopening.
|
||||
|
||||
### Translations
|
||||
|
||||
Nine languages ship alongside English: German, Spanish, French, Dutch,
|
||||
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, in
|
||||
Ten languages ship alongside English: German, Spanish, French, Dutch,
|
||||
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese, Japanese and
|
||||
Turkish, in
|
||||
`web/src/locales/`. A missing key renders its English source rather than
|
||||
failing, so an untranslated string is invisible until somebody reading that
|
||||
language finds it.
|
||||
|
||||
**Any change that adds or alters a user-visible string adds work in all nine
|
||||
**Any change that adds or alters a user-visible string adds work in all ten
|
||||
catalogs.** Say so explicitly in the PR — how many keys, and the fallback
|
||||
count before and after — and say so just as explicitly when a change adds none,
|
||||
so it is never left to be inferred.
|
||||
@@ -94,7 +95,7 @@ plural(n, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })
|
||||
|
||||
is keyed on **`"Deleted {n} contacts"`**. Keying the catalog on the `one`
|
||||
form type-checks, builds, passes every test, and silently falls back to English
|
||||
in all nine languages. Nothing errors. The only signal is the fallback count
|
||||
in all ten languages. Nothing errors. The only signal is the fallback count
|
||||
going up, so read it:
|
||||
|
||||
```sh
|
||||
@@ -212,7 +213,7 @@ and the single-host `deploy.example.sh`, are covered in
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
Please **do not** open a public issue for security vulnerabilities. Instead, report them privately by emailing **johnellisATlinuxDOTcom** with details of the issue. See `SECURITY.md` if one is present in the repo for further instructions.
|
||||
Please **do not** open a public issue for security vulnerabilities. Instead, report them privately by emailing **securityATcoffeylabsDOTorg** with details of the issue. See `SECURITY.md` if one is present in the repo for further instructions.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -107,4 +107,4 @@ keep up. Nothing here is pushed there.
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2026 Coffey Labs. AGPL-3.0-or-later; see [LICENSE](LICENSE).
|
||||
Copyright (C) 2026 Coffey Labs LLC. AGPL-3.0-or-later; see [LICENSE](LICENSE).
|
||||
+1
-1
@@ -15,7 +15,7 @@ ihasmail is under active development. Security fixes are applied to the latest r
|
||||
|
||||
Instead, report security issues privately by emailing:
|
||||
|
||||
**johnellisATlinuxDOTcom**
|
||||
**securityATcoffeylabsDOTorg**
|
||||
|
||||
Please include as much of the following as you can:
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* inbuxa MA-B: 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, signing
|
||||
* out ends only the one in front, and an organization that doesn't allow it
|
||||
* keeps adding off.
|
||||
*/
|
||||
|
||||
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.MAIL_SERVER_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("an organization that doesn't allow it keeps adding off", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
process.env.MOCK_NO_ADD_ACCOUNTS = "1";
|
||||
try {
|
||||
// The cached upstream session is a minute old at most; ask afresh
|
||||
await b.call("/api/auth/session?refresh=1");
|
||||
const res = await b.call("/api/auth/accounts");
|
||||
assert.equal(res.body.canAdd, false);
|
||||
const added = await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal(added.status, 403);
|
||||
assert.equal(added.body.error, "add_not_allowed");
|
||||
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"], "the front stayed");
|
||||
} finally {
|
||||
delete process.env.MOCK_NO_ADD_ACCOUNTS;
|
||||
}
|
||||
});
|
||||
|
||||
test("inbuxa MA-8: 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("inbuxa MA-8: 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("inbuxa MA-8: 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));
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* More than one signed-in account in a browser (multi-account spec, MA-B).
|
||||
*
|
||||
* 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 (MA-9), and only
|
||||
* while both accounts' organizations allow it (`addAccounts`, MA-C).
|
||||
*/
|
||||
import type { UpstreamSession } from "./upstream.js";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account behind `upstream` may have other accounts beside it:
|
||||
* `addAccounts` on its own account's `urn:inbuxa:jmap` capability. A server
|
||||
* that doesn't say (an older one, or not inbuxa) allows it, as before.
|
||||
*/
|
||||
export function mayAddAccounts(upstream: UpstreamSession): boolean {
|
||||
const primary = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? Object.keys(upstream.accounts ?? {})[0];
|
||||
const account = primary ? (upstream.accounts?.[primary] as { accountCapabilities?: Record<string, unknown> } | undefined) : undefined;
|
||||
const inbuxa = account?.accountCapabilities?.["urn:inbuxa:jmap"] as { addAccounts?: unknown } | undefined;
|
||||
return inbuxa?.addAccounts !== false;
|
||||
}
|
||||
|
||||
/** 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_not_allowed: "Your organization doesn't allow adding other accounts here.",
|
||||
add_other_server: "That account is on another mail server. Only accounts on this server can be added.",
|
||||
};
|
||||
+281
-2
@@ -45,6 +45,7 @@ import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, passwo
|
||||
import { icsProxyHandler } from "./icsproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
import { mailNode, webmailNode } from "./nodes.js";
|
||||
import { ADD_REFUSED, MAX_ACCOUNTS, mayAddAccounts, parseOthers, serializeOthers } from "./accounts.js";
|
||||
|
||||
type Env = { Variables: { session: LiveSession } };
|
||||
|
||||
@@ -330,6 +331,146 @@ function setSessionCookie(c: Context, value: string, remember: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* inbuxa MA-B: 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 (MA-7).
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
* inbuxa MA-8: 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/")),
|
||||
);
|
||||
}
|
||||
|
||||
/** inbuxa MA-8: 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. */
|
||||
async function addRefusal(c: Context, front: LiveSession): Promise<string | null> {
|
||||
if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full";
|
||||
try {
|
||||
const upstream = await getUpstreamSession(front.id, front.authorization, upstreamFor(front.username));
|
||||
if (!mayAddAccounts(upstream)) return "add_not_allowed";
|
||||
} catch {
|
||||
return "add_not_allowed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A session just signed in to be added beside the one in front (MA-B). 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.
|
||||
*/
|
||||
async function joinAccount(
|
||||
c: Context,
|
||||
created: { cookie: string; session: LiveSession },
|
||||
upstream: Awaited<ReturnType<typeof fetchUpstreamSession>>,
|
||||
): Promise<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");
|
||||
// Both organizations must allow it
|
||||
if (!mayAddAccounts(upstream)) return end("add_not_allowed");
|
||||
const frontRefusal = await addRefusal(c, front);
|
||||
if (frontRefusal === "add_not_allowed") return end(frontRefusal);
|
||||
// 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);
|
||||
@@ -406,8 +547,19 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
return c.redirect(`${basePath}/?signin_error=rate_limited`, 302);
|
||||
}
|
||||
const username = (c.req.query("username") ?? "").trim().slice(0, 320);
|
||||
// inbuxa MA-B: another account beside the one in front, if it may have one
|
||||
const front = c.req.query("add") === "1" ? sessions.resolve(getCookie(c, config.cookieName)) : null;
|
||||
if (front) {
|
||||
const refused = await addRefusal(c, front);
|
||||
if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302);
|
||||
}
|
||||
try {
|
||||
const { location, state } = await startSignIn({ username, base: upstreamFor(username), remember: c.req.query("remember") === "1" });
|
||||
const { location, state } = await startSignIn({
|
||||
username,
|
||||
base: upstreamFor(username),
|
||||
remember: c.req.query("remember") === "1",
|
||||
adding: front !== null,
|
||||
});
|
||||
setCookie(c, OAUTH_STATE_COOKIE, state, { httpOnly: true, sameSite: "Lax", secure: isSecureRequest(c), path: `${basePath}/api/auth`, maxAge: 600 });
|
||||
return c.redirect(location, 302);
|
||||
} catch (err) {
|
||||
@@ -452,6 +604,11 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip: clientIp(c),
|
||||
});
|
||||
if (result.adding) {
|
||||
const refused = await joinAccount(c, { cookie, session }, upstream);
|
||||
if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302);
|
||||
return c.redirect(`${basePath}/`, 302);
|
||||
}
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
|
||||
@@ -473,7 +630,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 {
|
||||
@@ -536,6 +693,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip,
|
||||
});
|
||||
// inbuxa MA-B: beside the account in front, when that's what was asked
|
||||
if (body.add && sessions.resolve(getCookie(c, config.cookieName))) {
|
||||
const refused = await joinAccount(c, { cookie, session }, upstream);
|
||||
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.
|
||||
@@ -606,7 +769,123 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
}
|
||||
// inbuxa MA-B: 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 });
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: 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 });
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: 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 = (await addRefusal(c, front)) === null;
|
||||
// inbuxa MA-8: 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 });
|
||||
});
|
||||
|
||||
/*
|
||||
* inbuxa MA-8: 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 ({ cookie, session }) => {
|
||||
const cached = unreadCache.get(session.id);
|
||||
if (cached && Date.now() - cached.at < UNREAD_CACHE_MS) return { id: session.id, unread: cached.unread };
|
||||
try {
|
||||
let live: LiveSession | null = session;
|
||||
if (live.tokens && needsRefresh(live.tokens)) live = await refreshSession(cookie, live);
|
||||
if (!live) return { id: session.id, unread: null };
|
||||
const unread = await inboxUnread(live);
|
||||
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 });
|
||||
});
|
||||
|
||||
/* inbuxa MA-8: 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") : [];
|
||||
let session: LiveSession | null = other.session;
|
||||
if (session.tokens && needsRefresh(session.tokens)) session = await refreshSession(other.cookie, session);
|
||||
if (!session) return c.json({ error: "unauthenticated" }, 401);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: 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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -23,9 +23,25 @@ function unauthorized(res: ServerResponse) {
|
||||
res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" }));
|
||||
}
|
||||
|
||||
/*
|
||||
* inbuxa MA-B: 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 (checkBearer(h)) return true;
|
||||
if (secondUser(req)) return true;
|
||||
if (!h.startsWith("Basic ") || basicRefused) return false;
|
||||
const raw = Buffer.from(h.slice(6), "base64").toString();
|
||||
const sep = raw.indexOf(":");
|
||||
@@ -83,7 +99,14 @@ 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!;
|
||||
// MA-C: an organization that doesn't allow adding accounts
|
||||
if (process.env.MOCK_NO_ADD_ACCOUNTS === "1") {
|
||||
const own = answer.accounts[ACCOUNT] as { accountCapabilities: Record<string, unknown> };
|
||||
own.accountCapabilities = { ...own.accountCapabilities, "urn:inbuxa:jmap": { addAccounts: false } };
|
||||
}
|
||||
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") {
|
||||
|
||||
@@ -217,3 +217,23 @@ test("push keeps a credential that renews itself", async () => {
|
||||
assert.notEqual(second, first, "a fresh access token");
|
||||
assert.match(second, /^Bearer mock-at-/);
|
||||
});
|
||||
|
||||
test("inbuxa MA-B: adding an account asks the server's page to sign in again", async () => {
|
||||
// With nobody in front, add=1 is an ordinary sign-in
|
||||
let res = await call("/api/auth/oauth/[email protected]&add=1");
|
||||
assert.equal(new URL(res.headers.get("location")!).searchParams.has("prompt"), false);
|
||||
|
||||
await signIn();
|
||||
res = await call("/api/auth/oauth/[email protected]&add=1");
|
||||
assert.equal(res.status, 302);
|
||||
const signInPage = new URL(res.headers.get("location")!);
|
||||
assert.equal(signInPage.searchParams.get("prompt"), "login", "the server's page must not reuse the first sign-in");
|
||||
|
||||
// The mock's page signs the same account in again: it stays one account
|
||||
const approved = await fetch(signInPage, { redirect: "manual" });
|
||||
const back = new URL(approved.headers.get("location")!);
|
||||
res = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(res.headers.get("location"), "/");
|
||||
const list = await jsonOf(await call("/api/auth/accounts"));
|
||||
assert.deepEqual(list.accounts.map((a: { username: string }) => a.username), ["[email protected]"]);
|
||||
});
|
||||
+9
-4
@@ -97,6 +97,8 @@ interface Pending {
|
||||
base: string;
|
||||
username: string;
|
||||
remember: boolean;
|
||||
/** MA-B: signing in a second account beside the one in front. */
|
||||
adding: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -114,13 +116,13 @@ function challengeOf(verifier: string): string {
|
||||
* Begin a sign-in. Returns where to send the browser, and the state to bind
|
||||
* to it in a cookie.
|
||||
*/
|
||||
export async function start(params: { username: string; base: string; remember: boolean }): Promise<{ location: string; state: string }> {
|
||||
export async function start(params: { username: string; base: string; remember: boolean; adding?: boolean }): Promise<{ location: string; state: string }> {
|
||||
const metadata = await metadataFor(params.base);
|
||||
sweepPending();
|
||||
if (pending.size >= MAX_PENDING) throw new UpstreamError("Too many sign-ins in progress", 503);
|
||||
const state = randomToken(24);
|
||||
const verifier = randomToken(48);
|
||||
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, createdAt: Date.now() });
|
||||
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, adding: Boolean(params.adding), createdAt: Date.now() });
|
||||
const scope = ["openid", "offline_access"].filter((s) => metadata.scopes.length === 0 || metadata.scopes.includes(s)).join(" ");
|
||||
const url = new URL(metadata.authorizationEndpoint);
|
||||
url.searchParams.set("response_type", "code");
|
||||
@@ -131,6 +133,9 @@ export async function start(params: { username: string; base: string; remember:
|
||||
url.searchParams.set("code_challenge", challengeOf(verifier));
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
if (params.username) url.searchParams.set("login_hint", params.username);
|
||||
// MA-B: ask again, rather than let the server's page reuse the sign-in of
|
||||
// the account already in front
|
||||
if (params.adding) url.searchParams.set("prompt", "login");
|
||||
return { location: url.toString(), state };
|
||||
}
|
||||
|
||||
@@ -177,7 +182,7 @@ export class SignInError extends Error {
|
||||
* Finish a sign-in: `state` as it came back in the URL, `boundState` as the
|
||||
* browser's cookie holds it. Each state is good for one attempt.
|
||||
*/
|
||||
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean }> {
|
||||
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean; adding: boolean }> {
|
||||
const p = pending.get(params.state);
|
||||
if (!p || !params.boundState || params.boundState !== params.state) {
|
||||
throw new SignInError("state_mismatch", "This sign-in didn't start in this browser. Try again.");
|
||||
@@ -192,7 +197,7 @@ export async function finish(params: { state: string; boundState: string | undef
|
||||
redirect_uri: redirectUri(),
|
||||
});
|
||||
if (!tokens) throw new SignInError("exchange_failed", "The mail server didn't accept the sign-in. Try again.");
|
||||
return { tokens, base: p.base, username: p.username, remember: p.remember };
|
||||
return { tokens, base: p.base, username: p.username, remember: p.remember, adding: p.adding };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+80
-6
@@ -386,8 +386,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) {
|
||||
// inbuxa MA-8: 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" },
|
||||
@@ -456,6 +459,15 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||
event.waitUntil((async () => {
|
||||
const facts = await readFacts();
|
||||
/*
|
||||
* inbuxa MA-8: 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
|
||||
@@ -464,7 +476,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.
|
||||
@@ -502,8 +513,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,
|
||||
@@ -515,6 +525,70 @@ self.addEventListener("push", (event) => {
|
||||
})());
|
||||
});
|
||||
|
||||
/*
|
||||
* inbuxa MA-8: 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.
|
||||
*
|
||||
@@ -535,7 +609,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",
|
||||
|
||||
+9
-6
@@ -1,6 +1,6 @@
|
||||
import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { Route, Switch, Redirect, useLocation, Router } from "wouter";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useSession, useViewingDelegation } from "@/store/session";
|
||||
import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail";
|
||||
import { scheduleSupported, useScheduled } from "@/store/scheduled";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
@@ -311,12 +311,14 @@ function AuthedApp() {
|
||||
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
|
||||
});
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
// inbuxa AL-7: a locked account in view is named, with a padlock, in the tab
|
||||
// inbuxa AL-7: a locked account in view is named, with a padlock, in the
|
||||
// tab; a shared or group mailbox (MA-A) is named without one
|
||||
const viewingName = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined));
|
||||
const lockedInView = useViewingDelegation()?.kind === "lock";
|
||||
useEffect(() => {
|
||||
setBaseTitle(viewingName ? `🔒 ${viewingName} · ${appName}` : appName);
|
||||
setBaseTitle(viewingName ? `${lockedInView ? "🔒 " : ""}${viewingName} · ${appName}` : appName);
|
||||
setUnreadBadge(inboxUnread);
|
||||
}, [inboxUnread, appName, viewingName]);
|
||||
}, [inboxUnread, appName, viewingName, lockedInView]);
|
||||
|
||||
/*
|
||||
* Leave the service worker its briefing.
|
||||
@@ -328,13 +330,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(() => {
|
||||
// inbuxa AL-7: the worker acts on the reader's own mail; while a
|
||||
// delegated account is in view, the archive folder here is its
|
||||
if (viewing) return;
|
||||
void publishWorkerFacts(accountId, archiveId);
|
||||
}, [accountId, archiveId, languageVersion, viewing]);
|
||||
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";
|
||||
|
||||
/** inbuxa MA-8: 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,12 @@ export type DelegationAccess = "read" | "organize" | "full";
|
||||
|
||||
export interface Delegation {
|
||||
locked: boolean;
|
||||
/**
|
||||
* A locked account, or a shared mailbox such as support@ (MA-S). Both are
|
||||
* reached as a delegate at an access level; only how they are shown
|
||||
* differs. Absent from older servers, where it is always a lock.
|
||||
*/
|
||||
kind: "lock" | "sharedMailbox";
|
||||
access: DelegationAccess;
|
||||
sendAs: boolean;
|
||||
/** UTC date the delegation ends, if it does. */
|
||||
@@ -38,19 +44,20 @@ export function delegationOf(session: SessionLike | null, accountId: Id | null):
|
||||
const access: DelegationAccess = raw.access === "organize" || raw.access === "full" ? raw.access : "read";
|
||||
return {
|
||||
locked: true,
|
||||
kind: raw.kind === "sharedMailbox" ? "sharedMailbox" : "lock",
|
||||
access,
|
||||
sendAs: raw.sendAs === true && access !== "read",
|
||||
until: typeof raw.until === "string" ? raw.until : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every locked account handed to the reader, by name. */
|
||||
/** Every locked account handed to the reader, by name. Shared mailboxes are listed by lib/sharedMail. */
|
||||
export function delegatedAccounts(session: SessionLike | null): DelegatedAccount[] {
|
||||
if (!session) return [];
|
||||
return Object.entries(session.accounts)
|
||||
.map(([id, account]) => {
|
||||
const delegation = delegationOf(session, id);
|
||||
return delegation ? { id, name: account.name, delegation } : null;
|
||||
return delegation?.kind === "lock" ? { id, name: account.name, delegation } : null;
|
||||
})
|
||||
.filter((a): a is DelegatedAccount => a !== null)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { otherAccountCall } from "@/lib/notify/otherAccount";
|
||||
import { listSubscriptions } from "@/lib/notify/webpush";
|
||||
|
||||
/** inbuxa MA-8: 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";
|
||||
|
||||
/** inbuxa MA-8 part 2: 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* inbuxa MA-8: 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;
|
||||
};
|
||||
}
|
||||
@@ -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 (inbuxa MA-8). 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 {
|
||||
/*
|
||||
* inbuxa MA-8: 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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: 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();
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
* 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";
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: 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();
|
||||
// inbuxa MA-8: 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 (MA-8). */
|
||||
endpointOf: Id | null;
|
||||
}
|
||||
|
||||
function frontTarget(): PushTarget {
|
||||
// inbuxa AL-7: the reader's own inbox, never a delegated account'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,35 +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));
|
||||
// inbuxa AL-7: the reader's own inbox, never a delegated account's in view
|
||||
const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), ownInboxId());
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,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();
|
||||
}
|
||||
|
||||
/** inbuxa MA-8: 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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* inbuxa MA-8: 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 (MA-8 part 2): 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]);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Mail other people let the reader into (multi-account spec, MA-A): a group's
|
||||
* mailbox, folders someone shared, or a shared mailbox an administrator
|
||||
* assigned them to (MA-S).
|
||||
*
|
||||
* Either one arrives as another account in the session, `isPersonal: false`.
|
||||
* That alone proves nothing about mail -- the server 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.
|
||||
*
|
||||
* A shared mailbox needs no asking: the server marks it, as a delegation of
|
||||
* kind `sharedMailbox`, and it is mail by definition. A locked account handed
|
||||
* to the reader (AL-7) is listed by `delegation.ts` instead, and left out
|
||||
* here so it is never offered twice.
|
||||
*/
|
||||
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { GetResponse, Id, JmapSession, Mailbox } from "@/jmap/types";
|
||||
import { delegationOf } from "@/lib/delegation";
|
||||
|
||||
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(([id, account]) => account.isPersonal === false && CAP.mail in (account.accountCapabilities ?? {}) && delegationOf(session, id)?.kind !== "lock")
|
||||
.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) =>
|
||||
delegationOf(session, account.id)?.kind === "sharedMailbox"
|
||||
? Promise.resolve(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);
|
||||
}
|
||||
@@ -87,6 +87,9 @@ function ownKeys(): string[] {
|
||||
export function clearSignedInData(): void {
|
||||
for (const key of ownKeys()) {
|
||||
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
|
||||
// inbuxa MA-8: 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
/**
|
||||
* inbuxa MA-8: 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[] = [];
|
||||
|
||||
/** inbuxa MA-8: 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"),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* The rule this replaces was `subject.replace(/[^\w.-]+/g, "_")`, and `\w`
|
||||
* without the `u` flag is ASCII: every character of a Russian, Japanese or
|
||||
* Chinese subject failed the class, so those messages downloaded as a row of
|
||||
* underscores. ihasmail ships in nine languages besides English, so the
|
||||
* underscores. ihasmail ships in ten languages besides English, so the
|
||||
* subjects it handled worst were most of the world's.
|
||||
*
|
||||
* What is actually unsafe in a filename is a much shorter list than "not
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSession } from "@/store/session";
|
||||
|
||||
/**
|
||||
* inbuxa MA-B: 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);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
@@ -70,7 +70,7 @@ afterEach(() => {
|
||||
describe("delegation", () => {
|
||||
it("is read only from the session's mark, never from a shared account's capabilities", () => {
|
||||
const session = sessionWith(delegated("organize", true));
|
||||
expect(delegationOf(session, "locked")).toEqual({ locked: true, access: "organize", sendAs: true, until: null });
|
||||
expect(delegationOf(session, "locked")).toEqual({ locked: true, kind: "lock", access: "organize", sendAs: true, until: null });
|
||||
expect(delegationOf(session, "shared")).toBeNull();
|
||||
expect(delegationOf(session, "own")).toBeNull();
|
||||
expect(delegatedAccounts(session).map((a) => a.id)).toEqual(["locked"]);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
import { useSession, viewingDelegation } from "@/store/session";
|
||||
import { findSharedMail, sharedMailCandidates } from "@/lib/sharedMail";
|
||||
import { delegatedAccounts } from "@/lib/delegation";
|
||||
|
||||
/**
|
||||
* MA-A: 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]: {} } },
|
||||
// A locked account handed over: listed by delegation.ts, never here
|
||||
locked: {
|
||||
name: "[email protected]",
|
||||
isPersonal: false,
|
||||
accountCapabilities: { [CAP.mail]: {}, "urn:inbuxa:jmap": { delegation: { locked: true, access: "read", sendAs: false, until: null } } },
|
||||
},
|
||||
// A shared mailbox an administrator assigned (MA-S): marked, so never asked about
|
||||
desk: {
|
||||
name: "[email protected]",
|
||||
isPersonal: false,
|
||||
accountCapabilities: {
|
||||
[CAP.mail]: {},
|
||||
[CAP.calendars]: {},
|
||||
"urn:inbuxa:jmap": { delegation: { locked: true, kind: "sharedMailbox", access: "organize", sendAs: true, until: null } },
|
||||
},
|
||||
},
|
||||
...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: [], delegationEnded: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSession.setState({ viewing: null, sharedMail: [] });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shared mail", () => {
|
||||
it("considers only other people's accounts that advertise mail, leaving locked accounts to delegation", () => {
|
||||
expect(sharedMailCandidates(sessionWith()).map((a) => a.id)).toEqual(["calendarOnly", "desk", "group"]);
|
||||
});
|
||||
|
||||
it("offers only accounts that answer with a mailbox", async () => {
|
||||
// The shared mailbox answers no Mailbox/get here, and is offered anyway
|
||||
expect((await findSharedMail(sessionWith())).map((a) => a.name)).toEqual(["[email protected]", "[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");
|
||||
// Calendars, contacts and files stay the reader's own
|
||||
expect(useSession.getState().viewAccountFor(CAP.calendars)).toBe("own");
|
||||
expect(useSession.getState().viewAccountFor(CAP.contacts)).toBe("own");
|
||||
expect(useSession.getState().viewAccountFor(CAP.filenode)).toBe("own");
|
||||
|
||||
// An account with no mailboxes is never offered
|
||||
useSession.getState().view("calendarOnly");
|
||||
expect(useSession.getState().viewing).toBe("group");
|
||||
});
|
||||
|
||||
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().delegationEnded).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("shows an assigned shared mailbox as shared, not locked, keeping its access level", async () => {
|
||||
await useSession.getState().loadSharedMail();
|
||||
expect(delegatedAccounts(useSession.getState().session).map((a) => a.id)).toEqual(["locked"]);
|
||||
useSession.getState().view("desk");
|
||||
expect(useSession.getState().viewing).toBe("desk");
|
||||
expect(viewingDelegation()?.access).toBe("organize");
|
||||
// Mail only, like any shared mailbox
|
||||
expect(useSession.getState().viewAccountFor(CAP.calendars)).toBe("own");
|
||||
});
|
||||
});
|
||||
+132
-14
@@ -6,10 +6,12 @@ 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 { delegationOf, type Delegation } from "@/lib/delegation";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail";
|
||||
|
||||
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
||||
|
||||
@@ -19,12 +21,19 @@ interface SessionState {
|
||||
/** Selected mail account (defaults to primary). */
|
||||
accountId: Id | null;
|
||||
/**
|
||||
* A locked account handed to the reader that the app shows instead of
|
||||
* their own (inbuxa AL-7): its mail, calendar, contacts and files. The
|
||||
* reader's settings, filters, signatures and push stay their own.
|
||||
* Another account whose mail the app shows instead of the reader's own:
|
||||
* a locked account handed to them (inbuxa AL-7), whose calendar, contacts
|
||||
* and files follow, or a shared or group mailbox (MA-A), which is mail
|
||||
* only. The reader's settings, filters, signatures and push stay their own.
|
||||
*/
|
||||
viewing: Id | null;
|
||||
/** The name of an account whose delegation ended while it was in view. */
|
||||
/** Shared and group mailboxes the reader can open (MA-A); see lib/sharedMail. */
|
||||
sharedMail: SharedMailAccount[];
|
||||
/** inbuxa MA-B: the accounts signed in in this browser, the one in front first. */
|
||||
signedIn: SignedInAccount[];
|
||||
/** Whether one more may be added (the cap, and both organizations' addAccounts). */
|
||||
canAddAccount: boolean;
|
||||
/** The name of an account the reader lost while it was in view. */
|
||||
delegationEnded: string | null;
|
||||
error: string | null;
|
||||
pushConnected: boolean;
|
||||
@@ -32,11 +41,20 @@ interface SessionState {
|
||||
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>;
|
||||
/** inbuxa MA-B: ends every account signed in in this browser. */
|
||||
logoutAll(): Promise<void>;
|
||||
/** inbuxa MA-B: finds the other accounts signed in here. */
|
||||
loadSignedIn(): Promise<void>;
|
||||
/** inbuxa MA-B: brings another signed-in account to the front, and reloads. */
|
||||
switchTo(sessionId: string): Promise<void>;
|
||||
refresh(): Promise<void>;
|
||||
setAccount(id: Id): void;
|
||||
/** Show a delegated account's mail, or the reader's own with null. */
|
||||
/** Show a delegated account's or 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>;
|
||||
clearDelegationEnded(): void;
|
||||
/** The account to read and write for a capability, honoring the account switcher. */
|
||||
accountFor(cap: string): Id | null;
|
||||
@@ -53,11 +71,49 @@ interface SessionState {
|
||||
|
||||
let refreshing: Promise<void> | null = null;
|
||||
|
||||
/** What a signed-in account looks like in the switcher (MA-B). */
|
||||
export interface SignedInAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
front: boolean;
|
||||
/** Its mail account, for its push subscription (MA-8); null when not known yet. */
|
||||
mailAccountId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: 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 (MA-B). */
|
||||
let signOutPath = "/api/auth/logout";
|
||||
|
||||
export const useSession = create<SessionState>((set, get) => ({
|
||||
status: "loading",
|
||||
session: null,
|
||||
accountId: null,
|
||||
viewing: null,
|
||||
sharedMail: [],
|
||||
signedIn: [],
|
||||
canAddAccount: false,
|
||||
delegationEnded: null,
|
||||
error: null,
|
||||
pushConnected: false,
|
||||
@@ -96,7 +152,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 {
|
||||
const everyone = signOutPath.endsWith("logout-all");
|
||||
const othersRemain = !everyone && get().signedIn.some((a) => !a.front);
|
||||
if (everyone) {
|
||||
// inbuxa MA-8: every account's subscription goes, the others' first
|
||||
const { unsubscribeOtherAccounts } = await import("@/lib/notify/webpushEnable");
|
||||
await unsubscribeOtherAccounts();
|
||||
}
|
||||
if (othersRemain) {
|
||||
// inbuxa MA-8: 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 */
|
||||
}
|
||||
@@ -109,8 +177,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 */
|
||||
}
|
||||
@@ -120,7 +191,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, viewing: null });
|
||||
// inbuxa MA-B: 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() {
|
||||
@@ -130,14 +229,16 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
|
||||
client.session = s;
|
||||
setServerLocale(s.ihasmail?.userLocale);
|
||||
// A delegation that ended takes the reader back to their own mail
|
||||
// A delegation that ended, or a shared mailbox taken away, takes the
|
||||
// reader back to their own mail
|
||||
const viewing = get().viewing;
|
||||
if (viewing && !delegationOf(s, viewing)) {
|
||||
if (viewing && !delegationOf(s, viewing) && !sharedMailCandidates(s).some((a) => a.id === viewing)) {
|
||||
const name = get().session?.accounts[viewing]?.name ?? null;
|
||||
set({ session: s, viewing: null, delegationEnded: name });
|
||||
} else {
|
||||
set({ session: s });
|
||||
}
|
||||
void get().loadSharedMail();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
@@ -152,11 +253,18 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
},
|
||||
|
||||
view(id) {
|
||||
if (id && !delegationOf(get().session, id)) return;
|
||||
if (id && !delegationOf(get().session, id) && !get().sharedMail.some((a) => a.id === id)) return;
|
||||
if (id === get().viewing) return;
|
||||
set({ viewing: id });
|
||||
},
|
||||
|
||||
async loadSharedMail() {
|
||||
const session = get().session;
|
||||
const found = await findSharedMail(session);
|
||||
// A sign-out or another account's session arrived while it was asking
|
||||
if (get().session === session) set({ sharedMail: found });
|
||||
},
|
||||
|
||||
clearDelegationEnded() {
|
||||
set({ delegationEnded: null });
|
||||
},
|
||||
@@ -172,7 +280,8 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
viewAccountFor(cap) {
|
||||
const { session, viewing } = get();
|
||||
const viewed = viewing ? session?.accounts[viewing] : undefined;
|
||||
if (viewing && viewed && cap in (viewed.accountCapabilities ?? {})) return viewing;
|
||||
// A shared or group mailbox in view is mail only (MA-A, MA-S)
|
||||
if (viewing && viewed && delegationOf(session, viewing)?.kind === "lock" && cap in (viewed.accountCapabilities ?? {})) return viewing;
|
||||
return ownAccountForCapability(session, cap);
|
||||
},
|
||||
}));
|
||||
@@ -194,7 +303,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, viewing: null, error: null });
|
||||
set({ status: "authenticated", session: s, accountId, viewing: null, sharedMail: [], error: null });
|
||||
void useSession.getState().loadSharedMail();
|
||||
void useSession.getState().loadSignedIn();
|
||||
}
|
||||
|
||||
client.onUnauthenticated(() => {
|
||||
@@ -207,7 +318,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, viewing: null });
|
||||
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,6 +331,13 @@ export function useViewingDelegation(): Delegation | null {
|
||||
return delegationOf(session, viewing);
|
||||
}
|
||||
|
||||
/** The shared or group mailbox in view, if one is (MA-A). */
|
||||
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;
|
||||
}
|
||||
|
||||
export function viewingDelegation(): Delegation | null {
|
||||
const s = useSession.getState();
|
||||
return delegationOf(s.session, s.viewing);
|
||||
|
||||
@@ -1257,6 +1257,10 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.app.delegated { grid-template-rows: auto var(--topbar-h) 1fr; }
|
||||
.delegated-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #b91c1c; color: #fff; font-size: .9em; min-width: 0; }
|
||||
.delegated-bar strong { font-weight: 700; }
|
||||
/* MA-A: a shared or group mailbox is the reader's to be in, so the palette's own color, not the locked account's red. */
|
||||
.delegated-bar.shared { background: var(--accent); color: var(--accent-fg); }
|
||||
/* MA-8: 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); }
|
||||
.delegated-bar button { flex: none; border: 1px solid rgba(255, 255, 255, .7); background: transparent; color: #fff; border-radius: 999px; padding: 3px 12px; font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.delegated-bar button:hover, .delegated-bar button:focus-visible { background: rgba(255, 255, 255, .15); }
|
||||
.topbar .brand.locked .brand-name, .topbar .brand.locked .brand-lock { color: #dc2626; }
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { apiFetch } from "@/jmap/client";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { clearSignedInData, isDeviceTrusted } from "@/lib/storage";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* inbuxa MA-B: sign a second account in beside the one in front.
|
||||
*
|
||||
* With sign-in on the mail server's own page and one mail server, there is
|
||||
* nothing to ask here: the browser goes straight to that page, which asks for
|
||||
* the account. With several servers the address comes first, to pick one; with
|
||||
* the password form, so does the password. Either way the account 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 (MA-7).
|
||||
*/
|
||||
export function AddAccountDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [mode, setMode] = useState<"oauth" | "oauth-address" | "password" | null>(null);
|
||||
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) return;
|
||||
setError(null);
|
||||
let live = true;
|
||||
fetch(withBase("/api/config"))
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((c) => {
|
||||
if (!live) return;
|
||||
const oauth = c?.signIn === "oauth";
|
||||
const next = oauth ? (c?.signInDirect === true ? "oauth" : "oauth-address") : "password";
|
||||
setMode(next);
|
||||
// Nothing to ask: off to the mail server's page
|
||||
if (next === "oauth") goToServer("");
|
||||
})
|
||||
.catch(() => live && setMode("password"));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// goToServer only reads `remember`, fixed for the dialog's life
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
function goToServer(address: string) {
|
||||
setBusy(true);
|
||||
// What is cached belongs to the account in front, which won't be
|
||||
clearSignedInData();
|
||||
const params = new URLSearchParams({ add: "1", ...(address ? { username: address } : {}), ...(remember ? { remember: "1" } : {}) });
|
||||
window.location.assign(withBase(`/api/auth/oauth/start?${params}`));
|
||||
}
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim()) return;
|
||||
if (mode === "oauth-address") return goToServer(username.trim());
|
||||
if (!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) {
|
||||
setError((err as Error).message || t("That account couldn't be added."));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === "oauth") return null;
|
||||
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() || (mode === "password" && !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>
|
||||
{mode === "password" && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { composeBlocked, draftFromMailto, useCompose } from "@/store/compose";
|
||||
import { delegatedAccounts } from "@/lib/delegation";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { DelegatedBar } from "./DelegatedBar";
|
||||
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";
|
||||
@@ -73,6 +75,39 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pushState = useSession((s) => s.pushState);
|
||||
const session = useSession((s) => s.session);
|
||||
const logout = useSession((s) => s.logout);
|
||||
// inbuxa MA-B: 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);
|
||||
// inbuxa MA-8: 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);
|
||||
};
|
||||
// A refused add comes back on the address (see the server's /auth/callback)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const why = params.get("account_error");
|
||||
if (!why) return;
|
||||
toast.show(
|
||||
why === "add_full"
|
||||
? t("You can't add more accounts here.")
|
||||
: why === "add_other_server"
|
||||
? t("That account is on another mail server. Only accounts on this server can be added.")
|
||||
: t("Your organization doesn't allow adding other accounts here."),
|
||||
);
|
||||
params.delete("account_error");
|
||||
const rest = params.toString();
|
||||
window.history.replaceState(null, "", window.location.pathname + (rest ? `?${rest}` : ""));
|
||||
}, []);
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
const acctMenu = useMenu();
|
||||
const administers = hasAdministration(usePermissions());
|
||||
@@ -150,6 +185,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
*/
|
||||
const viewing = useSession((s) => s.viewing);
|
||||
const delegated = delegatedAccounts(session);
|
||||
// MA-A: shared and group mailboxes are offered in the same list, after locked accounts
|
||||
const sharedMail = useSession((s) => s.sharedMail);
|
||||
const lockedInView = viewing !== null && delegated.some((a) => a.id === viewing);
|
||||
const switchTo = (id: string | null) => {
|
||||
acctMenu.close();
|
||||
if (id === viewing) return;
|
||||
@@ -173,9 +211,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<MenuIcon size={22} />
|
||||
</button>
|
||||
)}
|
||||
<Link href="/mail" className={`brand ${viewing ? "locked" : ""}`}>
|
||||
<Link href="/mail" className={`brand ${lockedInView ? "locked" : ""}`}>
|
||||
<img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
|
||||
{viewing && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
|
||||
{lockedInView && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
|
||||
{/* A product name, not a word: translated it is a different product.
|
||||
Read from the session rather than written here, so a deployment
|
||||
that set APP_NAME is called what it calls itself -- the document
|
||||
@@ -203,8 +241,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" />
|
||||
{/* inbuxa MA-8: 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" }}>
|
||||
@@ -217,7 +262,43 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
<MenuSep />
|
||||
{delegated.length > 0 && (
|
||||
{/* inbuxa MA-B: 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 />
|
||||
</>
|
||||
)}
|
||||
{delegated.length + sharedMail.length > 0 && (
|
||||
<>
|
||||
<div className="hint" style={{ padding: "4px 10px" }}>{t("Mail to show")}</div>
|
||||
<MenuItem
|
||||
@@ -235,6 +316,15 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
onClick={() => switchTo(account.id)}
|
||||
/>
|
||||
))}
|
||||
{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 />
|
||||
</>
|
||||
)}
|
||||
@@ -265,7 +355,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>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Lock } from "lucide-react";
|
||||
import { Lock, Users } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useSession, useViewingDelegation } from "@/store/session";
|
||||
import { useSession, useViewingDelegation, useViewingShared } from "@/store/session";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { DelegationAccess } from "@/lib/delegation";
|
||||
|
||||
@@ -25,7 +25,29 @@ export function DelegatedBar() {
|
||||
const viewing = useSession((s) => s.viewing);
|
||||
const name = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined));
|
||||
const delegation = useViewingDelegation();
|
||||
const shared = useViewingShared();
|
||||
const [, navigate] = useLocation();
|
||||
const back = () => {
|
||||
useSession.getState().view(null);
|
||||
navigate("/mail");
|
||||
};
|
||||
// MA-A: a shared or group mailbox in view says whose it is, with the same way back
|
||||
if (viewing && shared) {
|
||||
return (
|
||||
<div className="delegated-bar shared" role="status">
|
||||
<Users size={15} aria-hidden />
|
||||
<span className="grow truncate">
|
||||
{t("Shared mailbox:")} <strong className="notranslate" translate="no">{shared.name}</strong>
|
||||
{/* MA-S: one an administrator assigned says at what level */}
|
||||
{delegation ? ` · ${accessText(delegation.access)}` : ""}
|
||||
{delegation?.sendAs ? ` · ${t("You can send as this account")}` : ""}
|
||||
</span>
|
||||
<button type="button" onClick={back}>
|
||||
{t("Back to my mail")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!viewing || !delegation) return null;
|
||||
return (
|
||||
<div className="delegated-bar" role="status">
|
||||
@@ -36,13 +58,7 @@ export function DelegatedBar() {
|
||||
{accessText(delegation.access)}
|
||||
{delegation.sendAs ? ` · ${t("You can send as this account")}` : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
useSession.getState().view(null);
|
||||
navigate("/mail");
|
||||
}}
|
||||
>
|
||||
<button type="button" onClick={back}>
|
||||
{t("Back to my mail")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user