From cf41d6b39ac8c0d415e080b66781e4379273cc4f Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Mon, 27 Apr 2026 15:35:30 -0400 Subject: [PATCH] unify email templates --- emails/fromPublication.ts | 2 +- emails/leafletConfirmEmail.tsx | 95 ++---------- emails/post.tsx | 247 ++++++------------------------- emails/pubConfirmEmail.tsx | 94 ++---------- emails/shared.tsx | 262 +++++++++++++++++++++++++++++++++ 5 files changed, 329 insertions(+), 371 deletions(-) create mode 100644 emails/shared.tsx diff --git a/emails/fromPublication.ts b/emails/fromPublication.ts index 645175a8..555ecb68 100644 --- a/emails/fromPublication.ts +++ b/emails/fromPublication.ts @@ -1,4 +1,4 @@ -import { defaultEmailTheme, type EmailTheme } from "./post"; +import { defaultEmailTheme, type EmailTheme } from "./shared"; import type { PubLeafletPublication } from "lexicons/api"; import { getFontConfig, getFontFamilyValue } from "src/fonts"; diff --git a/emails/leafletConfirmEmail.tsx b/emails/leafletConfirmEmail.tsx index 76bd42e3..3333abd3 100644 --- a/emails/leafletConfirmEmail.tsx +++ b/emails/leafletConfirmEmail.tsx @@ -2,102 +2,29 @@ import { Body, Column, Container, - Head, - Hr, Html, Img, - Link, - Section, Row, - Button, - CodeBlock, } from "@react-email/components"; +import { Tailwind } from "@react-email/components"; import { Text, Heading } from "./post"; -import { Tailwind, pixelBasedPreset } from "@react-email/components"; -import { LeafletWatermark } from "./post"; +import { + confirmEmailTailwindConfig, + MailHead, + makeStaticUrl, +} from "./shared"; export const LeafletConfirmEmail = (props: { code?: string; assetsBaseUrl?: string; }) => { - const base = (props.assetsBaseUrl ?? "https://leaflet.pub").replace( - /\/$/, - "", - ); - const leafletSrc = `${base}/email-assets/leaflet.png`; + const leafletSrc = makeStaticUrl( + props.assetsBaseUrl ?? "https://leaflet.pub", + )("leaflet.png"); return ( - - + + { - 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 accent text fall 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. -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)})`; -}; - -type ResolvedColors = { - primary: string; - secondary: string; - tertiary: string; - border: string; - borderLight: string; -}; - -const resolveColors = (theme: EmailTheme): ResolvedColors => ({ - primary: theme.primary, - secondary: mixRgb(theme.primary, theme.pageBackground, 25), - tertiary: mixRgb(theme.primary, theme.pageBackground, 55), - border: mixRgb(theme.primary, theme.pageBackground, 75), - borderLight: mixRgb(theme.primary, theme.pageBackground, 85), -}); +import { + bgcolorAttr, + defaultEmailTheme, + LeafletWatermark, + MailHead, + makeStaticUrl, + resolveColors, + type EmailTheme, + type ResolvedColors, +} from "./shared"; export type PostEmailProps = { publicationName: string; @@ -254,15 +170,22 @@ const defaultProps: PostEmailProps = { ], }; -const BLOCK_MARGIN = "4px 0 16px"; -const HEADING_MARGIN = "4px 0 0"; +const BLOCK_MARGIN_TOP = 4; +const BLOCK_MARGIN_BOTTOM = 16; +const BLOCK_MARGIN = `${BLOCK_MARGIN_TOP}px 0 ${BLOCK_MARGIN_BOTTOM}px`; +const IMAGE_MARGIN = `${BLOCK_MARGIN_TOP}px auto ${BLOCK_MARGIN_BOTTOM}px`; +const HEADING_MARGIN = `${BLOCK_MARGIN_TOP}px 0 0`; +const HEADING_FONT_SIZE_PX: Record<1 | 2 | 3, number> = { + 1: 26, + 2: 18, + 3: 16, +}; export const PostEmail = (props: Partial = {}) => { const p: PostEmailProps = { ...defaultProps, ...props }; const theme = p.theme ?? defaultEmailTheme; const c = resolveColors(theme); - const staticUrl = (filename: string) => - `${p.assetsBaseUrl.replace(/\/$/, "")}/email-assets/${filename}`; + const staticUrl = makeStaticUrl(p.assetsBaseUrl); const byline = [p.authorName, p.publishedAtLabel].filter(Boolean).join(" | "); const accentLink: CSSProperties = { @@ -271,25 +194,9 @@ export const PostEmail = (props: Partial = {}) => { fontFamily: theme.bodyFont, }; - // Page width as both an HTML width attribute (px) and a CSS max-width. - // Gmail strips/ignores `max-width` in some contexts but always honors the - // HTML `width` attribute on a , so we set both: the HTML `width` - // pins the box for Gmail, and `maxWidth: 100%` lets it shrink on narrow - // viewports. The value mirrors the publication's web page width. - const pageWidth = theme.pageWidth; - return ( - - {/* Without this, iOS Mail / Gmail iOS auto-scale the email down to - fit a wider-than-viewport layout (e.g. 624px in a 400px screen) - — which makes every element appear "too small". With it, we get - 1:1 pixel sizing and our @media queries below can shrink the - card to viewport width directly. */} - + {/* Mobile-only padding tightening. Apple Mail, iOS Mail, Gmail web, and most other clients honor @media in ; Outlook desktop ignores it and keeps the wider inline padding, which is fine @@ -308,7 +215,7 @@ export const PostEmail = (props: Partial = {}) => { } } `} - + = {}) => {
)} + {...bgcolorAttr(theme.backgroundColor)} style={{ backgroundColor: theme.backgroundColor, padding: "24px 16px", }} > + {/* Both the HTML `width` attribute and CSS `width` are set: + Gmail strips/ignores `max-width` in some contexts but + always honors the HTML attribute, so it pins the box at + the publication's web page width on desktop. The + media-query rule above forces 100% on small screens. */}
)} + {...bgcolorAttr(theme.pageBackground)} style={{ backgroundColor: theme.pageBackground, border: `1px solid ${c.border}`, @@ -636,18 +545,18 @@ const BlockRenderer = ({ ); } if (PubLeafletBlocksHeader.isMain(block)) { - const raw = Math.floor(block.level ?? 1); - const clamped = (raw < 1 ? 1 : raw > 3 ? 3 : raw) as 1 | 2 | 3; - const fontSize = clamped === 1 ? 26 : clamped === 2 ? 18 : 16; - const color = clamped === 3 ? colors.secondary : theme.primary; + const level = Math.min( + 3, + Math.max(1, Math.floor(block.level ?? 1)), + ) as 1 | 2 | 3; return ( ; }; -export const LeafletWatermark = ({ - staticUrl, - theme = defaultEmailTheme, -}: { - staticUrl?: (filename: string) => string; - theme?: EmailTheme; -} = {}) => { - const c = resolveColors(theme); - const leafletSrc = staticUrl - ? staticUrl("leaflet.png") - : "/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. - return ( - - - - - - -
- - - - Published with{" "} - - Leaflet - - - -
- ); -}; - -// Backwards-compatible helpers used by `leafletConfirmEmail.tsx` and -// `pubConfirmEmail.tsx`, which wrap themselves in their own -// context. We keep layout (margin/font-size/line-height) inline so the -// helpers still look right outside Tailwind, but leave color/font-family -// to the caller (via className inside Tailwind, or a `style` override). +// Helpers used by the confirm-email templates inside their +// context. Layout (margin/font-size/line-height) is inline so they render +// without Tailwind too; color and font-family are left to the caller via +// className or a `style` override so Tailwind classes can win. export const Text = (props: { children: React.ReactNode; noPadding?: boolean; diff --git a/emails/pubConfirmEmail.tsx b/emails/pubConfirmEmail.tsx index 5ab75754..bbe13b4d 100644 --- a/emails/pubConfirmEmail.tsx +++ b/emails/pubConfirmEmail.tsx @@ -2,102 +2,30 @@ import { Body, Column, Container, - Head, - Hr, Html, Img, - Link, - Section, Row, - Button, - CodeBlock, + Tailwind, } from "@react-email/components"; import { Text, Heading } from "./post"; -import { Tailwind, pixelBasedPreset } from "@react-email/components"; -import { LeafletWatermark } from "./post"; +import { + confirmEmailTailwindConfig, + LeafletWatermark, + MailHead, + makeStaticUrl, +} from "./shared"; export const PubConfirmEmail = (props: { code?: string; assetsBaseUrl?: string; }) => { - const base = (props.assetsBaseUrl ?? "https://leaflet.pub").replace( - /\/$/, - "", + const staticUrl = makeStaticUrl( + props.assetsBaseUrl ?? "https://leaflet.pub", ); - const staticUrl = (filename: string) => `${base}/email-assets/${filename}`; return ( - - + + { + 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 => ({ + primary: theme.primary, + secondary: mixRgb(theme.primary, theme.pageBackground, 25), + tertiary: mixRgb(theme.primary, theme.pageBackground, 55), + border: mixRgb(theme.primary, theme.pageBackground, 75), + borderLight: mixRgb(theme.primary, theme.pageBackground, 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}`; +}; + +// 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(0, 0, 225)", + "accent-2": "rgb(255, 255, 255)", + "accent-contrast": "rgb(0, 0, 225)", + "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 `` 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 `