, 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 `