Quoting follows the message's own image decision (#410) (#411)

Replying sanitized the quoted body with allowRemote: true, so quoting
fetched every remote image in the message whatever the reader had
decided about it. A tracking pixel in the quote then reported the
message read, and the address live, to whoever was counting -- the thing
leaving the images blocked was meant to prevent. Edit as new and opening
a draft that quotes a message did the same.

The decision now lives in one place, remoteImagesAllowed(), asked with
the same inputs the reader's answer used: the image policy, the trusted
senders, whether the sender is a contact, and whether Show images was
pressed on that message. The last of those was component state, so it
moves to the mail store, where the composer can see it.

Blocked images already keep their address in data-ihm-remote, so nothing
is lost by not fetching: it goes back on the way out, and the sent quote
is what its sender wrote. The recipient's client decides for itself, as
it would with any other client's reply.

Before pr408 this needed a rich-text default to reach; the format offer
made it reachable from plain text, which is how it was found.

No new strings.
This commit is contained in:
jcoffey
2026-09-19 15:48:13 -07:00
committed by GitHub
parent 88f9e6c50a
commit d329b33912
7 changed files with 199 additions and 6 deletions
+6
View File
@@ -441,6 +441,12 @@ minimizable and maximizable; full-screen on mobile.
code block, links (`Ctrl+K`), inline images, an emoji picker, and remove code block, links (`Ctrl+K`), inline images, an emoji picker, and remove
formatting. Tab and Shift+Tab indent inside the body. formatting. Tab and Shift+Tab indent inside the body.
- **Plain text** as a per-message or default format. - **Plain text** as a per-message or default format.
- **Quoting follows the message's own image decision.** A quote renders the
message again, so the reply blocks its remote images unless that message was
allowed them — by policy, by a trusted sender, by the sender being a
contact, or by *Show images* having been pressed on it. Blocked images keep
their address and get it back when the reply is sent, so the recipient's
copy is the quote as its sender wrote it.
- **Answering in the format the message was written in.** Replying in plain - **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 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 the plain text somebody chose to write in. The composer opens in the default
+45
View File
@@ -0,0 +1,45 @@
import type { ImagePolicy } from "@/store/settings";
/**
* Whether a message's remote images may be fetched.
*
* The reader's decision, in one place, because the composer has to make the
* same one. Quoting a message into a reply renders it again — and a quote that
* fetched what the reader had declined would report the message read, and the
* address live, to whoever was counting. The tracking pixel does not care
* which window it loaded in.
*/
export function remoteImagesAllowed(opts: {
from: string | null | undefined;
policy: ImagePolicy;
trusted: string[];
inContacts: boolean;
/** The reader pressed "Show images" on this message. */
shown: boolean;
}): boolean {
if (opts.shown || opts.policy === "always") return true;
if (opts.trusted.includes((opts.from ?? "").toLowerCase())) return true;
return opts.policy === "contacts" && opts.inContacts;
}
/**
* Put back the addresses of images that were blocked when the message was
* quoted, on the way out.
*
* Blocking keeps the original URL on the element (`data-ihm-remote`), so
* nothing was lost by not fetching it. The copy that leaves here should be the
* quote as its sender wrote it: the recipient's client decides for itself
* whether to load those images, the same as it would have with any other
* client's reply.
*/
export function restoreBlockedImages(html: string): string {
if (!html.includes("data-ihm-blocked")) return html;
const doc = new DOMParser().parseFromString(html, "text/html");
for (const img of Array.from(doc.querySelectorAll("img[data-ihm-blocked]"))) {
const url = img.getAttribute("data-ihm-remote");
if (url) img.setAttribute("src", url);
img.removeAttribute("data-ihm-blocked");
img.removeAttribute("data-ihm-remote");
}
return doc.body.innerHTML;
}
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it } from "vitest";
import { buildEmailObject, useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import { useContacts } from "@/store/contacts";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
import type { Email, EmailAddress, Identity } from "@/jmap/types";
/*
* Remote images in a quoted message (#410).
*
* Quoting renders the message a second time. The reply was fetching every
* remote image in it, whatever the reader had decided — so replying to a
* message whose images had been left blocked told the tracker the mail was
* read and the address live. The composer is a window like any other.
*/
const PIXEL = "https://tracker.example/open.gif?id=42";
const MESSAGE = {
id: "m1", messageId: ["<[email protected]>"], subject: "Sale", references: [], inReplyTo: [], keywords: {},
attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {},
from: [{ name: "Shop", email: "[email protected]" }], to: [{ name: "John", email: "[email protected]" }], cc: [],
htmlBody: [{ partId: "2", type: "text/html" }],
textBody: [{ partId: "1", type: "text/plain" }],
bodyValues: {
"1": { value: "Sale on now", isEncodingProblem: false, isTruncated: false },
"2": { value: `<p>Sale on now</p><img src="${PIXEL}" width="1" height="1">`, isEncodingProblem: false, isTruncated: false },
},
} as unknown as Email;
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as unknown as Identity[];
function replyDraft() {
useMail.setState({
accountId: "a1",
identities: IDENTITIES as never,
getEmails: (async () => [MESSAGE]) as never,
defaultIdentity: (() => IDENTITIES[0]) as never,
loadIdentities: (async () => IDENTITIES) as never,
roleId: (() => null) as never,
});
return useCompose.getState().reply(MESSAGE, "reply").then((key) => useCompose.getState().drafts.find((d) => d.key === key)!);
}
beforeEach(() => {
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
useMail.setState({ imagesShown: {} });
useContacts.setState({ loaded: false } as never);
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, imagePolicy: "ask", composeFormat: "html" } });
});
describe("quoting a message whose images were not allowed", () => {
it("does not put a fetchable address in the draft", async () => {
const d = await replyDraft();
expect(d.html).not.toContain(PIXEL.split("?")[0]! + '"');
expect(d.html).toContain("data-ihm-blocked");
// The src is what the browser would fetch; nothing else in the draft is.
expect(/<img[^>]+src="https:/.test(d.html)).toBe(false);
});
it("keeps the address, so the sent copy is the quote as it was written", async () => {
const d = await replyDraft();
expect(d.html).toContain(PIXEL);
const email = await buildEmailObject({ ...d, to: [{ name: null, email: "[email protected]" }] as EmailAddress[] }, { forSend: true });
const sent = JSON.stringify(email);
expect(sent).toContain(PIXEL);
expect(sent).not.toContain("data-ihm-blocked");
});
it("fetches them once the reader has shown images on that message", async () => {
useMail.setState({ imagesShown: { m1: true } });
const d = await replyDraft();
expect(d.html).toContain(`src="${PIXEL}"`);
expect(d.html).not.toContain("data-ihm-blocked");
});
it("fetches them when the policy is to show images always", async () => {
useSettings.setState((s) => ({ settings: { ...s.settings, imagePolicy: "always" } }));
expect((await replyDraft()).html).toContain(`src="${PIXEL}"`);
});
it("fetches them from a sender the reader trusts", async () => {
useSettings.setState((s) => ({ settings: { ...s.settings, trustedImageSenders: ["[email protected]"] } }));
expect((await replyDraft()).html).toContain(`src="${PIXEL}"`);
});
it("leaves them blocked for a stranger when the policy is contacts only", async () => {
useSettings.setState((s) => ({ settings: { ...s.settings, imagePolicy: "contacts" } }));
expect((await replyDraft()).html).toContain("data-ihm-blocked");
});
});
+36 -4
View File
@@ -5,6 +5,7 @@ import { formatFullDate, uid } from "@/lib/format";
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address"; import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text/text"; import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text/text";
import { hasHtmlAlternative, sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html"; import { hasHtmlAlternative, sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
import { remoteImagesAllowed, restoreBlockedImages } from "@/lib/mail/remoteImages";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail"; import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { useSession } from "./session"; import { useSession } from "./session";
@@ -13,6 +14,7 @@ import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n"; import { t as translate } from "@/lib/i18n";
import { BASE_PATH } from "@/lib/basePath"; import { BASE_PATH } from "@/lib/basePath";
import { settings } from "./settings"; import { settings } from "./settings";
import { useContacts } from "./contacts";
import { emlFilename } from "@/lib/text/emlName"; import { emlFilename } from "@/lib/text/emlName";
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders"; import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
import { shareBody, type SharedContent } from "@/lib/shareTarget"; import { shareBody, type SharedContent } from "@/lib/shareTarget";
@@ -167,6 +169,26 @@ function blankDraft(init: Partial<Draft> = {}): Draft {
}; };
} }
/**
* Whether this message's remote images may be fetched into a composer.
*
* The same question the reader answered, asked with the same inputs: the
* policy, the trusted senders, whether the sender is a contact, and whether
* the reader pressed "Show images" on this message.
*/
function remoteImagesForMessage(email: Email): boolean {
const s = settings();
const from = email.from?.[0]?.email;
const contacts = useContacts.getState();
return remoteImagesAllowed({
from,
policy: s.imagePolicy,
trusted: s.trustedImageSenders,
inContacts: Boolean(from && contacts.loaded && contacts.lookupByEmail(from)),
shown: Boolean(useMail.getState().imagesShown[email.id]),
});
}
export function signatureBlock(identity: Identity | undefined, format: "html" | "text"): string { export function signatureBlock(identity: Identity | undefined, format: "html" | "text"): string {
if (!identity) return ""; if (!identity) return "";
if (format === "text") return identity.textSignature ? `\n\n-- \n${identity.textSignature}` : ""; if (format === "text") return identity.textSignature ? `\n\n-- \n${identity.textSignature}` : "";
@@ -260,7 +282,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
showCc: Boolean(full.cc?.length), showCc: Boolean(full.cc?.length),
showBcc: Boolean(full.bcc?.length), showBcc: Boolean(full.bcc?.length),
subject: full.subject ?? "", subject: full.subject ?? "",
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true, dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"), html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
text: text || (html ? htmlToText(html) : ""), text: text || (html ? htmlToText(html) : ""),
format: html ? "html" : settings().composeFormat, format: html ? "html" : settings().composeFormat,
attachments, attachments,
@@ -326,7 +348,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
showCc: Boolean(full.cc?.length), showCc: Boolean(full.cc?.length),
showBcc: Boolean(full.bcc?.length), showBcc: Boolean(full.bcc?.length),
subject: full.subject ?? "", subject: full.subject ?? "",
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true, dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"), html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
text: text || (html ? htmlToText(html) : ""), text: text || (html ? htmlToText(html) : ""),
format: html ? "html" : settings().composeFormat, format: html ? "html" : settings().composeFormat,
attachments, attachments,
@@ -413,9 +435,17 @@ export const useCompose = create<ComposeState>((set, get) => ({
attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline }); attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline });
} }
} }
/*
* Quoting renders the message a second time, so the reader's decision
* about its remote images applies here too: a quote that fetched what
* they declined would report the message read to whoever was counting
* (#410). Blocked images keep their address and get it back on the way
* out, so the recipient's copy is the quote as its sender wrote it.
*/
const allowRemote = remoteImagesForMessage(full);
// Inline images are shown via their blob URLs in the editor and converted back to cid: at send time. // Inline images are shown via their blob URLs in the editor and converted back to cid: at send time.
const quotedHtmlBody = origHtml const quotedHtmlBody = origHtml
? sanitizeEmailHtml(origHtml, { cidMap, allowRemote: true, proxyRemote: false, dropStyleBlocks: true }).html ? sanitizeEmailHtml(origHtml, { cidMap, allowRemote, proxyRemote: false, dropStyleBlocks: true }).html
: textToHtml(origText).replace(/\n/g, "<br>"); : textToHtml(origText).replace(/\n/g, "<br>");
const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", ")); const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", "));
const date = formatFullDate(full.receivedAt); const date = formatFullDate(full.receivedAt);
@@ -781,7 +811,9 @@ export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailb
if (!ident) throw new Error(translate("No sending identity available")); if (!ident) throw new Error(translate("No sending identity available"));
const from: EmailAddress = { name: ident.name || null, email: ident.email }; const from: EmailAddress = { name: ident.name || null, email: ident.email };
let html = d.format === "html" ? d.html : ""; // Images blocked when the message was quoted keep their address; the copy
// that leaves carries it, and the recipient's client decides for itself.
let html = d.format === "html" ? restoreBlockedImages(d.html) : "";
const text = d.format === "html" ? htmlToText(d.html) : d.text; const text = d.format === "html" ? htmlToText(d.html) : d.text;
// Inline attachments shown via blob URLs in the editor → back to cid: references. // Inline attachments shown via blob URLs in the editor → back to cid: references.
+5
View File
@@ -78,6 +78,7 @@ function offerArchiveFolder(retry: () => Promise<void>): void {
export const useMail = create<MailState>((set, get) => ({ export const useMail = create<MailState>((set, get) => ({
accountId: null, accountId: null,
imagesShown: {},
mailboxes: {}, mailboxes: {},
mailboxState: null, mailboxState: null,
mailboxesLoaded: false, mailboxesLoaded: false,
@@ -866,6 +867,10 @@ export const useMail = create<MailState>((set, get) => ({
} }
}, },
showImages(id) {
set((s) => ({ imagesShown: { ...s.imagesShown, [id]: true } }));
},
select(ids, on) { select(ids, on) {
set((s) => { set((s) => {
const next = { ...s.selected }; const next = { ...s.selected };
+8
View File
@@ -33,6 +33,12 @@ export interface ListState extends ListQuery {
export interface MailState { export interface MailState {
accountId: Id | null; accountId: Id | null;
/**
* Messages the reader pressed "Show images" on, this session. Kept here
* rather than in the message view because replying quotes the message into
* a second window, which has to honour the same decision.
*/
imagesShown: Record<Id, boolean>;
mailboxes: Record<Id, Mailbox>; mailboxes: Record<Id, Mailbox>;
mailboxState: string | null; mailboxState: string | null;
mailboxesLoaded: boolean; mailboxesLoaded: boolean;
@@ -113,6 +119,8 @@ export interface MailState {
saveVacation(patch: Partial<VacationResponse>): Promise<void>; saveVacation(patch: Partial<VacationResponse>): Promise<void>;
loadQuota(): Promise<void>; loadQuota(): Promise<void>;
/** Remember that this message's remote images were allowed by hand. */
showImages(id: Id): void;
select(ids: Id[], on: boolean): void; select(ids: Id[], on: boolean): void;
clearSelection(): void; clearSelection(): void;
/** Refresh the per-label unread counts, in one request. */ /** Refresh the per-label unread counts, in one request. */
+8 -2
View File
@@ -17,6 +17,7 @@ import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
import { spamReport, type SpamReport } from "@/lib/spamScore"; import { spamReport, type SpamReport } from "@/lib/spamScore";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, domainOf, formatAddress } from "@/lib/address"; import { displayName, domainOf, formatAddress } from "@/lib/address";
import { remoteImagesAllowed } from "@/lib/mail/remoteImages";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html"; import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
import { openableInTab, previewKind } from "@/lib/preview"; import { openableInTab, previewKind } from "@/lib/preview";
// Loaded when first opened: it is not needed to show mail, and it is not small. // Loaded when first opened: it is not needed to show mail, and it is not small.
@@ -118,7 +119,12 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
/* Stable, so the body's click handler keeps its identity between renders. /* Stable, so the body's click handler keeps its identity between renders.
Passing an inline arrow here is what made the handler change on every Passing an inline arrow here is what made the handler change on every
render in the first place. */ render in the first place. */
const showImages = useCallback(() => setAllowRemote(true), []); const showImages = useCallback(() => {
setAllowRemote(true);
// Recorded for the composer: a reply quotes this message and must not
// fetch what the reader has not agreed to (#410).
useMail.getState().showImages(e.id);
}, [e.id]);
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const moreMenu = useMenu(); const moreMenu = useMenu();
const [, navigate] = useLocation(); const [, navigate] = useLocation();
@@ -128,7 +134,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const from = e.from?.[0]; const from = e.from?.[0];
const senderTrusted = settings.trustedImageSenders.includes((from?.email ?? "").toLowerCase()); const senderTrusted = settings.trustedImageSenders.includes((from?.email ?? "").toLowerCase());
const inContacts = useContacts((s) => Boolean(from && s.loaded && s.lookupByEmail(from.email))); const inContacts = useContacts((s) => Boolean(from && s.loaded && s.lookupByEmail(from.email)));
const remoteAllowed = allowRemote || settings.imagePolicy === "always" || senderTrusted || (settings.imagePolicy === "contacts" && inContacts); const remoteAllowed = remoteImagesAllowed({ from: from?.email, policy: settings.imagePolicy, trusted: settings.trustedImageSenders, inContacts, shown: allowRemote });
const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true); const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true);
const scheduled = useScheduled((s) => s.pending[e.id]); const scheduled = useScheduled((s) => s.pending[e.id]);
const receipt = useMemo(() => mdnDecision(e), [e]); const receipt = useMemo(() => mdnDecision(e), [e]);