a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

unify email templates

Jared Pereira (Apr 27, 2026, 3:35 PM EDT) cf41d6b3 fad3f413

+329 -371
+1 -1
emails/fromPublication.ts
··· 1 - import { defaultEmailTheme, type EmailTheme } from "./post"; 1 + import { defaultEmailTheme, type EmailTheme } from "./shared"; 2 2 import type { PubLeafletPublication } from "lexicons/api"; 3 3 import { getFontConfig, getFontFamilyValue } from "src/fonts"; 4 4
+11 -84
emails/leafletConfirmEmail.tsx
··· 2 2 Body, 3 3 Column, 4 4 Container, 5 - Head, 6 - Hr, 7 5 Html, 8 6 Img, 9 - Link, 10 - Section, 11 7 Row, 12 - Button, 13 - CodeBlock, 14 8 } from "@react-email/components"; 9 + import { Tailwind } from "@react-email/components"; 15 10 import { Text, Heading } from "./post"; 16 - import { Tailwind, pixelBasedPreset } from "@react-email/components"; 17 - import { LeafletWatermark } from "./post"; 11 + import { 12 + confirmEmailTailwindConfig, 13 + MailHead, 14 + makeStaticUrl, 15 + } from "./shared"; 18 16 19 17 export const LeafletConfirmEmail = (props: { 20 18 code?: string; 21 19 assetsBaseUrl?: string; 22 20 }) => { 23 - const base = (props.assetsBaseUrl ?? "https://leaflet.pub").replace( 24 - /\/$/, 25 - "", 26 - ); 27 - const leafletSrc = `${base}/email-assets/leaflet.png`; 21 + const leafletSrc = makeStaticUrl( 22 + props.assetsBaseUrl ?? "https://leaflet.pub", 23 + )("leaflet.png"); 28 24 return ( 29 25 <Html> 30 - <Tailwind 31 - config={{ 32 - presets: [pixelBasedPreset], 33 - theme: { 34 - screens: { 35 - sm: "640px", 36 - md: "960px", 37 - lg: "1280px", 38 - }, 39 - borderRadius: { 40 - none: "0", 41 - md: "0.25rem", 42 - lg: "0.5rem", 43 - full: "9999px", 44 - }, 45 - 46 - colors: { 47 - inherit: "inherit", 48 - transparent: "transparent", 49 - current: "currentColor", 50 - 51 - //TEXT COLORS. 52 - primary: "rgb(39, 39, 39)", 53 - secondary: 54 - "color-mix(in oklab, rgb(39, 39, 39), rgb(255, 255, 255) 25%)", 55 - tertiary: 56 - "color-mix(in oklab, rgb(39, 39, 39), rgb(255, 255, 255) 55%)", 57 - border: 58 - "color-mix(in oklab, rgb(39, 39, 39), rgb(255, 255, 255) 75%)", 59 - "border-light": 60 - "color-mix(in oklab, rgb(39, 39, 39), rgb(255, 255, 255) 85%)", 61 - 62 - white: "#FFFFFF", 63 - 64 - //ACCENT COLORS 65 - "accent-1": "rgb(0, 0, 225)", 66 - "accent-2": "rgb(255, 255, 255;)", 67 - "accent-contrast": "rgb(0, 0, 225)", 68 - 69 - //BG COLORS (defined as css variables in global.css) 70 - "bg-leaflet": "rgb(240, 247, 250)", 71 - "bg-page": "rgba(255, 255, 255, 1)", 72 - 73 - // HIGHLIGHT COLORS 74 - "highlight-1": "rgb(255, 177, 177)", 75 - "highlight-2": "rgb(253, 245, 203)", 76 - "highlight-3": "rgb(255, 205, 195)", 77 - 78 - //DO NOT USE IN PRODUCTION. Test colors to aid development, ie, setting bg color on element to see edges of div. DO. NOT. USE. IN. PRODUCTION 79 - test: "#E18181", 80 - "test-blue": "#48D1EF", 81 - }, 82 - fontSize: { 83 - xs: ".75rem", 84 - sm: ".875rem", 85 - base: "1rem", 86 - lg: "1.125rem", 87 - xl: "1.625rem", 88 - "2xl": "2rem", 89 - }, 90 - 91 - extend: { 92 - fontFamily: { 93 - sans: ["Verdana"], 94 - serif: ["Georgia"], 95 - }, 96 - }, 97 - }, 98 - }} 99 - > 100 - <Head /> 26 + <Tailwind config={confirmEmailTailwindConfig}> 27 + <MailHead /> 101 28 102 29 <Body className={`bg-bg-page font-sans px-2 py-4 !m-0 `}> 103 30 <Row
+44 -203
emails/post.tsx
··· 1 1 import { 2 2 Body, 3 3 Column, 4 - Head, 5 4 Heading as ReactEmailHeading, 6 5 Hr, 7 6 Html, ··· 28 27 PubLeafletPagesLinearDocument, 29 28 } from "lexicons/api"; 30 29 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 31 - 32 - export type EmailTheme = { 33 - primary: string; 34 - pageBackground: string; 35 - backgroundColor: string; 36 - accentBackground: string; 37 - accentText: string; 38 - headingFont: string; 39 - bodyFont: string; 40 - // Matches the publication's web `pageWidth` (px) so subscribers see the 41 - // post at the same column width in their inbox as on the live page. 42 - // Default 624 mirrors `ThemeProvider.tsx`'s fallback. 43 - pageWidth: number; 44 - }; 45 - 46 - export const defaultEmailTheme: EmailTheme = { 47 - primary: "rgb(39, 39, 39)", 48 - pageBackground: "rgb(255, 255, 255)", 49 - backgroundColor: "rgb(240, 247, 250)", 50 - accentBackground: "rgb(0, 0, 225)", 51 - accentText: "rgb(255, 255, 255)", 52 - headingFont: "Georgia, serif", 53 - bodyFont: "Verdana, sans-serif", 54 - pageWidth: 624, 55 - }; 56 - 57 - // Parse rgb()/rgba()/#hex into [r, g, b]. Returns black on parse failure — 58 - // theme colors come from a typed config so this is just defensive. 59 - const parseColor = (input: string): [number, number, number] => { 60 - const rgbMatch = input.match( 61 - /rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i, 62 - ); 63 - if (rgbMatch) 64 - return [ 65 - Number(rgbMatch[1]), 66 - Number(rgbMatch[2]), 67 - Number(rgbMatch[3]), 68 - ]; 69 - const hexMatch = input.match(/^#([0-9a-f]{6})$/i); 70 - if (hexMatch) { 71 - const h = hexMatch[1]; 72 - return [ 73 - parseInt(h.slice(0, 2), 16), 74 - parseInt(h.slice(2, 4), 16), 75 - parseInt(h.slice(4, 6), 16), 76 - ]; 77 - } 78 - const hex3 = input.match(/^#([0-9a-f]{3})$/i); 79 - if (hex3) { 80 - const h = hex3[1]; 81 - return [ 82 - parseInt(h[0] + h[0], 16), 83 - parseInt(h[1] + h[1], 16), 84 - parseInt(h[2] + h[2], 16), 85 - ]; 86 - } 87 - return [0, 0, 0]; 88 - }; 89 - 90 - // Linear sRGB mix of two colors. We resolve theme tints to literal rgb() at 91 - // render time because Gmail's CSS sanitizer drops `color-mix(...)` — leaving 92 - // borders invisible and accent text fall back to defaults. Linear mixing 93 - // isn't perceptually identical to the oklab original, but for the 94 - // near-grayscale tints we use it's visually indistinguishable. 95 - const mixRgb = ( 96 - a: string, 97 - b: string, 98 - bPercent: number, 99 - ): string => { 100 - const [ar, ag, ab] = parseColor(a); 101 - const [br, bg, bb] = parseColor(b); 102 - const t = bPercent / 100; 103 - const round = (x: number) => Math.round(x); 104 - return `rgb(${round(ar * (1 - t) + br * t)}, ${round( 105 - ag * (1 - t) + bg * t, 106 - )}, ${round(ab * (1 - t) + bb * t)})`; 107 - }; 108 - 109 - type ResolvedColors = { 110 - primary: string; 111 - secondary: string; 112 - tertiary: string; 113 - border: string; 114 - borderLight: string; 115 - }; 116 - 117 - const resolveColors = (theme: EmailTheme): ResolvedColors => ({ 118 - primary: theme.primary, 119 - secondary: mixRgb(theme.primary, theme.pageBackground, 25), 120 - tertiary: mixRgb(theme.primary, theme.pageBackground, 55), 121 - border: mixRgb(theme.primary, theme.pageBackground, 75), 122 - borderLight: mixRgb(theme.primary, theme.pageBackground, 85), 123 - }); 30 + import { 31 + bgcolorAttr, 32 + defaultEmailTheme, 33 + LeafletWatermark, 34 + MailHead, 35 + makeStaticUrl, 36 + resolveColors, 37 + type EmailTheme, 38 + type ResolvedColors, 39 + } from "./shared"; 124 40 125 41 export type PostEmailProps = { 126 42 publicationName: string; ··· 254 170 ], 255 171 }; 256 172 257 - const BLOCK_MARGIN = "4px 0 16px"; 258 - const HEADING_MARGIN = "4px 0 0"; 173 + const BLOCK_MARGIN_TOP = 4; 174 + const BLOCK_MARGIN_BOTTOM = 16; 175 + const BLOCK_MARGIN = `${BLOCK_MARGIN_TOP}px 0 ${BLOCK_MARGIN_BOTTOM}px`; 176 + const IMAGE_MARGIN = `${BLOCK_MARGIN_TOP}px auto ${BLOCK_MARGIN_BOTTOM}px`; 177 + const HEADING_MARGIN = `${BLOCK_MARGIN_TOP}px 0 0`; 178 + const HEADING_FONT_SIZE_PX: Record<1 | 2 | 3, number> = { 179 + 1: 26, 180 + 2: 18, 181 + 3: 16, 182 + }; 259 183 260 184 export const PostEmail = (props: Partial<PostEmailProps> = {}) => { 261 185 const p: PostEmailProps = { ...defaultProps, ...props }; 262 186 const theme = p.theme ?? defaultEmailTheme; 263 187 const c = resolveColors(theme); 264 - const staticUrl = (filename: string) => 265 - `${p.assetsBaseUrl.replace(/\/$/, "")}/email-assets/${filename}`; 188 + const staticUrl = makeStaticUrl(p.assetsBaseUrl); 266 189 const byline = [p.authorName, p.publishedAtLabel].filter(Boolean).join(" | "); 267 190 268 191 const accentLink: CSSProperties = { ··· 271 194 fontFamily: theme.bodyFont, 272 195 }; 273 196 274 - // Page width as both an HTML width attribute (px) and a CSS max-width. 275 - // Gmail strips/ignores `max-width` in some contexts but always honors the 276 - // HTML `width` attribute on a <table>, so we set both: the HTML `width` 277 - // pins the box for Gmail, and `maxWidth: 100%` lets it shrink on narrow 278 - // viewports. The value mirrors the publication's web page width. 279 - const pageWidth = theme.pageWidth; 280 - 281 197 return ( 282 198 <Html> 283 - <Head> 284 - {/* Without this, iOS Mail / Gmail iOS auto-scale the email down to 285 - fit a wider-than-viewport layout (e.g. 624px in a 400px screen) 286 - — which makes every element appear "too small". With it, we get 287 - 1:1 pixel sizing and our @media queries below can shrink the 288 - card to viewport width directly. */} 289 - <meta 290 - name="viewport" 291 - content="width=device-width, initial-scale=1.0" 292 - /> 199 + <MailHead> 293 200 {/* Mobile-only padding tightening. Apple Mail, iOS Mail, Gmail web, 294 201 and most other clients honor @media in <head>; Outlook desktop 295 202 ignores it and keeps the wider inline padding, which is fine ··· 308 215 } 309 216 } 310 217 `}</style> 311 - </Head> 218 + </MailHead> 312 219 <Body 313 220 style={{ 314 221 backgroundColor: theme.backgroundColor, ··· 339 246 <td 340 247 align="center" 341 248 className="email-page-pad" 342 - {...({ bgcolor: theme.backgroundColor } as Record<string, string>)} 249 + {...bgcolorAttr(theme.backgroundColor)} 343 250 style={{ 344 251 backgroundColor: theme.backgroundColor, 345 252 padding: "24px 16px", 346 253 }} 347 254 > 255 + {/* Both the HTML `width` attribute and CSS `width` are set: 256 + Gmail strips/ignores `max-width` in some contexts but 257 + always honors the HTML attribute, so it pins the box at 258 + the publication's web page width on desktop. The 259 + media-query rule above forces 100% on small screens. */} 348 260 <table 349 261 role="presentation" 350 262 align="center" 351 263 className="email-card-table" 352 - width={pageWidth} 264 + width={theme.pageWidth} 353 265 cellPadding={0} 354 266 cellSpacing={0} 355 267 border={0} 356 - style={{ width: pageWidth, maxWidth: "100%" }} 268 + style={{ width: theme.pageWidth, maxWidth: "100%" }} 357 269 > 358 270 <tbody> 359 271 <tr> 360 272 <td 361 273 className="email-card-pad" 362 - {...({ bgcolor: theme.pageBackground } as Record< 363 - string, 364 - string 365 - >)} 274 + {...bgcolorAttr(theme.pageBackground)} 366 275 style={{ 367 276 backgroundColor: theme.pageBackground, 368 277 border: `1px solid ${c.border}`, ··· 636 545 ); 637 546 } 638 547 if (PubLeafletBlocksHeader.isMain(block)) { 639 - const raw = Math.floor(block.level ?? 1); 640 - const clamped = (raw < 1 ? 1 : raw > 3 ? 3 : raw) as 1 | 2 | 3; 641 - const fontSize = clamped === 1 ? 26 : clamped === 2 ? 18 : 16; 642 - const color = clamped === 3 ? colors.secondary : theme.primary; 548 + const level = Math.min( 549 + 3, 550 + Math.max(1, Math.floor(block.level ?? 1)), 551 + ) as 1 | 2 | 3; 643 552 return ( 644 553 <ReactEmailHeading 645 - as={`h${clamped}`} 554 + as={`h${level}`} 646 555 style={{ 647 - color, 556 + color: level === 3 ? colors.secondary : theme.primary, 648 557 fontFamily: theme.headingFont, 649 558 fontWeight: "bold", 650 - fontSize, 559 + fontSize: HEADING_FONT_SIZE_PX[level], 651 560 lineHeight: 1.25, 652 561 margin: HEADING_MARGIN, 653 562 }} ··· 702 611 style={{ 703 612 display: "block", 704 613 height: "auto", 705 - margin: `${BLOCK_MARGIN.split(" ")[0]} auto ${ 706 - BLOCK_MARGIN.split(" ").slice(-1)[0] 707 - }`, 614 + margin: IMAGE_MARGIN, 708 615 maxWidth: naturalWidth ? `${naturalWidth}px` : "100%", 709 616 width: "100%", 710 617 }} ··· 763 670 return <BlockNotSupported theme={theme} colors={colors} />; 764 671 }; 765 672 766 - export const LeafletWatermark = ({ 767 - staticUrl, 768 - theme = defaultEmailTheme, 769 - }: { 770 - staticUrl?: (filename: string) => string; 771 - theme?: EmailTheme; 772 - } = {}) => { 773 - const c = resolveColors(theme); 774 - const leafletSrc = staticUrl 775 - ? staticUrl("leaflet.png") 776 - : "/email-assets/leaflet.png"; 777 - // Shrink-to-fit table with align="center" — the bulletproof email 778 - // pattern for centering a chunk of inline content within whatever 779 - // container it lands in. 780 - return ( 781 - <table 782 - role="presentation" 783 - align="center" 784 - cellPadding={0} 785 - cellSpacing={0} 786 - border={0} 787 - > 788 - <tbody> 789 - <tr> 790 - <td> 791 - <Link 792 - href="https://leaflet.pub" 793 - style={{ 794 - color: c.tertiary, 795 - fontFamily: theme.bodyFont, 796 - fontSize: 14, 797 - fontStyle: "italic", 798 - textDecoration: "none", 799 - }} 800 - > 801 - <Img 802 - src={leafletSrc} 803 - width={16} 804 - height={16} 805 - alt="" 806 - style={{ 807 - display: "inline-block", 808 - marginRight: 4, 809 - verticalAlign: "middle", 810 - }} 811 - /> 812 - <span style={{ verticalAlign: "middle" }}> 813 - Published with{" "} 814 - <span 815 - style={{ 816 - color: theme.accentBackground, 817 - fontWeight: "bold", 818 - }} 819 - > 820 - Leaflet 821 - </span> 822 - </span> 823 - </Link> 824 - </td> 825 - </tr> 826 - </tbody> 827 - </table> 828 - ); 829 - }; 830 - 831 - // Backwards-compatible helpers used by `leafletConfirmEmail.tsx` and 832 - // `pubConfirmEmail.tsx`, which wrap themselves in their own <Tailwind> 833 - // context. We keep layout (margin/font-size/line-height) inline so the 834 - // helpers still look right outside Tailwind, but leave color/font-family 835 - // to the caller (via className inside Tailwind, or a `style` override). 673 + // Helpers used by the confirm-email templates inside their <Tailwind> 674 + // context. Layout (margin/font-size/line-height) is inline so they render 675 + // without Tailwind too; color and font-family are left to the caller via 676 + // className or a `style` override so Tailwind classes can win. 836 677 export const Text = (props: { 837 678 children: React.ReactNode; 838 679 noPadding?: boolean;
+11 -83
emails/pubConfirmEmail.tsx
··· 2 2 Body, 3 3 Column, 4 4 Container, 5 - Head, 6 - Hr, 7 5 Html, 8 6 Img, 9 - Link, 10 - Section, 11 7 Row, 12 - Button, 13 - CodeBlock, 8 + Tailwind, 14 9 } from "@react-email/components"; 15 10 import { Text, Heading } from "./post"; 16 - import { Tailwind, pixelBasedPreset } from "@react-email/components"; 17 - import { LeafletWatermark } from "./post"; 11 + import { 12 + confirmEmailTailwindConfig, 13 + LeafletWatermark, 14 + MailHead, 15 + makeStaticUrl, 16 + } from "./shared"; 18 17 19 18 export const PubConfirmEmail = (props: { 20 19 code?: string; 21 20 assetsBaseUrl?: string; 22 21 }) => { 23 - const base = (props.assetsBaseUrl ?? "https://leaflet.pub").replace( 24 - /\/$/, 25 - "", 22 + const staticUrl = makeStaticUrl( 23 + props.assetsBaseUrl ?? "https://leaflet.pub", 26 24 ); 27 - const staticUrl = (filename: string) => `${base}/email-assets/${filename}`; 28 25 return ( 29 26 <Html> 30 - <Tailwind 31 - config={{ 32 - presets: [pixelBasedPreset], 33 - theme: { 34 - screens: { 35 - sm: "640px", 36 - md: "960px", 37 - lg: "1280px", 38 - }, 39 - borderRadius: { 40 - none: "0", 41 - md: "0.25rem", 42 - lg: "0.5rem", 43 - full: "9999px", 44 - }, 45 - 46 - colors: { 47 - inherit: "inherit", 48 - transparent: "transparent", 49 - current: "currentColor", 50 - 51 - //TEXT COLORS. 52 - primary: "rgb(39, 39, 39)", 53 - secondary: 54 - "color-mix(in oklab, rgb(39, 39, 39), rgb(255, 255, 255) 25%)", 55 - tertiary: 56 - "color-mix(in oklab, rgb(39, 39, 39), rgb(255, 255, 255) 55%)", 57 - border: 58 - "color-mix(in oklab, rgb(39, 39, 39), rgb(255, 255, 255) 75%)", 59 - "border-light": 60 - "color-mix(in oklab, rgb(39, 39, 39), rgb(255, 255, 255) 85%)", 61 - 62 - white: "#FFFFFF", 63 - 64 - //ACCENT COLORS 65 - "accent-1": "rgb(0, 0, 225)", 66 - "accent-2": "rgb(255, 255, 255;)", 67 - "accent-contrast": "rgb(0, 0, 225)", 68 - 69 - //BG COLORS (defined as css variables in global.css) 70 - "bg-leaflet": "rgb(240, 247, 250)", 71 - "bg-page": "rgba(255, 255, 255, 1)", 72 - 73 - // HIGHLIGHT COLORS 74 - "highlight-1": "rgb(255, 177, 177)", 75 - "highlight-2": "rgb(253, 245, 203)", 76 - "highlight-3": "rgb(255, 205, 195)", 77 - 78 - //DO NOT USE IN PRODUCTION. Test colors to aid development, ie, setting bg color on element to see edges of div. DO. NOT. USE. IN. PRODUCTION 79 - test: "#E18181", 80 - "test-blue": "#48D1EF", 81 - }, 82 - fontSize: { 83 - xs: ".75rem", 84 - sm: ".875rem", 85 - base: "1rem", 86 - lg: "1.125rem", 87 - xl: "1.625rem", 88 - "2xl": "2rem", 89 - }, 90 - 91 - extend: { 92 - fontFamily: { 93 - sans: ["Verdana"], 94 - serif: ["Georgia"], 95 - }, 96 - }, 97 - }, 98 - }} 99 - > 100 - <Head /> 27 + <Tailwind config={confirmEmailTailwindConfig}> 28 + <MailHead /> 101 29 102 30 <Body className={`bg-bg-page font-sans px-2 py-4 !m-0 `}> 103 31 <Row
+262
emails/shared.tsx
··· 1 + import { 2 + Head, 3 + Img, 4 + Link, 5 + pixelBasedPreset, 6 + } from "@react-email/components"; 7 + import React from "react"; 8 + 9 + export type EmailTheme = { 10 + primary: string; 11 + pageBackground: string; 12 + backgroundColor: string; 13 + accentBackground: string; 14 + accentText: string; 15 + headingFont: string; 16 + bodyFont: string; 17 + // Matches the publication's web `pageWidth` (px) so subscribers see the 18 + // post at the same column width in their inbox as on the live page. 19 + // Default 624 mirrors `ThemeProvider.tsx`'s fallback. 20 + pageWidth: number; 21 + }; 22 + 23 + export const defaultEmailTheme: EmailTheme = { 24 + primary: "rgb(39, 39, 39)", 25 + pageBackground: "rgb(255, 255, 255)", 26 + backgroundColor: "rgb(240, 247, 250)", 27 + accentBackground: "rgb(0, 0, 225)", 28 + accentText: "rgb(255, 255, 255)", 29 + headingFont: "Georgia, serif", 30 + bodyFont: "Verdana, sans-serif", 31 + pageWidth: 624, 32 + }; 33 + 34 + // Parse rgb()/rgba()/#hex into [r, g, b]. Returns black on parse failure — 35 + // theme colors come from a typed config so this is just defensive. 36 + const parseColor = (input: string): [number, number, number] => { 37 + const rgbMatch = input.match( 38 + /rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i, 39 + ); 40 + if (rgbMatch) 41 + return [ 42 + Number(rgbMatch[1]), 43 + Number(rgbMatch[2]), 44 + Number(rgbMatch[3]), 45 + ]; 46 + const hexMatch = input.match(/^#([0-9a-f]{6})$/i); 47 + if (hexMatch) { 48 + const h = hexMatch[1]; 49 + return [ 50 + parseInt(h.slice(0, 2), 16), 51 + parseInt(h.slice(2, 4), 16), 52 + parseInt(h.slice(4, 6), 16), 53 + ]; 54 + } 55 + const hex3 = input.match(/^#([0-9a-f]{3})$/i); 56 + if (hex3) { 57 + const h = hex3[1]; 58 + return [ 59 + parseInt(h[0] + h[0], 16), 60 + parseInt(h[1] + h[1], 16), 61 + parseInt(h[2] + h[2], 16), 62 + ]; 63 + } 64 + return [0, 0, 0]; 65 + }; 66 + 67 + // Linear sRGB mix of two colors. We resolve theme tints to literal rgb() at 68 + // render time because Gmail's CSS sanitizer drops `color-mix(...)` — 69 + // leaving borders invisible and tinted text falling back to defaults. 70 + // Linear mixing isn't perceptually identical to the oklab original, but 71 + // for the near-grayscale tints we use it's visually indistinguishable. 72 + export const mixRgb = ( 73 + a: string, 74 + b: string, 75 + bPercent: number, 76 + ): string => { 77 + const [ar, ag, ab] = parseColor(a); 78 + const [br, bg, bb] = parseColor(b); 79 + const t = bPercent / 100; 80 + const round = (x: number) => Math.round(x); 81 + return `rgb(${round(ar * (1 - t) + br * t)}, ${round( 82 + ag * (1 - t) + bg * t, 83 + )}, ${round(ab * (1 - t) + bb * t)})`; 84 + }; 85 + 86 + export type ResolvedColors = { 87 + primary: string; 88 + secondary: string; 89 + tertiary: string; 90 + border: string; 91 + borderLight: string; 92 + }; 93 + 94 + export const resolveColors = (theme: EmailTheme): ResolvedColors => ({ 95 + primary: theme.primary, 96 + secondary: mixRgb(theme.primary, theme.pageBackground, 25), 97 + tertiary: mixRgb(theme.primary, theme.pageBackground, 55), 98 + border: mixRgb(theme.primary, theme.pageBackground, 75), 99 + borderLight: mixRgb(theme.primary, theme.pageBackground, 85), 100 + }); 101 + 102 + // Postmark fetches `<img src>` and link `href` values verbatim from the 103 + // rendered HTML — relative paths break. Templates accept an `assetsBaseUrl` 104 + // prop and use this builder for `/email-assets/*` references. 105 + export const makeStaticUrl = (assetsBaseUrl: string) => { 106 + const base = assetsBaseUrl.replace(/\/$/, ""); 107 + return (filename: string): string => `${base}/email-assets/${filename}`; 108 + }; 109 + 110 + // React's TypeScript types don't include the deprecated `bgcolor` HTML 111 + // attribute on <td>, but every email client (especially Outlook) reads it 112 + // directly — so we spread it via a typed cast. Combined with an inline 113 + // background-color style for clients that do honor CSS, this is the 114 + // belt-and-suspenders pattern for reliable email backgrounds. 115 + export const bgcolorAttr = (color: string): Record<string, string> => ({ 116 + bgcolor: color, 117 + }); 118 + 119 + // Tailwind config shared by the Leaflet/publication confirm emails. 120 + // All `color-mix(in oklab, ...)` expressions are pre-resolved to plain 121 + // rgb() because Gmail's CSS sanitizer drops `color-mix()` — leaving 122 + // classes like `text-secondary` and `border-border` rendering with no 123 + // color in Gmail. 124 + const CONFIRM_PRIMARY = "rgb(39, 39, 39)"; 125 + const CONFIRM_PAGE_BG = "rgb(255, 255, 255)"; 126 + 127 + export const confirmEmailTailwindConfig = { 128 + presets: [pixelBasedPreset], 129 + theme: { 130 + screens: { 131 + sm: "640px", 132 + md: "960px", 133 + lg: "1280px", 134 + }, 135 + borderRadius: { 136 + none: "0", 137 + md: "0.25rem", 138 + lg: "0.5rem", 139 + full: "9999px", 140 + }, 141 + colors: { 142 + inherit: "inherit", 143 + transparent: "transparent", 144 + current: "currentColor", 145 + primary: CONFIRM_PRIMARY, 146 + secondary: mixRgb(CONFIRM_PRIMARY, CONFIRM_PAGE_BG, 25), 147 + tertiary: mixRgb(CONFIRM_PRIMARY, CONFIRM_PAGE_BG, 55), 148 + border: mixRgb(CONFIRM_PRIMARY, CONFIRM_PAGE_BG, 75), 149 + "border-light": mixRgb(CONFIRM_PRIMARY, CONFIRM_PAGE_BG, 85), 150 + white: "#FFFFFF", 151 + "accent-1": "rgb(0, 0, 225)", 152 + "accent-2": "rgb(255, 255, 255)", 153 + "accent-contrast": "rgb(0, 0, 225)", 154 + "bg-leaflet": "rgb(240, 247, 250)", 155 + "bg-page": "rgba(255, 255, 255, 1)", 156 + "highlight-1": "rgb(255, 177, 177)", 157 + "highlight-2": "rgb(253, 245, 203)", 158 + "highlight-3": "rgb(255, 205, 195)", 159 + }, 160 + fontSize: { 161 + xs: ".75rem", 162 + sm: ".875rem", 163 + base: "1rem", 164 + lg: "1.125rem", 165 + xl: "1.625rem", 166 + "2xl": "2rem", 167 + }, 168 + extend: { 169 + fontFamily: { 170 + sans: ["Verdana"], 171 + serif: ["Georgia"], 172 + }, 173 + }, 174 + }, 175 + }; 176 + 177 + // Wraps `<Head>` with the viewport meta that prevents iOS Mail / Gmail iOS 178 + // from auto-shrinking the email to fit a wider-than-viewport layout (which 179 + // makes every element render visually small). Templates can pass extra 180 + // children — usually a `<style>` block with template-specific @media 181 + // rules — and they'll be appended after the meta. 182 + export const MailHead = ({ 183 + children, 184 + }: { 185 + children?: React.ReactNode; 186 + }) => ( 187 + <Head> 188 + <meta 189 + name="viewport" 190 + content="width=device-width, initial-scale=1.0" 191 + /> 192 + {children} 193 + </Head> 194 + ); 195 + 196 + export const LeafletWatermark = ({ 197 + staticUrl, 198 + theme = defaultEmailTheme, 199 + }: { 200 + staticUrl?: (filename: string) => string; 201 + theme?: EmailTheme; 202 + } = {}) => { 203 + const c = resolveColors(theme); 204 + // Mail clients fetch <img src> verbatim, so the fallback must be absolute. 205 + const leafletSrc = staticUrl 206 + ? staticUrl("leaflet.png") 207 + : "https://leaflet.pub/email-assets/leaflet.png"; 208 + // Shrink-to-fit table with align="center" — the bulletproof email 209 + // pattern for centering a chunk of inline content within whatever 210 + // container it lands in (works inside both <td align="center"> in the 211 + // post template and a bare Body in the confirm emails). 212 + return ( 213 + <table 214 + role="presentation" 215 + align="center" 216 + cellPadding={0} 217 + cellSpacing={0} 218 + border={0} 219 + > 220 + <tbody> 221 + <tr> 222 + <td> 223 + <Link 224 + href="https://leaflet.pub" 225 + style={{ 226 + color: c.tertiary, 227 + fontFamily: theme.bodyFont, 228 + fontSize: 14, 229 + fontStyle: "italic", 230 + textDecoration: "none", 231 + }} 232 + > 233 + <Img 234 + src={leafletSrc} 235 + width={16} 236 + height={16} 237 + alt="" 238 + style={{ 239 + display: "inline-block", 240 + marginRight: 4, 241 + verticalAlign: "middle", 242 + }} 243 + /> 244 + <span style={{ verticalAlign: "middle" }}> 245 + Published with{" "} 246 + <span 247 + style={{ 248 + color: theme.accentBackground, 249 + fontWeight: "bold", 250 + }} 251 + > 252 + Leaflet 253 + </span> 254 + </span> 255 + </Link> 256 + </td> 257 + </tr> 258 + </tbody> 259 + </table> 260 + ); 261 + }; 262 +