From 8674b70f62c5a9e940d832bb4dae8d604cdbd943 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 14:32:10 -0700 Subject: [PATCH 1/2] Open locked accounts handed to you, beside your own mail When the server hands a locked account to the reader (urn:inbuxa:jmap delegation), the account popover offers it. Only mail follows the switch; the reader's own settings, push and notifications stay theirs. A red bar, a red wordmark with a padlock and the tab title say which account is in view. Read delegates can't change anything, organize delegates can't delete, and writing needs send-as. A delegation taken away drops back to the reader's own mail. 14 new strings in all nine catalogs, unreviewed (inbuxa AL-7, AL-8). --- web/src/App.tsx | 62 ++++++++- web/src/lib/delegation.ts | 67 +++++++++ web/src/lib/notify/webpushEnable.ts | 5 +- web/src/locales/de.ts | 15 +++ web/src/locales/es.ts | 15 +++ web/src/locales/fr.ts | 15 +++ web/src/locales/ja.ts | 15 +++ web/src/locales/nl.ts | 15 +++ web/src/locales/pt-BR.ts | 15 +++ web/src/locales/ru.ts | 15 +++ web/src/locales/uk.ts | 15 +++ web/src/locales/zh-Hans.ts | 15 +++ .../__tests__/delegated-accounts.test.ts | 127 ++++++++++++++++++ web/src/store/compose.ts | 27 ++++ web/src/store/mail/index.ts | 64 ++++++++- web/src/store/session.ts | 51 ++++++- web/src/styles/app.css | 14 ++ web/src/views/AppShell.tsx | 60 ++++++++- web/src/views/DelegatedBar.tsx | 50 +++++++ web/src/views/mail/MailView.tsx | 28 ++++ web/src/views/mail/MessageView.tsx | 6 +- web/src/views/mail/ThreadView.tsx | 8 +- 22 files changed, 679 insertions(+), 25 deletions(-) create mode 100644 web/src/lib/delegation.ts create mode 100644 web/src/store/__tests__/delegated-accounts.test.ts create mode 100644 web/src/views/DelegatedBar.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 826824e..ca088b7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,7 @@ -import { Fragment, lazy, Suspense, useEffect, useState } from "react"; +import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react"; import { Route, Switch, Redirect, useLocation, Router } from "wouter"; import { useSession } from "@/store/session"; -import { useMail } from "@/store/mail"; +import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail"; import { scheduleSupported, useScheduled } from "@/store/scheduled"; import { useContacts } from "@/store/contacts"; import { useCalendar } from "@/store/calendar"; @@ -119,6 +119,7 @@ export function App() { function AuthedApp() { const accountId = useSession((s) => s.accountId); + const viewing = useSession((s) => s.viewing); const [location] = useLocation(); /* @@ -230,6 +231,9 @@ function AuthedApp() { timer = null; for (const [a, types] of pending) { if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types); + // inbuxa AL-7: the reader's own mail, while a delegated account + // is in view, is still announced + if (a === ownAccountAway() && types.has("Email")) void notifyOwnWhileAway(); if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types); if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types); if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types); @@ -253,16 +257,61 @@ function AuthedApp() { }; }, [accountId]); + /* + * inbuxa AL-7: a delegated account's mail, when it comes into view, and the + * reader's own when it goes back. Push carries nothing for an account only + * shared with the reader, so while one is open it is polled. + */ + const viewedOnce = useRef(false); + useEffect(() => { + if (!viewedOnce.current) { + viewedOnce.current = true; + if (!viewing) return; + } + const mail = useMail.getState(); + void mail.loadMailboxes(); + void mail.loadIdentities(); + if (!viewing) return; + const poll = window.setInterval(() => { + if (document.visibilityState === "visible") { + void useMail.getState().applyChanges(new Set(["Email", "Mailbox"])); + } + }, 60_000); + return () => window.clearInterval(poll); + }, [viewing]); + + // inbuxa AL-7: a delegation given or taken away while the app is open shows + // up when the reader comes back to it, without signing in again + useEffect(() => { + let last = 0; + const onVisible = () => { + if (document.visibilityState !== "visible" || Date.now() - last < 60_000) return; + last = Date.now(); + void useSession.getState().refresh(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => document.removeEventListener("visibilitychange", onVisible); + }, []); + + const delegationEnded = useSession((s) => s.delegationEnded); + useEffect(() => { + if (!delegationEnded) return; + toast.show(t("You no longer have access to {name}. Back to your own mail.", { name: delegationEnded })); + useSession.getState().clearDelegationEnded(); + }, [delegationEnded]); + // Unread badge in title/favicon const inboxUnread = useMail((s) => { const id = s.roleId("inbox"); 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 + const viewingName = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined)); useEffect(() => { - setBaseTitle(appName); + setBaseTitle(viewingName ? `🔒 ${viewingName} · ${appName}` : appName); setUnreadBadge(inboxUnread); - }, [inboxUnread, appName]); + }, [inboxUnread, appName, viewingName]); /* * Leave the service worker its briefing. @@ -276,8 +325,11 @@ function AuthedApp() { const archiveId = useMail((s) => s.roleId("archive")); 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]); + }, [accountId, archiveId, languageVersion, viewing]); // Request notification permission lazily when enabled const notif = useSettings((s) => s.settings.desktopNotifications); diff --git a/web/src/lib/delegation.ts b/web/src/lib/delegation.ts new file mode 100644 index 0000000..a7ca3f4 --- /dev/null +++ b/web/src/lib/delegation.ts @@ -0,0 +1,67 @@ +/** + * Locked accounts handed to the reader (inbuxa audit-hold-lock spec, AL-7). + * + * The server marks one in the account's `urn:inbuxa:jmap` capability, as + * `delegation: {locked, access, sendAs, until}`. Only that mark makes an + * account switchable: a server advertises every capability on any shared + * account, so "it has mail" proves nothing about what was handed over. + */ + +import type { Id, JmapSession } from "@/jmap/types"; + +export type DelegationAccess = "read" | "organize" | "full"; + +export interface Delegation { + locked: boolean; + access: DelegationAccess; + sendAs: boolean; + /** UTC date the delegation ends, if it does. */ + until: string | null; +} + +export interface DelegatedAccount { + id: Id; + name: string; + delegation: Delegation; +} + +const INBUXA = "urn:inbuxa:jmap"; + +type SessionLike = Pick; + +export function delegationOf(session: SessionLike | null, accountId: Id | null): Delegation | null { + if (!session || !accountId) return null; + const account = session.accounts[accountId]; + if (!account || account.isPersonal) return null; + const raw = (account.accountCapabilities?.[INBUXA] as { delegation?: Partial } | undefined)?.delegation; + if (!raw || raw.locked !== true) return null; + const access: DelegationAccess = raw.access === "organize" || raw.access === "full" ? raw.access : "read"; + return { + locked: true, + access, + sendAs: raw.sendAs === true && access !== "read", + until: typeof raw.until === "string" ? raw.until : null, + }; +} + +/** Every locked account handed to the reader, by name. */ +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; + }) + .filter((a): a is DelegatedAccount => a !== null) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Whether the reader may change anything in the account (AL-6). */ +export function mayWrite(delegation: Delegation | null): boolean { + return !delegation || delegation.access !== "read"; +} + +/** Whether the reader may delete in the account (AL-6). */ +export function mayDestroy(delegation: Delegation | null): boolean { + return !delegation || delegation.access === "full"; +} diff --git a/web/src/lib/notify/webpushEnable.ts b/web/src/lib/notify/webpushEnable.ts index 296c651..7f310e2 100644 --- a/web/src/lib/notify/webpushEnable.ts +++ b/web/src/lib/notify/webpushEnable.ts @@ -10,7 +10,7 @@ import { withBase } from "../basePath"; import { SW_CACHE_NAME } from "../sw/swCache"; import { isDeviceTrusted } from "@/lib/storage"; import { useSession } from "@/store/session"; -import { useMail } from "@/store/mail"; +import { ownInboxId } from "@/store/mail"; import { applicationServerKey, createSubscription, @@ -153,7 +153,8 @@ async function registerThisBrowser(key: string): Promise { } if (mine.length) await destroySubscriptions(mine.map((s) => s.id)); - const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), useMail.getState().roleId("inbox")); + // 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()); try { await createSubscription(payload); } catch (err) { diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index b64fc11..db9fd98 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1752,6 +1752,21 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion (inbuxa) ────────── "Language model's opinion": "Einschätzung des Sprachmodells", "One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat", + // inbuxa AL-7, AL-8: a locked account handed to the reader + "You no longer have access to {name}. Back to your own mail.": "Sie haben keinen Zugriff mehr auf {name}. Zurück zu Ihren eigenen E-Mails.", + "You can't send from {name}. It was handed to you to read, not to send as.": "Sie können nicht als {name} senden. Das Konto wurde Ihnen zum Lesen übergeben, nicht zum Senden.", + "This account was handed to you to read. Nothing in it can be changed.": "Dieses Konto wurde Ihnen zum Lesen übergeben. Darin kann nichts geändert werden.", + "You can file and move mail in this account, but not delete it.": "Sie können E-Mails in diesem Konto ablegen und verschieben, aber nicht löschen.", + "Read only": "Nur lesen", + "Read and organize": "Lesen und ordnen", + "Full access": "Voller Zugriff", + "Locked account:": "Gesperrtes Konto:", + "You can send as this account": "Sie können als dieses Konto senden", + "Back to my mail": "Zurück zu meinen E-Mails", + "Send or close the message you're writing first.": "Senden oder schließen Sie zuerst die Nachricht, die Sie gerade schreiben.", + "Mail to show": "E-Mails anzeigen von", + "My mail": "Meine E-Mails", + "Locked account": "Gesperrtes Konto", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 7abc76f..42b05f6 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1725,6 +1725,21 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion (inbuxa) ────────── "Language model's opinion": "Opinión del modelo de lenguaje", "One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta", + // inbuxa AL-7, AL-8: a locked account handed to the reader + "You no longer have access to {name}. Back to your own mail.": "Ya no tiene acceso a {name}. Vuelve a su propio correo.", + "You can't send from {name}. It was handed to you to read, not to send as.": "No puede enviar desde {name}. Se le entregó para leerla, no para enviar en su nombre.", + "This account was handed to you to read. Nothing in it can be changed.": "Esta cuenta se le entregó para leerla. No se puede cambiar nada en ella.", + "You can file and move mail in this account, but not delete it.": "Puede archivar y mover el correo de esta cuenta, pero no eliminarlo.", + "Read only": "Solo lectura", + "Read and organize": "Leer y organizar", + "Full access": "Acceso completo", + "Locked account:": "Cuenta bloqueada:", + "You can send as this account": "Puede enviar como esta cuenta", + "Back to my mail": "Volver a mi correo", + "Send or close the message you're writing first.": "Envíe o cierre primero el mensaje que está escribiendo.", + "Mail to show": "Correo que mostrar", + "My mail": "Mi correo", + "Locked account": "Cuenta bloqueada", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index bbdae6e..b8f8b13 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1730,6 +1730,21 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion (inbuxa) ────────── "Language model's opinion": "Avis du modèle de langage", "One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam", + // inbuxa AL-7, AL-8: a locked account handed to the reader + "You no longer have access to {name}. Back to your own mail.": "Vous n’avez plus accès à {name}. Retour à votre propre courrier.", + "You can't send from {name}. It was handed to you to read, not to send as.": "Vous ne pouvez pas envoyer depuis {name}. Ce compte vous a été confié pour le lire, pas pour envoyer en son nom.", + "This account was handed to you to read. Nothing in it can be changed.": "Ce compte vous a été confié pour le lire. Rien ne peut y être modifié.", + "You can file and move mail in this account, but not delete it.": "Vous pouvez classer et déplacer le courrier de ce compte, mais pas le supprimer.", + "Read only": "Lecture seule", + "Read and organize": "Lecture et classement", + "Full access": "Accès complet", + "Locked account:": "Compte verrouillé :", + "You can send as this account": "Vous pouvez envoyer au nom de ce compte", + "Back to my mail": "Retour à mon courrier", + "Send or close the message you're writing first.": "Envoyez ou fermez d’abord le message que vous rédigez.", + "Mail to show": "Courrier à afficher", + "My mail": "Mon courrier", + "Locked account": "Compte verrouillé", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index ae56c26..1c59378 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1733,6 +1733,21 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion (inbuxa) ────────── "Language model's opinion": "言語モデルの見解", "One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ", + // inbuxa AL-7, AL-8: a locked account handed to the reader + "You no longer have access to {name}. Back to your own mail.": "{name} にアクセスできなくなりました。自分のメールに戻ります。", + "You can't send from {name}. It was handed to you to read, not to send as.": "{name} から送信することはできません。このアカウントは閲覧のために委任されています。", + "This account was handed to you to read. Nothing in it can be changed.": "このアカウントは閲覧用に委任されています。内容を変更することはできません。", + "You can file and move mail in this account, but not delete it.": "このアカウントのメールは整理・移動できますが、削除はできません。", + "Read only": "閲覧のみ", + "Read and organize": "閲覧と整理", + "Full access": "フルアクセス", + "Locked account:": "ロックされたアカウント:", + "You can send as this account": "このアカウントとして送信できます", + "Back to my mail": "自分のメールに戻る", + "Send or close the message you're writing first.": "作成中のメッセージを先に送信するか閉じてください。", + "Mail to show": "表示するメール", + "My mail": "自分のメール", + "Locked account": "ロックされたアカウント", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 5cb82f5..02b5267 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1722,6 +1722,21 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion (inbuxa) ────────── "Language model's opinion": "Oordeel van het taalmodel", "One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen", + // inbuxa AL-7, AL-8: a locked account handed to the reader + "You no longer have access to {name}. Back to your own mail.": "U hebt geen toegang meer tot {name}. Terug naar uw eigen mail.", + "You can't send from {name}. It was handed to you to read, not to send as.": "U kunt niet verzenden vanuit {name}. Het account is u gegeven om te lezen, niet om namens te verzenden.", + "This account was handed to you to read. Nothing in it can be changed.": "Dit account is u gegeven om te lezen. Er kan niets in worden gewijzigd.", + "You can file and move mail in this account, but not delete it.": "U kunt mail in dit account ordenen en verplaatsen, maar niet verwijderen.", + "Read only": "Alleen lezen", + "Read and organize": "Lezen en ordenen", + "Full access": "Volledige toegang", + "Locked account:": "Vergrendeld account:", + "You can send as this account": "U kunt namens dit account verzenden", + "Back to my mail": "Terug naar mijn mail", + "Send or close the message you're writing first.": "Verzend of sluit eerst het bericht dat u schrijft.", + "Mail to show": "Mail tonen van", + "My mail": "Mijn mail", + "Locked account": "Vergrendeld account", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index dbd6ee9..d9de982 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1728,6 +1728,21 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion (inbuxa) ────────── "Language model's opinion": "Opinião do modelo de linguagem", "One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam", + // inbuxa AL-7, AL-8: a locked account handed to the reader + "You no longer have access to {name}. Back to your own mail.": "Você não tem mais acesso a {name}. De volta ao seu próprio e-mail.", + "You can't send from {name}. It was handed to you to read, not to send as.": "Você não pode enviar de {name}. A conta foi entregue a você para leitura, não para enviar em nome dela.", + "This account was handed to you to read. Nothing in it can be changed.": "Esta conta foi entregue a você para leitura. Nada nela pode ser alterado.", + "You can file and move mail in this account, but not delete it.": "Você pode arquivar e mover e-mails nesta conta, mas não excluí-los.", + "Read only": "Somente leitura", + "Read and organize": "Ler e organizar", + "Full access": "Acesso total", + "Locked account:": "Conta bloqueada:", + "You can send as this account": "Você pode enviar como esta conta", + "Back to my mail": "Voltar ao meu e-mail", + "Send or close the message you're writing first.": "Envie ou feche primeiro a mensagem que está escrevendo.", + "Mail to show": "E-mail a exibir", + "My mail": "Meu e-mail", + "Locked account": "Conta bloqueada", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index ea744ca..f645f1b 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1727,6 +1727,21 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion (inbuxa) ────────── "Language model's opinion": "Мнение языковой модели", "One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр", + // inbuxa AL-7, AL-8: a locked account handed to the reader + "You no longer have access to {name}. Back to your own mail.": "У вас больше нет доступа к {name}. Возвращаемся к вашей почте.", + "You can't send from {name}. It was handed to you to read, not to send as.": "Вы не можете отправлять письма от имени {name}. Учётная запись передана вам для чтения, а не для отправки.", + "This account was handed to you to read. Nothing in it can be changed.": "Эта учётная запись передана вам для чтения. Изменить в ней ничего нельзя.", + "You can file and move mail in this account, but not delete it.": "В этой учётной записи вы можете раскладывать и перемещать письма, но не удалять их.", + "Read only": "Только чтение", + "Read and organize": "Чтение и упорядочивание", + "Full access": "Полный доступ", + "Locked account:": "Заблокированная учётная запись:", + "You can send as this account": "Вы можете отправлять от имени этой учётной записи", + "Back to my mail": "Вернуться к моей почте", + "Send or close the message you're writing first.": "Сначала отправьте или закройте письмо, которое пишете.", + "Mail to show": "Какую почту показать", + "My mail": "Моя почта", + "Locked account": "Заблокированная учётная запись", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index f1fae58..4b335c3 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1721,6 +1721,21 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion (inbuxa) ────────── "Language model's opinion": "Думка мовної моделі", "One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр", + // inbuxa AL-7, AL-8: a locked account handed to the reader + "You no longer have access to {name}. Back to your own mail.": "У вас більше немає доступу до {name}. Повертаємося до вашої пошти.", + "You can't send from {name}. It was handed to you to read, not to send as.": "Ви не можете надсилати листи від імені {name}. Обліковий запис передано вам для читання, а не для надсилання.", + "This account was handed to you to read. Nothing in it can be changed.": "Цей обліковий запис передано вам для читання. Змінити в ньому нічого не можна.", + "You can file and move mail in this account, but not delete it.": "У цьому обліковому записі ви можете впорядковувати й переміщувати листи, але не видаляти їх.", + "Read only": "Лише читання", + "Read and organize": "Читання й упорядкування", + "Full access": "Повний доступ", + "Locked account:": "Заблокований обліковий запис:", + "You can send as this account": "Ви можете надсилати від імені цього облікового запису", + "Back to my mail": "Повернутися до моєї пошти", + "Send or close the message you're writing first.": "Спочатку надішліть або закрийте лист, який пишете.", + "Mail to show": "Яку пошту показати", + "My mail": "Моя пошта", + "Locked account": "Заблокований обліковий запис", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 29b8c3d..628971c 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1732,6 +1732,21 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion (inbuxa) ────────── "Language model's opinion": "语言模型的判断", "One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一", + // inbuxa AL-7, AL-8: a locked account handed to the reader + "You no longer have access to {name}. Back to your own mail.": "您已无法访问 {name}。已返回您自己的邮件。", + "You can't send from {name}. It was handed to you to read, not to send as.": "您不能从 {name} 发送邮件。该账户交给您是为了阅读,而不是代其发送。", + "This account was handed to you to read. Nothing in it can be changed.": "该账户交给您用于阅读,其中的任何内容都不能更改。", + "You can file and move mail in this account, but not delete it.": "您可以在此账户中整理和移动邮件,但不能删除。", + "Read only": "只读", + "Read and organize": "阅读并整理", + "Full access": "完全访问", + "Locked account:": "已锁定的账户:", + "You can send as this account": "您可以以此账户的身份发送", + "Back to my mail": "返回我的邮件", + "Send or close the message you're writing first.": "请先发送或关闭您正在撰写的邮件。", + "Mail to show": "要显示的邮件", + "My mail": "我的邮件", + "Locked account": "已锁定的账户", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/store/__tests__/delegated-accounts.test.ts b/web/src/store/__tests__/delegated-accounts.test.ts new file mode 100644 index 0000000..a7dcbce --- /dev/null +++ b/web/src/store/__tests__/delegated-accounts.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import type { JmapSession } from "@/jmap/types"; +import { useSession } from "@/store/session"; +import { useMail } from "@/store/mail"; +import { composeBlocked, useCompose } from "@/store/compose"; +import { delegatedAccounts, delegationOf, mayDestroy, mayWrite } from "@/lib/delegation"; + +/** + * inbuxa AL-7: a locked account handed to the reader shows in place of their + * own mail, and only mail follows it; the reader never loses their own + * account, and a delegation taken away takes them back. + */ + +const delegated = (access: string, sendAs = false) => ({ + name: "gone@example.com", + isPersonal: false, + isReadOnly: access === "read", + accountCapabilities: { + [CAP.mail]: {}, + "urn:inbuxa:jmap": { delegation: { locked: true, access, sendAs, until: null } }, + }, +}); + +const sessionWith = (locked: Record | null) => + ({ + capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} }, + accounts: { + own: { name: "me@example.com", isPersonal: true, accountCapabilities: { [CAP.mail]: {} } }, + shared: { name: "team@example.com", isPersonal: false, accountCapabilities: { [CAP.mail]: {} } }, + ...(locked ? { locked } : {}), + }, + primaryAccounts: { [CAP.mail]: "own" }, + state: "s", + }) as unknown as JmapSession; + +let nextSession: JmapSession; + +beforeEach(() => { + nextSession = sessionWith(delegated("read")); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).includes("/api/auth/session")) { + return { ok: true, status: 200, json: async () => nextSession } as Response; + } + const { methodCalls } = JSON.parse((init?.body as string) ?? "{}") as { methodCalls: [string, Record, string][] }; + const methodResponses = methodCalls.map(([name, args, id]) => [name, { accountId: args.accountId, state: "1", list: [], notFound: [] }, id]); + return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response; + }), + ); + const session = sessionWith(delegated("read")); + client.session = session; + useSession.setState({ status: "authenticated", session, accountId: "own", viewing: null, delegationEnded: null }); + useCompose.setState({ drafts: [] }); +}); + +afterEach(() => { + useSession.setState({ viewing: null }); + vi.unstubAllGlobals(); +}); + +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, "shared")).toBeNull(); + expect(delegationOf(session, "own")).toBeNull(); + expect(delegatedAccounts(session).map((a) => a.id)).toEqual(["locked"]); + }); + + it("never lets a read delegate send, whatever the server says", () => { + const session = sessionWith(delegated("read", true)); + expect(delegationOf(session, "locked")?.sendAs).toBe(false); + }); + + it("says what each level may do", () => { + const read = delegationOf(sessionWith(delegated("read")), "locked"); + const organize = delegationOf(sessionWith(delegated("organize")), "locked"); + expect(mayWrite(read)).toBe(false); + expect(mayWrite(organize)).toBe(true); + expect(mayDestroy(organize)).toBe(false); + expect(mayWrite(null) && mayDestroy(null)).toBe(true); + }); +}); + +describe("viewing a locked account", () => { + it("moves only the mail store; the session's own account stays", () => { + useSession.getState().view("locked"); + expect(useMail.getState().accountId).toBe("locked"); + expect(useSession.getState().accountId).toBe("own"); + expect(useSession.getState().ownAccountFor(CAP.mail)).toBe("own"); + useSession.getState().view(null); + expect(useMail.getState().accountId).toBe("own"); + }); + + it("refuses an account that isn't delegated", () => { + useSession.getState().view("shared"); + expect(useSession.getState().viewing).toBeNull(); + expect(useMail.getState().accountId).toBe("own"); + }); + + it("goes back to the reader's own mail when the delegation ends", async () => { + useSession.getState().view("locked"); + nextSession = sessionWith(null); + await useSession.getState().refresh(); + expect(useSession.getState().viewing).toBeNull(); + expect(useSession.getState().delegationEnded).toBe("gone@example.com"); + expect(useMail.getState().accountId).toBe("own"); + }); + + it("blocks writing mail where the delegate can't send", () => { + expect(composeBlocked()).toBeNull(); + useSession.getState().view("locked"); + expect(composeBlocked()).toMatch(/gone@example.com/); + expect(useCompose.getState().open()).toBe(""); + expect(useCompose.getState().drafts).toHaveLength(0); + }); + + it("lets a send-as delegate write", () => { + const session = sessionWith(delegated("full", true)); + client.session = session; + useSession.setState({ session }); + useSession.getState().view("locked"); + expect(composeBlocked()).toBeNull(); + }); +}); diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index b224642..91a5a8b 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -16,6 +16,7 @@ import { settings } from "./settings"; import { emlFilename } from "@/lib/text/emlName"; import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders"; import { shareBody, type SharedContent } from "@/lib/shareTarget"; +import { delegationOf } from "@/lib/delegation"; export interface ComposeAttachment { id: string; @@ -170,12 +171,33 @@ function defaultIdentity(identities: Identity[], email?: Email | null): Identity return useMail.getState().defaultIdentity() ?? identities[0]; } +/** + * Why nothing can be written from the account in view, if it can't: a locked + * account handed to the reader without the right to send as it (inbuxa + * AL-8). Nothing is then saved to its Drafts either. + */ +export function composeBlocked(): string | null { + const s = useSession.getState(); + if (!s.viewing) return null; + const delegation = delegationOf(s.session, s.viewing); + if (!delegation || delegation.sendAs) return null; + const name = s.session?.accounts[s.viewing]?.name ?? ""; + return translate("You can't send from {name}. It was handed to you to read, not to send as.", { name }); +} + +function refuseCompose(): boolean { + const why = composeBlocked(); + if (why) toast.show(why); + return why !== null; +} + export const useCompose = create((set, get) => ({ drafts: [], activeKey: null, pendingSends: {}, open(init = {}) { + if (refuseCompose()) return ""; const identities = useMail.getState().identities; const ident = init.identityId ? identities.find((i) => i.id === init.identityId) : useMail.getState().defaultIdentity(); const d = blankDraft({ identityId: ident?.id ?? null, replyTo: ident?.replyTo ?? [], showReplyTo: Boolean(ident?.replyTo?.length), ...init }); @@ -201,6 +223,7 @@ export const useCompose = create((set, get) => ({ * signature from every message that started as a share. */ openFromShare(share) { + if (refuseCompose()) return ""; const body = shareBody(share); const key = get().open({ subject: share.title.trim() }); if (body) { @@ -212,6 +235,7 @@ export const useCompose = create((set, get) => ({ }, async openDraftEmail(email) { + if (refuseCompose()) return ""; const existing = get().drafts.find((d) => d.draftId === email.id); if (existing) { get().focus(existing.key); @@ -273,6 +297,7 @@ export const useCompose = create((set, get) => ({ * both are new without anything here asking for it. */ async composeAsNew(email) { + if (refuseCompose()) return ""; const mail = useMail.getState(); const full = (await mail.getEmails([email.id], true))[0] ?? email; const identities = mail.identities.length ? mail.identities : await mail.loadIdentities(); @@ -324,6 +349,7 @@ export const useCompose = create((set, get) => ({ }, async reply(email, mode) { + if (refuseCompose()) return ""; const mail = useMail.getState(); const full = (await mail.getEmails([email.id], true))[0] ?? email; const identities = mail.identities.length ? mail.identities : await mail.loadIdentities(); @@ -440,6 +466,7 @@ export const useCompose = create((set, get) => ({ }, forwardAsAttachment(email) { + if (refuseCompose()) return ""; const accountId = useMail.getState().accountId; const key = get().open({ subject: replySubject(email.subject, "Fwd"), diff --git a/web/src/store/mail/index.ts b/web/src/store/mail/index.ts index e51048d..7e71131 100644 --- a/web/src/store/mail/index.ts +++ b/web/src/store/mail/index.ts @@ -104,6 +104,15 @@ export const useMail = create((set, get) => ({ setAccount(accountId) { if (accountId === get().accountId) return; + // inbuxa AL-7: leaving the reader's own mail for a delegated account, + // remember where theirs was, so new mail there is still announced + const own = useSession.getState().accountId; + const leaving = get().accountId; + if (leaving && leaving === own && accountId && accountId !== own) { + ownWhileAway = { accountId: own, inbox: get().roleId("inbox"), state: get().emailState }; + } else if (accountId === own) { + ownWhileAway = null; + } resetBodyOrder(); snapshots.clear(); set({ @@ -1485,10 +1494,56 @@ function removeFromList(ids: Id[], set: (fn: (s: MailState) => Partial MailState) { - const s = settings(); const inbox = get().roleId("inbox"); if (!inbox) return; const emails = await get().getEmails(created); + announceNewMail(emails, inbox); +} + +/** + * The reader's own account while a delegated one is in view (inbuxa AL-7): + * its inbox and how far its mail was seen, so what arrives there meanwhile + * is still announced. + */ +let ownWhileAway: { accountId: Id; inbox: Id | null; state: string | null } | null = null; + +/** New mail in the reader's own account, while a delegated one is in view. */ +export async function notifyOwnWhileAway(): Promise { + const away = ownWhileAway; + if (!away?.inbox || !away.state) return; + try { + const changes = await client.call("Email/changes", { + accountId: away.accountId, + sinceState: away.state, + maxChanges: 50, + }); + if (ownWhileAway !== away) return; + away.state = changes.newState; + if (!changes.created.length) return; + const got = await client.call>("Email/get", { + accountId: away.accountId, + ids: changes.created, + properties: LIST_PROPS, + }); + announceNewMail(got.list, away.inbox); + } catch { + /* the next change tries again */ + } +} + +export function ownAccountAway(): Id | null { + return ownWhileAway?.accountId ?? null; +} + +/** The reader's own inbox, whatever account is in view (inbuxa AL-7). */ +export function ownInboxId(): Id | null { + const mail = useMail.getState(); + if (mail.accountId === useSession.getState().accountId) return mail.roleId("inbox"); + return ownWhileAway?.inbox ?? null; +} + +function announceNewMail(emails: Email[], inbox: Id) { + const s = settings(); const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft); if (!fresh.length) return; if (s.notificationSound) playNewMailSound(); @@ -1514,9 +1569,12 @@ async function notifyNewMail(created: Id[], get: () => MailState) { } } -/** Keep the store bound to the selected account. */ +/** + * Keep the store bound to the account in view: a delegated account while one + * is open (inbuxa AL-7), the reader's own otherwise. + */ useSession.subscribe((s) => { - useMail.getState().setAccount(s.status === "authenticated" ? s.accountId : null); + useMail.getState().setAccount(s.status === "authenticated" ? (s.viewing ?? s.accountId) : null); }); diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 1132f25..4372063 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -9,6 +9,7 @@ import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild"; import { 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"; export type AuthStatus = "loading" | "anonymous" | "authenticated"; @@ -17,6 +18,14 @@ interface SessionState { session: JmapSession | null; /** Selected mail account (defaults to primary). */ accountId: Id | null; + /** + * A locked account handed to the reader that the mail view shows instead + * of their own (inbuxa AL-7). Only mail follows it: settings, filters, + * push and everything else stay the reader's own. + */ + viewing: Id | null; + /** The name of an account whose delegation ended while it was in view. */ + delegationEnded: string | null; error: string | null; pushConnected: boolean; /** Finer than pushConnected: tells "reconnecting" from "not connected". */ @@ -26,6 +35,9 @@ interface SessionState { logout(): Promise; refresh(): Promise; setAccount(id: Id): void; + /** Show a delegated account's mail, or the reader's own with null. */ + view(id: Id | null): void; + clearDelegationEnded(): void; /** The account to read and write for a capability, honoring the account switcher. */ accountFor(cap: string): Id | null; /** The user's own account for a capability, whatever they are looking at. */ @@ -38,6 +50,8 @@ export const useSession = create((set, get) => ({ status: "loading", session: null, accountId: null, + viewing: null, + delegationEnded: null, error: null, pushConnected: false, pushState: "disconnected", @@ -99,7 +113,7 @@ export const useSession = create((set, get) => ({ // problem next -- and the address book cached here is the same argument. clearSignedInData(); client.session = null; - set({ status: "anonymous", session: null, accountId: null }); + set({ status: "anonymous", session: null, accountId: null, viewing: null }); }, refresh() { @@ -109,7 +123,14 @@ export const useSession = create((set, get) => ({ const s = await apiFetch("/api/auth/session?refresh=1"); client.session = s; setServerLocale(s.ihasmail?.userLocale); - set({ session: s }); + // A delegation that ended takes the reader back to their own mail + const viewing = get().viewing; + if (viewing && !delegationOf(s, viewing)) { + const name = get().session?.accounts[viewing]?.name ?? null; + set({ session: s, viewing: null, delegationEnded: name }); + } else { + set({ session: s }); + } } catch { /* ignore */ } finally { @@ -123,6 +144,16 @@ export const useSession = create((set, get) => ({ set({ accountId: id }); }, + view(id) { + if (id && !delegationOf(get().session, id)) return; + if (id === get().viewing) return; + set({ viewing: id }); + }, + + clearDelegationEnded() { + set({ delegationEnded: null }); + }, + accountFor(cap) { return accountForCapability(get().session, get().accountId, cap); }, @@ -149,7 +180,7 @@ function applySession(s: JmapSession, set: (p: Partial) => void) { startIdleLogout(() => void useSession.getState().logout()); } const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null; - set({ status: "authenticated", session: s, accountId, error: null }); + set({ status: "authenticated", session: s, accountId, viewing: null, error: null }); } client.onUnauthenticated(() => { @@ -162,12 +193,24 @@ client.onUnauthenticated(() => { // usual reason to be signed out here, and reloading a form someone has // already started typing into would throw the password away. void reloadIfServerRebuilt().then((reloading) => { - if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null }); + if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null }); }); }); push.onConnection((state) => useSession.setState({ pushConnected: state === "connected", pushState: state })); +/** The delegation of the locked account in view, if one is (inbuxa AL-6). */ +export function useViewingDelegation(): Delegation | null { + const session = useSession((s) => s.session); + const viewing = useSession((s) => s.viewing); + return delegationOf(session, viewing); +} + +export function viewingDelegation(): Delegation | null { + const s = useSession.getState(); + return delegationOf(s.session, s.viewing); +} + export function hasCap(cap: string): boolean { return client.hasCapability(cap); } diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 06aefb2..8681f41 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1248,6 +1248,20 @@ a.menu-item:hover { color: var(--fg); } .topbar .brand img { width: 34px; height: 34px; object-fit: contain; } .topbar .brand .brand-name { color: #14b8a6; } .topbar .brand .brand-name span { color: var(--fg-muted); font-weight: 500; } +/* + * inbuxa AL-7: a locked account's mail in view. The bar is its own grid row + * above the top bar; its red is fixed, never the palette's, so it reads the + * same in every palette and mode and can't pass for part of a theme (white on + * red-700 is 6.5:1). The wordmark turns red too, with a padlock beside it. + */ +.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; } +.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; } +[data-theme="dark"] .topbar .brand.locked .brand-name, [data-theme="dark"] .topbar .brand.locked .brand-lock { color: #f87171; } +.topbar .brand .brand-lock { flex: none; } /* `min-width: 0`, or the flex default of `auto` holds the search field at its own min-content and the account buttons beside it are pushed off a phone. */ .searchbar { flex: 1 1 auto; min-width: 0; max-width: 720px; margin: 0 auto; position: relative; } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index eff482d..59e2de0 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -1,13 +1,16 @@ import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; -import { Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react"; +import { Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, Lock, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react"; import { useSession } from "@/store/session"; import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; import { useEffectiveTheme, useSettings } from "@/store/settings"; import { toggleTarget } from "@/lib/palette"; import { useMail } from "@/store/mail"; -import { draftFromMailto, useCompose } from "@/store/compose"; +import { composeBlocked, draftFromMailto, useCompose } from "@/store/compose"; +import { delegatedAccounts } from "@/lib/delegation"; +import { toast } from "@/ui/toast"; +import { DelegatedBar } from "./DelegatedBar"; import { Avatar, useIsMobile } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { Splitter } from "@/ui/Splitter"; @@ -130,24 +133,45 @@ export function AppShell({ children }: { children: ReactNode }) { }, [openShare, navigate]); /* - * There is no account switcher any more. + * There is no general account switcher. * * It existed to reach what other people shared, and was the wrong door: it * moved the whole app to somebody else's account, and Stalwart advertises * every capability on a shared account, so mail, calendar and contacts went * with it and were refused. Shares are listed where they belong now -- in - * Files and in Contacts, beside the reader's own -- and found without anyone - * having to know an account switch was involved. + * Files and in Contacts, beside the reader's own. + * + * inbuxa AL-7 brings back one narrow door: a locked account an administrator + * handed to the reader. Only its mail is shown, in place of the reader's + * own; calendars, contacts, files and settings stay theirs. It is offered + * only when the session marks such an account, and only then is there + * anything to switch. */ + const viewing = useSession((s) => s.viewing); + const delegated = delegatedAccounts(session); + const switchTo = (id: string | null) => { + acctMenu.close(); + if (id === viewing) return; + // A message being written belongs to the account it was started in + if (useCompose.getState().drafts.length) { + toast.show(t("Send or close the message you're writing first.")); + return; + } + useSession.getState().view(id); + navigate("/mail"); + }; + const composeOff = section !== "files" && section !== "calendar" && section !== "contacts" && composeBlocked() !== null; return ( -
+
+
- + + {viewing && } {/* 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 @@ -186,6 +210,27 @@ export function AppShell({ children }: { children: ReactNode }) {
+ {delegated.length > 0 && ( + <> +
{t("Mail to show")}
+ : } + label={t("My mail")} + active={!viewing} + onClick={() => switchTo(null)} + /> + {delegated.map((account) => ( + : } + label={{account.name}} + active={viewing === account.id} + onClick={() => switchTo(account.id)} + /> + ))} + + + )} {/* The project site. It is linked from the login screen footer, which is a page a signed-in user never sees again -- so from inside the app there was no way back to it. @@ -249,6 +294,7 @@ export function AppShell({ children }: { children: ReactNode }) { from the file manager and was the one thing nobody wanted there. */} + + ); +} diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx index 4f67106..7b85d5a 100644 --- a/web/src/views/mail/MailView.tsx +++ b/web/src/views/mail/MailView.tsx @@ -16,6 +16,7 @@ import { LabelPicker } from "./LabelPicker"; import type { Id } from "@/jmap/types"; import { confirmDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; +import { viewingDelegation } from "@/store/session"; import { isUnknownMailbox } from "@/lib/mailbox/mailboxRoute"; import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled"; import { plural, t as translate, tNode } from "@/lib/i18n"; @@ -284,15 +285,36 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; [threadId, currentRowIndex, settings.autoAdvance, ids, rowThreadId, openThread, setFocusId], ); + /* + * inbuxa AL-6: in a locked account handed to the reader, what their level + * doesn't allow says so instead of trying. Read changes nothing; organize + * moves and flags but never deletes, so Trash and Junk are out of reach. + */ + const refused = (destroys: boolean): boolean => { + const delegation = viewingDelegation(); + if (!delegation) return false; + if (delegation.access === "read") { + toast.show(translate("This account was handed to you to read. Nothing in it can be changed.")); + return true; + } + if (destroys && delegation.access === "organize") { + toast.show(translate("You can file and move mail in this account, but not delete it.")); + return true; + } + return false; + }; + const actions = useMemo( () => ({ archive: async (rows?: Id[]) => { + if (refused(false)) return; const t = await targetIds(rows); if (!t.length) return; await useMail.getState().archive(t); afterAction(true); }, trash: async (rows?: Id[]) => { + if (refused(true)) return; const t = await targetIds(rows); if (!t.length) return; const mail = useMail.getState(); @@ -315,6 +337,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; afterAction(true); }, spam: async (rows?: Id[]) => { + if (refused(true)) return; const t = await targetIds(rows); if (!t.length) return; const mail = useMail.getState(); @@ -324,23 +347,28 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; afterAction(true); }, read: async (read: boolean, rows?: Id[]) => { + if (refused(false)) return; const t = await targetIds(rows); if (t.length) await useMail.getState().markRead(t, read); useMail.getState().clearSelection(); }, star: async (on: boolean, rows?: Id[]) => { + if (refused(false)) return; const t = await targetIds(rows); if (t.length) await useMail.getState().star(t, on); }, move: async (rows?: Id[]) => { + if (refused(false)) return; const t = await targetIds(rows); if (t.length) setMovePicker({ ids: t }); }, label: async (rows: Id[] | undefined, anchor: { x: number; y: number }) => { + if (refused(false)) return; const t = await targetIds(rows); if (t.length) setLabelPicker({ ids: t, anchor }); }, moveTo: async (ids: Id[], mailboxId: Id) => { + if (refused(false)) return; await useMail.getState().move(ids, mailboxId); afterAction(true); }, diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index d12f1bf..d9133df 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -35,7 +35,7 @@ import type { ListActions } from "./MessageList"; import { InviteCard } from "./InviteCard"; import { VCardCard } from "./VCardCard"; import { AddressList, useAddressMenu } from "./AddressMenu"; -import { useSession } from "@/store/session"; +import { useSession, useViewingDelegation } from "@/store/session"; import { useScheduled } from "@/store/scheduled"; import { formatScheduleTime } from "@/lib/schedule"; import { mdnDecision, refusalText } from "@/lib/mdn"; @@ -134,6 +134,8 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true); const scheduled = useScheduled((s) => s.pending[e.id]); const receipt = useMemo(() => mdnDecision(e), [e]); + // inbuxa AL-4: a locked account sends no read receipts, not even by a delegate + const lockedInView = useViewingDelegation() !== null; const [receiptDone, setReceiptDone] = useState<"sending" | "dismissed" | null>(null); const cancelScheduled = useScheduled((s) => s.cancel); @@ -384,7 +386,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn {receiptRequested && <>
{translate("Receipt")}
{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}
} )} - {receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && ( + {receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && !lockedInView && (
diff --git a/web/src/views/mail/ThreadView.tsx b/web/src/views/mail/ThreadView.tsx index a9dfc65..585e3dc 100644 --- a/web/src/views/mail/ThreadView.tsx +++ b/web/src/views/mail/ThreadView.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useViewingDelegation } from "@/store/session"; import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MailPlus, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download , Paperclip} from "lucide-react"; import { useMail } from "@/store/mail"; import { visibleMessages } from "@/lib/openMessage"; @@ -131,8 +132,11 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h ); // Mark as read after delay + // inbuxa AL-6: never in a locked account handed over to read: reading it + // changes nothing there, not even $seen + const readOnly = useViewingDelegation()?.access === "read"; useEffect(() => { - if (!messages.length) return; + if (!messages.length || readOnly) return; const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e)).map((e) => e.id); if (!unread.length || settings.markReadDelay < 0) return; if (markTimer.current) window.clearTimeout(markTimer.current); @@ -141,7 +145,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h if (markTimer.current) window.clearTimeout(markTimer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [messages.map((m) => m.id + (m.keywords.$seen ? "1" : "0")).join(","), settings.markReadDelay]); + }, [messages.map((m) => m.id + (m.keywords.$seen ? "1" : "0")).join(","), settings.markReadDelay, readOnly]); /* * Open on the first unread message rather than the newest one (#87). From 4fcc8dd1b9783dee4100525b0e7df2bb6ef80ddb Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 15:50:51 -0700 Subject: [PATCH 2/2] Show every folder of a locked account in view Folder subscriptions are the reader's own and they have none in an account handed to them, so only Inbox showed. Every folder shows while a locked account is in view, and Hide from list is gone there. --- web/src/views/mail/MailboxTree.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 858532e..900870f 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -18,6 +18,7 @@ import { canPlaceFolder, compareFolders, neighbour, placeFolder, type Placement import { haptic, useTouchRow } from "@/lib/input/touch"; import { plural, t } from "@/lib/i18n"; import { mailboxDisplayName } from "@/lib/mailbox/mailboxName"; +import { useSession } from "@/store/session"; // Loaded when first opened: it is not needed to show mail, and it is not small. const ShareDialog = lazy(() => import("../settings/ShareDialog").then((m) => ({ default: m.ShareDialog }))); @@ -99,7 +100,10 @@ export function MailboxTree() { toast.error(t("Could not move “{name}”: {reason}", { name: mailboxDisplayName(mailboxes[id]!), reason: (err as Error).message })); } }; - const shown = (m: Mailbox) => showHidden || m.isSubscribed || m.role === "inbox"; + // inbuxa AL-7: subscriptions are the reader's own, and they have none in a + // locked account handed to them, so every folder there shows. + const viewingOther = useSession((s) => s.viewing !== null); + const shown = (m: Mailbox) => showHidden || viewingOther || m.isSubscribed || m.role === "inbox"; // Tree: in `compareFolders` order at every level (Inbox, then any order the // user has dragged into place, then the special folders, then A–Z), @@ -474,6 +478,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, } function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove, onStep, canStep }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void; onMove: () => void; onStep: (direction: "up" | "down") => void; canStep: (direction: "up" | "down") => boolean }) { + const viewingOther = useSession((s) => s.viewing !== null); const shared = Object.keys(m.shareWith ?? {}).length > 0; const [, navigate] = useLocation(); const colors = useSettings((s) => s.settings.folderColors); @@ -542,7 +547,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove, onSt {/* The way to reorder without a drag: from the keyboard, and on touch. */} } label={t("Move up")} onClick={() => onStep("up")} disabled={!canStep("up")} /> } label={t("Move down")} onClick={() => onStep("down")} disabled={!canStep("down")} /> - : } label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} /> + {!viewingOther && : } label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />} {/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and stores the share, and it never reaches the other account -- its own docs list calendars, address books and files as shareable and not mail