Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.
lib/mailbox/ archiveDate, emptyFolder, folderMove, labelTree,
mailboxName, mailboxRoute
lib/sieve/ sieve, sieveApply, sieveFolders
lib/input/ keyboard, swipe, touch, listSelection, dropUpload
lib/notify/ notify, webpush, webpushEnable
lib/sw/ swCache, swFacts, staleBuild
lib/text/ html, markdown, text, emlName
FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:
- appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
*Files*, where the client keeps signature images and synced settings.
It stays flat.
- format holds no formatting of text. It re-exports the date and clock
formatters, so it belongs with dates/datetime, not with text/.
- preview is the file viewer deciding what it can show without
downloading, and source is where to point someone asking for this
instance's AGPL source. Neither is about text.
- notify is not Web Push. It is the tab title, the favicon badge and
the new-mail sound -- in-app notification, which is why it sits with
webpush rather than under sw/ with the service worker's own concerns.
threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.
No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
This commit is contained in:
1 parent
5cc31037c1
commit
bd6a605d61
95 files changed
+104
-104
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emlFilename, sanitizeFilename } from "@/lib/text/emlName";
|
||||
|
||||
describe("emlFilename", () => {
|
||||
it("keeps an ordinary subject, with spaces as underscores", () => {
|
||||
expect(emlFilename("Quarterly report")).toBe("Quarterly_report.eml");
|
||||
});
|
||||
|
||||
it("keeps letters from any script, which the ASCII rule threw away", () => {
|
||||
// The whole point: none of these may come out as a row of underscores.
|
||||
expect(emlFilename("Квартальный отчёт")).toBe("Квартальный_отчёт.eml");
|
||||
expect(emlFilename("四半期報告")).toBe("四半期報告.eml");
|
||||
expect(emlFilename("Rapport trimestriel été")).toBe("Rapport_trimestriel_été.eml");
|
||||
});
|
||||
|
||||
it("keeps the punctuation that is fine in a filename", () => {
|
||||
expect(emlFilename("Re- budget (v3) [final]")).toBe("Re-_budget_(v3)_[final].eml");
|
||||
});
|
||||
|
||||
it("drops path separators and the characters Windows reserves", () => {
|
||||
expect(emlFilename("a/b\\c:d*e?f\"g<h>i|j")).toBe("abcdefghij.eml");
|
||||
});
|
||||
|
||||
it("drops control characters", () => {
|
||||
expect(emlFilename("a\u0007b\u0000c")).toBe("abc.eml");
|
||||
expect(emlFilename("a\u007fb")).toBe("ab.eml");
|
||||
});
|
||||
|
||||
it("falls back when there is no subject, or nothing survives", () => {
|
||||
expect(emlFilename("")).toBe("message.eml");
|
||||
expect(emlFilename(null)).toBe("message.eml");
|
||||
expect(emlFilename(undefined)).toBe("message.eml");
|
||||
expect(emlFilename("///")).toBe("message.eml");
|
||||
expect(emlFilename(" ")).toBe("message.eml");
|
||||
});
|
||||
|
||||
it("does not end in a dot or a space, which Windows refuses", () => {
|
||||
expect(emlFilename("Report.")).toBe("Report.eml");
|
||||
expect(emlFilename("Report ")).toBe("Report.eml");
|
||||
expect(emlFilename("...Report...")).toBe("Report.eml");
|
||||
});
|
||||
|
||||
it("does not start with a dot, which would hide the file on Unix", () => {
|
||||
expect(emlFilename(".hidden")).toBe("hidden.eml");
|
||||
});
|
||||
|
||||
it("caps the length so it survives a filesystem limit", () => {
|
||||
const name = emlFilename("x".repeat(500));
|
||||
expect(name).toBe(`${"x".repeat(80)}.eml`);
|
||||
});
|
||||
|
||||
it("exposes the stem on its own", () => {
|
||||
expect(sanitizeFilename("Quarterly report")).toBe("Quarterly_report");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMAIL_BASE_CSS, LIGHT_SURFACE_LUMINANCE, htmlDeclaresColors, markKeptSurfaces, relativeLuminance, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
|
||||
|
||||
describe("sanitizeEmailHtml", () => {
|
||||
it("removes scripts and event handlers", () => {
|
||||
const r = sanitizeEmailHtml('<div onclick="x()">hi<script>alert(1)</script><iframe src="https://evil"></iframe></div>');
|
||||
expect(r.html).not.toContain("script");
|
||||
expect(r.html).not.toContain("onclick");
|
||||
expect(r.html).not.toContain("iframe");
|
||||
});
|
||||
it("blocks remote images until allowed and maps cid", () => {
|
||||
const src = '<img src="https://t.example/p.gif"><img src="cid:logo@x"><div style="background:url(https://t.example/b.png)">x</div>';
|
||||
const blocked = sanitizeEmailHtml(src, { cidMap: { "logo@x": "/api/blob/a/b/logo.png" } });
|
||||
expect(blocked.remoteCount).toBe(2);
|
||||
expect(blocked.html).toContain('data-ihm-blocked="1"');
|
||||
expect(blocked.html).toContain("/api/blob/a/b/logo.png");
|
||||
expect(blocked.html).not.toMatch(/src="https:\/\/t\.example/);
|
||||
expect(blocked.html).not.toContain("url(https://t.example");
|
||||
const allowed = sanitizeEmailHtml(src, { allowRemote: true, proxyRemote: true });
|
||||
expect(allowed.html).toContain("/api/image?url=https%3A%2F%2Ft.example%2Fp.gif");
|
||||
});
|
||||
it("forces links to open in new tabs", () => {
|
||||
const r = sanitizeEmailHtml('<a href="https://x.io">x</a>');
|
||||
expect(r.html).toContain('target="_blank"');
|
||||
expect(r.html).toContain("noopener");
|
||||
});
|
||||
it("strips javascript: urls", () => {
|
||||
const r = sanitizeEmailHtml('<a href="javascript:alert(1)">x</a>');
|
||||
expect(r.html).not.toContain("javascript:");
|
||||
});
|
||||
it("editor sanitizer keeps basic formatting", () => {
|
||||
expect(sanitizeEditorHtml("<b>x</b><script>1</script>")).toBe("<b>x</b>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("htmlDeclaresColors", () => {
|
||||
it("is false for mail that brings no colors", () => {
|
||||
expect(htmlDeclaresColors("<p>Hi there</p>")).toBe(false);
|
||||
expect(htmlDeclaresColors("<div><b>bold</b> and <i>italic</i></div>", "font-family:Arial")).toBe(false);
|
||||
expect(htmlDeclaresColors('<a href="https://x.io/?color=red">link</a>')).toBe(false);
|
||||
expect(htmlDeclaresColors('<div style="border-color: red">x</div>')).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when the message paints itself", () => {
|
||||
expect(htmlDeclaresColors('<td bgcolor="#ffffff">x</td>')).toBe(true);
|
||||
expect(htmlDeclaresColors('<font color="red">x</font>')).toBe(true);
|
||||
expect(htmlDeclaresColors('<div style="color:#333">x</div>')).toBe(true);
|
||||
expect(htmlDeclaresColors('<div style="background-color:#fff">x</div>')).toBe(true);
|
||||
expect(htmlDeclaresColors("<style>p { color: red }</style><p>x</p>")).toBe(true);
|
||||
expect(htmlDeclaresColors("<p>plain</p>", "background:#eee")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Forcing the theme onto mail that styles itself — issue #290.
|
||||
*
|
||||
* The switch above it leaves nearly all HTML mail alone, because one color
|
||||
* anywhere opts a message out. What this half has to get right is telling a
|
||||
* sheet the design sits on from a surface painted on top of it: neutralize the
|
||||
* first and the white card goes away, keep the second and a button keeps a
|
||||
* label you can still read.
|
||||
*/
|
||||
describe("relativeLuminance", () => {
|
||||
it("reads the forms mail actually uses", () => {
|
||||
expect(relativeLuminance("#ffffff")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("#FFF")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("#000000")).toBeCloseTo(0, 5);
|
||||
expect(relativeLuminance("white")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("rgb(255, 255, 255)")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("rgba(255,255,255,0.5)")).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("has nothing to say about a color it cannot read", () => {
|
||||
// Not a failure: the caller treats null as "no deliberate surface", which
|
||||
// is the safe way round — an unreadable color must not keep a white sheet.
|
||||
expect(relativeLuminance("color-mix(in srgb, red, blue)")).toBeNull();
|
||||
expect(relativeLuminance("var(--brand)")).toBeNull();
|
||||
expect(relativeLuminance("")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a fully transparent color as painting nothing", () => {
|
||||
expect(relativeLuminance("rgba(0,0,0,0)")).toBeNull();
|
||||
expect(relativeLuminance("transparent")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts a white wrapper above the threshold and a call to action below it", () => {
|
||||
expect(relativeLuminance("#ffffff")!).toBeGreaterThanOrEqual(LIGHT_SURFACE_LUMINANCE);
|
||||
expect(relativeLuminance("#1155CC")!).toBeLessThan(LIGHT_SURFACE_LUMINANCE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markKeptSurfaces", () => {
|
||||
const frag = (html: string) => {
|
||||
const d = document.createElement("div");
|
||||
d.innerHTML = html;
|
||||
return d;
|
||||
};
|
||||
|
||||
/*
|
||||
* Marking is only half of it — the other half is the rule in EMAIL_BASE_CSS
|
||||
* that reads the marks, and #310 was a bug in that half rather than in the
|
||||
* marking. So these assert what the reader actually sees: does the
|
||||
* neutralizer hit this element? The selector is lifted out of the stylesheet
|
||||
* rather than copied, so a test cannot quietly drift from the rule it checks.
|
||||
*/
|
||||
const NEUTRALIZER = (() => {
|
||||
const m = EMAIL_BASE_CSS.match(
|
||||
/\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/,
|
||||
);
|
||||
if (!m) throw new Error("could not find the neutralizer rule in EMAIL_BASE_CSS");
|
||||
return m[1]!.trim();
|
||||
})();
|
||||
|
||||
/** True when the theme is forced onto this element rather than leaving it alone. */
|
||||
const neutralized = (el: Element) => el.matches(NEUTRALIZER);
|
||||
|
||||
it("keeps a colored button and drops the white sheet around it", () => {
|
||||
// The shape reported in #290: a Shopify/Klaviyo template whose outer 600px
|
||||
// wrapper carries bgcolor="#ffffff" and whose CTA carries bgcolor="#1155CC".
|
||||
const d = frag('<table bgcolor="#ffffff"><tr><td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td></tr></table>');
|
||||
expect(markKeptSurfaces(d)).toBe(1);
|
||||
expect(d.querySelector("table")!.hasAttribute("data-ihm-keep")).toBe(false);
|
||||
expect(d.querySelector("td")!.hasAttribute("data-ihm-keep")).toBe(true);
|
||||
// The label is not a painted surface itself. It is marked as sitting on
|
||||
// one, which is what stops white-on-blue turning unreadable.
|
||||
expect(d.querySelector("a")!.hasAttribute("data-ihm-keep")).toBe(false);
|
||||
expect(d.querySelector("a")!.hasAttribute("data-ihm-in-keep")).toBe(true);
|
||||
});
|
||||
|
||||
it("neutralizes a light panel nested inside a dark painted card", () => {
|
||||
// The shape reported in #310: a dark Klaviyo campaign whose 600px cards
|
||||
// are dark enough to be marked, with light content tables inside them.
|
||||
// Those tables used to inherit the card's exemption and render as beige
|
||||
// sheets in an otherwise themed message.
|
||||
const d = frag(
|
||||
'<div style="background-color:#e7e5e2">' +
|
||||
'<div style="background-color:#2b2b2b">' +
|
||||
'<table style="background-color:#e7e5e2"><tr><td>copy</td></tr></table>' +
|
||||
'</div>' +
|
||||
'</div>',
|
||||
);
|
||||
expect(markKeptSurfaces(d)).toBe(1);
|
||||
|
||||
const divs = Array.from(d.querySelectorAll("div"));
|
||||
const surround = divs[0]!;
|
||||
const card = divs[1]!;
|
||||
const nested = d.querySelector("table")!;
|
||||
|
||||
// The page surround is a sheet and always was.
|
||||
expect(surround.hasAttribute("data-ihm-keep")).toBe(false);
|
||||
// The card is paint and stays paint.
|
||||
expect(card.hasAttribute("data-ihm-keep")).toBe(true);
|
||||
// The fix, stated the way the reader experiences it: the nested sheet is
|
||||
// themed, and so is the copy inside it. Before #310 both were exempt for
|
||||
// being descendants of the card.
|
||||
expect(neutralized(nested)).toBe(true);
|
||||
expect(neutralized(d.querySelector("td")!)).toBe(true);
|
||||
// The card itself is still left alone, and the page surround still goes.
|
||||
expect(neutralized(card)).toBe(false);
|
||||
expect(neutralized(surround)).toBe(true);
|
||||
});
|
||||
|
||||
it("still keeps a button that sits inside a nested light panel", () => {
|
||||
// Paint resumes below a sheet, however deep it is: the fix must not cost
|
||||
// a call to action its label just because a sheet came between it and the
|
||||
// card it is on.
|
||||
const d = frag(
|
||||
'<div style="background-color:#2b2b2b">' +
|
||||
'<table style="background-color:#ffffff"><tr>' +
|
||||
'<td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td>' +
|
||||
'</tr></table>' +
|
||||
'</div>',
|
||||
);
|
||||
expect(markKeptSurfaces(d)).toBe(2);
|
||||
expect(neutralized(d.querySelector("table")!)).toBe(true);
|
||||
expect(neutralized(d.querySelector("td")!)).toBe(false);
|
||||
// The label keeps its white, which is the thing #294 bought and this must
|
||||
// not spend.
|
||||
expect(neutralized(d.querySelector("a")!)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves no light panel exempt across the whole reported specimen", () => {
|
||||
// #310 as reported: a dark campaign with no bgcolor attributes, 21 light
|
||||
// panels, 14 of them nested inside dark 600px cards. Those fourteen were
|
||||
// the ones rendering as beige sheets.
|
||||
let cards = "";
|
||||
for (let i = 0; i < 7; i++) {
|
||||
cards +=
|
||||
'<div style="background-color:#2b2b2b">' +
|
||||
'<table style="background-color:#e7e5e2"><tr><td>copy</td></tr></table>' +
|
||||
'<table style="background-color:#e7e5e2"><tr><td>more</td></tr></table>' +
|
||||
"</div>";
|
||||
}
|
||||
let loose = "";
|
||||
for (let i = 0; i < 7; i++) {
|
||||
loose += '<table style="background-color:#e7e5e2"><tr><td>loose</td></tr></table>';
|
||||
}
|
||||
const d = frag('<div style="background-color:#e7e5e2">' + cards + loose + "</div>");
|
||||
|
||||
const panels = Array.from(d.querySelectorAll<HTMLElement>("table"));
|
||||
expect(panels.length).toBe(21);
|
||||
|
||||
expect(markKeptSurfaces(d)).toBe(7);
|
||||
expect(panels.filter((p) => !neutralized(p))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads an inline background as well as the attribute", () => {
|
||||
const d = frag('<div style="background-color:#111827">dark</div><div style="background:#f8f8ff">sheet</div>');
|
||||
expect(markKeptSurfaces(d)).toBe(1);
|
||||
expect(d.querySelectorAll("[data-ihm-keep]").length).toBe(1);
|
||||
expect((d.querySelector("[data-ihm-keep]") as HTMLElement).textContent).toBe("dark");
|
||||
});
|
||||
|
||||
it("marks nothing in mail that paints no backgrounds", () => {
|
||||
const d = frag('<p style="color:#333">text</p><a href="https://x.io">link</a>');
|
||||
expect(markKeptSurfaces(d)).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves the sender's own markup alone, so the switch is reversible", () => {
|
||||
const d = frag('<table><tr><td bgcolor="#1155CC" style="color:#fff">Buy</td></tr></table>');
|
||||
markKeptSurfaces(d);
|
||||
const td = d.querySelector("td")!;
|
||||
expect(td.getAttribute("bgcolor")).toBe("#1155CC");
|
||||
expect(td.style.color).toBe("rgb(255, 255, 255)");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A shadow root scopes selectors, not layout. Mail CSS saying `position:fixed`
|
||||
* is still positioned against the viewport, so a sender could paint over the
|
||||
* whole application — a ready-made phishing surface inside our own origin.
|
||||
*
|
||||
* The control that actually stops it is layout containment on an ancestor of
|
||||
* the shadow host, which mail CSS has no selector for; that lives in app.css
|
||||
* and is asserted at the bottom of this file, because jsdom does no layout and
|
||||
* cannot prove it here. These cover the second line of defense.
|
||||
*/
|
||||
describe("mail CSS cannot climb out of its card", () => {
|
||||
const render = (html: string) => sanitizeEmailHtml(html).html;
|
||||
|
||||
it("turns fixed and sticky positioning into static", () => {
|
||||
const out = render(`<div><style>.x{position:fixed;inset:0;z-index:2147483647}</style><p class="x">hi</p></div>`);
|
||||
expect(out).toContain("position:static");
|
||||
expect(out).not.toMatch(/position\s*:\s*fixed/i);
|
||||
});
|
||||
|
||||
it("does so in style attributes too, however they are spaced", () => {
|
||||
expect(render(`<p style="position: FIXED; color:red">x</p>`)).not.toMatch(/position\s*:\s*fixed/i);
|
||||
expect(render(`<p style="position:sticky;top:0">x</p>`)).not.toMatch(/position\s*:\s*sticky/i);
|
||||
});
|
||||
|
||||
it("defangs :host, which is how mail CSS would reach the host element", () => {
|
||||
const out = render(`<div><style>:host{contain:none!important;position:fixed!important}</style><p>x</p></div>`);
|
||||
expect(out).not.toContain(":host");
|
||||
expect(out).not.toMatch(/position\s*:\s*fixed/i);
|
||||
});
|
||||
|
||||
it("leaves ordinary positioning alone", () => {
|
||||
const out = render(`<div><style>.a{position:relative}.b{position:absolute;top:2px}</style><p>x</p></div>`);
|
||||
expect(out).toContain("position:relative");
|
||||
expect(out).toContain("position:absolute");
|
||||
});
|
||||
|
||||
it("still rewrites url() while hardening", () => {
|
||||
const out = sanitizeEmailHtml(`<div><style>.x{position:fixed;background:url(https://tracker.example/p.gif)}</style><p>x</p></div>`, { allowRemote: true, proxyRemote: true }).html;
|
||||
expect(out).toContain("position:static");
|
||||
expect(out).toContain("/api/image?url=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the containment that mail CSS cannot override", () => {
|
||||
it("is still applied to the message body container", async () => {
|
||||
// jsdom does no layout, so this asserts the control is present rather than
|
||||
// that it works; the behavior was verified in a real browser. Without it,
|
||||
// a message can cover the viewport regardless of what the sanitizer does.
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
// vitest serves modules over http, so import.meta.url is not a file URL.
|
||||
const css = await readFile(join(process.cwd(), "src/styles/app.css"), "utf8");
|
||||
const rule = /\.message-body\s*\{[^}]*\}/.exec(css)?.[0] ?? "";
|
||||
expect(rule).toMatch(/contain\s*:\s*layout/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isMarkdown, renderMarkdown } from "@/lib/text/markdown";
|
||||
|
||||
describe("isMarkdown", () => {
|
||||
it("takes the type when there is one", () => {
|
||||
expect(isMarkdown("text/markdown", "a")).toBe(true);
|
||||
expect(isMarkdown("text/x-markdown; charset=utf-8", "a")).toBe(true);
|
||||
expect(isMarkdown("text/plain", "notes.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the name, which is the usual case for an upload", () => {
|
||||
expect(isMarkdown("application/octet-stream", "README.md")).toBe(true);
|
||||
expect(isMarkdown("application/octet-stream", "NOTES.MARKDOWN")).toBe(true);
|
||||
expect(isMarkdown(null, "changelog.mkd")).toBe(true);
|
||||
expect(isMarkdown(null, "readme.txt")).toBe(false);
|
||||
expect(isMarkdown(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderMarkdown", () => {
|
||||
it("renders the ordinary things", () => {
|
||||
const html = renderMarkdown("# Title\n\nSome **bold** and `code`.\n\n- one\n- two\n");
|
||||
expect(html).toContain("<h1");
|
||||
expect(html).toContain("<strong>bold</strong>");
|
||||
expect(html).toContain("<code>code</code>");
|
||||
expect(html).toContain("<li>one</li>");
|
||||
});
|
||||
|
||||
it("renders GitHub tables and fenced code", () => {
|
||||
const html = renderMarkdown("| a | b |\n| - | - |\n| 1 | 2 |\n\n```js\nconst x = 1;\n```\n");
|
||||
expect(html).toContain("<table>");
|
||||
expect(html).toContain("<pre>");
|
||||
});
|
||||
|
||||
/*
|
||||
* Markdown passes raw HTML through by design, and the file came from
|
||||
* somewhere else -- an upload, or a share from another account. Every one of
|
||||
* these renders as a script tag without a sanitizer.
|
||||
*/
|
||||
it("takes out anything that would execute", () => {
|
||||
const html = renderMarkdown("<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n<iframe src='https://evil.example'></iframe>\n");
|
||||
expect(html).not.toContain("<script");
|
||||
expect(html).not.toContain("onerror");
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
it("does not keep a javascript: link", () => {
|
||||
const html = renderMarkdown("[click](javascript:alert(1))");
|
||||
expect(html).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("shows an image as a link instead of fetching it", () => {
|
||||
// A remote image in a file is a tracking pixel by another name; this app
|
||||
// blocks those in mail and does not undo that here.
|
||||
const html = renderMarkdown("");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain('class="md-img"');
|
||||
expect(html).toContain("a diagram");
|
||||
expect(html).toContain("https://tracker.example/px.png");
|
||||
});
|
||||
|
||||
it("keeps a relative image visible even though it cannot resolve", () => {
|
||||
const html = renderMarkdown("");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("local");
|
||||
// Nothing to link to, so it is text rather than a dead link.
|
||||
expect(html).not.toContain('href="./diagram.png"');
|
||||
});
|
||||
|
||||
it("sends links out of the app safely", () => {
|
||||
const html = renderMarkdown("[docs](https://docs.ihasmail.org)");
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
expect(html).toContain('target="_blank"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { htmlToText, quoteText, replySubject, textToHtml } from "../text";
|
||||
|
||||
describe("text helpers", () => {
|
||||
it("linkifies and escapes", () => {
|
||||
const html = textToHtml("see <https://x.io/a?b=1> now");
|
||||
expect(html).toContain("<");
|
||||
expect(html).toContain('<a href="https://x.io/a?b=1"');
|
||||
});
|
||||
it("colors quote levels", () => {
|
||||
expect(textToHtml("> hi\n>> there")).toContain('class="q1"');
|
||||
expect(textToHtml("> hi\n>> there")).toContain('class="q2"');
|
||||
});
|
||||
it("converts html to text", () => {
|
||||
const t = htmlToText("<p>Hello <b>world</b></p><ul><li>one</li><li>two</li></ul><blockquote>q</blockquote><a href='https://a.b'>link</a>");
|
||||
expect(t).toContain("Hello world");
|
||||
expect(t).toContain("- one");
|
||||
expect(t).toContain("> q");
|
||||
expect(t).toContain("link <https://a.b>");
|
||||
});
|
||||
it("quotes and subjects", () => {
|
||||
expect(quoteText("a\n> b")).toBe("> a\n>> b");
|
||||
expect(replySubject("Re: Hi", "Re")).toBe("Re: Hi");
|
||||
expect(replySubject("Fwd: Hi", "Re")).toBe("Re: Hi");
|
||||
expect(replySubject("Hi", "Fwd")).toBe("Fwd: Hi");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* A filename for a message saved or attached as `.eml`.
|
||||
*
|
||||
* The rule this replaces was `subject.replace(/[^\w.-]+/g, "_")`, and `\w`
|
||||
* without the `u` flag is ASCII: every character of a Russian, Japanese or
|
||||
* Chinese subject failed the class, so those messages downloaded as a row of
|
||||
* underscores. ihasmail ships in nine languages besides English, so the
|
||||
* subjects it handled worst were most of the world's.
|
||||
*
|
||||
* What is actually unsafe in a filename is a much shorter list than "not
|
||||
* ASCII": the path separators, the characters Windows reserves, and the
|
||||
* control range. Everything else is a letter to somebody.
|
||||
*
|
||||
* The test is written by code point rather than as a character class because
|
||||
* the escaping in one of those is its own small trap, and this says plainly
|
||||
* what it means.
|
||||
*/
|
||||
|
||||
/** Reserved on Windows, or a path separator. */
|
||||
const RESERVED = '<>:"/\\|?*';
|
||||
|
||||
function unsafe(ch: string): boolean {
|
||||
const c = ch.codePointAt(0) ?? 0;
|
||||
// C0 controls, and DEL.
|
||||
if (c < 0x20 || c === 0x7f) return true;
|
||||
return RESERVED.includes(ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Long enough to stay recognizable, short enough to survive a 255-*byte* limit
|
||||
* once a CJK subject is three bytes a character.
|
||||
*/
|
||||
const MAX = 80;
|
||||
|
||||
/** The stem only, so a caller can put another extension on it. */
|
||||
export function sanitizeFilename(subject: string | null | undefined): string {
|
||||
const kept = [...(subject ?? "")].filter((ch) => !unsafe(ch)).join("");
|
||||
return kept
|
||||
// Whitespace becomes an underscore rather than being kept: it is what the
|
||||
// previous rule did, and it saves a quoting question in a shell later.
|
||||
.replace(/\s+/g, "_")
|
||||
.slice(0, MAX)
|
||||
// Windows refuses a name ending in a dot or a space, and a leading dot
|
||||
// hides the file on Unix. Neither is worth inheriting from a subject.
|
||||
.replace(/^[.\s_]+|[.\s_]+$/g, "");
|
||||
}
|
||||
|
||||
export function emlFilename(subject: string | null | undefined): string {
|
||||
return `${sanitizeFilename(subject) || "message"}.eml`;
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
export interface SanitizeOptions {
|
||||
/** Map of Content-ID (without angle brackets) → URL for inline images. */
|
||||
cidMap?: Record<string, string>;
|
||||
/** Whether remote content (http/https images, css urls) may load. */
|
||||
allowRemote?: boolean;
|
||||
/** Route remote images through the privacy proxy. */
|
||||
proxyRemote?: boolean;
|
||||
}
|
||||
|
||||
export interface SanitizeResult {
|
||||
html: string;
|
||||
remoteCount: number;
|
||||
bodyStyle: string;
|
||||
}
|
||||
|
||||
const REMOTE_URL_RE = /^(https?:)?\/\//i;
|
||||
const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
|
||||
|
||||
let hooked = false;
|
||||
function ensureHooks() {
|
||||
if (hooked) return;
|
||||
hooked = true;
|
||||
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
|
||||
// Strip <style> in dark-mode-unfriendly cases? No - keep styles, we scope them in a shadow root.
|
||||
if (data.tagName === "style" && node.textContent) {
|
||||
// Remove @import and remote url() references; they're handled later in processRemote().
|
||||
node.textContent = node.textContent.replace(/@import[^;]+;?/gi, "");
|
||||
}
|
||||
});
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName === "A") {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer nofollow");
|
||||
}
|
||||
// Forms are forbidden but be safe about formaction-like attributes on anything.
|
||||
for (const attr of ["formaction", "action", "ping", "xlink:href"]) {
|
||||
if (node.hasAttribute(attr)) node.removeAttribute(attr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Blunt the positioning tricks mail CSS can use to escape its card.
|
||||
*
|
||||
* A shadow root scopes selectors but not layout, so `position:fixed` in a
|
||||
* message is still positioned against the viewport — enough to paint a
|
||||
* convincing fake over the whole app. The control that actually stops that is
|
||||
* layout containment on an ancestor of the shadow host (see `.message-body` in
|
||||
* app.css), which mail CSS has no selector for. This is the second line:
|
||||
* neutralize the declarations themselves, and defang `:host`, which is how mail
|
||||
* CSS would otherwise reach the host element.
|
||||
*/
|
||||
function hardenCss(css: string): string {
|
||||
return css
|
||||
// `:host` / `:host-context` become a selector that matches nothing; where
|
||||
// they took an argument the rule is left invalid, and so dropped.
|
||||
.replace(/:host(-context)?/gi, ":not(*)")
|
||||
.replace(/position\s*:\s*(fixed|sticky)/gi, "position:static");
|
||||
}
|
||||
|
||||
export function proxiedImageUrl(url: string): string {
|
||||
return withBase(`/api/image?url=${encodeURIComponent(url)}`);
|
||||
}
|
||||
|
||||
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
|
||||
ensureHooks();
|
||||
let bodyStyle = "";
|
||||
const bodyMatch = /<body([^>]*)>/i.exec(input);
|
||||
if (bodyMatch) {
|
||||
const attrs = bodyMatch[1]!;
|
||||
const bg = /bgcolor\s*=\s*["']?([#\w()%,.\s-]+)["']?/i.exec(attrs)?.[1];
|
||||
const style = /style\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? /style\s*=\s*'([^']*)'/i.exec(attrs)?.[1];
|
||||
if (bg) bodyStyle += `background-color:${bg.trim()};`;
|
||||
if (style) bodyStyle += style;
|
||||
}
|
||||
|
||||
const clean = DOMPurify.sanitize(input, {
|
||||
WHOLE_DOCUMENT: false,
|
||||
RETURN_DOM: true,
|
||||
FORBID_TAGS: ["script", "iframe", "frame", "frameset", "object", "embed", "applet", "form", "input", "button", "textarea", "select", "option", "meta", "link", "base", "svg", "math", "video", "audio", "source", "track", "canvas", "template", "slot", "dialog", "noscript"],
|
||||
FORBID_ATTR: ["srcdoc", "formaction", "action", "ping", "autofocus", "autoplay", "contenteditable", "draggable", "tabindex"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_ARIA_ATTR: false,
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ["style", "center", "font", "marquee"],
|
||||
ADD_ATTR: ["bgcolor", "background", "valign", "align", "border", "cellpadding", "cellspacing", "width", "height", "color", "face", "size", "target"],
|
||||
}) as unknown as HTMLElement;
|
||||
|
||||
let remoteCount = 0;
|
||||
const cidMap = opts.cidMap ?? {};
|
||||
const allow = Boolean(opts.allowRemote);
|
||||
const proxy = Boolean(opts.proxyRemote);
|
||||
|
||||
const remote = (url: string): string => {
|
||||
remoteCount++;
|
||||
if (!allow) return "";
|
||||
return proxy ? proxiedImageUrl(url) : url;
|
||||
};
|
||||
|
||||
const rewriteUrl = (raw: string): { url: string; keep: boolean } => {
|
||||
const url = raw.trim();
|
||||
if (/^cid:/i.test(url)) {
|
||||
const cid = url.slice(4).replace(/^<|>$/g, "");
|
||||
const mapped = cidMap[cid] ?? cidMap[cid.toLowerCase()];
|
||||
return mapped ? { url: mapped, keep: true } : { url: "", keep: false };
|
||||
}
|
||||
if (/^data:image\//i.test(url)) return { url, keep: true };
|
||||
if (REMOTE_URL_RE.test(url)) {
|
||||
const abs = url.startsWith("//") ? `https:${url}` : url;
|
||||
const u = remote(abs);
|
||||
return { url: u, keep: Boolean(u) };
|
||||
}
|
||||
// Relative or unknown scheme -> drop.
|
||||
return { url: "", keep: false };
|
||||
};
|
||||
|
||||
// Image-bearing attributes
|
||||
const els = clean.querySelectorAll<HTMLElement>("[src],[background],[poster],[srcset]");
|
||||
els.forEach((el) => {
|
||||
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
|
||||
for (const attr of ["src", "background", "poster"]) {
|
||||
const v = el.getAttribute(attr);
|
||||
if (v == null) continue;
|
||||
const r = rewriteUrl(v);
|
||||
if (r.keep) el.setAttribute(attr, r.url);
|
||||
else {
|
||||
el.removeAttribute(attr);
|
||||
if (attr === "src" && el.tagName === "IMG") {
|
||||
el.setAttribute("data-ihm-blocked", "1");
|
||||
if (REMOTE_URL_RE.test(v)) el.setAttribute("data-ihm-remote", v.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// CSS url() in style attributes and <style> blocks
|
||||
const rewriteCss = (css: string): string =>
|
||||
css.replace(CSS_URL_RE, (_m, q: string, u: string) => {
|
||||
const r = rewriteUrl(u);
|
||||
return r.keep ? `url(${q}${r.url}${q})` : "none";
|
||||
});
|
||||
clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
|
||||
const s = el.getAttribute("style");
|
||||
if (!s) return;
|
||||
const out = hardenCss(/url\(/i.test(s) ? rewriteCss(s) : s);
|
||||
if (out !== s) el.setAttribute("style", out);
|
||||
});
|
||||
clean.querySelectorAll("style").forEach((st) => {
|
||||
const css = st.textContent ?? "";
|
||||
if (!css) return;
|
||||
st.textContent = hardenCss(rewriteCss(css.replace(/@import[^;]+;?/gi, "")));
|
||||
});
|
||||
if (bodyStyle && /url\(/i.test(bodyStyle)) bodyStyle = rewriteCss(bodyStyle);
|
||||
|
||||
return { html: clean.innerHTML, remoteCount, bodyStyle };
|
||||
}
|
||||
|
||||
/** Minimal sanitizer for signatures / composer HTML (no remote blocking, keeps images). */
|
||||
export function sanitizeEditorHtml(input: string): string {
|
||||
ensureHooks();
|
||||
return DOMPurify.sanitize(input, {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "style", "meta", "link", "base", "svg", "math"],
|
||||
FORBID_ATTR: ["srcdoc", "formaction", "ping", "onerror", "onload"],
|
||||
ADD_ATTR: ["target", "bgcolor", "align", "valign", "border", "cellpadding", "cellspacing", "width", "height", "color", "face", "size"],
|
||||
}) as string;
|
||||
}
|
||||
|
||||
/** Base CSS injected into the shadow root that hosts HTML email. */
|
||||
export const EMAIL_BASE_CSS = `
|
||||
:host { display:block; color-scheme: light; }
|
||||
:host(.themed) { color-scheme: inherit; }
|
||||
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; }
|
||||
.ihm-email-root img { max-width:100%; height:auto; }
|
||||
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
|
||||
.ihm-email-root table { max-width:100%; }
|
||||
.ihm-email-root pre { white-space:pre-wrap; }
|
||||
.ihm-email-root blockquote { margin:0 0 0 .8ex; border-left:2px solid #cbd5e1; padding-left:1ex; color:#475569; }
|
||||
.ihm-email-root a { color:#0f766e; }
|
||||
.ihm-email-root * { max-width:100%; box-sizing:border-box; }
|
||||
.ihm-email-root [style*="position:fixed"], .ihm-email-root [style*="position: fixed"] { position:static !important; }
|
||||
|
||||
/* "Follow the app theme" — only applied to mail that brings no colors of its
|
||||
own. The custom properties are inherited from the host document, so a theme
|
||||
switch repaints the message without re-rendering it. */
|
||||
.ihm-email-root.themed { color: var(--fg, #1f2937); background: var(--bg-elev, #fff); }
|
||||
.ihm-email-root.themed blockquote { border-left-color: var(--border-strong, #cbd5e1); color: var(--fg-muted, #475569); }
|
||||
.ihm-email-root.themed a { color: var(--link, #0f766e); }
|
||||
.ihm-email-root.themed hr { border-color: var(--border, #e3e7ec); }
|
||||
.ihm-email-root.themed img[data-ihm-blocked] { background: var(--bg-sunken, #f1f5f9) repeating-linear-gradient(45deg, var(--bg-hover, #e2e8f0) 0 6px, transparent 6px 12px); border-color: var(--border-strong, #cbd5e1); }
|
||||
|
||||
/* "Even mail that styles itself" — the second, opt-in switch, applied on top of
|
||||
.themed. Everything the sender colored is neutralized except the surfaces
|
||||
marked by markKeptSurfaces() and what it marked as sitting on them, so a
|
||||
white wrapper table
|
||||
stops being a bright card while a blue button keeps its white label. The
|
||||
sender's markup is untouched; this is all cascade, so the switch is
|
||||
reversible and print still pins the tokens to ink on white. */
|
||||
.ihm-email-root.forced { color: var(--fg, #1f2937) !important; background: var(--bg-elev, #fff) !important; }
|
||||
.ihm-email-root.forced *:not([data-ihm-keep]):not([data-ihm-in-keep]) { color: inherit !important; background-color: transparent !important; }
|
||||
.ihm-email-root.forced a:not([data-ihm-keep]):not([data-ihm-in-keep]) { color: var(--link, #0f766e) !important; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* Does this message paint itself? Mail that sets a background or text color
|
||||
* has a design of its own, and forcing a dark palette on half of it is worse
|
||||
* than leaving it alone — so those keep the light card they were built for.
|
||||
*
|
||||
* The bar is deliberately low, and that is the point of the second switch
|
||||
* (`themeStyledMessages`): in real mail this is true of very nearly everything.
|
||||
* One `color:#FFFFFF` on one button label is enough, so a template that is
|
||||
* plain in every way a reader would notice still counts as painting itself.
|
||||
* See `markKeptSurfaces` for what the opt-in does about it.
|
||||
*/
|
||||
export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
|
||||
const haystack = `${bodyStyle} ${html}`;
|
||||
return (
|
||||
/\bbgcolor\s*=/i.test(haystack) ||
|
||||
/<font[^>]*\bcolor\s*=/i.test(haystack) ||
|
||||
/(?:^|[;"'\s{])(?:background(?:-color)?|color)\s*:/i.test(haystack)
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- forcing the theme onto mail that styles itself ---------- */
|
||||
|
||||
/**
|
||||
* Relative luminance per WCAG 2.x, or `null` when the color cannot be read.
|
||||
*
|
||||
* Only what actually turns up in mail is parsed: hex in three, six or eight
|
||||
* digits, `rgb()`/`rgba()`, and the handful of names senders still write out.
|
||||
* Anything else is `null`, which the caller treats as "not a deliberate
|
||||
* surface" — the safe way round, because the failure it avoids is a white
|
||||
* sheet surviving the switch the reader just turned on.
|
||||
*/
|
||||
const NAMED: Record<string, string> = {
|
||||
white: "#ffffff", ivory: "#fffff0", snow: "#fffafa", whitesmoke: "#f5f5f5",
|
||||
ghostwhite: "#f8f8ff", floralwhite: "#fffaf0", seashell: "#fff5ee", beige: "#f5f5dc",
|
||||
linen: "#faf0e6", lightgray: "#d3d3d3", lightgrey: "#d3d3d3", gainsboro: "#dcdcdc",
|
||||
silver: "#c0c0c0", gray: "#808080", grey: "#808080", black: "#000000",
|
||||
navy: "#000080", darkblue: "#00008b", maroon: "#800000", teal: "#008080",
|
||||
};
|
||||
|
||||
export function relativeLuminance(color: string): number | null {
|
||||
const raw = color.trim().toLowerCase();
|
||||
if (!raw || raw === "transparent" || raw === "inherit" || raw === "initial" || raw === "none") return null;
|
||||
let r: number, g: number, b: number, a = 1;
|
||||
const named = NAMED[raw];
|
||||
const hex = (named ?? raw).match(/^#([0-9a-f]{3,8})$/);
|
||||
if (hex) {
|
||||
const h = hex[1]!;
|
||||
if (h.length === 3) [r, g, b] = [h[0]! + h[0]!, h[1]! + h[1]!, h[2]! + h[2]!].map((x) => parseInt(x, 16)) as [number, number, number];
|
||||
else if (h.length === 6 || h.length === 8) {
|
||||
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
|
||||
if (h.length === 8) a = parseInt(h.slice(6, 8), 16) / 255;
|
||||
} else return null;
|
||||
} else {
|
||||
const m = raw.match(/^rgba?\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)(?:[\s,/]+([0-9.%]+))?\s*\)$/);
|
||||
if (!m) return null;
|
||||
r = Number(m[1]); g = Number(m[2]); b = Number(m[3]);
|
||||
if (m[4] !== undefined) a = m[4].endsWith("%") ? Number(m[4].slice(0, -1)) / 100 : Number(m[4]);
|
||||
}
|
||||
if ([r, g, b, a].some((n) => !Number.isFinite(n))) return null;
|
||||
// A fully transparent color paints nothing, whatever its channels say.
|
||||
if (a === 0) return null;
|
||||
const lin = (c: number) => { const x = c / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Above this, a background is a sheet the message is laid on rather than a
|
||||
* thing drawn on top of it. White wrappers sit at 1.0; the blue of a call to
|
||||
* action lands near 0.09, mid-gray near 0.22.
|
||||
*/
|
||||
export const LIGHT_SURFACE_LUMINANCE = 0.5;
|
||||
|
||||
/** The background an element declares itself, or null if it declares none we can read. */
|
||||
function declaredLuminance(el: HTMLElement): number | null {
|
||||
const declared = el.getAttribute("bgcolor") ?? el.style?.backgroundColor ?? "";
|
||||
if (!declared) return null;
|
||||
return relativeLuminance(declared);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the surfaces that must survive being themed, and count them.
|
||||
*
|
||||
* The reader has asked for their palette on mail that brings its own, which
|
||||
* cannot be done perfectly — this is the same bargain a dark-reader extension
|
||||
* makes. What it can do is tell the two kinds of color apart: a **sheet** the
|
||||
* design sits on, which is what reads as a bright card and is neutralized, and
|
||||
* a **painted surface** — a button, a banner — which is kept whole so its
|
||||
* label stays legible on it.
|
||||
*
|
||||
* Two attributes come out of this. `data-ihm-keep` is a painted surface, which
|
||||
* keeps its own colors. `data-ihm-in-keep` is an element sitting on one with
|
||||
* no background of its own, whose color is left alone so a white label on a
|
||||
* blue button stays readable. One rule in EMAIL_BASE_CSS neutralizes
|
||||
* everything else.
|
||||
*
|
||||
* The distinction that matters is that being *inside* a painted surface is not
|
||||
* inherited past a sheet. A light table nested in a dark 600px card is still a
|
||||
* sheet and is still neutralized — that is issue #310, where a dark campaign
|
||||
* rendered with beige cards inside it because the exemption used to be
|
||||
* `[data-ihm-keep] *` in CSS and could not see the difference. Paint resumes
|
||||
* below it: a dark button inside that nested table is kept as usual.
|
||||
*
|
||||
* Nothing the sender wrote is removed, so turning the switch off puts the
|
||||
* message back exactly as it was — and a color that arrived from a `<style>`
|
||||
* block rather than an attribute is covered too, which is most of them in
|
||||
* modern templates.
|
||||
*/
|
||||
export function markKeptSurfaces(root: ParentNode): number {
|
||||
let kept = 0;
|
||||
|
||||
// An explicit stack rather than recursion: this walks untrusted mail, and
|
||||
// deeply nested tables are exactly what old newsletter HTML is made of.
|
||||
const stack: Array<{ el: HTMLElement; onPaint: boolean }> = [];
|
||||
const push = (parent: ParentNode, onPaint: boolean) => {
|
||||
for (const child of Array.from(parent.children)) {
|
||||
stack.push({ el: child as HTMLElement, onPaint });
|
||||
}
|
||||
};
|
||||
|
||||
push(root, false);
|
||||
|
||||
while (stack.length) {
|
||||
const { el, onPaint } = stack.pop()!;
|
||||
const lum = declaredLuminance(el);
|
||||
let childrenOnPaint = onPaint;
|
||||
|
||||
if (lum !== null && lum < LIGHT_SURFACE_LUMINANCE) {
|
||||
// Painted: keep it whole, and anything on it inherits that protection.
|
||||
el.setAttribute("data-ihm-keep", "");
|
||||
kept++;
|
||||
childrenOnPaint = true;
|
||||
} else if (lum !== null) {
|
||||
// A sheet, wherever it sits. Left unmarked so it neutralizes, and it
|
||||
// ends the protection rather than passing it on.
|
||||
childrenOnPaint = false;
|
||||
} else if (onPaint) {
|
||||
// No background of its own, sitting on paint: leave its color alone.
|
||||
el.setAttribute("data-ihm-in-keep", "");
|
||||
}
|
||||
|
||||
push(el, childrenOnPaint);
|
||||
}
|
||||
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a message really has an HTML alternative to render.
|
||||
*
|
||||
* `htmlBody` is a *derived* list, not a filter: RFC 8621 §4.1.4 says a message
|
||||
* with no HTML alternative still gets one, and it holds the text/plain part.
|
||||
* Confirmed live against Stalwart 0.16.21 (2026-09-10) -- a plain-text mail
|
||||
* comes back with `htmlBody` and `textBody` naming the same part, typed
|
||||
* `text/plain`, while a real multipart/alternative names two different parts.
|
||||
*
|
||||
* So "is there a body value under htmlBody" is not the question; the part's own
|
||||
* type is. Answering the first one sent every plain-text message down the HTML
|
||||
* path, where the body is placed in `.ihm-email-root` under
|
||||
* `white-space: normal` and every line break collapses -- hard-wrapped mail
|
||||
* arrived as a single paragraph with the signature and the quoted reply run
|
||||
* into the prose.
|
||||
*/
|
||||
export function hasHtmlAlternative(part: { type?: string } | undefined, value: string | undefined): boolean {
|
||||
return /^text\/html\b/i.test(part?.type ?? "") && Boolean(value);
|
||||
}
|
||||
|
||||
export const TEXT_EMAIL_CSS = `
|
||||
:host { display:block; }
|
||||
.ihm-text-root { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.5px; line-height:1.55; white-space: pre-wrap; overflow-wrap: anywhere; color: inherit; }
|
||||
.ihm-text-root a { color: var(--link, #0f766e); }
|
||||
.ihm-text-root .q1 { color: var(--q1,#2563eb); } .ihm-text-root .q2 { color: var(--q2,#16a34a); } .ihm-text-root .q3 { color: var(--q3,#9333ea); }
|
||||
`;
|
||||
@@ -0,0 +1,72 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
/**
|
||||
* Markdown, rendered for the file viewer.
|
||||
*
|
||||
* The source is somebody else's file -- uploaded, or shared into the account
|
||||
* by another user -- so it is treated as hostile. Markdown is not a safe
|
||||
* subset of anything: raw HTML passes straight through it by design, so
|
||||
* `<script>` in a .md is a script tag unless something takes it out. That
|
||||
* something is DOMPurify, which the app already carries for mail.
|
||||
*
|
||||
* Rendered inline rather than in a shadow root the way mail bodies are: this
|
||||
* output is ours, sanitized and styled by `.md-body`, where an email arrives
|
||||
* with a design of its own that has to be quarantined from the app's.
|
||||
*/
|
||||
|
||||
marked.use({ gfm: true, breaks: false });
|
||||
|
||||
export function isMarkdown(type: string | null | undefined, name: string | null | undefined): boolean {
|
||||
const t = (type ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (t === "text/markdown" || t === "text/x-markdown") return true;
|
||||
// A .md upload usually arrives as application/octet-stream, so the name is
|
||||
// the only evidence -- the same reason previewKind falls back to it.
|
||||
return /\.(md|markdown|mdown|mkd)$/i.test(name ?? "");
|
||||
}
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
const html = marked.parse(source, { async: false });
|
||||
const clean = DOMPurify.sanitize(html, {
|
||||
WHOLE_DOCUMENT: false,
|
||||
RETURN_DOM: true,
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ["script", "iframe", "frame", "frameset", "object", "embed", "applet", "form", "input", "button", "textarea", "select", "meta", "link", "base", "svg", "math", "video", "audio", "source", "track", "canvas", "template", "noscript", "style"],
|
||||
FORBID_ATTR: ["srcdoc", "formaction", "action", "ping", "autofocus", "style"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ADD_ATTR: ["target", "rel"],
|
||||
}) as unknown as HTMLElement;
|
||||
|
||||
/*
|
||||
* Pictures become links rather than pictures.
|
||||
*
|
||||
* An image in a Markdown file is either a relative path, which has no base
|
||||
* to resolve against here and would render broken, or a URL somewhere else,
|
||||
* which fetches on open and tells that server the file was read -- the same
|
||||
* tracking pixel this app blocks in mail. Neither is worth rendering. A link
|
||||
* keeps the alt text and the address visible, so nothing vanishes silently
|
||||
* and the reader chooses whether to fetch it.
|
||||
*/
|
||||
for (const img of [...clean.querySelectorAll("img")]) {
|
||||
const href = img.getAttribute("src") ?? "";
|
||||
const label = img.getAttribute("alt") || href || "image";
|
||||
const a = clean.ownerDocument.createElement("a");
|
||||
a.className = "md-img";
|
||||
a.textContent = label;
|
||||
if (/^https?:/i.test(href)) {
|
||||
a.setAttribute("href", href);
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
a.setAttribute("title", href);
|
||||
}
|
||||
img.replaceWith(a);
|
||||
}
|
||||
|
||||
// Links leave the app, so they leave it safely.
|
||||
for (const a of clean.querySelectorAll("a[href]")) {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
|
||||
return clean.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const URL_RE = /\b((?:https?:\/\/|www\.)[^\s<>"'()]+[^\s<>"'().,;:!?])/gi;
|
||||
const EMAIL_RE = /\b([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})\b/gi;
|
||||
|
||||
/** Convert plain text into safe HTML with links and quote-level coloring. */
|
||||
export function textToHtml(text: string, opts: { linkify?: boolean; quoteColors?: boolean } = {}): string {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const out: string[] = [];
|
||||
for (const line of lines) {
|
||||
let depth = 0;
|
||||
let rest = line;
|
||||
if (opts.quoteColors !== false) {
|
||||
const m = /^((?:>\s?)+)/.exec(line);
|
||||
if (m) {
|
||||
depth = (m[1]!.match(/>/g) ?? []).length;
|
||||
rest = line.slice(m[1]!.length);
|
||||
// keep markers visually
|
||||
}
|
||||
}
|
||||
const html = opts.linkify === false ? escapeHtml(rest) : linkify(rest);
|
||||
if (depth > 0) {
|
||||
const marker = escapeHtml(line.slice(0, line.length - rest.length));
|
||||
out.push(`<span class="q${Math.min(depth, 3)}">${marker}${html}</span>`);
|
||||
} else out.push(html);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Escape text while turning URLs / email addresses into links (tokenized so escaping never corrupts hrefs). */
|
||||
function linkify(text: string): string {
|
||||
const re = new RegExp(`${URL_RE.source}|${EMAIL_RE.source}`, "gi");
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text))) {
|
||||
out += escapeHtml(text.slice(last, m.index));
|
||||
const tok = m[0];
|
||||
if (tok.includes("@") && !/^(https?:\/\/|www\.)/i.test(tok)) {
|
||||
out += `<a href="mailto:${escapeHtml(tok)}">${escapeHtml(tok)}</a>`;
|
||||
} else {
|
||||
const href = tok.startsWith("www.") ? `http://${tok}` : tok;
|
||||
out += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer nofollow">${escapeHtml(tok)}</a>`;
|
||||
}
|
||||
last = m.index + tok.length;
|
||||
}
|
||||
out += escapeHtml(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convert HTML to reasonably formatted plain text (for text/plain alternative + quoting). */
|
||||
export function htmlToText(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
doc.querySelectorAll("script,style,head,title,noscript").forEach((n) => n.remove());
|
||||
const out: string[] = [];
|
||||
const walk = (node: Node, ctx: { pre: boolean; listIndex: number[]; quote: number }) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const t = node.textContent ?? "";
|
||||
out.push(ctx.pre ? t : t.replace(/\s+/g, " "));
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const block = /^(p|div|section|article|header|footer|h[1-6]|ul|ol|li|table|tr|blockquote|pre|hr|br|address|center|dl|dt|dd|form|fieldset|figure|figcaption)$/.test(tag);
|
||||
if (tag === "br") {
|
||||
out.push("\n");
|
||||
return;
|
||||
}
|
||||
if (tag === "hr") {
|
||||
out.push("\n----------\n");
|
||||
return;
|
||||
}
|
||||
if (tag === "img") {
|
||||
const alt = el.getAttribute("alt");
|
||||
if (alt) out.push(`[${alt}]`);
|
||||
return;
|
||||
}
|
||||
if (block && tag !== "li") out.push("\n");
|
||||
if (/^h[1-6]$/.test(tag)) out.push("\n");
|
||||
if (tag === "li") {
|
||||
const parent = el.parentElement;
|
||||
if (parent?.tagName.toLowerCase() === "ol") {
|
||||
const idx = (ctx.listIndex[ctx.listIndex.length - 1] ?? 0) + 1;
|
||||
ctx.listIndex[ctx.listIndex.length - 1] = idx;
|
||||
out.push(`\n${" ".repeat(Math.max(0, ctx.listIndex.length - 1))}${idx}. `);
|
||||
} else out.push(`\n${" ".repeat(Math.max(0, ctx.listIndex.length - 1))}- `);
|
||||
}
|
||||
const nextCtx = { ...ctx };
|
||||
if (tag === "pre") nextCtx.pre = true;
|
||||
if (tag === "ul" || tag === "ol") nextCtx.listIndex = [...ctx.listIndex, 0];
|
||||
if (tag === "blockquote") {
|
||||
const start = out.length;
|
||||
el.childNodes.forEach((c) => walk(c, nextCtx));
|
||||
const inner = out.splice(start).join("");
|
||||
out.push(
|
||||
"\n" +
|
||||
inner
|
||||
.replace(/^\n+|\n+$/g, "")
|
||||
.split("\n")
|
||||
.map((l) => `> ${l}`)
|
||||
.join("\n") +
|
||||
"\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (tag === "a") {
|
||||
const href = el.getAttribute("href") ?? "";
|
||||
const start = out.length;
|
||||
el.childNodes.forEach((c) => walk(c, nextCtx));
|
||||
const inner = out.splice(start).join("");
|
||||
const text = inner.trim();
|
||||
if (href && !href.startsWith("mailto:") && text && text !== href && !href.startsWith("#")) out.push(`${text} <${href}>`);
|
||||
else out.push(inner);
|
||||
return;
|
||||
}
|
||||
if (tag === "td" || tag === "th") {
|
||||
el.childNodes.forEach((c) => walk(c, nextCtx));
|
||||
out.push("\t");
|
||||
return;
|
||||
}
|
||||
el.childNodes.forEach((c) => walk(c, nextCtx));
|
||||
if (block) out.push("\n");
|
||||
};
|
||||
doc.body.childNodes.forEach((c) => walk(c, { pre: false, listIndex: [], quote: 0 }));
|
||||
return out
|
||||
.join("")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Prefix every line with "> " for plain text quoting. */
|
||||
export function quoteText(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.split("\n")
|
||||
.map((l) => (l.startsWith(">") ? `>${l}` : `> ${l}`))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Wrap long lines at width for format=flowed-ish plain text. */
|
||||
export function wrapText(text: string, width = 76): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line.length <= width || line.startsWith(">")) return line;
|
||||
const words = line.split(" ");
|
||||
const lines: string[] = [];
|
||||
let cur = "";
|
||||
for (const w of words) {
|
||||
if ((cur + " " + w).trim().length > width && cur) {
|
||||
lines.push(cur);
|
||||
cur = w;
|
||||
} else cur = cur ? `${cur} ${w}` : w;
|
||||
}
|
||||
if (cur) lines.push(cur);
|
||||
return lines.join("\n");
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function stripHtml(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
return (doc.body.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Normalize a subject for reply/forward: strip existing prefixes, add new. */
|
||||
export function replySubject(subject: string | null | undefined, prefix: "Re" | "Fwd"): string {
|
||||
const s = (subject ?? "").trim();
|
||||
const stripped = s.replace(/^((re|fw|fwd|aw|sv|vs|tr|wg)\s*:\s*)+/i, "");
|
||||
if (prefix === "Re" && /^re\s*:/i.test(s)) return s;
|
||||
if (prefix === "Fwd" && /^(fwd?|fw)\s*:/i.test(s)) return s;
|
||||
return `${prefix}: ${stripped}`;
|
||||
}
|
||||
|
||||
/** Detect quoted section boundaries (for "show trimmed content"). Returns index in lines or -1. */
|
||||
export function findQuoteStart(lines: string[]): number {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const l = lines[i]!;
|
||||
if (/^On .+wrote:\s*$/.test(l) || /^-{3,}\s*Original Message\s*-{3,}$/i.test(l) || /^_{5,}$/.test(l) || /^From:\s.+$/.test(l) && i + 1 < lines.length && /^(Sent|Date|To):/.test(lines[i + 1] ?? "")) {
|
||||
return i;
|
||||
}
|
||||
if (l.startsWith(">") && i > 0) {
|
||||
// First run of quote lines after some content
|
||||
let allQuoted = true;
|
||||
for (let j = i; j < Math.min(lines.length, i + 3); j++) if (!lines[j]!.startsWith(">") && lines[j]!.trim() !== "") allQuoted = false;
|
||||
if (allQuoted) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
Reference in new issue
Block a user