feat: recover mail deleted for good from the server's archive (bulwarkmail/webmail#1319)

This commit is contained in:
Linus Rath
2026-10-31 05:23:28 +01:00
parent 3769b24b3a
commit 113e96bdec
14 changed files with 1745 additions and 6 deletions
+142
View File
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'ci',
request: vi.fn(),
currentSession: null as unknown,
getMaxObjectsInGet: () => 500,
getMaxObjectsInSet: () => 500,
getMaxCallsInRequest: () => 16,
},
}));
import { jmapClient } from '../jmap-client';
import { jmapErrorType } from '../jmap-result';
import {
LOGIN_SWITCHED,
advertisesArchivedItems,
hasArchivedEmail,
listArchivedEmails,
queryArchivedItemIds,
resetArchiveFilterMemory,
restoreArchivedItems,
} from '../archived-items';
import { ACCOUNT, LICENSE_REFUSAL, archivedEmail, bigArchive, fakeArchiveServer } from '../../lib/recovery/__tests__/fixtures';
// Ported from the webmail's lib/__tests__/jmap-archived-items.test.ts.
type Call = [string, Record<string, unknown>, string];
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
function setSession(stalwart: boolean) {
(jmapClient as { currentSession: unknown }).currentSession = {
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} },
accounts: {
[ACCOUNT]: {
name: 'org-alice@example.org', isPersonal: true, isReadOnly: false,
accountCapabilities: stalwart
? { 'urn:ietf:params:jmap:mail': {}, 'urn:stalwart:jmap': {} }
: { 'urn:ietf:params:jmap:mail': {} },
},
},
};
}
function serve(answer: (body: { methodCalls: Call[] }) => unknown) {
mockRequest.mockImplementation(async (methodCalls: Call[]) => answer({ methodCalls }));
}
const sent = () => mockRequest.mock.calls.map(([calls, using]) => ({ calls: calls as Call[], using: using as string[] }));
const queries = () => sent().flatMap((b) => b.calls).filter(([m]) => m === 'x:ArchivedItem/query');
beforeEach(() => {
vi.clearAllMocks();
resetArchiveFilterMemory();
setSession(true);
});
describe('archived items API', () => {
it('advertises them only where the account lists urn:stalwart:jmap', () => {
expect(advertisesArchivedItems()).toBe(true);
setSession(false);
expect(advertisesArchivedItems()).toBe(false);
// Session-level only (never how Stalwart sends it) doesn't count either.
(jmapClient as { currentSession: unknown }).currentSession = {
capabilities: { 'urn:stalwart:jmap': {} },
accounts: { [ACCOUNT]: { name: 'a', isPersonal: true, isReadOnly: false, accountCapabilities: {} } },
};
expect(advertisesArchivedItems()).toBe(false);
});
it('asks the account filter first and, once refused, never again for that account', async () => {
serve(fakeArchiveServer([archivedEmail('a')]).answer);
await expect(queryArchivedItemIds(ACCOUNT, { limit: 1 })).resolves.toEqual({ ids: ['a'], total: null });
await queryArchivedItemIds(ACCOUNT, { limit: 1 });
expect(queries().map(([, args]) => args)).toEqual([
{ accountId: ACCOUNT, limit: 1, filter: { accountId: ACCOUNT } },
{ accountId: ACCOUNT, limit: 1 },
{ accountId: ACCOUNT, limit: 1 },
]);
expect(sent()[0].using).toEqual(['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']);
});
it("keeps an admin's answer to the account through the filter, and drops other accounts' items anyway", async () => {
serve(fakeArchiveServer([
archivedEmail('i3'),
archivedEmail('i2', { accountId: 'cz' }),
archivedEmail('i1', { accountId: undefined }),
], { impersonate: true }).answer);
const result = await listArchivedEmails(ACCOUNT, { days: 0 });
expect(result.items.map((i) => i.id)).toEqual(['i3']);
expect(queries().every(([, args]) => (args.filter as { accountId?: string })?.accountId === ACCOUNT)).toBe(true);
});
it('throws the refusal with its type when the server has no license', async () => {
serve(() => ({ methodResponses: [['error', LICENSE_REFUSAL, 'q']] }));
const error = await queryArchivedItemIds(ACCOUNT).catch((e: unknown) => e);
expect(jmapErrorType(error)).toBe('forbidden');
});
it('pages with calculateTotal, so the newest of more than 5000 items comes first', async () => {
serve(fakeArchiveServer(bigArchive(6000, Date.now())).answer);
const first = await listArchivedEmails(ACCOUNT, { days: 0 });
expect(first.items[0].id).toBe('i006000');
expect(first.nextPosition).toBe(2000);
const pages = queries().filter(([, args]) => args.calculateTotal && !args.filter);
expect(pages.map(([, args]) => [args.position ?? 0, args.limit])).toEqual([[0, 500], [500, 500], [1000, 500], [1500, 500]]);
const gets = sent().flatMap((b) => b.calls).filter(([m]) => m === 'x:ArchivedItem/get');
expect(gets.every(([, args]) => (args.ids as string[]).length <= 500 && args.accountId === ACCOUNT)).toBe(true);
const older = await listArchivedEmails(ACCOUNT, { days: 0, startPosition: 2000 });
expect(older.items[0].id).toBe('i004000');
});
it('checks for the Trash link with one-item pages and stops at the first message', async () => {
serve(fakeArchiveServer(bigArchive(30, Date.now())).answer);
await expect(hasArchivedEmail(ACCOUNT, { days: 7 })).resolves.toBe(true);
expect(queries().filter(([, args]) => args.calculateTotal && !args.filter).map(([, args]) => args.limit)).toEqual([1]);
});
it('restores with requestRestore in the account it was asked for and reports notFound per item', async () => {
const server = fakeArchiveServer([archivedEmail('a')]);
serve(server.answer);
const results = await restoreArchivedItems(['a', 'b'], ACCOUNT);
expect(results).toEqual([{ id: 'a', ok: true }, { id: 'b', ok: false, error: 'notFound', type: 'notFound' }]);
expect(sent()[0].calls).toEqual([
['x:ArchivedItem/set', { accountId: ACCOUNT, update: { a: { status: 'requestRestore' }, b: { status: 'requestRestore' } } }, 'r0'],
]);
expect(server.remaining()).toEqual([]);
});
it('fails every item of a request that fails instead of throwing', async () => {
mockRequest.mockRejectedValue(new Error('offline'));
await expect(restoreArchivedItems(['a'], ACCOUNT)).resolves.toEqual([{ id: 'a', ok: false, error: 'offline' }]);
});
it('sends nothing once the login it was opened in is no longer active', async () => {
serve(fakeArchiveServer([archivedEmail('a')]).answer);
const results = await restoreArchivedItems(['a'], ACCOUNT, () => false);
expect(results).toEqual([{ id: 'a', ok: false, error: LOGIN_SWITCHED }]);
expect(mockRequest).not.toHaveBeenCalled();
});
});
@@ -461,3 +461,83 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
}, 60_000);
});
});
// Recover deleted items (#1319): x:ArchivedItem. Stalwart keeps mail deleted
// for good only with an Enterprise license and `archiveDeletedItemsFor` set
// for the account; without them the case says why and skips.
live('live Stalwart: recover deleted items (x:ArchivedItem)', () => {
const tag = `bulwark-archive-${RUN}`;
let scratch = '';
beforeAll(async () => {
const mod = await import('../jmap-client');
singleton = mod.jmapClient as unknown as JMAPClient;
client = new JMAPClient();
await client.connect(SERVER!, USER, PASS);
await useLogin(client);
scratch = await createMailbox({ name: `live-archive-${RUN}` });
}, 60_000);
afterAll(async () => {
await useLogin(client);
const leftovers = await messagesWith(tag).catch(() => [] as string[]);
if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined);
if (scratch) {
await emptyMailbox(scratch).catch(() => undefined);
await deleteMailbox(scratch).catch(() => undefined);
}
}, 60_000);
it('lists mail deleted for good and puts it back in the Inbox, or reports the feature unsupported', async (ctx) => {
const { advertisesArchivedItems, listArchivedEmails, queryArchivedItemIds, restoreArchivedItems } = await import('../archived-items');
const { classifyArchiveError } = await import('../../lib/recovery/archived-items');
const accountId = client.accountId;
// What the detection store would decide.
let probe: 'supported' | 'unsupported' | 'error' = 'unsupported';
let refusal = '';
if (advertisesArchivedItems(accountId)) {
try {
await queryArchivedItemIds(accountId, { limit: 1 });
probe = 'supported';
} catch (err) {
probe = classifyArchiveError(err);
refusal = err instanceof Error ? err.message : String(err);
}
}
if (!is10()) {
// 0.16: Settings hides the page.
expect(probe).toBe('unsupported');
return;
}
expect(advertisesArchivedItems(accountId)).toBe(true);
expect(probe).not.toBe('error');
if (probe === 'unsupported') {
ctx.skip(`x:ArchivedItem refused on this 1.0 server (${refusal}): it needs an Enterprise license`);
return;
}
const emailId = await importMessage(scratch, `${tag} restore me`, 'Deleted for good, then restored.');
await destroyEmails([emailId]);
const findArchived = async () => (await listArchivedEmails(accountId, { days: 1 })).items.find((i) => i.subject.includes(tag));
const archived = await eventually(findArchived, (item) => !!item, 20_000);
if (!archived) {
ctx.skip('Nothing was archived: the account needs "archiveDeletedItemsFor" (Data retention > Archive deleted items) set on the server');
return;
}
expect(archived.accountId).toBe(accountId);
expect(archived.fromEmail).toBe('partner@example.net');
const results = await restoreArchivedItems([archived.id], accountId);
expect(results).toEqual([{ id: archived.id, ok: true }]);
// The server restores in the background, into the Inbox.
const inbox = (await getMailboxes()).find((b) => b.role === 'inbox')!.id;
const back = await eventually(() => messagesWith(tag), (ids) => ids.length > 0, 30_000);
expect(back).toHaveLength(1);
const [restored] = await getFullEmails([back[0]]);
expect(Object.keys(restored.mailboxIds ?? {})).toContain(inbox);
expect(await findArchived()).toBeUndefined();
}, 120_000);
});
+155
View File
@@ -0,0 +1,155 @@
// "Recover deleted items" (webmail JMAPClient.queryArchivedItemIds and
// friends, #1319): Stalwart's x:ArchivedItem methods. The pure parts are in
// lib/recovery/archived-items.
//
// Every call names the account it is for. The page passes `stillSameLogin`,
// which is asked right before each write: a switch of logins while a restore
// runs must not send the rest of it to the other login's server.
import { jmapClient } from './jmap-client';
import { JMAPMethodError, batched } from './jmap-result';
import type { JMAPMethodCall } from './types';
import {
ARCHIVED_ITEM_USING,
STALWART_CAPABILITY,
buildRestoreCalls,
hasArchivedEmail as peekArchive,
listArchivedEmailsPaged,
readRestoreResults,
type ArchiveCall,
type ArchiveIo,
type ArchiveListResult,
type ArchiveQueryPage,
type RestoreResult,
} from '../lib/recovery/archived-items';
function methodError(result: unknown, fallback: string): JMAPMethodError {
const error = result as { type?: string; description?: string } | undefined;
return new JMAPMethodError(error?.type ?? 'serverFail', error?.description || fallback);
}
/**
* Whether the account lists Stalwart's own capability. Stalwart puts it in
* the account's accountCapabilities only, never at session level, so this
* reads the account (webmail hasAccountCapability).
*/
export function advertisesArchivedItems(accountId: string = jmapClient.accountId): boolean {
const caps = jmapClient.currentSession?.accounts?.[accountId]?.accountCapabilities;
return !!caps && STALWART_CAPABILITY in caps;
}
/** Accounts whose server refused the `accountId` filter (every user without Impersonate). */
const filterRefused = new Set<string>();
/** For tests: forget which accounts refused the filter. */
export function resetArchiveFilterMemory(): void {
filterRefused.clear();
}
/**
* One page of the account's archived item ids, newest first. A request of
* its own: without an Enterprise license Stalwart refuses it (`forbidden`),
* and that must not take other calls down with it. The `accountId` filter
* keeps an admin's answer to this account; a normal user is refused it
* (`unsupportedFilter`) and asked again without it, once per account.
*/
export async function queryArchivedItemIds(
accountId: string = jmapClient.accountId,
options: { position?: number; limit?: number; calculateTotal?: boolean } = {},
): Promise<ArchiveQueryPage> {
const args: Record<string, unknown> = { accountId };
if (options.position) args.position = options.position;
if (options.limit) args.limit = options.limit;
if (options.calculateTotal) args.calculateTotal = true;
const ask = async (filtered: boolean) => {
const res = await jmapClient.request(
[['x:ArchivedItem/query', filtered ? { ...args, filter: { accountId } } : args, 'q']],
ARCHIVED_ITEM_USING,
);
return res.methodResponses?.[0] ?? [];
};
let [method, result] = await ask(!filterRefused.has(accountId));
if (method === 'error' && (result as { type?: string } | undefined)?.type === 'unsupportedFilter' && !filterRefused.has(accountId)) {
filterRefused.add(accountId);
[method, result] = await ask(false);
}
if (method !== 'x:ArchivedItem/query') throw methodError(result, 'Failed to list deleted items');
const { ids, total } = result as { ids?: unknown; total?: unknown };
return {
ids: Array.isArray(ids) ? ids.filter((v): v is string => typeof v === 'string') : [],
total: typeof total === 'number' ? total : null,
};
}
function archiveIo(accountId: string): ArchiveIo {
return {
query: (position, limit) => queryArchivedItemIds(accountId, { position, limit, calculateTotal: true }),
get: async (ids) => {
const calls: JMAPMethodCall[] = batched(ids, jmapClient.getMaxObjectsInGet())
.map((batch, i): JMAPMethodCall => ['x:ArchivedItem/get', { accountId, ids: batch }, `g${i}`]);
const list: unknown[] = [];
for (const group of batched(calls, jmapClient.getMaxCallsInRequest())) {
const res = await jmapClient.request(group, ARCHIVED_ITEM_USING);
for (const [method, result] of res.methodResponses ?? []) {
if (method !== 'x:ArchivedItem/get') throw methodError(result, 'Failed to read deleted items');
const items = (result as { list?: unknown }).list;
if (Array.isArray(items)) list.push(...items);
}
}
return list;
},
};
}
/** The account's deleted messages inside the days window, newest first, in pages. */
export async function listArchivedEmails(
accountId: string = jmapClient.accountId,
options: { days?: number; startPosition?: number } = {},
): Promise<ArchiveListResult> {
return listArchivedEmailsPaged(archiveIo(accountId), accountId, {
days: options.days ?? 0,
startPosition: options.startPosition,
});
}
/** Whether at least one deleted message of the account is inside the days window. */
export async function hasArchivedEmail(
accountId: string = jmapClient.accountId,
options: { days?: number } = {},
): Promise<boolean> {
return peekArchive(archiveIo(accountId), accountId, { days: options.days ?? 0 });
}
/** The reason a restore was not sent: another login is active now. */
export const LOGIN_SWITCHED = 'The account was switched';
/**
* Asks the server to put `ids` back. Never throws: a request that fails, or
* one not sent because the login changed, marks its items failed.
*/
export async function restoreArchivedItems(
ids: string[],
accountId: string = jmapClient.accountId,
stillSameLogin: () => boolean = () => true,
): Promise<RestoreResult[]> {
const calls = buildRestoreCalls(ids, accountId, jmapClient.getMaxObjectsInSet());
const results: RestoreResult[] = [];
const failGroup = (group: ArchiveCall[], reason: string) => {
for (const [, args] of group) {
results.push(...Object.keys(args.update as Record<string, unknown>).map((itemId) => ({ id: itemId, ok: false, error: reason })));
}
};
for (const group of batched(calls, jmapClient.getMaxCallsInRequest())) {
if (!stillSameLogin()) {
failGroup(group, LOGIN_SWITCHED);
continue;
}
try {
const res = await jmapClient.request(group, ARCHIVED_ITEM_USING);
results.push(...readRestoreResults(group, (res.methodResponses ?? []) as ArchiveCall[]));
} catch (error) {
failGroup(group, error instanceof Error ? error.message : String(error));
}
}
return results;
}
@@ -0,0 +1,337 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import { CheckSquare, RefreshCw, Square, Undo2 } from '../icons';
import Button from '../Button';
import { SettingsSection } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingsStore } from '../../stores/settings-store';
import { toast } from '../../stores/toast-store';
import { jmapClient } from '../../api/jmap-client';
import { listArchivedEmails, restoreArchivedItems } from '../../api/archived-items';
import { servedLoginOf } from '../../lib/draft-login';
import { loginMovedFrom } from '../../lib/compose-login';
import { formatListDate } from '../../lib/date-format';
import {
classifyArchiveError,
restoreOutcome,
visibleArchivedItems,
type ArchivedEmail,
} from '../../lib/recovery/archived-items';
import { recoveryPolicy } from '../../lib/recovery/policy';
import { useRecoveryStore } from '../../lib/recovery/recovery-store';
import { useDeletedItemsRecovery } from '../../lib/recovery/use-deleted-items-recovery';
// Ported from the webmail's components/settings/recover-deleted-settings.tsx
// (#1319): the messages the user deleted for good that Stalwart still keeps
// (Enterprise, `archiveDeletedItemsFor`), newest first, read in pages with
// "Show older" past 2000. Restoring asks the server to put them back in the
// Inbox; it does that in the background, and the mail list picks them up
// from the push that follows. The admin's day limit comes from
// lib/recovery/policy (no limit until the app has the org policy).
type ListState =
| { status: 'loading' }
| { status: 'error' }
| { status: 'ready'; items: ArchivedEmail[]; nextPosition: number | null; loadingOlder: boolean };
interface Outcome {
restored: number;
failed: number;
/** No longer in the archive: restored elsewhere, or the server stopped keeping it. */
gone: number;
}
const NO_OUTCOME: Outcome = { restored: 0, failed: 0, gone: 0 };
export function RecoverDeletedSettings() {
const c = useColors();
const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const { available, accountId, key } = useDeletedItemsRecovery();
const { days } = recoveryPolicy();
const record = useRecoveryStore((s) => s.record);
const forgetRecent = useRecoveryStore((s) => s.forgetRecent);
const [list, setList] = useState<ListState>({ status: 'loading' });
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const [restoring, setRestoring] = useState(false);
const [outcome, setOutcome] = useState<Outcome>(NO_OUTCOME);
// The account the page shows now: answers for another one are dropped.
const keyRef = useRef(key);
useEffect(() => {
keyRef.current = key;
}, [key]);
// The login the list was read in: a restore is sent only while it is active.
const ownerRef = useRef<string | null>(null);
// An answer that arrives after a newer read started is dropped too.
const readSeq = useRef(0);
const refused = useCallback((error: unknown, forKey: string) => {
// A refusal now (the license ran out) hides the page like detection would.
if (classifyArchiveError(error) === 'unsupported') record(forKey, 'unsupported');
}, [record]);
const load = useCallback(async () => {
if (!accountId || !key) return;
const seq = ++readSeq.current;
ownerRef.current = servedLoginOf(jmapClient);
setList({ status: 'loading' });
setSelected(new Set());
setOutcome(NO_OUTCOME);
try {
const result = await listArchivedEmails(accountId, { days });
if (seq !== readSeq.current) return;
setList({ status: 'ready', items: result.items, nextPosition: result.nextPosition, loadingOlder: false });
} catch (error) {
if (seq !== readSeq.current) return;
refused(error, key);
setList({ status: 'error' });
}
}, [accountId, key, days, refused]);
useEffect(() => {
if (!available) return undefined;
void load();
const reads = readSeq;
return () => {
reads.current++;
};
}, [available, load]);
const items = useMemo(() => (list.status === 'ready' ? visibleArchivedItems(list.items, days) : []), [list, days]);
if (!available || !accountId || !key) return null;
const showOlder = async () => {
if (list.status !== 'ready' || list.nextPosition === null || list.loadingOlder) return;
const seq = readSeq.current;
const start = list.nextPosition;
setList({ ...list, loadingOlder: true });
try {
const result = await listArchivedEmails(accountId, { days, startPosition: start });
if (seq !== readSeq.current) return;
setList((prev) => {
if (prev.status !== 'ready') return prev;
const known = new Set(prev.items.map((item) => item.id));
return {
status: 'ready',
items: [...prev.items, ...result.items.filter((item) => !known.has(item.id))],
nextPosition: result.nextPosition,
loadingOlder: false,
};
});
} catch (error) {
if (seq !== readSeq.current) return;
refused(error, key);
setList((prev) => (prev.status === 'ready' ? { ...prev, loadingOlder: false } : prev));
toast.error(t('recover_deleted.load_failed', "Deleted mail couldn't be loaded."));
}
};
const allSelected = items.length > 0 && items.every((item) => selected.has(item.id));
const toggle = (id: string) => setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(items.map((item) => item.id)));
const nothingSelected = !items.some((item) => selected.has(item.id));
const restore = async () => {
const ids = items.filter((item) => selected.has(item.id)).map((item) => item.id);
if (restoring || ids.length === 0) return;
const forKey = key;
const seq = readSeq.current;
const owner = ownerRef.current;
setRestoring(true);
try {
const results = await restoreArchivedItems(ids, accountId, () => !loginMovedFrom(owner));
forgetRecent(forKey);
const { done, gone, failed, refused: unlicensed } = restoreOutcome(ids, results);
if (unlicensed) record(forKey, 'unsupported');
// Another account (or a reload of the list) since: none of this applies to what is shown now.
if (keyRef.current !== forKey || seq !== readSeq.current) return;
setOutcome({ restored: done.size, failed: failed.length, gone: gone.size });
// Restored and vanished items leave the list; the ones that failed stay selected to try again.
setList((prev) => (prev.status === 'ready'
? { ...prev, items: prev.items.filter((item) => !done.has(item.id) && !gone.has(item.id)) }
: prev));
setSelected(new Set(failed));
if (done.size > 0) {
toast.success(t('recover_deleted.restored', '{count, plural, one {# message is} other {# messages are}} on the way back to your Inbox.', { count: done.size }));
}
if (failed.length > 0) {
toast.error(t('recover_deleted.restore_partial', "{count} messages couldn't be restored. They stay selected, so you can try again.", { count: failed.length }));
}
} finally {
setRestoring(false);
}
};
const dateOf = (iso: string | null) => (iso ? formatListDate(iso, { dateFormat: 'full', timeFormat, locale, t }) : '');
const outcomeLines = [
outcome.restored > 0
? t('recover_deleted.restored', '{count, plural, one {# message is} other {# messages are}} on the way back to your Inbox.', { count: outcome.restored })
: null,
outcome.gone > 0
? t('recover_deleted.restore_gone', '{count} messages were no longer in the archive: they were restored elsewhere or the server stopped keeping them.', { count: outcome.gone })
: null,
].filter((line): line is string => !!line);
return (
<SettingsSection
title={t('recover_deleted.title', 'Recover deleted items')}
description={t('recover_deleted.description', 'Mail you deleted for good, for example by emptying Trash, that the server still keeps. Select messages and restore them.')}
>
<Text style={styles.muted}>
{days > 0
? t('recover_deleted.range_days', 'Showing mail deleted in the last {days} days.', { days })
: t('recover_deleted.range_all', 'Showing all deleted mail the server still keeps.')}
</Text>
{list.status === 'loading' && (
<View style={styles.inline} accessibilityLiveRegion="polite">
<ActivityIndicator size="small" color={c.textMuted} />
<Text style={styles.muted}>{t('recover_deleted.loading', 'Loading deleted mail…')}</Text>
</View>
)}
{list.status === 'error' && (
<View style={styles.inline} accessibilityLiveRegion="assertive">
<Text style={styles.error}>{t('recover_deleted.load_failed', "Deleted mail couldn't be loaded.")}</Text>
<Button variant="outline" size="sm" onPress={() => void load()}>
{t('recover_deleted.retry', 'Try again')}
</Button>
</View>
)}
{list.status === 'ready' && (
<View style={styles.block}>
{/* Always there, so what a restore did is read out. */}
<View accessibilityLiveRegion="polite">
{outcomeLines.map((line) => <Text key={line} style={styles.muted}>{line}</Text>)}
{outcome.failed > 0 && (
<Text style={styles.error}>
{t('recover_deleted.restore_partial', "{count} messages couldn't be restored. They stay selected, so you can try again.", { count: outcome.failed })}
</Text>
)}
</View>
{items.length === 0 ? (
<View style={styles.block}>
<Text style={styles.muted}>{t('recover_deleted.empty', 'There is no deleted mail to recover.')}</Text>
<Text style={styles.small}>
{t('recover_deleted.empty_hint', 'Deleted mail only shows up here if your administrator turned on archiving of deleted items in Stalwart.')}
</Text>
</View>
) : (
<>
<View style={styles.toolbar}>
<Pressable
onPress={toggleAll}
disabled={restoring}
accessibilityRole="checkbox"
accessibilityState={{ checked: allSelected, disabled: restoring }}
style={styles.selectAll}
>
{allSelected ? <CheckSquare size={20} color={c.primary} /> : <Square size={20} color={c.textMuted} />}
<Text style={styles.body}>
{selected.size > 0
? t('recover_deleted.selected', '{count} selected', { count: selected.size })
: t('recover_deleted.select_all', 'Select all')}
</Text>
</Pressable>
<View style={styles.toolbarButtons}>
<Button
variant="outline"
size="sm"
onPress={() => void load()}
disabled={restoring}
icon={<RefreshCw size={16} color={c.text} />}
accessibilityLabel={t('recover_deleted.refresh', 'Refresh')}
/>
<Button
size="sm"
onPress={() => void restore()}
disabled={nothingSelected}
loading={restoring}
icon={<Undo2 size={16} color={c.primaryForeground} />}
>
{t('recover_deleted.restore', 'Restore')}
</Button>
</View>
</View>
<View style={styles.list}>
{items.map((item, index) => {
const sender = item.fromName || item.fromEmail || t('recover_deleted.unknown_sender', 'Unknown sender');
const checked = selected.has(item.id);
const subject = item.subject || t('recover_deleted.no_subject', '(no subject)');
const deleted = t('recover_deleted.deleted_on', 'Deleted {date}', { date: dateOf(item.archivedAt) });
return (
<Pressable
key={item.id}
onPress={() => toggle(item.id)}
disabled={restoring}
accessibilityRole="checkbox"
accessibilityState={{ checked, disabled: restoring }}
accessibilityLabel={[sender, subject, dateOf(item.receivedAt), deleted].filter(Boolean).join(', ')}
style={({ pressed }) => [styles.row, index > 0 && styles.rowBorder, pressed && styles.rowPressed]}
>
{checked ? <CheckSquare size={20} color={c.primary} /> : <Square size={20} color={c.textMuted} />}
<View style={styles.rowText}>
<View style={styles.rowTop}>
<Text style={styles.sender} numberOfLines={1}>{sender}</Text>
{item.receivedAt ? <Text style={styles.small}>{dateOf(item.receivedAt)}</Text> : null}
</View>
<Text style={styles.body} numberOfLines={1}>{subject}</Text>
<Text style={styles.small}>{deleted}</Text>
</View>
</Pressable>
);
})}
</View>
<Text style={styles.small}>
{t('recover_deleted.restore_hint', "Restored mail goes back to your Inbox as unread, sorted by its original date, so it may be far down the list. The server restores it in the background. If that fails there, the message is lost and Bulwark can't tell.")}
</Text>
</>
)}
{list.nextPosition !== null && (
<Button variant="outline" size="sm" onPress={() => void showOlder()} loading={list.loadingOlder} style={styles.olderButton}>
{t('recover_deleted.show_older', 'Show older')}
</Button>
)}
</View>
)}
</SettingsSection>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
block: { gap: spacing.sm, marginTop: spacing.sm },
inline: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm },
muted: { ...typography.body, color: c.textMuted },
small: { fontSize: 12, lineHeight: 16, color: c.textMuted },
error: { ...typography.body, color: c.error },
body: { ...typography.body, color: c.text },
toolbar: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: spacing.sm },
selectAll: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, minHeight: 44, paddingRight: spacing.sm },
toolbarButtons: { flexDirection: 'row', gap: spacing.sm, marginLeft: 'auto' },
list: { borderWidth: 1, borderColor: c.border, borderRadius: radius.md, overflow: 'hidden' },
row: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.md, paddingHorizontal: spacing.md, paddingVertical: spacing.md },
rowBorder: { borderTopWidth: 1, borderTopColor: c.border },
rowPressed: { backgroundColor: c.muted },
rowText: { flex: 1, minWidth: 0, gap: 2 },
rowTop: { flexDirection: 'row', alignItems: 'baseline', gap: spacing.sm },
sender: { ...typography.bodyMedium, color: c.text, flex: 1 },
olderButton: { alignSelf: 'flex-start' },
});
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { getDictionary, translate } from '../../i18n';
import { buildSettingsSearchIndex, normalizeSettingsQuery, tabMatchesQuery } from '../settings-search';
// Settings search finds the Stalwart 1.0 pages (webmail lib/settings-search)
// by the words people use for them.
const en = getDictionary('en');
const tEn = (key: string) => translate('en', key);
const index = buildSettingsSearchIndex(en, tEn);
const finds = (tab: 'recovery', label: string, q: string) => tabMatchesQuery(index, tab, label, normalizeSettingsQuery(q));
describe('settings search for Recover deleted items', () => {
it('finds Recover deleted items', () => {
expect(finds('recovery', 'Recover deleted items', 'undelete')).toBe(true);
expect(finds('recovery', 'Recover deleted items', 'emptied')).toBe(true);
expect(finds('recovery', 'Recover deleted items', 'colleague')).toBe(false);
});
});
@@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest';
import {
ARCHIVE_LIST_CAP,
buildRestoreCalls,
classifyArchiveError,
hasArchivedEmail,
listArchivedEmailsPaged,
parseArchivedFrom,
parseArchivedItems,
readRestoreResults,
restoreOutcome,
visibleArchivedItems,
type ArchiveIo,
} from '../archived-items';
import { JMAPMethodError } from '../../../api/jmap-result';
import { ACCOUNT, ARCHIVED_FILE, LICENSE_REFUSAL, archivedEmail, bigArchive, fakeArchiveServer } from './fixtures';
const NOW = Date.parse('2026-10-28T12:00:00Z');
/** An ArchiveIo over the derived server, counting what it is asked. */
function ioFor(items: Array<Record<string, unknown>>) {
const server = fakeArchiveServer(items);
const asked = { queries: 0, gets: 0 };
const io: ArchiveIo = {
query: async (position, limit) => {
asked.queries++;
const [[, result]] = server.answer({ methodCalls: [['x:ArchivedItem/query', { accountId: ACCOUNT, position, limit, calculateTotal: true }, 'q']] }).methodResponses;
return { ids: result.ids as string[], total: result.total as number };
},
get: async (ids) => {
asked.gets++;
const [[, result]] = server.answer({ methodCalls: [['x:ArchivedItem/get', { accountId: ACCOUNT, ids }, 'g']] }).methodResponses;
return result.list as unknown[];
},
};
return { io, asked };
}
describe('parseArchivedFrom', () => {
it('reads the three forms the server stores', () => {
expect(parseArchivedFrom('Org Bob <org-bob@example.org>')).toEqual({ name: 'Org Bob', email: 'org-bob@example.org' });
expect(parseArchivedFrom('org-bob@example.org')).toEqual({ name: '', email: 'org-bob@example.org' });
expect(parseArchivedFrom('Org Bob')).toEqual({ name: 'Org Bob', email: '' });
expect(parseArchivedFrom('')).toEqual({ name: '', email: '' });
});
});
describe('parseArchivedItems', () => {
it('keeps deleted messages with sender, subject and dates, and leaves other kinds out', () => {
const items = parseArchivedItems([archivedEmail('a'), ARCHIVED_FILE, archivedEmail('b', { from: 'carol@example.org', subject: '' })], ACCOUNT);
expect(items).toEqual([
{
id: 'a', fromName: 'Org Bob', fromEmail: 'org-bob@example.org', subject: 'Message a',
receivedAt: '2026-10-01T09:00:00Z', archivedAt: '2026-10-20T08:00:00Z', archivedUntil: '2026-11-19T08:00:00Z',
size: 2048, accountId: ACCOUNT,
},
expect.objectContaining({ id: 'b', fromName: '', fromEmail: 'carol@example.org', subject: '' }),
]);
});
it('drops malformed entries instead of failing', () => {
expect(parseArchivedItems([null, 'x', { '@type': 'Email' }, archivedEmail('c', { archivedAt: 'yesterday' })], ACCOUNT)).toEqual([]);
expect(parseArchivedItems(undefined, ACCOUNT)).toEqual([]);
});
it("keeps only the account's own items from a mixed answer, never one without an account", () => {
const items = parseArchivedItems([
archivedEmail('a'),
archivedEmail('z', { accountId: 'cz' }),
archivedEmail('n', { accountId: undefined }),
archivedEmail('e', { accountId: '' }),
], ACCOUNT);
expect(items.map((i) => i.id)).toEqual(['a']);
});
});
describe('visibleArchivedItems', () => {
const items = parseArchivedItems([
archivedEmail('old', { archivedAt: '2026-10-01T08:00:00Z' }),
archivedEmail('new', { archivedAt: '2026-10-27T08:00:00Z' }),
archivedEmail('mid', { archivedAt: '2026-10-20T08:00:00Z' }),
archivedEmail('gone', { archivedAt: '2026-10-26T08:00:00Z', archivedUntil: '2026-10-27T08:00:00Z' }),
], ACCOUNT);
it('lists everything the server still keeps, newest deletion first, with days 0', () => {
expect(visibleArchivedItems(items, 0, NOW).map((i) => i.id)).toEqual(['new', 'mid', 'old']);
});
it('limits the list to the last N days', () => {
expect(visibleArchivedItems(items, 10, NOW).map((i) => i.id)).toEqual(['new', 'mid']);
expect(visibleArchivedItems(items, 2, NOW).map((i) => i.id)).toEqual(['new']);
expect(visibleArchivedItems(items, 1, NOW)).toEqual([]);
});
});
describe('listArchivedEmailsPaged', () => {
it('reads newest first in pages of 500 and stops after 2000 with where Show older goes on', async () => {
const { io, asked } = ioFor(bigArchive(6000, NOW));
const first = await listArchivedEmailsPaged(io, ACCOUNT, { days: 0, now: NOW });
expect(asked.queries).toBe(ARCHIVE_LIST_CAP / 500);
expect(first.nextPosition).toBe(2000);
// The newest item (past the 5000 an unpaged query would stop at) comes first; files are left out.
expect(first.items[0].id).toBe('i006000');
expect(first.items).toHaveLength(1600);
const second = await listArchivedEmailsPaged(io, ACCOUNT, { days: 0, now: NOW, startPosition: first.nextPosition! });
expect(second.nextPosition).toBe(4000);
const third = await listArchivedEmailsPaged(io, ACCOUNT, { days: 0, now: NOW, startPosition: second.nextPosition! });
expect(third.nextPosition).toBeNull();
const all = [...first.items, ...second.items, ...third.items];
expect(all).toHaveLength(4800);
expect(all[all.length - 1].id).toBe('i000002');
});
it('stops once a whole page is older than the days window', async () => {
// Item n was deleted n minutes ago: one day is items 0-1440, 1153 of them messages.
const { io, asked } = ioFor(bigArchive(6000, NOW));
const result = await listArchivedEmailsPaged(io, ACCOUNT, { days: 1, now: NOW });
expect(result.nextPosition).toBeNull();
// Pages 1-3 hold items inside the day, page 4 (1500-1999) is the first wholly older one.
expect(asked.queries).toBe(4);
expect(result.items.every((i) => Date.parse(i.archivedAt) >= NOW - 86_400_000)).toBe(true);
expect(result.items).toHaveLength(1153);
});
it('ends at the last page', async () => {
const { io, asked } = ioFor(bigArchive(30, NOW));
const result = await listArchivedEmailsPaged(io, ACCOUNT, { days: 0, now: NOW });
expect(result).toMatchObject({ nextPosition: null });
expect(result.items).toHaveLength(24);
expect(asked.queries).toBe(1);
});
});
describe('hasArchivedEmail', () => {
it('looks one item at a time and stops at the first message inside the window', async () => {
const { io, asked } = ioFor([
{ ...ARCHIVED_FILE, id: 'i3', archivedAt: '2026-10-28T11:00:00Z' },
archivedEmail('i2', { archivedAt: '2026-10-28T10:00:00Z', archivedUntil: '2026-11-28T10:00:00Z' }),
archivedEmail('i1', { archivedAt: '2026-10-28T09:00:00Z', archivedUntil: '2026-11-28T10:00:00Z' }),
]);
await expect(hasArchivedEmail(io, ACCOUNT, { days: 7, now: NOW })).resolves.toBe(true);
expect(asked).toEqual({ queries: 2, gets: 2 });
});
it('says no at the first item older than the window', async () => {
const { io, asked } = ioFor([archivedEmail('i1', { archivedAt: '2026-09-01T09:00:00Z', archivedUntil: '2026-11-28T10:00:00Z' })]);
await expect(hasArchivedEmail(io, ACCOUNT, { days: 7, now: NOW })).resolves.toBe(false);
expect(asked.queries).toBe(1);
});
it('says no for an archive of files only, after its limit', async () => {
const files = Array.from({ length: 50 }, (_, n) => ({ ...ARCHIVED_FILE, id: `i${String(n).padStart(3, '0')}`, archivedAt: '2026-10-28T11:00:00Z' }));
const { io, asked } = ioFor(files);
await expect(hasArchivedEmail(io, ACCOUNT, { days: 0, now: NOW, limit: 5 })).resolves.toBe(false);
expect(asked.queries).toBe(5);
});
});
describe('restore calls', () => {
it('asks for requestRestore per item, split at maxObjectsInSet', () => {
const calls = buildRestoreCalls(['a', 'b', 'c'], ACCOUNT, 2);
expect(calls).toEqual([
['x:ArchivedItem/set', { accountId: ACCOUNT, update: { a: { status: 'requestRestore' }, b: { status: 'requestRestore' } } }, 'r0'],
['x:ArchivedItem/set', { accountId: ACCOUNT, update: { c: { status: 'requestRestore' } } }, 'r1'],
]);
});
it('reports every item: restored, refused with the reason and type, or failed with its call', () => {
const calls = buildRestoreCalls(['a', 'b', 'c'], ACCOUNT, 2);
const results = readRestoreResults(calls, [
['x:ArchivedItem/set', { updated: { a: null }, notUpdated: { b: { type: 'notFound' } } }, 'r0'],
['error', LICENSE_REFUSAL, 'r1'],
]);
expect(results).toEqual([
{ id: 'a', ok: true },
{ id: 'b', ok: false, error: 'notFound', type: 'notFound' },
{ id: 'c', ok: false, error: LICENSE_REFUSAL.description, type: 'forbidden' },
]);
});
it('fails the items of a call the answer leaves out', () => {
const calls = buildRestoreCalls(['a'], ACCOUNT, 10);
expect(readRestoreResults(calls, [])).toEqual([{ id: 'a', ok: false, error: 'No answer' }]);
});
});
describe('classifyArchiveError', () => {
it('counts a refusal by the server as unsupported and anything else as an error', () => {
expect(classifyArchiveError(new JMAPMethodError('forbidden', LICENSE_REFUSAL.description))).toBe('unsupported');
expect(classifyArchiveError(new JMAPMethodError('unknownMethod', 'x'))).toBe('unsupported');
expect(classifyArchiveError(new Error('JMAP request failed: 400 - {"type":"urn:ietf:params:jmap:error:unknownCapability"}'))).toBe('unsupported');
expect(classifyArchiveError(new Error('Failed to fetch'))).toBe('error');
expect(classifyArchiveError(new JMAPMethodError('serverFail', 'x'))).toBe('error');
});
});
describe('restoreOutcome', () => {
it('takes restored and vanished items off the list, keeps failures selected and notices a license refusal', () => {
const outcome = restoreOutcome(['a', 'b', 'c', 'd'], [
{ id: 'a', ok: true },
{ id: 'b', ok: false, error: 'notFound', type: 'notFound' },
{ id: 'c', ok: false, error: 'boom' },
]);
expect([...outcome.done]).toEqual(['a']);
expect([...outcome.gone]).toEqual(['b']);
// d got no result at all: it failed too.
expect(outcome.failed).toEqual(['c', 'd']);
expect(outcome.refused).toBe(false);
expect(restoreOutcome(['a'], [{ id: 'a', ok: false, error: 'x', type: 'forbidden' }])).toMatchObject({ failed: ['a'], refused: true });
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* x:ArchivedItem answers for the tests (copied from the webmail's
* lib/recovery/__tests__/fixtures.ts). DERIVED, NOT RECORDED: no test server
* has an Enterprise license, so these are built from the Stalwart source
* (0.16.23 and the 1.0 checkout, identical here):
* - the object: crates/registry/src/schema/structs_impl.rs, `impl IntoValue
* for ArchivedEmail` (from, subject, receivedAt, size, accountId,
* archivedAt, archivedUntil, blobId) inside `ArchivedItem`, tagged `@type`;
* files, events, contacts and scripts are archived too. Dates are
* UTCDateTime's Display ("2026-10-20T08:00:00Z");
* - `from` as the archiving code builds it (1.0
* crates/services/src/index_queue/index.rs, 0.16
* crates/services/src/task_manager/index.rs): "Name <address>", the
* address, or the name. `receivedAt` is the Date header, or the deletion
* time without one, on 1.0, and the received time on 0.16;
* - the query (fakeArchiveServer below): crates/jmap/src/registry/query.rs
* `extract_parameters` and crates/jmap/src/api/query.rs
* `QueryResponseBuilder`. Descending by id by default. Without
* `calculateTotal` the store scans ascending, cut at `limit` or 5000 from
* `position`, then the ids are reversed and `position` is applied again;
* with it everything is scanned and sorted, and position and limit page
* it. A user without Impersonate is refused an `accountId` filter
* (`unsupportedFilter`);
* - the /set answer: crates/jmap/src/registry/mapping/archived_item.rs
* (`updated` with null, `notUpdated` with `notFound`).
* The `forbidden` refusal is the one the unlicensed test servers really
* send (local-data/org-features-facts.md §3).
*/
export const ACCOUNT = 'ci';
export const LICENSE_REFUSAL = {
type: 'forbidden',
description: 'This feature is only available in the Enterprise edition. Obtain your trial license at https://license.stalw.art/trial.',
};
export function archivedEmail(id: string, fields: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
return {
id,
'@type': 'Email',
from: 'Org Bob <org-bob@example.org>',
subject: `Message ${id}`,
receivedAt: '2026-10-01T09:00:00Z',
size: 2048,
accountId: ACCOUNT,
archivedAt: '2026-10-20T08:00:00Z',
archivedUntil: '2026-11-19T08:00:00Z',
blobId: `blob-${id}`,
...fields,
};
}
export const ARCHIVED_FILE = {
id: 'f1',
'@type': 'FileNode',
name: 'plan.pdf',
createdAt: '2026-10-01T09:00:00Z',
accountId: ACCOUNT,
archivedAt: '2026-10-21T08:00:00Z',
archivedUntil: '2026-11-20T08:00:00Z',
blobId: 'blob-f1',
};
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
/**
* `count` archived items, item n deleted n minutes before `now` (so a
* higher n is older); every fifth is a file, the rest messages. Ids sort
* like Stalwart's registry ids: the newest has the highest id.
*/
export function bigArchive(count: number, now: number, accountId = ACCOUNT): Array<Record<string, unknown>> {
const out: Array<Record<string, unknown>> = [];
for (let n = 0; n < count; n++) {
const id = `i${String(count - n).padStart(6, '0')}`;
const archivedAt = iso(now - n * 60_000);
out.push(n % 5 === 4
? { ...ARCHIVED_FILE, id, accountId, archivedAt, archivedUntil: iso(now + 86_400_000) }
: archivedEmail(id, { accountId, archivedAt, archivedUntil: iso(now + 86_400_000), subject: `Message ${n}` }));
}
return out;
}
type Call = [string, Record<string, unknown>, string];
/**
* A stand-in for Stalwart's x:ArchivedItem methods over `items` (any
* accounts), answering one JMAP request body. `impersonate` gives the
* admin's view: every account, and the `accountId` filter accepted.
*/
export function fakeArchiveServer(items: Array<Record<string, unknown>>, options: { impersonate?: boolean; maxResults?: number } = {}) {
const maxResults = options.maxResults ?? 5000;
const byId = new Map(items.map((item) => [item.id as string, item]));
const calls: Call[] = [];
const answer = (body: { methodCalls: Call[] }) => ({
methodResponses: body.methodCalls.map(([method, args, callId]): Call => {
calls.push([method, args, callId]);
const account = args.accountId as string;
if (method === 'x:ArchivedItem/query') {
const filter = args.filter as { accountId?: string } | undefined;
if (filter?.accountId && !options.impersonate) return ['error', { type: 'unsupportedFilter', description: 'Filter on property accountId is not supported or invalid' }, callId];
const owner = filter?.accountId ?? (options.impersonate ? null : account);
const ascending = [...byId.values()].filter((i) => owner === null || i.accountId === owner).map((i) => i.id as string).sort();
const position = (args.position as number | undefined) ?? 0;
const limit = Math.min((args.limit as number | undefined) ?? maxResults, maxResults);
let ids: string[];
if (args.calculateTotal) {
ids = [...ascending].reverse().slice(position, position + limit);
} else {
// The store cuts the ascending scan, then the ids are reversed and position applied again.
const scanned = ascending.slice(position, position + limit).reverse();
ids = scanned.slice(position, position + limit);
}
return [method, { accountId: account, ids, position, ...(args.calculateTotal ? { total: ascending.length } : {}), queryState: 's0', canCalculateChanges: true }, callId];
}
if (method === 'x:ArchivedItem/get') {
const ids = args.ids as string[];
const visible = ids.map((i) => byId.get(i)).filter((i): i is Record<string, unknown> => !!i && (!!options.impersonate || i.accountId === account));
return [method, { accountId: account, list: visible, notFound: ids.filter((i) => !visible.some((v) => v.id === i)) }, callId];
}
if (method === 'x:ArchivedItem/set') {
const ids = Object.keys(args.update as object);
const updated: Record<string, null> = {};
const notUpdated: Record<string, { type: string }> = {};
for (const i of ids) {
if (byId.has(i)) { updated[i] = null; byId.delete(i); } else notUpdated[i] = { type: 'notFound' };
}
return [method, { accountId: account, updated, notUpdated }, callId];
}
return ['error', { type: 'unknownMethod' }, callId];
}),
});
return { answer, calls, remaining: () => [...byId.keys()] };
}
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ERROR_RETRY_MS, RECENT_TTL_MS, UNSUPPORTED_RETRY_MS, recoveryKey, useRecoveryStore } from '../recovery-store';
import { JMAPMethodError } from '../../../api/jmap-result';
import type { ArchiveQueryPage } from '../archived-items';
import { LICENSE_REFUSAL } from './fixtures';
// Ported from the webmail's stores/__tests__/recovery-store.test.ts (the
// app keeps answers for the app run, so the tab-reload and sessionStorage
// cases have no counterpart).
const page = (ids: string[]): ArchiveQueryPage => ({ ids, total: null });
function makeClient(query: () => Promise<ArchiveQueryPage>, stalwart = true, has: () => Promise<boolean> = async () => true) {
return {
advertisesArchivedItems: () => stalwart,
queryArchivedItemIds: vi.fn(query),
hasArchivedEmail: vi.fn(has),
};
}
const refused = () => Promise.reject(new JMAPMethodError('forbidden', LICENSE_REFUSAL.description));
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-28T12:00:00Z'));
});
afterEach(() => {
useRecoveryStore.getState().reset();
vi.useRealTimers();
});
describe('recovery detection', () => {
it('asks a one-item query, hides the feature when the server refuses and asks again only after the retry time', async () => {
const client = makeClient(refused);
const key = recoveryKey('acc-alice', 'ci');
await expect(useRecoveryStore.getState().detect(client, key)).resolves.toBe(false);
expect(client.queryArchivedItemIds).toHaveBeenCalledWith({ limit: 1 });
expect(useRecoveryStore.getState().checks[key]).toEqual({ supported: false, retryAt: Date.now() + UNSUPPORTED_RETRY_MS });
await useRecoveryStore.getState().detect(client, key);
expect(client.queryArchivedItemIds).toHaveBeenCalledTimes(1);
// The license arrives later: the next check after the retry time shows it.
client.queryArchivedItemIds.mockImplementation(async () => page([]));
vi.setSystemTime(Date.now() + UNSUPPORTED_RETRY_MS + 1);
await expect(useRecoveryStore.getState().detect(client, key)).resolves.toBe(true);
expect(useRecoveryStore.getState().checks[key]?.supported).toBe(true);
});
it('asks again before the retry time when forced', async () => {
const client = makeClient(refused);
const key = recoveryKey('acc-alice', 'ci');
await useRecoveryStore.getState().detect(client, key);
await useRecoveryStore.getState().detect(client, key, { force: true });
expect(client.queryArchivedItemIds).toHaveBeenCalledTimes(2);
});
it('hides it on any other error too, and asks again sooner', async () => {
const client = makeClient(() => Promise.reject(new Error('Network request failed')));
const key = recoveryKey('acc-alice', 'ci');
await expect(useRecoveryStore.getState().detect(client, key)).resolves.toBe(false);
expect(useRecoveryStore.getState().checks[key]?.retryAt).toBe(Date.now() + ERROR_RETRY_MS);
});
it('shows it when the query answers, and keeps that', async () => {
const client = makeClient(async () => page(['a']));
const key = recoveryKey('acc-alice', 'ci');
await expect(useRecoveryStore.getState().detect(client, key)).resolves.toBe(true);
vi.setSystemTime(Date.now() + 24 * 60 * 60_000);
await expect(useRecoveryStore.getState().detect(client, key)).resolves.toBe(true);
expect(client.queryArchivedItemIds).toHaveBeenCalledTimes(1);
});
it('asks once for two callers at the same time', async () => {
const client = makeClient(async () => page([]));
const key = recoveryKey('acc-alice', 'ci');
await Promise.all([useRecoveryStore.getState().detect(client, key), useRecoveryStore.getState().detect(client, key)]);
expect(client.queryArchivedItemIds).toHaveBeenCalledTimes(1);
});
it('keeps one answer per login, even where the JMAP account ids are the same', async () => {
await useRecoveryStore.getState().detect(makeClient(async () => page([])), recoveryKey('acc-alice', 'ci'));
await useRecoveryStore.getState().detect(makeClient(refused), recoveryKey('acc-bob', 'ci'));
const { checks } = useRecoveryStore.getState();
expect(checks['acc-alice|ci']?.supported).toBe(true);
expect(checks['acc-bob|ci']?.supported).toBe(false);
});
it("doesn't ask a server that isn't Stalwart (0.16 without the capability)", async () => {
const client = makeClient(async () => page([]), false);
await expect(useRecoveryStore.getState().detect(client, 'k')).resolves.toBe(false);
expect(client.queryArchivedItemIds).not.toHaveBeenCalled();
expect(useRecoveryStore.getState().checks.k).toEqual({ supported: false, retryAt: Infinity });
});
});
describe('the Trash link check', () => {
it('looks once per days value for a while, and again after a restore', async () => {
const client = makeClient(async () => page([]));
const key = 'acc-alice|ci';
await expect(useRecoveryStore.getState().checkRecent(client, key, 7)).resolves.toBe(true);
await useRecoveryStore.getState().checkRecent(client, key, 7);
expect(client.hasArchivedEmail).toHaveBeenCalledTimes(1);
expect(client.hasArchivedEmail).toHaveBeenCalledWith({ days: 7 });
await useRecoveryStore.getState().checkRecent(client, key, 14);
expect(client.hasArchivedEmail).toHaveBeenCalledTimes(2);
useRecoveryStore.getState().forgetRecent(key);
await useRecoveryStore.getState().checkRecent(client, key, 14);
expect(client.hasArchivedEmail).toHaveBeenCalledTimes(3);
vi.setSystemTime(Date.now() + RECENT_TTL_MS + 1);
await useRecoveryStore.getState().checkRecent(client, key, 14);
expect(client.hasArchivedEmail).toHaveBeenCalledTimes(4);
});
it('records a refusal like detection does', async () => {
const client = makeClient(async () => page([]), true, refused);
await expect(useRecoveryStore.getState().checkRecent(client, 'k', 0)).resolves.toBe(false);
expect(useRecoveryStore.getState().checks.k?.supported).toBe(false);
});
});
+308
View File
@@ -0,0 +1,308 @@
/**
* "Recover deleted items" (webmail lib/recovery/archived-items.ts, #1319):
* messages Stalwart keeps after they were deleted for good (an emptied
* Trash, an expunge, Trash cleaned up after its retention). Stalwart
* Enterprise only, and only when the admin sets `archiveDeletedItemsFor`
* (DataRetention). Pure functions; src/api/archived-items.ts sends what they
* build.
*
* - x:ArchivedItem/query, /get, /set: a user without the Impersonate
* permission only ever sees the items of the account the call names and
* is refused an `accountId` filter (`unsupportedFilter`). An admin's query
* covers every account unless it names one in the filter. So the query
* sends the filter, falls back to none on `unsupportedFilter`, and the
* client keeps only items whose `accountId` is the account it asked for.
* - Query order and paging: the default sort is by id, descending (newest
* first). Without `calculateTotal` Stalwart scans ascending, cut at
* `limit` or 5000, and only then reverses, so the newest items past 5000
* are never seen and `position` is applied twice. With `calculateTotal:
* true` it pages correctly, so every page asks for the total.
* - The object: `ArchivedItem`, tagged by `@type` (Email, FileNode,
* CalendarEvent, ContactCard, SieveScript; all count in the query), and
* `ArchivedEmail` (from, subject, receivedAt, size, accountId, archivedAt,
* archivedUntil, blobId). No folder is kept. `from` is "Name <address>",
* the address or the name.
* - Restoring: x:ArchivedItem/set update `{ status: "requestRestore" }`
* removes the item at once and schedules a task that puts the message
* back in the Inbox, unread and without keywords. If that task fails the
* message is lost and the client can't tell. Only emails are restored.
* - Without a license every call answers `forbidden`.
*/
import { jmapErrorType } from '../../api/jmap-result';
export const STALWART_CAPABILITY = 'urn:stalwart:jmap';
export const ARCHIVED_ITEM_USING = ['urn:ietf:params:jmap:core', STALWART_CAPABILITY];
/** Ids per query page. */
export const ARCHIVE_PAGE_SIZE = 500;
/** Items looked at before the list stops and offers "Show older". */
export const ARCHIVE_LIST_CAP = 2000;
/** Items the Trash link looks at, at most, before giving up. */
export const ARCHIVE_PEEK_LIMIT = 20;
const DAY_MS = 24 * 60 * 60 * 1000;
/** One deleted message the server still keeps. */
export interface ArchivedEmail {
id: string;
/** Sender as the server stored it: a name, an address, or both. */
fromName: string;
fromEmail: string;
subject: string;
/** The message's date, ISO. Null when the server had none. */
receivedAt: string | null;
/** When it was deleted for good, ISO. */
archivedAt: string;
/** Until when the server keeps it, ISO. */
archivedUntil: string | null;
size: number;
accountId: string;
}
export interface RestoreResult {
id: string;
ok: boolean;
/** The server's reason when it refused this one, or the error of the whole call. */
error?: string;
/** The JMAP error type, when there was one (`notFound`, `forbidden`). */
type?: string;
}
export interface ArchiveQueryPage {
ids: string[];
total: number | null;
}
/** What the paging needs: one query page, and the raw items of some ids. */
export interface ArchiveIo {
query: (position: number, limit: number) => Promise<ArchiveQueryPage>;
get: (ids: string[]) => Promise<unknown[]>;
}
export interface ArchiveListResult {
items: ArchivedEmail[];
/** Where "Show older" goes on; null when there is nothing older to look at. */
nextPosition: number | null;
}
export type ArchiveCall = [string, Record<string, unknown>, string];
function str(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function isoOrNull(value: unknown): string | null {
if (typeof value !== 'string' || !value) return null;
return Number.isNaN(Date.parse(value)) ? null : value;
}
/** "Name <address>", "address" or "Name", as the server builds it. */
export function parseArchivedFrom(from: string): { name: string; email: string } {
const value = from.trim();
const angle = /^(.*?)\s*<([^<>\s]+)>$/.exec(value);
if (angle) return { name: angle[1].replace(/^"(.*)"$/, '$1').trim(), email: angle[2] };
if (/^[^\s@]+@[^\s@]+$/.test(value)) return { name: '', email: value };
return { name: value, email: '' };
}
function asRecord(raw: unknown): Record<string, unknown> | null {
return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
}
/**
* The deleted messages of `accountId` in an x:ArchivedItem/get list. Other
* kinds (files, events, contacts, scripts) are left out: the server can't
* restore them. So is every item of another account, and every item that
* doesn't say whose it is.
*/
export function parseArchivedItems(list: unknown, accountId: string): ArchivedEmail[] {
if (!Array.isArray(list)) return [];
const out: ArchivedEmail[] = [];
for (const raw of list) {
const item = asRecord(raw);
if (!item || item['@type'] !== 'Email') continue;
const id = str(item.id);
const archivedAt = isoOrNull(item.archivedAt);
if (!id || !archivedAt) continue;
if (str(item.accountId) !== accountId) continue;
const from = parseArchivedFrom(str(item.from));
out.push({
id,
fromName: from.name,
fromEmail: from.email,
subject: str(item.subject),
receivedAt: isoOrNull(item.receivedAt),
archivedAt,
archivedUntil: isoOrNull(item.archivedUntil),
size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0,
accountId,
});
}
return out;
}
/** The start of the days window, or -Infinity for 0 (everything the server keeps). */
export function daysCutoff(days: number, now: number): number {
return days > 0 ? now - days * DAY_MS : -Infinity;
}
/** Whether an item is shown: deleted inside the window and still kept by the server. */
function inWindow(item: ArchivedEmail, cutoff: number, now: number): boolean {
if (Date.parse(item.archivedAt) < cutoff) return false;
return !item.archivedUntil || Date.parse(item.archivedUntil) > now;
}
/**
* What the user sees: items deleted within the last `days` (0 = all the
* server keeps), without those the server no longer keeps, newest first.
*/
export function visibleArchivedItems(items: ArchivedEmail[], days: number, now: number = Date.now()): ArchivedEmail[] {
const cutoff = daysCutoff(days, now);
return items
.filter((item) => inWindow(item, cutoff, now))
.sort((a, b) => Date.parse(b.archivedAt) - Date.parse(a.archivedAt) || (a.id < b.id ? 1 : -1));
}
/** Every raw item of a page is older than the window (of any kind; a page with an undated item isn't). */
function wholePageOlder(raw: unknown[], cutoff: number): boolean {
if (cutoff === -Infinity || raw.length === 0) return false;
return raw.every((r) => {
const at = isoOrNull(asRecord(r)?.archivedAt);
return at !== null && Date.parse(at) < cutoff;
});
}
/**
* Reads the archive newest first in pages of ARCHIVE_PAGE_SIZE from
* `startPosition`, with the details of each page. Stops at the end, once a
* whole page is older than the days window, or after ARCHIVE_LIST_CAP items
* (then `nextPosition` says where "Show older" goes on).
*/
export async function listArchivedEmailsPaged(
io: ArchiveIo,
accountId: string,
options: { days: number; now?: number; startPosition?: number; cap?: number; pageSize?: number },
): Promise<ArchiveListResult> {
const now = options.now ?? Date.now();
const cutoff = daysCutoff(options.days, now);
const pageSize = options.pageSize ?? ARCHIVE_PAGE_SIZE;
const cap = options.cap ?? ARCHIVE_LIST_CAP;
let position = options.startPosition ?? 0;
let looked = 0;
const items: ArchivedEmail[] = [];
for (;;) {
const page = await io.query(position, pageSize);
if (page.ids.length === 0) return { items, nextPosition: null };
const raw = await io.get(page.ids);
items.push(...parseArchivedItems(raw, accountId).filter((item) => inWindow(item, cutoff, now)));
position += page.ids.length;
looked += page.ids.length;
if (wholePageOlder(raw, cutoff)) return { items, nextPosition: null };
if (page.total !== null && position >= page.total) return { items, nextPosition: null };
if (page.ids.length < pageSize && page.total === null) return { items, nextPosition: null };
if (looked >= cap) return { items, nextPosition: position };
}
}
/**
* Whether the archive holds at least one message of `accountId` inside the
* days window, looking at one item at a time, newest first: stops at the
* first such message, at the first item older than the window, or after
* `limit` items.
*/
export async function hasArchivedEmail(
io: ArchiveIo,
accountId: string,
options: { days: number; now?: number; limit?: number },
): Promise<boolean> {
const now = options.now ?? Date.now();
const cutoff = daysCutoff(options.days, now);
const limit = options.limit ?? ARCHIVE_PEEK_LIMIT;
for (let position = 0; position < limit; position++) {
const page = await io.query(position, 1);
if (page.ids.length === 0) return false;
const raw = await io.get(page.ids);
if (parseArchivedItems(raw, accountId).some((item) => inWindow(item, cutoff, now))) return true;
if (wholePageOlder(raw, cutoff)) return false;
if (page.total !== null && position + 1 >= page.total) return false;
}
return false;
}
/**
* The calls that restore `ids`, split at maxObjectsInSet. The narrowest
* change a user may make: only `status` (and `archivedUntil`) are writable.
*/
export function buildRestoreCalls(ids: string[], accountId: string, maxObjectsInSet: number): ArchiveCall[] {
const step = Math.max(1, Math.floor(maxObjectsInSet));
const calls: ArchiveCall[] = [];
for (let i = 0; i < ids.length; i += step) {
const update = Object.fromEntries(ids.slice(i, i + step).map((id) => [id, { status: 'requestRestore' }]));
calls.push(['x:ArchivedItem/set', { accountId, update }, `r${calls.length}`]);
}
return calls;
}
/** Each id of a restore call: updated, refused with the reason, or failed with the call. */
export function readRestoreResults(calls: ArchiveCall[], responses: ArchiveCall[]): RestoreResult[] {
const byCallId = new Map(responses.map((r) => [r[2], r]));
const results: RestoreResult[] = [];
for (const [, args, callId] of calls) {
const ids = Object.keys(args.update as Record<string, unknown>);
const [method, result] = byCallId.get(callId) ?? [];
if (method !== 'x:ArchivedItem/set' || !result) {
const type = method === 'error' ? str(result?.type) || undefined : undefined;
const reason = method === 'error' ? str(result?.description) || type || 'error' : 'No answer';
results.push(...ids.map((id) => ({ id, ok: false, error: reason, ...(type ? { type } : {}) })));
continue;
}
const updated = (result.updated ?? {}) as Record<string, unknown>;
const notUpdated = (result.notUpdated ?? {}) as Record<string, { type?: string; description?: string } | undefined>;
for (const id of ids) {
if (id in updated) results.push({ id, ok: true });
else {
const refused = notUpdated[id];
results.push({ id, ok: false, error: refused?.description || refused?.type || 'notUpdated', ...(refused?.type ? { type: refused.type } : {}) });
}
}
}
return results;
}
/**
* How a failed archive call counts for detection: `unsupported` when the
* server says no (no Enterprise license: `forbidden`; not Stalwart or too
* old: `unknownMethod`, `unknownCapability`), `error` for anything else
* (offline, a server error), which is asked again sooner.
*/
export function classifyArchiveError(error: unknown): 'unsupported' | 'error' {
const type = jmapErrorType(error);
if (type === 'forbidden' || type === 'unknownMethod' || type === 'unknownCapability' || type === 'accountNotFound') return 'unsupported';
const message = error instanceof Error ? error.message : String(error);
if (/unknownCapability|unknownMethod/.test(message)) return 'unsupported';
return 'error';
}
export interface RestoreOutcome {
/** Restored: they leave the list. */
done: Set<string>;
/** No longer in the archive (`notFound`): they leave the list too. */
gone: Set<string>;
/** Failed: they stay selected to try again. */
failed: string[];
/** The server refused for want of a license: the page hides. */
refused: boolean;
}
/** What a restore of `ids` did, as the page shows it (webmail RecoverDeletedSettings.restore). */
export function restoreOutcome(ids: string[], results: RestoreResult[]): RestoreOutcome {
const done = new Set(results.filter((r) => r.ok).map((r) => r.id));
const gone = new Set(results.filter((r) => !r.ok && r.type === 'notFound').map((r) => r.id));
return {
done,
gone,
failed: ids.filter((id) => !done.has(id) && !gone.has(id)),
refused: results.some((r) => r.type === 'forbidden'),
};
}
+17
View File
@@ -0,0 +1,17 @@
// What the organisation policy says about "Recover deleted items". The
// webmail reads it from its policy store: the admin can hide the page
// (restriction `deletedItemsRecovery`) and limit the list to the last N days
// (`organisation.recovery.days`, 0 = everything the server keeps). The app
// has no policy route yet, so this answers "shown, no limit"; once the
// policy arrives, this is the one place to plug it in.
export interface RecoveryPolicy {
/** The admin hid the page: nothing is asked of the server. */
hidden: boolean;
/** Days of deleted mail to list; 0 = all the server keeps. */
days: number;
}
export function recoveryPolicy(): RecoveryPolicy {
return { hidden: false, days: 0 };
}
+131
View File
@@ -0,0 +1,131 @@
import { create } from 'zustand';
import { classifyArchiveError, type ArchiveQueryPage } from './archived-items';
/**
* Whether "Recover deleted items" works for an account (webmail
* stores/recovery-store.ts), asked once per app run and account with a
* one-item x:ArchivedItem/query. The answer can change while the app runs
* (an admin installs the license, or it runs out), so a "no" is asked again
* after a while. Separately, whether the archive holds a recent message,
* which decides the Trash link.
*/
/** A server that said no (no license, not Stalwart) is asked again after this. */
export const UNSUPPORTED_RETRY_MS = 30 * 60_000;
/** An error that says nothing about the server (offline, a server error) is asked again sooner. */
export const ERROR_RETRY_MS = 2 * 60_000;
/** How long the Trash link trusts its look at the archive. */
export const RECENT_TTL_MS = 5 * 60_000;
export interface RecoveryCheck {
supported: boolean;
/** When to ask again; null while supported, Infinity when it never changes. */
retryAt: number | null;
}
export interface RecentCheck {
has: boolean;
days: number;
checkedAt: number;
}
/** The parts of src/api/archived-items the store asks, for one account. */
export interface RecoveryClient {
advertisesArchivedItems: () => boolean;
queryArchivedItemIds: (options: { limit?: number }) => Promise<ArchiveQueryPage>;
hasArchivedEmail: (options: { days?: number }) => Promise<boolean>;
}
interface RecoveryState {
checks: Record<string, RecoveryCheck>;
recent: Record<string, RecentCheck>;
/** Asks the server unless a fresh answer is cached; resolves to whether it works. */
detect: (client: RecoveryClient, key: string, options?: { force?: boolean }) => Promise<boolean>;
/** Records what a list or restore call learned, so the page and the Trash link agree. */
record: (key: string, outcome: 'supported' | 'unsupported' | 'error') => void;
/** Whether the archive holds a message inside the days window, for the Trash link. */
checkRecent: (client: RecoveryClient, key: string, days: number) => Promise<boolean>;
/** The archive changed (a restore): look again next time. */
forgetRecent: (key: string) => void;
reset: () => void;
}
const inFlight = new Map<string, Promise<boolean>>();
const recentInFlight = new Map<string, Promise<boolean>>();
function checkFor(outcome: 'supported' | 'unsupported' | 'error', now: number): RecoveryCheck {
if (outcome === 'supported') return { supported: true, retryAt: null };
return { supported: false, retryAt: now + (outcome === 'unsupported' ? UNSUPPORTED_RETRY_MS : ERROR_RETRY_MS) };
}
/** The cache key: the signed-in login and the JMAP account in it. */
export function recoveryKey(localAccountId: string | null | undefined, jmapAccountId: string): string {
return `${localAccountId ?? ''}|${jmapAccountId}`;
}
export const useRecoveryStore = create<RecoveryState>()((set, get) => ({
checks: {},
recent: {},
detect: (client, key, options) => {
const cached = get().checks[key];
if (!options?.force && cached && (cached.supported || (cached.retryAt ?? 0) > Date.now())) {
return Promise.resolve(cached.supported);
}
if (!client.advertisesArchivedItems()) {
// Not Stalwart: nothing will change while the session lasts.
set((state) => ({ checks: { ...state.checks, [key]: { supported: false, retryAt: Infinity } } }));
return Promise.resolve(false);
}
const running = inFlight.get(key);
if (running) return running;
const probe = client.queryArchivedItemIds({ limit: 1 })
.then(() => 'supported' as const)
.catch((error: unknown) => classifyArchiveError(error))
.then((outcome) => {
get().record(key, outcome);
return outcome === 'supported';
})
.finally(() => inFlight.delete(key));
inFlight.set(key, probe);
return probe;
},
record: (key, outcome) => {
set((state) => ({ checks: { ...state.checks, [key]: checkFor(outcome, Date.now()) } }));
},
checkRecent: (client, key, days) => {
const cached = get().recent[key];
if (cached && cached.days === days && Date.now() - cached.checkedAt < RECENT_TTL_MS) return Promise.resolve(cached.has);
const flightKey = `${key}|${days}`;
const running = recentInFlight.get(flightKey);
if (running) return running;
const look = client.hasArchivedEmail({ days })
.catch((error: unknown) => {
if (classifyArchiveError(error) === 'unsupported') get().record(key, 'unsupported');
return false;
})
.then((has) => {
set((state) => ({ recent: { ...state.recent, [key]: { has, days, checkedAt: Date.now() } } }));
return has;
})
.finally(() => recentInFlight.delete(flightKey));
recentInFlight.set(flightKey, look);
return look;
},
forgetRecent: (key) => {
set((state) => {
const recent = { ...state.recent };
delete recent[key];
return { recent };
});
},
reset: () => {
inFlight.clear();
recentInFlight.clear();
set({ checks: {}, recent: {} });
},
}));
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { jmapClient } from '../../api/jmap-client';
import {
advertisesArchivedItems,
hasArchivedEmail,
queryArchivedItemIds,
} from '../../api/archived-items';
import { useAuthStore } from '../../stores/auth-store';
import { recoveryPolicy } from './policy';
import { recoveryKey, useRecoveryStore, type RecoveryClient } from './recovery-store';
/** The archive calls for one JMAP account, as the detection store asks them. */
export function recoveryClientFor(accountId: string): RecoveryClient {
return {
advertisesArchivedItems: () => advertisesArchivedItems(accountId),
queryArchivedItemIds: (options) => queryArchivedItemIds(accountId, options),
hasArchivedEmail: (options) => hasArchivedEmail(accountId, options),
};
}
export interface DeletedItemsRecovery {
/** Offered for the active account (the server has the feature). */
available: boolean;
/** The JMAP account the page is for; null while there is no session. */
accountId: string | null;
/** The detection cache key of the active account. */
key: string | null;
}
/**
* Whether "Recover deleted items" is offered for the active account
* (webmail hooks/use-deleted-items-recovery.ts). Nothing is asked until the
* live session is in, nor when the policy hides it (see ./policy). Then the
* server must answer x:ArchivedItem/query (Stalwart with an Enterprise
* license). Asked once per account and app run, and again after a refusal
* once its retry time comes, also while Settings stays open.
*/
export function useDeletedItemsRecovery(): DeletedItemsRecovery {
const session = useAuthStore((s) => s.session);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const accountId = session ? jmapClient.accountId || null : null;
const off = recoveryPolicy().hidden;
const key = accountId ? recoveryKey(activeAccountId, accountId) : null;
const check = useRecoveryStore((s) => (key ? s.checks[key] : undefined));
const detect = useRecoveryStore((s) => s.detect);
useEffect(() => {
if (off || !accountId || !key) return undefined;
if (!check) {
void detect(recoveryClientFor(accountId), key);
return undefined;
}
if (check.supported || check.retryAt === null || !Number.isFinite(check.retryAt)) return undefined;
const timer = setTimeout(
() => void detect(recoveryClientFor(accountId), key, { force: true }),
Math.max(0, check.retryAt - Date.now()),
);
return () => clearTimeout(timer);
}, [off, accountId, key, check, detect]);
return { available: !off && !!check?.supported, accountId, key };
}
+4 -1
View File
@@ -17,7 +17,7 @@ export type SettingsTabId =
| 'account' | 'language' | 'notifications' | 'accessibility'
| 'appearance' | 'layout'
| 'reading' | 'composing' | 'identities' | 'vacation'
| 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads'
| 'filters' | 'recovery' | 'templates' | 'folders' | 'keywords' | 'downloads'
| 'security' | 'content_senders'
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
// about_data is Storage & backup (the id predates the split from About);
@@ -38,6 +38,8 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
'settings.account.accounts',
'settings.account.shared_accounts',
],
// Recover deleted items shows only where the server has it.
recovery: ['recover_deleted.title'],
language: [
'settings.appearance.language',
'settings.language_region.date_format',
@@ -256,6 +258,7 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
// translation doesn't contain the literal word.
export const SETTINGS_SEARCH_KEYWORDS: Record<SettingsTabId, string> = {
account: 'profile email password user signin signout reorder switch default multi-account shared storage quota',
recovery: 'recover restore undelete deleted trash emptied lost messages',
language: 'locale region date time format translation',
notifications: 'alert push badge unifiedpush relay device reminder',
accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm',
+24 -5
View File
@@ -9,7 +9,7 @@ import {
Filter, FileText, FolderOpen, Tags, HardDrive,
BookUser, PanelLeftClose, Bell, RefreshCw,
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Download,
Database, Bug, Scale,
Database, Bug, Scale, RotateCcw,
type AppIcon,
} from '../components/icons';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
@@ -45,6 +45,8 @@ import { LanguageSettings } from '../components/settings/LanguageSettings';
import { ComposingSettings } from '../components/settings/ComposingSettings';
import { LayoutSettings } from '../components/settings/LayoutSettings';
import { DownloadsSettings } from '../components/settings/DownloadsSettings';
import { RecoverDeletedSettings } from '../components/settings/RecoverDeletedSettings';
import { useDeletedItemsRecovery } from '../lib/recovery/use-deleted-items-recovery';
import { useLocaleStore } from '../stores/locale-store';
import { useAuthStore } from '../stores/auth-store';
import { useManagedAccountStore } from '../stores/managed-account-store';
@@ -112,6 +114,7 @@ const TABS: TabDef[] = [
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'mail', implemented: true },
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'mail', implemented: true },
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'mail', implemented: true },
{ id: 'recovery', label: 'Recover deleted items', icon: RotateCcw, group: 'mail', implemented: true },
{ id: 'templates', label: 'Templates', icon: FileText, group: 'mail', implemented: true },
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'mail', implemented: true },
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'mail', implemented: true },
@@ -145,6 +148,7 @@ const TAB_LABEL_KEYS: Partial<Record<Tab, string>> = {
debug: 'settings.tabs.troubleshooting',
licences: 'settings.about.licences',
privacy: 'settings.about.privacy',
recovery: 'recover_deleted.tab',
};
const tabLabelKey = (id: Tab) => TAB_LABEL_KEYS[id] ?? `settings.tabs.${id}`;
@@ -164,6 +168,7 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
folders: FolderSettings,
keywords: KeywordSettings,
downloads: DownloadsSettings,
recovery: RecoverDeletedSettings,
security: AccountSecuritySettings,
content_senders: ContentSendersSettings,
calendar: CalendarSettings,
@@ -185,11 +190,11 @@ const AVAILABLE_TABS: TabDef[] = TABS.filter(
(t) => !t.hidden && t.id !== 'account' && (t.id !== 'updates' || supportsSideloadUpdates),
);
function groupTabs(only: ReadonlySet<Tab> | null) {
function groupTabs(only: ReadonlySet<Tab> | null, hidden: ReadonlySet<Tab>) {
return GROUP_ORDER.map(group => ({
group,
label: GROUP_LABELS[group],
items: AVAILABLE_TABS.filter(t => t.group === group && (!only || only.has(t.id))),
items: AVAILABLE_TABS.filter(t => t.group === group && (!only || only.has(t.id)) && !hidden.has(t.id)),
})).filter(g => g.items.length > 0);
}
@@ -217,6 +222,14 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
const hasFiles = useHasFiles();
const hasSieve = useHasSieve();
const hasVacation = useHasVacation();
// Webmail 1.19: Recover deleted items is left out of the list, not shown
// as "Not on this server", where the server lacks it.
const recoveryAvailable = useDeletedItemsRecovery().available;
const hiddenTabs = React.useMemo(() => {
const set = new Set<Tab>();
if (!recoveryAvailable) set.add('recovery');
return set;
}, [recoveryAvailable]);
// A shared/group account picked under "Shared with me" scopes Settings to
// the panes that account can be managed in (webmail: scoped settings).
@@ -259,7 +272,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
}, [pendingTab, clearManagedAccount]);
const groupedTabs = React.useMemo(() => {
void locale; // dependency: re-translate on locale change
return groupTabs(scopedTabs).map((g) => ({
return groupTabs(scopedTabs, hiddenTabs).map((g) => ({
...g,
label: t(`settings.tab_groups.${g.group}`, g.label),
items: g.items.map((tab) => ({
@@ -267,7 +280,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
label: t(tabLabelKey(tab.id), tab.label),
})),
}));
}, [locale, t, scopedTabs]);
}, [locale, t, scopedTabs, hiddenTabs]);
// The second line of each row: the pane's current value.
const settings = useSettingsStore();
@@ -325,6 +338,12 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
.filter((g) => g.items.length > 0)
: groupedTabs;
// A page whose feature went away (the license ran out, another account)
// closes instead of staying open empty.
useEffect(() => {
if (selectedTab && hiddenTabs.has(selectedTab)) setSelectedTab(null);
}, [selectedTab, hiddenTabs]);
// Back from a page opened inside another one returns to that page.
const closePane = React.useCallback(() => {
setSelectedTab((tab) => TABS.find((def) => def.id === tab)?.parent ?? null);