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.
This commit is contained in:
dealerweb
2026-07-23 13:26:05 +02:00
parent e442c55931
commit af2b00dd35
29 changed files with 197 additions and 27 deletions
+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 };
}
/**