Merge pull request #675 from dealerweb/fix/stale-draft-after-send

Fix: stop resurrecting deleted rows in the mailbox refresh merge
This commit is contained in:
Linus Rath
2026-07-23 16:13:43 +02:00
committed by GitHub
29 changed files with 197 additions and 27 deletions
+8
View File
@@ -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);
+6 -1
View File
@@ -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
+21 -2
View File
@@ -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<string, { type?: string; description?: string }>)[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<string, { type?: string; description?: string }>)[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 };
}
/**
+6
View File
@@ -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 {
+2 -1
View File
@@ -685,7 +685,8 @@
"recipient_email_placeholder": "عنوان البريد الإلكتروني",
"recipient_name_placeholder": "الاسم المعروض",
"autocomplete_search_server": "البحث في الخادم",
"autocomplete_searching": "جارٍ البحث..."
"autocomplete_searching": "جارٍ البحث...",
"send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة."
},
"confirm_dialog": {
"confirm": "تأكيد",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -685,7 +685,8 @@
"recipient_email_placeholder": "آدرس ایمیل",
"recipient_name_placeholder": "نام نمایشی",
"autocomplete_search_server": "جستجو در سرور",
"autocomplete_searching": "در حال جستجو..."
"autocomplete_searching": "در حال جستجو...",
"send_filing_warning": "ارسال شد - اما پاک‌سازی پس از ارسال ناموفق بود، ممکن است پیش‌نویس قدیمی باقی بماند."
},
"confirm_dialog": {
"confirm": "تأیید",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -650,7 +650,8 @@
"recipient_email_placeholder": "כתובת דוא״ל",
"recipient_name_placeholder": "שם תצוגה",
"autocomplete_search_server": "חיפוש בשרת",
"autocomplete_searching": "מחפש..."
"autocomplete_searching": "מחפש...",
"send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה."
},
"confirm_dialog": {
"confirm": "אשר",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -685,7 +685,8 @@
"recipient_email_placeholder": "メールアドレス",
"recipient_name_placeholder": "表示名",
"autocomplete_search_server": "サーバーを検索",
"autocomplete_searching": "検索中..."
"autocomplete_searching": "検索中...",
"send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。"
},
"confirm_dialog": {
"confirm": "確認",
+2 -1
View File
@@ -685,7 +685,8 @@
"recipient_email_placeholder": "이메일 주소",
"recipient_name_placeholder": "표시 이름",
"autocomplete_search_server": "서버에서 검색",
"autocomplete_searching": "검색 중..."
"autocomplete_searching": "검색 중...",
"send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다."
},
"confirm_dialog": {
"confirm": "확인",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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ź",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -685,7 +685,8 @@
"recipient_email_placeholder": "Адрес эл. почты",
"recipient_name_placeholder": "Отображаемое имя",
"autocomplete_search_server": "Искать на сервере",
"autocomplete_searching": "Поиск..."
"autocomplete_searching": "Поиск...",
"send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик."
},
"confirm_dialog": {
"confirm": "Подтвердить",
+2 -1
View File
@@ -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ť",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -685,7 +685,8 @@
"recipient_email_placeholder": "Адреса ел. пошти",
"recipient_name_placeholder": "Відображуване ім'я",
"autocomplete_search_server": "Шукати на сервері",
"autocomplete_searching": "Пошук..."
"autocomplete_searching": "Пошук...",
"send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка."
},
"confirm_dialog": {
"confirm": "Підтвердити",
+2 -1
View File
@@ -685,7 +685,8 @@
"recipient_email_placeholder": "电子邮件地址",
"recipient_name_placeholder": "显示名称",
"autocomplete_search_server": "在服务器上搜索",
"autocomplete_searching": "搜索中..."
"autocomplete_searching": "搜索中...",
"send_filing_warning": "已发送,但发送后的清理失败,可能会残留旧草稿。"
},
"confirm_dialog": {
"confirm": "确认",
@@ -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');
});
});
+8 -1
View File
@@ -2884,7 +2884,14 @@ export const useEmailStore = create<EmailStore>((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)) {