From f2513de0251fc768cd26ea78ed5a6e888788ecb8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:49:12 +0100 Subject: [PATCH] fix: say a message deleted elsewhere is gone instead of failing to load it --- src/lib/__tests__/email-detail-cache.test.ts | 29 +++++++++++++++++++- src/lib/email-detail-cache.ts | 15 ++++++++-- src/screens/EmailThreadScreen.tsx | 22 +++++++++++---- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/lib/__tests__/email-detail-cache.test.ts b/src/lib/__tests__/email-detail-cache.test.ts index 780d0e67..1ce91d6a 100644 --- a/src/lib/__tests__/email-detail-cache.test.ts +++ b/src/lib/__tests__/email-detail-cache.test.ts @@ -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'); diff --git a/src/lib/email-detail-cache.ts b/src/lib/email-detail-cache.ts index cce18d61..2ac4b900 100644 --- a/src/lib/email-detail-cache.ts +++ b/src/lib/email-detail-cache.ts @@ -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`)); } } diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 0771d07c..ab16ce41 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -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 => - loadDetail(id, ownerAccountId, hintFor(id)).catch(() => peekDetail(id, ownerAccountId) ?? null), + (id: string, onGone?: () => void): Promise => + 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);