UNPKG

payload-plugin-newsletter

Version:

Complete newsletter management plugin for Payload CMS with subscriber management, magic link authentication, and email service integration

1,587 lines (1,570 loc) 208 kB
import { BaseBroadcastProvider, BroadcastApiProvider, BroadcastProviderError, NewsletterProviderError } from "./chunk-KETHRCG7.js"; // src/utils/access.ts var isAdmin = (user, config) => { if (!user || user.collection !== "users") { return false; } if (config?.access?.isAdmin) { return config.access.isAdmin(user); } if (user.roles?.includes("admin")) { return true; } if (user.isAdmin === true) { return true; } if (user.role === "admin") { return true; } if (user.admin === true) { return true; } return false; }; var adminOnly = (config) => ({ req }) => { const user = req.user; return isAdmin(user, config); }; var adminOrSelf = (config) => ({ req, id }) => { const user = req.user; if (!user) { if (!id) { return { id: { equals: "unauthorized-no-access" } }; } return false; } if (isAdmin(user, config)) { return true; } if (user.collection === "subscribers") { if (!id) { return { id: { equals: user.id } }; } return id === user.id; } if (!id) { return { id: { equals: "unauthorized-no-access" } }; } return false; }; // src/emails/render.tsx import { render } from "@react-email/render"; // src/emails/MagicLink.tsx import { Body, Button, Container, Head, Hr, Html, Preview, Text } from "@react-email/components"; // src/emails/styles.ts var styles = { main: { backgroundColor: "#f6f9fc", fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif' }, container: { backgroundColor: "#ffffff", border: "1px solid #f0f0f0", borderRadius: "5px", margin: "0 auto", padding: "45px", marginBottom: "64px", maxWidth: "500px" }, heading: { fontSize: "24px", letterSpacing: "-0.5px", lineHeight: "1.3", fontWeight: "600", color: "#484848", margin: "0 0 20px", padding: "0" }, text: { fontSize: "16px", lineHeight: "26px", fontWeight: "400", color: "#484848", margin: "16px 0" }, button: { backgroundColor: "#000000", borderRadius: "5px", color: "#fff", fontSize: "16px", fontWeight: "bold", textDecoration: "none", textAlign: "center", display: "block", width: "100%", padding: "14px 20px", margin: "30px 0" }, link: { color: "#2754C5", fontSize: "14px", textDecoration: "underline", wordBreak: "break-all" }, hr: { borderColor: "#e6ebf1", margin: "30px 0" }, footer: { fontSize: "14px", lineHeight: "24px", color: "#9ca2ac", textAlign: "center", margin: "0" }, code: { display: "inline-block", padding: "16px", width: "100%", backgroundColor: "#f4f4f4", borderRadius: "5px", border: "1px solid #eee", fontSize: "14px", fontFamily: "monospace", textAlign: "center", margin: "24px 0" } }; // src/emails/MagicLink.tsx import { jsx, jsxs } from "react/jsx-runtime"; var MagicLinkEmail = ({ magicLink, email, siteName = "Newsletter", expiresIn = "24 hours" }) => { const previewText = `Sign in to ${siteName}`; return /* @__PURE__ */ jsxs(Html, { children: [ /* @__PURE__ */ jsx(Head, {}), /* @__PURE__ */ jsx(Preview, { children: previewText }), /* @__PURE__ */ jsx(Body, { style: styles.main, children: /* @__PURE__ */ jsxs(Container, { style: styles.container, children: [ /* @__PURE__ */ jsxs(Text, { style: styles.heading, children: [ "Sign in to ", siteName ] }), /* @__PURE__ */ jsxs(Text, { style: styles.text, children: [ "Hi ", email.split("@")[0], "," ] }), /* @__PURE__ */ jsxs(Text, { style: styles.text, children: [ "We received a request to sign in to your ", siteName, " account. Click the button below to complete your sign in:" ] }), /* @__PURE__ */ jsxs(Button, { href: magicLink, style: styles.button, children: [ "Sign in to ", siteName ] }), /* @__PURE__ */ jsx(Text, { style: styles.text, children: "Or copy and paste this URL into your browser:" }), /* @__PURE__ */ jsx("code", { style: styles.code, children: magicLink }), /* @__PURE__ */ jsx(Hr, { style: styles.hr }), /* @__PURE__ */ jsxs(Text, { style: styles.footer, children: [ "This link will expire in ", expiresIn, ". If you didn't request this email, you can safely ignore it." ] }) ] }) }) ] }); }; // src/emails/Welcome.tsx import { Body as Body2, Button as Button2, Container as Container2, Head as Head2, Hr as Hr2, Html as Html2, Preview as Preview2, Text as Text2 } from "@react-email/components"; import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime"; var WelcomeEmail = ({ email, siteName = "Newsletter", preferencesUrl }) => { const previewText = `Welcome to ${siteName}!`; const firstName = email.split("@")[0]; return /* @__PURE__ */ jsxs2(Html2, { children: [ /* @__PURE__ */ jsx2(Head2, {}), /* @__PURE__ */ jsx2(Preview2, { children: previewText }), /* @__PURE__ */ jsx2(Body2, { style: styles.main, children: /* @__PURE__ */ jsxs2(Container2, { style: styles.container, children: [ /* @__PURE__ */ jsxs2(Text2, { style: styles.heading, children: [ "Welcome to ", siteName, "! \u{1F389}" ] }), /* @__PURE__ */ jsxs2(Text2, { style: styles.text, children: [ "Hi ", firstName, "," ] }), /* @__PURE__ */ jsxs2(Text2, { style: styles.text, children: [ "Thanks for subscribing to ", siteName, "! We're excited to have you as part of our community." ] }), /* @__PURE__ */ jsx2(Text2, { style: styles.text, children: "You'll receive our newsletter based on your preferences. Speaking of which, you can update your preferences anytime:" }), preferencesUrl && /* @__PURE__ */ jsx2(Button2, { href: preferencesUrl, style: styles.button, children: "Manage Preferences" }), /* @__PURE__ */ jsx2(Text2, { style: styles.text, children: "Here's what you can expect from us:" }), /* @__PURE__ */ jsxs2(Text2, { style: styles.text, children: [ "\u2022 Regular updates based on your chosen frequency", /* @__PURE__ */ jsx2("br", {}), "\u2022 Content tailored to your interests", /* @__PURE__ */ jsx2("br", {}), "\u2022 Easy unsubscribe options in every email", /* @__PURE__ */ jsx2("br", {}), "\u2022 Your privacy respected always" ] }), /* @__PURE__ */ jsx2(Hr2, { style: styles.hr }), /* @__PURE__ */ jsx2(Text2, { style: styles.footer, children: "If you have any questions, feel free to reply to this email. We're here to help!" }) ] }) }) ] }); }; // src/emails/SignIn.tsx import { jsx as jsx3 } from "react/jsx-runtime"; var SignInEmail = (props) => { return /* @__PURE__ */ jsx3(MagicLinkEmail, { ...props }); }; // src/emails/render.tsx import { jsx as jsx4 } from "react/jsx-runtime"; async function renderEmail(template, data, config) { try { if (config?.customTemplates) { const customTemplate = config.customTemplates[template]; if (customTemplate) { const CustomComponent = customTemplate; return render(/* @__PURE__ */ jsx4(CustomComponent, { ...data })); } } switch (template) { case "magic-link": { const magicLinkData = data; return render( /* @__PURE__ */ jsx4( MagicLinkEmail, { magicLink: magicLinkData.magicLink || magicLinkData.verificationUrl || magicLinkData.magicLinkUrl || "", email: magicLinkData.email || "", siteName: magicLinkData.siteName, expiresIn: magicLinkData.expiresIn } ) ); } case "signin": { const signinData = data; return render( /* @__PURE__ */ jsx4( SignInEmail, { magicLink: signinData.magicLink || signinData.verificationUrl || signinData.magicLinkUrl || "", email: signinData.email || "", siteName: signinData.siteName, expiresIn: signinData.expiresIn } ) ); } case "welcome": { const welcomeData = data; return render( /* @__PURE__ */ jsx4( WelcomeEmail, { email: welcomeData.email || "", siteName: welcomeData.siteName, preferencesUrl: welcomeData.preferencesUrl } ) ); } default: throw new Error(`Unknown email template: ${template}`); } } catch (error) { console.error(`Failed to render email template ${template}:`, error); throw error; } } // src/collections/Subscribers.ts var createSubscribersCollection = (pluginConfig) => { const slug = pluginConfig.subscribersSlug || "subscribers"; const defaultFields = [ // Core fields { name: "email", type: "email", required: true, unique: true, admin: { description: "Subscriber email address" } }, { name: "name", type: "text", admin: { description: "Subscriber full name" } }, { name: "locale", type: "select", options: pluginConfig.i18n?.locales?.map((locale) => ({ label: locale.toUpperCase(), value: locale })) || [ { label: "EN", value: "en" } ], defaultValue: pluginConfig.i18n?.defaultLocale || "en", admin: { description: "Preferred language for communications" } }, // Authentication fields (hidden from admin UI) { name: "magicLinkToken", type: "text", hidden: true }, { name: "magicLinkTokenExpiry", type: "date", hidden: true }, // External ID for webhook integration { name: "externalId", type: "text", admin: { description: "ID from email service provider", readOnly: true } }, // Subscription status { name: "subscriptionStatus", type: "select", options: [ { label: "Active", value: "active" }, { label: "Unsubscribed", value: "unsubscribed" }, { label: "Pending", value: "pending" } ], defaultValue: "pending", required: true, admin: { description: "Current subscription status" } }, { name: "subscribedAt", type: "date", admin: { description: "When the user subscribed", readOnly: true } }, { name: "unsubscribedAt", type: "date", admin: { condition: (data) => data?.subscriptionStatus === "unsubscribed", description: "When the user unsubscribed", readOnly: true } }, { name: "unsubscribeReason", type: "text", admin: { condition: (data) => data?.subscriptionStatus === "unsubscribed", description: "Reason for unsubscribing", readOnly: true } }, // Email preferences { name: "emailPreferences", type: "group", fields: [ { name: "newsletter", type: "checkbox", defaultValue: true, label: "Newsletter", admin: { description: "Receive regular newsletter updates" } }, { name: "announcements", type: "checkbox", defaultValue: true, label: "Announcements", admin: { description: "Receive important announcements" } } ], admin: { description: "Email communication preferences" } }, // Source tracking { name: "source", type: "text", admin: { description: "Where the subscriber signed up from" } }, // Import tracking { name: "importedFromProvider", type: "checkbox", defaultValue: false, admin: { description: "Indicates this subscriber was imported from an external provider via webhook", position: "sidebar", readOnly: true } } ]; if (pluginConfig.features?.utmTracking?.enabled) { const utmFields = pluginConfig.features.utmTracking.fields || [ "source", "medium", "campaign", "content", "term" ]; defaultFields.push({ name: "utmParameters", type: "group", fields: utmFields.map((field) => ({ name: field, type: "text", admin: { description: `UTM ${field} parameter` } })), admin: { description: "UTM tracking parameters" } }); } defaultFields.push({ name: "signupMetadata", type: "group", fields: [ { name: "ipAddress", type: "text", admin: { readOnly: true } }, { name: "userAgent", type: "text", admin: { readOnly: true } }, { name: "referrer", type: "text", admin: { readOnly: true } }, { name: "signupPage", type: "text", admin: { readOnly: true } } ], admin: { description: "Technical information about signup" } }); if (pluginConfig.features?.leadMagnets?.enabled) { defaultFields.push({ name: "leadMagnet", type: "relationship", relationTo: pluginConfig.features.leadMagnets.collection || "media", admin: { description: "Lead magnet downloaded at signup" } }); } let fields = defaultFields; if (pluginConfig.fields?.overrides) { fields = pluginConfig.fields.overrides({ defaultFields }); } if (pluginConfig.fields?.additional) { fields = [...fields, ...pluginConfig.fields.additional]; } const subscribersCollection = { slug, labels: { singular: "Subscriber", plural: "Subscribers" }, admin: { useAsTitle: "email", defaultColumns: ["email", "name", "subscriptionStatus", "createdAt"], group: "Newsletter" }, fields, hooks: { afterChange: [ async ({ doc, req, operation, previousDoc }) => { if (operation === "create") { const emailService = req.payload.newsletterEmailService; console.log("[Newsletter Plugin] Creating subscriber:", { email: doc.email, hasEmailService: !!emailService }); if (emailService) { try { await emailService.addContact(doc); console.log("[Newsletter Plugin] Successfully added contact to email service"); } catch (error) { console.error("[Newsletter Plugin] Failed to add contact to email service:", error); } } else { console.warn("[Newsletter Plugin] No email service configured for subscriber creation"); } if (doc.subscriptionStatus === "active" && emailService && !doc.importedFromProvider) { try { const settings = await req.payload.findGlobal({ slug: pluginConfig.settingsSlug || "newsletter-settings" }); const serverURL = req.payload.config.serverURL || process.env.PAYLOAD_PUBLIC_SERVER_URL || ""; const html = await renderEmail("welcome", { email: doc.email, siteName: settings?.brandSettings?.siteName || "Newsletter", preferencesUrl: `${serverURL}/account/preferences` // This could be customized }, pluginConfig); await emailService.send({ to: doc.email, subject: settings?.brandSettings?.siteName ? `Welcome to ${settings.brandSettings.siteName}!` : "Welcome!", html }); console.warn(`Welcome email sent to: ${doc.email}`); } catch (error) { console.error("Failed to send welcome email:", error); } } if (pluginConfig.hooks?.afterSubscribe) { await pluginConfig.hooks.afterSubscribe({ doc, req }); } } if (operation === "update" && previousDoc) { const emailService = req.payload.newsletterEmailService; if (doc.subscriptionStatus !== previousDoc.subscriptionStatus) { console.log("[Newsletter Plugin] Subscription status changed:", { email: doc.email, from: previousDoc.subscriptionStatus, to: doc.subscriptionStatus, hasEmailService: !!emailService }); if (emailService) { try { await emailService.updateContact(doc); console.log("[Newsletter Plugin] Successfully updated contact in email service"); } catch (error) { console.error("[Newsletter Plugin] Failed to update contact in email service:", error); } } else { console.warn("[Newsletter Plugin] No email service configured"); } } if (doc.subscriptionStatus === "active" && previousDoc.subscriptionStatus === "unsubscribed" && !doc.importedFromProvider && emailService) { try { const settings = await req.payload.findGlobal({ slug: pluginConfig.settingsSlug || "newsletter-settings" }); const serverURL = req.payload.config.serverURL || process.env.PAYLOAD_PUBLIC_SERVER_URL || ""; const html = await renderEmail("welcome", { email: doc.email, siteName: settings?.brandSettings?.siteName || "Newsletter", preferencesUrl: `${serverURL}/account/preferences` }, pluginConfig); await emailService.send({ to: doc.email, subject: settings?.brandSettings?.siteName ? `Welcome back to ${settings.brandSettings.siteName}!` : "Welcome back!", html }); console.warn(`Welcome email sent to resubscribed user: ${doc.email}`); } catch (error) { console.error("Failed to send resubscription welcome email:", error); } if (pluginConfig.hooks?.afterSubscribe) { await pluginConfig.hooks.afterSubscribe({ doc, req }); } } if (doc.subscriptionStatus === "unsubscribed" && previousDoc.subscriptionStatus !== "unsubscribed") { doc.unsubscribedAt = (/* @__PURE__ */ new Date()).toISOString(); if (pluginConfig.hooks?.afterUnsubscribe) { await pluginConfig.hooks.afterUnsubscribe({ doc, req }); } } } } ], beforeDelete: [ async ({ id, req }) => { const emailService = req.payload.newsletterEmailService; if (emailService) { try { const doc = await req.payload.findByID({ collection: slug, id }); await emailService.removeContact(doc.email); } catch { } } } ] }, access: { create: () => true, // Public can subscribe read: adminOrSelf(pluginConfig), update: adminOrSelf(pluginConfig), delete: adminOnly(pluginConfig) }, timestamps: true }; return subscribersCollection; }; // src/globals/NewsletterSettings.ts var createNewsletterSettingsGlobal = (pluginConfig) => { const slug = pluginConfig.settingsSlug || "newsletter-settings"; return { slug, label: "Newsletter Settings", admin: { group: "Newsletter", description: "Configure email provider settings and templates" }, fields: [ { type: "tabs", tabs: [ { label: "Provider Settings", fields: [ { name: "provider", type: "select", label: "Email Provider", required: true, options: [ { label: "Resend", value: "resend" }, { label: "Broadcast (Self-Hosted)", value: "broadcast" } ], defaultValue: pluginConfig.providers.default, admin: { description: "Choose which email service to use" } }, { name: "resendSettings", type: "group", label: "Resend Settings", admin: { condition: (data) => data?.provider === "resend" }, fields: [ { name: "apiKey", type: "text", label: "API Key", required: true, admin: { description: "Your Resend API key" } }, { name: "audienceIds", type: "array", label: "Audience IDs by Locale", fields: [ { name: "locale", type: "select", label: "Locale", required: true, options: pluginConfig.i18n?.locales?.map((locale) => ({ label: locale.toUpperCase(), value: locale })) || [ { label: "EN", value: "en" } ] }, { name: "production", type: "text", label: "Production Audience ID" }, { name: "development", type: "text", label: "Development Audience ID" } ] } ] }, { name: "broadcastSettings", type: "group", label: "Broadcast Settings", admin: { condition: (data) => data?.provider === "broadcast" }, fields: [ { name: "apiUrl", type: "text", label: "API URL", required: true, admin: { description: "Your Broadcast instance URL" } }, { name: "token", type: "text", label: "API Token", required: true, admin: { description: "Your Broadcast API token" } }, { type: "collapsible", label: "Webhook Configuration", fields: [ { name: "webhookUrl", type: "text", label: "Webhook URL", admin: { readOnly: true, description: "Copy this URL to your Broadcast webhook settings", placeholder: "URL will be generated after save" }, hooks: { beforeChange: [ ({ req }) => { const host = req.headers.get("host") || "localhost:3000"; const protocol = req.headers.get("x-forwarded-proto") || "http"; const baseUrl = process.env.PAYLOAD_PUBLIC_SERVER_URL || `${protocol}://${host}`; return `${baseUrl}/api/newsletter/webhooks/broadcast`; } ] } }, { name: "webhookConfiguration", type: "ui", admin: { components: { Field: "payload-plugin-newsletter/admin#WebhookConfiguration" } } }, { name: "webhookSecret", type: "text", label: "Webhook Secret", admin: { description: "Paste the webhook secret from Broadcast here" } }, { name: "webhookStatus", type: "select", label: "Status", options: [ { label: "Not Configured", value: "not_configured" }, { label: "Configured", value: "configured" }, { label: "Verified", value: "verified" }, { label: "Error", value: "error" } ], defaultValue: "not_configured", admin: { readOnly: true } }, { name: "lastWebhookReceived", type: "date", label: "Last Event Received", admin: { readOnly: true, date: { displayFormat: "yyyy-MM-dd HH:mm:ss" } } } ] } ] }, { name: "fromAddress", type: "email", label: "From Address", required: true, admin: { description: "Default sender email address" } }, { name: "fromName", type: "text", label: "From Name", required: true, admin: { description: "Default sender name" } }, { name: "replyTo", type: "email", label: "Reply-To Address", admin: { description: "Optional reply-to email address" } } ] }, { label: "Brand Settings", fields: [ { name: "brandSettings", type: "group", label: "Brand Settings", fields: [ { name: "siteName", type: "text", label: "Site Name", required: true, defaultValue: "Newsletter", admin: { description: "Your website or newsletter name" } }, { name: "siteUrl", type: "text", label: "Site URL", admin: { description: "Your website URL (optional)" } }, { name: "logoUrl", type: "text", label: "Logo URL", admin: { description: "URL to your logo image (optional)" } } ] } ] }, { label: "Email Templates", fields: [ { name: "emailTemplates", type: "group", label: "Email Templates", fields: [ { name: "welcome", type: "group", label: "Welcome Email", fields: [ { name: "enabled", type: "checkbox", label: "Send Welcome Email", defaultValue: true }, { name: "subject", type: "text", label: "Subject Line", defaultValue: "Welcome to {{fromName}}!", admin: { condition: (data) => data?.emailTemplates?.welcome?.enabled } }, { name: "preheader", type: "text", label: "Preheader Text", admin: { condition: (data) => data?.emailTemplates?.welcome?.enabled } } ] }, { name: "magicLink", type: "group", label: "Magic Link Email", fields: [ { name: "subject", type: "text", label: "Subject Line", defaultValue: "Sign in to {{fromName}}" }, { name: "preheader", type: "text", label: "Preheader Text", defaultValue: "Click the link to access your preferences" }, { name: "expirationTime", type: "select", label: "Link Expiration", defaultValue: "7d", options: [ { label: "1 hour", value: "1h" }, { label: "24 hours", value: "24h" }, { label: "7 days", value: "7d" }, { label: "30 days", value: "30d" } ] } ] } ] } ] }, { label: "Subscription Settings", fields: [ { name: "subscriptionSettings", type: "group", label: "Subscription Settings", fields: [ { name: "requireDoubleOptIn", type: "checkbox", label: "Require Double Opt-In", defaultValue: false, admin: { description: "Require email confirmation before activating subscriptions" } }, { name: "allowedDomains", type: "array", label: "Allowed Email Domains", admin: { description: "Leave empty to allow all domains" }, fields: [ { name: "domain", type: "text", label: "Domain", required: true, admin: { placeholder: "example.com" } } ] }, { name: "maxSubscribersPerIP", type: "number", label: "Max Subscribers per IP", defaultValue: 10, min: 1, admin: { description: "Maximum number of subscriptions allowed from a single IP address" } } ] } ] } ] } ], hooks: { beforeChange: [ async ({ data, req }) => { if (!req.user || req.user.collection !== "users") { throw new Error("Only administrators can modify newsletter settings"); } return data; } ], afterChange: [ async ({ doc, req }) => { if (req.payload.newsletterEmailService) { try { console.warn("Newsletter settings updated, reinitializing service..."); } catch { } } return doc; } ] }, access: { read: () => true, // Settings can be read publicly for validation update: adminOnly(pluginConfig) } }; }; // src/providers/resend.ts import { Resend } from "resend"; // src/providers/types.ts var EmailProviderError = class extends Error { constructor(message, provider, originalError) { super(message); this.name = "EmailProviderError"; this.provider = provider; this.originalError = originalError; } }; // src/providers/resend.ts var ResendProvider = class { constructor(config) { this.client = new Resend(config.apiKey); this.audienceIds = config.audienceIds || {}; this.fromAddress = config.fromAddress; this.fromName = config.fromName; this.isDevelopment = process.env.NODE_ENV !== "production"; } getProvider() { return "resend"; } async send(params) { try { const from = params.from || { email: this.fromAddress, name: this.fromName }; if (!params.html && !params.text) { throw new Error("Either html or text content is required"); } await this.client.emails.send({ from: `${from.name} <${from.email}>`, to: Array.isArray(params.to) ? params.to : [params.to], subject: params.subject, html: params.html || "", text: params.text, replyTo: params.replyTo }); } catch (error) { throw new EmailProviderError( `Failed to send email via Resend: ${error instanceof Error ? error.message : "Unknown error"}`, "resend", error ); } } async addContact(contact) { try { const audienceId = this.getAudienceId(contact.locale); if (!audienceId) { console.warn(`No audience ID configured for locale: ${contact.locale}`); return; } await this.client.contacts.create({ email: contact.email, firstName: contact.name?.split(" ")[0], lastName: contact.name?.split(" ").slice(1).join(" "), unsubscribed: contact.subscriptionStatus === "unsubscribed", audienceId }); } catch (error) { throw new EmailProviderError( `Failed to add contact to Resend: ${error instanceof Error ? error.message : "Unknown error"}`, "resend", error ); } } async updateContact(contact) { try { const audienceId = this.getAudienceId(contact.locale); if (!audienceId) { console.warn(`No audience ID configured for locale: ${contact.locale}`); return; } const contacts = await this.client.contacts.list({ audienceId }); const existingContact = contacts.data?.data?.find((c) => c.email === contact.email); if (existingContact) { await this.client.contacts.update({ id: existingContact.id, audienceId, firstName: contact.name?.split(" ")[0], lastName: contact.name?.split(" ").slice(1).join(" "), unsubscribed: contact.subscriptionStatus === "unsubscribed" }); } else { await this.addContact(contact); } } catch (error) { throw new EmailProviderError( `Failed to update contact in Resend: ${error instanceof Error ? error.message : "Unknown error"}`, "resend", error ); } } async removeContact(email) { try { for (const locale in this.audienceIds) { const audienceId = this.getAudienceId(locale); if (!audienceId) continue; const contacts = await this.client.contacts.list({ audienceId }); const contact = contacts.data?.data?.find((c) => c.email === email); if (contact) { await this.client.contacts.update({ id: contact.id, audienceId, unsubscribed: true }); break; } } } catch (error) { throw new EmailProviderError( `Failed to remove contact from Resend: ${error instanceof Error ? error.message : "Unknown error"}`, "resend", error ); } } getAudienceId(locale) { const localeKey = locale || "en"; if (!this.audienceIds) return void 0; const localeConfig = this.audienceIds[localeKey]; if (!localeConfig) return void 0; const audienceId = this.isDevelopment ? localeConfig.development || localeConfig.production : localeConfig.production || localeConfig.development; return audienceId; } }; // src/providers/broadcast.ts var BroadcastProvider = class { constructor(config) { this.apiUrl = config.apiUrl.replace(/\/$/, ""); this.token = config.token; this.fromAddress = config.fromAddress; this.fromName = config.fromName; this.replyTo = config.replyTo; } getProvider() { return "broadcast"; } async send(params) { try { const from = params.from || { email: this.fromAddress, name: this.fromName }; const recipients = Array.isArray(params.to) ? params.to : [params.to]; const response = await fetch(`${this.apiUrl}/api/v1/transactionals.json`, { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ to: recipients[0], // Broadcast API expects a single recipient for transactional emails from: `${from.name} <${from.email}>`, // Include from name and email subject: params.subject, body: params.html || params.text || "", reply_to: params.replyTo || this.replyTo || from.email }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } } catch (error) { throw new EmailProviderError( `Failed to send email via Broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "broadcast", error ); } } async addContact(contact) { try { const [firstName, ...lastNameParts] = (contact.name || "").split(" "); const lastName = lastNameParts.join(" "); const response = await fetch(`${this.apiUrl}/api/v1/subscribers.json`, { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ subscriber: { email: contact.email, first_name: firstName || void 0, last_name: lastName || void 0, source: contact.source } }) }); if (!response.ok && response.status !== 201) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } } catch (error) { throw new EmailProviderError( `Failed to add contact to Broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "broadcast", error ); } } async updateContact(contact) { try { const [firstName, ...lastNameParts] = (contact.name || "").split(" "); const lastName = lastNameParts.join(" "); if (contact.subscriptionStatus === "unsubscribed") { const response2 = await fetch(`${this.apiUrl}/api/v1/subscribers/unsubscribe.json`, { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ email: contact.email }) }); if (!response2.ok) { const error = await response2.text(); throw new Error(`Broadcast API error: ${response2.status} - ${error}`); } return; } const response = await fetch(`${this.apiUrl}/api/v1/subscribers.json`, { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ subscriber: { email: contact.email, first_name: firstName || void 0, last_name: lastName || void 0, source: contact.source } }) }); if (!response.ok && response.status !== 201) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } } catch (error) { throw new EmailProviderError( `Failed to update contact in Broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "broadcast", error ); } } async removeContact(email) { try { const response = await fetch(`${this.apiUrl}/api/v1/subscribers/unsubscribe.json`, { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ email }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } } catch (error) { throw new EmailProviderError( `Failed to remove contact from Broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "broadcast", error ); } } }; // src/providers/index.ts var EmailService = class { constructor(config) { this.provider = this.createProvider(config); } createProvider(config) { const baseConfig = { fromAddress: config.fromAddress, fromName: config.fromName }; switch (config.provider) { case "resend": if (!config.resend) { throw new Error("Resend configuration is required when using Resend provider"); } return new ResendProvider({ ...config.resend, ...baseConfig }); case "broadcast": if (!config.broadcast) { throw new Error("Broadcast configuration is required when using Broadcast provider"); } return new BroadcastProvider({ ...config.broadcast, ...baseConfig }); default: throw new Error(`Unknown email provider: ${config.provider}`); } } async send(params) { return this.provider.send(params); } async addContact(contact) { return this.provider.addContact(contact); } async updateContact(contact) { return this.provider.updateContact(contact); } async removeContact(email) { return this.provider.removeContact(email); } getProvider() { return this.provider.getProvider(); } /** * Update the provider configuration * Useful when settings are changed in the admin UI */ updateConfig(config) { this.provider = this.createProvider(config); } }; function createEmailService(config) { return new EmailService(config); } // src/utils/validation.ts import DOMPurify from "isomorphic-dompurify"; function isValidEmail(email) { if (!email || typeof email !== "string") return false; const trimmed = email.trim(); if (trimmed.length > 255) return false; if (trimmed.includes("<") || trimmed.includes(">")) return false; if (trimmed.includes("javascript:")) return false; if (trimmed.includes("data:")) return false; const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; if (!emailRegex.test(trimmed)) return false; const parts = trimmed.split("@"); if (parts.length !== 2) return false; const [localPart, domain] = parts; if (localPart.length > 64 || localPart.length === 0) return false; if (localPart.startsWith(".") || localPart.endsWith(".")) return false; if (domain.startsWith(".") || domain.endsWith(".")) return false; if (domain.includes("..")) return false; if (localPart.includes("..")) return false; return true; } function isDomainAllowed(email, allowedDomains) { if (!isValidEmail(email)) { return false; } if (!allowedDomains || allowedDomains.length === 0) { return true; } const domain = email.split("@")[1]?.toLowerCase(); if (!domain) return false; return allowedDomains.some( (allowedDomain) => domain === allowedDomain.toLowerCase() ); } function sanitizeInput(input) { if (!input) return ""; let cleaned = DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [], KEEP_CONTENT: true }); cleaned = cleaned.replace(/javascript:/gi, "").replace(/data:/gi, "").replace(/vbscript:/gi, "").replace(/file:\/\//gi, "").replace(/onload/gi, "").replace(/onerror/gi, "").replace(/onclick/gi, "").replace(/onmouseover/gi, "").replace(/alert\(/gi, "").replace(/prompt\(/gi, "").replace(/confirm\(/gi, "").replace(/\|/g, "").replace(/;/g, "").replace(/`/g, "").replace(/&&/g, "").replace(/\$\(/g, "").replace(/\.\./g, "").replace(/\/..\//g, "").replace(/\0/g, ""); return cleaned.trim(); } function extractUTMParams(searchParams) { const utmParams = {}; const utmKeys = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"]; utmKeys.forEach((key) => { const value = searchParams.get(key); if (value) { const shortKey = key.replace("utm_", ""); utmParams[shortKey] = value; } }); return utmParams; } function isValidSource(source) { if (!source || typeof source !== "string") return false; const allowedSources = [ "website", "api", "import", "admin", "signup-form", "magic-link", "preferences", "external" ]; return allowedSources.includes(source); } function validateSubscriberData(data) { const errors = []; if (!data.email) { errors.push("Email is required"); } else if (!isValidEmail(data.email)) { errors.push("Invalid email format"); } if (data.name && data.name.length > 100) { errors.push("Name is too long (max 100 characters)"); } if (data.source !== void 0) { if (!data.source || data.source.length === 0) { errors.push("Source cannot be empty"); } else if (data.source.length > 50) { errors.push("Source is too long (max 50 characters)"); } else if (!isValidSource(data.source)) { errors.push("Invalid source value"); } } return { valid: errors.length === 0, errors }; } // src/utils/jwt.ts import jwt from "jsonwebtoken"; function getJWTSecret() { const secret = process.env.JWT_SECRET || process.env.PAYLOAD_SECRET; if (!secret) { console.warn( "WARNING: No JWT_SECRET or PAYLOAD_SECRET found in environment variables. Magic link authentication will not work properly. Please set JWT_SECRET in your environment." ); return "INSECURE_DEVELOPMENT_SECRET_PLEASE_SET_JWT_SECRET"; } return secret; } function generateMagicLinkToken(subscriberId, email, config) { const payload = { subscriberId, email, type: "magic-link" }; const expiresIn = config.auth?.tokenExpiration || "7d"; return jwt.sign(payload, getJWTSecret(), { expiresIn, issuer: "payload-newsletter-plugin" }); } function verifyMagicLinkToken(token) { try { const payload = jwt.verify(token, getJWTSecret(), { issuer: "payload-newsletter-plugin" }); if (payload.type !== "magic-link") { throw new Error("Invalid token type"); } return payload; } catch (error) { if (error instanceof Error && error.name === "TokenExpiredError") { throw new Error("Magic link has expired. Please request a new one."); } if (error instanceof Error && error.name === "JsonWebTokenError") { throw new Error("Invalid magic link token"); } throw error; } } function generateSessionToken(subscriberId, email) { const payload = { subscriberId, email, type: "session" }; return jwt.sign(payload, getJWTSecret(), { expiresIn: "30d", issuer: "payload-newsletter-plugin" }); } function verifySessionToken(token) { try { const payload = jwt.verify(token, getJWTSecret(), { issuer: "payload-newsletter-plugin" }); if (payload.type !== "session") { throw new Error("Invalid token type"); } return payload; } catch (error) { if (error instanceof Error && error.name === "TokenExpiredError") { throw new Error("Session has expired. Please sign in again."); } if (error instanceof Error && error.name === "JsonWebTokenError") { throw new Error("Invalid session token"); } throw e