A reply opened in the format the settings ask for, whatever the message being answered was written in, and the per-draft switch was buried in the composer's ⋮ menu. Replying in plain text to a rich text message throws away the formatting; replying in rich text to a plain-text one overrides what the sender chose to write in. When the two disagree the composer now says so above the editor -- "This message is rich text", with a Switch button and a dismiss -- and the draft still opens in the format the settings ask for. Switching converts that draft only and leaves the setting alone; switching from the ⋮ menu answers the offer too. Forwards get it as well, where the formatting being passed on is somebody else's. What counts as rich text is hasHtmlAlternative(), which reads the body part's own type: `htmlBody` is derived (RFC 8621 4.1.4), so a plain-text message has one too and its presence proves nothing. The mock said otherwise -- it returned an empty `htmlBody` for a plain-text message, where Stalwart 0.16.21 returns the text/plain part in both lists. Both builders now answer as the server does, so the path this feature depends on is exercised in development rather than only against a real mailbox. Two new strings, translated in all nine catalogs; the buttons reuse the menu's existing "Switch to plain text" / "Switch to rich text". The count falling back to English stays at 16 in every language. Fixes #407
This commit is contained in:
@@ -18,7 +18,7 @@ function draft(over: Partial<Draft> = {}): Draft {
|
||||
requestReceipt: false, priority: "normal",
|
||||
showCc: false, showBcc: false, showReplyTo: false,
|
||||
minimized: false, maximized: false, dirty: false, savedAt: null,
|
||||
saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, sendAt: null,
|
||||
saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, formatOffer: null, sendAt: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { Email, Identity } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* Offering to answer a message in the format it was written in (#407).
|
||||
*
|
||||
* The trap is `htmlBody`: RFC 8621 derives it, so a plain-text message has one
|
||||
* too, holding its text/plain part. Reading that as "there is HTML" would
|
||||
* offer a switch to rich text on every plain-text message, and never offer the
|
||||
* switch to plain text where it is actually wanted.
|
||||
*/
|
||||
|
||||
const base = {
|
||||
messageId: ["<[email protected]>"], subject: "Numbers", references: [], inReplyTo: [],
|
||||
keywords: {}, attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {},
|
||||
from: [{ name: "Ann", email: "[email protected]" }], to: [{ name: "John", email: "[email protected]" }], cc: [],
|
||||
};
|
||||
|
||||
/** A real multipart/alternative: two parts, one of them text/html. */
|
||||
const RICH = {
|
||||
...base, id: "m1",
|
||||
htmlBody: [{ partId: "2", type: "text/html" }],
|
||||
textBody: [{ partId: "1", type: "text/plain" }],
|
||||
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false }, "2": { value: "<p>hi</p>", isEncodingProblem: false, isTruncated: false } },
|
||||
} as unknown as Email;
|
||||
|
||||
/** Plain text, as Stalwart returns it: both lists name the same text/plain part. */
|
||||
const PLAIN = {
|
||||
...base, id: "m2",
|
||||
htmlBody: [{ partId: "1", type: "text/plain" }],
|
||||
textBody: [{ partId: "1", type: "text/plain" }],
|
||||
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false } },
|
||||
} as unknown as Email;
|
||||
|
||||
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as unknown as Identity[];
|
||||
|
||||
function draftFor(email: Email, mode: "reply" | "replyAll" | "forward") {
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: IDENTITIES as never,
|
||||
getEmails: (async () => [email]) as never,
|
||||
defaultIdentity: (() => IDENTITIES[0]) as never,
|
||||
loadIdentities: (async () => IDENTITIES) as never,
|
||||
roleId: (() => null) as never,
|
||||
});
|
||||
return useCompose.getState().reply(email, mode).then((key) => useCompose.getState().drafts.find((d) => d.key === key)!);
|
||||
}
|
||||
|
||||
const composeIn = (format: "html" | "text") => useSettings.setState({ settings: { ...DEFAULT_SETTINGS, composeFormat: format } });
|
||||
|
||||
beforeEach(() => {
|
||||
useCompose.setState({ drafts: [], activeKey: null });
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
|
||||
});
|
||||
|
||||
describe("answering a message written in the other format", () => {
|
||||
it("offers rich text when a plain-text reply answers a rich message", async () => {
|
||||
composeIn("text");
|
||||
const d = await draftFor(RICH, "reply");
|
||||
expect(d.format).toBe("text");
|
||||
expect(d.formatOffer).toBe("html");
|
||||
});
|
||||
|
||||
it("offers plain text when a rich reply answers a plain-text message", async () => {
|
||||
composeIn("html");
|
||||
const d = await draftFor(PLAIN, "reply");
|
||||
expect(d.format).toBe("html");
|
||||
expect(d.formatOffer).toBe("text");
|
||||
});
|
||||
|
||||
it("offers nothing when the formats already agree", async () => {
|
||||
composeIn("html");
|
||||
expect((await draftFor(RICH, "reply")).formatOffer).toBeNull();
|
||||
composeIn("text");
|
||||
expect((await draftFor(PLAIN, "reply")).formatOffer).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the part's own type, not the derived htmlBody list", async () => {
|
||||
// PLAIN has an htmlBody; it names the text/plain part. Offering a switch
|
||||
// to rich text here would fire on every plain-text message there is.
|
||||
composeIn("text");
|
||||
expect((await draftFor(PLAIN, "reply")).formatOffer).toBeNull();
|
||||
});
|
||||
|
||||
it("offers on a reply all and on a forward, where the same formatting is lost", async () => {
|
||||
composeIn("text");
|
||||
expect((await draftFor(RICH, "replyAll")).formatOffer).toBe("html");
|
||||
expect((await draftFor(RICH, "forward")).formatOffer).toBe("html");
|
||||
});
|
||||
|
||||
it("carries both bodies either way, so switching has something to switch to", async () => {
|
||||
composeIn("text");
|
||||
const d = await draftFor(RICH, "reply");
|
||||
expect(d.text).toContain("hi");
|
||||
expect(d.html).toContain("hi");
|
||||
});
|
||||
|
||||
it("makes no offer on a message started from scratch", () => {
|
||||
composeIn("text");
|
||||
const key = useCompose.getState().open();
|
||||
expect(useCompose.getState().drafts.find((x) => x.key === key)!.formatOffer).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } fr
|
||||
import { formatFullDate, uid } from "@/lib/format";
|
||||
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
|
||||
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text/text";
|
||||
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
|
||||
import { hasHtmlAlternative, sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
|
||||
import { useSession } from "./session";
|
||||
@@ -75,6 +75,12 @@ export interface Draft {
|
||||
/** Original identity signature HTML currently embedded, to replace on identity switch. */
|
||||
signatureHtml: string;
|
||||
replyMode: "reply" | "replyAll" | "forward" | null;
|
||||
/**
|
||||
* The format the message being answered was written in, when it is not the
|
||||
* one this draft opened in (#407). The composer offers the switch; answering
|
||||
* it either way, or dismissing it, clears this.
|
||||
*/
|
||||
formatOffer: "html" | "text" | null;
|
||||
mailboxIdOnSend?: Id | null;
|
||||
/** When set, hand the message to the server held until this instant. */
|
||||
sendAt: number | null;
|
||||
@@ -145,6 +151,7 @@ function blankDraft(init: Partial<Draft> = {}): Draft {
|
||||
error: null,
|
||||
signatureHtml: "",
|
||||
replyMode: null,
|
||||
formatOffer: null,
|
||||
sendAt: null,
|
||||
...init,
|
||||
};
|
||||
@@ -379,6 +386,13 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
const textPart = full.textBody?.[0];
|
||||
const origHtml = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
|
||||
const origText = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : "";
|
||||
/*
|
||||
* What the message being answered was really written in. `htmlBody` is
|
||||
* derived, so its presence proves nothing -- hasHtmlAlternative() reads the
|
||||
* part's own type. Getting this wrong would offer every plain-text message
|
||||
* a switch to rich text it does not need.
|
||||
*/
|
||||
const origFormat = hasHtmlAlternative(htmlPart, origHtml) ? "html" : "text";
|
||||
const accountId = mail.accountId!;
|
||||
const attachments: ComposeAttachment[] = [];
|
||||
const cidMap: Record<string, string> = {};
|
||||
@@ -434,6 +448,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
relatedKeyword: mode === "forward" ? "$forwarded" : "$answered",
|
||||
signatureHtml: sigHtml,
|
||||
replyMode: mode,
|
||||
formatOffer: origFormat === s.composeFormat ? null : origFormat,
|
||||
});
|
||||
set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key }));
|
||||
return d.key;
|
||||
|
||||
Reference in New Issue
Block a user