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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -685,7 +685,8 @@
|
||||
"recipient_email_placeholder": "عنوان البريد الإلكتروني",
|
||||
"recipient_name_placeholder": "الاسم المعروض",
|
||||
"autocomplete_search_server": "البحث في الخادم",
|
||||
"autocomplete_searching": "جارٍ البحث..."
|
||||
"autocomplete_searching": "جارٍ البحث...",
|
||||
"send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "تأكيد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -685,7 +685,8 @@
|
||||
"recipient_email_placeholder": "آدرس ایمیل",
|
||||
"recipient_name_placeholder": "نام نمایشی",
|
||||
"autocomplete_search_server": "جستجو در سرور",
|
||||
"autocomplete_searching": "در حال جستجو..."
|
||||
"autocomplete_searching": "در حال جستجو...",
|
||||
"send_filing_warning": "ارسال شد - اما پاکسازی پس از ارسال ناموفق بود، ممکن است پیشنویس قدیمی باقی بماند."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "تأیید",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -650,7 +650,8 @@
|
||||
"recipient_email_placeholder": "כתובת דוא״ל",
|
||||
"recipient_name_placeholder": "שם תצוגה",
|
||||
"autocomplete_search_server": "חיפוש בשרת",
|
||||
"autocomplete_searching": "מחפש..."
|
||||
"autocomplete_searching": "מחפש...",
|
||||
"send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "אשר",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -685,7 +685,8 @@
|
||||
"recipient_email_placeholder": "メールアドレス",
|
||||
"recipient_name_placeholder": "表示名",
|
||||
"autocomplete_search_server": "サーバーを検索",
|
||||
"autocomplete_searching": "検索中..."
|
||||
"autocomplete_searching": "検索中...",
|
||||
"send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "確認",
|
||||
|
||||
@@ -685,7 +685,8 @@
|
||||
"recipient_email_placeholder": "이메일 주소",
|
||||
"recipient_name_placeholder": "표시 이름",
|
||||
"autocomplete_search_server": "서버에서 검색",
|
||||
"autocomplete_searching": "검색 중..."
|
||||
"autocomplete_searching": "검색 중...",
|
||||
"send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "확인",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ź",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -685,7 +685,8 @@
|
||||
"recipient_email_placeholder": "Адрес эл. почты",
|
||||
"recipient_name_placeholder": "Отображаемое имя",
|
||||
"autocomplete_search_server": "Искать на сервере",
|
||||
"autocomplete_searching": "Поиск..."
|
||||
"autocomplete_searching": "Поиск...",
|
||||
"send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Подтвердить",
|
||||
|
||||
@@ -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ť",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -685,7 +685,8 @@
|
||||
"recipient_email_placeholder": "Адреса ел. пошти",
|
||||
"recipient_name_placeholder": "Відображуване ім'я",
|
||||
"autocomplete_search_server": "Шукати на сервері",
|
||||
"autocomplete_searching": "Пошук..."
|
||||
"autocomplete_searching": "Пошук...",
|
||||
"send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Підтвердити",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user