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
JavaScript
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