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.
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
/**
|
|
* INBUXA: `inbuxa:ProtocolPolicy`, the server-wide legacy mail protocols switch
|
|
* (legacy-protocols spec). This module is the wire and the rules; the screen
|
|
* and the banner draw from it.
|
|
*/
|
|
|
|
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
|
import type { JmapSetError } from '@/types/jmap';
|
|
|
|
export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
|
|
const OBJECT = 'inbuxa:ProtocolPolicy';
|
|
|
|
/** The phrase that turns legacy protocols off (LP-17). Turning them back on needs none. */
|
|
export const CONFIRM_PHRASE = 'turn off legacy mail';
|
|
|
|
/** A listener the switch closed, or would close, by name and port (LP-16). */
|
|
export interface PolicyListener {
|
|
name: string;
|
|
protocol: string;
|
|
ports: number[];
|
|
}
|
|
|
|
export interface ProtocolPolicy {
|
|
legacyProtocols: 'enabled' | 'disabled';
|
|
closeSubmission: boolean;
|
|
/** Listeners taken away and not yet put back. Non-empty while enabled means some failed to reopen (LP-5). */
|
|
savedListeners: PolicyListener[];
|
|
/** Milliseconds since the epoch. */
|
|
changedAt: number | null;
|
|
changedBy: string | null;
|
|
/** Registry protocols the switch may never close (LP-21), as the server says. */
|
|
lockedProtocols: string[];
|
|
/** What turning the switch off would close, whichever way it is set now (LP-16). */
|
|
wouldClose: PolicyListener[];
|
|
/**
|
|
* Who signed in over a legacy protocol in the last 30 days (LP-15), or null
|
|
* from a server too old to say -- which is not the same as nobody.
|
|
*/
|
|
recentLegacyUse: RecentUse[] | null;
|
|
}
|
|
|
|
/** One account's last sign-in over one legacy protocol, as the server reports it. */
|
|
export interface RecentUse {
|
|
accountId: string;
|
|
name: string;
|
|
protocol: string;
|
|
/** Milliseconds since the epoch. */
|
|
lastUsedAt: number;
|
|
}
|
|
|
|
/** One account on the impact panel: every protocol it used, and when it last did. */
|
|
export interface ImpactEntry {
|
|
name: string;
|
|
protocols: string[];
|
|
lastUsedAt: number;
|
|
}
|
|
|
|
/**
|
|
* The server sends each listener as an object keyed by the policy's own
|
|
* property names: `id` is the listener's name, `legacyProtocols` its protocol
|
|
* and `wouldClose` its ports. Read them back into something that says so.
|
|
*/
|
|
function parseListener(raw: unknown): PolicyListener | null {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const r = raw as Record<string, unknown>;
|
|
if (typeof r.id !== 'string') return null;
|
|
return {
|
|
name: r.id,
|
|
protocol: typeof r.legacyProtocols === 'string' ? r.legacyProtocols : '',
|
|
ports: Array.isArray(r.wouldClose) ? r.wouldClose.filter((p): p is number => typeof p === 'number') : [],
|
|
};
|
|
}
|
|
|
|
function parseListeners(raw: unknown): PolicyListener[] {
|
|
return Array.isArray(raw) ? raw.map(parseListener).filter((l): l is PolicyListener => l !== null) : [];
|
|
}
|
|
|
|
export function parsePolicy(raw: Record<string, unknown>): ProtocolPolicy {
|
|
return {
|
|
legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled',
|
|
closeSubmission: raw.closeSubmission === true,
|
|
savedListeners: parseListeners(raw.savedListeners),
|
|
changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null,
|
|
changedBy: typeof raw.changedBy === 'string' ? raw.changedBy : null,
|
|
lockedProtocols: Array.isArray(raw.lockedProtocols)
|
|
? raw.lockedProtocols.filter((p): p is string => typeof p === 'string')
|
|
: [],
|
|
wouldClose: parseListeners(raw.wouldClose),
|
|
recentLegacyUse: Array.isArray(raw.recentLegacyUse) ? parseRecent(raw.recentLegacyUse) : null,
|
|
};
|
|
}
|
|
|
|
function parseRecent(raw: unknown[]): RecentUse[] {
|
|
return raw.flatMap((entry) => {
|
|
if (!entry || typeof entry !== 'object') return [];
|
|
const r = entry as Record<string, unknown>;
|
|
if (typeof r.name !== 'string' || typeof r.protocol !== 'string' || typeof r.lastUsedAt !== 'number') return [];
|
|
return [
|
|
{
|
|
accountId: typeof r.accountId === 'string' ? r.accountId : '',
|
|
name: r.name,
|
|
protocol: r.protocol,
|
|
lastUsedAt: r.lastUsedAt,
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
const PROTOCOL_LABELS: Record<string, string> = {
|
|
imap: 'IMAP',
|
|
pop3: 'POP3',
|
|
manageSieve: 'ManageSieve',
|
|
submission: 'SMTP submission',
|
|
};
|
|
|
|
/**
|
|
* The impact panel's lines (LP-15): one per account, naming every protocol it
|
|
* used and when it last used any, most recent first.
|
|
*/
|
|
export function impactEntries(recent: RecentUse[]): ImpactEntry[] {
|
|
const byAccount = new Map<string, ImpactEntry>();
|
|
for (const use of recent) {
|
|
const key = use.accountId || use.name;
|
|
const entry = byAccount.get(key) ?? { name: use.name, protocols: [], lastUsedAt: 0 };
|
|
const label = PROTOCOL_LABELS[use.protocol] ?? use.protocol;
|
|
if (!entry.protocols.includes(label)) entry.protocols.push(label);
|
|
entry.lastUsedAt = Math.max(entry.lastUsedAt, use.lastUsedAt);
|
|
byAccount.set(key, entry);
|
|
}
|
|
const order = Object.values(PROTOCOL_LABELS);
|
|
return [...byAccount.values()]
|
|
.map((e) => ({ ...e, protocols: e.protocols.sort((a, b) => order.indexOf(a) - order.indexOf(b)) }))
|
|
.sort((a, b) => b.lastUsedAt - a.lastUsedAt || a.name.localeCompare(b.name));
|
|
}
|
|
|
|
/** "2 days ago", "3 hours ago", "just now", in the reader's language. */
|
|
export function ago(at: number, now: number, locale?: string): string {
|
|
const seconds = Math.round((at - now) / 1000);
|
|
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
|
const steps: [Intl.RelativeTimeFormatUnit, number][] = [
|
|
['day', 86400],
|
|
['hour', 3600],
|
|
['minute', 60],
|
|
];
|
|
for (const [unit, size] of steps) {
|
|
if (Math.abs(seconds) >= size) return rtf.format(Math.round(seconds / size), unit);
|
|
}
|
|
return rtf.format(0, 'minute');
|
|
}
|
|
|
|
/** Thrown when the server has no `inbuxa:ProtocolPolicy`, so callers can stay quiet about it. */
|
|
export class PolicyUnavailable extends Error {}
|
|
|
|
export async function fetchProtocolPolicy(signal?: AbortSignal): Promise<ProtocolPolicy> {
|
|
const accountId = getAccountId('x:NetworkListener');
|
|
// No `properties`: the server answers with all of them, recentLegacyUse
|
|
// included where it has it.
|
|
const responses = await jmapRequest([[`${OBJECT}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]);
|
|
const [name, result] = responses[0] ?? [];
|
|
if (name !== `${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<string, unknown>[] }).list ?? [];
|
|
if (!list[0]) throw new PolicyUnavailable('notFound');
|
|
return parsePolicy(list[0]);
|
|
}
|
|
|
|
export interface SetOutcome {
|
|
/** What the server stored differently from what was asked (LP-21), or nothing. */
|
|
overruled: Record<string, unknown> | null;
|
|
}
|
|
|
|
export async function updateProtocolPolicy(
|
|
update: Partial<Pick<ProtocolPolicy, 'legacyProtocols' | 'closeSubmission'>>,
|
|
): Promise<SetOutcome> {
|
|
const accountId = getAccountId('x:NetworkListener');
|
|
const responses = await jmapRequest(
|
|
[[`${OBJECT}/set`, { accountId, update: { singleton: update } }, '0']],
|
|
undefined,
|
|
[INBUXA_CAPABILITY],
|
|
);
|
|
const [name, result] = responses[0] ?? [];
|
|
if (name !== `${OBJECT}/set`) {
|
|
throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed');
|
|
}
|
|
const r = result as {
|
|
updated?: Record<string, Record<string, unknown> | null> | null;
|
|
notUpdated?: Record<string, JmapSetError> | null;
|
|
};
|
|
const failed = r.notUpdated?.singleton;
|
|
if (failed) throw new Error(failed.description ?? failed.type);
|
|
const stored = r.updated?.singleton;
|
|
return { overruled: stored && Object.keys(stored).length > 0 ? stored : null };
|
|
}
|
|
|
|
/** How a protocol stands under the switch, for the selector (LP-21). */
|
|
export type RowState = 'closes' | 'refused' | 'locked';
|
|
|
|
export interface ProtocolRow {
|
|
key: string;
|
|
label: string;
|
|
state: RowState;
|
|
/** Ports that close with the switch; empty when nothing is listening or the row doesn't close. */
|
|
ports: number[];
|
|
}
|
|
|
|
function portsOf(listeners: PolicyListener[], protocol: string): number[] {
|
|
const ports = listeners.filter((l) => l.protocol === protocol).flatMap((l) => l.ports);
|
|
return [...new Set(ports)].sort((a, b) => a - b);
|
|
}
|
|
|
|
/**
|
|
* Every mail protocol the server speaks, in one place, with what the switch
|
|
* does to each. The locked set comes from the server, so unlocking later is
|
|
* a server change and no admin release (LP-21).
|
|
*
|
|
* `listeners` is what the switch closes: `wouldClose` while it's on, or
|
|
* `savedListeners` once it's off.
|
|
*/
|
|
export function protocolRows(policy: ProtocolPolicy, listeners: PolicyListener[]): ProtocolRow[] {
|
|
const locked = new Set(policy.lockedProtocols.map((p) => p.toLowerCase()));
|
|
const legacy: ProtocolRow[] = [
|
|
{ key: 'imap', label: 'IMAP', state: 'closes', ports: portsOf(listeners, 'imap') },
|
|
{ key: 'pop3', label: 'POP3', state: 'closes', ports: portsOf(listeners, 'pop3') },
|
|
{ key: 'manageSieve', label: 'ManageSieve', state: 'closes', ports: portsOf(listeners, 'manageSieve') },
|
|
];
|
|
const smtpLocked = locked.has('smtp');
|
|
return [
|
|
...legacy,
|
|
{
|
|
key: 'submission',
|
|
label: 'SMTP submission',
|
|
// Locked submission keeps its ports; sign-in over them is refused instead.
|
|
state: smtpLocked ? 'locked' : policy.closeSubmission ? 'closes' : 'refused',
|
|
ports: smtpLocked ? [] : portsOf(listeners, 'smtp'),
|
|
},
|
|
// Incoming mail and JMAP are never the switch's to close (LP-3, "Not affected, ever").
|
|
{ key: 'smtp', label: 'SMTP (incoming mail)', state: 'locked', ports: [] },
|
|
{ key: 'jmap', label: 'JMAP (INBUXA webmail)', state: 'locked', ports: [] },
|
|
];
|
|
}
|
|
|
|
/** Whether the typed confirmation matches (LP-17). Exact: no trimming, no case folding. */
|
|
export function phraseMatches(typed: string): boolean {
|
|
return typed === CONFIRM_PHRASE;
|
|
}
|
|
|
|
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<string, unknown>): 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<TenantPolicy> {
|
|
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<string, unknown>[] }).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<void> {
|
|
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<string, JmapSetError> | null }).notUpdated?.[tenantId];
|
|
if (failed) throw new Error(failed.description ?? failed.type);
|
|
}
|