From d166b8932b1aed17ef687a2a21351ff71f1926e6 Mon Sep 17 00:00:00 2001 From: Gabriel Birman <25272206+gbirman@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:35:09 -0400 Subject: [PATCH 1/2] fix(email): clean up reply draft when the body is emptied hasDraftContent counted the auto-derived reply recipients, so clearing a reply's body left an empty draft behind instead of deleting it. The reply path now omits recipients from the content check; compose still counts user-entered recipients. --- js/app/packages/block-email/component/BaseInput.tsx | 5 +---- js/app/packages/block-email/util/prepareEmailBody.ts | 4 +++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/js/app/packages/block-email/component/BaseInput.tsx b/js/app/packages/block-email/component/BaseInput.tsx index 099f23fb6b..41a9809ee9 100644 --- a/js/app/packages/block-email/component/BaseInput.tsx +++ b/js/app/packages/block-email/component/BaseInput.tsx @@ -690,10 +690,7 @@ export function BaseInput(props: { !hasDraftContent( prepared.bodyText, form().subject(), - form().attachments.list().length, - form().recipients().to.length + - form().recipients().cc.length + - form().recipients().bcc.length + form().attachments.list().length ) ) { return null; diff --git a/js/app/packages/block-email/util/prepareEmailBody.ts b/js/app/packages/block-email/util/prepareEmailBody.ts index 4af764a94b..b3140f3fd3 100644 --- a/js/app/packages/block-email/util/prepareEmailBody.ts +++ b/js/app/packages/block-email/util/prepareEmailBody.ts @@ -509,7 +509,9 @@ export function prepareEmailBody( /** * Returns true if the draft has meaningful user content worth saving. - * Auto-filled reply/forward subjects alone don't count. + * Auto-filled reply/forward subjects alone don't count. Pass recipientCount + * only when recipients are user-entered (compose) and should keep the draft + * alive — reply recipients are auto-derived, so the reply path omits it. */ export function hasDraftContent( bodyText: string, From 74a4d401f3016a8fef1358b573a79ddb973720ed Mon Sep 17 00:00:00 2001 From: Gabriel Birman <25272206+gbirman@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:10:30 -0400 Subject: [PATCH 2/2] fix(email): drop a deleted draft's thread from the drafts tab The delete-draft mutation invalidated the soup entity by draft-message id, but email soup entities are keyed by thread id, so it was a no-op and the drafts tab stayed stale until a manual refresh. Pass the thread id and refetch the thread entity (mirroring the save path), flipping its is_draft flag so the thread leaves the drafts tab while remaining in the inbox. --- js/app/packages/block-email/component/BaseInput.tsx | 2 ++ .../block-email/component/compose/Compose.tsx | 2 ++ js/app/packages/queries/email/draft.ts | 12 ++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/js/app/packages/block-email/component/BaseInput.tsx b/js/app/packages/block-email/component/BaseInput.tsx index 41a9809ee9..7d73abe8eb 100644 --- a/js/app/packages/block-email/component/BaseInput.tsx +++ b/js/app/packages/block-email/component/BaseInput.tsx @@ -717,6 +717,7 @@ export function BaseInput(props: { if (draftId) { await deleteDraftMutation.mutateAsync({ draftId, + threadId: ctx.thread()?.db_id, linkId: headerLinkId(), }); refetchThreadMessages(); @@ -1089,6 +1090,7 @@ export function BaseInput(props: { if (draftId) { await deleteDraftMutation.mutateAsync({ draftId, + threadId: ctx.thread()?.db_id, linkId: headerLinkId(), }); refetchThreadMessages(); diff --git a/js/app/packages/block-email/component/compose/Compose.tsx b/js/app/packages/block-email/component/compose/Compose.tsx index 1128e56800..77dc9e0b5f 100644 --- a/js/app/packages/block-email/component/compose/Compose.tsx +++ b/js/app/packages/block-email/component/compose/Compose.tsx @@ -231,6 +231,7 @@ export function EmailCompose(props: EmailComposeProps) { if (draftID) { await deleteDraftMutation.mutateAsync({ draftId: draftID, + threadId: currentThreadID(), linkId: headerLinkId(), }); } @@ -602,6 +603,7 @@ export function EmailCompose(props: EmailComposeProps) { if (draftId) { await deleteDraftMutation.mutateAsync({ draftId, + threadId: currentThreadID(), linkId: headerLinkId(), }); } diff --git a/js/app/packages/queries/email/draft.ts b/js/app/packages/queries/email/draft.ts index 47862469c6..2fdf99f403 100644 --- a/js/app/packages/queries/email/draft.ts +++ b/js/app/packages/queries/email/draft.ts @@ -1,6 +1,6 @@ import { toast } from '@core/component/Toast/Toast'; import { throwOnErr } from '@core/util/result'; -import { invalidateSoupEntity, refetchSoupEntity } from '@queries/soup/cache'; +import { refetchSoupEntity } from '@queries/soup/cache'; import { emailClient } from '@service-email/client'; import type { ApiDraftInput, @@ -64,6 +64,8 @@ export function useSaveDraftMutation( type DeleteDraftParams = { draftId: string; + /** Thread the draft belonged to, refetched so it leaves the drafts tab. */ + threadId?: string; /** Target inbox for a non-primary inbox; sent as the X-Email-Link-Id header. */ linkId?: string; }; @@ -91,7 +93,13 @@ export function useDeleteDraftMutation( queryClient.invalidateQueries({ queryKey: emailKeys.previews._def, }); - invalidateSoupEntity(vars.draftId); + // Refetch the thread (not the deleted draft) so it drops from the + // drafts tab without a manual refresh. No-op for compose drafts, + // whose thread is deleted along with the draft, so the refetch + // finds nothing to update. + if (vars.threadId) { + refetchSoupEntity(vars.threadId, 'emailThread'); + } }, }, callbacks