payload-plugin-newsletter
Version:
Complete newsletter management plugin for Payload CMS with subscriber management, magic link authentication, and email service integration
1,614 lines (1,599 loc) • 60.4 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
default: () => newsletterPlugin,
newsletterPlugin: () => newsletterPlugin
});
module.exports = __toCommonJS(src_exports);
// 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/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
},
// 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: "unsubscribedAt",
type: "date",
admin: {
condition: (data) => data?.subscriptionStatus === "unsubscribed",
description: "When the user unsubscribed",
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"
}
}
];
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;
if (emailService) {
try {
await emailService.addContact(doc);
} catch {
}
}
if (doc.subscriptionStatus === "active" && emailService) {
try {
} catch {
}
}
if (pluginConfig.hooks?.afterSubscribe) {
await pluginConfig.hooks.afterSubscribe({ doc, req });
}
}
if (operation === "update" && previousDoc) {
const emailService = req.payload.newsletterEmailService;
if (doc.subscriptionStatus !== previousDoc.subscriptionStatus && emailService) {
try {
await emailService.updateContact(doc);
} catch {
}
}
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: "productionToken",
type: "text",
label: "Production Token",
admin: {
description: "Token for production environment"
}
},
{
name: "developmentToken",
type: "text",
label: "Development Token",
admin: {
description: "Token for development environment"
}
}
]
},
{
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: "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
var import_resend = require("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 import_resend.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.isDevelopment = process.env.NODE_ENV !== "production";
this.token = this.isDevelopment ? config.tokens.development || config.tokens.production || "" : config.tokens.production || config.tokens.development || "";
this.fromAddress = config.fromAddress;
this.fromName = config.fromName;
}
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/emails`, {
method: "POST",
headers: {
"Authorization": `Bearer ${this.token}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from_email: from.email,
from_name: from.name,
to: recipients,
subject: params.subject,
html_body: params.html,
text_body: params.text,
reply_to: params.replyTo
})
});
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,
tags: [`lang:${contact.locale || "en"}`],
is_active: contact.subscriptionStatus === "active",
source: contact.source
}
})
});
if (!response.ok) {
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 searchResponse = await fetch(
`${this.apiUrl}/api/v1/subscribers/find.json?email=${encodeURIComponent(contact.email)}`,
{
headers: {
"Authorization": `Bearer ${this.token}`
}
}
);
if (!searchResponse.ok) {
await this.addContact(contact);
return;
}
const existingContact = await searchResponse.json();
if (!existingContact || !existingContact.id) {
await this.addContact(contact);
return;
}
const [firstName, ...lastNameParts] = (contact.name || "").split(" ");
const lastName = lastNameParts.join(" ");
const response = await fetch(`${this.apiUrl}/api/v1/subscribers.json`, {
method: "PATCH",
headers: {
"Authorization": `Bearer ${this.token}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
email: contact.email,
subscriber: {
first_name: firstName || void 0,
last_name: lastName || void 0,
tags: [`lang:${contact.locale || "en"}`],
is_active: contact.subscriptionStatus === "active",
source: contact.source
}
})
});
if (!response.ok) {
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 searchResponse = await fetch(
`${this.apiUrl}/api/v1/subscribers/find.json?email=${encodeURIComponent(email)}`,
{
headers: {
"Authorization": `Bearer ${this.token}`
}
}
);
if (!searchResponse.ok) {
return;
}
const contact = await searchResponse.json();
if (!contact || !contact.id) {
return;
}
const response = await fetch(`${this.apiUrl}/api/v1/subscribers/deactivate.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
var import_isomorphic_dompurify = __toESM(require("isomorphic-dompurify"), 1);
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 = import_isomorphic_dompurify.default.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/endpoints/subscribe.ts
var createSubscribeEndpoint = (config) => {
return {
path: "/newsletter/subscribe",
method: "post",
handler: async (req, res) => {
try {
const {
email,
name,
source,
preferences,
leadMagnet,
surveyResponses,
metadata = {}
} = req.body;
const trimmedEmail = email?.trim();
const validation = validateSubscriberData({ email: trimmedEmail, name, source });
if (!validation.valid) {
return res.status(400).json({
success: false,
errors: validation.errors
});
}
const settings = await req.payload.findGlobal({
slug: config.settingsSlug || "newsletter-settings",
overrideAccess: false
// No user context for public endpoint
});
const allowedDomains = settings?.subscriptionSettings?.allowedDomains?.map((d) => d.domain) || [];
if (!isDomainAllowed(trimmedEmail, allowedDomains)) {
return res.status(400).json({
success: false,
error: "Email domain not allowed"
});
}
const existing = await req.payload.find({
collection: config.subscribersSlug || "subscribers",
where: {
email: {
equals: trimmedEmail.toLowerCase()
}
},
overrideAccess: true
// Need to check for duplicates in public endpoint
});
if (existing.docs.length > 0) {
const subscriber2 = existing.docs[0];
if (subscriber2.subscriptionStatus === "unsubscribed") {
return res.status(400).json({
success: false,
error: "This email has been unsubscribed. Please contact support to resubscribe."
});
}
return res.status(400).json({
success: false,
error: "Already subscribed",
subscriber: {
id: subscriber2.id,
email: subscriber2.email,
subscriptionStatus: subscriber2.subscriptionStatus
}
});
}
const ipAddress = req.ip || req.connection.remoteAddress;
const maxPerIP = settings?.subscriptionSettings?.maxSubscribersPerIP || 10;
const ipSubscribers = await req.payload.find({
collection: config.subscribersSlug || "subscribers",
where: {
"signupMetadata.ipAddress": {
equals: ipAddress
}
},
overrideAccess: true
// Need to check IP limits in public endpoint
});
if (ipSubscribers.docs.length >= maxPerIP) {
return res.status(429).json({
success: false,
error: "Too many subscriptions from this IP address"
});
}
const referer = req.headers.referer || req.headers.referrer || "";
let utmParams = {};
if (referer) {
try {
utmParams = extractUTMParams(new URL(referer).searchParams);
} catch {
}
}
const subscriberData = {
email: trimmedEmail.toLowerCase(),
name: name ? sanitizeInput(name) : void 0,
locale: metadata.locale || config.i18n?.defaultLocale || "en",
subscriptionStatus: settings?.subscriptionSettings?.requireDoubleOptIn ? "pending" : "active",
source: source || "api",
emailPreferences: {
newsletter: true,
announcements: true,
...preferences || {}
},
signupMetadata: {
ipAddress,
userAgent: req.headers["user-agent"],
referrer: referer,
signupPage: metadata.signupPage || referer
}
};
if (config.features?.utmTracking?.enabled && Object.keys(utmParams).length > 0) {
subscriberData.utmParameters = utmParams;
}
if (config.features?.leadMagnets?.enabled && leadMagnet) {
subscriberData.leadMagnet = leadMagnet;
}
const subscriber = await req.payload.create({
collection: config.subscribersSlug || "subscribers",
data: subscriberData,
overrideAccess: true
// Public endpoint needs to create subscribers
});
if (config.features?.surveys?.enabled && surveyResponses) {
}
if (settings?.subscriptionSettings?.requireDoubleOptIn) {
}
res.json({
success: true,
subscriber: {
id: subscriber.id,
email: subscriber.email,
subscriptionStatus: subscriber.subscriptionStatus
},
message: settings?.subscriptionSettings?.requireDoubleOptIn ? "Please check your email to confirm your subscription" : "Successfully subscribed"
});
} catch {
res.status(500).json({
success: false,
error: "Failed to subscribe. Please try again."
});
}
}
};
};
// src/utils/jwt.ts
var import_jsonwebtoken = __toESM(require("jsonwebtoken"), 1);
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 verifyMagicLinkToken(token) {
try {
const payload = import_jsonwebtoken.default.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 import_jsonwebtoken.default.sign(payload, getJWTSecret(), {
expiresIn: "30d",
issuer: "payload-newsletter-plugin"
});
}
function verifySessionToken(token) {
try {
const payload = import_jsonwebtoken.default.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 error;
}
}
// src/endpoints/verify-magic-link.ts
var createVerifyMagicLinkEndpoint = (config) => {
return {
path: "/newsletter/verify-magic-link",
method: "post",
handler: async (req, res) => {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({
success: false,
error: "Token is required"
});
}
let payload;
try {
payload = verifyMagicLinkToken(token);
} catch (error) {
return res.status(401).json({
success: false,
error: error instanceof Error ? error.message : "Invalid token"
});
}
const subscriber = await req.payload.findByID({
collection: config.subscribersSlug || "subscribers",
id: payload.subscriberId
// Keep overrideAccess: true for token verification
});
if (!subscriber) {
return res.status(404).json({
success: false,
error: "Subscriber not found"
});
}
if (subscriber.email !== payload.email) {
return res.status(401).json({
success: false,
error: "Invalid token"
});
}
if (subscriber.subscriptionStatus === "unsubscribed") {
return res.status(403).json({
success: false,
error: "This email has been unsubscribed"
});
}
const syntheticUser = {
collection: "subscribers",
id: subscriber.id,
email: subscriber.email
};
if (subscriber.subscriptionStatus === "pending") {
await req.payload.update({
collection: config.subscribersSlug || "subscribers",
id: subscriber.id,
data: {
subscriptionStatus: "active"
},
overrideAccess: false,
user: syntheticUser
});
}
await req.payload.update({
collection: config.subscribersSlug || "subscribers",
id: subscriber.id,
data: {
magicLinkToken: null,
magicLinkTokenExpiry: null
},
overrideAccess: false,
user: syntheticUser
});
const sessionToken = generateSessionToken(
String(subscriber.id),
subscriber.email
);
res.json({
success: true,
sessionToken,
subscriber: {
id: subscriber.id,
email: subscriber.email,
name: subscriber.name,
locale: subscriber.locale,
emailPreferences: subscriber.emailPreferences
}
});
} catch (error) {
console.error("Verify magic link error:", error);
res.status(500).json({
success: false,
error: "Failed to verify magic link"
});
}
}
};
};
// src/endpoints/preferences.ts
var createPreferencesEndpoint = (config) => {
return {
path: "/newsletter/preferences",
method: "get",
handler: async (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({
success: false,
error: "Authorization required"
});
}
const token = authHeader.substring(7);
let payload;
try {
payload = verifySessionToken(token);
} catch (error) {
return res.status(401).json({
success: false,
error: error instanceof Error ? error.message : "Invalid token"
});
}
const subscriber = await req.payload.findByID({
collection: config.subscribersSlug || "subscribers",
id: payload.subscriberId,
overrideAccess: false,
user: {
collection: "subscribers",
id: payload.subscriberId,
email: payload.email
}
});
if (!subscriber) {
return res.status(404).json({
success: false,
error: "Subscriber not found"
});
}
res.json({
success: true,
subscriber: {
id: subscriber.id,
email: subscriber.email,
name: subscriber.name,
locale: subscriber.locale,
emailPreferences: subscriber.emailPreferences,
subscriptionStatus: subscriber.subscriptionStatus
}
});
} catch (error) {
console.error("Get preferences error:", error);
res.status(500).json({
success: false,
error: "Failed to get preferences"
});
}
}
};
};
var createUpdatePreferencesEndpoint = (config) => {
return {
path: "/newsletter/preferences",
method: "post",
handler: async (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({
success: false,
error: "Authorization required"
});
}
const token = authHeader.substring(7);
let payload;
try {
payload = verifySessionToken(token);
} catch (error) {
return res.status(401).json({
success: false,
error: error instanceof Error ? error.message : "Invalid token"
});
}
const { name, locale, emailPreferences } = req.body;
const updateData = {};
if (name !== void 0) {
updateData.name = name;
}
if (locale !== void 0) {
updateData.locale = locale;
}
if (emailPreferences !== void 0) {
updateData.emailPreferences = emailPreferences;
}
const subscriber = await req.payload.update({
collection: config.subscribersSlug || "subscribers",
id: payload.subscriberId,
data: updateData,
overrideAccess: false,
user: {
collection: "subscribers",
id: payload.subscriberId,
email: payload.email
}
});
res.json({
success: true,
subscriber: {
id: subscriber.id,
email: subscriber.email,
name: subscriber.name,
locale: subscriber.locale,
emailPreferences: subscriber.emailPreferences,
subscriptionStatus: subscriber.subscriptionStatus
}
});
} catch (error) {
console.error("Update preferences error:", error);
res.status(500).json({
success: false,
error: "Failed to update preferences"
});
}
}
};
};
// src/endpoints/unsubscribe.ts
var createUnsubscribeEndpoint = (config) => {
return {
path: "/newsletter/unsubscribe",
method: "post",
handler: async (req, res) => {
try {
const { email, token } = req.body;
if (!email && !token) {
return res.status(400).json({
success: false,
error: "Email or token is required"
});
}
let subscriber;
if (token) {
try {
const jwt2 = await import("jsonwebtoken");
const payload = jwt2.verify(
token,
process.env.JWT_SECRET || process.env.PAYLOAD_SECRET || ""
);
if (payload.type !== "unsubscribe") {
throw new Error("Invalid token type");
}
subscriber = await req.payload.findByID({
collection: config.subscribersSlug || "subscribers",
id: payload.subscriberId
});
} catch {
return res.status(401).json({
success: false,
error: "Invalid or expired unsubscribe link"
});
}
} else {
if (!isValidEmail(email)) {
return res.status(400).json({
success: false,
error: "Invalid email format"
});
}
const result = await req.payload.find({
collection: config.subscribersSlug || "subscribers",
where: {
email: {
equals: email.toLowerCase()
}
}
});
if (result.docs.length === 0) {
return res.json({
success: true,
message: "If this email was subscribed, it has been unsubscribed."
});
}
subscriber = result.docs[0];
}
if (!subscriber) {
return res.json({
success: true,
message: "If this email was subscribed, it has been unsubscribed."
});
}
if (subscriber.subscriptionStatus === "unsubscribed") {
return res.json({
success: true,
message: "Already unsubscribed"
});
}
await req.payload.