Files
webmail/lib/offline/evicted-secrets.ts
T
Linus Rath 940a572844 feat: hide an account's mail when its login is refused, until the new password or 30 days
A refused login used to delete the account's data, and with it everyone's
S/MIME and OpenPGP keys, also with offline off. The usual cause is a password
changed on another device, which Stalwart 0.16 and 1.0 both answer with
`invalid_grant`, so that lost unsent mail for little protection.

- A refused login signs out at once and hides the data. A new password brings
  it back, unsent mail included. Otherwise it goes after 30 days. The sign-in
  page gives a count and a date. The 24-hour timer is gone.
- `organisation.devices.wipeOnRefusedLogin`, off by default, asks for deletion
  at once, of that account's own data only.
- The session limit no longer counts as a refusal on any route. It hides the
  data and says nothing.
- A wipe runs under a Web Lock, tells other windows when it starts and ends,
  and queues its report before deleting anything. It reports only once the
  deletion worked and no plugin database is left.
- More goes with a wipe: Pro tabs, search history, Files and WebDAV state,
  templates, pop-out drafts, shown notifications, push state, and Web Push
  with the last account.
- The unsent count is per account, and unknown rather than 0 when the store
  can't say. A new row is checked against the account that asked, and a
  sign-out drops its row.

Existing tests changed because the rule did: `auth-store-keep-local.test.ts`
expected a wipe on `invalid_grant` and at the session limit, and
`device-wipe.test.ts` expected shared keys to go on a refused login.
2026-11-03 15:11:47 +01:00

24 lines
1.1 KiB
TypeScript

/**
* Plugin secrets of an account the app dropped, in the desktop app, where they sit
* on this device's own server. The account has no cookie left to ask with, so the
* route takes who it was, from what was written down at the time. On an instance
* they aren't the device's to remove, and nothing is written down.
*/
import { apiFetch } from '@/lib/browser-navigation';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { dropEvictedOwner, readEvictedOwners } from './state';
const BUDGET_MS = 3_000;
/** `account` null: everyone's. */
export async function forgetEvictedSecrets(account: string | null): Promise<void> {
if (!IS_DESKTOP_BUILD) return;
const owners = Object.entries(readEvictedOwners()).filter(([id]) => account === null || id === account);
await Promise.all(owners.map(async ([id, owner]) => {
const query = new URLSearchParams({ all: 'true', username: owner.username, server: owner.serverUrl });
const response = await apiFetch(`/api/plugin-secrets?${query}`, { method: 'DELETE', signal: AbortSignal.timeout(BUDGET_MS) }).catch(() => null);
if (response?.ok) dropEvictedOwner(id);
}));
}