UNPKG

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
"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.