Merge pull request #1213 from shukiv/fix/draft-body-from-body-parts

fix: take a re-opened draft's body from its body parts only
This commit is contained in:
Linus Rath
2026-10-11 21:03:18 +02:00
committed by GitHub
4 changed files with 72 additions and 7 deletions
+4 -3
View File
@@ -85,6 +85,7 @@ import { MvTopBar } from "@/components/layout/mv-top-bar";
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 { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIdentitySync } from "@/hooks/use-identity-sync";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
@@ -2087,9 +2088,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
draft = await emailHooks.onBeforeEditDraft.transform(draft);
const bodyText = draft.bodyValues
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
: '';
// 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];
+4 -3
View File
@@ -19,6 +19,7 @@ import { getQuoteBodies } from "@/lib/email-composer-utils";
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
import { cn } from "@/lib/utils";
import { getDraftTextBody } from '@/lib/email-body-selection';
interface ProEmailTabBodyProps {
tabId: string;
@@ -319,9 +320,9 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
const handleEditDraft = useCallback(() => {
if (!email) return;
const bodyText = email.bodyValues
? Object.values(email.bodyValues).map((v) => v.value).join('\n')
: '';
// 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];
+43 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { getRenderableHtmlBody } from '@/lib/email-body-selection';
import { getRenderableHtmlBody, getDraftTextBody } from '@/lib/email-body-selection';
type PartialEmail = Parameters<typeof getRenderableHtmlBody>[0];
@@ -105,3 +105,45 @@ describe('getRenderableHtmlBody', () => {
expect(getRenderableHtmlBody(email)).toBeNull();
});
});
/**
* Re-opening a draft used to join every value in bodyValues into the
* message. getEmail asks for fetchAllBodyValues, which per RFC 8621
* covers every text/* part including attachments, so a draft carrying
* only a .csv re-opened with the contents of that .csv as its text - and
* the next save made that the real message.
*/
describe('getDraftTextBody', () => {
type DraftEmail = Parameters<typeof getDraftTextBody>[0];
const draft = (textBody: { partId: string }[], values: Record<string, string>): DraftEmail => ({
textBody: textBody as DraftEmail['textBody'],
bodyValues: Object.fromEntries(
Object.entries(values).map(([id, value]) => [id, { value, isEncodingProblem: false, isTruncated: false }]),
) as DraftEmail['bodyValues'],
});
it('takes the body from the parts listed as the body', () => {
expect(getDraftTextBody(draft([{ partId: '1' }], { '1': 'hello' }))).toBe('hello');
});
it('ignores a text attachment that is not part of the body', () => {
// Part 2 is an attached text/csv: fetchAllBodyValues returns its value
// too, but it is not in textBody and is not the message.
const d = draft([{ partId: '1' }], { '1': 'see attached', '2': 'Date,Amount\n2026-08-30,12.00' });
expect(getDraftTextBody(d)).toBe('see attached');
});
it('is empty for a draft that has attachments and no body at all', () => {
expect(getDraftTextBody(draft([], { '2': 'Date,Amount\n2026-08-30,12.00' }))).toBe('');
});
it('joins several body parts', () => {
expect(getDraftTextBody(draft([{ partId: '1' }, { partId: '2' }], { '1': 'one', '2': 'two' })))
.toBe('one\ntwo');
});
it('survives a part whose value the server did not return', () => {
expect(getDraftTextBody(draft([{ partId: '1' }, { partId: '9' }], { '1': 'one' }))).toBe('one');
});
});
+21
View File
@@ -33,3 +33,24 @@ export function getRenderableHtmlBody(
return htmlContent;
}
/**
* The plain-text body of a draft being re-opened, taken from the parts the
* server lists as the body and from nothing else.
*
* `bodyValues` is keyed by part id, and `getEmail` asks for
* `fetchAllBodyValues`, which per RFC 8621 § 4.1.4 covers every `text/*`
* part of the message - attachments included. Joining all of them put the
* contents of an attached .csv or .txt into the composer as the message
* text: a draft carrying only attachments and no body re-opened showing an
* attachment's contents as what you had written, and saving or sending it
* then made that the actual message.
*/
export function getDraftTextBody(
email: Pick<Email, 'textBody' | 'bodyValues'>
): string {
return (email.textBody ?? [])
.map(part => (part.partId ? email.bodyValues?.[part.partId]?.value : undefined))
.filter((value): value is string => !!value)
.join('\n');
}