Seven app fixes from public ihasmail #25

Merged
jcoffey-dev merged 7 commits from merge/public-app-fixes into main 2026-09-27 23:32:22 +00:00
5 changed files with 161 additions and 9 deletions
Showing only changes of commit 2fffc9043d - Show all commits

No files matched your search

+20
View File
@@ -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.
+18 -1
View File
@@ -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);
});
});
+14 -5
View File
@@ -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";
@@ -177,6 +177,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;
@@ -306,7 +311,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,
@@ -373,7 +378,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,
@@ -469,9 +474,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);
@@ -840,7 +849,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.