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

" } }, + }); + expect(body).toBe("Hallo Jonas.\n\nZeile zwei\nund drei"); + expect(htmlBody).toContain("

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 +
) renders it. + */ +export function getQuoteBodies( + email: Pick +): { body: string; htmlBody?: string } { + const textPart = email.textBody?.[0]; + const htmlPart = email.htmlBody?.[0]; + const textValue = textPart ? email.bodyValues?.[textPart.partId]?.value : undefined; + const htmlValue = htmlPart ? email.bodyValues?.[htmlPart.partId]?.value : undefined; + + const textPartIsHtml = textPart?.type?.toLowerCase() === "text/html"; + // A missing type is treated as HTML, matching the viewer's rendering path. + const htmlPartIsHtml = !htmlPart?.type || htmlPart.type.toLowerCase() === "text/html"; + + const body = textValue + ? (textPartIsHtml ? htmlToPlainText(textValue, { paragraphSpacing: true }) : textValue) + : (email.preview || ""); + return { + body, + htmlBody: htmlPartIsHtml ? htmlValue || undefined : undefined, + }; +} + export function plainTextToComposerBody(text: string): string { if (!text) return "";