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/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." } } 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