Compare commits

...
Author SHA1 Message Date
jcoffey-dev 2174ccb7b3 Merge pull request 'Send security and conduct reports to Coffey Labs LLC addresses' (#55) from chore/company-contacts into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m16s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 07:25:37 +00:00
jcoffey-dev 0580812216 Send security and conduct reports to Coffey Labs LLC addresses
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m33s
ci / announce (pull_request) Skipped
Security reports now go to [email protected] and code-of-conduct reports to [email protected], replacing a personal address. Written in the same obfuscated form as before.
2026-10-06 00:22:15 -07:00
jcoffey-dev 06ea28dade Merge pull request 'Name Coffey Labs LLC as the copyright holder' (#54) from chore/copyright-coffey-labs-llc into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m34s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 06:42:03 +00:00
jcoffey-dev 20ac83c8df Name Coffey Labs LLC as the copyright holder
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m5s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Coffey Labs is now Coffey Labs LLC, an Arizona limited liability company. Copyright lines and SPDX-FileCopyrightText headers naming Coffey Labs or John Coffey now name Coffey Labs LLC. Upstream copyright notices are unchanged.
2026-10-05 23:33:07 -07:00
jcoffey-dev a58ea326d9 Merge pull request 'Notifications with the app closed, for every signed-in account' (#53) from feat/other-accounts-push-b into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m24s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 03:42:31 +00:00
jcoffey-dev 9fcf4812f3 Notifications with the app closed, for every signed-in account
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m3s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Second of three for background push across accounts (multi-account
spec, MA-8 part 2).

Turning on "Notify me even when inbuxa is closed" now registers this
browser in every signed-in account, each through its own session (the
route from the previous change), and renewal keeps them all current.
GET /api/auth/accounts says which mail account each session is, so the
subscription can name it. The remembered endpoint is kept per account,
and survives the clean-up that follows switching accounts.

The service worker is told who the other accounts are. A push for one
of them is titled with that account's address, shown even while a tab
is focused (the tab only shows the front account's mail), and its
Mark read and Archive act through that account's session. Clicking it
brings that account to the front and opens the message. While push is
on, the tab's own polling of other accounts stops notifying, so nothing
arrives twice.

Signing out of one account removes this device's subscription there
only; signing out of all, or turning push off, removes every one.

Also fixes where every background notification opened: the worker
linked to /mail/inbox/<thread>, and the route takes a mailbox id there,
so a click landed on the inbox list with "That folder no longer
exists". The worker now gets each account's inbox id and links to the
message.

Checked end to end in Chrome against a local server with two accounts:
both registered and verified, a message to the account not in front
showed a notification under its address, and clicking it switched
accounts and opened the message. No new strings. typecheck, tests
(web 1547, server 279) and build pass.
2026-10-05 20:38:44 -07:00
jcoffey-dev 13c9b8ef91 Merge pull request 'A narrow route to another signed-in account, for its push subscription' (#52) from feat/other-accounts-push into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m23s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 03:27:03 +00:00
jcoffey-dev 6b979c5ac7 A narrow route to another signed-in account, for its push subscription
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m24s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
First of three for notifications with the app closed, for every
signed-in account (multi-account spec, MA-8 part 2). No behavior
changes yet.

A JMAP push subscription belongs to whoever signs the request, so an
account that isn't in front can only have one registered, verified and
renewed through its own session. POST /api/auth/accounts/<sessionId>/jmap
forwards to the mail server as that session, when it is one of this
browser's other accounts, and only for PushSubscription/get and /set,
Mailbox/get, and Email/set limited to keywords and mailboxIds updates
(what a notification's Archive and Mark read need). Anything else is
403; the account in front, or a session this browser doesn't hold, is
404. Its OAuth token is renewed first if due. The browser already holds
the session, so nothing new becomes reachable.

On the web app side the push helpers (list, create, extend, destroy,
verify) take a JMAP caller, defaulting to the account in front exactly
as before, and lib/notify/otherAccount gives the caller for another
account through the route.

Tests: the allowlist, the route end to end with two accounts (allowed,
refused, front and unknown sessions), and the caller. The accounts test
file now raises LOGIN_RATE_LIMIT, since it signs in more often from one
address than the default allows. typecheck, tests (web 1543, server
278) and build pass.
2026-10-05 20:24:18 -07:00
jcoffey-dev 8acd30e8a1 Merge pull request 'New mail in the other signed-in accounts: counts, a dot, and a notification' (#51) from feat/other-accounts-unread into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 3m2s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 03:12:56 +00:00
jcoffey-dev 5f27923ce6 New mail in the other signed-in accounts: counts, a dot, and a notification
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m24s
ci / announce (pull_request) Skipped
With more than one account signed in (#49), mail arriving in one that
isn't in front went unseen until someone switched to it (multi-account
spec, MA-8).

- GET /api/auth/accounts/unread answers the Inbox unread count of each
  account not in front, asked through that account's own session (its
  OAuth token renewed first if due), kept a minute per account.
- The web app asks every two minutes while another account is signed
  in. The account menu shows each one's count beside its name, and the
  avatar carries a dot when any of them has unread mail.
- When a count rises while the app is open and desktop notifications
  are on, a notification names the account ("New mail for
  [email protected]"); clicking it switches to that account. An
  account seen for the first time doesn't notify: its mail was already
  there.

Not in this change: notifications with the app closed, which need each
added account's own Web Push subscription.

New strings (3, English only in the other ten catalogs): "New mail for
{name}", "Unread in the Inbox: {count}", "Account: new mail in another
account". Tests: the server answers the other account's count and
nothing when alone; the client keeps the counts, notifies only on a
rise and only with notifications on. Checked in Chrome against the
mock. typecheck, tests (web 1541, server 277) and build pass.
2026-10-05 20:10:07 -07:00
jcoffey-dev 68c91a1ad4 Merge pull request 'Ten translations: count Turkish where the repo states the number' (#50) from docs/ten-translations into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m15s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 02:33:55 +00:00
jcoffey-dev 9e77fb3f47 Ten translations: count Turkish where the repo states the number
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m30s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Turkish shipped (ihasmail #32/#37), so ten languages ship alongside English, not nine. CONTRIBUTING.md, the PR template and the emlName.ts comment said nine; ROADMAP.md and the i18n-literals.mjs comment describe what shipped on 2026-08-31 and stay as they are. Translations: no user-visible strings added or changed, so no catalog work.
2026-10-05 19:30:40 -07:00
jcoffey-dev 351a5aa01d Merge pull request 'Account switcher: more than one account signed in at once' (#49) from feat/account-switcher into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m25s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped
2026-10-06 01:47:37 +00:00
jcoffey-dev 34baca4365 Account switcher: more than one account signed in at once
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m26s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Someone who looks after several mailboxes of their own can now keep
them all signed in in one browser and move between them from the
account menu, without signing out (multi-account spec, MA-B; forum
topic 75).

How it holds them. The session cookie is unchanged: it is the account
in front, and every request is answered with it, so nothing else in the
server changes. The others ride in a second cookie, <name>_more, as a
list of their own session cookies. Each session stays its own -- sealed
credential, expiry and "this is my device" -- and nothing about one is
read through another. At most 5 in all, all on this mail server.

- Add account (account menu): with sign-in on the mail server's page
  it goes there with prompt=login, so the server asks again rather than
  reuse the first sign-in; with the password form, a small dialog asks.
  Refused, and the front stays, when either account's organization has
  addAccounts off (inbuxa:SharingPolicy), the account is on another
  server, or 5 are open. The same account again just comes to the
  front.
- Switching (POST /api/auth/accounts/<id>/front) swaps it into front;
  the web app clears what it cached for the previous account and
  reloads. A message being written blocks the switch.
- Sign out ends only the account in front, and the next one comes
  forward; Sign out of all accounts ends every one.
- GET /api/auth/accounts lists them, the front first, and says whether
  one more may be added.

Not in this change: unread counts and notifications for the accounts
not in front (MA-8), which the spec puts last.

The mock can sign in a second user (MOCK_SECOND_USER/PASS) and answer
addAccounts false (MOCK_NO_ADD_ACCOUNTS), for the new server tests:
two accounts joining, switching, the same account twice, a switch to a
session it doesn't hold, signing out of one and of all, and an
organization that forbids it; and the OAuth start asking prompt=login.
The client tests cover listing, switching (cache cleared, reload) and
both sign-outs. Checked in Chrome against the mock: add, switch, sign
out of one.

New strings (9, English only in the other ten catalogs): "Add
account", "Sign out of all accounts", "Add an account", "Both accounts
stay signed in here; switch between them from this menu.", "Working…",
"That account couldn't be added.", "You can't add more accounts here.",
and the other-server and organization refusals. typecheck, tests (web
1538, server 276) and build pass.
2026-10-05 18:44:45 -07:00
jcoffey-dev 9f53759462 Merge pull request 'Show an assigned shared mailbox as shared, not as a locked account' (#48) from feat/shared-mailbox-kind into main
ci / version (push) Skipped
ci / github (push) Skipped
github/ci (branch) GitHub Actions
ci / node (push) Successful in 2m45s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 23s
2026-10-05 22:18:41 +00:00
jcoffey-dev c66a8aadd7 Show an assigned shared mailbox as shared, not as a locked account
ci / version (pull_request) Skipped
ci / github (pull_request) Skipped
ci / node (pull_request) Successful in 1m29s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 36s
github/ci (branch) GitHub Actions
The server now marks a shared mailbox (support@, legal@) as a
delegation of kind "sharedMailbox" (multi-account spec, MA-S). Without
this, the webmail would show one exactly as it shows a locked account:
a red bar, a padlock in the tab and on the brand, and its calendars and
files in place of the reader's own.

Now such a mailbox is listed with the other shared mailboxes under
"Mail to show", opens with the shared bar, which also gives the
person's access level and whether they can send as it, and changes
mail only. Its access level still applies exactly as a lock's does:
read changes nothing, organize never deletes, no sending without
send-as. Found without asking Mailbox/get, since the server's mark says
it is mail.

A server that sends no kind is treated as before: every delegation is a
lock. No new strings. typecheck and vitest (174 files, 1534 tests)
pass.
2026-10-05 15:15:57 -07:00
jcoffey-dev 7f12a7d8ee Merge pull request 'Open a group's mailbox, or folders someone shared, from the account menu' (#47) from feat/shared-mail into main
ci / version (push) Skipped
ci / github (push) Skipped
github/ci (branch) GitHub Actions
ci / node (push) Successful in 3m1s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 16s
2026-10-05 21:52:10 +00:00
jcoffey-dev 722a68432c Open a group's mailbox, or folders someone shared, from the account menu
ci / version (pull_request) Skipped
ci / github (pull_request) Skipped
ci / node (pull_request) Successful in 1m29s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 41s
github/ci (branch) GitHub Actions
A group's members, and anyone a folder was shared with, could reach that
mail over IMAP but not here: Mail read only the reader's own account.
Calendar, Contacts and Files already list other people's shares; Mail
now does too (multi-account spec, MA-A).

Such an account is listed under "Mail to show" in the account menu,
after any locked account handed to the reader, and opens in place of
the reader's own mail through the same switch AL-7 uses. Only accounts
whose Mailbox/get answers with a mailbox are offered: the server
advertises every capability on any shared account, so a colleague who
shared one calendar would otherwise appear with mail.

While one is in view:

- a bar in the palette's accent (not the locked account's red) names
  it, with "Back to my mail"; the tab title names it without a padlock;
- calendars, contacts and files stay the reader's own (viewAccountFor
  follows only a delegation now);
- writing a message uses the viewed account's identities, so a reply in
  support@ goes out as support@ and is saved in its Drafts and Sent.

Losing the account (removed from the group, share withdrawn) takes the
reader back to their own mail with the existing "You no longer have
access" notice.

New string: "Shared mailbox:" (1, English only in the other ten
catalogs). Checked in Chrome against a scratch server with a support@
group and two members. typecheck and vitest (174 files, 1533 tests)
pass.
2026-10-05 14:49:28 -07:00
35 changed files with 1804 additions and 92 deletions

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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?
+1 -1
View File
@@ -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
View File
@@ -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:
+200
View File
@@ -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));
});
+58
View File
@@ -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
View File
@@ -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 });
});
+24 -1
View File
@@ -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") {
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
});
});
+9 -2
View File
@@ -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);
});
});
+22
View File
@@ -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;
};
}
+49 -15
View File
@@ -22,6 +22,15 @@ import type { GetResponse, Id, SetResponse } from "@/jmap/types";
import { isDeviceTrusted } from "@/lib/storage";
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
/**
* Who a push call is made as (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();
+109 -16
View File
@@ -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.
*
+81
View File
@@ -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]);
}
+53
View File
@@ -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);
}
+3
View File
@@ -87,6 +87,9 @@ function ownKeys(): string[] {
export function clearSignedInData(): void {
for (const key of ownKeys()) {
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
// 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);
}
}
+8
View File
@@ -44,6 +44,14 @@ describe("the worker's briefing", () => {
expect(facts.archiveId).toBe("mb-archive");
});
it("names the inbox a notification opens in", async () => {
// The route takes a mailbox id. The worker used to put the word `inbox`
// there, and every click landed on "That folder no longer exists".
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive", "mb-inbox");
expect(written(store).inboxId).toBe("mb-inbox");
});
it("carries the worker's text in the language the tab is in", async () => {
// The worker has no catalog. Everything it will say has to be said here
// first, or a German reader gets English buttons on their lock screen.
+28 -1
View File
@@ -28,6 +28,13 @@ export interface WorkerFacts {
accountId: string;
/** Where Archive files to; null where the account has no archive folder. */
archiveId: string | null;
/** The inbox a notification opens in; the route names a mailbox by id. */
inboxId?: string | null;
/**
* 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"),
+1 -1
View File
@@ -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 -1
View File
@@ -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"]);
+136
View File
@@ -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
View File
@@ -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);
+4
View File
@@ -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; }
+111
View File
@@ -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>
);
}
+98 -4
View File
@@ -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>
+25 -9
View File
@@ -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>