From 3e4e207636f98d5689730ba5285ef8e1d3df2041 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 15:20:15 -0700 Subject: [PATCH 1/2] Shared mailboxes: a page in Directory for support@, legal@ and the like MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server can now make an account a shared mailbox: a lock of its own kind, where nobody signs in, the people assigned open it beside their own mail at Read, Organize or Full, with or without sending as it, and its automatic replies keep working (multi-account spec, MA-S). This is its page, Management › Directory › Shared mailboxes, shown to whoever may see account locks. - Add a shared mailbox: choose the account and the people (up to 100); no reason is asked for. - People…: change who has it and at what level. - Stop sharing: it becomes an ordinary account again, after saying that anyone who knows its password can then sign in. Locked accounts lists only locks now, the account page shows a neutral "Shared mailbox" banner instead of the red lock one, and the directory offers no Unlock for a shared mailbox. The delegate editor takes its limit, hint and button label from the page that uses it. An older server sends no kind, and every lock stays a lock. The menu entry comes from the server's layout (server PR alongside). New strings are inline English fallbacks, as the lock page's are. typecheck, eslint and vitest (61 files, 594 tests) pass. --- src/components/layout/MainContent.tsx | 8 + src/features/lock/AccountLockBanner.tsx | 23 +- src/features/lock/AccountLocksPage.tsx | 3 +- src/features/lock/LockDialogs.tsx | 28 +- src/features/lock/SharedMailboxesPage.tsx | 308 ++++++++++++++++++++++ src/features/lock/accountLock.test.ts | 14 +- src/features/lock/accountLock.ts | 45 +++- src/features/lock/useDirectoryLocks.tsx | 3 +- src/lib/layout.ts | 4 + 9 files changed, 418 insertions(+), 18 deletions(-) create mode 100644 src/features/lock/SharedMailboxesPage.tsx diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 1d4688e..00e34b0 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -130,6 +130,11 @@ const AccountLocksPage = lazyFeature( () => import('@/features/lock/AccountLocksPage'), (m) => m.AccountLocksPage, ); +// inbuxa: Management › Directory › Shared mailboxes (multi-account spec, MA-S4). +const SharedMailboxesPage = lazyFeature( + () => import('@/features/lock/SharedMailboxesPage'), + (m) => m.SharedMailboxesPage, +); const LegalHoldsPage = lazyFeature( () => import('@/features/hold/LegalHoldsPage'), (m) => m.LegalHoldsPage, @@ -233,6 +238,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (componentName === 'AccountLocks') { return ; } + if (componentName === 'SharedMailboxes') { + return ; + } if (componentName === 'LegalHolds') { return ; } diff --git a/src/features/lock/AccountLockBanner.tsx b/src/features/lock/AccountLockBanner.tsx index fd55991..11ea7bc 100644 --- a/src/features/lock/AccountLockBanner.tsx +++ b/src/features/lock/AccountLockBanner.tsx @@ -11,7 +11,7 @@ */ import { useTranslation } from 'react-i18next'; -import { Lock } from 'lucide-react'; +import { Lock, Users } from 'lucide-react'; import { useAccountStore } from '@/stores/accountStore'; import { useAccountLock } from './useAccountLock'; import { accessLabel } from './labels'; @@ -22,6 +22,27 @@ export function AccountLockBanner({ accountId }: { accountId: string }) { const lock = useAccountLock(accountId, canGet); if (!canGet || !lock) return null; + // MA-S: a shared mailbox is a lock of its own kind, and is nobody's trouble + if (lock.kind === 'sharedMailbox') { + return ( +
+
+ +
+

+ {t('sharedMailbox.bannerTitle', 'Shared mailbox: receives mail; nobody signs in to it.')} +

+ {lock.delegates.length > 0 && ( +

+ {t('sharedMailbox.bannerPeople', 'Opened by')}{' '} + {lock.delegates.map((d) => `${d.name ?? d.accountId} (${accessLabel(t, d.access)})`).join(', ')} +

+ )} +
+
+
+ ); + } return (
diff --git a/src/features/lock/AccountLocksPage.tsx b/src/features/lock/AccountLocksPage.tsx index de5912d..6c432b5 100644 --- a/src/features/lock/AccountLocksPage.tsx +++ b/src/features/lock/AccountLocksPage.tsx @@ -40,7 +40,8 @@ export function AccountLocksPage() { const controller = new AbortController(); fetchLocks(undefined, controller.signal) .then((locks) => { - if (!controller.signal.aborted) setLoad({ kind: 'ready', locks }); + // Shared mailboxes have a page of their own (MA-S) + if (!controller.signal.aborted) setLoad({ kind: 'ready', locks: locks.filter((l) => l.kind === 'lock') }); }) .catch((e: unknown) => { if (controller.signal.aborted) return; diff --git a/src/features/lock/LockDialogs.tsx b/src/features/lock/LockDialogs.tsx index 56a57dc..1c9c8d3 100644 --- a/src/features/lock/LockDialogs.tsx +++ b/src/features/lock/LockDialogs.tsx @@ -69,7 +69,20 @@ function ReasonField({ value, onChange }: { value: string; onChange: (v: string) ); } -function DelegateEditor({ delegates, onChange }: { delegates: Delegate[]; onChange: (d: Delegate[]) => void }) { +export function DelegateEditor({ + delegates, + onChange, + max = 10, + hint, + addLabel, +}: { + delegates: Delegate[]; + onChange: (d: Delegate[]) => void; + /** Most people this kind of lock may have (MA-S: 100 for a shared mailbox). */ + max?: number; + hint?: string; + addLabel?: string; +}) { const { t } = useTranslation(); const schema = useSchemaStore((s) => s.schema); const update = (i: number, patch: Partial) => @@ -77,10 +90,11 @@ function DelegateEditor({ delegates, onChange }: { delegates: Delegate[]; onChan return (
- {t( - 'lock.delegatesHint', - 'People who can open this account, beside their own mail, while it is locked. Everything they do there is recorded.', - )} + {hint ?? + t( + 'lock.delegatesHint', + 'People who can open this account, beside their own mail, while it is locked. Everything they do there is recorded.', + )}
{delegates.map((d, i) => (
@@ -142,14 +156,14 @@ function DelegateEditor({ delegates, onChange }: { delegates: Delegate[]; onChan
))} - {delegates.length < 10 && ( + {delegates.length < max && ( )}
diff --git a/src/features/lock/SharedMailboxesPage.tsx b/src/features/lock/SharedMailboxesPage.tsx new file mode 100644 index 0000000..e15eb98 --- /dev/null +++ b/src/features/lock/SharedMailboxesPage.tsx @@ -0,0 +1,308 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: Management › Directory › Shared mailboxes (multi-account spec, + * MA-S4). Role addresses such as support@ or legal@ that belong to no one: + * nobody signs in to them, and the people assigned open them beside their + * own mail, at the level given here. A shared mailbox is a lock of its own + * kind on the server, so the people are a lock's delegates. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, Pencil, Plus, Users, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { PageHeader } from '@/components/common/PageHeader'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { ObjectPicker } from '@/components/common/ObjectPicker'; +import { useAccountStore } from '@/stores/accountStore'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { toast } from '@/hooks/use-toast'; +import { + delegateProblem, + fetchLocks, + lockAccount, + LocksUnavailable, + maxDelegates, + unlockAccount, + updateDelegates, + type AccountLock, + type Delegate, +} from './accountLock'; +import { DelegateEditor } from './LockDialogs'; +import { accessLabel } from './labels'; + +type Load = { kind: 'loading' } | { kind: 'ready'; mailboxes: AccountLock[] } | { kind: 'error'; message: string }; + +const MAX = maxDelegates('sharedMailbox'); + +function PeopleDialog({ + existing, + onClose, + onDone, +}: { + existing?: AccountLock; + onClose: () => void; + onDone: () => void; +}) { + const { t } = useTranslation(); + const schema = useSchemaStore((s) => s.schema); + const [accountId, setAccountId] = useState(existing?.id ?? ''); + const [people, setPeople] = useState(existing?.delegates ?? []); + const [busy, setBusy] = useState(false); + const problem = accountId ? delegateProblem(people, accountId, 'sharedMailbox') : null; + const ready = !!accountId && !problem; + + const save = async () => { + setBusy(true); + try { + if (existing) await updateDelegates(existing.id, people, ''); + else await lockAccount(accountId, '', people, 'sharedMailbox'); + toast({ + title: existing + ? t('sharedMailbox.updated', 'People changed') + : t('sharedMailbox.created', 'Shared mailbox ready. Nobody can sign in to it now.'), + }); + onDone(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('sharedMailbox.failed', 'Nothing changed'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + + return ( + !open && onClose()}> + + + + {existing + ? t('sharedMailbox.editTitle', 'Who has {{name}}', { name: existing.name }) + : t('sharedMailbox.newTitle', 'Add a shared mailbox')} + + {!existing && ( + + {t( + 'sharedMailbox.newBody', + 'Choose the account for the address, such as support@. From now on nobody signs in to it: the people below open it beside their own mail, and what they send goes out as it. Its automatic replies keep working. Make the account first under Accounts if it doesn’t exist yet.', + )} + + )} + +
+ {!existing && ( + + )} + + {problem &&

{problem}

} +
+ + + + +
+
+ ); +} + +function StopDialog({ mailbox, onClose, onDone }: { mailbox: AccountLock; onClose: () => void; onDone: () => void }) { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + const run = async () => { + setBusy(true); + try { + await unlockAccount(mailbox.id, ''); + toast({ title: t('sharedMailbox.stopped', 'No longer shared') }); + onDone(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('sharedMailbox.failed', 'Nothing changed'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + return ( + !open && onClose()}> + + + {t('sharedMailbox.stopTitle', 'Stop sharing {{name}}', { name: mailbox.name })} + + {t( + 'sharedMailbox.stopBody', + 'The people here lose it, and it becomes an ordinary account again: anyone who knows its password can sign in. Its mail stays where it is.', + )} + + + + + + + + + ); +} + +export function SharedMailboxesPage() { + const { t } = useTranslation(); + const canCreate = useAccountStore((s) => s.hasPermission('sysAccountLockCreate')); + const canUpdate = useAccountStore((s) => s.hasPermission('sysAccountLockUpdate')); + const canDestroy = useAccountStore((s) => s.hasPermission('sysAccountLockDestroy')); + const [load, setLoad] = useState({ kind: 'loading' }); + const [fetches, setFetches] = useState(0); + const [adding, setAdding] = useState(false); + const [editing, setEditing] = useState(null); + const [stopping, setStopping] = useState(null); + const refetch = useCallback(() => setFetches((n) => n + 1), []); + + useEffect(() => { + const controller = new AbortController(); + fetchLocks(undefined, controller.signal) + .then((locks) => { + if (!controller.signal.aborted) + setLoad({ kind: 'ready', mailboxes: locks.filter((l) => l.kind === 'sharedMailbox') }); + }) + .catch((e: unknown) => { + if (controller.signal.aborted) return; + setLoad({ + kind: 'error', + message: + e instanceof LocksUnavailable + ? t('sharedMailbox.unavailable', 'This server doesn’t offer shared mailboxes.') + : e instanceof Error + ? e.message + : String(e), + }); + }); + return () => controller.abort(); + }, [fetches, t]); + + const done = () => { + setAdding(false); + setEditing(null); + setStopping(null); + refetch(); + }; + + return ( +
+ setAdding(true)}> + + {t('sharedMailbox.addAction', 'Add a shared mailbox…')} + + ) + } + /> + + {load.kind === 'loading' && } + {load.kind === 'error' && ( +
{load.message}
+ )} + {load.kind === 'ready' && load.mailboxes.length === 0 && ( +
+ {t('sharedMailbox.none', 'No shared mailboxes yet.')} +
+ )} + {load.kind === 'ready' && + load.mailboxes.map((mailbox) => ( +
+
+
+ + {mailbox.name} +
+
+ {canUpdate && ( + + )} + {canDestroy && ( + + )} +
+
+ {mailbox.delegates.length === 0 ? ( +

{t('sharedMailbox.noPeople', 'Nobody has it yet.')}

+ ) : ( +
    + {mailbox.delegates.map((d) => ( +
  • + {d.name ?? d.accountId} + {accessLabel(t, d.access)} + {d.sendAs && {t('lock.sendsAs', 'sends as')}} +
  • + ))} +
+ )} +
+ ))} + + {adding && setAdding(false)} onDone={done} />} + {editing && setEditing(null)} onDone={done} />} + {stopping && setStopping(null)} onDone={done} />} +
+ ); +} diff --git a/src/features/lock/accountLock.test.ts b/src/features/lock/accountLock.test.ts index 3f8f49c..ef4386a 100644 --- a/src/features/lock/accountLock.test.ts +++ b/src/features/lock/accountLock.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it } from 'vitest'; -import { delegateProblem, untilFromServer, untilToServer, type Delegate } from './accountLock'; +import { delegateProblem, maxDelegates, untilFromServer, untilToServer, type Delegate } from './accountLock'; const d = (patch: Partial): Delegate => ({ accountId: 'b', @@ -48,3 +48,15 @@ describe('until', () => { expect(untilFromServer('not a date')).toBe(''); }); }); + +describe('shared mailboxes (MA-S)', () => { + it('hold up to 100 people, where a lock holds 10', () => { + const people = (n: number) => Array.from({ length: n }, (_, i) => d({ accountId: `a${i}` })); + expect(maxDelegates('lock')).toBe(10); + expect(maxDelegates('sharedMailbox')).toBe(100); + expect(delegateProblem(people(11), 'z')).toMatch(/At most 10/); + expect(delegateProblem(people(11), 'z', 'sharedMailbox')).toBeNull(); + expect(delegateProblem(people(101), 'z', 'sharedMailbox')).toMatch(/At most 100 people/); + expect(delegateProblem([d({ accountId: '' })], 'z', 'sharedMailbox')).toMatch(/each person/); + }); +}); diff --git a/src/features/lock/accountLock.ts b/src/features/lock/accountLock.ts index df20576..80faa93 100644 --- a/src/features/lock/accountLock.ts +++ b/src/features/lock/accountLock.ts @@ -20,6 +20,18 @@ const OBJECT = 'inbuxa:AccountLock'; export type Access = 'read' | 'organize' | 'full'; +/** + * What a lock is for (multi-account spec, MA-S): a locked account, or a shared + * mailbox such as support@. A shared mailbox needs no reason, holds up to 100 + * people, and runs its own automatic replies. + */ +export type LockKind = 'lock' | 'sharedMailbox'; + +/** Most people a lock of this kind may have. */ +export function maxDelegates(kind: LockKind): number { + return kind === 'sharedMailbox' ? 100 : 10; +} + export const ACCESS_LEVELS: Access[] = ['read', 'organize', 'full']; export interface Delegate { @@ -34,6 +46,7 @@ export interface Delegate { export interface AccountLock { id: string; name: string; + kind: LockKind; reason: string; /** RFC 3339. */ lockedAt: string; @@ -79,13 +92,17 @@ export function untilFromServer(value: string | null | undefined): string { } /** What's wrong with a delegate list before it's sent, or null (AL-5, AL-8). */ -export function delegateProblem(delegates: Delegate[], lockedId: string): string | null { - if (delegates.length > 10) return 'At most 10 delegates.'; +export function delegateProblem(delegates: Delegate[], lockedId: string, kind: LockKind = 'lock'): string | null { + const max = maxDelegates(kind); + // A shared mailbox's delegates are just the people in it (MA-S4) + const who = kind === 'sharedMailbox' ? 'person' : 'delegate'; + if (delegates.length > max) return `At most ${max} ${who === 'person' ? 'people' : 'delegates'}.`; const seen = new Set(); for (const d of delegates) { - if (!d.accountId) return 'Choose an account for each delegate.'; - if (d.accountId === lockedId) return 'An account can’t be its own delegate.'; - if (seen.has(d.accountId)) return 'A delegate is listed twice.'; + if (!d.accountId) return `Choose an account for each ${who}.`; + if (d.accountId === lockedId) + return kind === 'sharedMailbox' ? 'A shared mailbox can’t have itself as a person.' : 'An account can’t be its own delegate.'; + if (seen.has(d.accountId)) return `A ${who} is listed twice.`; seen.add(d.accountId); if (d.sendAs && d.access === 'read') return 'Sending as the account needs organize or full access: the message is made in its Drafts first.'; @@ -107,6 +124,8 @@ function fromServer(raw: Record): AccountLock { return { id: String(raw.id ?? ''), name: String(raw.name ?? ''), + // An older server sends no kind: every lock is a lock + kind: raw.kind === 'sharedMailbox' ? 'sharedMailbox' : 'lock', reason: String(raw.reason ?? ''), lockedAt: String(raw.lockedAt ?? ''), lockedBy: String(raw.lockedBy ?? ''), @@ -132,14 +151,26 @@ export async function fetchLocks(accountId?: string, signal?: AbortSignal): Prom return ((result as { list?: Record[] }).list ?? []).map(fromServer); } -export async function lockAccount(accountId: string, reason: string, delegates: Delegate[]): Promise { +export async function lockAccount( + accountId: string, + reason: string, + delegates: Delegate[], + kind: LockKind = 'lock', +): Promise { const responses = await jmapRequest( [ [ `${OBJECT}/set`, { accountId: getAccountId('x:Account'), - create: { l: { accountId, reason: reason.trim(), delegates: toServer(delegates) } }, + create: { + l: { + accountId, + ...(kind === 'lock' ? {} : { kind }), + ...(reason.trim() ? { reason: reason.trim() } : {}), + delegates: toServer(delegates), + }, + }, }, '0', ], diff --git a/src/features/lock/useDirectoryLocks.tsx b/src/features/lock/useDirectoryLocks.tsx index b65ea8f..9653575 100644 --- a/src/features/lock/useDirectoryLocks.tsx +++ b/src/features/lock/useDirectoryLocks.tsx @@ -65,7 +65,8 @@ export function useDirectoryLocks(active: boolean) { const id = String(item.id); const lock = locks.get(id); if (lock) { - if (!canDestroy) return null; + // A shared mailbox is managed on its own page (MA-S4) + if (!canDestroy || lock.kind === 'sharedMailbox') return null; return ( <> diff --git a/src/lib/layout.ts b/src/lib/layout.ts index 45e1117..bcdaff4 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -111,6 +111,10 @@ function checkSpecialLink( if (viewName === 'CustomComponent/AccountLocks') { return { visible: canGet ? canGet('sysAccountLock') : true, enterprise: false }; } + // inbuxa: shared mailboxes are locks of their own kind (MA-S4) + if (viewName === 'CustomComponent/SharedMailboxes') { + return { visible: canGet ? canGet('sysAccountLock') : true, enterprise: false }; + } // INBUXA: a page this console can't draw is hidden. A server newer than the // console lists pages the console doesn't know yet, and counting them as -- 2.54.0 From d7a3c64b67bb0c5199381c2f9794133b337a7568 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 15:34:20 -0700 Subject: [PATCH 2/2] Add the shared mailbox strings to en.json i18n:check found the page's 25 new strings missing from the English catalog; added with tools/i18n-extract.mjs. --- src/i18n/en.json | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/i18n/en.json b/src/i18n/en.json index e840d08..59bab00 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1971,5 +1971,32 @@ "guideLimits": "Set up sending and receiving limits", "guideDirectory": "Connect a sign-in directory", "guideCertificates": "Set up automatic certificates" + }, + "sharedMailbox": { + "bannerTitle": "Shared mailbox: receives mail; nobody signs in to it.", + "bannerPeople": "Opened by", + "updated": "People changed", + "created": "Shared mailbox ready. Nobody can sign in to it now.", + "failed": "Nothing changed", + "editTitle": "Who has {{name}}", + "newTitle": "Add a shared mailbox", + "newBody": "Choose the account for the address, such as support@. From now on nobody signs in to it: the people below open it beside their own mail, and what they send goes out as it. Its automatic replies keep working. Make the account first under Accounts if it doesn’t exist yet.", + "account": "Account", + "chooseAccount": "Choose the account, such as support@", + "peopleHint": "People who open this mailbox beside their own mail. Each message sent as it is recorded with who sent it.", + "addPerson": "Add a person", + "createGo": "Make it shared", + "stopped": "No longer shared", + "stopTitle": "Stop sharing {{name}}", + "stopBody": "The people here lose it, and it becomes an ordinary account again: anyone who knows its password can sign in. Its mail stays where it is.", + "stopGo": "Stop sharing", + "unavailable": "This server doesn’t offer shared mailboxes.", + "title": "Shared mailboxes", + "subtitle": "Addresses like support@ or legal@ that belong to no one person. Nobody signs in to them; the people you choose open them beside their own mail, and replies go out from the shared address.", + "addAction": "Add a shared mailbox…", + "none": "No shared mailboxes yet.", + "editAction": "People…", + "stopAction": "Stop sharing…", + "noPeople": "Nobody has it yet." } } -- 2.54.0