diff --git a/FEATURES.md b/FEATURES.md index 8e64471..34e7cee 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -446,7 +446,9 @@ minimizable and maximizable; full-screen on mobile. 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. + copy is the quote as its sender wrote it. Allowed ones are fetched through + the server's image proxy, the same as when the message was read, and the + sent copy points at their own addresses rather than at this server. - **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 diff --git a/web/src/lib/mail/remoteImages.ts b/web/src/lib/mail/remoteImages.ts index b31b773..aac0181 100644 --- a/web/src/lib/mail/remoteImages.ts +++ b/web/src/lib/mail/remoteImages.ts @@ -1,3 +1,4 @@ +import { unproxiedImageUrl } from "@/lib/text/html"; import type { ImagePolicy } from "@/store/settings"; /** @@ -22,6 +23,25 @@ export function remoteImagesAllowed(opts: { return opts.policy === "contacts" && opts.inContacts; } +/** + * Point proxied images back at their own addresses, on the way out. + * + * Reading a message fetches its remote images through this server, so the + * sender learns nothing about the reader. Those URLs belong to this + * deployment, so a quote that kept them would reach the recipient as images + * only this server can serve -- broken for them, and a beacon back here for + * anyone who could load them (#412). + */ +export function unproxyImages(html: string): string { + if (!html.includes("/api/image?url=")) return html; + const doc = new DOMParser().parseFromString(html, "text/html"); + for (const img of Array.from(doc.querySelectorAll("img[src]"))) { + const real = unproxiedImageUrl(img.getAttribute("src") ?? ""); + if (real) img.setAttribute("src", real); + } + return doc.body.innerHTML; +} + /** * Put back the addresses of images that were blocked when the message was * quoted, on the way out. diff --git a/web/src/lib/text/html.ts b/web/src/lib/text/html.ts index bb0d9a4..a2eb1ed 100644 --- a/web/src/lib/text/html.ts +++ b/web/src/lib/text/html.ts @@ -1,5 +1,5 @@ import DOMPurify from "dompurify"; -import { withBase } from "@/lib/basePath"; +import { BASE_PATH, withBase } from "@/lib/basePath"; export interface SanitizeOptions { /** Map of Content-ID (without angle brackets) → URL for inline images. */ @@ -158,6 +158,23 @@ export function proxiedImageUrl(url: string): string { return withBase(`/api/image?url=${encodeURIComponent(url)}`); } +/** + * The address a proxied image really points at, or null if this is not one. + * + * A proxied URL is this server's, so it is right for reading a message and + * wrong for sending one: a quote left this way would hand the recipient + * images that only load from inside this deployment (#412). + */ +export function unproxiedImageUrl(src: string): string | null { + const path = `${BASE_PATH}/api/image?url=`; + if (!src.startsWith(path)) return null; + try { + return decodeURIComponent(src.slice(path.length)) || null; + } catch { + return null; // Malformed escape: leave it alone rather than mangle it. + } +} + export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult { ensureHooks(); let bodyStyle = ""; diff --git a/web/src/store/__tests__/quote-image-policy.test.ts b/web/src/store/__tests__/quote-image-policy.test.ts index 38b00f9..fb92444 100644 --- a/web/src/store/__tests__/quote-image-policy.test.ts +++ b/web/src/store/__tests__/quote-image-policy.test.ts @@ -30,6 +30,13 @@ const MESSAGE = { const IDENTITIES = [{ id: "i1", name: "John", email: "john@example.org", replyTo: null }] as unknown as Identity[]; +/** + * Whether the draft will actually load the image. Allowed images go through + * the server's proxy where the deployment has one (#412), so the address is + * escaped inside an `/api/image` URL rather than sitting in `src` as it is. + */ +const fetched = (html: string) => html.includes(`/api/image?url=${encodeURIComponent(PIXEL)}`) || html.includes(`src="${PIXEL}"`); + function replyDraft() { useMail.setState({ accountId: "a1", @@ -70,18 +77,18 @@ describe("quoting a message whose images were not allowed", () => { 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(fetched(d.html)).toBe(true); 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}"`); + expect(fetched((await replyDraft()).html)).toBe(true); }); it("fetches them from a sender the reader trusts", async () => { useSettings.setState((s) => ({ settings: { ...s.settings, trustedImageSenders: ["shop@example.com"] } })); - expect((await replyDraft()).html).toContain(`src="${PIXEL}"`); + expect(fetched((await replyDraft()).html)).toBe(true); }); it("leaves them blocked for a stranger when the policy is contacts only", async () => { diff --git a/web/src/store/__tests__/quote-image-proxy.test.ts b/web/src/store/__tests__/quote-image-proxy.test.ts new file mode 100644 index 0000000..0c21079 --- /dev/null +++ b/web/src/store/__tests__/quote-image-proxy.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { buildEmailObject, useCompose } from "@/store/compose"; +import { useMail } from "@/store/mail"; +import { useContacts } from "@/store/contacts"; +import { useSession } from "@/store/session"; +import { DEFAULT_SETTINGS, useSettings } from "@/store/settings"; +import { unproxyImages } from "@/lib/mail/remoteImages"; +import type { Email, EmailAddress, Identity } from "@/jmap/types"; + +/* + * Remote images in a quote go through this server, and come back out pointing + * at their own addresses (#412). + * + * Reading a message proxies its images so the sender learns nothing about the + * reader. Quoting fetched them directly, which handed the same pixel the + * reader's IP and user agent. Proxying the quote is only half of it: those + * URLs belong to this deployment, so the copy that is sent has to carry the + * originals or the recipient gets images only this server can serve. + */ + +const IMAGE = "https://cdn.example/banner.png?id=7"; + +const MESSAGE = { + id: "m1", messageId: [""], subject: "Sale", references: [], inReplyTo: [], keywords: {}, + attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {}, + from: [{ name: "Shop", email: "shop@example.com" }], to: [{ name: "John", email: "john@example.org" }], 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: `

Sale

`, 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(mode: "reply" | "forward") { + 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, mode).then((key) => useCompose.getState().drafts.find((d) => d.key === key)!); +} + +const proxy = (on: boolean) => useSession.setState({ session: { ihasmail: { imageProxy: on } } } as never); + +beforeEach(() => { + useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} }); + useMail.setState({ imagesShown: {} }); + useContacts.setState({ loaded: false } as never); + // Images allowed, so the question is only how they are fetched. + useSettings.setState({ settings: { ...DEFAULT_SETTINGS, imagePolicy: "always", composeFormat: "html" } }); + proxy(true); +}); + +describe("images in a quote, while the reply is being written", () => { + it("are fetched through this server, as reading the message does", async () => { + const d = await draftFor("reply"); + expect(d.html).toContain("/api/image?url="); + expect(d.html).not.toContain(`src="${IMAGE}"`); + }); + + it("are fetched directly where the deployment has no proxy", async () => { + proxy(false); + const d = await draftFor("reply"); + expect(d.html).toContain(`src="${IMAGE}"`); + expect(d.html).not.toContain("/api/image?url="); + }); + + it("go through it on a forward too", async () => { + expect((await draftFor("forward")).html).toContain("/api/image?url="); + }); +}); + +describe("the copy that is sent", () => { + it("points at the image's own address, not at this server", async () => { + const d = await draftFor("reply"); + const sent = JSON.stringify(await buildEmailObject({ ...d, to: [{ name: null, email: "shop@example.com" }] as EmailAddress[] }, { forSend: true })); + expect(sent).toContain(IMAGE.replace(/&/g, "&")); + expect(sent).not.toContain("/api/image?url="); + }); + + it("restores a signature or template image that used the proxy as well", () => { + const logo = "https://cdn.example/logo.png"; + const html = `

Regards

`; + const out = unproxyImages(html); + expect(out).toContain(`src="${logo}"`); + expect(out).toContain('src="cid:x@1"'); + }); + + it("leaves everything else alone", () => { + const html = 'link'; + expect(unproxyImages(html)).toBe(html); + }); +}); diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index 7f432dc..660b8a5 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -5,7 +5,7 @@ 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 { hasHtmlAlternative, sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html"; -import { remoteImagesAllowed, restoreBlockedImages } from "@/lib/mail/remoteImages"; +import { remoteImagesAllowed, restoreBlockedImages, unproxyImages } from "@/lib/mail/remoteImages"; import { toast } from "@/ui/toast"; import { useMail, FULL_PROPS, BODY_PROPS } from "./mail"; import { useSession } from "./session"; @@ -176,6 +176,11 @@ function blankDraft(init: Partial = {}): Draft { * policy, the trusted senders, whether the sender is a contact, and whether * the reader pressed "Show images" on this message. */ +/** Whether this deployment fetches remote images through its own server. */ +function imageProxyOn(): boolean { + return useSession.getState().session?.ihasmail?.imageProxy ?? true; +} + function remoteImagesForMessage(email: Email): boolean { const s = settings(); const from = email.from?.[0]?.email; @@ -282,7 +287,7 @@ export const useCompose = create((set, get) => ({ showCc: Boolean(full.cc?.length), showBcc: Boolean(full.bcc?.length), subject: full.subject ?? "", - html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "
"), + html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), proxyRemote: imageProxyOn(), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "
"), text: text || (html ? htmlToText(html) : ""), format: html ? "html" : settings().composeFormat, attachments, @@ -348,7 +353,7 @@ export const useCompose = create((set, get) => ({ showCc: Boolean(full.cc?.length), showBcc: Boolean(full.bcc?.length), subject: full.subject ?? "", - html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "
"), + html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), proxyRemote: imageProxyOn(), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "
"), text: text || (html ? htmlToText(html) : ""), format: html ? "html" : settings().composeFormat, attachments, @@ -443,9 +448,13 @@ export const useCompose = create((set, get) => ({ * out, so the recipient's copy is the quote as its sender wrote it. */ const allowRemote = remoteImagesForMessage(full); + // Fetched through this server while the reply is written, as reading the + // message does, and pointed back at their own addresses on the way out + // (#412). + const proxyRemote = imageProxyOn(); // Inline images are shown via their blob URLs in the editor and converted back to cid: at send time. const quotedHtmlBody = origHtml - ? sanitizeEmailHtml(origHtml, { cidMap, allowRemote, proxyRemote: false, dropStyleBlocks: true }).html + ? sanitizeEmailHtml(origHtml, { cidMap, allowRemote, proxyRemote, dropStyleBlocks: true }).html : textToHtml(origText).replace(/\n/g, "
"); const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", ")); const date = formatFullDate(full.receivedAt); @@ -813,7 +822,7 @@ export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailb // 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) : ""; + let html = d.format === "html" ? unproxyImages(restoreBlockedImages(d.html)) : ""; const text = d.format === "html" ? htmlToText(d.html) : d.text; // Inline attachments shown via blob URLs in the editor → back to cid: references.