From 2a45f6099c86bc475be06657a9c100da244e04a0 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Sun, 11 Oct 2026 17:04:13 +0000 Subject: [PATCH] fix: take a re-opened draft's body from its body parts only A draft that carried attachments and no message re-opened with the contents of one of the attachments as its text. Attach a .csv to an empty draft, save it, re-open it: the editor shows the rows of the csv as what you had written, and the next save or send makes that the real message. Both edit-draft paths built the text body by joining every value in bodyValues: Object.values(draft.bodyValues).map(v => v.value).join('\n') bodyValues is keyed by part id, and getEmail asks for fetchAllBodyValues, which per RFC 8621 4.1.4 returns a value for every text/* part of the message - attachments included. So any attached .csv, .txt, .log or .md was joined in as body text, and with no html body to prefer it became the whole message. Take the body from the parts the server lists as the body instead, and from nothing else. A draft whose textBody is empty now re-opens empty, which is what an attachments-only draft is. The selection lives in lib/email-body-selection.ts beside getRenderableHtmlBody, which exists because the viewer and the mobile thread view had already drifted apart once (#489). mail-app and the pro email tab held character-for-character copies of this join, so the same drift was already in place. --- components/mail/mail-app.tsx | 7 ++-- components/pro/pro-email-tab-body.tsx | 7 ++-- lib/__tests__/email-body-selection.test.ts | 44 +++++++++++++++++++++- lib/email-body-selection.ts | 21 +++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index caf37937e..51eb2882c 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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]; diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 46060b624..ed29e0d55 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -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]; diff --git a/lib/__tests__/email-body-selection.test.ts b/lib/__tests__/email-body-selection.test.ts index 70210bbfc..d62641734 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 } from '@/lib/email-body-selection'; +import { getRenderableHtmlBody, getDraftTextBody } from '@/lib/email-body-selection'; type PartialEmail = Parameters[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[0]; + + const draft = (textBody: { partId: string }[], values: Record): 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'); + }); +}); diff --git a/lib/email-body-selection.ts b/lib/email-body-selection.ts index 02f5515ea..ee000d818 100644 --- a/lib/email-body-selection.ts +++ b/lib/email-body-selection.ts @@ -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 +): string { + return (email.textBody ?? []) + .map(part => (part.partId ? email.bodyValues?.[part.partId]?.value : undefined)) + .filter((value): value is string => !!value) + .join('\n'); +}