import { Body, Column, Heading as ReactEmailHeading, Hr, Html, Img, Link, Text as ReactEmailText, Section, Row, CodeBlock as ReactEmailCodeBlock, dracula, } from "@react-email/components"; import type { PrismLanguage } from "@react-email/code-block"; import React, { type CSSProperties } from "react"; import { PubLeafletBlocksBlockquote, PubLeafletBlocksCode, PubLeafletBlocksHeader, PubLeafletBlocksHorizontalRule, PubLeafletBlocksImage, PubLeafletBlocksOrderedList, PubLeafletBlocksText, PubLeafletBlocksUnorderedList, PubLeafletBlocksWebsite, PubLeafletPagesLinearDocument, } from "lexicons/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { bgcolorAttr, defaultEmailTheme, LeafletWatermark, MailHead, makeStaticUrl, resolveColors, type EmailTheme, type ResolvedColors, } from "./shared"; export type PostEmailProps = { publicationName: string; publicationUrl: string; postTitle: string; postDescription?: string; postUrl: string; authorName?: string; publishedAtLabel?: string; blocks: PubLeafletPagesLinearDocument.Block[]; /** * Used to build absolute URLs for `pub.leaflet.blocks.image` blob refs via * the /api/atproto_images proxy. Preview renders from drafts may encode a * direct URL in the blob ref's $link instead — we pass those through. */ did: string; /** * If omitted the template renders a "(preview)" footer in place of the * real unsubscribe link — used by the Phase 4 preview-send path. */ unsubscribeUrl?: string; /** * Absolute URL used to resolve bundled /email-assets/* images. Postmark * fetches `` verbatim so these must be absolute in outgoing mail. */ assetsBaseUrl: string; theme?: EmailTheme; }; const drawerUrl = (base: string, drawer: "quotes" | "comments") => { const sep = base.includes("?") ? "&" : "?"; return `${base}${sep}interactionDrawer=${drawer}`; }; const textBlock = (plaintext: string): PubLeafletPagesLinearDocument.Block => ({ $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.text", plaintext }, }); const headingBlock = ( plaintext: string, level: number, ): PubLeafletPagesLinearDocument.Block => ({ $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.header", plaintext, level }, }); const defaultProps: PostEmailProps = { publicationName: "Publication", publicationUrl: "https://leaflet.pub", postTitle: "Post Title Here", postDescription: "Hello this is a description of everything that is to come", postUrl: "https://leaflet.pub", authorName: "author", publishedAtLabel: "Jan 1, 2026", assetsBaseUrl: "https://leaflet.pub/", did: "did:plc:example", blocks: [ textBlock( "This would be the post. I'll give it a little lorem ipsum to make it look longer so i don't forget which thing is what.", ), headingBlock("This is a Title", 1), textBlock( "We'll keep it nice and separate so we can see what it looks like.", ), headingBlock("And a Header", 2), { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.unorderedList", children: [ { content: { $type: "pub.leaflet.blocks.text", plaintext: "fruits" }, children: [ { content: { $type: "pub.leaflet.blocks.text", plaintext: "apple", }, }, { content: { $type: "pub.leaflet.blocks.text", plaintext: "banana", }, }, ], }, { content: { $type: "pub.leaflet.blocks.text", plaintext: "veggies", }, }, ], }, }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.blockquote", plaintext: "A quote of some kind.", }, }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.code", plaintext: "const x = 1;", language: "javascript", }, }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.website", src: "https://example.com", title: "Link Title Here", description: "Description on the link", }, }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.horizontalRule" }, }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.math" }, }, ], }; 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 = makeStaticUrl(p.assetsBaseUrl); const byline = [p.authorName, p.publishedAtLabel].filter(Boolean).join(" | "); const accentLink: CSSProperties = { color: theme.accentBackground, textDecoration: "none", fontFamily: theme.bodyFont, }; return ( {/* 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 (it's a desktop client). !important is required so the media-query rule beats the inline `style.padding`. */} {/* Page-background wrapper. Gmail strips/replaces styling, so we paint the background on this table's using both the bgcolor HTML attribute (Outlook/Gmail bulletproof) and a backgroundColor style (everything else). */}
{/* 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. */}
{p.publicationName} {p.postTitle} {p.postDescription ? ( {p.postDescription} ) : null} {byline ? (
{byline} See quotes See comments Open post
) : null} {p.blocks.map((b, i) => ( ))} {/* Footer: Gmail won't reliably cascade `text-align` from a wrapping , so each centered row is its own {/* Spacer */} {/* Horizontal rule between card and watermark.
margins are flaky in Gmail, so we use a 1px-tall
— the bulletproof email-centering pattern. `min-width: 100%` keeps Gmail iOS from shrink-wrapping the table around the short link text. */}
See Full Post
{p.unsubscribeUrl ? ( Unsubscribe ) : ( (preview — not sent to subscribers) )}
 
with border-top instead. */}
 
 
); }; export default PostEmail; const BlockRenderer = ({ block, did, assetsBaseUrl, theme, colors, }: { block: PubLeafletPagesLinearDocument.Block["block"]; did: string; assetsBaseUrl: string; theme: EmailTheme; colors: ResolvedColors; }) => { if (PubLeafletBlocksText.isMain(block)) { return ( {block.plaintext || " "} ); } if (PubLeafletBlocksHeader.isMain(block)) { const level = Math.min( 3, Math.max(1, Math.floor(block.level ?? 1)), ) as 1 | 2 | 3; return ( {block.plaintext} ); } if (PubLeafletBlocksBlockquote.isMain(block)) { return (
{block.plaintext}
); } if (PubLeafletBlocksCode.isMain(block)) { return ( ); } if (PubLeafletBlocksImage.isMain(block)) { const src = blobRefToSrc(block.image.ref, did, assetsBaseUrl); // Deliberately no numeric `width`/`height` HTML attributes: Outlook // honors those over CSS `max-width`, so a 1200px natural-size photo // would blow out our 28rem container. `max-width: px` keeps // smaller images from being upscaled. const naturalWidth = block.aspectRatio?.width; return ( {block.alt ); } if (PubLeafletBlocksWebsite.isMain(block)) { const previewSrc = block.previewImage ? blobRefToSrc(block.previewImage.ref, did, assetsBaseUrl) : undefined; return ( ); } if (PubLeafletBlocksHorizontalRule.isMain(block)) { return (
); } if (PubLeafletBlocksUnorderedList.isMain(block)) { return ( ); } if (PubLeafletBlocksOrderedList.isMain(block)) { return ( ); } return ; }; // 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; small?: boolean; className?: string; style?: CSSProperties; }) => { const fontSize = props.small ? 14 : 16; return ( {props.children} ); }; export const Heading = (props: { children: React.ReactNode; noPadding?: boolean; as: "h1" | "h2" | "h3"; className?: string; style?: CSSProperties; }) => { const fontSize = props.as === "h1" ? 26 : props.as === "h2" ? 18 : 16; return ( {props.children} ); }; type ListItem = | PubLeafletBlocksUnorderedList.ListItem | PubLeafletBlocksOrderedList.ListItem; const listItemPlaintext = (item: ListItem): string => { const content = item.content; if (PubLeafletBlocksText.isMain(content)) return content.plaintext; if (PubLeafletBlocksHeader.isMain(content)) return content.plaintext; return ""; }; export const List = ({ items, style, did, assetsBaseUrl, theme = defaultEmailTheme, }: { items: ListItem[]; style: "ordered" | "unordered"; did: string; assetsBaseUrl: string; theme?: EmailTheme; }) => { const Tag = style === "ordered" ? "ol" : "ul"; return (
{items.map((item, i) => { const plaintext = listItemPlaintext(item); const nestedUnordered = item.children && style === "unordered" ? (item.children as PubLeafletBlocksUnorderedList.ListItem[]) : (item as PubLeafletBlocksOrderedList.ListItem) .unorderedListChildren?.children; const nestedOrdered = item.children && style === "ordered" ? (item.children as PubLeafletBlocksOrderedList.ListItem[]) : (item as PubLeafletBlocksUnorderedList.ListItem) .orderedListChildren?.children; return (
  • {typeof item.checked === "boolean" ? `${item.checked ? "☑ " : "☐ "}${plaintext}` : plaintext}
  • {nestedUnordered && nestedUnordered.length > 0 ? ( ) : null} {nestedOrdered && nestedOrdered.length > 0 ? ( ) : null}
    ); })}
    ); }; export const LinkBlock = ({ url, title, description, previewSrc, theme = defaultEmailTheme, colors, }: { url?: string; title?: string; description?: string; previewSrc?: string; theme?: EmailTheme; colors?: ResolvedColors; } = {}) => { const c = colors ?? resolveColors(theme); const displayUrl = (() => { if (!url) return "www.example.com"; try { return new URL(url).hostname; } catch { return url; } })(); return (
    {title || displayUrl} {description ? ( {description} ) : null} {displayUrl} {previewSrc ? ( ) : null}
    ); }; export const CodeBlock = ({ code, language, borderColor, }: { code?: string; language?: string; borderColor?: string; } = {}) => { return ( defaults to `white-space: pre`, so long lines blow past the // card on mobile. Wrap, and break long tokens (URLs, identifiers) // when nothing else fits. overflow: "hidden", padding: 8, whiteSpace: "pre-wrap", wordBreak: "break-word", }} code={code ?? ""} theme={dracula} language={(language as PrismLanguage) || "text"} /> ); }; export const BlockNotSupported = ({ theme = defaultEmailTheme, colors, }: { theme?: EmailTheme; colors?: ResolvedColors; } = {}) => { const c = colors ?? resolveColors(theme); return (
    This media isn't supported in email... See full post
    ); };