diff --git a/FEATURES.md b/FEATURES.md index 35002f2..77a5d0c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -441,6 +441,13 @@ minimizable and maximizable; full-screen on mobile. code block, links (`Ctrl+K`), inline images, an emoji picker, and remove formatting. Tab and Shift+Tab indent inside the body. - **Plain text** as a per-message or default format. +- **Answering in the format the message was written in.** Replying in plain + text to a rich text message, or the reverse, loses either the formatting or + the plain text somebody chose to write in. The composer opens in the default + format and offers the other one for that message, above the editor; the + offer is dismissible and changes no setting. Forwards too. What counts as + rich text is the body part's own type, not the presence of `htmlBody`, which + RFC 8621 derives for plain-text mail as well. - **Recipient chips** with autocomplete from contacts, shared address books you have added, the server directory and recent recipients; your own cards win a tie against a colleague's copy of the same person. Free-form addresses parse diff --git a/server/src/mock/data.ts b/server/src/mock/data.ts index 542a2ae..1fc41b7 100644 --- a/server/src/mock/data.ts +++ b/server/src/mock/data.ts @@ -122,7 +122,11 @@ export function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [ hasAttachment: false, preview: body.slice(0, 120), textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], - htmlBody: [], + // `htmlBody` is derived (RFC 8621 4.1.4): a message with no HTML + // alternative still gets one, holding the text/plain part. Checked against + // Stalwart 0.16.21 on 2026-09-10 -- see hasHtmlAlternative() in the client, + // which reads the part's type rather than trusting this list to be empty. + htmlBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], attachments: [], bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } }, bodyStructure: { @@ -192,7 +196,8 @@ export function addEmail(o: { from: [string, string]; to?: string; subject: stri from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null, subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "), textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], - htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [], + // No HTML alternative means `htmlBody` names the text part, not nothing. See addSignedEmail. + htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], attachments, bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) }, bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] }, diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 2f8a3cb..00d19d7 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1392,6 +1392,8 @@ export const catalog: Catalog = { "Collapse all": "Alle einklappen", "Expand all": "Alle ausklappen", "Send now instead": "Stattdessen jetzt senden", + "This message is rich text": "Diese Nachricht ist formatierter Text", + "This message is plain text": "Diese Nachricht ist Nur-Text", "Switch to plain text": "Zu Nur-Text wechseln", "Switch to rich text": "Zu formatiertem Text wechseln", "{used} of {total} used": "{used} von {total} belegt", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 88f993f..24829a3 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1365,6 +1365,8 @@ export const catalog: Catalog = { "Collapse all": "Contraer todo", "Expand all": "Expandir todo", "Send now instead": "Enviar ahora, sin programar", + "This message is rich text": "Este mensaje es texto enriquecido", + "This message is plain text": "Este mensaje es texto sin formato", "Switch to plain text": "Cambiar a texto sin formato", "Switch to rich text": "Cambiar a texto enriquecido", "{used} of {total} used": "{used} de {total} usados", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 3309a26..3c34b5a 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1370,6 +1370,8 @@ export const catalog: Catalog = { "Collapse all": "Tout réduire", "Expand all": "Tout développer", "Send now instead": "Envoyer tout de suite", + "This message is rich text": "Ce message est en texte enrichi", + "This message is plain text": "Ce message est en texte brut", "Switch to plain text": "Passer en texte brut", "Switch to rich text": "Passer en texte enrichi", "{used} of {total} used": "{used} sur {total} utilisés", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 666073d..a8d4b3b 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1373,6 +1373,8 @@ export const catalog: Catalog = { "Collapse all": "すべて折りたたむ", "Expand all": "すべて展開", "Send now instead": "予約をやめて今すぐ送信", + "This message is rich text": "このメールはリッチテキストです", + "This message is plain text": "このメールはプレーンテキストです", "Switch to plain text": "プレーンテキストに切り替え", "Switch to rich text": "リッチテキストに切り替え", "{used} of {total} used": "{total} 中 {used} を使用", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 4effb3c..c14b384 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1362,6 +1362,8 @@ export const catalog: Catalog = { "Collapse all": "Alles samenvouwen", "Expand all": "Alles uitvouwen", "Send now instead": "Toch nu verzenden", + "This message is rich text": "Dit bericht is opgemaakte tekst", + "This message is plain text": "Dit bericht is platte tekst", "Switch to plain text": "Overschakelen naar platte tekst", "Switch to rich text": "Overschakelen naar opgemaakte tekst", "{used} of {total} used": "{used} van {total} gebruikt", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 4031e43..23f4f6b 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1368,6 +1368,8 @@ export const catalog: Catalog = { "Collapse all": "Recolher tudo", "Expand all": "Expandir tudo", "Send now instead": "Enviar agora mesmo", + "This message is rich text": "Esta mensagem está em texto formatado", + "This message is plain text": "Esta mensagem está em texto simples", "Switch to plain text": "Mudar para texto simples", "Switch to rich text": "Mudar para texto formatado", "{used} of {total} used": "{used} de {total} usados", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 02c29b7..71be590 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1367,6 +1367,8 @@ export const catalog: Catalog = { "Collapse all": "Свернуть все", "Expand all": "Развернуть все", "Send now instead": "Отправить сейчас", + "This message is rich text": "Это письмо в формате HTML", + "This message is plain text": "Это письмо в виде простого текста", "Switch to plain text": "Переключиться на обычный текст", "Switch to rich text": "Переключиться на форматированный текст", "{used} of {total} used": "Использовано {used} из {total}", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 15a69eb..4013e3f 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1361,6 +1361,8 @@ export const catalog: Catalog = { "Collapse all": "Згорнути все", "Expand all": "Розгорнути все", "Send now instead": "Надіслати зараз", + "This message is rich text": "Цей лист у форматі HTML", + "This message is plain text": "Цей лист у вигляді простого тексту", "Switch to plain text": "Перейти на звичайний текст", "Switch to rich text": "Перейти на форматований текст", "{used} of {total} used": "Використано {used} з {total}", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 0acd9d6..321e2c6 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1372,6 +1372,8 @@ export const catalog: Catalog = { "Collapse all": "全部折叠", "Expand all": "全部展开", "Send now instead": "改为立即发送", + "This message is rich text": "这封邮件是富文本", + "This message is plain text": "这封邮件是纯文本", "Switch to plain text": "切换为纯文本", "Switch to rich text": "切换为富文本", "{used} of {total} used": "已使用 {used},共 {total}", diff --git a/web/src/store/__tests__/compose-email.test.ts b/web/src/store/__tests__/compose-email.test.ts index 44a46fa..395ae97 100644 --- a/web/src/store/__tests__/compose-email.test.ts +++ b/web/src/store/__tests__/compose-email.test.ts @@ -18,7 +18,7 @@ function draft(over: Partial = {}): 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, }; } diff --git a/web/src/store/__tests__/reply-format-offer.test.ts b/web/src/store/__tests__/reply-format-offer.test.ts new file mode 100644 index 0000000..6f8847c --- /dev/null +++ b/web/src/store/__tests__/reply-format-offer.test.ts @@ -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: [""], subject: "Numbers", references: [], inReplyTo: [], + keywords: {}, attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {}, + from: [{ name: "Ann", email: "ann@example.com" }], to: [{ name: "John", email: "john@example.org" }], 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: "

hi

", 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: "john@example.org", 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(); + }); +}); diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index b224642..41e4f6d 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -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 { error: null, signatureHtml: "", replyMode: null, + formatOffer: null, sendAt: null, ...init, }; @@ -379,6 +386,13 @@ export const useCompose = create((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 = {}; @@ -434,6 +448,7 @@ export const useCompose = create((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; diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 10d6c48..2412f78 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1570,6 +1570,9 @@ a.menu-item:hover { color: var(--fg); } .composer-head .title { flex: 1; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .composer-head .status { color: var(--fg-faint); font-size: .8em; margin-right: 6px; white-space: nowrap; } .composer-body { display: flex; flex-direction: column; flex: 1; min-height: 0; } +/* An offer the draft makes about itself, above the editor: quiet, one line, and dismissible. */ +.composer-notice { display: flex; align-items: center; gap: 8px; padding: 6px 10px 6px 14px; background: var(--accent-soft); color: var(--accent-soft-fg); border-bottom: 1px solid var(--border); font-size: .85em; flex: 0 0 auto; } +.composer-notice span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .composer-fields { flex: 0 0 auto; padding: 0 12px; } .composer-field { display: flex; align-items: center; gap: 8px; min-height: 40px; border-bottom: 1px solid var(--border); padding: 4px 0; } .composer-field > label { color: var(--fg-muted); width: 42px; flex: 0 0 auto; font-size: .92em; } diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index f81b5e4..644a032 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -148,10 +148,11 @@ export function Composer({ draft }: { draft: Draft }) { }; const toggleFormat = () => { + // Whichever way the format is changed, the offer has been answered. if (d.format === "html") { - patch({ format: "text", text: htmlToText(d.html) }); + patch({ format: "text", text: htmlToText(d.html), formatOffer: null }); } else { - patch({ format: "html", html: textToHtml(d.text, { linkify: false, quoteColors: false }).replace(/\n/g, "
") }); + patch({ format: "html", html: textToHtml(d.text, { linkify: false, quoteColors: false }).replace(/\n/g, "
"), formatOffer: null }); } }; @@ -261,6 +262,22 @@ export function Composer({ draft }: { draft: Draft }) { )} + {/* + Replying in one format to a message written in the other loses + something either way: the formatting of a rich reply, or the plain + text somebody chose to write in. The draft opens in the format the + settings ask for, and this offers the other one for this message + only, rather than quietly overriding the setting (#407). + */} + {d.formatOffer && ( +
+ {d.formatOffer === "html" ? translate("This message is rich text") : translate("This message is plain text")} + + +
+ )} {d.format === "html" ? ( addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} /> ) : ( diff --git a/web/src/views/compose/__tests__/format-offer-bar.test.tsx b/web/src/views/compose/__tests__/format-offer-bar.test.tsx new file mode 100644 index 0000000..5d1cc34 --- /dev/null +++ b/web/src/views/compose/__tests__/format-offer-bar.test.tsx @@ -0,0 +1,63 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Composer } from "../Composer"; +import { useCompose, type Draft } from "@/store/compose"; +import { useMail } from "@/store/mail"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +/** + * The bar the composer shows when the draft's format doesn't match the message + * it is answering (#407). A store test can say the offer was made; only the + * component can say that pressing it converts the body and puts the bar away. + */ + +window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia; + +const REPLY: Partial = { + key: "d1", replyMode: "reply", subject: "Re: Numbers", + format: "text", text: "\n\nOn Friday, Ann wrote:\n> hi", html: "

hi
", + formatOffer: "html", +}; + +describe("the format offer in the composer", () => { + let host: HTMLDivElement; + let root: Root; + const bar = () => document.querySelector(".composer-notice"); + const draft = () => useCompose.getState().drafts[0]!; + const button = (label: string) => Array.from(document.querySelectorAll(".composer-notice button")).find((b) => b.textContent === label || b.getAttribute("aria-label") === label)!; + + beforeEach(() => { + useMail.setState({ accountId: "a1", identities: [] as never }); + useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} }); + const key = useCompose.getState().open(); + useCompose.getState().update(key, REPLY); + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + act(() => root.render()); + }); + afterEach(() => { act(() => root.unmount()); host.remove(); }); + + it("offers the message's own format, and says which it is", () => { + expect(bar()?.textContent).toContain("This message is rich text"); + expect(button("Switch to rich text")).toBeTruthy(); + }); + + it("switches this draft and puts the bar away", () => { + act(() => button("Switch to rich text").click()); + act(() => root.render()); + expect(draft().format).toBe("html"); + // The quoted reply came across, rather than the editor opening empty. + expect(draft().html).toContain("Ann wrote"); + expect(bar()).toBeNull(); + }); + + it("dismisses without changing the format", () => { + act(() => button("Dismiss").click()); + act(() => root.render()); + expect(draft().format).toBe("text"); + expect(bar()).toBeNull(); + }); +});