fix: honor part-type fallback when quoting replies #649
This commit is contained in:
@@ -70,7 +70,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
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 { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||||
import { emailToReadView } from "@/lib/plugin-projection";
|
import { emailToReadView } from "@/lib/plugin-projection";
|
||||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||||
@@ -787,8 +787,7 @@ export default function Home() {
|
|||||||
cc: selectedEmail.cc,
|
cc: selectedEmail.cc,
|
||||||
bcc: selectedEmail.bcc,
|
bcc: selectedEmail.bcc,
|
||||||
subject: selectedEmail.subject,
|
subject: selectedEmail.subject,
|
||||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
...getQuoteBodies(selectedEmail),
|
||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
|
||||||
receivedAt: selectedEmail.receivedAt,
|
receivedAt: selectedEmail.receivedAt,
|
||||||
attachments: selectedEmail.attachments,
|
attachments: selectedEmail.attachments,
|
||||||
messageId: selectedEmail.messageId,
|
messageId: selectedEmail.messageId,
|
||||||
@@ -1387,8 +1386,13 @@ export default function Home() {
|
|||||||
const bodyText = draft.bodyValues
|
const bodyText = draft.bodyValues
|
||||||
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
||||||
: '';
|
: '';
|
||||||
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
// A plain-text-only draft lists its text/plain part under htmlBody
|
||||||
? draft.bodyValues[draft.htmlBody[0].partId].value
|
// (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;
|
: undefined;
|
||||||
|
|
||||||
// Try to find the identity that matches the draft's from address to preserve it
|
// 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,
|
cc: selectedEmail.cc,
|
||||||
bcc: selectedEmail.bcc,
|
bcc: selectedEmail.bcc,
|
||||||
subject: selectedEmail.subject,
|
subject: selectedEmail.subject,
|
||||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
...getQuoteBodies(selectedEmail),
|
||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
|
||||||
receivedAt: selectedEmail.receivedAt,
|
receivedAt: selectedEmail.receivedAt,
|
||||||
attachments: selectedEmail.attachments,
|
attachments: selectedEmail.attachments,
|
||||||
messageId: selectedEmail.messageId,
|
messageId: selectedEmail.messageId,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
|
|||||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||||
import type { Email } from "@/lib/jmap/types";
|
import type { Email } from "@/lib/jmap/types";
|
||||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
|
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||||
|
|
||||||
interface ProEmailTabBodyProps {
|
interface ProEmailTabBodyProps {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
@@ -19,8 +20,6 @@ interface ProEmailTabBodyProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildReplyContext(email: Email): ProReplyContext {
|
function buildReplyContext(email: Email): ProReplyContext {
|
||||||
const textPartId = email.textBody?.[0]?.partId ?? '';
|
|
||||||
const htmlPartId = email.htmlBody?.[0]?.partId ?? '';
|
|
||||||
return {
|
return {
|
||||||
from: email.from,
|
from: email.from,
|
||||||
replyToAddresses: email.replyTo,
|
replyToAddresses: email.replyTo,
|
||||||
@@ -28,8 +27,7 @@ function buildReplyContext(email: Email): ProReplyContext {
|
|||||||
cc: email.cc,
|
cc: email.cc,
|
||||||
bcc: email.bcc,
|
bcc: email.bcc,
|
||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
body: email.bodyValues?.[textPartId]?.value || email.preview || '',
|
...getQuoteBodies(email),
|
||||||
htmlBody: email.bodyValues?.[htmlPartId]?.value || undefined,
|
|
||||||
receivedAt: email.receivedAt,
|
receivedAt: email.receivedAt,
|
||||||
accountId: email.accountId,
|
accountId: email.accountId,
|
||||||
attachments: email.attachments,
|
attachments: email.attachments,
|
||||||
@@ -235,8 +233,13 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
const bodyText = email.bodyValues
|
const bodyText = email.bodyValues
|
||||||
? Object.values(email.bodyValues).map((v) => v.value).join('\n')
|
? Object.values(email.bodyValues).map((v) => v.value).join('\n')
|
||||||
: '';
|
: '';
|
||||||
const htmlBody = email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]
|
// A plain-text-only draft lists its text/plain part under htmlBody
|
||||||
? email.bodyValues[email.htmlBody[0].partId].value
|
// (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;
|
: undefined;
|
||||||
|
|
||||||
// Preserve the identity that matches the draft's From address.
|
// Preserve the identity that matches the draft's From address.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
extractUserAuthoredText,
|
extractUserAuthoredText,
|
||||||
formatRecipientEntry,
|
formatRecipientEntry,
|
||||||
expandRecipients,
|
expandRecipients,
|
||||||
|
getQuoteBodies,
|
||||||
} from '../email-composer-utils';
|
} from '../email-composer-utils';
|
||||||
|
|
||||||
const FORWARDED_SEPARATOR = "---------- Forwarded message ----------";
|
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' }]);
|
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: "<p>Hallo Jonas.</p><p>Zeile zwei<br>und drei</p>" } },
|
||||||
|
});
|
||||||
|
expect(body).toBe("Hallo Jonas.\n\nZeile zwei\nund drei");
|
||||||
|
expect(htmlBody).toContain("<p>Hallo Jonas.</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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: "<p>html version</p>" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(body).toBe("plain version");
|
||||||
|
expect(htmlBody).toBe("<p>html version</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { isValidEmail } from "@/lib/validation";
|
|||||||
import { htmlToPlainText } from "@/lib/html-to-text";
|
import { htmlToPlainText } from "@/lib/html-to-text";
|
||||||
import { emailHooks } from "@/lib/plugin-hooks";
|
import { emailHooks } from "@/lib/plugin-hooks";
|
||||||
import { Ellipsis, Lock, TriangleAlert } from "lucide-react";
|
import { Ellipsis, Lock, TriangleAlert } from "lucide-react";
|
||||||
|
import type { Email } from "@/lib/jmap/types";
|
||||||
|
|
||||||
const HTML_ESCAPE_MAP = {
|
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 + <br>) renders it.
|
||||||
|
*/
|
||||||
|
export function getQuoteBodies(
|
||||||
|
email: Pick<Email, "textBody" | "htmlBody" | "bodyValues" | "preview">
|
||||||
|
): { 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 {
|
export function plainTextToComposerBody(text: string): string {
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user