import { 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, 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 `` 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
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,
] : [part], ); }; // React's TypeScript types don't include the deprecated `bgcolor` HTML // attribute on , 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 => ({ 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 `` 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 `