Files
LambdaMail/services/webmail/src/lib/emailBody.ts
T
Lucas RayanandClaude Sonnet 5.5 fece870d49 feat(webmail): a better reader and a drawn logo
The mark is rebuilt as a lambda of two square-cut strokes of one weight, joined
without a seam, on a flat ultramarine tile with continuous corners: the rounded
strokes and the gradient are gone. A message written plainly is now drawn in the
app's own colours instead of a white slab, with text too dark for the ground
lifted and white backdrops cleared; a designed one (newsletter, receipt) keeps
its white sheet. A reply's quoted history folds behind a button. A narrower
reading column, a larger title, and a way to see a plain message as sent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 05:57:52 -03:00

205 lines
9.9 KiB
TypeScript

/**
* Turns a message's sanitized HTML into something a frame can actually show.
*
* Two things were missing and both looked like "images are broken": a message
* written by any normal mail client references its own pictures as
* src="cid:...", which resolves against nothing in a browser; and the frame was
* handed a bare fragment with no document around it, so mail written for a
* white page rendered as dark text on a dark surface with no width limit.
*/
/** The parts the API returns alongside the body, keyed by Content-ID. */
export type InlineImages = Record<string, string>;
/**
* Replaces src="cid:x" with the data: URI the server delivered for that part.
*
* A reference with no matching part loses its source entirely rather than
* keeping a cid: the browser cannot fetch: an unresolvable src draws a
* broken-image icon, while no src at all falls back to the alt text.
*/
export function resolveInlineImages(html: string, images: InlineImages): string {
if (!html) return "";
return html.replace(
/(<img\b[^>]*?)\ssrc\s*=\s*("|')cid:([^"']*)\2/gi,
(_match, head: string, _quote: string, reference: string) => {
// Clients disagree about whether the reference keeps the angle brackets
// the Content-ID header carries, so both spellings resolve.
const key = decodeURIComponent(reference.trim()).replace(/^<|>$/g, "");
const resolved = images[key];
return resolved ? `${head} src="${resolved}"` : head;
},
);
}
/**
* Whether a message was designed, as a newsletter or a receipt is, rather than
* just written, as a letter between two people is.
*
* A designed message brings its own colours, tables and background images, and
* they only make sense on the white sheet it was drawn for. A written one has
* nothing of the kind and reads best when it takes the app's own colours - on a
* dark ground, a plain note as a bright white slab is the loudest thing on the
* screen and says nothing the words do not.
*
* Plain white is not design: Outlook paints it behind everything it sends.
*/
export function looksDesigned(html: string): boolean {
if (!html) return false;
const neutral = /^\s*(?:#f{3}(?:f{3})?|white|transparent|none|rgba?\(\s*255\s*,\s*255\s*,\s*255(?:\s*,\s*(?:1|1\.0|0?\.\d+))?\s*\)|rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\))\s*(?:!important)?\s*;?\s*$/i;
// bgcolor="#336699" and background="hero.png" are always a statement.
for (const m of html.matchAll(/\b(?:bgcolor|background)\s*=\s*(["']?)([^"'\s>]+)\1/gi)) {
if (!neutral.test(m[2])) return true;
}
// background / background-color in a style attribute or a <style> block.
for (const m of html.matchAll(/background(?:-color|-image)?\s*:\s*([^;}"']+)/gi)) {
const value = m[1].trim();
if (/url\(/i.test(value) || !neutral.test(value)) return true;
}
// A layout built from tables is a design even where nothing is coloured.
return (html.match(/<table\b/gi) ?? []).length >= 3;
}
/** The colours a message is drawn in, by the way it is shown. */
const SURFACES = {
paper: { fg: "#1d1d1f", muted: "#5b6272", link: "#3f4fe6", rule: "#d6d9e0", wash: "rgba(20,28,50,.05)", scheme: "light" },
dark: { fg: "#e6e9f0", muted: "#8e96a8", link: "#8794ff", rule: "#383e4d", wash: "rgba(233,237,245,.07)", scheme: "dark" },
light: { fg: "#12151c", muted: "#5b6272", link: "#3f4fe6", rule: "#cdd1db", wash: "rgba(20,28,50,.05)", scheme: "light" },
} as const;
export interface ReaderDocumentOptions {
/** The app's theme, which a message written plainly takes the colours of. */
theme?: "dark" | "light";
/**
* Draw the message on a white sheet, as it was written. A designed message
* always is; a plain one only when the reader asks to see it as sent.
*/
paper?: boolean;
}
/**
* Wraps a message body in the document the reader frame renders.
*
* The frame is sandboxed with an opaque origin, so it cannot inherit anything
* from the app: every style, the surface mail assumes, the height report the
* host page sizes the frame from and the little script that tidies a message
* all have to travel inside it.
*/
export function buildReaderDocument(bodyHtml: string, options: ReaderDocumentOptions = {}): string {
const paper = options.paper ?? true;
const surface = paper ? SURFACES.paper : SURFACES[options.theme ?? "dark"];
const native = !paper;
return `<!doctype html>
<html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="${surface.scheme}">
<!-- Every link leaves this document: it has an opaque origin, so navigating in
place strands the reader on a blank pane with no way back. Declared here
rather than only in script so it holds even if script does not run. -->
<base target="_blank">
<!-- Any remote resource the reader opted into must not carry the message's URL
with it, and this holds without script. -->
<meta name="referrer" content="no-referrer">
<style>
:root { color-scheme:${surface.scheme}; }
html { background:${native ? "transparent" : "#ffffff"}; }
body {
margin:0; padding:${native ? "2px 0 6px" : "26px 30px"}; background:${native ? "transparent" : "#ffffff"}; color:${surface.fg};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size:${native ? "15.5px" : "15px"}; line-height:${native ? "1.68" : "1.55"};
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
/* Mail is written against desktop widths. Without these a newsletter
pushes a horizontal scrollbar across its own content. */
overflow-wrap:anywhere; word-break:break-word;
}
img, video, table, pre { max-width:100% !important; }
img { height:auto; }
table { border-collapse:collapse; }
/* A layout table keeps the width its author gave it - forcing "auto" is what
collapsed designed messages into a narrow column. max-width above is what
stops one from overflowing a small screen. */
p { margin:0 0 .9em; }
${native ? "img { border-radius:8px; }" : ""}
a { color:${surface.link}; text-decoration-thickness:1px; text-underline-offset:2px; }
pre, code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size:.9em; }
pre { white-space:pre-wrap; background:${surface.wash}; padding:12px 14px; border-radius:8px; }
blockquote {
margin:.4em 0 .9em; padding:2px 0 2px 14px;
border-left:3px solid ${surface.rule}; color:${surface.muted};
}
hr { border:0; border-top:1px solid ${surface.rule}; margin:1.2em 0; }
/* The button that stands in for a reply's quoted history. */
.lm-quote-toggle {
display:inline-flex; align-items:center; justify-content:center; height:20px; padding:0 9px; margin:2px 0 8px;
border:0; border-radius:10px; background:${surface.wash}; color:${surface.muted};
font:600 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing:.08em; cursor:pointer;
}
.lm-quote-toggle:hover { color:${surface.fg}; }
.lm-quote-toggle:focus-visible { outline:2px solid ${surface.link}; outline-offset:2px; }
</style>
</head>
<body>
${bodyHtml}
<script>
// <base> already sends the click to a new tab; this adds the rel that keeps
// the destination from learning where the click came from.
for (const link of document.querySelectorAll("a[href]")) {
link.setAttribute("rel", "noopener noreferrer");
}
${native ? `// A message written plainly is drawn in the app's colours, but it may still
// carry its own: black text it was given by the program that wrote it, and a
// white backdrop behind every block. Dark text on a dark ground is the one
// thing this must never produce, so any colour too close to the ground goes.
(function () {
const dark = ${surface.scheme === "dark"};
const channels = (value) => (value.match(/[0-9.]+/g) || []).map(Number);
const luminance = ([r, g, b]) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
for (const el of document.body.querySelectorAll("*")) {
const style = getComputedStyle(el);
const [r, g, b, a = 1] = channels(style.backgroundColor);
if (a > 0 && r > 244 && g > 244 && b > 244) el.style.setProperty("background-color", "transparent", "important");
const [fr, fg, fb] = channels(style.color);
const lum = luminance([fr, fg, fb]);
if ((dark && lum < 0.42) || (!dark && lum > 0.62)) el.style.removeProperty("color"), el.removeAttribute("color"), el.style.setProperty("color", "inherit", "important");
}
})();` : ""}
// A reply carries the whole conversation under it. The history is one click
// away rather than a screenful in the way - and in a conversation, where each
// message is shown anyway, it is a duplicate.
(function () {
const quotes = [...document.querySelectorAll("blockquote")].filter((q) => !q.parentElement.closest("blockquote"));
for (const quote of quotes) {
if ((quote.textContent || "").trim().length < 60) continue;
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "lm-quote-toggle";
toggle.textContent = "\u00b7\u00b7\u00b7";
toggle.setAttribute("aria-expanded", "false");
toggle.title = "...";
quote.style.display = "none";
toggle.addEventListener("click", () => {
const open = quote.style.display === "none";
quote.style.display = open ? "" : "none";
toggle.setAttribute("aria-expanded", String(open));
});
quote.parentNode.insertBefore(toggle, quote);
}
})();
// The host sizes the frame from this rather than guessing: a fixed height
// either clips a long message or leaves a slab of empty white under a short
// one, and a sandboxed frame cannot be measured from outside.
const report = () => parent.postMessage(
{ type: "lm:reader-height", height: document.documentElement.scrollHeight }, "*");
report();
window.addEventListener("load", report);
if (typeof ResizeObserver === "function") new ResizeObserver(report).observe(document.body);
</script>
</body></html>`;
}