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'); +}