Hardening shows who still uses legacy mail apps (LP-15)
The impact panel, above the statement, shown before anything can change: "3 accounts used a legacy mail app in the last 30 days. Their mail apps will stop working the moment you turn this on:", then one line per account with every protocol it used and how long ago it last did, most recent first. With nobody, it says so in one line. While the switch is off it isn't shown -- there is nothing left to warn about. It reads recentLegacyUse from inbuxa:ProtocolPolicy, which the server fills from one timestamp per account and protocol (inbuxa-server feat/legacy-use-panel). A server without it gets no panel at all rather than a false "nobody": null and an empty list are kept apart. Protocols read as the table names them (IMAP, POP3, ManageSieve, SMTP submission), and "2 days ago" comes from Intl.RelativeTimeFormat in the reader's language.
This commit is contained in:
@@ -25,8 +25,10 @@ import { useAccountStore } from '@/stores/accountStore';
|
|||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
|
ago,
|
||||||
CONFIRM_PHRASE,
|
CONFIRM_PHRASE,
|
||||||
describeListener,
|
describeListener,
|
||||||
|
impactEntries,
|
||||||
fetchProtocolPolicy,
|
fetchProtocolPolicy,
|
||||||
phraseMatches,
|
phraseMatches,
|
||||||
PolicyUnavailable,
|
PolicyUnavailable,
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
type PolicyListener,
|
type PolicyListener,
|
||||||
type ProtocolPolicy,
|
type ProtocolPolicy,
|
||||||
type ProtocolRow,
|
type ProtocolRow,
|
||||||
|
type RecentUse,
|
||||||
} from './protocolPolicy';
|
} from './protocolPolicy';
|
||||||
|
|
||||||
type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string };
|
type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string };
|
||||||
@@ -156,6 +159,8 @@ export function LegacyProtocolsPage() {
|
|||||||
|
|
||||||
<ProtocolTable rows={protocolRows(policy, listeners)} off={off} />
|
<ProtocolTable rows={protocolRows(policy, listeners)} off={off} />
|
||||||
|
|
||||||
|
{!off && policy.recentLegacyUse && <ImpactPanel recent={policy.recentLegacyUse} />}
|
||||||
|
|
||||||
{(off || confirming) && <Statement listeners={listeners} />}
|
{(off || confirming) && <Statement listeners={listeners} />}
|
||||||
|
|
||||||
{!off && canUpdate && !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 (
|
||||||
|
<p className="rounded-xl border px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{t('legacyProtocols.impactNone', 'No account used a legacy mail app in the last 30 days.')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="space-y-2 rounded-xl border p-4 text-sm">
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
{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.',
|
||||||
|
})}
|
||||||
|
</strong>{' '}
|
||||||
|
{t('legacyProtocols.impactLead', 'Their mail apps will stop working the moment you turn this on:')}
|
||||||
|
</p>
|
||||||
|
<ul className="max-h-72 space-y-1 overflow-y-auto">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<li key={entry.name} className="flex flex-wrap gap-x-2">
|
||||||
|
<span className="font-medium">{entry.name}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{entry.protocols.join(', ')} · {ago(entry.lastUsedAt, now, i18n.language)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The statement (LP-16), at server scope, with the firewall note (LP-20). */
|
/** The statement (LP-16), at server scope, with the firewall note (LP-20). */
|
||||||
function Statement({ listeners }: { listeners: PolicyListener[] }) {
|
function Statement({ listeners }: { listeners: PolicyListener[] }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -5,7 +5,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
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.
|
// As inbuxa:ProtocolPolicy/get sends it: listeners keyed by the policy's own property names.
|
||||||
const WIRE = {
|
const WIRE = {
|
||||||
@@ -82,3 +90,33 @@ describe('phraseMatches (LP-17)', () => {
|
|||||||
expect(phraseMatches('')).toBe(false);
|
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: '[email protected]', protocol: 'submission', lastUsedAt: 100 },
|
||||||
|
{ accountId: 'a', name: '[email protected]', protocol: 'imap', lastUsedAt: 300 },
|
||||||
|
{ accountId: 'b', name: '[email protected]', protocol: 'pop3', lastUsedAt: 200 },
|
||||||
|
{ accountId: 'c', name: 'bad' },
|
||||||
|
],
|
||||||
|
}).recentLegacyUse!;
|
||||||
|
expect(impactEntries(recent)).toEqual([
|
||||||
|
{ name: '[email protected]', protocols: ['IMAP', 'SMTP submission'], lastUsedAt: 300 },
|
||||||
|
{ name: '[email protected]', 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,27 @@ export interface ProtocolPolicy {
|
|||||||
lockedProtocols: string[];
|
lockedProtocols: string[];
|
||||||
/** What turning the switch off would close, whichever way it is set now (LP-16). */
|
/** What turning the switch off would close, whichever way it is set now (LP-16). */
|
||||||
wouldClose: PolicyListener[];
|
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<string, unknown>): ProtocolPolicy {
|
|||||||
? raw.lockedProtocols.filter((p): p is string => typeof p === 'string')
|
? raw.lockedProtocols.filter((p): p is string => typeof p === 'string')
|
||||||
: [],
|
: [],
|
||||||
wouldClose: parseListeners(raw.wouldClose),
|
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. */
|
/** Thrown when the server has no `inbuxa:ProtocolPolicy`, so callers can stay quiet about it. */
|
||||||
export class PolicyUnavailable extends Error {}
|
export class PolicyUnavailable extends Error {}
|
||||||
|
|
||||||
export async function fetchProtocolPolicy(signal?: AbortSignal): Promise<ProtocolPolicy> {
|
export async function fetchProtocolPolicy(signal?: AbortSignal): Promise<ProtocolPolicy> {
|
||||||
const accountId = getAccountId('x:NetworkListener');
|
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 responses = await jmapRequest([[`${OBJECT}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]);
|
||||||
const [name, result] = responses[0] ?? [];
|
const [name, result] = responses[0] ?? [];
|
||||||
if (name !== `${OBJECT}/get`) {
|
if (name !== `${OBJECT}/get`) {
|
||||||
|
|||||||
Reference in New Issue
Block a user