payload-plugin-newsletter
Version:
Complete newsletter management plugin for Payload CMS with subscriber management, magic link authentication, and email service integration
1 lines • 269 kB
Source Map (JSON)
{"version":3,"sources":["../src/types/newsletter.ts","../src/types/broadcast.ts","../src/types/providers.ts","../src/types/index.ts","../src/providers/broadcast/broadcast.ts","../src/exports/collections.ts","../src/collections/Broadcasts.ts","../src/fields/emailContent.ts","../src/utils/blockValidation.ts","../src/fields/broadcastInlinePreview.ts","../src/fields/broadcastSchedule.ts","../src/utils/emailSafeHtml.ts","../src/utils/getBroadcastConfig.ts","../src/utils/getProvider.ts","../src/utils/mediaPopulation.ts","../src/endpoints/broadcasts/preview.ts","../src/utils/getErrorMessage.ts","../src/utils/broadcast-sync.ts","../src/utils/scheduling-state.ts","../src/utils/idempotency.ts","../src/endpoints/broadcasts/send.ts","../src/utils/access.ts","../src/utils/auth.ts","../src/endpoints/broadcasts/schedule.ts","../src/endpoints/broadcasts/retry-sync.ts","../src/endpoints/broadcasts/test.ts","../src/emails/render.tsx","../src/emails/MagicLink.tsx","../src/emails/styles.ts","../src/emails/Welcome.tsx","../src/emails/SignIn.tsx","../src/collections/Subscribers.ts"],"sourcesContent":["/**\n * Core types for newsletter management functionality\n */\n\n/**\n * Represents a newsletter/broadcast in the system\n */\nexport interface Newsletter {\n id: string;\n name: string;\n subject: string;\n preheader?: string;\n content: string; // HTML content\n status: NewsletterStatus;\n trackOpens: boolean;\n trackClicks: boolean;\n replyTo?: string;\n recipientCount?: number;\n sentAt?: Date;\n scheduledAt?: Date;\n createdAt: Date;\n updatedAt: Date;\n // Provider-specific data stored here\n providerData?: Record<string, any>;\n // Provider information\n providerId?: string;\n providerType?: 'broadcast' | 'resend';\n}\n\n/**\n * Possible statuses for a newsletter\n */\nexport enum NewsletterStatus {\n DRAFT = 'draft',\n SCHEDULED = 'scheduled',\n SENDING = 'sending',\n SENT = 'sent',\n FAILED = 'failed',\n PAUSED = 'paused',\n CANCELED = 'canceled'\n}\n\n/**\n * Options for listing newsletters\n */\nexport interface ListNewsletterOptions {\n limit?: number;\n offset?: number;\n status?: NewsletterStatus;\n sortBy?: 'createdAt' | 'updatedAt' | 'sentAt' | 'name';\n sortOrder?: 'asc' | 'desc';\n}\n\n/**\n * Response from listing newsletters\n */\nexport interface ListNewsletterResponse<T = Newsletter> {\n items: T[];\n total: number;\n limit: number;\n offset: number;\n hasMore: boolean;\n}\n\n/**\n * Input for creating a new newsletter\n */\nexport interface CreateNewsletterInput {\n name: string;\n subject: string;\n preheader?: string;\n content: string;\n trackOpens?: boolean;\n trackClicks?: boolean;\n replyTo?: string;\n audienceIds?: string[]; // Maps to segments/audiences\n}\n\n/**\n * Input for updating an existing newsletter\n */\nexport interface UpdateNewsletterInput {\n name?: string;\n subject?: string;\n preheader?: string;\n content?: string;\n trackOpens?: boolean;\n trackClicks?: boolean;\n replyTo?: string;\n audienceIds?: string[];\n}\n\n/**\n * Options for sending a newsletter\n */\nexport interface SendNewsletterOptions {\n audienceIds?: string[]; // Target specific audiences\n testMode?: boolean; // Send test email\n testRecipients?: string[]; // Email addresses for test send\n}\n\n/**\n * Analytics data for a newsletter\n */\nexport interface NewsletterAnalytics {\n sent: number;\n delivered: number;\n opened: number;\n clicked: number;\n bounced: number;\n complained: number;\n unsubscribed: number;\n deliveryRate?: number;\n openRate?: number;\n clickRate?: number;\n bounceRate?: number;\n}\n\n/**\n * Capabilities that a newsletter provider supports\n */\nexport interface NewsletterProviderCapabilities {\n supportsScheduling: boolean;\n supportsSegmentation: boolean;\n supportsAnalytics: boolean;\n supportsABTesting: boolean;\n supportsTemplates: boolean;\n supportsPersonalization: boolean;\n maxRecipientsPerSend?: number;\n editableStatuses: NewsletterStatus[];\n supportedContentTypes: ('html' | 'text' | 'react')[];\n}\n\n/**\n * Error types specific to newsletter operations\n */\nexport class NewsletterProviderError extends Error {\n constructor(\n message: string,\n public code: NewsletterErrorCode,\n public provider: string,\n public details?: any\n ) {\n super(message);\n this.name = 'NewsletterProviderError';\n }\n}\n\nexport enum NewsletterErrorCode {\n NOT_SUPPORTED = 'NOT_SUPPORTED',\n INVALID_STATUS = 'INVALID_STATUS',\n PROVIDER_ERROR = 'PROVIDER_ERROR',\n VALIDATION_ERROR = 'VALIDATION_ERROR',\n NOT_FOUND = 'NOT_FOUND',\n PERMISSION_DENIED = 'PERMISSION_DENIED',\n RATE_LIMITED = 'RATE_LIMITED',\n CONFIGURATION_ERROR = 'CONFIGURATION_ERROR'\n}\n\n/**\n * Newsletter template for reusable content\n */\nexport interface NewsletterTemplate {\n id: string;\n name: string;\n description?: string;\n content: string;\n variables?: NewsletterTemplateVariable[];\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface NewsletterTemplateVariable {\n name: string;\n type: 'text' | 'html' | 'image' | 'url';\n defaultValue?: string;\n required?: boolean;\n}","/**\n * Core types for broadcast management functionality\n */\n\n/**\n * Represents a broadcast (individual email campaign) in the system\n */\nexport interface Broadcast {\n id: string;\n name: string;\n subject: string;\n preheader?: string;\n content: string; // HTML content\n sendStatus: BroadcastStatus;\n trackOpens: boolean;\n trackClicks: boolean;\n replyTo?: string;\n recipientCount?: number;\n sentAt?: Date;\n scheduledAt?: Date;\n createdAt: Date;\n updatedAt: Date;\n // Provider-specific data stored here\n providerData?: Record<string, any>;\n // Provider information\n providerId?: string;\n providerType?: 'broadcast' | 'resend';\n}\n\n/**\n * Possible statuses for a broadcast\n */\nexport enum BroadcastStatus {\n DRAFT = 'draft',\n SCHEDULED = 'scheduled',\n SENDING = 'sending',\n SENT = 'sent',\n FAILED = 'failed',\n PAUSED = 'paused',\n CANCELED = 'canceled'\n}\n\n/**\n * Options for listing broadcasts\n */\nexport interface ListBroadcastOptions {\n limit?: number;\n offset?: number;\n status?: BroadcastStatus;\n sortBy?: 'createdAt' | 'updatedAt' | 'sentAt' | 'name';\n sortOrder?: 'asc' | 'desc';\n}\n\n/**\n * Response from listing broadcasts\n */\nexport interface ListBroadcastResponse<T = Broadcast> {\n items: T[];\n total: number;\n limit: number;\n offset: number;\n hasMore: boolean;\n}\n\n/**\n * Input for creating a new broadcast\n */\nexport interface CreateBroadcastInput {\n name: string;\n subject: string;\n preheader?: string;\n content: string;\n trackOpens?: boolean;\n trackClicks?: boolean;\n replyTo?: string;\n audienceIds?: string[]; // Maps to segments/audiences\n}\n\n/**\n * Input for updating an existing broadcast\n */\nexport interface UpdateBroadcastInput {\n name?: string;\n subject?: string;\n preheader?: string;\n content?: string;\n trackOpens?: boolean;\n trackClicks?: boolean;\n replyTo?: string;\n audienceIds?: string[];\n}\n\n/**\n * Options for sending a broadcast\n */\nexport interface SendBroadcastOptions {\n audienceIds?: string[]; // Target specific audiences\n testMode?: boolean; // Send test email\n testRecipients?: string[]; // Email addresses for test send\n}\n\n/**\n * Analytics data for a broadcast\n */\nexport interface BroadcastAnalytics {\n sent: number;\n delivered: number;\n opened: number;\n clicked: number;\n bounced: number;\n complained: number;\n unsubscribed: number;\n deliveryRate?: number;\n openRate?: number;\n clickRate?: number;\n bounceRate?: number;\n}\n\n/**\n * Capabilities that a broadcast provider supports\n */\nexport interface BroadcastProviderCapabilities {\n supportsScheduling: boolean;\n supportsSegmentation: boolean;\n supportsAnalytics: boolean;\n supportsABTesting: boolean;\n supportsTemplates: boolean;\n supportsPersonalization: boolean;\n maxRecipientsPerSend?: number;\n editableStatuses: BroadcastStatus[];\n supportedContentTypes: ('html' | 'text' | 'react')[];\n supportsMultipleChannels: boolean;\n supportsChannelSegmentation: boolean;\n}\n\n/**\n * Error types specific to broadcast operations\n */\nexport class BroadcastProviderError extends Error {\n constructor(\n message: string,\n public code: BroadcastErrorCode,\n public provider: string,\n public details?: any\n ) {\n super(message);\n this.name = 'BroadcastProviderError';\n }\n}\n\nexport enum BroadcastErrorCode {\n NOT_SUPPORTED = 'NOT_SUPPORTED',\n INVALID_STATUS = 'INVALID_STATUS',\n PROVIDER_ERROR = 'PROVIDER_ERROR',\n VALIDATION_ERROR = 'VALIDATION_ERROR',\n NOT_FOUND = 'NOT_FOUND',\n PERMISSION_DENIED = 'PERMISSION_DENIED',\n RATE_LIMITED = 'RATE_LIMITED',\n CONFIGURATION_ERROR = 'CONFIGURATION_ERROR'\n}\n\n/**\n * Broadcast template for reusable content\n */\nexport interface BroadcastTemplate {\n id: string;\n name: string;\n description?: string;\n content: string;\n variables?: BroadcastTemplateVariable[];\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface BroadcastTemplateVariable {\n name: string;\n type: 'text' | 'html' | 'image' | 'url';\n defaultValue?: string;\n required?: boolean;\n}\n\n/**\n * Audience ID field structure used in broadcast documents\n */\nexport interface AudienceIdField {\n audienceId: string;\n}\n\n/**\n * Data required for creating a broadcast in the provider\n */\nexport interface ProviderCreateData {\n name: string;\n subject: string;\n preheader: string;\n content: string;\n trackOpens: boolean;\n trackClicks: boolean;\n replyTo?: string;\n audienceIds: string[];\n}\n\n/**\n * Result from syncing a broadcast to the provider (discriminated union)\n */\nexport type ProviderSyncResult = {\n success: true;\n providerId: string;\n externalId: string;\n providerData: unknown;\n} | {\n success: false;\n error: string;\n}\n\n/**\n * Document shape for broadcast in afterChange hook\n */\nexport interface BroadcastDocument {\n id: string;\n subject?: string | null;\n contentSection?: {\n content?: unknown;\n preheader?: string;\n } | null;\n settings?: {\n trackOpens?: boolean;\n trackClicks?: boolean;\n replyTo?: string;\n };\n audienceIds?: AudienceIdField[];\n providerId?: string | null;\n externalId?: string | null;\n providerData?: unknown;\n providerSyncStatus?: 'pending' | 'synced' | 'failed';\n providerSyncError?: string | null;\n}\n\n// Re-export newsletter types with deprecation notice for backwards compatibility\nexport {\n NewsletterStatus,\n type ListNewsletterOptions,\n type ListNewsletterResponse,\n type CreateNewsletterInput,\n type UpdateNewsletterInput,\n type SendNewsletterOptions,\n type NewsletterAnalytics,\n type NewsletterProviderCapabilities,\n NewsletterProviderError,\n NewsletterErrorCode,\n type NewsletterTemplate,\n type NewsletterTemplateVariable\n} from './newsletter';","/**\n * Provider interfaces for broadcast management\n */\n\n// Import broadcast types\nimport type {\n Broadcast,\n BroadcastStatus,\n ListBroadcastOptions,\n ListBroadcastResponse,\n CreateBroadcastInput,\n UpdateBroadcastInput,\n SendBroadcastOptions,\n BroadcastAnalytics,\n BroadcastProviderCapabilities\n} from './broadcast'\n\nimport {\n BroadcastProviderError,\n BroadcastErrorCode\n} from './broadcast'\n\n\n// Import legacy newsletter types for backwards compatibility\nimport type {\n Newsletter,\n NewsletterStatus,\n ListNewsletterOptions,\n ListNewsletterResponse,\n CreateNewsletterInput,\n UpdateNewsletterInput,\n SendNewsletterOptions,\n NewsletterAnalytics,\n NewsletterProviderCapabilities\n} from './newsletter'\n\nimport {\n NewsletterProviderError,\n NewsletterErrorCode\n} from './newsletter'\n\n/**\n * Main interface for broadcast providers\n */\nexport interface BroadcastProvider {\n /**\n * Get the provider name\n */\n readonly name: string;\n\n // Broadcast management methods\n /**\n * List broadcasts with pagination\n */\n list(options?: ListBroadcastOptions): Promise<ListBroadcastResponse<Broadcast>>;\n \n /**\n * Get a specific broadcast by ID\n */\n get(id: string): Promise<Broadcast>;\n \n /**\n * Create a new broadcast\n */\n create(data: CreateBroadcastInput): Promise<Broadcast>;\n \n /**\n * Update an existing broadcast\n */\n update(id: string, data: UpdateBroadcastInput): Promise<Broadcast>;\n \n /**\n * Delete a broadcast\n */\n delete(id: string): Promise<void>;\n \n /**\n * Send a broadcast immediately or to test recipients\n */\n send(id: string, options?: SendBroadcastOptions): Promise<Broadcast>;\n \n /**\n * Schedule a broadcast for future sending\n */\n schedule(id: string, scheduledAt: Date): Promise<Broadcast>;\n \n /**\n * Cancel a scheduled broadcast\n */\n cancelSchedule(id: string): Promise<Broadcast>;\n \n /**\n * Get analytics for a broadcast\n */\n getAnalytics(id: string): Promise<BroadcastAnalytics>;\n \n /**\n * Get provider capabilities\n */\n getCapabilities(): BroadcastProviderCapabilities;\n \n /**\n * Validate that the provider is properly configured\n */\n validateConfiguration(): Promise<boolean>;\n}\n\n/**\n * Legacy newsletter provider interface for backwards compatibility\n * @deprecated Use BroadcastProvider instead\n */\nexport interface NewsletterProvider {\n /**\n * Get the provider name\n */\n readonly name: string;\n\n /**\n * List newsletters with pagination\n */\n list(options?: ListNewsletterOptions): Promise<ListNewsletterResponse<Newsletter>>;\n \n /**\n * Get a specific newsletter by ID\n */\n get(id: string): Promise<Newsletter>;\n \n /**\n * Create a new newsletter\n */\n create(data: CreateNewsletterInput): Promise<Newsletter>;\n \n /**\n * Update an existing newsletter\n */\n update(id: string, data: UpdateNewsletterInput): Promise<Newsletter>;\n \n /**\n * Delete a newsletter\n */\n delete(id: string): Promise<void>;\n \n /**\n * Send a newsletter immediately or to test recipients\n */\n send(id: string, options?: SendNewsletterOptions): Promise<Newsletter>;\n \n /**\n * Schedule a newsletter for future sending\n */\n schedule(id: string, scheduledAt: Date): Promise<Newsletter>;\n \n /**\n * Cancel a scheduled newsletter\n */\n cancelSchedule(id: string): Promise<Newsletter>;\n \n /**\n * Get analytics for a newsletter\n */\n getAnalytics(id: string): Promise<NewsletterAnalytics>;\n \n /**\n * Get provider capabilities\n */\n getCapabilities(): NewsletterProviderCapabilities;\n \n /**\n * Validate that the provider is properly configured\n */\n validateConfiguration(): Promise<boolean>;\n}\n\n/**\n * Base abstract class for broadcast providers\n */\nexport abstract class BaseBroadcastProvider implements BroadcastProvider {\n abstract readonly name: string;\n \n constructor(protected config: any) {}\n \n // Broadcast management - abstract methods\n abstract list(options?: ListBroadcastOptions): Promise<ListBroadcastResponse<Broadcast>>;\n abstract get(id: string): Promise<Broadcast>;\n abstract create(data: CreateBroadcastInput): Promise<Broadcast>;\n abstract update(id: string, data: UpdateBroadcastInput): Promise<Broadcast>;\n abstract delete(id: string): Promise<void>;\n abstract send(id: string, options?: SendBroadcastOptions): Promise<Broadcast>;\n abstract getCapabilities(): BroadcastProviderCapabilities;\n abstract validateConfiguration(): Promise<boolean>;\n \n /**\n * Schedule a broadcast - default implementation throws not supported\n */\n async schedule(_id: string, _scheduledAt: Date): Promise<Broadcast> {\n const capabilities = this.getCapabilities();\n if (!capabilities.supportsScheduling) {\n throw new BroadcastProviderError(\n 'Scheduling is not supported by this provider',\n BroadcastErrorCode.NOT_SUPPORTED,\n this.name\n );\n }\n throw new Error('Method not implemented');\n }\n \n /**\n * Cancel scheduled broadcast - default implementation throws not supported\n */\n async cancelSchedule(_id: string): Promise<Broadcast> {\n const capabilities = this.getCapabilities();\n if (!capabilities.supportsScheduling) {\n throw new BroadcastProviderError(\n 'Scheduling is not supported by this provider',\n BroadcastErrorCode.NOT_SUPPORTED,\n this.name\n );\n }\n throw new Error('Method not implemented');\n }\n \n /**\n * Get analytics - default implementation returns zeros\n */\n async getAnalytics(_id: string): Promise<BroadcastAnalytics> {\n const capabilities = this.getCapabilities();\n if (!capabilities.supportsAnalytics) {\n throw new BroadcastProviderError(\n 'Analytics are not supported by this provider',\n BroadcastErrorCode.NOT_SUPPORTED,\n this.name\n );\n }\n \n return {\n sent: 0,\n delivered: 0,\n opened: 0,\n clicked: 0,\n bounced: 0,\n complained: 0,\n unsubscribed: 0\n };\n }\n \n /**\n * Helper method to validate required fields\n */\n protected validateRequiredFields(data: any, fields: string[]): void {\n const missing = fields.filter(field => !data[field]);\n if (missing.length > 0) {\n throw new BroadcastProviderError(\n `Missing required fields: ${missing.join(', ')}`,\n BroadcastErrorCode.VALIDATION_ERROR,\n this.name\n );\n }\n }\n \n /**\n * Helper method to check if a status transition is allowed\n */\n protected canEditInStatus(status: BroadcastStatus): boolean {\n const capabilities = this.getCapabilities();\n return capabilities.editableStatuses.includes(status);\n }\n \n /**\n * Helper to build pagination response\n */\n protected buildListResponse<T>(\n items: T[],\n total: number,\n options: ListBroadcastOptions = {}\n ): ListBroadcastResponse<T> {\n const limit = options.limit || 20;\n const offset = options.offset || 0;\n \n return {\n items,\n total,\n limit,\n offset,\n hasMore: offset + items.length < total\n };\n }\n}\n\n/**\n * Base abstract class for newsletter providers\n * @deprecated Use BaseBroadcastProvider instead\n */\nexport abstract class BaseNewsletterProvider implements NewsletterProvider {\n abstract readonly name: string;\n \n constructor(protected config: any) {}\n \n abstract list(options?: ListNewsletterOptions): Promise<ListNewsletterResponse<Newsletter>>;\n abstract get(id: string): Promise<Newsletter>;\n abstract create(data: CreateNewsletterInput): Promise<Newsletter>;\n abstract update(id: string, data: UpdateNewsletterInput): Promise<Newsletter>;\n abstract delete(id: string): Promise<void>;\n abstract send(id: string, options?: SendNewsletterOptions): Promise<Newsletter>;\n abstract getCapabilities(): NewsletterProviderCapabilities;\n abstract validateConfiguration(): Promise<boolean>;\n \n /**\n * Schedule a newsletter - default implementation throws not supported\n */\n async schedule(_id: string, _scheduledAt: Date): Promise<Newsletter> {\n const capabilities = this.getCapabilities();\n if (!capabilities.supportsScheduling) {\n throw new NewsletterProviderError(\n 'Scheduling is not supported by this provider',\n NewsletterErrorCode.NOT_SUPPORTED,\n this.name\n );\n }\n throw new Error('Method not implemented');\n }\n \n /**\n * Cancel scheduled newsletter - default implementation throws not supported\n */\n async cancelSchedule(_id: string): Promise<Newsletter> {\n const capabilities = this.getCapabilities();\n if (!capabilities.supportsScheduling) {\n throw new NewsletterProviderError(\n 'Scheduling is not supported by this provider',\n NewsletterErrorCode.NOT_SUPPORTED,\n this.name\n );\n }\n throw new Error('Method not implemented');\n }\n \n /**\n * Get analytics - default implementation returns zeros\n */\n async getAnalytics(_id: string): Promise<NewsletterAnalytics> {\n const capabilities = this.getCapabilities();\n if (!capabilities.supportsAnalytics) {\n throw new NewsletterProviderError(\n 'Analytics are not supported by this provider',\n NewsletterErrorCode.NOT_SUPPORTED,\n this.name\n );\n }\n \n return {\n sent: 0,\n delivered: 0,\n opened: 0,\n clicked: 0,\n bounced: 0,\n complained: 0,\n unsubscribed: 0\n };\n }\n \n /**\n * Helper method to validate required fields\n */\n protected validateRequiredFields(data: any, fields: string[]): void {\n const missing = fields.filter(field => !data[field]);\n if (missing.length > 0) {\n throw new NewsletterProviderError(\n `Missing required fields: ${missing.join(', ')}`,\n NewsletterErrorCode.VALIDATION_ERROR,\n this.name\n );\n }\n }\n \n /**\n * Helper method to check if a status transition is allowed\n */\n protected canEditInStatus(status: NewsletterStatus): boolean {\n const capabilities = this.getCapabilities();\n return capabilities.editableStatuses.includes(status);\n }\n \n /**\n * Helper to build pagination response\n */\n protected buildListResponse<T>(\n items: T[],\n total: number,\n options: ListNewsletterOptions = {}\n ): ListNewsletterResponse<T> {\n const limit = options.limit || 20;\n const offset = options.offset || 0;\n \n return {\n items,\n total,\n limit,\n offset,\n hasMore: offset + items.length < total\n };\n }\n}","import type { Field, Block, RichTextField } from 'payload'\nimport type { BroadcastProvider } from './providers'\n\n// Export broadcast types\nexport * from './broadcast'\nexport * from './providers'\n// Export legacy newsletter types for backwards compatibility\nexport * from './newsletter'\n\n// Email wrapper options interface\nexport interface EmailWrapperOptions {\n preheader?: string\n subject?: string\n documentData?: Record<string, any> // Generic document data\n}\n\n// Re-export for convenience\nexport type CustomEmailWrapper = (\n content: string, \n options?: EmailWrapperOptions\n) => string | Promise<string>\n\n// Add new interface for broadcast customizations\nexport interface BroadcastCustomizations {\n additionalFields?: Field[]\n customBlocks?: Block[]\n fieldOverrides?: {\n content?: (defaultField: RichTextField) => RichTextField\n }\n /**\n * Custom block email converter\n * @param node - The block node from Lexical editor state\n * @param mediaUrl - Base URL for media files\n * @returns Promise<string> - The email-safe HTML for the block\n */\n customBlockConverter?: (node: any, mediaUrl?: string) => Promise<string>\n \n /**\n * Fields to populate in custom blocks before email conversion\n * Can be an array of field names or a function that returns field names based on block type\n * This is useful for upload fields that need to be populated with full media objects\n * \n * @example\n * // Array of field names to always populate\n * populateFields: ['bannerImage', 'sponsorLogo']\n * \n * @example\n * // Function to return fields based on block type\n * populateFields: (blockType) => {\n * if (blockType === 'newsletter-hero') return ['bannerImage', 'sponsorLogo']\n * if (blockType === 'content-section') return ['featuredImage']\n * return []\n * }\n */\n populateFields?: string[] | ((blockType: string) => string[])\n \n /**\n * Email preview customization options\n */\n emailPreview?: {\n /**\n * Whether to wrap preview content in default email template\n * @default true\n */\n wrapInTemplate?: boolean\n \n /**\n * Custom wrapper function for preview content\n * Receives the converted HTML and should return wrapped HTML\n */\n customWrapper?: (content: string, options?: EmailWrapperOptions) => string | Promise<string>\n \n /**\n * Custom preview component to replace the default one entirely\n * If provided, this component will be used instead of the default EmailPreview\n */\n customPreviewComponent?: string // Path to custom component for import map\n }\n}\n\nexport interface NewsletterPluginConfig {\n /**\n * Enable or disable the plugin\n * @default true\n */\n enabled?: boolean\n\n /**\n * Slug for the subscribers collection\n * @default 'subscribers'\n */\n subscribersSlug?: string\n \n /**\n * Slug for the newsletter settings global\n * @default 'newsletter-settings'\n */\n settingsSlug?: string\n\n /**\n * Authentication configuration for magic links\n */\n auth?: {\n /**\n * Enable magic link authentication\n * @default true\n */\n enabled?: boolean\n \n /**\n * Token expiration time\n * @default '7d'\n */\n tokenExpiration?: string\n \n /**\n * Path where magic link redirects\n * @default '/newsletter/verify'\n */\n magicLinkPath?: string\n \n /**\n * Allow unsubscribed users to sign in\n * @default false\n */\n allowUnsubscribedSignin?: boolean\n \n /**\n * Allow unsubscribed users to resubscribe\n * @default false\n */\n allowResubscribe?: boolean\n }\n\n /**\n * Access control configuration\n */\n access?: {\n /**\n * Custom function to determine if a user is an admin\n * @param user - The authenticated user object\n * @returns true if the user should have admin access\n */\n isAdmin?: (user: any) => boolean\n }\n\n /**\n * Email provider configuration\n */\n providers: {\n /**\n * Default provider to use\n */\n default: 'resend' | 'broadcast' | string\n \n /**\n * Resend provider configuration\n */\n resend?: ResendProviderConfig\n \n /**\n * Broadcast provider configuration\n */\n broadcast?: BroadcastProviderConfig\n }\n\n /**\n * Field customization options\n */\n fields?: {\n /**\n * Override default fields\n */\n overrides?: (args: { defaultFields: Field[] }) => Field[]\n \n /**\n * Additional custom fields\n */\n additional?: Field[]\n }\n\n /**\n * Email template components\n */\n templates?: {\n /**\n * Welcome email template\n */\n welcome?: React.ComponentType<WelcomeEmailProps>\n \n /**\n * Magic link email template\n */\n magicLink?: React.ComponentType<MagicLinkEmailProps>\n }\n\n /**\n * Plugin hooks\n */\n hooks?: {\n beforeSubscribe?: (args: BeforeSubscribeArgs) => void | Promise<void>\n afterSubscribe?: (args: AfterSubscribeArgs) => void | Promise<void>\n beforeUnsubscribe?: (args: BeforeUnsubscribeArgs) => void | Promise<void>\n afterUnsubscribe?: (args: AfterUnsubscribeArgs) => void | Promise<void>\n }\n\n /**\n * UI component overrides\n */\n components?: {\n signupForm?: React.ComponentType<SignupFormProps>\n preferencesForm?: React.ComponentType<PreferencesFormProps>\n }\n\n /**\n * Feature flags\n */\n features?: {\n /**\n * Lead magnet configuration\n */\n leadMagnets?: {\n enabled?: boolean\n collection?: string\n }\n \n /**\n * Post-signup survey configuration\n */\n surveys?: {\n enabled?: boolean\n questions?: SurveyQuestion[]\n }\n \n /**\n * UTM tracking configuration\n */\n utmTracking?: {\n enabled?: boolean\n fields?: string[]\n }\n \n /**\n * Newsletter scheduling configuration\n */\n newsletterScheduling?: {\n enabled?: boolean\n /**\n * Collections to add newsletter fields to\n * Can be a string for single collection or array for multiple\n * @example 'articles' or ['articles', 'posts', 'updates']\n */\n collections?: string | string[]\n /**\n * Field configuration\n */\n fields?: {\n /**\n * Group name for newsletter fields\n * @default 'newsletterScheduling'\n */\n groupName?: string\n /**\n * Rich text field name to use for content\n * @default 'content'\n */\n contentField?: string\n /**\n * Whether to create a markdown companion field\n * @default true\n */\n createMarkdownField?: boolean\n }\n }\n \n \n /**\n * Newsletter management configuration\n */\n newsletterManagement?: {\n /**\n * Enable newsletter management features\n * @default false\n */\n enabled?: boolean\n /**\n * Collection names for broadcast management\n */\n collections?: {\n /**\n * Broadcasts collection slug\n * @default 'broadcasts'\n */\n broadcasts?: string\n }\n /**\n * Optional: Custom broadcast provider implementation\n * If not provided, will use the default email provider\n */\n provider?: BroadcastProvider\n }\n }\n\n /**\n * Internationalization configuration\n */\n i18n?: {\n defaultLocale?: string\n locales?: string[]\n }\n\n /**\n * Custom email templates\n */\n customTemplates?: {\n [key: string]: React.ComponentType<any>\n }\n\n /**\n * Customization options for plugin collections\n */\n customizations?: {\n broadcasts?: BroadcastCustomizations\n }\n}\n\nexport interface ResendProviderConfig {\n apiKey: string\n fromEmail?: string\n fromAddress?: string // Alias for fromEmail\n fromName?: string\n replyTo?: string\n audienceIds?: {\n [locale: string]: {\n production?: string\n development?: string\n }\n }\n}\n\nexport interface BroadcastProviderConfig {\n apiUrl: string\n token: string\n fromEmail?: string\n fromAddress?: string // Alias for fromEmail\n fromName?: string\n replyTo?: string\n}\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}\n\nexport interface SendEmailParams {\n to: string | string[]\n subject: string\n html?: string\n text?: string\n react?: React.ReactElement\n}\n\nexport interface Subscriber {\n id: string\n email: string\n name?: string\n locale?: string\n subscriptionStatus: 'active' | 'unsubscribed' | 'pending'\n emailPreferences?: {\n newsletter?: boolean\n announcements?: boolean\n [key: string]: boolean | undefined\n }\n source?: string\n importedFromProvider?: boolean\n utmParameters?: {\n source?: string\n medium?: string\n campaign?: string\n content?: string\n term?: string\n }\n // Additional fields that may exist in the database\n signupMetadata?: {\n ipAddress?: string\n userAgent?: string\n referrer?: string\n signupPage?: string\n }\n leadMagnet?: string\n unsubscribedAt?: string\n magicLinkToken?: string\n magicLinkTokenExpiry?: string\n createdAt: string\n updatedAt: string\n}\n\nexport interface WelcomeEmailProps {\n subscriber: Subscriber\n unsubscribeUrl: string\n preferencesUrl: string\n}\n\nexport interface MagicLinkEmailProps {\n magicLinkUrl: string\n subscriber: Subscriber\n}\n\nexport interface SignupFormProps {\n onSuccess?: (subscriber: Subscriber) => void\n onError?: (error: Error) => void\n showName?: boolean\n showPreferences?: boolean\n leadMagnet?: {\n id: string\n title: string\n description?: string\n }\n className?: string\n styles?: {\n form?: React.CSSProperties\n inputGroup?: React.CSSProperties\n label?: React.CSSProperties\n input?: React.CSSProperties\n button?: React.CSSProperties\n buttonDisabled?: React.CSSProperties\n error?: React.CSSProperties\n success?: React.CSSProperties\n checkbox?: React.CSSProperties\n checkboxInput?: React.CSSProperties\n checkboxLabel?: React.CSSProperties\n }\n apiEndpoint?: string\n buttonText?: string\n loadingText?: string\n successMessage?: string\n placeholders?: {\n email?: string\n name?: string\n }\n labels?: {\n email?: string\n name?: string\n newsletter?: string\n announcements?: string\n }\n}\n\nexport interface PreferencesFormProps {\n subscriber?: Subscriber\n onSuccess?: (subscriber: Subscriber) => void\n onError?: (error: Error) => void\n className?: string\n styles?: {\n container?: React.CSSProperties\n heading?: React.CSSProperties\n form?: React.CSSProperties\n section?: React.CSSProperties\n sectionTitle?: React.CSSProperties\n inputGroup?: React.CSSProperties\n label?: React.CSSProperties\n input?: React.CSSProperties\n select?: React.CSSProperties\n checkbox?: React.CSSProperties\n checkboxInput?: React.CSSProperties\n checkboxLabel?: React.CSSProperties\n buttonGroup?: React.CSSProperties\n button?: React.CSSProperties\n primaryButton?: React.CSSProperties\n secondaryButton?: React.CSSProperties\n dangerButton?: React.CSSProperties\n error?: React.CSSProperties\n success?: React.CSSProperties\n info?: React.CSSProperties\n }\n sessionToken?: string\n apiEndpoint?: string\n showUnsubscribe?: boolean\n locales?: string[]\n labels?: {\n title?: string\n personalInfo?: string\n emailPreferences?: string\n name?: string\n language?: string\n newsletter?: string\n announcements?: string\n saveButton?: string\n unsubscribeButton?: string\n saving?: string\n saved?: string\n unsubscribeConfirm?: string\n }\n}\n\nexport interface BeforeSubscribeArgs {\n data: Partial<Subscriber>\n req: any\n}\n\nexport interface AfterSubscribeArgs {\n doc: Subscriber\n req: any\n}\n\nexport interface BeforeUnsubscribeArgs {\n email: string\n req: any\n}\n\nexport interface AfterUnsubscribeArgs {\n doc: Subscriber\n req: any\n}\n\n\nexport interface SurveyQuestion {\n id: string\n question: string\n type: 'text' | 'select' | 'multiselect' | 'radio'\n options?: string[]\n required?: boolean\n}\n\n// Request data interfaces for endpoints\nexport interface SubscribeRequestData {\n email: string\n name?: string\n source?: string\n preferences?: { [key: string]: boolean }\n leadMagnet?: string\n surveyResponses?: { [key: string]: string | string[] }\n metadata?: {\n locale?: string\n signupPage?: string\n [key: string]: unknown\n }\n}\n\nexport interface UnsubscribeRequestData {\n email?: string\n token?: string\n}\n\nexport interface VerifyMagicLinkRequestData {\n token: string\n}\n\nexport interface SigninRequestData {\n email: string\n}\n\nexport interface UpdatePreferencesRequestData {\n name?: string\n locale?: string\n emailPreferences?: { [key: string]: boolean }\n}\n\n// Extended request types with proper data typing\nexport interface ExtendedPayloadRequest extends Request {\n payload: any // TODO: Add proper payload type\n data?: unknown\n ip?: string\n connection?: {\n remoteAddress?: string\n }\n cookies?: {\n [key: string]: string\n }\n // Headers are inherited from Request, but we document common ones for reference\n // Access via: req.headers.get('authorization'), req.headers.get('referer'), etc.\n}","import type { \n Broadcast,\n ListBroadcastOptions,\n ListBroadcastResponse,\n CreateBroadcastInput,\n UpdateBroadcastInput,\n SendBroadcastOptions,\n BroadcastAnalytics,\n BroadcastProviderCapabilities\n} from '../../types'\nimport { \n BroadcastProviderError, \n BroadcastErrorCode,\n BroadcastStatus,\n BaseBroadcastProvider\n} from '../../types'\nimport type { BroadcastProviderConfig } from '../../types'\n\ninterface BroadcastApiResponse {\n id: number\n name: string\n subject: string\n preheader?: string\n body: string\n status: string\n track_opens: boolean\n track_clicks: boolean\n html_body: boolean\n reply_to?: string\n total_recipients: number\n sent_at?: string\n scheduled_send_at?: string\n created_at: string\n updated_at: string\n}\n\ninterface BroadcastListResponse {\n data: BroadcastApiResponse[]\n total: number\n}\n\n\nexport class BroadcastApiProvider extends BaseBroadcastProvider {\n readonly name = 'broadcast'\n private apiUrl: string\n private token: string\n\n constructor(config: BroadcastProviderConfig) {\n super(config)\n this.apiUrl = config.apiUrl.replace(/\\/$/, '') // Remove trailing slash\n this.token = config.token\n \n if (!this.token) {\n throw new BroadcastProviderError(\n 'Broadcast API token is required',\n BroadcastErrorCode.CONFIGURATION_ERROR,\n this.name\n )\n }\n }\n\n // Broadcast Management Methods\n async list(options?: ListBroadcastOptions): Promise<ListBroadcastResponse<Broadcast>> {\n try {\n const params = new URLSearchParams()\n if (options?.limit) params.append('limit', options.limit.toString())\n if (options?.offset) params.append('offset', options.offset.toString())\n\n const response = await fetch(`${this.apiUrl}/api/v1/broadcasts?${params}`, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\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\n const data: BroadcastListResponse = await response.json()\n \n const broadcasts = data.data.map(broadcast => this.transformBroadcastFromApi(broadcast))\n \n return this.buildListResponse(broadcasts, data.total, options)\n } catch (error: unknown) {\n throw new BroadcastProviderError(\n `Failed to list broadcasts: ${error instanceof Error ? error.message : 'Unknown error'}`,\n BroadcastErrorCode.PROVIDER_ERROR,\n this.name,\n error\n )\n }\n }\n\n async get(id: string): Promise<Broadcast> {\n try {\n console.log('[BroadcastApiProvider] Getting broadcast with ID:', id)\n \n const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n })\n\n if (!response.ok) {\n if (response.status === 404) {\n throw new BroadcastProviderError(\n `Broadcast not found: ${id}`,\n BroadcastErrorCode.NOT_FOUND,\n this.name\n )\n }\n const error = await response.text()\n throw new Error(`Broadcast API error: ${response.status} - ${error}`)\n }\n\n const broadcast: BroadcastApiResponse = await response.json()\n console.log('[BroadcastApiProvider] GET response:', broadcast)\n return this.transformBroadcastFromApi(broadcast)\n } catch (error: unknown) {\n if (error instanceof BroadcastProviderError) throw error\n \n throw new BroadcastProviderError(\n `Failed to get broadcast: ${error instanceof Error ? error.message : 'Unknown error'}`,\n BroadcastErrorCode.PROVIDER_ERROR,\n this.name,\n error\n )\n }\n }\n\n async create(data: CreateBroadcastInput): Promise<Broadcast> {\n try {\n this.validateRequiredFields(data, ['name', 'subject', 'content'])\n\n const requestBody = {\n broadcast: {\n name: data.name,\n subject: data.subject,\n preheader: data.preheader,\n body: data.content,\n html_body: true,\n track_opens: data.trackOpens ?? true,\n track_clicks: data.trackClicks ?? true,\n reply_to: data.replyTo,\n segment_ids: data.audienceIds,\n }\n }\n\n // Log the request details (without exposing the token)\n console.log('[BroadcastApiProvider] Creating broadcast:', {\n url: `${this.apiUrl}/api/v1/broadcasts`,\n method: 'POST',\n hasToken: !!this.token,\n tokenLength: this.token?.length,\n body: JSON.stringify(requestBody, null, 2),\n })\n\n const response = await fetch(`${this.apiUrl}/api/v1/broadcasts`, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(requestBody),\n })\n\n console.log('[BroadcastApiProvider] Response status:', response.status)\n console.log('[BroadcastApiProvider] Response headers:', Object.fromEntries(response.headers.entries()))\n\n if (!response.ok) {\n const errorText = await response.text()\n console.error('[BroadcastApiProvider] Error response body:', errorText)\n \n // Try to parse as JSON if possible\n let errorDetails\n try {\n errorDetails = JSON.parse(errorText)\n console.error('[BroadcastApiProvider] Parsed error:', errorDetails)\n } catch {\n // Not JSON, use as is\n }\n \n throw new Error(`Broadcast API error: ${response.status} - ${errorText}`)\n }\n\n const responseText = await response.text()\n console.log('[BroadcastApiProvider] Success response body:', responseText)\n \n let result\n try {\n result = JSON.parse(responseText)\n } catch {\n throw new Error(`Failed to parse response as JSON: ${responseText}`)\n }\n \n console.log('[BroadcastApiProvider] Parsed result:', result)\n \n if (!result.id) {\n throw new Error(`Response missing expected 'id' field: ${JSON.stringify(result)}`)\n }\n \n // Broadcast API returns just {id: 123}, so we need to fetch the full object\n return this.get(result.id.toString())\n } catch (error: unknown) {\n if (error instanceof BroadcastProviderError) throw error\n \n throw new BroadcastProviderError(\n `Failed to create broadcast: ${error instanceof Error ? error.message : 'Unknown error'}`,\n BroadcastErrorCode.PROVIDER_ERROR,\n this.name,\n error\n )\n }\n }\n\n async update(id: string, data: UpdateBroadcastInput): Promise<Broadcast> {\n try {\n // First check if the broadcast can be edited\n const existing = await this.get(id)\n if (!this.canEditInStatus(existing.sendStatus)) {\n throw new BroadcastProviderError(\n `Cannot update broadcast in status: ${existing.sendStatus}`,\n BroadcastErrorCode.INVALID_STATUS,\n this.name\n )\n }\n\n const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, {\n method: 'PATCH',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n broadcast: {\n name: data.name,\n subject: data.subject,\n preheader: data.preheader,\n body: data.content,\n track_opens: data.trackOpens,\n track_clicks: data.trackClicks,\n reply_to: data.replyTo,\n segment_ids: data.audienceIds,\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\n const broadcast: BroadcastApiResponse = await response.json()\n return this.transformBroadcastFromApi(broadcast)\n } catch (error: unknown) {\n if (error instanceof BroadcastProviderError) throw error\n \n throw new BroadcastProviderError(\n `Failed to update broadcast: ${error instanceof Error ? error.message : 'Unknown error'}`,\n BroadcastErrorCode.PROVIDER_ERROR,\n this.name,\n error\n )\n }\n }\n\n async delete(id: string): Promise<void> {\n try {\n // First check if the broadcast can be deleted\n const existing = await this.get(id)\n if (!this.canEditInStatus(existing.sendStatus)) {\n throw new BroadcastProviderError(\n `Cannot delete broadcast in status: ${existing.sendStatus}`,\n BroadcastErrorCode.INVALID_STATUS,\n this.name\n )\n }\n\n const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, {\n method: 'DELETE',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\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 if (error instanceof BroadcastProviderError) throw error\n \n throw new BroadcastProviderError(\n `Failed to delete broadcast: ${error instanceof Error ? error.message : 'Unknown error'}`,\n BroadcastErrorCode.PROVIDER_ERROR,\n this.name,\n error\n )\n }\n }\n\n async send(id: string, options?: SendBroadcastOptions): Promise<Broadcast> {\n try {\n // Check if we're in test mode\n if (options?.testMode && options.testRecipients?.length) {\n // TODO: Broadcast doesn't have a documented test send API\n // For now, we'll throw an error\n throw new BroadcastProviderError(\n 'Test send is not yet implemented for Broadcast provider',\n BroadcastErrorCode.NOT_SUPPORTED,\n this.name\n )\n }\n\n const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}/send_broadcast`, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n segment_ids: options?.audienceIds\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\n // Response includes updated status\n const result = await response.json()\n return this.get(result.id.toString())\n } catch (error: unknown) {\n if (error instanceof BroadcastProviderError) throw error\n \n throw new BroadcastProviderError(\n `Failed to send broadcast: ${error instanceof Error ? error.message : 'Unknown error'}`,\n BroadcastErrorCode.PROVIDER_ERROR,\n this.name,\n error\n )\n }\n }\n\n async schedule(id: string, scheduledAt: Date): Promise<Broadcast> {\n try {\n // Update the newsletter with scheduled time\n const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, {\n method: 'PATCH',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n broadcast: {\n scheduled_send_at: scheduledAt.toISOString(),\n // TODO: Handle timezone properly\n scheduled_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone\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\n const broadcast: BroadcastApiResponse = await response.json()\n return this.transformBroadcastFromApi(broadcast)\n } catch (error: unknown) {\n throw new BroadcastProviderError(\n `Failed to schedule broadcast: ${error instanceof Error ? error.message : 'Unknown error'}`,\n BroadcastErrorCode.PROVIDER_ERROR,\n this.name,\n error\n )\n }\n }\n\n async cancelSchedule(id: string): Promise<Broadcast> {\n try {\n // Clear the scheduled time\n const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, {\n method: 'PATCH',\n headers: {\n 'Authorization': `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n broadcast: {\n scheduled_send_at: null,\n scheduled_timezone: null\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\n const broadcast: BroadcastApiResponse = await response.json()\n return this.transformBroadcastFromApi(broadcast)\n } catch (error: unknown) {\n throw new BroadcastProviderError(\n `Failed to cancel scheduled broadcast: ${error instanceof Error ? error.message : 'Unknown error'}`,\n BroadcastErrorCode.PROVIDER_ERROR,\n this.name,\n error\n )\n }\n }\n\n async getAnalytics(_id: string): Promise<BroadcastAnalytics> {\n // TODO: Broadcast analytics API is not documented in the CRUD API\n // This would need additional API documentation\n throw new BroadcastProviderError(\n 'Analytics API not yet implemented for Broadcast provider',\n BroadcastErrorCode.NOT_SUPPORTED,\n this.name\n )\n }\n\n getCapabilities(): BroadcastProviderCapabilities {\n return {\n supportsScheduling: true,\n supportsSegmentation: true,\n supportsAnalytics: false, // Not documented yet\n supportsABTesting: false,\n supportsTemplates: false,\n supportsPersonalization: true,\n supportsMultipleChannels: false,\n supportsChannelSegmentation: false,\n editableStatuses: [BroadcastStatus.DRAFT, BroadcastStatus.SCHEDULED],\n