From 877aadbb0683eae616871ef3d76bcab3230918d6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:37:52 +0100 Subject: [PATCH] fix: reopen drafts from the pro view in the format they were written in --- components/mail/mail-app.tsx | 23 ++++----------- .../__tests__/pro-email-edit-draft.test.tsx | 22 ++++++++++++++ components/pro/pro-email-tab-body.tsx | 19 ++++-------- lib/__tests__/email-body-selection.test.ts | 27 ++++++++++++++++- lib/email-body-selection.ts | 29 +++++++++++++++++++ 5 files changed, 88 insertions(+), 32 deletions(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 4efe79fa7..c69d7c2c7 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -111,7 +111,7 @@ import { PluginSidePanel } from "@/components/plugins/plugin-side-panels"; import { MvListToolbar } from "@/components/email/mv-list-toolbar"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; -import { getDraftTextBody } from '@/lib/email-body-selection'; +import { getDraftComposerBody } from '@/lib/email-body-selection'; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIdentitySync } from "@/hooks/use-identity-sync"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; @@ -2468,17 +2468,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { draft = await emailHooks.onBeforeEditDraft.transform(draft); - // Only the parts the server lists as the body: bodyValues also carries - // every text/* ATTACHMENT, which used to be joined in as the message. - const bodyText = getDraftTextBody(draft); - // A plain-text-only draft lists its text/plain part under htmlBody - // (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is. - const draftHtmlPart = draft.htmlBody?.[0]; - const htmlBody = draftHtmlPart?.partId - && (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html') - && draft.bodyValues?.[draftHtmlPart.partId] - ? draft.bodyValues[draftHtmlPart.partId].value - : undefined; + const { body: draftBody, bodyText, plainTextMode: draftPlainTextMode } = getDraftComposerBody(draft); // Restore the identity the draft was composed with. Match the saved From // (name + address) against the same list the composer renders — the flat @@ -2508,12 +2498,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Drafts saved before #1189 stored an empty subject as the composer's // "(No Subject)" placeholder; reopen those with an empty field. subject: draft.subject && draft.subject !== t('email_composer.no_subject') ? draft.subject : '', - body: htmlBody || bodyText, - // Re-open in the format the draft was written in: a text-only draft - // used to land raw in the rich-text editor (newlines collapsed) and an - // HTML draft raw in the plain-text textarea. Empty draft: the setting - // decides (#1022). - plainTextMode: htmlBody != null ? false : bodyText ? true : undefined, + body: draftBody, + // Re-open in the format the draft was written in (#1022). + plainTextMode: draftPlainTextMode, showCc: (draft.cc?.length || 0) > 0, showBcc: (draft.bcc?.length || 0) > 0, selectedIdentityId: matchedIdentityId, diff --git a/components/pro/__tests__/pro-email-edit-draft.test.tsx b/components/pro/__tests__/pro-email-edit-draft.test.tsx index c3ebd7e3e..fac8fc3a7 100644 --- a/components/pro/__tests__/pro-email-edit-draft.test.tsx +++ b/components/pro/__tests__/pro-email-edit-draft.test.tsx @@ -169,6 +169,28 @@ describe('Pro email view - editing a draft', () => { expect(initialData.selectedIdentityId).toBe('userb@v10::d'); }); + // A text-only draft went to the rich editor (newlines collapsed, "" + // gone) and an HTML draft to the textarea with its tags, whichever the + // plain-text setting said. + it('re-opens a plain-text draft as plain text', async () => { + const initialData = await openAndEdit(undefined, undefined, { + ...DRAFT, + bodyValues: { '1': { value: 'Hi ,\n\nsee below' } }, + }); + + expect(initialData).toMatchObject({ body: 'Hi ,\n\nsee below', plainTextMode: true }); + }); + + it('re-opens an HTML draft in the rich editor', async () => { + const initialData = await openAndEdit(undefined, undefined, { + ...DRAFT, + bodyValues: { '1': { value: 'Hi' }, '2': { value: '

Hi there

' } }, + htmlBody: [{ partId: '2', type: 'text/html' }], + }); + + expect(initialData).toMatchObject({ body: '

Hi there

', plainTextMode: false }); + }); + it('reads a message of the login it names through that login\'s client, not through the main list', async () => { const client = { getEmail: vi.fn().mockResolvedValue(DRAFT), getUsername: () => 'userb@example.org', getAccountId: () => 'e' }; diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index cf655ac1a..c92d66519 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -22,7 +22,7 @@ import { getQuoteBodies } from "@/lib/email-composer-utils"; import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, emailKeyFor } from "@/lib/thread-utils"; import { cn } from "@/lib/utils"; -import { getDraftTextBody } from '@/lib/email-body-selection'; +import { getDraftComposerBody } from '@/lib/email-body-selection'; import { messagePriorityFromHeaders } from '@/lib/message-priority'; import { labelSourceHeaders, reopenedLabelChoice } from '@/lib/outgoing/classification'; import { useArchiveActionEnabled } from '@/lib/archive-gate'; @@ -461,17 +461,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, const handleEditDraft = useCallback(() => { if (!email) return; - // Only the parts the server lists as the body: bodyValues also carries - // every text/* ATTACHMENT, which used to be joined in as the message. - const bodyText = getDraftTextBody(email); - // A plain-text-only draft lists its text/plain part under htmlBody - // (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is. - const draftHtmlPart = email.htmlBody?.[0]; - const htmlBody = draftHtmlPart?.partId - && (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html') - && email.bodyValues?.[draftHtmlPart.partId] - ? email.bodyValues[draftHtmlPart.partId].value - : undefined; + const { body, bodyText, plainTextMode } = getDraftComposerBody(email); // Preserve the identity the draft was composed with — match name + address // against the same list the composer renders, this login's identities @@ -493,7 +483,10 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, // Drafts saved before #1189 stored an empty subject as the composer's // "(No Subject)" placeholder; reopen those with an empty field. subject: email.subject && email.subject !== t('email_composer.no_subject') ? email.subject : '', - body: htmlBody || bodyText, + body, + // Re-open in the format the draft was written in, as the classic + // view does: without it the composer's setting decided. + plainTextMode, showCc: (email.cc?.length || 0) > 0, showBcc: (email.bcc?.length || 0) > 0, selectedIdentityId: matchedIdentityId, diff --git a/lib/__tests__/email-body-selection.test.ts b/lib/__tests__/email-body-selection.test.ts index d62641734..e3ba2ebf1 100644 --- a/lib/__tests__/email-body-selection.test.ts +++ b/lib/__tests__/email-body-selection.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getRenderableHtmlBody, getDraftTextBody } from '@/lib/email-body-selection'; +import { getRenderableHtmlBody, getDraftTextBody, getDraftComposerBody } from '@/lib/email-body-selection'; type PartialEmail = Parameters[0]; @@ -147,3 +147,28 @@ describe('getDraftTextBody', () => { expect(getDraftTextBody(draft([{ partId: '1' }, { partId: '9' }], { '1': 'one' }))).toBe('one'); }); }); + +describe('getDraftComposerBody', () => { + it('opens a text-only draft as plain text', () => { + // Plain-text-only draft: the text/plain part is listed under htmlBody too. + const email = makeEmail({ + html: { partId: '1', type: 'text/plain' }, + text: { partId: '1', type: 'text/plain' }, + values: { '1': 'Hi ,\n\nbye' }, + }); + expect(getDraftComposerBody(email)).toEqual({ body: 'Hi ,\n\nbye', bodyText: 'Hi ,\n\nbye', plainTextMode: true }); + }); + + it('opens an HTML draft in the rich editor', () => { + const email = makeEmail({ + html: { partId: '2', type: 'text/html' }, + text: { partId: '1', type: 'text/plain' }, + values: { '1': 'Hi', '2': '

Hi

' }, + }); + expect(getDraftComposerBody(email)).toEqual({ body: '

Hi

', bodyText: 'Hi', plainTextMode: false }); + }); + + it('leaves the format of an empty draft to the setting', () => { + expect(getDraftComposerBody(makeEmail({ values: {} })).plainTextMode).toBeUndefined(); + }); +}); diff --git a/lib/email-body-selection.ts b/lib/email-body-selection.ts index ee000d818..c87e27025 100644 --- a/lib/email-body-selection.ts +++ b/lib/email-body-selection.ts @@ -54,3 +54,32 @@ export function getDraftTextBody( .filter((value): value is string => !!value) .join('\n'); } + +/** + * The body a re-opened draft seeds the composer with, and the format to open + * it in. Shared by the classic and the Pro "Edit draft" so both re-open a + * draft the way it was written: a text-only draft used to land raw in the + * rich-text editor (newlines collapsed) and an HTML draft raw in the + * plain-text textarea. An empty draft leaves the format to the setting + * (#1022). + */ +export function getDraftComposerBody( + email: Pick +): { body: string; bodyText: string; plainTextMode: boolean | undefined } { + // Only the parts the server lists as the body: bodyValues also carries + // every text/* ATTACHMENT, which used to be joined in as the message. + const bodyText = getDraftTextBody(email); + // A plain-text-only draft lists its text/plain part under htmlBody + // (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is. + const htmlPart = email.htmlBody?.[0]; + const htmlBody = htmlPart?.partId + && (!htmlPart.type || htmlPart.type.toLowerCase() === 'text/html') + && email.bodyValues?.[htmlPart.partId] + ? email.bodyValues[htmlPart.partId].value + : undefined; + return { + body: htmlBody || bodyText, + bodyText, + plainTextMode: htmlBody != null ? false : bodyText ? true : undefined, + }; +}