payload-plugin-newsletter
Version:
Complete newsletter management plugin for Payload CMS with subscriber management, magic link authentication, and email service integration
1 lines • 112 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../src/utils/access.ts","../src/collections/Subscribers.ts","../src/globals/NewsletterSettings.ts","../src/providers/resend.ts","../src/providers/types.ts","../src/providers/broadcast.ts","../src/providers/index.ts","../src/utils/validation.ts","../src/endpoints/subscribe.ts","../src/utils/jwt.ts","../src/endpoints/verify-magic-link.ts","../src/endpoints/preferences.ts","../src/endpoints/unsubscribe.ts","../src/endpoints/index.ts","../src/fields/newsletterScheduling.ts"],"sourcesContent":["import type { Config } from 'payload'\nimport type { NewsletterPluginConfig } from './types'\nimport { createSubscribersCollection } from './collections/Subscribers'\nimport { createNewsletterSettingsGlobal } from './globals/NewsletterSettings'\nimport { createEmailService } from './providers'\nimport { createNewsletterEndpoints } from './endpoints'\nimport { createNewsletterSchedulingFields } from './fields/newsletterScheduling'\n\n// Extend Payload type to include our email service\ndeclare module 'payload' {\n interface BasePayload {\n newsletterEmailService?: any\n }\n}\n\nexport const newsletterPlugin = (pluginConfig: NewsletterPluginConfig) => (incomingConfig: Config): Config => {\n // Validate and set defaults\n const config: NewsletterPluginConfig = {\n enabled: true,\n subscribersSlug: 'subscribers',\n settingsSlug: 'newsletter-settings',\n auth: {\n enabled: true,\n tokenExpiration: '7d',\n magicLinkPath: '/newsletter/verify',\n ...pluginConfig.auth,\n },\n ...pluginConfig,\n }\n\n // If plugin is disabled, return config unchanged\n if (!config.enabled) {\n return incomingConfig\n }\n\n // Create plugin collections and globals\n const subscribersCollection = createSubscribersCollection(config)\n const settingsGlobal = createNewsletterSettingsGlobal(config)\n\n // Build collections array\n let collections = [...(incomingConfig.collections || []), subscribersCollection]\n\n // Extend collections with newsletter scheduling fields if enabled\n if (config.features?.newsletterScheduling?.enabled) {\n const targetCollections = config.features.newsletterScheduling.collections || 'articles'\n const collectionsToExtend = Array.isArray(targetCollections) ? targetCollections : [targetCollections]\n const schedulingFields = createNewsletterSchedulingFields(config)\n \n collections = collections.map(collection => {\n if (collectionsToExtend.includes(collection.slug)) {\n return {\n ...collection,\n fields: [\n ...collection.fields,\n ...schedulingFields,\n ],\n }\n }\n return collection\n })\n }\n\n // Create API endpoints\n const endpoints = createNewsletterEndpoints(config)\n\n // Build the modified config\n const modifiedConfig: Config = {\n ...incomingConfig,\n collections,\n globals: [\n ...(incomingConfig.globals || []),\n settingsGlobal,\n ],\n endpoints: [\n ...(incomingConfig.endpoints || []),\n ...endpoints,\n ],\n onInit: async (payload) => {\n // Initialize email service\n try {\n // Get settings from global\n const settings = await payload.findGlobal({\n slug: config.settingsSlug || 'newsletter-settings',\n })\n\n let emailServiceConfig: any\n \n if (settings) {\n emailServiceConfig = {\n provider: settings.provider || config.providers.default,\n fromAddress: settings.fromAddress || config.providers.resend?.fromAddress || config.providers.broadcast?.fromAddress || 'noreply@example.com',\n fromName: settings.fromName || config.providers.resend?.fromName || config.providers.broadcast?.fromName || 'Newsletter',\n replyTo: settings.replyTo,\n resend: settings.provider === 'resend' ? {\n apiKey: settings.resendSettings?.apiKey || config.providers.resend?.apiKey || '',\n audienceIds: settings.resendSettings?.audienceIds?.reduce((acc: any, item: any) => {\n acc[item.locale] = {\n production: item.production,\n development: item.development,\n }\n return acc\n }, {}) || config.providers.resend?.audienceIds,\n } : config.providers.resend,\n broadcast: settings.provider === 'broadcast' ? {\n apiUrl: settings.broadcastSettings?.apiUrl || config.providers.broadcast?.apiUrl || '',\n tokens: {\n production: settings.broadcastSettings?.productionToken || config.providers.broadcast?.tokens.production,\n development: settings.broadcastSettings?.developmentToken || config.providers.broadcast?.tokens.development,\n },\n } : config.providers.broadcast,\n }\n } else {\n // Use config defaults\n emailServiceConfig = {\n provider: config.providers.default,\n fromAddress: config.providers.resend?.fromAddress || config.providers.broadcast?.fromAddress || 'noreply@example.com',\n fromName: config.providers.resend?.fromName || config.providers.broadcast?.fromName || 'Newsletter',\n resend: config.providers.resend,\n broadcast: config.providers.broadcast,\n }\n }\n\n (payload as any).newsletterEmailService = createEmailService(emailServiceConfig)\n\n console.warn('Newsletter plugin initialized with', (payload as any).newsletterEmailService.getProvider(), 'provider')\n } catch (error) {\n console.error('Failed to initialize newsletter email service:', error)\n }\n\n // Call original onInit if it exists\n if (incomingConfig.onInit) {\n await incomingConfig.onInit(payload)\n }\n },\n }\n\n return modifiedConfig\n}\n\nexport { newsletterPlugin as default }","import type { Access, AccessArgs } from 'payload'\nimport type { NewsletterPluginConfig } from '../types'\n\n/**\n * Check if a user is an admin based on the plugin configuration\n */\nexport const isAdmin = (user: any, config?: NewsletterPluginConfig): boolean => {\n if (!user || user.collection !== 'users') {\n return false\n }\n\n // If custom admin check is provided, use it\n if (config?.access?.isAdmin) {\n return config.access.isAdmin(user)\n }\n\n // Default checks for common admin patterns\n // 1. Check for admin role\n if (user.roles?.includes('admin')) {\n return true\n }\n\n // 2. Check for isAdmin boolean field\n if (user.isAdmin === true) {\n return true\n }\n\n // 3. Check for role field with admin value\n if (user.role === 'admin') {\n return true\n }\n\n // 4. Check for admin collection relationship\n if (user.admin === true) {\n return true\n }\n\n return false\n}\n\n/**\n * Create admin-only access control\n */\nexport const adminOnly = (config?: NewsletterPluginConfig): Access => \n ({ req }: AccessArgs) => {\n const user = req.user\n return isAdmin(user, config)\n }\n\n/**\n * Create admin or owner access control\n */\nexport const adminOrSelf = (config?: NewsletterPluginConfig): Access => \n ({ req, id }: AccessArgs) => {\n const user = req.user\n \n // No user = no access\n if (!user) {\n // For list operations without ID, return impossible condition\n if (!id) {\n return {\n id: {\n equals: 'unauthorized-no-access',\n },\n }\n }\n return false\n }\n \n // Admins can access everything\n if (isAdmin(user, config)) {\n return true\n }\n \n // Synthetic users (subscribers from magic link) can access their own data\n if (user.collection === 'subscribers') {\n // For list operations, scope to their own data\n if (!id) {\n return {\n id: {\n equals: user.id,\n },\n }\n }\n // For specific document access, check if it's their own\n return id === user.id\n }\n \n // Regular users cannot access subscriber data\n if (!id) {\n return {\n id: {\n equals: 'unauthorized-no-access',\n },\n }\n }\n return false\n }","import type { CollectionConfig, Field, CollectionAfterChangeHook, CollectionBeforeDeleteHook } from 'payload'\nimport type { NewsletterPluginConfig } from '../types'\nimport { adminOnly, adminOrSelf } from '../utils/access'\n\nexport const createSubscribersCollection = (\n pluginConfig: NewsletterPluginConfig\n): CollectionConfig => {\n const slug = pluginConfig.subscribersSlug || 'subscribers'\n \n // Default fields for the subscribers collection\n const defaultFields: Field[] = [\n // Core fields\n {\n name: 'email',\n type: 'email',\n required: true,\n unique: true,\n admin: {\n description: 'Subscriber email address',\n },\n },\n {\n name: 'name',\n type: 'text',\n admin: {\n description: 'Subscriber full name',\n },\n },\n {\n name: 'locale',\n type: 'select',\n options: pluginConfig.i18n?.locales?.map(locale => ({\n label: locale.toUpperCase(),\n value: locale,\n })) || [\n { label: 'EN', value: 'en' },\n ],\n defaultValue: pluginConfig.i18n?.defaultLocale || 'en',\n admin: {\n description: 'Preferred language for communications',\n },\n },\n \n // Authentication fields (hidden from admin UI)\n {\n name: 'magicLinkToken',\n type: 'text',\n hidden: true,\n },\n {\n name: 'magicLinkTokenExpiry',\n type: 'date',\n hidden: true,\n },\n \n // Subscription status\n {\n name: 'subscriptionStatus',\n type: 'select',\n options: [\n { label: 'Active', value: 'active' },\n { label: 'Unsubscribed', value: 'unsubscribed' },\n { label: 'Pending', value: 'pending' },\n ],\n defaultValue: 'pending',\n required: true,\n admin: {\n description: 'Current subscription status',\n },\n },\n {\n name: 'unsubscribedAt',\n type: 'date',\n admin: {\n condition: (data) => data?.subscriptionStatus === 'unsubscribed',\n description: 'When the user unsubscribed',\n readOnly: true,\n },\n },\n \n // Email preferences\n {\n name: 'emailPreferences',\n type: 'group',\n fields: [\n {\n name: 'newsletter',\n type: 'checkbox',\n defaultValue: true,\n label: 'Newsletter',\n admin: {\n description: 'Receive regular newsletter updates',\n },\n },\n {\n name: 'announcements',\n type: 'checkbox',\n defaultValue: true,\n label: 'Announcements',\n admin: {\n description: 'Receive important announcements',\n },\n },\n ],\n admin: {\n description: 'Email communication preferences',\n },\n },\n \n // Source tracking\n {\n name: 'source',\n type: 'text',\n admin: {\n description: 'Where the subscriber signed up from',\n },\n },\n ]\n\n // Add UTM tracking fields if enabled\n if (pluginConfig.features?.utmTracking?.enabled) {\n const utmFields = pluginConfig.features.utmTracking.fields || [\n 'source',\n 'medium',\n 'campaign',\n 'content',\n 'term',\n ]\n \n defaultFields.push({\n name: 'utmParameters',\n type: 'group',\n fields: utmFields.map(field => ({\n name: field,\n type: 'text',\n admin: {\n description: `UTM ${field} parameter`,\n },\n })),\n admin: {\n description: 'UTM tracking parameters',\n },\n })\n }\n\n // Add signup metadata\n defaultFields.push({\n name: 'signupMetadata',\n type: 'group',\n fields: [\n {\n name: 'ipAddress',\n type: 'text',\n admin: {\n readOnly: true,\n },\n },\n {\n name: 'userAgent',\n type: 'text',\n admin: {\n readOnly: true,\n },\n },\n {\n name: 'referrer',\n type: 'text',\n admin: {\n readOnly: true,\n },\n },\n {\n name: 'signupPage',\n type: 'text',\n admin: {\n readOnly: true,\n },\n },\n ],\n admin: {\n description: 'Technical information about signup',\n },\n })\n\n // Add lead magnet field if enabled\n if (pluginConfig.features?.leadMagnets?.enabled) {\n defaultFields.push({\n name: 'leadMagnet',\n type: 'relationship',\n relationTo: pluginConfig.features.leadMagnets.collection || 'media',\n admin: {\n description: 'Lead magnet downloaded at signup',\n },\n })\n }\n\n // Allow field customization\n let fields = defaultFields\n if (pluginConfig.fields?.overrides) {\n fields = pluginConfig.fields.overrides({ defaultFields })\n }\n if (pluginConfig.fields?.additional) {\n fields = [...fields, ...pluginConfig.fields.additional]\n }\n\n const subscribersCollection: CollectionConfig = {\n slug,\n labels: {\n singular: 'Subscriber',\n plural: 'Subscribers',\n },\n admin: {\n useAsTitle: 'email',\n defaultColumns: ['email', 'name', 'subscriptionStatus', 'createdAt'],\n group: 'Newsletter',\n },\n fields,\n hooks: {\n afterChange: [\n async ({ doc, req, operation, previousDoc }) => {\n // After create logic\n if (operation === 'create') {\n // Add to email service\n const emailService = (req.payload as any).newsletterEmailService\n if (emailService) {\n try {\n await emailService.addContact(doc)\n } catch {\n // Failed to add contact to email service\n }\n }\n\n // Send welcome email if active\n if (doc.subscriptionStatus === 'active' && emailService) {\n try {\n // TODO: Send welcome email\n } catch {\n // Failed to send welcome email\n }\n }\n\n // Custom after subscribe hook\n if (pluginConfig.hooks?.afterSubscribe) {\n await pluginConfig.hooks.afterSubscribe({ doc, req })\n }\n }\n \n // After update logic\n if (operation === 'update' && previousDoc) {\n // Update email service if status changed\n const emailService = (req.payload as any).newsletterEmailService\n if (\n doc.subscriptionStatus !== previousDoc.subscriptionStatus &&\n emailService\n ) {\n try {\n await emailService.updateContact(doc)\n } catch {\n // Failed to update contact in email service\n }\n }\n\n // Handle unsubscribe\n if (\n doc.subscriptionStatus === 'unsubscribed' &&\n previousDoc.subscriptionStatus !== 'unsubscribed'\n ) {\n // Set unsubscribed timestamp\n doc.unsubscribedAt = new Date().toISOString()\n \n // Custom after unsubscribe hook\n if (pluginConfig.hooks?.afterUnsubscribe) {\n await pluginConfig.hooks.afterUnsubscribe({ doc, req })\n }\n }\n }\n },\n ] as CollectionAfterChangeHook[],\n beforeDelete: [\n async ({ id, req }) => {\n // Remove from email service\n const emailService = (req.payload as any).newsletterEmailService\n if (emailService) {\n try {\n const doc = await req.payload.findByID({\n collection: slug,\n id,\n })\n await emailService.removeContact(doc.email)\n } catch {\n // Failed to remove contact from email service\n }\n }\n },\n ] as CollectionBeforeDeleteHook[],\n },\n access: {\n create: () => true, // Public can subscribe\n read: adminOrSelf(pluginConfig),\n update: adminOrSelf(pluginConfig),\n delete: adminOnly(pluginConfig),\n },\n timestamps: true,\n }\n\n return subscribersCollection\n}","import type { GlobalConfig } from 'payload'\nimport type { NewsletterPluginConfig } from '../types'\nimport { adminOnly } from '../utils/access'\n\nexport const createNewsletterSettingsGlobal = (\n pluginConfig: NewsletterPluginConfig\n): GlobalConfig => {\n const slug = pluginConfig.settingsSlug || 'newsletter-settings'\n \n return {\n slug,\n label: 'Newsletter Settings',\n admin: {\n group: 'Newsletter',\n description: 'Configure email provider settings and templates',\n },\n fields: [\n {\n type: 'tabs',\n tabs: [\n {\n label: 'Provider Settings',\n fields: [\n {\n name: 'provider',\n type: 'select',\n label: 'Email Provider',\n required: true,\n options: [\n { label: 'Resend', value: 'resend' },\n { label: 'Broadcast (Self-Hosted)', value: 'broadcast' },\n ],\n defaultValue: pluginConfig.providers.default,\n admin: {\n description: 'Choose which email service to use',\n },\n },\n {\n name: 'resendSettings',\n type: 'group',\n label: 'Resend Settings',\n admin: {\n condition: (data) => data?.provider === 'resend',\n },\n fields: [\n {\n name: 'apiKey',\n type: 'text',\n label: 'API Key',\n required: true,\n admin: {\n description: 'Your Resend API key',\n },\n },\n {\n name: 'audienceIds',\n type: 'array',\n label: 'Audience IDs by Locale',\n fields: [\n {\n name: 'locale',\n type: 'select',\n label: 'Locale',\n required: true,\n options: pluginConfig.i18n?.locales?.map(locale => ({\n label: locale.toUpperCase(),\n value: locale,\n })) || [\n { label: 'EN', value: 'en' },\n ],\n },\n {\n name: 'production',\n type: 'text',\n label: 'Production Audience ID',\n },\n {\n name: 'development',\n type: 'text',\n label: 'Development Audience ID',\n },\n ],\n },\n ],\n },\n {\n name: 'broadcastSettings',\n type: 'group',\n label: 'Broadcast Settings',\n admin: {\n condition: (data) => data?.provider === 'broadcast',\n },\n fields: [\n {\n name: 'apiUrl',\n type: 'text',\n label: 'API URL',\n required: true,\n admin: {\n description: 'Your Broadcast instance URL',\n },\n },\n {\n name: 'productionToken',\n type: 'text',\n label: 'Production Token',\n admin: {\n description: 'Token for production environment',\n },\n },\n {\n name: 'developmentToken',\n type: 'text',\n label: 'Development Token',\n admin: {\n description: 'Token for development environment',\n },\n },\n ],\n },\n {\n name: 'fromAddress',\n type: 'email',\n label: 'From Address',\n required: true,\n admin: {\n description: 'Default sender email address',\n },\n },\n {\n name: 'fromName',\n type: 'text',\n label: 'From Name',\n required: true,\n admin: {\n description: 'Default sender name',\n },\n },\n {\n name: 'replyTo',\n type: 'email',\n label: 'Reply-To Address',\n admin: {\n description: 'Optional reply-to email address',\n },\n },\n ],\n },\n {\n label: 'Email Templates',\n fields: [\n {\n name: 'emailTemplates',\n type: 'group',\n label: 'Email Templates',\n fields: [\n {\n name: 'welcome',\n type: 'group',\n label: 'Welcome Email',\n fields: [\n {\n name: 'enabled',\n type: 'checkbox',\n label: 'Send Welcome Email',\n defaultValue: true,\n },\n {\n name: 'subject',\n type: 'text',\n label: 'Subject Line',\n defaultValue: 'Welcome to {{fromName}}!',\n admin: {\n condition: (data) => data?.emailTemplates?.welcome?.enabled,\n },\n },\n {\n name: 'preheader',\n type: 'text',\n label: 'Preheader Text',\n admin: {\n condition: (data) => data?.emailTemplates?.welcome?.enabled,\n },\n },\n ],\n },\n {\n name: 'magicLink',\n type: 'group',\n label: 'Magic Link Email',\n fields: [\n {\n name: 'subject',\n type: 'text',\n label: 'Subject Line',\n defaultValue: 'Sign in to {{fromName}}',\n },\n {\n name: 'preheader',\n type: 'text',\n label: 'Preheader Text',\n defaultValue: 'Click the link to access your preferences',\n },\n {\n name: 'expirationTime',\n type: 'select',\n label: 'Link Expiration',\n defaultValue: '7d',\n options: [\n { label: '1 hour', value: '1h' },\n { label: '24 hours', value: '24h' },\n { label: '7 days', value: '7d' },\n { label: '30 days', value: '30d' },\n ],\n },\n ],\n },\n ],\n },\n ],\n },\n {\n label: 'Subscription Settings',\n fields: [\n {\n name: 'subscriptionSettings',\n type: 'group',\n label: 'Subscription Settings',\n fields: [\n {\n name: 'requireDoubleOptIn',\n type: 'checkbox',\n label: 'Require Double Opt-In',\n defaultValue: false,\n admin: {\n description: 'Require email confirmation before activating subscriptions',\n },\n },\n {\n name: 'allowedDomains',\n type: 'array',\n label: 'Allowed Email Domains',\n admin: {\n description: 'Leave empty to allow all domains',\n },\n fields: [\n {\n name: 'domain',\n type: 'text',\n label: 'Domain',\n required: true,\n admin: {\n placeholder: 'example.com',\n },\n },\n ],\n },\n {\n name: 'maxSubscribersPerIP',\n type: 'number',\n label: 'Max Subscribers per IP',\n defaultValue: 10,\n min: 1,\n admin: {\n description: 'Maximum number of subscriptions allowed from a single IP address',\n },\n },\n ],\n },\n ],\n },\n ],\n },\n ],\n hooks: {\n beforeChange: [\n async ({ data, req }) => {\n // Verify admin access for settings changes\n if (!req.user || req.user.collection !== 'users') {\n throw new Error('Only administrators can modify newsletter settings')\n }\n \n return data\n },\n ],\n afterChange: [\n async ({ doc, req }) => {\n // Reinitialize email service when settings change\n if ((req.payload as any).newsletterEmailService) {\n try {\n // TODO: Implement email service reinitialization\n console.warn('Newsletter settings updated, reinitializing service...')\n } catch {\n // Failed to reinitialize email service\n }\n }\n \n return doc\n },\n ],\n },\n access: {\n read: () => true, // Settings can be read publicly for validation\n update: adminOnly(pluginConfig),\n },\n }\n}","import { Resend } from 'resend'\nimport type { EmailProvider, SendEmailParams } from './types'\nimport { EmailProviderError } from './types'\nimport type { Subscriber, ResendProviderConfig } from '../types'\n\nexport class ResendProvider implements EmailProvider {\n private client: Resend\n private audienceIds: ResendProviderConfig['audienceIds']\n private fromAddress: string\n private fromName: string\n private isDevelopment: boolean\n\n constructor(config: ResendProviderConfig & { \n fromAddress: string\n fromName: string \n }) {\n this.client = new Resend(config.apiKey)\n this.audienceIds = config.audienceIds || {}\n this.fromAddress = config.fromAddress\n this.fromName = config.fromName\n this.isDevelopment = process.env.NODE_ENV !== 'production'\n }\n\n getProvider(): string {\n return 'resend'\n }\n\n async send(params: SendEmailParams): Promise<void> {\n try {\n const from = params.from || {\n email: this.fromAddress,\n name: this.fromName,\n }\n\n if (!params.html && !params.text) {\n throw new Error('Either html or text content is required')\n }\n\n await this.client.emails.send({\n from: `${from.name} <${from.email}>`,\n to: Array.isArray(params.to) ? params.to : [params.to],\n subject: params.subject,\n html: params.html || '',\n text: params.text,\n replyTo: params.replyTo,\n })\n } catch (error: unknown) {\n throw new EmailProviderError(\n `Failed to send email via Resend: ${error instanceof Error ? error.message : 'Unknown error'}`,\n 'resend',\n error\n )\n }\n }\n\n async addContact(contact: Subscriber): Promise<void> {\n try {\n const audienceId = this.getAudienceId(contact.locale)\n if (!audienceId) {\n console.warn(`No audience ID configured for locale: ${contact.locale}`)\n return\n }\n\n await this.client.contacts.create({\n email: contact.email,\n firstName: contact.name?.split(' ')[0],\n lastName: contact.name?.split(' ').slice(1).join(' '),\n unsubscribed: contact.subscriptionStatus === 'unsubscribed',\n audienceId,\n })\n } catch (error: unknown) {\n throw new EmailProviderError(\n `Failed to add contact to Resend: ${error instanceof Error ? error.message : 'Unknown error'}`,\n 'resend',\n error\n )\n }\n }\n\n async updateContact(contact: Subscriber): Promise<void> {\n try {\n const audienceId = this.getAudienceId(contact.locale)\n if (!audienceId) {\n console.warn(`No audience ID configured for locale: ${contact.locale}`)\n return\n }\n\n // Resend requires finding the contact first\n const contacts = await this.client.contacts.list({ audienceId })\n const existingContact = contacts.data?.data?.find(c => c.email === contact.email)\n\n if (existingContact) {\n await this.client.contacts.update({\n id: existingContact.id,\n audienceId,\n firstName: contact.name?.split(' ')[0],\n lastName: contact.name?.split(' ').slice(1).join(' '),\n unsubscribed: contact.subscriptionStatus === 'unsubscribed',\n })\n } else {\n // If contact doesn't exist, add them\n await this.addContact(contact)\n }\n } catch (error: unknown) {\n throw new EmailProviderError(\n `Failed to update contact in Resend: ${error instanceof Error ? error.message : 'Unknown error'}`,\n 'resend',\n error\n )\n }\n }\n\n async removeContact(email: string): Promise<void> {\n try {\n // Resend doesn't have a direct remove method, so we unsubscribe instead\n // First, we need to find the contact across all audiences\n for (const locale in this.audienceIds) {\n const audienceId = this.getAudienceId(locale)\n if (!audienceId) continue\n\n const contacts = await this.client.contacts.list({ audienceId })\n const contact = contacts.data?.data?.find(c => c.email === email)\n\n if (contact) {\n await this.client.contacts.update({\n id: contact.id,\n audienceId,\n unsubscribed: true,\n })\n break\n }\n }\n } catch (error: unknown) {\n throw new EmailProviderError(\n `Failed to remove contact from Resend: ${error instanceof Error ? error.message : 'Unknown error'}`,\n 'resend',\n error\n )\n }\n }\n\n private getAudienceId(locale?: string): string | undefined {\n const localeKey = locale || 'en'\n if (!this.audienceIds) return undefined\n \n const localeConfig = this.audienceIds[localeKey]\n if (!localeConfig) return undefined\n\n const audienceId = this.isDevelopment \n ? (localeConfig.development || localeConfig.production)\n : (localeConfig.production || localeConfig.development)\n \n return audienceId\n }\n}","import type { Subscriber } from '../types'\n\nexport interface EmailProvider {\n send(params: SendEmailParams): Promise<void>\n addContact(contact: Subscriber): Promise<void>\n updateContact(contact: Subscriber): Promise<void>\n removeContact(email: string): Promise<void>\n getProvider(): string\n}\n\nexport interface SendEmailParams {\n to: string | string[]\n subject: string\n html?: string\n text?: string\n react?: React.ReactElement\n from?: {\n email: string\n name?: string\n }\n replyTo?: string\n}\n\nexport interface EmailServiceConfig {\n provider: 'resend' | 'broadcast' | string\n fromAddress: string\n fromName: string\n replyTo?: string\n resend?: {\n apiKey: string\n audienceIds?: Record<string, { production?: string; development?: string }>\n }\n broadcast?: {\n apiUrl: string\n tokens: {\n production?: string\n development?: string\n }\n }\n}\n\nexport class EmailProviderError extends Error {\n provider: string\n originalError?: any\n\n constructor(message: string, provider: string, originalError?: any) {\n super(message)\n this.name = 'EmailProviderError'\n this.provider = provider\n this.originalError = originalError\n }\n}","import type { EmailProvider, SendEmailParams } from './types'\nimport { EmailProviderError } from './types'\nimport type { Subscriber, BroadcastProviderConfig } from '../types'\n\nexport class BroadcastProvider implements EmailProvider {\n private apiUrl: string\n private token: string\n private fromAddress: string\n private fromName: string\n private isDevelopment: boolean\n\n constructor(config: BroadcastProviderConfig & { \n fromAddress: string\n fromName: string \n }) {\n this.apiUrl = config.apiUrl.replace(/\\/$/, '') // Remove trailing slash\n this.isDevelopment = process.env.NODE_ENV !== 'production'\n this.token = this.isDevelopment \n ? config.tokens.development || config.tokens.production || ''\n : config.tokens.production || config.tokens.development || ''\n this.fromAddress = config.fromAddress\n this.fromName = config.fromName\n }\n\n getProvider(): string {\n return 'broadcast'\n }\n\n async send(params: SendEmailParams): Promise<void> {\n try {\n const from = params.from || {\n email: this.fromAddress,\n name: this.fromName,\n }\n\n const recipients = Array.isArray(params.to) ? params.to : [params.to]\n \n // Broadcast expects a specific format\n const response = await fetch(`${this.apiUrl}/api/v1/emails`, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n from_email: from.email,\n from_name: from.name,\n to: recipients,\n subject: params.subject,\n html_body: params.html,\n text_body: params.text,\n reply_to: params.replyTo,\n }),\n })\n\n if (!response.ok) {\n const error = await response.text()\n throw new Error(`Broadcast API error: ${response.status} - ${error}`)\n }\n } catch (error: unknown) {\n throw new EmailProviderError(\n `Failed to send email via Broadcast: ${error instanceof Error ? error.message : 'Unknown error'}`,\n 'broadcast',\n error\n )\n }\n }\n\n async addContact(contact: Subscriber): Promise<void> {\n try {\n const [firstName, ...lastNameParts] = (contact.name || '').split(' ')\n const lastName = lastNameParts.join(' ')\n\n const response = await fetch(`${this.apiUrl}/api/v1/subscribers.json`, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n subscriber: {\n email: contact.email,\n first_name: firstName || undefined,\n last_name: lastName || undefined,\n tags: [`lang:${contact.locale || 'en'}`],\n is_active: contact.subscriptionStatus === 'active',\n source: contact.source,\n },\n }),\n })\n\n if (!response.ok) {\n const error = await response.text()\n throw new Error(`Broadcast API error: ${response.status} - ${error}`)\n }\n } catch (error: unknown) {\n throw new EmailProviderError(\n `Failed to add contact to Broadcast: ${error instanceof Error ? error.message : 'Unknown error'}`,\n 'broadcast',\n error\n )\n }\n }\n\n async updateContact(contact: Subscriber): Promise<void> {\n try {\n // First, try to find the contact\n const searchResponse = await fetch(\n `${this.apiUrl}/api/v1/subscribers/find.json?email=${encodeURIComponent(contact.email)}`,\n {\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n },\n }\n )\n\n if (!searchResponse.ok) {\n // If contact doesn't exist, create it\n await this.addContact(contact)\n return\n }\n\n const existingContact = await searchResponse.json()\n\n if (!existingContact || !existingContact.id) {\n await this.addContact(contact)\n return\n }\n\n const [firstName, ...lastNameParts] = (contact.name || '').split(' ')\n const lastName = lastNameParts.join(' ')\n\n // Update existing contact\n const response = await fetch(`${this.apiUrl}/api/v1/subscribers.json`, {\n method: 'PATCH',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n email: contact.email,\n subscriber: {\n first_name: firstName || undefined,\n last_name: lastName || undefined,\n tags: [`lang:${contact.locale || 'en'}`],\n is_active: contact.subscriptionStatus === 'active',\n source: contact.source,\n },\n }),\n })\n\n if (!response.ok) {\n const error = await response.text()\n throw new Error(`Broadcast API error: ${response.status} - ${error}`)\n }\n } catch (error: unknown) {\n throw new EmailProviderError(\n `Failed to update contact in Broadcast: ${error instanceof Error ? error.message : 'Unknown error'}`,\n 'broadcast',\n error\n )\n }\n }\n\n async removeContact(email: string): Promise<void> {\n try {\n // First, find the contact\n const searchResponse = await fetch(\n `${this.apiUrl}/api/v1/subscribers/find.json?email=${encodeURIComponent(email)}`,\n {\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n },\n }\n )\n\n if (!searchResponse.ok) {\n // Contact doesn't exist, nothing to remove\n return\n }\n\n const contact = await searchResponse.json()\n\n if (!contact || !contact.id) {\n return\n }\n\n // Deactivate the contact\n const response = await fetch(`${this.apiUrl}/api/v1/subscribers/deactivate.json`, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ email }),\n })\n\n if (!response.ok) {\n const error = await response.text()\n throw new Error(`Broadcast API error: ${response.status} - ${error}`)\n }\n } catch (error: unknown) {\n throw new EmailProviderError(\n `Failed to remove contact from Broadcast: ${error instanceof Error ? error.message : 'Unknown error'}`,\n 'broadcast',\n error\n )\n }\n }\n}","import type { EmailProvider, EmailServiceConfig, SendEmailParams } from './types'\nimport type { Subscriber } from '../types'\nimport { ResendProvider } from './resend'\nimport { BroadcastProvider } from './broadcast'\n\nexport * from './types'\n\nexport class EmailService {\n private provider: EmailProvider\n\n constructor(config: EmailServiceConfig) {\n this.provider = this.createProvider(config)\n }\n\n private createProvider(config: EmailServiceConfig): EmailProvider {\n const baseConfig = {\n fromAddress: config.fromAddress,\n fromName: config.fromName,\n }\n\n switch (config.provider) {\n case 'resend':\n if (!config.resend) {\n throw new Error('Resend configuration is required when using Resend provider')\n }\n return new ResendProvider({\n ...config.resend,\n ...baseConfig,\n })\n\n case 'broadcast':\n if (!config.broadcast) {\n throw new Error('Broadcast configuration is required when using Broadcast provider')\n }\n return new BroadcastProvider({\n ...config.broadcast,\n ...baseConfig,\n })\n\n default:\n throw new Error(`Unknown email provider: ${config.provider}`)\n }\n }\n\n async send(params: SendEmailParams): Promise<void> {\n return this.provider.send(params)\n }\n\n async addContact(contact: Subscriber): Promise<void> {\n return this.provider.addContact(contact)\n }\n\n async updateContact(contact: Subscriber): Promise<void> {\n return this.provider.updateContact(contact)\n }\n\n async removeContact(email: string): Promise<void> {\n return this.provider.removeContact(email)\n }\n\n getProvider(): string {\n return this.provider.getProvider()\n }\n\n /**\n * Update the provider configuration\n * Useful when settings are changed in the admin UI\n */\n updateConfig(config: EmailServiceConfig): void {\n this.provider = this.createProvider(config)\n }\n}\n\n/**\n * Create email service from plugin configuration\n */\nexport function createEmailService(config: EmailServiceConfig): EmailService {\n return new EmailService(config)\n}","import DOMPurify from 'isomorphic-dompurify'\n\n/**\n * Validate email address format\n */\nexport function isValidEmail(email: string): boolean {\n if (!email || typeof email !== 'string') return false\n \n // Trim whitespace\n const trimmed = email.trim()\n \n // Length limits\n if (trimmed.length > 255) return false\n \n // Check for dangerous patterns\n if (trimmed.includes('<') || trimmed.includes('>')) return false\n if (trimmed.includes('javascript:')) return false\n if (trimmed.includes('data:')) return false\n \n // Basic format validation with stricter regex\n const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/\n if (!emailRegex.test(trimmed)) return false\n \n // Additional validation rules\n const parts = trimmed.split('@')\n if (parts.length !== 2) return false\n \n const [localPart, domain] = parts\n \n // Check local part length\n if (localPart.length > 64 || localPart.length === 0) return false\n \n // Check for invalid patterns\n if (localPart.startsWith('.') || localPart.endsWith('.')) return false\n if (domain.startsWith('.') || domain.endsWith('.')) return false\n if (domain.includes('..')) return false\n if (localPart.includes('..')) return false\n \n return true\n}\n\n/**\n * Normalize email for rate limiting and deduplication\n */\nexport function normalizeEmail(email: string): string {\n if (!email || typeof email !== 'string') return ''\n \n const parts = email.toLowerCase().trim().split('@')\n if (parts.length !== 2) return email.toLowerCase().trim()\n \n let [localPart] = parts\n const [, domain] = parts\n \n // Remove dots from local part (Gmail-style)\n localPart = localPart.replace(/\\./g, '')\n \n // Remove everything after + (Gmail-style aliases)\n const plusIndex = localPart.indexOf('+')\n if (plusIndex > -1) {\n localPart = localPart.substring(0, plusIndex)\n }\n \n return `${localPart}@${domain}`\n}\n\n/**\n * Check if email domain is allowed\n */\nexport function isDomainAllowed(\n email: string,\n allowedDomains?: string[]\n): boolean {\n // Validate email format first\n if (!isValidEmail(email)) {\n return false\n }\n \n // If no domains specified, allow all valid emails\n if (!allowedDomains || allowedDomains.length === 0) {\n return true\n }\n\n const domain = email.split('@')[1]?.toLowerCase()\n if (!domain) return false\n\n return allowedDomains.some(\n allowedDomain => domain === allowedDomain.toLowerCase()\n )\n}\n\n/**\n * Sanitize user input to prevent XSS\n */\nexport function sanitizeInput(input: string): string {\n if (!input) return ''\n \n // First, remove all HTML tags and scripts\n let cleaned = DOMPurify.sanitize(input, { \n ALLOWED_TAGS: [],\n ALLOWED_ATTR: [],\n KEEP_CONTENT: true\n })\n \n // Additional security: remove dangerous patterns\n cleaned = cleaned\n .replace(/javascript:/gi, '')\n .replace(/data:/gi, '')\n .replace(/vbscript:/gi, '')\n .replace(/file:\\/\\//gi, '')\n .replace(/onload/gi, '')\n .replace(/onerror/gi, '')\n .replace(/onclick/gi, '')\n .replace(/onmouseover/gi, '')\n .replace(/alert\\(/gi, '')\n .replace(/prompt\\(/gi, '')\n .replace(/confirm\\(/gi, '')\n .replace(/\\|/g, '') // Remove pipe character (command injection)\n .replace(/;/g, '') // Remove semicolon (command chaining)\n .replace(/`/g, '') // Remove backticks (command substitution)\n .replace(/&&/g, '') // Remove command chaining\n .replace(/\\$\\(/g, '') // Remove command substitution pattern $()\n .replace(/\\.\\./g, '') // Remove directory traversal\n .replace(/\\/..\\//g, '') // Remove path traversal\n .replace(/\\0/g, '') // Remove null bytes\n \n return cleaned.trim()\n}\n\n/**\n * Extract UTM parameters from URL search params\n */\nexport function extractUTMParams(searchParams: URLSearchParams): Record<string, string> {\n const utmParams: Record<string, string> = {}\n const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']\n\n utmKeys.forEach(key => {\n const value = searchParams.get(key)\n if (value) {\n // Remove 'utm_' prefix for storage\n const shortKey = key.replace('utm_', '')\n utmParams[shortKey] = value\n }\n })\n\n return utmParams\n}\n\n/**\n * Validate source field - only allow predefined values\n */\nexport function isValidSource(source: string): boolean {\n if (!source || typeof source !== 'string') return false\n \n const allowedSources = [\n 'website',\n 'api',\n 'import',\n 'admin',\n 'signup-form',\n 'magic-link',\n 'preferences',\n 'external'\n ]\n \n return allowedSources.includes(source)\n}\n\n/**\n * Validate subscriber data before creation\n */\nexport interface ValidateSubscriberResult {\n valid: boolean\n errors: string[]\n}\n\nexport function validateSubscriberData(data: any): ValidateSubscriberResult {\n const errors: string[] = []\n\n // Email validation\n if (!data.email) {\n errors.push('Email is required')\n } else if (!isValidEmail(data.email)) {\n errors.push('Invalid email format')\n }\n\n // Name validation (optional but if provided, should be reasonable)\n if (data.name && data.name.length > 100) {\n errors.push('Name is too long (max 100 characters)')\n }\n\n // Source validation\n if (data.source !== undefined) {\n if (!data.source || data.source.length === 0) {\n errors.push('Source cannot be empty')\n } else if (data.source.length > 50) {\n errors.push('Source is too long (max 50 characters)')\n } else if (!isValidSource(data.source)) {\n errors.push('Invalid source value')\n }\n }\n\n return {\n valid: errors.length === 0,\n errors,\n }\n}","import type { Endpoint, PayloadHandler } from 'payload'\nimport type { NewsletterPluginConfig } from '../types'\nimport { \n isDomainAllowed, \n sanitizeInput, \n validateSubscriberData,\n extractUTMParams \n} from '../utils/validation'\n\nexport const createSubscribeEndpoint = (\n config: NewsletterPluginConfig\n): Endpoint => {\n return {\n path: '/newsletter/subscribe',\n method: 'post',\n handler: (async (req: any, res: any) => {\n try {\n const { \n email, \n name, \n source,\n preferences,\n leadMagnet,\n surveyResponses,\n metadata = {}\n } = req.body\n\n // Trim email before validation\n const trimmedEmail = email?.trim()\n\n // Validate input\n const validation = validateSubscriberData({ email: trimmedEmail, name, source })\n if (!validation.valid) {\n return res.status(400).json({\n success: false,\n errors: validation.errors,\n })\n }\n\n // Check domain restrictions from global settings\n // Settings are public info needed for validation, but we can still respect access control\n const settings = await req.payload.findGlobal({\n slug: config.settingsSlug || 'newsletter-settings',\n overrideAccess: false,\n // No user context for public endpoint\n })\n\n const allowedDomains = settings?.subscriptionSettings?.allowedDomains?.map((d: any) => d.domain) || []\n if (!isDomainAllowed(trimmedEmail, allowedDomains)) {\n return res.status(400).json({\n success: false,\n error: 'Email domain not allowed',\n })\n }\n\n // Check if already subscribed\n // This needs admin access to check for existing email\n const existing = await req.payload.find({\n collection: config.subscribersSlug || 'subscribers',\n where: {\n