Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
TSX
at perf/paste
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270import { Head, Img, Link, pixelBasedPreset } from "@react-email/components";import React from "react";
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(240, 247, 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)})`;};
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", 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()}`;};
// 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(240, 247, 250)", "bg-page": "rgba(255, 255, 255, 1)", "highlight-1": "rgb(255, 177, 177)", "highlight-2": "rgb(253, 245, 203)", "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> );};