Seven app fixes from public ihasmail #25
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -30,6 +30,13 @@ const MESSAGE = {
|
||||
|
||||
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() {
|
||||
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: ["[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 () => {
|
||||
|
||||
@@ -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 { 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";
|
||||
@@ -177,6 +177,11 @@ function blankDraft(init: Partial<Draft> = {}): 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;
|
||||
@@ -306,7 +311,7 @@ export const useCompose = create<ComposeState>((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, "<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) : ""),
|
||||
format: html ? "html" : settings().composeFormat,
|
||||
attachments,
|
||||
@@ -373,7 +378,7 @@ export const useCompose = create<ComposeState>((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, "<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) : ""),
|
||||
format: html ? "html" : settings().composeFormat,
|
||||
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.
|
||||
*/
|
||||
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, "<br>");
|
||||
const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", "));
|
||||
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
|
||||
// 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.
|
||||
|
||||
Reference in new issue
Block a user