From 11d85cd7596f03146fd9c68a4ea3bff31d5ab2fe Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 21 Sep 2026 13:39:56 -0700 Subject: [PATCH] Each tenant's page carries its legacy protocols switch (LP-9 to LP-18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card above the tenant form: whether legacy mail protocols are on or off for the organization, a one-click "Turn legacy protocols back on", and "Turn off legacy protocols…", which opens -- before anything can change -- the impact panel with the tenant's own people (LP-15), the statement at tenant scope and the typed confirmation (LP-16, LP-17). The statement reads "everyone in {organization}" and names no ports and no firewall: a tenant's switch closes nothing, other tenants share the ports (LP-13). It reads and turns inbuxa:TenantProtocolPolicy, with the domain permissions the server checks for it. It shows on the read-only tenant page too: a tenant administrator reads its tenant without changing it (MT-12) and may still turn the switch. Turning it back on while the server has legacy protocols off is refused by the server, and the refusal is shown. The banner (LP-18) falls back to the tenant's switch where the server's can't be read -- inside a tenant -- and reads "off for your organization". The impact panel, statement and confirmation move to parts.tsx, shared by Hardening and the tenant card; the statement takes its scope. Checked by hand against a local server with a tenant, two of its users and the admin signed in over IMAP: the tenant card's panel lists the tenant's two users and not the admin; the statement reads "everyone in Example Co" with no ports or firewall; turning it off makes the tenant's user get "NO [ALERT] Your organization allows only INBUXA webmail and JMAP apps" over IMAP; one click turns it back on; Hardening's panel lists all three. Not checked by hand: the banner as a tenant administrator sees it. --- src/components/layout/MainContent.tsx | 23 +- .../hardening/LegacyProtocolsBanner.tsx | 52 +++-- .../hardening/LegacyProtocolsPage.tsx | 164 +------------- .../hardening/TenantLegacyProtocols.tsx | 138 ++++++++++++ src/features/hardening/parts.tsx | 201 ++++++++++++++++++ src/features/hardening/protocolPolicy.test.ts | 16 ++ src/features/hardening/protocolPolicy.ts | 62 ++++++ 7 files changed, 484 insertions(+), 172 deletions(-) create mode 100644 src/features/hardening/TenantLegacyProtocols.tsx create mode 100644 src/features/hardening/parts.tsx diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 642eefb..01068ab 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -51,6 +51,10 @@ const LegacyProtocolsPage = lazyFeature( () => import('@/features/hardening/LegacyProtocolsPage'), (m) => m.LegacyProtocolsPage, ); +const TenantLegacyProtocols = lazyFeature( + () => import('@/features/hardening/TenantLegacyProtocols'), + (m) => m.TenantLegacyProtocols, +); interface MainContentProps { viewName?: string; @@ -150,10 +154,23 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti return ; } const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update'); - if (!canUpdate) { - return ; + const page = canUpdate ? ( + + ) : ( + + ); + // INBUXA: a tenant's page carries its legacy protocols switch (LP-9). A + // tenant admin reads its tenant without changing it (MT-12), and may + // still turn the switch, so it shows on the read-only page too. + if (resolved.objectName === 'x:Tenant') { + return ( +
+ + {page} +
+ ); } - return ; + return page; } return ; diff --git a/src/features/hardening/LegacyProtocolsBanner.tsx b/src/features/hardening/LegacyProtocolsBanner.tsx index ce1a3bf..f737611 100644 --- a/src/features/hardening/LegacyProtocolsBanner.tsx +++ b/src/features/hardening/LegacyProtocolsBanner.tsx @@ -15,24 +15,43 @@ import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ShieldCheck } from 'lucide-react'; import { useAccountStore } from '@/stores/accountStore'; -import { fetchProtocolPolicy } from './protocolPolicy'; +import { fetchProtocolPolicy, fetchTenantPolicy } from './protocolPolicy'; export const LEGACY_PROTOCOLS_VIEW = 'CustomComponent/LegacyProtocols'; export function LegacyProtocolsBanner() { const { t } = useTranslation(); - const canGet = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Get')); - const [off, setOff] = useState(false); + const canGetServer = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Get')); + const canGetTenant = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get')); + const [off, setOff] = useState(null); useEffect(() => { - if (!canGet) return; + if (!canGetServer && !canGetTenant) return; const controller = new AbortController(); - fetchProtocolPolicy(controller.signal) - .then((policy) => setOff(policy.legacyProtocols === 'disabled')) - // A banner is not worth an error: an older server simply has no switch. - .catch(() => setOff(false)); + const signal = controller.signal; + (async () => { + // The server's switch first. Inside a tenant it can't be read, and the + // tenant's own is the one to report (LP-18 at tenant scope). + try { + if (canGetServer) { + const policy = await fetchProtocolPolicy(signal); + if (!signal.aborted) setOff(policy.legacyProtocols === 'disabled' ? 'server' : null); + return; + } + } catch { + // Fall through to the tenant's. + } + try { + if (canGetTenant) { + const policy = await fetchTenantPolicy(null, signal); + if (!signal.aborted) setOff(policy.legacyProtocols === 'disabled' ? 'tenant' : null); + } + } catch { + // A banner is not worth an error: an older server simply has no switch. + } + })(); return () => controller.abort(); - }, [canGet]); + }, [canGetServer, canGetTenant]); if (!off) return null; @@ -42,11 +61,18 @@ export function LegacyProtocolsBanner() { {t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '} {t('legacyProtocols.bannerOff', 'off')}{' '} - {t('legacyProtocols.bannerTail', 'on this server. Only INBUXA webmail and JMAP apps can sign in.')} + {off === 'server' + ? t('legacyProtocols.bannerTail', 'on this server. Only INBUXA webmail and JMAP apps can sign in.') + : t( + 'legacyProtocols.bannerTailTenant', + 'for your organization. Only INBUXA webmail and JMAP apps can sign in.', + )} - - {t('legacyProtocols.review', 'Review')} - + {off === 'server' && ( + + {t('legacyProtocols.review', 'Review')} + + )} ); } diff --git a/src/features/hardening/LegacyProtocolsPage.tsx b/src/features/hardening/LegacyProtocolsPage.tsx index 0ada5a3..19b0cff 100644 --- a/src/features/hardening/LegacyProtocolsPage.tsx +++ b/src/features/hardening/LegacyProtocolsPage.tsx @@ -18,27 +18,20 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, Loader2, Lock, RotateCcw, ShieldCheck, ShieldOff } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { useAccountStore } from '@/stores/accountStore'; import { toast } from '@/hooks/use-toast'; import { cn } from '@/lib/utils'; import { - ago, - CONFIRM_PHRASE, describeListener, - impactEntries, fetchProtocolPolicy, - phraseMatches, PolicyUnavailable, protocolRows, updateProtocolPolicy, - type PolicyListener, type ProtocolPolicy, type ProtocolRow, - type RecentUse, } from './protocolPolicy'; +import { ConfirmTurnOff, ImpactPanel, Statement } from './parts'; type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string }; @@ -47,7 +40,6 @@ export function LegacyProtocolsPage() { const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Update')); const [load, setLoad] = useState({ kind: 'loading' }); const [confirming, setConfirming] = useState(false); - const [typed, setTyped] = useState(''); const [busy, setBusy] = useState(false); const loaded = useCallback( @@ -86,7 +78,6 @@ export function LegacyProtocolsPage() { // overruled by the SMTP lock (LP-21), which the selector already shows. await updateProtocolPolicy({ legacyProtocols }); setConfirming(false); - setTyped(''); await refresh(); } catch (e) { toast({ @@ -161,7 +152,7 @@ export function LegacyProtocolsPage() { {!off && policy.recentLegacyUse && } - {(off || confirming) && } + {(off || confirming) && } {!off && canUpdate && !confirming && ( - - - + /> )} ); @@ -320,113 +282,3 @@ function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) { ); } - -/** - * The impact panel (LP-15): who would notice, shown before anything can - * change. With nobody, it says so in one line. - */ -function ImpactPanel({ recent }: { recent: RecentUse[] }) { - const { t, i18n } = useTranslation(); - const entries = impactEntries(recent); - // Read once, when the panel appears: "2 days ago" needn't tick. - const [now] = useState(() => Date.now()); - if (entries.length === 0) { - return ( -

- {t('legacyProtocols.impactNone', 'No account used a legacy mail app in the last 30 days.')} -

- ); - } - return ( -
-

- - {t('legacyProtocols.impactCount', { - count: entries.length, - defaultValue_one: '1 account used a legacy mail app in the last 30 days.', - defaultValue_other: '{{count}} accounts used a legacy mail app in the last 30 days.', - })} - {' '} - {t('legacyProtocols.impactLead', 'Their mail apps will stop working the moment you turn this on:')} -

-
    - {entries.map((entry) => ( -
  • - {entry.name} - - {entry.protocols.join(', ')} · {ago(entry.lastUsedAt, now, i18n.language)} - -
  • - ))} -
-
- ); -} - -/** The statement (LP-16), at server scope, with the firewall note (LP-20). */ -function Statement({ listeners }: { listeners: PolicyListener[] }) { - const { t } = useTranslation(); - return ( -
-

- {t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')} -

-

- {t( - 'legacyProtocols.statementLead', - 'Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone on this server.', - )} -

-
    -
  • - {t( - 'legacyProtocols.statementApps', - 'Phone and desktop mail apps will stop receiving and sending mail. That’s iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.', - )} -
  • -
  • - {t( - 'legacyProtocols.statementFilters', - 'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.', - )} -
  • -
  • - {t( - 'legacyProtocols.statementUnaffected', - 'Incoming mail is not affected. Calendars and contacts are not affected.', - )} -
  • -
  • - {t( - 'legacyProtocols.statementWebmail', - 'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.', - )} -
  • -
-

- {listeners.length > 0 - ? t('legacyProtocols.statementPorts', 'The IMAP, POP3 and ManageSieve ports will close: {{list}}.', { - list: listeners.map(describeListener).join(', '), - }) - : t( - 'legacyProtocols.statementNoPorts', - 'No IMAP, POP3 or ManageSieve listeners are configured, so no ports will close.', - )} -

-

- {t( - 'legacyProtocols.statementSubmission', - 'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and INBUXA webmail (JMAP) are not affected and cannot be turned off here.', - )} -

-

- {t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}{' '} - {t( - 'legacyProtocols.firewallBody', - 'INBUXA stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.', - )} -

-

{t('legacyProtocols.statementUndo', 'You can turn legacy protocols back on at any time.')}

-
- ); -} diff --git a/src/features/hardening/TenantLegacyProtocols.tsx b/src/features/hardening/TenantLegacyProtocols.tsx new file mode 100644 index 0000000..c8d86bc --- /dev/null +++ b/src/features/hardening/TenantLegacyProtocols.tsx @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: one tenant's legacy mail protocols switch, on the tenant's page + * (legacy-protocols spec, LP-9 to LP-18 at tenant scope). + * + * It closes no port -- other tenants share them (LP-13) -- so the statement + * names no listener and carries no firewall note. It refuses sign-in over + * legacy protocols on the tenant's domains. Turning it off takes the typed + * phrase; turning it back on is one click, which the server refuses while + * it has legacy protocols off itself (LP-9), and says so. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useAccountStore } from '@/stores/accountStore'; +import { toast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; +import { getAccountId, jmapGet } from '@/services/jmap/client'; +import { fetchTenantPolicy, PolicyUnavailable, updateTenantPolicy, type TenantPolicy } from './protocolPolicy'; +import { ConfirmTurnOff, ImpactPanel, Statement } from './parts'; + +export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) { + const { t } = useTranslation(); + const canGet = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get')); + const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Update')); + const [policy, setPolicy] = useState(null); + const [organization, setOrganization] = useState(''); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + + const loaded = useCallback( + (fetching: Promise, signal?: AbortSignal) => + fetching + .then((p) => { + if (!signal?.aborted) setPolicy(p); + }) + .catch((e: unknown) => { + // An older server has no tenant switch: show nothing rather than an error. + if (!signal?.aborted && !(e instanceof PolicyUnavailable)) console.error(e); + }), + [], + ); + + useEffect(() => { + if (!canGet) return; + const controller = new AbortController(); + void loaded(fetchTenantPolicy(tenantId, controller.signal), controller.signal); + // The organization's name, for the statement. + jmapGet('x:Tenant', getAccountId('x:Tenant'), [tenantId], ['name'], controller.signal) + .then((responses) => { + const list = (responses[0]?.[1] as { list?: { name?: string }[] } | undefined)?.list; + if (!controller.signal.aborted && list?.[0]?.name) setOrganization(list[0].name); + }) + .catch(() => {}); + return () => controller.abort(); + }, [tenantId, canGet, loaded]); + + const turn = useCallback( + async (value: 'enabled' | 'disabled') => { + setBusy(true); + try { + await updateTenantPolicy(tenantId, value); + setConfirming(false); + await loaded(fetchTenantPolicy(tenantId)); + } catch (e) { + toast({ + variant: 'destructive', + title: t('legacyProtocols.failed', 'The switch did not change'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }, + [tenantId, loaded, t], + ); + + if (!policy) return null; + const off = policy.legacyProtocols === 'disabled'; + const name = organization || t('legacyProtocols.thisOrganization', 'this organization'); + + return ( + // Aligned with the tenant form beneath it. +
+
+
+ {off ? ( + + ) : ( + + )} +
+

{t('legacyProtocols.title', 'Legacy mail protocols')}

+

+ {off + ? t( + 'legacyProtocols.tenantOff', + 'Off for {{organization}}. Only INBUXA webmail and JMAP apps can sign in to its domains.', + { organization: name }, + ) + : t( + 'legacyProtocols.tenantOn', + 'On for {{organization}}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.', + { organization: name }, + )} +

+
+
+ {canUpdate && off && ( + + )} + {canUpdate && !off && !confirming && ( + + )} +
+ + {!off && confirming && ( + <> + {policy.recentLegacyUse && } + + void turn('disabled')} onCancel={() => setConfirming(false)} /> + + )} +
+ ); +} diff --git a/src/features/hardening/parts.tsx b/src/features/hardening/parts.tsx new file mode 100644 index 0000000..cc11527 --- /dev/null +++ b/src/features/hardening/parts.tsx @@ -0,0 +1,201 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: the parts the server's switch (Settings › Security › Hardening) and + * a tenant's switch (each tenant's page) share: the impact panel (LP-15), the + * statement (LP-16) and the typed confirmation (LP-17). + */ + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + ago, + CONFIRM_PHRASE, + describeListener, + impactEntries, + phraseMatches, + type PolicyListener, + type RecentUse, +} from './protocolPolicy'; + +/** + * The typed confirmation to turn legacy protocols off (LP-17): the button + * stays disabled until the phrase matches exactly. + */ +export function ConfirmTurnOff({ + busy, + onConfirm, + onCancel, +}: { + busy: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const [typed, setTyped] = useState(''); + return ( +
{ + e.preventDefault(); + if (phraseMatches(typed)) onConfirm(); + }} + > + + setTyped(e.target.value)} + /> +
+ + +
+
+ ); +} + +/** + * The impact panel (LP-15): who would notice, shown before anything can + * change. With nobody, it says so in one line. + */ +export function ImpactPanel({ recent }: { recent: RecentUse[] }) { + const { t, i18n } = useTranslation(); + const entries = impactEntries(recent); + // Read once, when the panel appears: "2 days ago" needn't tick. + const [now] = useState(() => Date.now()); + if (entries.length === 0) { + return ( +

+ {t('legacyProtocols.impactNone', 'No account used a legacy mail app in the last 30 days.')} +

+ ); + } + return ( +
+

+ + {t('legacyProtocols.impactCount', { + count: entries.length, + defaultValue_one: '1 account used a legacy mail app in the last 30 days.', + defaultValue_other: '{{count}} accounts used a legacy mail app in the last 30 days.', + })} + {' '} + {t('legacyProtocols.impactLead', 'Their mail apps will stop working the moment you turn this on:')} +

+
    + {entries.map((entry) => ( +
  • + {entry.name} + + {entry.protocols.join(', ')} · {ago(entry.lastUsedAt, now, i18n.language)} + +
  • + ))} +
+
+ ); +} + +/** Whose switch the statement is about: the server's, naming what closes, or a tenant's. */ +export type StatementScope = { kind: 'server'; listeners: PolicyListener[] } | { kind: 'tenant'; organization: string }; + +/** + * The statement (LP-16). At server scope it names the ports that close and + * carries the firewall note (LP-20); at tenant scope no port closes, so + * neither is said (LP-13). + */ +export function Statement({ scope }: { scope: StatementScope }) { + const { t } = useTranslation(); + return ( +
+

+ {t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')} +

+

+ {scope.kind === 'server' + ? t( + 'legacyProtocols.statementLead', + 'Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone on this server.', + ) + : t( + 'legacyProtocols.statementLeadTenant', + 'Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {{organization}}.', + { organization: scope.organization }, + )} +

+
    +
  • + {t( + 'legacyProtocols.statementApps', + 'Phone and desktop mail apps will stop receiving and sending mail. That’s iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.', + )} +
  • +
  • + {t( + 'legacyProtocols.statementFilters', + 'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.', + )} +
  • +
  • + {t( + 'legacyProtocols.statementUnaffected', + 'Incoming mail is not affected. Calendars and contacts are not affected.', + )} +
  • +
  • + {t( + 'legacyProtocols.statementWebmail', + 'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.', + )} +
  • +
+ {scope.kind === 'server' && ( +

+ {scope.listeners.length > 0 + ? t('legacyProtocols.statementPorts', 'The IMAP, POP3 and ManageSieve ports will close: {{list}}.', { + list: scope.listeners.map(describeListener).join(', '), + }) + : t( + 'legacyProtocols.statementNoPorts', + 'No IMAP, POP3 or ManageSieve listeners are configured, so no ports will close.', + )} +

+ )} +

+ {t( + 'legacyProtocols.statementSubmission', + 'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and INBUXA webmail (JMAP) are not affected and cannot be turned off here.', + )} +

+ {scope.kind === 'server' && ( +

+ {t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}{' '} + {t( + 'legacyProtocols.firewallBody', + 'INBUXA stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.', + )} +

+ )} +

{t('legacyProtocols.statementUndo', 'You can turn legacy protocols back on at any time.')}

+
+ ); +} diff --git a/src/features/hardening/protocolPolicy.test.ts b/src/features/hardening/protocolPolicy.test.ts index 6bd822f..17456fe 100644 --- a/src/features/hardening/protocolPolicy.test.ts +++ b/src/features/hardening/protocolPolicy.test.ts @@ -10,6 +10,7 @@ import { CONFIRM_PHRASE, impactEntries, parsePolicy, + parseTenantPolicy, phraseMatches, protocolRows, type ProtocolPolicy, @@ -120,3 +121,18 @@ describe('the impact panel (LP-15)', () => { expect(ago(now - 10_000, now, 'en')).toBe('this minute'); }); }); + +describe("a tenant's switch", () => { + it('reads the wire, and tells an older server from nobody', () => { + const p = parseTenantPolicy({ + id: 'b', + tenantId: 'b', + legacyProtocols: 'disabled', + changedAt: 5, + recentLegacyUse: [{ accountId: 'c', name: 'u@t.example', protocol: 'imap', lastUsedAt: 9 }], + }); + expect(p).toMatchObject({ id: 'b', legacyProtocols: 'disabled', changedAt: 5 }); + expect(p.recentLegacyUse).toHaveLength(1); + expect(parseTenantPolicy({ id: 'b' })).toMatchObject({ legacyProtocols: 'enabled', recentLegacyUse: null }); + }); +}); diff --git a/src/features/hardening/protocolPolicy.ts b/src/features/hardening/protocolPolicy.ts index 126f701..c083b71 100644 --- a/src/features/hardening/protocolPolicy.ts +++ b/src/features/hardening/protocolPolicy.ts @@ -256,3 +256,65 @@ export function phraseMatches(typed: string): boolean { export function describeListener(l: PolicyListener): string { return l.ports.length > 0 ? `${l.name} (${l.ports.join(', ')})` : l.name; } + +// ---- A tenant's switch: inbuxa:TenantProtocolPolicy (LP-9 to LP-14) ---- + +const TENANT_OBJECT = 'inbuxa:TenantProtocolPolicy'; + +export interface TenantPolicy { + /** The tenant's id, which is also the policy's. */ + id: string; + legacyProtocols: 'enabled' | 'disabled'; + /** Milliseconds since the epoch. */ + changedAt: number | null; + /** The tenant's own people who used a legacy mail app lately (LP-15), or null from an older server. */ + recentLegacyUse: RecentUse[] | null; +} + +export function parseTenantPolicy(raw: Record): TenantPolicy { + return { + id: typeof raw.id === 'string' ? raw.id : '', + legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled', + changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null, + recentLegacyUse: Array.isArray(raw.recentLegacyUse) ? parseRecent(raw.recentLegacyUse) : null, + }; +} + +/** + * A tenant's switch. With no id, the caller's own tenant's -- which is how a + * tenant administrator reads it; a server administrator names the tenant. + */ +export async function fetchTenantPolicy(tenantId: string | null, signal?: AbortSignal): Promise { + const accountId = getAccountId('x:Domain'); + const responses = await jmapRequest( + [[`${TENANT_OBJECT}/get`, { accountId, ids: tenantId ? [tenantId] : null }, '0']], + signal, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name !== `${TENANT_OBJECT}/get`) { + const type = (result as { type?: string } | undefined)?.type; + if (type === 'unknownMethod' || type === 'unknownCapability') throw new PolicyUnavailable(type); + throw new Error((result as { description?: string } | undefined)?.description ?? type ?? 'Request failed'); + } + const list = (result as { list?: Record[] }).list ?? []; + // A tenant admin's /get with no ids holds exactly its own tenant's. + if (!list[0] || (!tenantId && list.length !== 1)) throw new PolicyUnavailable('notFound'); + return parseTenantPolicy(list[0]); +} + +/** Turns a tenant's switch. The server refuses turning it on while its own is off (LP-9). */ +export async function updateTenantPolicy(tenantId: string, legacyProtocols: 'enabled' | 'disabled'): Promise { + const accountId = getAccountId('x:Domain'); + const responses = await jmapRequest( + [[`${TENANT_OBJECT}/set`, { accountId, update: { [tenantId]: { legacyProtocols } } }, '0']], + undefined, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name !== `${TENANT_OBJECT}/set`) { + throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed'); + } + const failed = (result as { notUpdated?: Record | null }).notUpdated?.[tenantId]; + if (failed) throw new Error(failed.description ?? failed.type); +} -- 2.54.0