fix: say a message deleted elsewhere is gone instead of failing to load it

This commit is contained in:
Linus Rath
2026-10-31 13:52:38 +01:00
parent 9bc7b4a5b8
commit f2513de025
3 changed files with 58 additions and 8 deletions
+28 -1
View File
@@ -1,4 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'own', serverUrl: 'https://mail.example', username: 'me' },
@@ -15,7 +17,7 @@ import { getEmailFlags, getFullEmailsWithState, getThreadsHeaders } from '../../
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
import { dispatchStateChange } from '../state-change-bus';
import {
clearEmailDetailCache, loadDetail, loadDetails, loadThread, needsRevalidation, noteEmailState,
clearEmailDetailCache, EmailNotFoundError, loadDetail, loadDetails, loadThread, needsRevalidation, noteEmailState,
patchDetail, peekDetail, peekRow, peekThread, prefetchMessage, rememberRows, subscribeEmailCache,
} from '../email-detail-cache';
@@ -158,6 +160,31 @@ describe('loading a message', () => {
expect(peekDetail('e1')).toBeUndefined();
});
// A row deleted elsewhere opened "Failed to load email": the viewer could
// not tell a missing message from a failed load.
it('tells a message the server no longer has from a failed load', async () => {
fullGet.mockResolvedValueOnce(fullResponse([], 's1', ['e9']));
await expect(loadDetail('e9')).rejects.toBeInstanceOf(EmailNotFoundError);
fullGet.mockResolvedValueOnce(fullResponse([full('e1')], 's1'));
await loadDetail('e1');
noteEmailState(undefined, 's2');
flagsGet.mockResolvedValueOnce({ list: [], notFound: ['e1'], state: 's2' });
await expect(loadDetail('e1')).rejects.toBeInstanceOf(EmailNotFoundError);
fullGet.mockRejectedValueOnce(new TypeError('Network request failed'));
const failed = await loadDetail('e3').catch((err: unknown) => err);
expect(failed).not.toBeInstanceOf(EmailNotFoundError);
});
it('has the viewer say a gone message is no longer available and refresh the list', () => {
const viewer = fs.readFileSync(path.resolve(__dirname, '..', '..', 'screens/EmailThreadScreen.tsx'), 'utf8');
expect(viewer).toMatch(/if \(err instanceof EmailNotFoundError\) onGone\?\.\(\);/);
expect(viewer).toMatch(/const fetched = await ensureDetail\(activeEmailId, \(\) => \{ gone = true; \}\);/);
expect(viewer).toMatch(/t\('deep_link\.message_not_found', 'This message is no longer available\.'\)/);
expect(viewer).toMatch(/if \(gone\) void useEmailStore\.getState\(\)\.refreshEmails\(\);/);
});
it('rejects with the fetch error when nothing is held', async () => {
fullGet.mockRejectedValue(new Error('boom'));
await expect(loadDetail('e1')).rejects.toThrow('boom');
+13 -2
View File
@@ -177,6 +177,17 @@ function localSince(key: string, startedAt: number): FlagsHint | undefined {
return local && local.at > startedAt ? local.patch : undefined;
}
/**
* The server has no such message (deleted or moved out of reach elsewhere),
* as opposed to a load that failed: the viewer says it is gone.
*/
export class EmailNotFoundError extends Error {
constructor(id: string) {
super(`Email ${id} not found`);
this.name = 'EmailNotFoundError';
}
}
/** The copy held for a message, if any. Synchronous: for rendering. */
export function peekDetail(id: string, accountId?: string): Email | undefined {
return details.get(keyOf(id, accountId))?.email;
@@ -292,7 +303,7 @@ async function fetchBodies(
for (const id of ids) {
const email = found.get(id);
if (email) waiting.get(id)?.resolve(email);
else waiting.get(id)?.reject(new Error(`Email ${id} not found`));
else waiting.get(id)?.reject(new EmailNotFoundError(id));
}
} catch (err) {
for (const id of ids) waiting.get(id)?.reject(err);
@@ -345,7 +356,7 @@ async function revalidate(
for (const id of ids) {
const email = peekDetail(id, accountId);
if (email) waiting.get(id)?.resolve(email);
else waiting.get(id)?.reject(new Error(`Email ${id} not found`));
else waiting.get(id)?.reject(gone.has(id) ? new EmailNotFoundError(id) : new Error(`Email ${id} not found`));
}
}
+17 -5
View File
@@ -37,7 +37,7 @@ import {
import { patchKeywordsForEmails } from '../api/email';
import {
loadDetail, loadDetails, loadThread, patchDetail, peekDetail, peekRow, peekThread, subscribeEmailCache,
type FlagsHint,
EmailNotFoundError, type FlagsHint,
} from '../lib/email-detail-cache';
import { shareEmailEml } from '../lib/email-export';
import { useKeywordsStore, useAccountKeywordDefs, keywordToken, type KeywordDef } from '../stores/keywords-store';
@@ -237,9 +237,13 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
// Cache first: a held copy renders at once and the promise resolves with the
// checked one (which mark-as-read goes by). Concurrent loads share a request.
// `onGone` hears when the server has no such message any more.
const ensureDetail = React.useCallback(
(id: string): Promise<Email | null> =>
loadDetail(id, ownerAccountId, hintFor(id)).catch(() => peekDetail(id, ownerAccountId) ?? null),
(id: string, onGone?: () => void): Promise<Email | null> =>
loadDetail(id, ownerAccountId, hintFor(id)).catch((err) => {
if (err instanceof EmailNotFoundError) onGone?.();
return peekDetail(id, ownerAccountId) ?? null;
}),
[ownerAccountId, hintFor],
);
const ensureDetails = React.useCallback((ids: string[]) => {
@@ -418,10 +422,18 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
// this lands. Then mark it read per the user's delay.
setError(null);
void (async () => {
const fetched = await ensureDetail(activeEmailId);
let gone = false;
const fetched = await ensureDetail(activeEmailId, () => { gone = true; });
if (cancelled) return;
if (!fetched) {
if (!detailOf(activeEmailId)) setError(t('email_viewer.load_failed', 'Failed to load email'));
if (!detailOf(activeEmailId)) {
// Deleted or moved elsewhere: say so (webmail deep_link
// message_not_found) and let the list drop its row.
setError(gone
? t('deep_link.message_not_found', 'This message is no longer available.')
: t('email_viewer.load_failed', 'Failed to load email'));
if (gone) void useEmailStore.getState().refreshEmails();
}
return;
}
cancelRead = scheduleMarkRead(fetched);