From de55fb6b73a235847b77ef6c0b18139c1c75fe96 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:27:40 +0200 Subject: [PATCH] fix: honor part-type fallback when quoting replies #649 --- app/(main)/[locale]/page.tsx | 17 ++++---- components/pro/pro-email-tab-body.tsx | 15 +++++--- lib/__tests__/email-composer-utils.test.ts | 45 ++++++++++++++++++++++ lib/email-composer-utils.ts | 36 +++++++++++++++++ 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 0db35bc3..f0ae5a56 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -70,7 +70,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { useThemeStore } from "@/stores/theme-store"; import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; import type { ParsedMailto } from "@/lib/protocol-handlers/mailto"; -import { plainTextToComposerBody } from "@/lib/email-composer-utils"; +import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; import { buildQuoteHeader } from "@/lib/quote-header"; @@ -787,8 +787,7 @@ export default function Home() { cc: selectedEmail.cc, bcc: selectedEmail.bcc, subject: selectedEmail.subject, - body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', - htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, + ...getQuoteBodies(selectedEmail), receivedAt: selectedEmail.receivedAt, attachments: selectedEmail.attachments, messageId: selectedEmail.messageId, @@ -1387,8 +1386,13 @@ export default function Home() { const bodyText = draft.bodyValues ? Object.values(draft.bodyValues).map(v => v.value).join('\n') : ''; - const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId] - ? draft.bodyValues[draft.htmlBody[0].partId].value + // 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; // Try to find the identity that matches the draft's from address to preserve it @@ -3300,8 +3304,7 @@ export default function Home() { cc: selectedEmail.cc, bcc: selectedEmail.bcc, subject: selectedEmail.subject, - body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', - htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, + ...getQuoteBodies(selectedEmail), receivedAt: selectedEmail.receivedAt, attachments: selectedEmail.attachments, messageId: selectedEmail.messageId, diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 2094963e..bd7dfdfc 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store"; import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store"; import type { Email } from "@/lib/jmap/types"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; +import { getQuoteBodies } from "@/lib/email-composer-utils"; interface ProEmailTabBodyProps { tabId: string; @@ -19,8 +20,6 @@ interface ProEmailTabBodyProps { } function buildReplyContext(email: Email): ProReplyContext { - const textPartId = email.textBody?.[0]?.partId ?? ''; - const htmlPartId = email.htmlBody?.[0]?.partId ?? ''; return { from: email.from, replyToAddresses: email.replyTo, @@ -28,8 +27,7 @@ function buildReplyContext(email: Email): ProReplyContext { cc: email.cc, bcc: email.bcc, subject: email.subject, - body: email.bodyValues?.[textPartId]?.value || email.preview || '', - htmlBody: email.bodyValues?.[htmlPartId]?.value || undefined, + ...getQuoteBodies(email), receivedAt: email.receivedAt, accountId: email.accountId, attachments: email.attachments, @@ -235,8 +233,13 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { const bodyText = email.bodyValues ? Object.values(email.bodyValues).map((v) => v.value).join('\n') : ''; - const htmlBody = email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId] - ? email.bodyValues[email.htmlBody[0].partId].value + // 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; // Preserve the identity that matches the draft's From address. diff --git a/lib/__tests__/email-composer-utils.test.ts b/lib/__tests__/email-composer-utils.test.ts index 5d40b7f0..b3a3b202 100644 --- a/lib/__tests__/email-composer-utils.test.ts +++ b/lib/__tests__/email-composer-utils.test.ts @@ -14,6 +14,7 @@ import { extractUserAuthoredText, formatRecipientEntry, expandRecipients, + getQuoteBodies, } from '../email-composer-utils'; const FORWARDED_SEPARATOR = "---------- Forwarded message ----------"; @@ -477,3 +478,47 @@ describe('contact group recipients (RFC 5322 group syntax)', () => { expect(expandRecipients([{ name: 'X', email: 'x@y.z' }])).toEqual([{ name: 'X', email: 'x@y.z' }]); }); }); + +describe("getQuoteBodies", () => { + const part = (partId: string, type: string) => ({ partId, blobId: "b", size: 1, type }); + + it("converts an HTML-only message's shared part into readable text (#649)", () => { + const { body, htmlBody } = getQuoteBodies({ + textBody: [part("1", "text/html")], + htmlBody: [part("1", "text/html")], + bodyValues: { "1": { value: "
Hallo Jonas.
Zeile zwei
und drei
Hallo Jonas.
"); + }); + + it("drops htmlBody when it is really the text/plain part (#649)", () => { + const text = "Hallo Herr Test,\n\ndas ist eine Test-Email.\n\nBeste Grüße"; + const { body, htmlBody } = getQuoteBodies({ + textBody: [part("1", "text/plain")], + htmlBody: [part("1", "text/plain")], + bodyValues: { "1": { value: text } }, + }); + expect(body).toBe(text); + expect(htmlBody).toBeUndefined(); + }); + + it("passes both parts through when the message has real alternatives", () => { + const { body, htmlBody } = getQuoteBodies({ + textBody: [part("t", "text/plain")], + htmlBody: [part("h", "text/html")], + bodyValues: { + t: { value: "plain version" }, + h: { value: "html version
" }, + }, + }); + expect(body).toBe("plain version"); + expect(htmlBody).toBe("html version
"); + }); + + it("falls back to the preview when body values are missing", () => { + const { body, htmlBody } = getQuoteBodies({ preview: "preview text" }); + expect(body).toBe("preview text"); + expect(htmlBody).toBeUndefined(); + }); +}); diff --git a/lib/email-composer-utils.ts b/lib/email-composer-utils.ts index 1ac83c19..57be4a0e 100644 --- a/lib/email-composer-utils.ts +++ b/lib/email-composer-utils.ts @@ -2,6 +2,7 @@ import { isValidEmail } from "@/lib/validation"; import { htmlToPlainText } from "@/lib/html-to-text"; import { emailHooks } from "@/lib/plugin-hooks"; import { Ellipsis, Lock, TriangleAlert } from "lucide-react"; +import type { Email } from "@/lib/jmap/types"; const HTML_ESCAPE_MAP = { "&": "&", @@ -17,6 +18,41 @@ function escapeHtml(value: string): string { ); } +/** + * Picks the plain-text and HTML bodies of an original message for seeding a + * reply/forward quote. + * + * Per RFC 8621 § 4.1.4 a message with only one body variant exposes that + * single part in BOTH `textBody` and `htmlBody`. So for an HTML-only message + * `textBody[0]` is the raw text/html source, and for a plain-text-only + * message `htmlBody[0]` is the text/plain part. Quoting either verbatim + * breaks the reply (#649): raw HTML tags end up in a plain-text quote, and + * plain text rendered as HTML collapses all newlines. Route by each part's + * actual MIME type instead: HTML listed under textBody is converted to + * readable text, and plain text listed under htmlBody is dropped so the + * composer's text path (escape +