From 9833be65d147607c12eef5c45f52574e6e75fc91 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 24 Apr 2026 18:51:55 -0400 Subject: [PATCH] tweak pub email settings and from address --- actions/publications/newsletterSettings.tsx | 105 ++++- actions/publications/sendPostPreview.tsx | 21 +- app/[leaflet_id]/publish/ShareOptions.tsx | 2 + .../inngest/functions/send_post_broadcast.ts | 32 +- app/api/rpc/[command]/get_publication_data.ts | 2 +- .../dashboard/settings/ProSettings.tsx | 372 ++++++++++-------- src/utils/newsletterSender.ts | 31 ++ 7 files changed, 379 insertions(+), 186 deletions(-) create mode 100644 src/utils/newsletterSender.ts diff --git a/actions/publications/newsletterSettings.tsx b/actions/publications/newsletterSettings.tsx index 01cae105..d8bdddcc 100644 --- a/actions/publications/newsletterSettings.tsx +++ b/actions/publications/newsletterSettings.tsx @@ -12,12 +12,16 @@ import { type ConfirmationError, } from "src/utils/confirmationEmail"; -type RequestError = "unauthorized" | "invalid_email" | ConfirmationError; +type ToggleError = "unauthorized" | "database_error"; +type SetReplyToError = + | "unauthorized" + | "invalid_email" + | "database_error" + | ConfirmationError; type ConfirmError = | "unauthorized" | "no_pending_verification" | ConfirmationError; -type DisableError = "unauthorized" | "database_error"; async function assertPublicationOwner(publicationUri: string) { const [identity, { data: publication }] = await Promise.all([ @@ -34,33 +38,102 @@ async function assertPublicationOwner(publicationUri: string) { return identity; } -export async function requestReplyToVerification( +export async function enableNewsletter( publicationUri: string, - emailRaw: string, -): Promise> { +): Promise> { + if (!(await assertPublicationOwner(publicationUri))) + return Err("unauthorized"); + + const { error } = await supabaseServerClient + .from("publication_newsletter_settings") + .upsert( + { + publication: publicationUri, + enabled: true, + updated_at: new Date().toISOString(), + }, + { onConflict: "publication" }, + ); + if (error) { + console.error("[newsletterSettings] enable upsert failed:", error); + return Err("database_error"); + } + return Ok(null); +} + +export async function disableNewsletter( + publicationUri: string, +): Promise> { if (!(await assertPublicationOwner(publicationUri))) return Err("unauthorized"); + const { error } = await supabaseServerClient + .from("publication_newsletter_settings") + .update({ + enabled: false, + updated_at: new Date().toISOString(), + }) + .eq("publication", publicationUri); + if (error) { + console.error("[newsletterSettings] disable update failed:", error); + return Err("database_error"); + } + return Ok(null); +} + +// If the email matches the user's identity email we trust the address and +// skip the confirmation flow. +export async function setReplyToEmail( + publicationUri: string, + emailRaw: string, +): Promise> { + const identity = await assertPublicationOwner(publicationUri); + if (!identity) return Err("unauthorized"); + const email = emailRaw.trim().toLowerCase(); if (!EMAIL_REGEX.test(email)) return Err("invalid_email"); - const code = generateConfirmationCode(); + const identityEmail = identity.email?.trim().toLowerCase() ?? null; + const trustedMatch = identityEmail !== null && identityEmail === email; + + if (trustedMatch) { + const { error } = await supabaseServerClient + .from("publication_newsletter_settings") + .upsert( + { + publication: publicationUri, + reply_to_email: email, + reply_to_verified_at: new Date().toISOString(), + confirmation_code: null, + updated_at: new Date().toISOString(), + }, + { onConflict: "publication" }, + ); + if (error) { + console.error( + "[newsletterSettings] trusted reply-to upsert failed:", + error, + ); + return Err("database_error"); + } + return Ok({ verification_required: false }); + } + const code = generateConfirmationCode(); const { error } = await supabaseServerClient .from("publication_newsletter_settings") .upsert( { publication: publicationUri, reply_to_email: email, - confirmation_code: code, - enabled: false, reply_to_verified_at: null, + confirmation_code: code, updated_at: new Date().toISOString(), }, { onConflict: "publication" }, ); if (error) { - console.error("[newsletterSettings] upsert failed:", error); + console.error("[newsletterSettings] reply-to upsert failed:", error); return Err("database_error"); } @@ -74,7 +147,7 @@ export async function requestReplyToVerification( }); if (!sent) return Err("email_send_failed"); - return Ok(null); + return Ok({ verification_required: true }); } export async function confirmReplyToVerification( @@ -100,7 +173,6 @@ export async function confirmReplyToVerification( const { error } = await supabaseServerClient .from("publication_newsletter_settings") .update({ - enabled: true, reply_to_verified_at: new Date().toISOString(), confirmation_code: null, updated_at: new Date().toISOString(), @@ -114,23 +186,24 @@ export async function confirmReplyToVerification( return Ok(null); } -export async function disableNewsletter( +export async function clearReplyToEmail( publicationUri: string, -): Promise> { +): Promise> { if (!(await assertPublicationOwner(publicationUri))) return Err("unauthorized"); const { error } = await supabaseServerClient .from("publication_newsletter_settings") .update({ - enabled: false, + reply_to_email: null, + reply_to_verified_at: null, + confirmation_code: null, updated_at: new Date().toISOString(), }) .eq("publication", publicationUri); if (error) { - console.error("[newsletterSettings] disable update failed:", error); + console.error("[newsletterSettings] clear reply-to failed:", error); return Err("database_error"); } - return Ok(null); } diff --git a/actions/publications/sendPostPreview.tsx b/actions/publications/sendPostPreview.tsx index d2857dba..15499724 100644 --- a/actions/publications/sendPostPreview.tsx +++ b/actions/publications/sendPostPreview.tsx @@ -11,6 +11,11 @@ import { processBlocksToPages } from "src/utils/factsToPagesRecord"; import type { Fact } from "src/replicache"; import type { Attribute } from "src/replicache/attributes"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { + buildFromHeader, + resolveFromDomain, + resolveReplyToEmail, +} from "src/utils/newsletterSender"; import { PubLeafletPagesLinearDocument } from "lexicons/api"; import { PostEmail } from "emails/post"; import { emailPropsFromPublication } from "emails/fromPublication"; @@ -20,6 +25,7 @@ type SendPreviewError = | "invalid_email" | "publication_not_found" | "newsletter_not_enabled" + | "no_from_address" | "render_failed" | "email_send_failed"; @@ -39,7 +45,7 @@ export async function sendPostPreview(args: { const { data: publication } = await supabaseServerClient .from("publications") .select( - "identity_did, record, publication_newsletter_settings(enabled, reply_to_email, reply_to_verified_at)", + "identity_did, record, publication_domains(domain), publication_newsletter_settings(enabled, reply_to_email, reply_to_verified_at)", ) .eq("uri", args.publication_uri) .single(); @@ -48,7 +54,7 @@ export async function sendPostPreview(args: { if (publication.identity_did !== identity.atp_did) return Err("unauthorized"); const settings = publication.publication_newsletter_settings; - if (!settings?.enabled || !settings.reply_to_email) { + if (!settings?.enabled) { return Err("newsletter_not_enabled"); } @@ -78,6 +84,13 @@ export async function sendPostPreview(args: { const pubRecord = normalizePublicationRecord(publication.record); const pubProps = emailPropsFromPublication(pubRecord); + const fromDomain = resolveFromDomain( + pubRecord?.url, + publication.publication_domains?.[0]?.domain, + ); + if (!fromDomain) return Err("no_from_address"); + const fromHeader = buildFromHeader(pubRecord?.name, fromDomain); + const replyToEmail = resolveReplyToEmail(settings); const assetsBaseUrl = await getCurrentDeploymentDomain(); @@ -114,8 +127,8 @@ export async function sendPostPreview(args: { }, body: JSON.stringify({ MessageStream: "outbound", - From: "Leaflet ", - ReplyTo: settings.reply_to_email, + From: fromHeader, + ReplyTo: replyToEmail, To: email, Subject: `[preview] ${args.title || "(untitled)"}`, HtmlBody: html, diff --git a/app/[leaflet_id]/publish/ShareOptions.tsx b/app/[leaflet_id]/publish/ShareOptions.tsx index a3bd385f..74bc3832 100644 --- a/app/[leaflet_id]/publish/ShareOptions.tsx +++ b/app/[leaflet_id]/publish/ShareOptions.tsx @@ -192,6 +192,8 @@ function EmailPreview(props: { return "That email address doesn't look right."; case "newsletter_not_enabled": return "Newsletter mode isn't enabled for this publication."; + case "no_from_address": + return "This publication doesn't have a leaflet.pub subdomain to send from."; case "render_failed": return "Couldn't render the email. Try again?"; case "email_send_failed": diff --git a/app/api/inngest/functions/send_post_broadcast.ts b/app/api/inngest/functions/send_post_broadcast.ts index b17bb646..4bef3841 100644 --- a/app/api/inngest/functions/send_post_broadcast.ts +++ b/app/api/inngest/functions/send_post_broadcast.ts @@ -9,6 +9,11 @@ import { normalizeDocumentRecord, normalizePublicationRecord, } from "src/utils/normalizeRecords"; +import { + buildFromHeader, + resolveFromDomain, + resolveReplyToEmail, +} from "src/utils/newsletterSender"; import { PubLeafletPagesLinearDocument } from "lexicons/api"; import type { Json } from "supabase/database.types"; @@ -45,7 +50,7 @@ export const send_post_broadcast = inngest.createFunction( supabaseServerClient .from("publications") .select( - "record, publication_newsletter_settings(enabled, reply_to_email)", + "record, publication_domains(domain), publication_newsletter_settings(enabled, reply_to_email, reply_to_verified_at)", ) .eq("uri", publication_uri) .maybeSingle(), @@ -59,7 +64,7 @@ export const send_post_broadcast = inngest.createFunction( }); const settings = loaded.pub?.publication_newsletter_settings; - if (!loaded.pub || !settings?.enabled || !settings.reply_to_email) { + if (!loaded.pub || !settings?.enabled) { await step.run("mark-failed-not-enabled", async () => { await supabaseServerClient .from("publication_post_sends") @@ -83,7 +88,26 @@ export const send_post_broadcast = inngest.createFunction( pubRecord?.url && docRecord?.path ? `${pubRecord.url.replace(/\/$/, "")}${docRecord.path}` : pubProps.publicationUrl; - const replyToEmail = settings.reply_to_email; + const fromDomain = resolveFromDomain( + pubRecord?.url, + loaded.pub.publication_domains?.[0]?.domain, + ); + if (!fromDomain) { + await step.run("mark-failed-no-from-address", async () => { + await supabaseServerClient + .from("publication_post_sends") + .update({ + status: "failed", + error: "no_from_address", + completed_at: new Date().toISOString(), + }) + .eq("publication", publication_uri) + .eq("document", document_uri); + }); + return { aborted: "no_from_address" }; + } + const fromHeader = buildFromHeader(pubRecord?.name, fromDomain); + const replyToEmail = resolveReplyToEmail(settings); const did = new AtUri(document_uri).host; // The first page is the document body. Canvas pages don't map to a linear @@ -165,7 +189,7 @@ export const send_post_broadcast = inngest.createFunction( ); return { MessageStream: "broadcast", - From: "Leaflet ", + From: fromHeader, ReplyTo: replyToEmail, To: sub.email, Subject: postTitle, diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts index 1f3ab431..714ad992 100644 --- a/app/api/rpc/[command]/get_publication_data.ts +++ b/app/api/rpc/[command]/get_publication_data.ts @@ -47,7 +47,7 @@ export const get_publication_data = makeRoute({ publication_subscriptions(*, identities(bsky_profiles(*))), publication_email_subscribers(*, identities(atp_did, bsky_profiles(*))), publication_domains(*), - publication_newsletter_settings(enabled), + publication_newsletter_settings(enabled, reply_to_email, reply_to_verified_at), leaflets_in_publications(*, documents(*), permission_tokens(*, diff --git a/app/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx b/app/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx index 4bc0d7a2..a5b79c5a 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx @@ -1,15 +1,13 @@ -import { useState } from "react"; -import { ButtonPrimary } from "components/Buttons"; +import { useEffect, useMemo, useState } from "react"; +import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; import { createBillingPortalSession } from "actions/createBillingPortalSession"; import { useIdentityData } from "components/IdentityProvider"; import { DotLoader } from "components/utils/DotLoader"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; -import { GoBackSmall } from "components/Icons/GoBackSmall"; import { PRODUCT_DEFINITION } from "stripe/products"; import { DashboardContainer } from "./SettingsContent"; import { Modal } from "components/Modal"; -import { EmailConfirm, EmailInput } from "components/Subscribe/EmailSubscribe"; -import { GoToArrow } from "components/Icons/GoToArrow"; +import { EmailConfirm } from "components/Subscribe/EmailSubscribe"; import { Input } from "components/Input"; import { useNormalizedPublicationRecord, @@ -17,10 +15,17 @@ import { } from "../PublicationSWRProvider"; import { useToaster } from "components/Toast"; import { + clearReplyToEmail, confirmReplyToVerification, disableNewsletter, - requestReplyToVerification, + enableNewsletter, + setReplyToEmail, } from "actions/publications/newsletterSettings"; +import { + NO_REPLY_EMAIL, + buildFromAddress, + resolveFromDomain, +} from "src/utils/newsletterSender"; export const NewsletterSettings = () => { let { data, mutate } = usePublicationData(); @@ -28,123 +33,163 @@ export const NewsletterSettings = () => { let toaster = useToaster(); let publicationUri = data?.publication?.uri; - let newsletterMode = - data?.publication?.publication_newsletter_settings?.enabled ?? false; + let settings = data?.publication?.publication_newsletter_settings; + let newsletterMode = settings?.enabled ?? false; + let pubDomains = data?.publication?.publication_domains ?? []; - let [enableOpen, setEnableOpen] = useState(false); - let [disableOpen, setDisableOpen] = useState(false); - let [emailValue, setEmailValue] = useState(""); - let [state, setState] = useState<"default" | "confirm">("default"); - let [disableConfirmValue, setDisableConfirmValue] = useState(""); - let [requesting, setRequesting] = useState(false); - let [confirming, setConfirming] = useState(false); + let fromAddress = useMemo(() => { + let domain = resolveFromDomain(record?.url, pubDomains[0]?.domain); + return domain ? buildFromAddress(domain) : null; + }, [record?.url, pubDomains]); + + let fromName = record?.name || ""; + let savedReplyTo = settings?.reply_to_email ?? ""; + let pendingVerification = + !!settings?.reply_to_email && !settings?.reply_to_verified_at; + + let [enabling, setEnabling] = useState(false); let [disabling, setDisabling] = useState(false); + let [replyToValue, setReplyToValue] = useState(savedReplyTo); + let [savingReplyTo, setSavingReplyTo] = useState(false); + let [confirming, setConfirming] = useState(false); + let [verifyOpen, setVerifyOpen] = useState(false); + + useEffect(() => { + setReplyToValue(savedReplyTo); + }, [savedReplyTo]); + useEffect(() => { + setVerifyOpen(pendingVerification); + }, [pendingVerification]); if (!publicationUri) return null; - if (newsletterMode) { + if (!newsletterMode) { return ( - - Newsletter mode is currently enabled. - { - setDisableOpen(o); - if (!o) setDisableConfirmValue(""); + +
+ Email posts directly to publication subscribers when you publish. +
+ { + if (!publicationUri || enabling) return; + setEnabling(true); + let res = await enableNewsletter(publicationUri); + setEnabling(false); + if (!res.ok) { + toaster({ + type: "error", + content: "Failed to enable newsletter.", + }); + return; + } + toaster({ type: "success", content: "Newsletter enabled!" }); + await mutate(); }} - asChild - className="max-w-full w-sm" - title="Are you sure?" - trigger={Disable Newsletter Mode} > -
-
This action cannot be undone.
-
- Subscribers will no longer receive emails when you publish. They - can keep following via the Leaflet Reader. -
-
-
- To disable, enter the name of this publication below. -
- setDisableConfirmValue(e.currentTarget.value)} - /> - { - if (!publicationUri) return; - setDisabling(true); - let res = await disableNewsletter(publicationUri); - setDisabling(false); - if (!res.ok) { - toaster({ - type: "error", - content: "Failed to disable newsletter.", - }); - return; - } - toaster({ type: "success", content: "Newsletter disabled." }); - setDisableOpen(false); - await mutate(); - }} - > - {disabling ? : "Yes, Disable Newsletter"} - -
-
-
+ {enabling ? : "Enable Newsletter"} +
); } + + let replyToDirty = + replyToValue.trim().toLowerCase() !== savedReplyTo.toLowerCase(); + return ( -
-
- Enable newsletter to email posts directly to publication subscribers. +
+
+
+ Newsletter mode is enabled. Subscribers receive an email when you + publish. +
+ { + if (!publicationUri || disabling) return; + setDisabling(true); + let res = await disableNewsletter(publicationUri); + setDisabling(false); + if (!res.ok) { + toaster({ + type: "error", + content: "Failed to disable newsletter.", + }); + return; + } + toaster({ type: "success", content: "Newsletter disabled." }); + await mutate(); + }} + > + {disabling ? : "Disable"} +
-
- { - setEnableOpen(o); - if (!o) { - setEmailValue(""); - setState("default"); - } - }} - asChild - className="max-w-full w-sm" - title="Enable Newsletter!" - trigger={Enable Newsletter} - > -
-
- When you enable, we will notify your current subscribers. They will - need to opt-in to receive emails. + +
+ +
+

From Name

+
+ {fromName || "—"}
-
- {state === "default" ? ( -
{ - e.preventDefault(); - e.stopPropagation(); - if (!publicationUri || requesting) return; - setRequesting(true); - let res = await requestReplyToVerification( - publicationUri, - emailValue, - ); - setRequesting(false); +

+ The publication name is used as the sender name. +

+
+ +
+

From Email

+
+ {fromAddress || "—"} +
+
+ +
+ +

+ Where subscriber replies are sent. Leave blank to use the + no-reply address ({NO_REPLY_EMAIL}). +

+
+ setReplyToValue(e.currentTarget.value)} + /> + {replyToDirty ? ( + { + if (!publicationUri || savingReplyTo) return; + let trimmed = replyToValue.trim(); + setSavingReplyTo(true); + if (trimmed === "") { + let res = await clearReplyToEmail(publicationUri); + setSavingReplyTo(false); + if (!res.ok) { + toaster({ + type: "error", + content: "Failed to clear reply-to.", + }); + return; + } + toaster({ + type: "success", + content: "Reply-to cleared. Using no-reply address.", + }); + await mutate(); + return; + } + let res = await setReplyToEmail(publicationUri, trimmed); + setSavingReplyTo(false); if (!res.ok) { toaster({ type: "error", @@ -157,66 +202,71 @@ export const NewsletterSettings = () => { }); return; } - setState("confirm"); - }} - > -
- Reply-to address. Readers will see this as - the sender and can reply here. -
- - - - } - /> - - ) : ( - { - if (!publicationUri || confirming) return; - setConfirming(true); - let res = await confirmReplyToVerification( - publicationUri, - code, - ); - setConfirming(false); - if (!res.ok) { + if (res.value.verification_required) { + setVerifyOpen(true); toaster({ - type: "error", - content: - res.error === "invalid_code" - ? "That code didn't match. Try again." - : res.error === "no_pending_verification" - ? "No pending verification. Start over." - : "Something went wrong. Try again.", + type: "success", + content: "Confirmation code sent.", + }); + } else { + toaster({ + type: "success", + content: "Reply-to saved.", }); - return; } - toaster({ - type: "success", - content: "Newsletter enabled!", - }); - setEnableOpen(false); - setEmailValue(""); - setState("default"); await mutate(); }} - onBack={() => { - setState("default"); - }} - /> - )} + > + {savingReplyTo ? : "Save"} +
+ ) : pendingVerification ? ( + setVerifyOpen(true)}> + Verify + + ) : null}
+ {pendingVerification && !replyToDirty && ( +

+ Pending verification. Until confirmed, the no-reply address is + used. +

+ )}
+
+ + + { + if (!publicationUri || confirming) return; + setConfirming(true); + let res = await confirmReplyToVerification(publicationUri, code); + setConfirming(false); + if (!res.ok) { + toaster({ + type: "error", + content: + res.error === "invalid_code" + ? "That code didn't match. Try again." + : res.error === "no_pending_verification" + ? "No pending verification." + : "Something went wrong. Try again.", + }); + return; + } + toaster({ type: "success", content: "Reply-to verified." }); + setVerifyOpen(false); + await mutate(); + }} + onBack={() => setVerifyOpen(false)} + /> ); diff --git a/src/utils/newsletterSender.ts b/src/utils/newsletterSender.ts new file mode 100644 index 00000000..c5dfc9bf --- /dev/null +++ b/src/utils/newsletterSender.ts @@ -0,0 +1,31 @@ +export const NEWSLETTER_FROM_SUFFIX = "@email.leaflet.pub"; +export const NO_REPLY_EMAIL = "no-reply@leaflet.pub"; + +export function resolveFromDomain( + pubUrl: string | null | undefined, + fallbackDomain: string | null | undefined, +): string | null { + const fromUrl = pubUrl?.replace(/^https?:\/\//, "") || null; + return fromUrl || fallbackDomain || null; +} + +export function buildFromAddress(fromDomain: string): string { + return `${fromDomain}${NEWSLETTER_FROM_SUFFIX}`; +} + +export function buildFromHeader( + pubName: string | null | undefined, + fromDomain: string, +): string { + const name = (pubName || "Leaflet").replace(/"/g, '\\"'); + return `"${name}" <${buildFromAddress(fromDomain)}>`; +} + +export function resolveReplyToEmail(settings: { + reply_to_email: string | null; + reply_to_verified_at: string | null; +}): string { + return settings.reply_to_email && settings.reply_to_verified_at + ? settings.reply_to_email + : NO_REPLY_EMAIL; +} -- 2.51.2