diff --git a/actions/publications/sendPostPreview.tsx b/actions/publications/sendPostPreview.tsx new file mode 100644 index 00000000..e6a8bc07 --- /dev/null +++ b/actions/publications/sendPostPreview.tsx @@ -0,0 +1,111 @@ +"use server"; + +import { render } from "@react-email/render"; +import { getIdentityData } from "actions/getIdentityData"; +import { supabaseServerClient } from "supabase/serverClient"; +import { getCurrentDeploymentDomain } from "src/utils/getCurrentDeploymentDomain"; +import { Ok, Err, type Result } from "src/result"; +import { EMAIL_REGEX } from "src/utils/confirmationEmail"; +import { extractEmailBlocksFromFacts } from "src/utils/postToEmailBlocks"; +import type { Fact } from "src/replicache"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { PostEmail } from "emails/post"; + +type SendPreviewError = + | "unauthorized" + | "invalid_email" + | "publication_not_found" + | "newsletter_not_enabled" + | "render_failed" + | "email_send_failed"; + +export async function sendPostPreview(args: { + publication_uri: string; + root_entity: string; + title: string; + description?: string; + to: string; +}): Promise> { + const identity = await getIdentityData(); + if (!identity?.atp_did) return Err("unauthorized"); + + const email = args.to.trim().toLowerCase(); + if (!EMAIL_REGEX.test(email)) return Err("invalid_email"); + + const { data: publication } = await supabaseServerClient + .from("publications") + .select( + "identity_did, record, publication_newsletter_settings(enabled, reply_to_email, reply_to_verified_at)", + ) + .eq("uri", args.publication_uri) + .single(); + + if (!publication) return Err("publication_not_found"); + if (publication.identity_did !== identity.atp_did) return Err("unauthorized"); + + const settings = publication.publication_newsletter_settings; + if (!settings?.enabled || !settings.reply_to_email) { + return Err("newsletter_not_enabled"); + } + + const { data: factsData } = await supabaseServerClient.rpc("get_facts", { + root: args.root_entity, + }); + const facts = (factsData as unknown as Fact[]) || []; + + const blocks = extractEmailBlocksFromFacts(facts, args.root_entity); + + const pubRecord = normalizePublicationRecord(publication.record); + const publicationName = pubRecord?.name || "Publication"; + const publicationUrl = pubRecord?.url || "https://leaflet.pub"; + + const assetsBaseUrl = await getCurrentDeploymentDomain(); + + let html: string; + try { + html = await render( + PostEmail({ + publicationName, + publicationUrl, + postTitle: args.title || "(untitled)", + postDescription: args.description, + postUrl: publicationUrl, + authorName: identity.bsky_profiles?.handle ?? undefined, + publishedAtLabel: new Date().toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }), + blocks, + assetsBaseUrl, + // Omitting unsubscribeUrl triggers the "(preview)" footer branch. + }), + ); + } catch (e) { + console.error("[sendPostPreview] render failed:", e); + return Err("render_failed"); + } + + const res = await fetch("https://api.postmarkapp.com/email", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, + }, + body: JSON.stringify({ + MessageStream: "outbound", + From: "Leaflet ", + ReplyTo: settings.reply_to_email, + To: email, + Subject: `[preview] ${args.title || "(untitled)"}`, + HtmlBody: html, + }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + console.error("[sendPostPreview] postmark failed:", res.status, body); + return Err("email_send_failed"); + } + + return Ok(null); +} diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx index d5ec5393..4e7c83ed 100644 --- a/app/[leaflet_id]/publish/PublishPost.tsx +++ b/app/[leaflet_id]/publish/PublishPost.tsx @@ -41,6 +41,7 @@ type Props = { publication_uri?: string; record?: NormalizedPublication | null; posts_in_pub?: number; + newsletter_enabled?: boolean; entitiesToDelete?: string[]; hasDraft: boolean; }; @@ -292,6 +293,9 @@ const PublishPostForm = ( profile={props.profile} description={props.description} record={props.record} + newsletter_enabled={props.newsletter_enabled} + publication_uri={props.publication_uri} + root_entity={props.root_entity} />
diff --git a/app/[leaflet_id]/publish/ShareOptions.tsx b/app/[leaflet_id]/publish/ShareOptions.tsx index 269a0078..65aa0a8c 100644 --- a/app/[leaflet_id]/publish/ShareOptions.tsx +++ b/app/[leaflet_id]/publish/ShareOptions.tsx @@ -4,6 +4,10 @@ import { BlueskyPostEditorProsemirror } from "./BskyPostEditorProsemirror"; import { EditorState } from "prosemirror-state"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import type { NormalizedPublication } from "src/utils/normalizeRecords"; +import { useState } from "react"; +import { sendPostPreview } from "actions/publications/sendPostPreview"; +import { ButtonSecondary } from "components/Buttons"; +import { DotLoader } from "components/utils/DotLoader"; export type ShareState = { bluesky: boolean; @@ -23,6 +27,9 @@ type Props = { description: string; record?: NormalizedPublication | null; subscriberCount?: number; + newsletter_enabled?: boolean; + publication_uri?: string; + root_entity: string; }; export function ShareOptions(props: Props) { @@ -64,6 +71,14 @@ export function ShareOptions(props: Props) { + {props.newsletter_enabled && props.publication_uri ? ( + + ) : null} ); } + +function EmailPreview(props: { + publication_uri: string; + root_entity: string; + title: string; + description: string; +}) { + let [email, setEmail] = useState(""); + let [status, setStatus] = useState< + | { state: "idle" } + | { state: "sending" } + | { state: "sent" } + | { state: "error"; message: string } + >({ state: "idle" }); + + const errorCopy = (code: string) => { + switch (code) { + case "unauthorized": + return "You don't have permission to send a preview for this publication."; + case "invalid_email": + return "That email address doesn't look right."; + case "newsletter_not_enabled": + return "Newsletter mode isn't enabled for this publication."; + case "render_failed": + return "Couldn't render the email. Try again?"; + case "email_send_failed": + return "Postmark rejected the send. Try again later."; + default: + return "Something went wrong sending the preview."; + } + }; + + const submit = async () => { + if (!email) return; + setStatus({ state: "sending" }); + const res = await sendPostPreview({ + publication_uri: props.publication_uri, + root_entity: props.root_entity, + title: props.title, + description: props.description, + to: email, + }); + if (res.ok) { + setStatus({ state: "sent" }); + } else { + setStatus({ state: "error", message: errorCopy(res.error) }); + } + }; + + return ( +
+
+ { + setEmail(e.target.value); + if (status.state !== "idle") setStatus({ state: "idle" }); + }} + className="grow input-with-border px-2 py-1 text-sm rounded-md" + /> + + {status.state === "sending" ? ( + + ) : ( + "Send preview" + )} + +
+ {status.state === "sent" ? ( +
+ Preview sent to {email}. +
+ ) : status.state === "error" ? ( +
{status.message}
+ ) : ( +
+ Send the rendered email to a test address before broadcasting. +
+ )} +
+ ); +} diff --git a/app/[leaflet_id]/publish/page.tsx b/app/[leaflet_id]/publish/page.tsx index fa0f2f6f..2da9361d 100644 --- a/app/[leaflet_id]/publish/page.tsx +++ b/app/[leaflet_id]/publish/page.tsx @@ -31,7 +31,8 @@ export default async function PublishLeafletPage(props: Props) { *, publications( *, - documents_in_publications(count) + documents_in_publications(count), + publication_newsletter_settings(enabled) ), documents(*)), leaflets_to_documents( @@ -53,7 +54,9 @@ export default async function PublishLeafletPage(props: Props) { console.log(decodeURIComponent(pub_uri)); let { data: pubData, error } = await supabaseServerClient .from("publications") - .select("*, documents_in_publications(count)") + .select( + "*, documents_in_publications(count), publication_newsletter_settings(enabled)", + ) .eq("uri", decodeURIComponent(pub_uri)) .single(); console.log(error); @@ -120,6 +123,9 @@ export default async function PublishLeafletPage(props: Props) { publication_uri={publication?.uri} record={normalizePublicationRecord(publication?.record)} posts_in_pub={publication?.documents_in_publications[0]?.count} + newsletter_enabled={ + !!publication?.publication_newsletter_settings?.enabled + } entitiesToDelete={entitiesToDelete} hasDraft={hasDraft} /> diff --git a/emails/post.tsx b/emails/post.tsx index 52710d0e..d09ef604 100644 --- a/emails/post.tsx +++ b/emails/post.tsx @@ -17,227 +17,280 @@ import { } from "@react-email/components"; import { Tailwind, pixelBasedPreset } from "@react-email/components"; import React from "react"; +import type { EmailBlock, EmailListItem } from "src/utils/postToEmailBlocks"; -export const PostEmail = (props: {}) => ( - - ` verbatim so these must be absolute in outgoing mail. + */ + assetsBaseUrl: string; +}; - // HIGHLIGHT COLORS - "highlight-1": "rgb(255, 177, 177)", - "highlight-2": "rgb(253, 245, 203)", - "highlight-3": "rgb(255, 205, 195)", +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/", + blocks: [ + { + type: "text", + plaintext: + "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.", + }, + { type: "heading", level: 1, plaintext: "This is a Title" }, + { + type: "text", + plaintext: "We'll keep it nice and separate so we can see what it looks like.", + }, + { type: "heading", level: 2, plaintext: "And a Header" }, + { + type: "list", + style: "unordered", + items: [ + { plaintext: "fruits", children: [ + { type: "list", style: "unordered", items: [ + { plaintext: "apple" }, + { plaintext: "banana" }, + ] }, + ] }, + { plaintext: "veggies" }, + ], + }, + { type: "blockquote", plaintext: "A quote of some kind." }, + { type: "code", code: "const x = 1;", language: "javascript" }, + { + type: "link", + url: "https://example.com", + title: "Link Title Here", + description: "Description on the link", + }, + { type: "horizontal-rule" }, + { type: "unsupported", kind: "math" }, + ], +}; - //DO NOT USE IN PRODUCTION. Test colors to aid development, ie, setting bg color on element to see edges of div. DO. NOT. USE. IN. PRODUCTION - test: "#E18181", - "test-blue": "#48D1EF", - }, - fontSize: { - xs: ".75rem", - sm: ".875rem", - base: "1rem", - lg: "1.125rem", - xl: "1.625rem", - "2xl": "2rem", - }, +export const PostEmail = (props: Partial = {}) => { + const p: PostEmailProps = { ...defaultProps, ...props }; + const staticUrl = (filename: string) => + `${p.assetsBaseUrl.replace(/\/$/, "")}/static/${filename}`; + const byline = [p.authorName, p.publishedAtLabel].filter(Boolean).join(" | "); - extend: { - fontFamily: { - sans: ["Verdana"], - serif: ["Georgia"], + return ( + + - - - - + }} + > + + + + - - Post Title Here - - - Hello this is a description of everything that is to come - + + {p.postTitle} + + {p.postDescription ? ( + + {p.postDescription} + + ) : null} -
- - - - celine | Jun 23, 2025 - - - - - - - - - - - - - - - - -
-
- - 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. - - - Here's what an image block looks like. It also needs to align if - you have a small picture. Let's see what that looks like. I will - need to import a new one and that sounds like so much work but - whatever, it's easy actually im just lazy - - - And here we have all the Headers - This is an Title - - We'll keep it nice an separate to i can see what it looks like. - After all i want this to look like a real text document. - + + + {p.unsubscribeUrl ? ( + + ) : ( + + (preview — not sent to subscribers) + + )} + +
+
+
- And a Header - - If i didn't do this it would be difficult to know waht things look - like so its important to get jiggy with the flavor text - - And finally a SubHeader - - It ain't easy to be jiggy but I make it look breezy. Like you - rhymes are cheesy, I'm allergic they make me sneezy. Besides, - relying on rhymes is sleazy like yo, it's measely. - - - how about lists????? - - - and blockquote!!! - - - - - - Hi this is some text. I want to make it wrap this so that it's - multiline but idk how long that will end up being - - - - code block??? - - - - I don't even know if we an do external link but why not give it a - go. - - - - can't forget about the horizontal rule - -
- - - and what if we don't support it? like math and code blocks? though - maybe we should support code? there's a component after all. - - - -
- - - - - - -
-
-
- - - -
- -); + + +
+ + ); +}; export default PostEmail; -export const LeafletWatermark = () => { +const BlockRenderer = ({ + block, + assetsBaseUrl, +}: { + block: EmailBlock; + assetsBaseUrl: string; +}) => { + switch (block.type) { + case "text": + return {block.plaintext || " "}; + case "heading": + return ( + + {block.plaintext} + + ); + case "blockquote": + return ( + + + + + {block.plaintext} + + + ); + case "code": + return ; + case "image": + return ( + {block.alt} + ); + case "link": + return ( + + ); + case "horizontal-rule": + return
; + case "list": + return ; + case "unsupported": + return ; + } +}; + +export const LeafletWatermark = ({ + staticUrl, +}: { + staticUrl?: (filename: string) => string; +} = {}) => { + const leafletSrc = staticUrl ? staticUrl("leaflet.png") : "/static/leaflet.png"; return ( -