Merge pull request 'Open locked accounts handed to you, beside your own mail' (#22) from feature/delegated-accounts into main
ci / version (push) Skipped
ci / node (push) Successful in 2m45s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 15s

This commit was merged in pull request #22.
This commit is contained in:
jcoffey-dev committed 2026-09-27 22:55:38 +00:00
commit d0f7c6ed20
23 files changed
+685 -26

No files matched your search

+57 -5
View File
@@ -1,7 +1,7 @@
import { Fragment, lazy, Suspense, useEffect, useState } from "react";
import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react";
import { Route, Switch, Redirect, useLocation, Router } from "wouter";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail";
import { scheduleSupported, useScheduled } from "@/store/scheduled";
import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
@@ -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);
+67
View File
@@ -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<JmapSession, "accounts">;
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<Delegation> } | 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";
}
+3 -2
View File
@@ -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<void> {
}
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) {
+15
View File
@@ -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) ──────────────
+15
View File
@@ -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) ──────────────
+15
View File
@@ -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) ──────────────
+15
View File
@@ -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) ──────────────
+15
View File
@@ -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) ──────────────
+15
View File
@@ -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) ──────────────
+15
View File
@@ -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) ──────────────
+15
View File
@@ -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) ──────────────
+15
View File
@@ -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) ──────────────
@@ -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: "[email protected]",
isPersonal: false,
isReadOnly: access === "read",
accountCapabilities: {
[CAP.mail]: {},
"urn:inbuxa:jmap": { delegation: { locked: true, access, sendAs, until: null } },
},
});
const sessionWith = (locked: Record<string, unknown> | null) =>
({
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} },
accounts: {
own: { name: "[email protected]", isPersonal: true, accountCapabilities: { [CAP.mail]: {} } },
shared: { name: "[email protected]", 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, unknown>, 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("[email protected]");
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(/[email protected]/);
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();
});
});
+27
View File
@@ -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<ComposeState>((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<ComposeState>((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<ComposeState>((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<ComposeState>((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<ComposeState>((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<ComposeState>((set, get) => ({
},
forwardAsAttachment(email) {
if (refuseCompose()) return "";
const accountId = useMail.getState().accountId;
const key = get().open({
subject: replySubject(email.subject, "Fwd"),
+61 -3
View File
@@ -104,6 +104,15 @@ export const useMail = create<MailState>((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
}
async function notifyNewMail(created: Id[], get: () => MailState) {
const s = settings();
const inbox = get().roleId("inbox");
if (!inbox) return;
const emails = await get().getEmails(created);
announceNewMail(emails, inbox);
}
/**
* The reader's own account while a 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<void> {
const away = ownWhileAway;
if (!away?.inbox || !away.state) return;
try {
const changes = await client.call<ChangesResponse>("Email/changes", {
accountId: away.accountId,
sinceState: away.state,
maxChanges: 50,
});
if (ownWhileAway !== away) return;
away.state = changes.newState;
if (!changes.created.length) return;
const got = await client.call<GetResponse<Email>>("Email/get", {
accountId: away.accountId,
ids: changes.created,
properties: LIST_PROPS,
});
announceNewMail(got.list, away.inbox);
} catch {
/* the next change tries again */
}
}
export function ownAccountAway(): Id | null {
return ownWhileAway?.accountId ?? null;
}
/** The reader's own inbox, whatever account is in view (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);
});
+46 -3
View File
@@ -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<void>;
refresh(): Promise<void>;
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<SessionState>((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<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 });
set({ status: "anonymous", session: null, accountId: null, viewing: null });
},
refresh() {
@@ -109,7 +123,14 @@ 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
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<SessionState>((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<SessionState>) => void) {
startIdleLogout(() => void useSession.getState().logout());
}
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
set({ status: "authenticated", session: s, accountId, error: null });
set({ status: "authenticated", session: s, accountId, viewing: null, 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);
}
+14
View File
@@ -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; }
+53 -7
View File
@@ -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 (
<div className="app">
<div className={`app ${viewing ? "delegated" : ""}`}>
<DelegatedBar />
<header className="topbar">
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}>
<MenuIcon size={22} />
</button>
<Link href="/mail" className="brand">
<Link href="/mail" className={`brand ${viewing ? "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")} />}
{/* 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 }) {
</div>
</div>
<MenuSep />
{delegated.length > 0 && (
<>
<div className="hint" style={{ padding: "4px 10px" }}>{t("Mail to show")}</div>
<MenuItem
icon={viewing ? <Mail size={16} /> : <Check size={16} />}
label={t("My mail")}
active={!viewing}
onClick={() => switchTo(null)}
/>
{delegated.map((account) => (
<MenuItem
key={account.id}
icon={viewing === account.id ? <Check size={16} /> : <Lock size={16} />}
label={<span className="notranslate" translate="no">{account.name}</span>}
active={viewing === account.id}
onClick={() => switchTo(account.id)}
/>
))}
<MenuSep />
</>
)}
{/* 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. */}
<button
className="compose-btn"
hidden={composeOff}
onClick={() => {
if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event"));
else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact"));
+50
View File
@@ -0,0 +1,50 @@
import { Lock } from "lucide-react";
import { useLocation } from "wouter";
import { useSession, useViewingDelegation } from "@/store/session";
import { t } from "@/lib/i18n";
import type { DelegationAccess } from "@/lib/delegation";
export function accessText(access: DelegationAccess): string {
switch (access) {
case "read":
return t("Read only");
case "organize":
return t("Read and organize");
case "full":
return t("Full access");
}
}
/**
* inbuxa AL-7: while a locked account's mail is in view, a red bar across
* the whole app says so, with the way back. Red, not the palette's accent: a
* fixed color that reads the same in every palette and in dark mode, so it
* can't be mistaken for part of a theme.
*/
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 [, navigate] = useLocation();
if (!viewing || !delegation) return null;
return (
<div className="delegated-bar" role="status">
<Lock size={15} aria-hidden />
<span className="grow truncate">
{t("Locked account:")} <strong className="notranslate" translate="no">{name}</strong>
{" · "}
{accessText(delegation.access)}
{delegation.sendAs ? ` · ${t("You can send as this account")}` : ""}
</span>
<button
type="button"
onClick={() => {
useSession.getState().view(null);
navigate("/mail");
}}
>
{t("Back to my mail")}
</button>
</div>
);
}
+28
View File
@@ -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);
},
+7 -2
View File
@@ -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. */}
<MenuItem icon={<ArrowUp size={16} />} label={t("Move up")} onClick={() => onStep("up")} disabled={!canStep("up")} />
<MenuItem icon={<ArrowDown size={16} />} label={t("Move down")} onClick={() => onStep("down")} disabled={!canStep("down")} />
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
{!viewingOther && <MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />}
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
stores the share, and it never reaches the other account -- its own
docs list calendars, address books and files as shareable and not mail
+4 -2
View File
@@ -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 && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}</dd></>}
</dl>
)}
{receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && (
{receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && !lockedInView && (
<div className="receipt-banner" style={{ margin: "0 16px 8px" }}>
<CheckCheck size={16} />
<span className="grow">
+6 -2
View File
@@ -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).