a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

tweak pub email settings and from address

Jared Pereira (Apr 24, 2026, 6:51 PM EDT) 9833be65 4acf9823

+386 -193
+95 -22
actions/publications/newsletterSettings.tsx
··· 12 12 type ConfirmationError, 13 13 } from "src/utils/confirmationEmail"; 14 14 15 - type RequestError = "unauthorized" | "invalid_email" | ConfirmationError; 15 + type ToggleError = "unauthorized" | "database_error"; 16 + type SetReplyToError = 17 + | "unauthorized" 18 + | "invalid_email" 19 + | "database_error" 20 + | ConfirmationError; 16 21 type ConfirmError = 17 22 | "unauthorized" 18 23 | "no_pending_verification" 19 24 | ConfirmationError; 20 - type DisableError = "unauthorized" | "database_error"; 21 25 22 26 async function assertPublicationOwner(publicationUri: string) { 23 27 const [identity, { data: publication }] = await Promise.all([ ··· 34 38 return identity; 35 39 } 36 40 37 - export async function requestReplyToVerification( 41 + export async function enableNewsletter( 38 42 publicationUri: string, 39 - emailRaw: string, 40 - ): Promise<Result<null, RequestError>> { 43 + ): Promise<Result<null, ToggleError>> { 41 44 if (!(await assertPublicationOwner(publicationUri))) 42 45 return Err("unauthorized"); 43 - 44 - const email = emailRaw.trim().toLowerCase(); 45 - if (!EMAIL_REGEX.test(email)) return Err("invalid_email"); 46 - 47 - const code = generateConfirmationCode(); 48 46 49 47 const { error } = await supabaseServerClient 50 48 .from("publication_newsletter_settings") 51 49 .upsert( 52 50 { 53 51 publication: publicationUri, 54 - reply_to_email: email, 55 - confirmation_code: code, 56 - enabled: false, 57 - reply_to_verified_at: null, 52 + enabled: true, 58 53 updated_at: new Date().toISOString(), 59 54 }, 60 55 { onConflict: "publication" }, 61 56 ); 62 57 if (error) { 63 - console.error("[newsletterSettings] upsert failed:", error); 58 + console.error("[newsletterSettings] enable upsert failed:", error); 59 + return Err("database_error"); 60 + } 61 + return Ok(null); 62 + } 63 + 64 + export async function disableNewsletter( 65 + publicationUri: string, 66 + ): Promise<Result<null, ToggleError>> { 67 + if (!(await assertPublicationOwner(publicationUri))) 68 + return Err("unauthorized"); 69 + 70 + const { error } = await supabaseServerClient 71 + .from("publication_newsletter_settings") 72 + .update({ 73 + enabled: false, 74 + updated_at: new Date().toISOString(), 75 + }) 76 + .eq("publication", publicationUri); 77 + if (error) { 78 + console.error("[newsletterSettings] disable update failed:", error); 79 + return Err("database_error"); 80 + } 81 + return Ok(null); 82 + } 83 + 84 + // If the email matches the user's identity email we trust the address and 85 + // skip the confirmation flow. 86 + export async function setReplyToEmail( 87 + publicationUri: string, 88 + emailRaw: string, 89 + ): Promise<Result<{ verification_required: boolean }, SetReplyToError>> { 90 + const identity = await assertPublicationOwner(publicationUri); 91 + if (!identity) return Err("unauthorized"); 92 + 93 + const email = emailRaw.trim().toLowerCase(); 94 + if (!EMAIL_REGEX.test(email)) return Err("invalid_email"); 95 + 96 + const identityEmail = identity.email?.trim().toLowerCase() ?? null; 97 + const trustedMatch = identityEmail !== null && identityEmail === email; 98 + 99 + if (trustedMatch) { 100 + const { error } = await supabaseServerClient 101 + .from("publication_newsletter_settings") 102 + .upsert( 103 + { 104 + publication: publicationUri, 105 + reply_to_email: email, 106 + reply_to_verified_at: new Date().toISOString(), 107 + confirmation_code: null, 108 + updated_at: new Date().toISOString(), 109 + }, 110 + { onConflict: "publication" }, 111 + ); 112 + if (error) { 113 + console.error( 114 + "[newsletterSettings] trusted reply-to upsert failed:", 115 + error, 116 + ); 117 + return Err("database_error"); 118 + } 119 + return Ok({ verification_required: false }); 120 + } 121 + 122 + const code = generateConfirmationCode(); 123 + const { error } = await supabaseServerClient 124 + .from("publication_newsletter_settings") 125 + .upsert( 126 + { 127 + publication: publicationUri, 128 + reply_to_email: email, 129 + reply_to_verified_at: null, 130 + confirmation_code: code, 131 + updated_at: new Date().toISOString(), 132 + }, 133 + { onConflict: "publication" }, 134 + ); 135 + if (error) { 136 + console.error("[newsletterSettings] reply-to upsert failed:", error); 64 137 return Err("database_error"); 65 138 } 66 139 ··· 74 147 }); 75 148 if (!sent) return Err("email_send_failed"); 76 149 77 - return Ok(null); 150 + return Ok({ verification_required: true }); 78 151 } 79 152 80 153 export async function confirmReplyToVerification( ··· 100 173 const { error } = await supabaseServerClient 101 174 .from("publication_newsletter_settings") 102 175 .update({ 103 - enabled: true, 104 176 reply_to_verified_at: new Date().toISOString(), 105 177 confirmation_code: null, 106 178 updated_at: new Date().toISOString(), ··· 114 186 return Ok(null); 115 187 } 116 188 117 - export async function disableNewsletter( 189 + export async function clearReplyToEmail( 118 190 publicationUri: string, 119 - ): Promise<Result<null, DisableError>> { 191 + ): Promise<Result<null, ToggleError>> { 120 192 if (!(await assertPublicationOwner(publicationUri))) 121 193 return Err("unauthorized"); 122 194 123 195 const { error } = await supabaseServerClient 124 196 .from("publication_newsletter_settings") 125 197 .update({ 126 - enabled: false, 198 + reply_to_email: null, 199 + reply_to_verified_at: null, 200 + confirmation_code: null, 127 201 updated_at: new Date().toISOString(), 128 202 }) 129 203 .eq("publication", publicationUri); 130 204 if (error) { 131 - console.error("[newsletterSettings] disable update failed:", error); 205 + console.error("[newsletterSettings] clear reply-to failed:", error); 132 206 return Err("database_error"); 133 207 } 134 - 135 208 return Ok(null); 136 209 }
+17 -4
actions/publications/sendPostPreview.tsx
··· 11 11 import type { Fact } from "src/replicache"; 12 12 import type { Attribute } from "src/replicache/attributes"; 13 13 import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 14 + import { 15 + buildFromHeader, 16 + resolveFromDomain, 17 + resolveReplyToEmail, 18 + } from "src/utils/newsletterSender"; 14 19 import { PubLeafletPagesLinearDocument } from "lexicons/api"; 15 20 import { PostEmail } from "emails/post"; 16 21 import { emailPropsFromPublication } from "emails/fromPublication"; ··· 20 25 | "invalid_email" 21 26 | "publication_not_found" 22 27 | "newsletter_not_enabled" 28 + | "no_from_address" 23 29 | "render_failed" 24 30 | "email_send_failed"; 25 31 ··· 39 45 const { data: publication } = await supabaseServerClient 40 46 .from("publications") 41 47 .select( 42 - "identity_did, record, publication_newsletter_settings(enabled, reply_to_email, reply_to_verified_at)", 48 + "identity_did, record, publication_domains(domain), publication_newsletter_settings(enabled, reply_to_email, reply_to_verified_at)", 43 49 ) 44 50 .eq("uri", args.publication_uri) 45 51 .single(); ··· 48 54 if (publication.identity_did !== identity.atp_did) return Err("unauthorized"); 49 55 50 56 const settings = publication.publication_newsletter_settings; 51 - if (!settings?.enabled || !settings.reply_to_email) { 57 + if (!settings?.enabled) { 52 58 return Err("newsletter_not_enabled"); 53 59 } 54 60 ··· 78 84 79 85 const pubRecord = normalizePublicationRecord(publication.record); 80 86 const pubProps = emailPropsFromPublication(pubRecord); 87 + const fromDomain = resolveFromDomain( 88 + pubRecord?.url, 89 + publication.publication_domains?.[0]?.domain, 90 + ); 91 + if (!fromDomain) return Err("no_from_address"); 92 + const fromHeader = buildFromHeader(pubRecord?.name, fromDomain); 93 + const replyToEmail = resolveReplyToEmail(settings); 81 94 82 95 const assetsBaseUrl = await getCurrentDeploymentDomain(); 83 96 ··· 114 127 }, 115 128 body: JSON.stringify({ 116 129 MessageStream: "outbound", 117 - From: "Leaflet <newsletters@leaflet.pub>", 118 - ReplyTo: settings.reply_to_email, 130 + From: fromHeader, 131 + ReplyTo: replyToEmail, 119 132 To: email, 120 133 Subject: `[preview] ${args.title || "(untitled)"}`, 121 134 HtmlBody: html,
+31
src/utils/newsletterSender.ts
··· 1 + export const NEWSLETTER_FROM_SUFFIX = "@email.leaflet.pub"; 2 + export const NO_REPLY_EMAIL = "no-reply@leaflet.pub"; 3 + 4 + export function resolveFromDomain( 5 + pubUrl: string | null | undefined, 6 + fallbackDomain: string | null | undefined, 7 + ): string | null { 8 + const fromUrl = pubUrl?.replace(/^https?:\/\//, "") || null; 9 + return fromUrl || fallbackDomain || null; 10 + } 11 + 12 + export function buildFromAddress(fromDomain: string): string { 13 + return `${fromDomain}${NEWSLETTER_FROM_SUFFIX}`; 14 + } 15 + 16 + export function buildFromHeader( 17 + pubName: string | null | undefined, 18 + fromDomain: string, 19 + ): string { 20 + const name = (pubName || "Leaflet").replace(/"/g, '\\"'); 21 + return `"${name}" <${buildFromAddress(fromDomain)}>`; 22 + } 23 + 24 + export function resolveReplyToEmail(settings: { 25 + reply_to_email: string | null; 26 + reply_to_verified_at: string | null; 27 + }): string { 28 + return settings.reply_to_email && settings.reply_to_verified_at 29 + ? settings.reply_to_email 30 + : NO_REPLY_EMAIL; 31 + }
+2
app/[leaflet_id]/publish/ShareOptions.tsx
··· 192 192 return "That email address doesn't look right."; 193 193 case "newsletter_not_enabled": 194 194 return "Newsletter mode isn't enabled for this publication."; 195 + case "no_from_address": 196 + return "This publication doesn't have a leaflet.pub subdomain to send from."; 195 197 case "render_failed": 196 198 return "Couldn't render the email. Try again?"; 197 199 case "email_send_failed":
+28 -4
app/api/inngest/functions/send_post_broadcast.ts
··· 9 9 normalizeDocumentRecord, 10 10 normalizePublicationRecord, 11 11 } from "src/utils/normalizeRecords"; 12 + import { 13 + buildFromHeader, 14 + resolveFromDomain, 15 + resolveReplyToEmail, 16 + } from "src/utils/newsletterSender"; 12 17 import { PubLeafletPagesLinearDocument } from "lexicons/api"; 13 18 import type { Json } from "supabase/database.types"; 14 19 ··· 45 50 supabaseServerClient 46 51 .from("publications") 47 52 .select( 48 - "record, publication_newsletter_settings(enabled, reply_to_email)", 53 + "record, publication_domains(domain), publication_newsletter_settings(enabled, reply_to_email, reply_to_verified_at)", 49 54 ) 50 55 .eq("uri", publication_uri) 51 56 .maybeSingle(), ··· 59 64 }); 60 65 61 66 const settings = loaded.pub?.publication_newsletter_settings; 62 - if (!loaded.pub || !settings?.enabled || !settings.reply_to_email) { 67 + if (!loaded.pub || !settings?.enabled) { 63 68 await step.run("mark-failed-not-enabled", async () => { 64 69 await supabaseServerClient 65 70 .from("publication_post_sends") ··· 83 88 pubRecord?.url && docRecord?.path 84 89 ? `${pubRecord.url.replace(/\/$/, "")}${docRecord.path}` 85 90 : pubProps.publicationUrl; 86 - const replyToEmail = settings.reply_to_email; 91 + const fromDomain = resolveFromDomain( 92 + pubRecord?.url, 93 + loaded.pub.publication_domains?.[0]?.domain, 94 + ); 95 + if (!fromDomain) { 96 + await step.run("mark-failed-no-from-address", async () => { 97 + await supabaseServerClient 98 + .from("publication_post_sends") 99 + .update({ 100 + status: "failed", 101 + error: "no_from_address", 102 + completed_at: new Date().toISOString(), 103 + }) 104 + .eq("publication", publication_uri) 105 + .eq("document", document_uri); 106 + }); 107 + return { aborted: "no_from_address" }; 108 + } 109 + const fromHeader = buildFromHeader(pubRecord?.name, fromDomain); 110 + const replyToEmail = resolveReplyToEmail(settings); 87 111 const did = new AtUri(document_uri).host; 88 112 89 113 // The first page is the document body. Canvas pages don't map to a linear ··· 165 189 ); 166 190 return { 167 191 MessageStream: "broadcast", 168 - From: "Leaflet <newsletters@leaflet.pub>", 192 + From: fromHeader, 169 193 ReplyTo: replyToEmail, 170 194 To: sub.email, 171 195 Subject: postTitle,
+1 -1
app/api/rpc/[command]/get_publication_data.ts
··· 47 47 publication_subscriptions(*, identities(bsky_profiles(*))), 48 48 publication_email_subscribers(*, identities(atp_did, bsky_profiles(*))), 49 49 publication_domains(*), 50 - publication_newsletter_settings(enabled), 50 + publication_newsletter_settings(enabled, reply_to_email, reply_to_verified_at), 51 51 leaflets_in_publications(*, 52 52 documents(*), 53 53 permission_tokens(*,
+212 -162
app/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx
··· 1 - import { useState } from "react"; 2 - import { ButtonPrimary } from "components/Buttons"; 1 + import { useEffect, useMemo, useState } from "react"; 2 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 3 3 import { createBillingPortalSession } from "actions/createBillingPortalSession"; 4 4 import { useIdentityData } from "components/IdentityProvider"; 5 5 import { DotLoader } from "components/utils/DotLoader"; 6 6 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 7 - import { GoBackSmall } from "components/Icons/GoBackSmall"; 8 7 import { PRODUCT_DEFINITION } from "stripe/products"; 9 8 import { DashboardContainer } from "./SettingsContent"; 10 9 import { Modal } from "components/Modal"; 11 - import { EmailConfirm, EmailInput } from "components/Subscribe/EmailSubscribe"; 12 - import { GoToArrow } from "components/Icons/GoToArrow"; 10 + import { EmailConfirm } from "components/Subscribe/EmailSubscribe"; 13 11 import { Input } from "components/Input"; 14 12 import { 15 13 useNormalizedPublicationRecord, ··· 17 15 } from "../PublicationSWRProvider"; 18 16 import { useToaster } from "components/Toast"; 19 17 import { 18 + clearReplyToEmail, 20 19 confirmReplyToVerification, 21 20 disableNewsletter, 22 - requestReplyToVerification, 21 + enableNewsletter, 22 + setReplyToEmail, 23 23 } from "actions/publications/newsletterSettings"; 24 + import { 25 + NO_REPLY_EMAIL, 26 + buildFromAddress, 27 + resolveFromDomain, 28 + } from "src/utils/newsletterSender"; 24 29 25 30 export const NewsletterSettings = () => { 26 31 let { data, mutate } = usePublicationData(); ··· 28 33 let toaster = useToaster(); 29 34 30 35 let publicationUri = data?.publication?.uri; 31 - let newsletterMode = 32 - data?.publication?.publication_newsletter_settings?.enabled ?? false; 36 + let settings = data?.publication?.publication_newsletter_settings; 37 + let newsletterMode = settings?.enabled ?? false; 38 + let pubDomains = data?.publication?.publication_domains ?? []; 33 39 34 - let [enableOpen, setEnableOpen] = useState(false); 35 - let [disableOpen, setDisableOpen] = useState(false); 36 - let [emailValue, setEmailValue] = useState(""); 37 - let [state, setState] = useState<"default" | "confirm">("default"); 38 - let [disableConfirmValue, setDisableConfirmValue] = useState(""); 39 - let [requesting, setRequesting] = useState(false); 40 - let [confirming, setConfirming] = useState(false); 40 + let fromAddress = useMemo(() => { 41 + let domain = resolveFromDomain(record?.url, pubDomains[0]?.domain); 42 + return domain ? buildFromAddress(domain) : null; 43 + }, [record?.url, pubDomains]); 44 + 45 + let fromName = record?.name || ""; 46 + let savedReplyTo = settings?.reply_to_email ?? ""; 47 + let pendingVerification = 48 + !!settings?.reply_to_email && !settings?.reply_to_verified_at; 49 + 50 + let [enabling, setEnabling] = useState(false); 41 51 let [disabling, setDisabling] = useState(false); 52 + let [replyToValue, setReplyToValue] = useState(savedReplyTo); 53 + let [savingReplyTo, setSavingReplyTo] = useState(false); 54 + let [confirming, setConfirming] = useState(false); 55 + let [verifyOpen, setVerifyOpen] = useState(false); 56 + 57 + useEffect(() => { 58 + setReplyToValue(savedReplyTo); 59 + }, [savedReplyTo]); 60 + useEffect(() => { 61 + setVerifyOpen(pendingVerification); 62 + }, [pendingVerification]); 42 63 43 64 if (!publicationUri) return null; 44 65 45 - if (newsletterMode) { 66 + if (!newsletterMode) { 46 67 return ( 47 - <DashboardContainer section="Newsletter Mode"> 48 - Newsletter mode is currently enabled. 49 - <Modal 50 - open={disableOpen} 51 - onOpenChange={(o) => { 52 - setDisableOpen(o); 53 - if (!o) setDisableConfirmValue(""); 68 + <DashboardContainer section="Newsletter" className="pb-4"> 69 + <div className="leading-snug text-secondary"> 70 + Email posts directly to publication subscribers when you publish. 71 + </div> 72 + <ButtonPrimary 73 + className="self-start" 74 + disabled={enabling} 75 + onClick={async () => { 76 + if (!publicationUri || enabling) return; 77 + setEnabling(true); 78 + let res = await enableNewsletter(publicationUri); 79 + setEnabling(false); 80 + if (!res.ok) { 81 + toaster({ 82 + type: "error", 83 + content: "Failed to enable newsletter.", 84 + }); 85 + return; 86 + } 87 + toaster({ type: "success", content: "Newsletter enabled!" }); 88 + await mutate(); 54 89 }} 55 - asChild 56 - className="max-w-full w-sm" 57 - title="Are you sure?" 58 - trigger={<ButtonPrimary>Disable Newsletter Mode</ButtonPrimary>} 59 90 > 60 - <div className="text-secondary"> 61 - <div className="font-bold pb-3">This action cannot be undone.</div> 62 - <div> 63 - Subscribers will no longer receive emails when you publish. They 64 - can keep following via the Leaflet Reader. 65 - </div> 66 - <div className="flex flex-col accent-container p-3 mt-3"> 67 - <div className=""> 68 - To disable, enter the name of this publication below. 69 - </div> 70 - <Input 71 - className="input-with-border w-full mt-2 mb-1 text-primary" 72 - placeholder={record?.name ?? "Publication Name"} 73 - type="text" 74 - value={disableConfirmValue} 75 - onChange={(e) => setDisableConfirmValue(e.currentTarget.value)} 76 - /> 77 - <ButtonPrimary 78 - className="mt-2" 79 - disabled={ 80 - disabling || 81 - record?.name?.toLowerCase() !== 82 - disableConfirmValue.toLowerCase() 83 - } 84 - onClick={async () => { 85 - if (!publicationUri) return; 86 - setDisabling(true); 87 - let res = await disableNewsletter(publicationUri); 88 - setDisabling(false); 89 - if (!res.ok) { 90 - toaster({ 91 - type: "error", 92 - content: "Failed to disable newsletter.", 93 - }); 94 - return; 95 - } 96 - toaster({ type: "success", content: "Newsletter disabled." }); 97 - setDisableOpen(false); 98 - await mutate(); 99 - }} 100 - > 101 - {disabling ? <DotLoader /> : "Yes, Disable Newsletter"} 102 - </ButtonPrimary> 103 - </div> 104 - </div> 105 - </Modal> 91 + {enabling ? <DotLoader /> : "Enable Newsletter"} 92 + </ButtonPrimary> 106 93 </DashboardContainer> 107 94 ); 108 95 } 96 + 97 + let replyToDirty = 98 + replyToValue.trim().toLowerCase() !== savedReplyTo.toLowerCase(); 99 + 109 100 return ( 110 101 <DashboardContainer section="Newsletter" className="pb-4"> 111 - <div className="leading-snug"> 112 - <div className="font-bold"> 113 - Enable newsletter to email posts directly to publication subscribers. 114 - </div> 115 - </div> 116 - <Modal 117 - open={enableOpen} 118 - onOpenChange={(o) => { 119 - setEnableOpen(o); 120 - if (!o) { 121 - setEmailValue(""); 122 - setState("default"); 123 - } 124 - }} 125 - asChild 126 - className="max-w-full w-sm" 127 - title="Enable Newsletter!" 128 - trigger={<ButtonPrimary>Enable Newsletter</ButtonPrimary>} 129 - > 130 - <div className="text-secondary"> 131 - <div className="pb-3"> 132 - When you enable, we will notify your current subscribers. They will 133 - need to opt-in to receive emails. 102 + <div className="flex flex-col gap-4"> 103 + <div className="flex items-center justify-between gap-3 flex-wrap"> 104 + <div className="text-secondary leading-snug"> 105 + Newsletter mode is enabled. Subscribers receive an email when you 106 + publish. 134 107 </div> 135 - <div className="accent-container p-3"> 136 - {state === "default" ? ( 137 - <form 138 - onSubmit={async (e) => { 139 - e.preventDefault(); 140 - e.stopPropagation(); 141 - if (!publicationUri || requesting) return; 142 - setRequesting(true); 143 - let res = await requestReplyToVerification( 144 - publicationUri, 145 - emailValue, 146 - ); 147 - setRequesting(false); 108 + <ButtonSecondary 109 + disabled={disabling} 110 + onClick={async () => { 111 + if (!publicationUri || disabling) return; 112 + setDisabling(true); 113 + let res = await disableNewsletter(publicationUri); 114 + setDisabling(false); 115 + if (!res.ok) { 116 + toaster({ 117 + type: "error", 118 + content: "Failed to disable newsletter.", 119 + }); 120 + return; 121 + } 122 + toaster({ type: "success", content: "Newsletter disabled." }); 123 + await mutate(); 124 + }} 125 + > 126 + {disabling ? <DotLoader /> : "Disable"} 127 + </ButtonSecondary> 128 + </div> 129 + 130 + <hr className="border-border-light" /> 131 + 132 + <div className="flex flex-col gap-1"> 133 + <p className="text-secondary font-bold">From Name</p> 134 + <div className="input-with-border w-full max-w-prose text-tertiary bg-border-light px-2 py-1 rounded-md"> 135 + {fromName || "—"} 136 + </div> 137 + <p className="text-tertiary text-sm leading-snug"> 138 + The publication name is used as the sender name. 139 + </p> 140 + </div> 141 + 142 + <div className="flex flex-col gap-1"> 143 + <p className="text-secondary font-bold">From Email</p> 144 + <div className="input-with-border w-full max-w-prose text-tertiary bg-border-light px-2 py-1 rounded-md"> 145 + {fromAddress || "—"} 146 + </div> 147 + </div> 148 + 149 + <div className="flex flex-col gap-1"> 150 + <label className="text-secondary font-bold" htmlFor="newsletterReplyTo"> 151 + Reply-to Email{" "} 152 + <span className="font-normal text-tertiary">(optional)</span> 153 + </label> 154 + <p className="text-tertiary text-sm leading-snug"> 155 + Where subscriber replies are sent. Leave blank to use the 156 + no-reply address ({NO_REPLY_EMAIL}). 157 + </p> 158 + <div className="flex gap-2 items-stretch max-w-prose"> 159 + <Input 160 + id="newsletterReplyTo" 161 + className="input-with-border w-full text-primary" 162 + type="email" 163 + value={replyToValue} 164 + placeholder={NO_REPLY_EMAIL} 165 + onChange={(e) => setReplyToValue(e.currentTarget.value)} 166 + /> 167 + {replyToDirty ? ( 168 + <ButtonSecondary 169 + disabled={savingReplyTo} 170 + onClick={async () => { 171 + if (!publicationUri || savingReplyTo) return; 172 + let trimmed = replyToValue.trim(); 173 + setSavingReplyTo(true); 174 + if (trimmed === "") { 175 + let res = await clearReplyToEmail(publicationUri); 176 + setSavingReplyTo(false); 177 + if (!res.ok) { 178 + toaster({ 179 + type: "error", 180 + content: "Failed to clear reply-to.", 181 + }); 182 + return; 183 + } 184 + toaster({ 185 + type: "success", 186 + content: "Reply-to cleared. Using no-reply address.", 187 + }); 188 + await mutate(); 189 + return; 190 + } 191 + let res = await setReplyToEmail(publicationUri, trimmed); 192 + setSavingReplyTo(false); 148 193 if (!res.ok) { 149 194 toaster({ 150 195 type: "error", ··· 157 202 }); 158 203 return; 159 204 } 160 - setState("confirm"); 161 - }} 162 - > 163 - <div className="pb-3"> 164 - <strong>Reply-to address.</strong> Readers will see this as 165 - the sender and can reply here. 166 - </div> 167 - <EmailInput 168 - value={emailValue} 169 - onChange={setEmailValue} 170 - large 171 - loading={requesting} 172 - action={ 173 - <button type="submit" aria-label="Send confirmation code"> 174 - <GoToArrow /> 175 - </button> 176 - } 177 - /> 178 - </form> 179 - ) : ( 180 - <EmailConfirm 181 - emailValue={emailValue} 182 - autoFocus 183 - loading={confirming} 184 - onSubmit={async (code) => { 185 - if (!publicationUri || confirming) return; 186 - setConfirming(true); 187 - let res = await confirmReplyToVerification( 188 - publicationUri, 189 - code, 190 - ); 191 - setConfirming(false); 192 - if (!res.ok) { 205 + if (res.value.verification_required) { 206 + setVerifyOpen(true); 193 207 toaster({ 194 - type: "error", 195 - content: 196 - res.error === "invalid_code" 197 - ? "That code didn't match. Try again." 198 - : res.error === "no_pending_verification" 199 - ? "No pending verification. Start over." 200 - : "Something went wrong. Try again.", 208 + type: "success", 209 + content: "Confirmation code sent.", 201 210 }); 202 - return; 211 + } else { 212 + toaster({ 213 + type: "success", 214 + content: "Reply-to saved.", 215 + }); 203 216 } 204 - toaster({ 205 - type: "success", 206 - content: "Newsletter enabled!", 207 - }); 208 - setEnableOpen(false); 209 - setEmailValue(""); 210 - setState("default"); 211 217 await mutate(); 212 218 }} 213 - onBack={() => { 214 - setState("default"); 215 - }} 216 - /> 217 - )} 219 + > 220 + {savingReplyTo ? <DotLoader /> : "Save"} 221 + </ButtonSecondary> 222 + ) : pendingVerification ? ( 223 + <ButtonSecondary onClick={() => setVerifyOpen(true)}> 224 + Verify 225 + </ButtonSecondary> 226 + ) : null} 218 227 </div> 228 + {pendingVerification && !replyToDirty && ( 229 + <p className="text-tertiary text-sm leading-snug"> 230 + Pending verification. Until confirmed, the no-reply address is 231 + used. 232 + </p> 233 + )} 219 234 </div> 235 + </div> 236 + 237 + <Modal 238 + open={verifyOpen} 239 + onOpenChange={setVerifyOpen} 240 + title="Confirm reply-to address" 241 + className="max-w-full w-sm" 242 + > 243 + <EmailConfirm 244 + emailValue={settings?.reply_to_email ?? ""} 245 + autoFocus 246 + loading={confirming} 247 + onSubmit={async (code) => { 248 + if (!publicationUri || confirming) return; 249 + setConfirming(true); 250 + let res = await confirmReplyToVerification(publicationUri, code); 251 + setConfirming(false); 252 + if (!res.ok) { 253 + toaster({ 254 + type: "error", 255 + content: 256 + res.error === "invalid_code" 257 + ? "That code didn't match. Try again." 258 + : res.error === "no_pending_verification" 259 + ? "No pending verification." 260 + : "Something went wrong. Try again.", 261 + }); 262 + return; 263 + } 264 + toaster({ type: "success", content: "Reply-to verified." }); 265 + setVerifyOpen(false); 266 + await mutate(); 267 + }} 268 + onBack={() => setVerifyOpen(false)} 269 + /> 220 270 </Modal> 221 271 </DashboardContainer> 222 272 );