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.

(cherry picked from commit d329b33912921a851c548bffef5085e5fbf72bed)
This commit is contained in:
jcoffey authored and John Coffey committed 2026-09-27 16:27:24 -07:00
1 parent 9ba2c6e290
commit a94fd9cce3
6 files changed
+193 -6

No files matched your search

+8 -2
View File
@@ -19,6 +19,7 @@ import { llmOpinion } from "@/lib/llmOpinion";
import { LlmOpinionBanner, LlmOpinionDetail, llmBannerOpinion } from "./LlmOpinion";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
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 { openableInTab, previewKind } from "@/lib/preview";
// Loaded when first opened: it is not needed to show mail, and it is not small.
@@ -120,7 +121,12 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
/* 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
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 moreMenu = useMenu();
const [, navigate] = useLocation();
@@ -130,7 +136,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const from = e.from?.[0];
const senderTrusted = settings.trustedImageSenders.includes((from?.email ?? "").toLowerCase());
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 scheduled = useScheduled((s) => s.pending[e.id]);
const receipt = useMemo(() => mdnDecision(e), [e]);