Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316import { Head, Img, Link, pixelBasedPreset } from "@react-email/components";import React from "react";import { compareColors } from "components/ThemeManager/themeUtils";
export type EmailTheme = { primary: string; pageBackground: string; backgroundColor: string; accentBackground: string; accentText: string; headingFont: string; bodyFont: string; // Matches the publication's web `pageWidth` (px) so subscribers see the // post at the same column width in their inbox as on the live page. // Default 624 mirrors `ThemeProvider.tsx`'s fallback. pageWidth: number; showPageBackground: boolean;};
export const defaultEmailTheme: EmailTheme = { primary: "rgb(39, 39, 39)", pageBackground: "rgb(255, 255, 255)", backgroundColor: "rgb(253, 252, 250)", accentBackground: "rgb(87, 130, 43)", accentText: "rgb(255, 255, 255)", headingFont: "Georgia, serif", bodyFont: "Verdana, sans-serif", pageWidth: 624, showPageBackground: true,};
// Parse rgb()/rgba()/#hex into [r, g, b]. Returns black on parse failure —// theme colors come from a typed config so this is just defensive.const parseColor = (input: string): [number, number, number] => { const rgbMatch = input.match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i); if (rgbMatch) return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])]; const hexMatch = input.match(/^#([0-9a-f]{6})$/i); if (hexMatch) { const h = hexMatch[1]; return [ parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), ]; } const hex3 = input.match(/^#([0-9a-f]{3})$/i); if (hex3) { const h = hex3[1]; return [ parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16), ]; } return [0, 0, 0];};
// Linear sRGB mix of two colors. We resolve theme tints to literal rgb() at// render time because Gmail's CSS sanitizer drops `color-mix(...)` —// leaving borders invisible and tinted text falling back to defaults.// Linear mixing isn't perceptually identical to the oklab original, but// for the near-grayscale tints we use it's visually indistinguishable.export const mixRgb = (a: string, b: string, bPercent: number): string => { const [ar, ag, ab] = parseColor(a); const [br, bg, bb] = parseColor(b); const t = bPercent / 100; const round = (x: number) => Math.round(x); return `rgb(${round(ar * (1 - t) + br * t)}, ${round( ag * (1 - t) + bg * t, )}, ${round(ab * (1 - t) + bb * t)})`;};
// Mirrors BaseThemeProvider's `--accent-contrast` selection: of the two// accents, pick the one with more OKLab contrast against the surface, unless// it's near-indistinguishable from the text color while the other accent// still reads against the background. The web renders themed text (pub// names, links) in accent-contrast rather than raw accent-1; without this a// pale accentBackground on a pale card is near-invisible.export const pickAccentContrast = ( theme: Pick<EmailTheme, "primary" | "accentBackground" | "accentText">, surface: string,): string => { const toTriplet = (c: string) => parseColor(c).join(", "); const bgRef = toTriplet(surface); const primary = toTriplet(theme.primary); const accents = [theme.accentBackground, theme.accentText].sort( (a, b) => compareColors(toTriplet(b), bgRef).distance - compareColors(toTriplet(a), bgRef).distance, ); const bestVsText = compareColors(toTriplet(accents[0]), primary); const altVsBg = compareColors(toTriplet(accents[1]), bgRef); if ( bestVsText.distance < 0.15 && bestVsText.chromaDiff < 0.05 && altVsBg.distance > 0.31 ) { return accents[1]; } return accents[0];};
export type ResolvedColors = { primary: string; secondary: string; tertiary: string; border: string; borderLight: string;};
export const resolveColors = (theme: EmailTheme): ResolvedColors => { // When the card background is hidden, the surface visible behind every // block is the outer page bg, not the publication's pageBackground. // Mix tints against that visible surface so block fills (e.g. the // unsupported-block fallback) sit on a tone derived from what's // actually behind them — mirroring `BaseThemeProvider`'s web behavior // where `bgPage` collapses to `bgLeaflet` when showPageBackground is // false. const effectiveBg = theme.showPageBackground ? theme.pageBackground : theme.backgroundColor; return { primary: theme.primary, secondary: mixRgb(theme.primary, effectiveBg, 25), tertiary: mixRgb(theme.primary, effectiveBg, 55), border: mixRgb(theme.primary, effectiveBg, 75), borderLight: mixRgb(theme.primary, effectiveBg, 85), };};
// Postmark fetches `<img src>` and link `href` values verbatim from the// rendered HTML — relative paths break. Templates accept an `assetsBaseUrl`// prop and use this builder for `/email-assets/*` references.export const makeStaticUrl = (assetsBaseUrl: string) => { const base = assetsBaseUrl.replace(/\/$/, ""); return (filename: string): string => `${base}/email-assets/${filename}`;};
// Build a URL for the dynamic icon endpoint that bakes the theme color// into a PNG at request time. We render PNG (not SVG) because Gmail's// image proxy strips SVG for @gmail.com inboxes, and Microsoft removed// SVG support across new Outlook + Outlook.com in late 2025.export const makeEmailIconUrl = ( assetsBaseUrl: string, name: "quote" | "comment" | "bluesky", color: string, size = 16,): string => { const base = assetsBaseUrl.replace(/\/$/, ""); const params = new URLSearchParams({ color }); if (size !== 16) params.set("size", String(size)); return `${base}/api/email-assets/${name}.png?${params.toString()}`;};
// Email clients don't render `white-space: pre-wrap` reliably (Outlook// collapses it), so newlines in plaintext become explicit <br /> elements.export const renderTextWithBreaks = ( text: string, key: string,): React.ReactNode => { const parts = text.split("\n"); if (parts.length === 1) return text; return parts.flatMap((part, i) => i < parts.length - 1 ? [part, <br key={`${key}-br-${i}`} />] : [part], );};
// React's TypeScript types don't include the deprecated `bgcolor` HTML// attribute on <td>, but every email client (especially Outlook) reads it// directly — so we spread it via a typed cast. Combined with an inline// background-color style for clients that do honor CSS, this is the// belt-and-suspenders pattern for reliable email backgrounds.export const bgcolorAttr = (color: string): Record<string, string> => ({ bgcolor: color,});
// Tailwind config shared by the Leaflet/publication confirm emails.// All `color-mix(in oklab, ...)` expressions are pre-resolved to plain// rgb() because Gmail's CSS sanitizer drops `color-mix()` — leaving// classes like `text-secondary` and `border-border` rendering with no// color in Gmail.const CONFIRM_PRIMARY = "rgb(39, 39, 39)";const CONFIRM_PAGE_BG = "rgb(255, 255, 255)";
export const confirmEmailTailwindConfig = { presets: [pixelBasedPreset], theme: { screens: { sm: "640px", md: "960px", lg: "1280px", }, borderRadius: { none: "0", md: "0.25rem", lg: "0.5rem", full: "9999px", }, colors: { inherit: "inherit", transparent: "transparent", current: "currentColor", primary: CONFIRM_PRIMARY, secondary: mixRgb(CONFIRM_PRIMARY, CONFIRM_PAGE_BG, 25), tertiary: mixRgb(CONFIRM_PRIMARY, CONFIRM_PAGE_BG, 55), border: mixRgb(CONFIRM_PRIMARY, CONFIRM_PAGE_BG, 75), "border-light": mixRgb(CONFIRM_PRIMARY, CONFIRM_PAGE_BG, 85), white: "#FFFFFF", "accent-1": "rgb(87, 130, 43)", "accent-2": "rgb(255, 255, 255)", "accent-contrast": "rgb(87, 130, 43)", "bg-leaflet": "rgb(253, 252, 250)", "bg-page": "rgba(255, 255, 255, 1)", // Gmail strips color-mix, so highlight-1's accent tint is resolved to a // literal rgb() (green accent mixed 75% into the page background). "highlight-1": "rgb(213, 224, 202)", "highlight-2": "rgb(237, 210, 128)", "highlight-3": "rgb(255, 205, 195)", }, fontSize: { xs: ".75rem", sm: ".875rem", base: "1rem", lg: "1.125rem", xl: "1.625rem", "2xl": "2rem", }, extend: { fontFamily: { sans: ["Verdana"], serif: ["Georgia"], }, }, },};
// Wraps `<Head>` with the viewport meta that prevents iOS Mail / Gmail iOS// from auto-shrinking the email to fit a wider-than-viewport layout (which// makes every element render visually small). Templates can pass extra// children — usually a `<style>` block with template-specific @media// rules — and they'll be appended after the meta.export const MailHead = ({ children }: { children?: React.ReactNode }) => ( <Head> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> {children} </Head>);
export const LeafletWatermark = ({ staticUrl, theme = defaultEmailTheme,}: { staticUrl?: (filename: string) => string; theme?: EmailTheme;} = {}) => { const c = resolveColors(theme); // Mail clients fetch <img src> verbatim, so the fallback must be absolute. const leafletSrc = staticUrl ? staticUrl("leaflet.png") : "https://leaflet.pub/email-assets/leaflet.png"; // Shrink-to-fit table with align="center" — the bulletproof email // pattern for centering a chunk of inline content within whatever // container it lands in (works inside both <td align="center"> in the // post template and a bare Body in the confirm emails). return ( <table role="presentation" align="center" cellPadding={0} cellSpacing={0} border={0} > <tbody> <tr> <td> <Link href="https://leaflet.pub" style={{ color: c.tertiary, fontFamily: theme.bodyFont, fontSize: 14, fontStyle: "italic", textDecoration: "none", }} > <Img src={leafletSrc} width={16} height={16} alt="" style={{ display: "inline-block", marginRight: 4, verticalAlign: "middle", }} /> <span style={{ verticalAlign: "middle" }}> Published with{" "} <span style={{ color: theme.accentBackground, fontWeight: "bold", }} > Leaflet </span> </span> </Link> </td> </tr> </tbody> </table> );};