From af2b00dd3542e64cd732c6eb432ab2b84f998b5a Mon Sep 17 00:00:00 2001 From: dealerweb Date: Thu, 23 Jul 2026 13:26:05 +0200 Subject: [PATCH] Fix: stop resurrecting deleted rows in the mailbox refresh merge Fixes #592. refreshCurrentMailbox merges the refreshed first page with the already loaded list, appending existing entries beyond a cutoff. That cutoff was derived from the refreshed list's length - so whenever a folder shrank, the fresh page was shorter than the stale list and the loop re-appended the deleted rows from stale local state, despite the comment right above promising the opposite. The visible result is the reported bug: after sending a draft, the Drafts view keeps showing a ghost row for the already-destroyed draft. The send actually succeeded - resending the ghost delivers the mail again, which we reproduced with a live JMAP trace: four successful submissions, an empty server-side Drafts folder, a notFound ghost id, and five delivered copies. Deriving the cutoff from the page size fixes the shrink case while preserving the merge's intent for arrivals and loaded deeper pages; regression tests cover all three shapes. Also surface post-send filing failures instead of dropping them, as flagged in 4dc76bbb's follow-up note: a rejected onSuccessUpdateEmail patch or old-draft destroy now logs the server's error details and returns a filingError on SendEmailResult, and the UI shows a warning toast (all 23 locales) so a stale draft row is never again mistaken for a failed send. A plugin veto of the send leaves a debug trace. --- app/(main)/[locale]/page.tsx | 8 ++ components/email/email-composer.tsx | 7 +- lib/jmap/client.ts | 23 +++- lib/jmap/types.ts | 6 ++ locales/ar/common.json | 3 +- locales/cs/common.json | 3 +- locales/da/common.json | 3 +- locales/de/common.json | 3 +- locales/en/common.json | 3 +- locales/es/common.json | 3 +- locales/fa/common.json | 3 +- locales/fr/common.json | 3 +- locales/he/common.json | 3 +- locales/hu/common.json | 3 +- locales/it/common.json | 3 +- locales/ja/common.json | 3 +- locales/ko/common.json | 3 +- locales/lv/common.json | 3 +- locales/nl/common.json | 3 +- locales/pl/common.json | 3 +- locales/pt/common.json | 3 +- locales/ro/common.json | 3 +- locales/ru/common.json | 3 +- locales/sk/common.json | 3 +- locales/tr/common.json | 3 +- locales/uk/common.json | 3 +- locales/zh/common.json | 3 +- .../email-store-refresh-merge.test.ts | 102 ++++++++++++++++++ stores/email-store.ts | 9 +- 29 files changed, 197 insertions(+), 27 deletions(-) create mode 100644 stores/__tests__/email-store-refresh-merge.test.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index d475056c..33613ef8 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1222,6 +1222,14 @@ export default function Home() { const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt }); setShowComposer(false); + if (result.filingError) { + // The mail went out, but a post-send step (filing to Sent / + // removing the old draft) was rejected - warn instead of staying + // silent, so a stale draft row is not mistaken for a failed send + // and re-sent (#592). + const toastInstance = (await import('sonner')).toast; + toastInstance.warning(t('email_composer.send_filing_warning')); + } if (result.scheduled) { await refreshScheduledMetadata(client); if (isScheduledView) await fetchScheduledEmails(client); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 9847ffd9..f5abe5d5 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1848,7 +1848,12 @@ export function EmailComposer({ inReplyTo: threadingHeaders?.inReplyTo?.[0], }; const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview); - if (!sendAllowed) return; + if (!sendAllowed) { + // A plugin vetoed the send (it is expected to show its own UI). + // Leave a trace so a silent no-op send is diagnosable (#592). + debug.log('email', 'Send aborted by an onBeforeEmailSend plugin handler'); + return; + } // Hand off to a crypto plugin (S/MIME, PGP, …) if one wants to take over // the send: it builds raw MIME, signs/encrypts, and submits via diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 770d28bd..c5285d59 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2707,6 +2707,7 @@ export class JMAPClient implements IJMAPClient { let createdEmailId: string | undefined; let emailSubmissionId: string | undefined; let serverSendAt: string | undefined; + let filingError: string | undefined; if (response.methodResponses) { for (const [methodName, result] of response.methodResponses) { @@ -2740,6 +2741,24 @@ export class JMAPClient implements IJMAPClient { ); } + // Post-submission filing problems (the implicit Email/set from + // onSuccessUpdateEmail, or destroying the old draft) must not fail + // the send - the message already left - but they must not stay + // silent either: a silently rejected filing/cleanup is exactly how + // "sent mail still sits in Drafts" reports look (#592, #588's + // sibling note in 4dc76bbb). Log the details and surface a warning + // to the caller. + if (result.notUpdated && Object.keys(result.notUpdated).length) { + console.error(`[sendEmail] ${methodName} notUpdated:`, JSON.stringify(result.notUpdated, null, 2)); + const first = Object.values(result.notUpdated as Record)[0]; + filingError = filingError ?? (first?.description || first?.type || 'post-send filing failed'); + } + if (result.notDestroyed && Object.keys(result.notDestroyed).length) { + console.error(`[sendEmail] ${methodName} notDestroyed (old draft):`, JSON.stringify(result.notDestroyed, null, 2)); + const first = Object.values(result.notDestroyed as Record)[0]; + filingError = filingError ?? (first?.description || first?.type || 'old draft cleanup failed'); + } + if (methodName === 'Email/set' && result.created?.[emailId]?.id) { createdEmailId = result.created[emailId].id; } @@ -2755,8 +2774,8 @@ export class JMAPClient implements IJMAPClient { } return delayedUntil - ? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt } - : { scheduled: false, emailId: createdEmailId, emailSubmissionId }; + ? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt, filingError } + : { scheduled: false, emailId: createdEmailId, emailSubmissionId, filingError }; } /** diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 49747613..e94dfba6 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -95,6 +95,12 @@ export interface SendEmailResult { emailSubmissionId?: string; sendAt?: string; isSmime?: boolean; + /** + * Set when the submission succeeded but a post-send step was rejected + * (the implicit onSuccessUpdateEmail filing patch, or destroying the + * old draft). The mail left the server - callers should warn, not fail. + */ + filingError?: string; } export interface ScheduledEmail extends Email { diff --git a/locales/ar/common.json b/locales/ar/common.json index 79d811f5..e4ee13bd 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "عنوان البريد الإلكتروني", "recipient_name_placeholder": "الاسم المعروض", "autocomplete_search_server": "البحث في الخادم", - "autocomplete_searching": "جارٍ البحث..." + "autocomplete_searching": "جارٍ البحث...", + "send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة." }, "confirm_dialog": { "confirm": "تأكيد", diff --git a/locales/cs/common.json b/locales/cs/common.json index 8ab24325..ba24711b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "E-mailová adresa", "recipient_name_placeholder": "Zobrazované jméno", "autocomplete_search_server": "Hledat na serveru", - "autocomplete_searching": "Hledání..." + "autocomplete_searching": "Hledání...", + "send_filing_warning": "Odesláno - ale následný úklid selhal, může zůstat zastaralý koncept." }, "confirm_dialog": { "confirm": "Potvrdit", diff --git a/locales/da/common.json b/locales/da/common.json index b19d5070..4ba49bcc 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "E-mailadresse", "recipient_name_placeholder": "Visningsnavn", "autocomplete_search_server": "Søg på serveren", - "autocomplete_searching": "Søger..." + "autocomplete_searching": "Søger...", + "send_filing_warning": "Sendt - men oprydningen bagefter mislykkedes, en forældet kladde kan blive stående." }, "confirm_dialog": { "confirm": "Bekræft", diff --git a/locales/de/common.json b/locales/de/common.json index 9b7eb9eb..6b1ceff3 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "E-Mail-Adresse", "recipient_name_placeholder": "Anzeigename", "autocomplete_search_server": "Auf dem Server suchen", - "autocomplete_searching": "Suche läuft..." + "autocomplete_searching": "Suche läuft...", + "send_filing_warning": "Gesendet - aber das Aufräumen danach schlug fehl, evtl. bleibt ein alter Entwurf sichtbar." }, "confirm_dialog": { "confirm": "Bestätigen", diff --git a/locales/en/common.json b/locales/en/common.json index 6c1e4189..e9890db7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "Email address", "recipient_name_placeholder": "Display name", "autocomplete_search_server": "Search the server", - "autocomplete_searching": "Searching..." + "autocomplete_searching": "Searching...", + "send_filing_warning": "Sent - but the post-send cleanup failed, a stale draft may remain." }, "confirm_dialog": { "confirm": "Confirm", diff --git a/locales/es/common.json b/locales/es/common.json index 13af4b30..8e789bde 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "Dirección de correo", "recipient_name_placeholder": "Nombre para mostrar", "autocomplete_search_server": "Buscar en el servidor", - "autocomplete_searching": "Buscando..." + "autocomplete_searching": "Buscando...", + "send_filing_warning": "Enviado - pero la limpieza posterior falló, puede quedar un borrador obsoleto." }, "confirm_dialog": { "confirm": "Confirmar", diff --git a/locales/fa/common.json b/locales/fa/common.json index fde49342..be4ec6c6 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "آدرس ایمیل", "recipient_name_placeholder": "نام نمایشی", "autocomplete_search_server": "جستجو در سرور", - "autocomplete_searching": "در حال جستجو..." + "autocomplete_searching": "در حال جستجو...", + "send_filing_warning": "ارسال شد - اما پاک‌سازی پس از ارسال ناموفق بود، ممکن است پیش‌نویس قدیمی باقی بماند." }, "confirm_dialog": { "confirm": "تأیید", diff --git a/locales/fr/common.json b/locales/fr/common.json index afb52809..2ca92cec 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "Adresse e-mail", "recipient_name_placeholder": "Nom d'affichage", "autocomplete_search_server": "Rechercher sur le serveur", - "autocomplete_searching": "Recherche en cours..." + "autocomplete_searching": "Recherche en cours...", + "send_filing_warning": "Envoyé - mais le nettoyage après envoi a échoué, un ancien brouillon peut subsister." }, "confirm_dialog": { "confirm": "Confirmer", diff --git a/locales/he/common.json b/locales/he/common.json index 23fbbcda..d659253f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -650,7 +650,8 @@ "recipient_email_placeholder": "כתובת דוא״ל", "recipient_name_placeholder": "שם תצוגה", "autocomplete_search_server": "חיפוש בשרת", - "autocomplete_searching": "מחפש..." + "autocomplete_searching": "מחפש...", + "send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה." }, "confirm_dialog": { "confirm": "אשר", diff --git a/locales/hu/common.json b/locales/hu/common.json index 50866701..3abd3143 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "E-mail-cím", "recipient_name_placeholder": "Megjelenített név", "autocomplete_search_server": "Keresés a kiszolgálón", - "autocomplete_searching": "Keresés..." + "autocomplete_searching": "Keresés...", + "send_filing_warning": "Elküldve - de az utólagos rendrakás nem sikerült, egy elavult piszkozat megmaradhat." }, "confirm_dialog": { "confirm": "Megerősítés", diff --git a/locales/it/common.json b/locales/it/common.json index 606c6747..16481d66 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "Indirizzo email", "recipient_name_placeholder": "Nome visualizzato", "autocomplete_search_server": "Cerca nel server", - "autocomplete_searching": "Ricerca in corso..." + "autocomplete_searching": "Ricerca in corso...", + "send_filing_warning": "Inviato - ma la pulizia successiva non è riuscita, potrebbe restare una bozza obsoleta." }, "confirm_dialog": { "confirm": "Conferma", diff --git a/locales/ja/common.json b/locales/ja/common.json index b6256067..9b59170e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "メールアドレス", "recipient_name_placeholder": "表示名", "autocomplete_search_server": "サーバーを検索", - "autocomplete_searching": "検索中..." + "autocomplete_searching": "検索中...", + "send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。" }, "confirm_dialog": { "confirm": "確認", diff --git a/locales/ko/common.json b/locales/ko/common.json index ec9edf2a..3be1c812 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "이메일 주소", "recipient_name_placeholder": "표시 이름", "autocomplete_search_server": "서버에서 검색", - "autocomplete_searching": "검색 중..." + "autocomplete_searching": "검색 중...", + "send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다." }, "confirm_dialog": { "confirm": "확인", diff --git a/locales/lv/common.json b/locales/lv/common.json index 0bb11f70..3002c607 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "E-pasta adrese", "recipient_name_placeholder": "Parādāmais vārds", "autocomplete_search_server": "Meklēt serverī", - "autocomplete_searching": "Meklē..." + "autocomplete_searching": "Meklē...", + "send_filing_warning": "Nosūtīts - bet pēcapstrāde neizdevās, var palikt novecojis melnraksts." }, "confirm_dialog": { "confirm": "Apstiprināt", diff --git a/locales/nl/common.json b/locales/nl/common.json index 387f314c..bd3a6420 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "E-mailadres", "recipient_name_placeholder": "Weergavenaam", "autocomplete_search_server": "Op de server zoeken", - "autocomplete_searching": "Bezig met zoeken..." + "autocomplete_searching": "Bezig met zoeken...", + "send_filing_warning": "Verzonden - maar het opruimen daarna is mislukt, mogelijk blijft een oud concept staan." }, "confirm_dialog": { "confirm": "Bevestigen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 68df0fbb..e0a3f682 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "Adres e-mail", "recipient_name_placeholder": "Wyświetlana nazwa", "autocomplete_search_server": "Szukaj na serwerze", - "autocomplete_searching": "Wyszukiwanie..." + "autocomplete_searching": "Wyszukiwanie...", + "send_filing_warning": "Wysłano - ale późniejsze porządkowanie nie powiodło się, może pozostać nieaktualna wersja robocza." }, "confirm_dialog": { "confirm": "Potwierdź", diff --git a/locales/pt/common.json b/locales/pt/common.json index 4e3e7841..df5fb085 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "Endereço de e-mail", "recipient_name_placeholder": "Nome de exibição", "autocomplete_search_server": "Pesquisar no servidor", - "autocomplete_searching": "Pesquisando..." + "autocomplete_searching": "Pesquisando...", + "send_filing_warning": "Enviado - mas a limpeza posterior falhou, um rascunho antigo pode permanecer." }, "confirm_dialog": { "confirm": "Confirmar", diff --git a/locales/ro/common.json b/locales/ro/common.json index 84a3ba28..31cea1ef 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "Adresă de e-mail", "recipient_name_placeholder": "Numele afișat", "autocomplete_search_server": "Caută pe server", - "autocomplete_searching": "Se caută..." + "autocomplete_searching": "Se caută...", + "send_filing_warning": "Trimis - dar curățarea ulterioară a eșuat, poate rămâne o ciornă veche." }, "confirm_dialog": { "confirm": "Confirmare", diff --git a/locales/ru/common.json b/locales/ru/common.json index be14c29e..0635033c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "Адрес эл. почты", "recipient_name_placeholder": "Отображаемое имя", "autocomplete_search_server": "Искать на сервере", - "autocomplete_searching": "Поиск..." + "autocomplete_searching": "Поиск...", + "send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик." }, "confirm_dialog": { "confirm": "Подтвердить", diff --git a/locales/sk/common.json b/locales/sk/common.json index 01cbed03..012f92ac 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "E-mailová adresa", "recipient_name_placeholder": "Zobrazené meno", "autocomplete_search_server": "Hľadať na serveri", - "autocomplete_searching": "Hľadanie..." + "autocomplete_searching": "Hľadanie...", + "send_filing_warning": "Odoslané - ale následné upratovanie zlyhalo, môže zostať zastaraný koncept." }, "confirm_dialog": { "confirm": "Potvrdiť", diff --git a/locales/tr/common.json b/locales/tr/common.json index 2b76eb3c..fab595f4 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "E-posta adresi", "recipient_name_placeholder": "Görünen ad", "autocomplete_search_server": "Sunucuda ara", - "autocomplete_searching": "Aranıyor..." + "autocomplete_searching": "Aranıyor...", + "send_filing_warning": "Gönderildi - ancak sonrasındaki temizleme başarısız oldu, eski bir taslak kalabilir." }, "confirm_dialog": { "confirm": "Onayla", diff --git a/locales/uk/common.json b/locales/uk/common.json index b66b3617..88363876 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "Адреса ел. пошти", "recipient_name_placeholder": "Відображуване ім'я", "autocomplete_search_server": "Шукати на сервері", - "autocomplete_searching": "Пошук..." + "autocomplete_searching": "Пошук...", + "send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка." }, "confirm_dialog": { "confirm": "Підтвердити", diff --git a/locales/zh/common.json b/locales/zh/common.json index 2f0d05f1..9411f923 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -685,7 +685,8 @@ "recipient_email_placeholder": "电子邮件地址", "recipient_name_placeholder": "显示名称", "autocomplete_search_server": "在服务器上搜索", - "autocomplete_searching": "搜索中..." + "autocomplete_searching": "搜索中...", + "send_filing_warning": "已发送,但发送后的清理失败,可能会残留旧草稿。" }, "confirm_dialog": { "confirm": "确认", diff --git a/stores/__tests__/email-store-refresh-merge.test.ts b/stores/__tests__/email-store-refresh-merge.test.ts new file mode 100644 index 00000000..714f645a --- /dev/null +++ b/stores/__tests__/email-store-refresh-merge.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useSettingsStore } from '../settings-store'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +/** + * refreshCurrentMailbox merges the refreshed first page with the already + * loaded list. The append cutoff must derive from the page size, not from + * the refreshed list's length: when the folder shrank (a deletion - e.g. + * the draft of a just-sent mail), a length-based cutoff re-appends the + * deleted rows from stale local state. That ghost row is how "sent mail + * still shows as draft" reports happen (#592) - and re-sending the ghost + * delivers the mail again. + */ + +const makeEmail = (id: string): Email => + ({ + id, + threadId: `t-${id}`, + mailboxIds: { d: true }, + keywords: {}, + from: [{ email: 'a@example.com' }], + to: [{ email: 'b@example.com' }], + subject: `mail ${id}`, + receivedAt: '2026-07-23T10:00:00Z', + preview: '', + hasAttachment: false, + size: 1, + }) as unknown as Email; + +const draftsMailbox = { + id: 'd', + name: 'Drafts', + role: 'drafts', + totalEmails: 1, + unreadEmails: 0, + totalThreads: 1, + unreadThreads: 0, +} as unknown as Mailbox; + +function makeClient(page: Email[], total: number): IJMAPClient { + return { + getEmails: vi.fn(async () => ({ emails: page, hasMore: false, total })), + } as unknown as IJMAPClient; +} + +describe('refreshCurrentMailbox merge', () => { + beforeEach(() => { + useSettingsStore.setState({ emailsPerPage: 3 }); + // Only override what the tests need - the store's initial state already + // carries the correct empty search filters, view flags and caches. + useEmailStore.setState({ + selectedMailbox: 'd', + mailboxes: [draftsMailbox], + accountMailboxes: {}, + emails: [], + totalEmails: 0, + }); + }); + + it('drops a deleted row when the folder shrank below a full page (#592 ghost draft)', async () => { + // Client state still lists the old draft; the server already deleted it. + useEmailStore.setState({ emails: [makeEmail('ghost')], totalEmails: 1 }); + const client = makeClient([], 0); + + await useEmailStore.getState().refreshCurrentMailbox(client); + + expect(useEmailStore.getState().emails).toEqual([]); + expect(useEmailStore.getState().totalEmails).toBe(0); + }); + + it('still preserves the item a new arrival pushes off the first page', async () => { + const a = makeEmail('a'); + const b = makeEmail('b'); + const c = makeEmail('c'); + const fresh = makeEmail('new'); + useEmailStore.setState({ emails: [a, b, c], totalEmails: 3 }); + // New mail arrived: first page (size 3) now starts with it, c fell off. + const client = makeClient([fresh, a, b], 4); + + await useEmailStore.getState().refreshCurrentMailbox(client); + + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['new', 'a', 'b', 'c']); + }); + + it('keeps loaded deeper pages while dropping a first-page deletion', async () => { + const a = makeEmail('a'); + const b = makeEmail('b'); + const c = makeEmail('c'); + const d2 = makeEmail('d2'); + // Two loaded pages (page size 3); server deleted b from page one. + useEmailStore.setState({ emails: [a, b, c, d2], totalEmails: 4 }); + const client = makeClient([a, c, d2], 3); + + await useEmailStore.getState().refreshCurrentMailbox(client); + + const ids = useEmailStore.getState().emails.map((e) => e.id); + expect(ids).toContain('d2'); + expect(ids).not.toContain('b'); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index ad91b473..14ca81d2 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -2884,7 +2884,14 @@ export const useEmailStore = create((set, get) => ({ const merged: Email[] = [...refreshedEmails]; const mergedIds = new Set(refreshedEmails.map((e: Email) => e.id)); const insertedCount = Math.max((result.total || 0) - previousTotal, 0); - const appendFromIndex = Math.max(refreshedEmails.length - insertedCount, 0); + // Derive the cutoff from the page size, not from the refreshed list's + // length: when the folder shrank (a deletion - e.g. the draft of a just + // sent mail), the fresh page is shorter than the stale list and a + // length-based cutoff re-appends the deleted rows from stale local + // state. That ghost row is how "sent mail still shows as draft" + // reports happen (#592) - and re-sending the ghost delivers the mail + // again. + const appendFromIndex = Math.max(emailsPerPage - insertedCount, 0); for (const email of currentEmails.slice(appendFromIndex)) { if (!mergedIds.has(email.id)) {