Reading a message fetches its remote images through this server, so the sender learns nothing about the reader. Quoting the same message into a reply fetched them directly: same pixel, same reader, but the request carried their IP and user agent -- exactly what the proxy withholds. A quote now proxies them the way the message view does. That alone would be wrong, because a proxied URL belongs to this deployment: sent unchanged it would reach the recipient as images only this server can serve, broken for them and a beacon back here. So buildEmailObject turns them back into the addresses they came from, beside the pass that restores images blocked under pr411 and the one that turns editor blob URLs into cid: references. Deployments with the proxy off are unaffected: the quote fetches directly, as reading does there. Three tests from pr411 asserted the address sat in src when images were allowed, which was the old behaviour; they now ask whether the draft fetches it at all, proxied or not. No new strings.
This commit is contained in:
+3
-1
@@ -446,7 +446,9 @@ minimizable and maximizable; full-screen on mobile.
|
|||||||
allowed them — by policy, by a trusted sender, by the sender being a
|
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
|
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
|
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
|
- **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
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { unproxiedImageUrl } from "@/lib/text/html";
|
||||||
import type { ImagePolicy } from "@/store/settings";
|
import type { ImagePolicy } from "@/store/settings";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +23,25 @@ export function remoteImagesAllowed(opts: {
|
|||||||
return opts.policy === "contacts" && opts.inContacts;
|
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
|
* Put back the addresses of images that were blocked when the message was
|
||||||
* quoted, on the way out.
|
* quoted, on the way out.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { withBase } from "@/lib/basePath";
|
import { BASE_PATH, withBase } from "@/lib/basePath";
|
||||||
|
|
||||||
export interface SanitizeOptions {
|
export interface SanitizeOptions {
|
||||||
/** Map of Content-ID (without angle brackets) → URL for inline images. */
|
/** 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)}`);
|
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 {
|
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
|
||||||
ensureHooks();
|
ensureHooks();
|
||||||
let bodyStyle = "";
|
let bodyStyle = "";
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ const MESSAGE = {
|
|||||||
|
|
||||||
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as unknown as Identity[];
|
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", 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() {
|
function replyDraft() {
|
||||||
useMail.setState({
|
useMail.setState({
|
||||||
accountId: "a1",
|
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 () => {
|
it("fetches them once the reader has shown images on that message", async () => {
|
||||||
useMail.setState({ imagesShown: { m1: true } });
|
useMail.setState({ imagesShown: { m1: true } });
|
||||||
const d = await replyDraft();
|
const d = await replyDraft();
|
||||||
expect(d.html).toContain(`src="${PIXEL}"`);
|
expect(fetched(d.html)).toBe(true);
|
||||||
expect(d.html).not.toContain("data-ihm-blocked");
|
expect(d.html).not.toContain("data-ihm-blocked");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches them when the policy is to show images always", async () => {
|
it("fetches them when the policy is to show images always", async () => {
|
||||||
useSettings.setState((s) => ({ settings: { ...s.settings, imagePolicy: "always" } }));
|
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 () => {
|
it("fetches them from a sender the reader trusts", async () => {
|
||||||
useSettings.setState((s) => ({ settings: { ...s.settings, trustedImageSenders: ["[email protected]"] } }));
|
useSettings.setState((s) => ({ settings: { ...s.settings, trustedImageSenders: ["[email protected]"] } }));
|
||||||
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 () => {
|
it("leaves them blocked for a stranger when the policy is contacts only", async () => {
|
||||||
|
|||||||
@@ -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: ["<[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</p><img src="${IMAGE}">`, 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(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: "[email protected]" }] 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 = `<p>Regards</p><img src="/api/image?url=${encodeURIComponent(logo)}"><img src="cid:x@1">`;
|
||||||
|
const out = unproxyImages(html);
|
||||||
|
expect(out).toContain(`src="${logo}"`);
|
||||||
|
expect(out).toContain('src="cid:x@1"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves everything else alone", () => {
|
||||||
|
const html = '<img src="cid:logo@1"><img src="blob:http://localhost/abc"><a href="/api/image?url=x">link</a>';
|
||||||
|
expect(unproxyImages(html)).toBe(html);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +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 { remoteImagesAllowed, restoreBlockedImages, unproxyImages } 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";
|
||||||
@@ -176,6 +176,11 @@ function blankDraft(init: Partial<Draft> = {}): Draft {
|
|||||||
* policy, the trusted senders, whether the sender is a contact, and whether
|
* policy, the trusted senders, whether the sender is a contact, and whether
|
||||||
* the reader pressed "Show images" on this message.
|
* 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 {
|
function remoteImagesForMessage(email: Email): boolean {
|
||||||
const s = settings();
|
const s = settings();
|
||||||
const from = email.from?.[0]?.email;
|
const from = email.from?.[0]?.email;
|
||||||
@@ -282,7 +287,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: remoteImagesForMessage(full), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), proxyRemote: imageProxyOn(), 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,
|
||||||
@@ -348,7 +353,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: remoteImagesForMessage(full), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), proxyRemote: imageProxyOn(), 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,
|
||||||
@@ -443,9 +448,13 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
* out, so the recipient's copy is the quote as its sender wrote it.
|
* out, so the recipient's copy is the quote as its sender wrote it.
|
||||||
*/
|
*/
|
||||||
const allowRemote = remoteImagesForMessage(full);
|
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.
|
// 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, proxyRemote: false, dropStyleBlocks: true }).html
|
? sanitizeEmailHtml(origHtml, { cidMap, allowRemote, proxyRemote, 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);
|
||||||
@@ -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
|
// Images blocked when the message was quoted keep their address; the copy
|
||||||
// that leaves carries it, and the recipient's client decides for itself.
|
// 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;
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user