diff --git a/src/features/hardening/LegacyProtocolsPage.tsx b/src/features/hardening/LegacyProtocolsPage.tsx
index 7b3add4..0ada5a3 100644
--- a/src/features/hardening/LegacyProtocolsPage.tsx
+++ b/src/features/hardening/LegacyProtocolsPage.tsx
@@ -25,8 +25,10 @@ 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,
@@ -35,6 +37,7 @@ import {
type PolicyListener,
type ProtocolPolicy,
type ProtocolRow,
+ type RecentUse,
} from './protocolPolicy';
type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string };
@@ -156,6 +159,8 @@ export function LegacyProtocolsPage() {
+ {!off && policy.recentLegacyUse && }
+
{(off || confirming) && }
{!off && canUpdate && !confirming && (
@@ -316,6 +321,48 @@ 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();
diff --git a/src/features/hardening/protocolPolicy.test.ts b/src/features/hardening/protocolPolicy.test.ts
index 46ecfc0..6bd822f 100644
--- a/src/features/hardening/protocolPolicy.test.ts
+++ b/src/features/hardening/protocolPolicy.test.ts
@@ -5,7 +5,15 @@
*/
import { describe, expect, it } from 'vitest';
-import { CONFIRM_PHRASE, parsePolicy, phraseMatches, protocolRows, type ProtocolPolicy } from './protocolPolicy';
+import {
+ ago,
+ CONFIRM_PHRASE,
+ impactEntries,
+ parsePolicy,
+ phraseMatches,
+ protocolRows,
+ type ProtocolPolicy,
+} from './protocolPolicy';
// As inbuxa:ProtocolPolicy/get sends it: listeners keyed by the policy's own property names.
const WIRE = {
@@ -82,3 +90,33 @@ describe('phraseMatches (LP-17)', () => {
expect(phraseMatches('')).toBe(false);
});
});
+
+describe('the impact panel (LP-15)', () => {
+ it('reads nothing from a server too old to say, and an empty list as nobody', () => {
+ expect(parsePolicy(WIRE).recentLegacyUse).toBeNull();
+ expect(parsePolicy({ ...WIRE, recentLegacyUse: [] }).recentLegacyUse).toEqual([]);
+ });
+
+ it('shows each account once, with every protocol it used and its latest use', () => {
+ const recent = parsePolicy({
+ ...WIRE,
+ recentLegacyUse: [
+ { accountId: 'a', name: 'maria@example.org', protocol: 'submission', lastUsedAt: 100 },
+ { accountId: 'a', name: 'maria@example.org', protocol: 'imap', lastUsedAt: 300 },
+ { accountId: 'b', name: 'ada@example.org', protocol: 'pop3', lastUsedAt: 200 },
+ { accountId: 'c', name: 'bad' },
+ ],
+ }).recentLegacyUse!;
+ expect(impactEntries(recent)).toEqual([
+ { name: 'maria@example.org', protocols: ['IMAP', 'SMTP submission'], lastUsedAt: 300 },
+ { name: 'ada@example.org', protocols: ['POP3'], lastUsedAt: 200 },
+ ]);
+ });
+
+ it('says how long ago in words', () => {
+ const now = Date.UTC(2026, 8, 21);
+ expect(ago(now - 2 * 86400_000, now, 'en')).toBe('2 days ago');
+ expect(ago(now - 3 * 3600_000, now, 'en')).toBe('3 hours ago');
+ expect(ago(now - 10_000, now, 'en')).toBe('this minute');
+ });
+});
diff --git a/src/features/hardening/protocolPolicy.ts b/src/features/hardening/protocolPolicy.ts
index 7f76e19..126f701 100644
--- a/src/features/hardening/protocolPolicy.ts
+++ b/src/features/hardening/protocolPolicy.ts
@@ -38,6 +38,27 @@ export interface ProtocolPolicy {
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;
}
/**
@@ -71,14 +92,75 @@ export function parsePolicy(raw: Record): ProtocolPolicy {
? 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;
+ 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 = {
+ 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();
+ 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 {
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`) {