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. */}