Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
TSX
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000import { 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 `<img src>` 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<PostEmailProps> = {}) => { 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 ( <Html> <MailHead> {/* Mobile-only padding tightening. Apple Mail, iOS Mail, Gmail web, and most other clients honor @media in <head>; 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`. */} <style>{` @media only screen and (max-width: 480px) { .email-page-pad { padding: 12px 8px !important; } .email-card-pad { padding: 16px !important; } /* The card table carries an HTML width attribute (e.g. 624) so Gmail anchors on it; on mobile that pins it wider than the viewport. Force it to fit. */ .email-card-table { width: 100% !important; max-width: 100% !important; } } `}</style> </MailHead> <Body style={{ backgroundColor: theme.backgroundColor, color: theme.primary, fontFamily: theme.bodyFont, margin: 0, padding: 0, }} > {/* Page-background wrapper. Gmail strips/replaces <body> styling, so we paint the background on this table's <td> using both the bgcolor HTML attribute (Outlook/Gmail bulletproof) and a backgroundColor style (everything else). */} <table role="presentation" width="100%" cellPadding={0} cellSpacing={0} border={0} bgcolor={theme.backgroundColor} style={{ width: "100%", backgroundColor: theme.backgroundColor, }} > <tbody> <tr> <td align="center" className="email-page-pad" {...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. */} <table role="presentation" align="center" className="email-card-table" width={theme.pageWidth} cellPadding={0} cellSpacing={0} border={0} style={{ width: theme.pageWidth, maxWidth: "100%" }} > <tbody> <tr> <td className="email-card-pad" {...bgcolorAttr(theme.pageBackground)} style={{ backgroundColor: theme.pageBackground, border: `1px solid ${c.border}`, borderRadius: 8, padding: "20px 24px", }} > <Link href={p.publicationUrl} style={{ ...accentLink, fontWeight: "bold", fontSize: 16, }} > {p.publicationName} </Link>
<ReactEmailHeading as="h1" style={{ color: theme.primary, fontFamily: theme.headingFont, fontWeight: "bold", fontSize: 26, lineHeight: 1.2, margin: "8px 0 0", }} > {p.postTitle} </ReactEmailHeading>
{p.postDescription ? ( <ReactEmailText style={{ color: c.secondary, fontFamily: theme.bodyFont, fontSize: 16, fontStyle: "italic", lineHeight: 1.4, margin: "4px 0 0", }} > {p.postDescription} </ReactEmailText> ) : null}
{byline ? ( <Section style={{ margin: "12px 0 28px", minWidth: "100%" }} > <Row style={{ minWidth: "100%" }}> <Column style={{ verticalAlign: "middle" }}> <ReactEmailText style={{ color: c.tertiary, fontFamily: theme.bodyFont, fontSize: 14, lineHeight: 1.4, margin: 0, }} > {byline} </ReactEmailText> </Column> <Column style={{ width: 12 }} /> <Column style={{ width: 16, verticalAlign: "middle" }}> <Link href={drawerUrl(p.postUrl, "quotes")} style={accentLink} > <Img width={16} height={16} src={staticUrl("quote.png")} alt="See quotes" /> </Link> </Column> <Column style={{ width: 8 }} /> <Column style={{ width: 16, verticalAlign: "middle" }}> <Link href={drawerUrl(p.postUrl, "comments")} style={accentLink} > <Img width={16} height={16} src={staticUrl("comment.png")} alt="See comments" /> </Link> </Column> <Column style={{ width: 10 }} /> <Column style={{ width: 16, verticalAlign: "middle" }}> <Link href={p.postUrl} style={accentLink}> <Img width={16} height={16} src={staticUrl("external-link.png")} alt="Open post" /> </Link> </Column> </Row> </Section> ) : null}
{p.blocks.map((b, i) => ( <BlockRenderer key={i} block={b.block} did={p.did} assetsBaseUrl={p.assetsBaseUrl} theme={theme} colors={c} /> ))}
{/* Footer: Gmail won't reliably cascade `text-align` from a wrapping <table>, so each centered row is its own <td align="center"> — the bulletproof email-centering pattern. `min-width: 100%` keeps Gmail iOS from shrink-wrapping the table around the short link text. */} <table role="presentation" width="100%" cellPadding={0} cellSpacing={0} border={0} style={{ width: "100%", minWidth: "100%" }} > <tbody> <tr> <td align="center" style={{ paddingTop: 16 }}> <Link href={p.postUrl} style={{ ...accentLink, fontWeight: "bold", fontSize: 14, lineHeight: "20px", }} > See Full Post </Link> </td> </tr> <tr> <td align="center" style={{ color: c.tertiary, fontFamily: theme.bodyFont, fontSize: 14, lineHeight: "20px", paddingTop: 8, }} > {p.unsubscribeUrl ? ( <Link href={p.unsubscribeUrl} style={{ color: c.tertiary, fontSize: 14, lineHeight: "20px", textDecoration: "underline", }} > Unsubscribe </Link> ) : ( <span style={{ fontStyle: "italic" }}> (preview — not sent to subscribers) </span> )} </td> </tr> </tbody> </table> </td> </tr>
{/* Spacer */} <tr> <td style={{ fontSize: 0, height: 12, lineHeight: "12px", }} > </td> </tr>
{/* Horizontal rule between card and watermark. <hr> margins are flaky in Gmail, so we use a 1px-tall <td> with border-top instead. */} <tr> <td style={{ borderTop: `1px solid ${c.borderLight}`, fontSize: 0, height: 1, lineHeight: "1px", }} > </td> </tr>
<tr> <td style={{ fontSize: 0, height: 12, lineHeight: "12px", }} > </td> </tr>
<tr> <td align="center"> <LeafletWatermark theme={theme} staticUrl={staticUrl} /> </td> </tr> </tbody> </table> </td> </tr> </tbody> </table> </Body> </Html> );};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 ( <ReactEmailText style={{ color: theme.primary, fontFamily: theme.bodyFont, fontSize: 16, lineHeight: 1.5, margin: BLOCK_MARGIN, }} > {block.plaintext || " "} </ReactEmailText> ); } if (PubLeafletBlocksHeader.isMain(block)) { const level = Math.min( 3, Math.max(1, Math.floor(block.level ?? 1)), ) as 1 | 2 | 3; return ( <ReactEmailHeading as={`h${level}`} style={{ color: level === 3 ? colors.secondary : theme.primary, fontFamily: theme.headingFont, fontWeight: "bold", fontSize: HEADING_FONT_SIZE_PX[level], lineHeight: 1.25, margin: HEADING_MARGIN, }} > {block.plaintext} </ReactEmailHeading> ); } if (PubLeafletBlocksBlockquote.isMain(block)) { return ( <Section style={{ margin: BLOCK_MARGIN }}> <Row> <Column style={{ width: 2, backgroundColor: colors.border }} /> <Column style={{ width: 8 }} /> <Column> <ReactEmailText style={{ color: theme.primary, fontFamily: theme.bodyFont, fontSize: 16, lineHeight: 1.5, margin: "2px 0", }} > {block.plaintext} </ReactEmailText> </Column> </Row> </Section> ); } if (PubLeafletBlocksCode.isMain(block)) { return ( <CodeBlock code={block.plaintext} language={block.language} borderColor={colors.borderLight} /> ); } 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: <natural>px` keeps // smaller images from being upscaled. const naturalWidth = block.aspectRatio?.width; return ( <Img src={src} alt={block.alt ?? ""} style={{ display: "block", height: "auto", margin: IMAGE_MARGIN, maxWidth: naturalWidth ? `${naturalWidth}px` : "100%", width: "100%", }} /> ); } if (PubLeafletBlocksWebsite.isMain(block)) { const previewSrc = block.previewImage ? blobRefToSrc(block.previewImage.ref, did, assetsBaseUrl) : undefined; return ( <LinkBlock url={block.src} title={block.title} description={block.description} previewSrc={previewSrc} theme={theme} colors={colors} /> ); } if (PubLeafletBlocksHorizontalRule.isMain(block)) { return ( <Hr style={{ border: "none", borderTop: `1px solid ${colors.borderLight}`, margin: "12px 0", width: "100%", }} /> ); } if (PubLeafletBlocksUnorderedList.isMain(block)) { return ( <List items={block.children} style="unordered" did={did} assetsBaseUrl={assetsBaseUrl} theme={theme} /> ); } if (PubLeafletBlocksOrderedList.isMain(block)) { return ( <List items={block.children} style="ordered" did={did} assetsBaseUrl={assetsBaseUrl} theme={theme} /> ); } return <BlockNotSupported theme={theme} colors={colors} />;};
// Helpers used by the confirm-email templates inside their <Tailwind>// 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 ( <ReactEmailText className={props.className} style={{ fontSize, lineHeight: 1.5, margin: props.noPadding ? 0 : BLOCK_MARGIN, ...props.style, }} > {props.children} </ReactEmailText> );};
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 ( <ReactEmailHeading as={props.as} className={props.className} style={{ fontWeight: "bold", fontSize, lineHeight: 1.25, margin: props.noPadding ? 0 : HEADING_MARGIN, ...props.style, }} > {props.children} </ReactEmailHeading> );};
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 ( <Section style={{ margin: BLOCK_MARGIN }}> <Tag style={{ color: theme.primary, fontFamily: theme.bodyFont, fontSize: 16, lineHeight: 1.5, margin: 0, paddingLeft: 24, }} > {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 ( <React.Fragment key={i}> <li style={{ margin: "2px 0", paddingLeft: 4 }}> {typeof item.checked === "boolean" ? `${item.checked ? "☑ " : "☐ "}${plaintext}` : plaintext} </li> {nestedUnordered && nestedUnordered.length > 0 ? ( <List items={nestedUnordered} style="unordered" did={did} assetsBaseUrl={assetsBaseUrl} theme={theme} /> ) : null} {nestedOrdered && nestedOrdered.length > 0 ? ( <List items={nestedOrdered} style="ordered" did={did} assetsBaseUrl={assetsBaseUrl} theme={theme} /> ) : null} </React.Fragment> ); })} </Tag> </Section> );};
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 ( <Section style={{ margin: BLOCK_MARGIN, minWidth: "100%" }}> <Row style={{ border: `1px solid ${theme.accentBackground}`, borderRadius: 8, minWidth: "100%", }} > <Column style={{ padding: "8px 12px", verticalAlign: "top", // Long URL-like titles or domains have no break opportunities // and would otherwise force the column wider than the card. wordBreak: "break-word", }} > <Link href={url} style={{ color: theme.primary, fontFamily: theme.bodyFont, fontWeight: "bold", fontSize: 16, textDecoration: "none", wordBreak: "break-word", }} > {title || displayUrl} </Link> {description ? ( <ReactEmailText style={{ color: c.secondary, fontFamily: theme.bodyFont, fontSize: 16, lineHeight: 1.4, margin: "4px 0 0", wordBreak: "break-word", }} > {description} </ReactEmailText> ) : null} <ReactEmailText style={{ color: theme.accentBackground, fontFamily: theme.bodyFont, fontSize: 14, fontStyle: "italic", lineHeight: 1.4, margin: "4px 0 0", wordBreak: "break-word", }} > {displayUrl} </ReactEmailText> </Column> {previewSrc ? ( <Column style={{ padding: "8px 8px 8px 0", verticalAlign: "top", width: 112, }} > <Img src={previewSrc} alt="" style={{ borderRadius: 4, display: "block", height: 88, objectFit: "cover", width: "100%", }} /> </Column> ) : null} </Row> </Section> );};
export const CodeBlock = ({ code, language, borderColor,}: { code?: string; language?: string; borderColor?: string;} = {}) => { return ( <ReactEmailCodeBlock style={{ border: `1px solid ${borderColor ?? "rgba(0, 0, 0, 0.1)"}`, borderRadius: 4, boxSizing: "border-box", margin: BLOCK_MARGIN, maxWidth: "100%", // <pre> 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 ( <Section style={{ backgroundColor: c.borderLight, borderRadius: 4, margin: BLOCK_MARGIN, padding: "16px 12px", }} > <ReactEmailText style={{ color: c.tertiary, fontFamily: theme.bodyFont, fontSize: 14, fontStyle: "italic", lineHeight: 1.4, margin: 0, textAlign: "center", }} > This media isn't supported in email... </ReactEmailText> <ReactEmailText style={{ fontSize: 14, lineHeight: 1.4, margin: "4px 0 0", textAlign: "center", }} > <Link style={{ color: theme.accentBackground, fontWeight: "bold", textDecoration: "none", }} > See full post </Link> </ReactEmailText> </Section> );};