fix: reopen drafts from the pro view in the format they were written in

This commit is contained in:
Linus Rath
2026-11-02 22:52:03 +01:00
parent 45a7f2ec6a
commit 877aadbb06
5 changed files with 88 additions and 32 deletions
+5 -18
View File
@@ -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,
@@ -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, "<you>"
// 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 <you>,\n\nsee below' } },
});
expect(initialData).toMatchObject({ body: 'Hi <you>,\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: '<p>Hi <b>there</b></p>' } },
htmlBody: [{ partId: '2', type: 'text/html' }],
});
expect(initialData).toMatchObject({ body: '<p>Hi <b>there</b></p>', 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' };
+6 -13
View File
@@ -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,
+26 -1
View File
@@ -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<typeof getRenderableHtmlBody>[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 <you>,\n\nbye' },
});
expect(getDraftComposerBody(email)).toEqual({ body: 'Hi <you>,\n\nbye', bodyText: 'Hi <you>,\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': '<p>Hi</p>' },
});
expect(getDraftComposerBody(email)).toEqual({ body: '<p>Hi</p>', bodyText: 'Hi', plainTextMode: false });
});
it('leaves the format of an empty draft to the setting', () => {
expect(getDraftComposerBody(makeEmail({ values: {} })).plainTextMode).toBeUndefined();
});
});
+29
View File
@@ -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<Email, 'htmlBody' | 'textBody' | 'bodyValues'>
): { 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,
};
}