UNPKG

payload-plugin-newsletter

Version:

Complete newsletter management plugin for Payload CMS with subscriber management, magic link authentication, and email service integration

1,616 lines (1,575 loc) 103 kB
var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; // src/types/newsletter.ts var NewsletterProviderError; var init_newsletter = __esm({ "src/types/newsletter.ts"() { "use strict"; NewsletterProviderError = class extends Error { constructor(message, code, provider, details) { super(message); this.code = code; this.provider = provider; this.details = details; this.name = "NewsletterProviderError"; } }; } }); // src/types/broadcast.ts var BroadcastProviderError; var init_broadcast = __esm({ "src/types/broadcast.ts"() { "use strict"; init_newsletter(); BroadcastProviderError = class extends Error { constructor(message, code, provider, details) { super(message); this.code = code; this.provider = provider; this.details = details; this.name = "BroadcastProviderError"; } }; } }); // src/types/providers.ts var BaseBroadcastProvider; var init_providers = __esm({ "src/types/providers.ts"() { "use strict"; init_broadcast(); init_newsletter(); BaseBroadcastProvider = class { constructor(config) { this.config = config; } /** * Schedule a broadcast - default implementation throws not supported */ async schedule(_id, _scheduledAt) { const capabilities = this.getCapabilities(); if (!capabilities.supportsScheduling) { throw new BroadcastProviderError( "Scheduling is not supported by this provider", "NOT_SUPPORTED" /* NOT_SUPPORTED */, this.name ); } throw new Error("Method not implemented"); } /** * Cancel scheduled broadcast - default implementation throws not supported */ async cancelSchedule(_id) { const capabilities = this.getCapabilities(); if (!capabilities.supportsScheduling) { throw new BroadcastProviderError( "Scheduling is not supported by this provider", "NOT_SUPPORTED" /* NOT_SUPPORTED */, this.name ); } throw new Error("Method not implemented"); } /** * Get analytics - default implementation returns zeros */ async getAnalytics(_id) { const capabilities = this.getCapabilities(); if (!capabilities.supportsAnalytics) { throw new BroadcastProviderError( "Analytics are not supported by this provider", "NOT_SUPPORTED" /* NOT_SUPPORTED */, this.name ); } return { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0 }; } /** * Helper method to validate required fields */ validateRequiredFields(data, fields) { const missing = fields.filter((field) => !data[field]); if (missing.length > 0) { throw new BroadcastProviderError( `Missing required fields: ${missing.join(", ")}`, "VALIDATION_ERROR" /* VALIDATION_ERROR */, this.name ); } } /** * Helper method to check if a status transition is allowed */ canEditInStatus(status) { const capabilities = this.getCapabilities(); return capabilities.editableStatuses.includes(status); } /** * Helper to build pagination response */ buildListResponse(items, total, options = {}) { const limit = options.limit || 20; const offset = options.offset || 0; return { items, total, limit, offset, hasMore: offset + items.length < total }; } }; } }); // src/types/index.ts var init_types = __esm({ "src/types/index.ts"() { "use strict"; init_broadcast(); init_providers(); init_newsletter(); } }); // src/providers/broadcast/broadcast.ts var broadcast_exports = {}; __export(broadcast_exports, { BroadcastApiProvider: () => BroadcastApiProvider }); var BroadcastApiProvider; var init_broadcast2 = __esm({ "src/providers/broadcast/broadcast.ts"() { "use strict"; init_types(); BroadcastApiProvider = class extends BaseBroadcastProvider { constructor(config) { super(config); this.name = "broadcast"; this.apiUrl = config.apiUrl.replace(/\/$/, ""); this.token = config.token; if (!this.token) { throw new BroadcastProviderError( "Broadcast API token is required", "CONFIGURATION_ERROR" /* CONFIGURATION_ERROR */, this.name ); } } // Broadcast Management Methods async list(options) { try { const params = new URLSearchParams(); if (options?.limit) params.append("limit", options.limit.toString()); if (options?.offset) params.append("offset", options.offset.toString()); const response = await fetch(`${this.apiUrl}/api/v1/broadcasts?${params}`, { method: "GET", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" } }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const data = await response.json(); const broadcasts = data.data.map((broadcast) => this.transformBroadcastFromApi(broadcast)); return this.buildListResponse(broadcasts, data.total, options); } catch (error) { throw new BroadcastProviderError( `Failed to list broadcasts: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async get(id) { try { console.log("[BroadcastApiProvider] Getting broadcast with ID:", id); const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, { method: "GET", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" } }); if (!response.ok) { if (response.status === 404) { throw new BroadcastProviderError( `Broadcast not found: ${id}`, "NOT_FOUND" /* NOT_FOUND */, this.name ); } const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const broadcast = await response.json(); console.log("[BroadcastApiProvider] GET response:", broadcast); return this.transformBroadcastFromApi(broadcast); } catch (error) { if (error instanceof BroadcastProviderError) throw error; throw new BroadcastProviderError( `Failed to get broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async create(data) { try { this.validateRequiredFields(data, ["name", "subject", "content"]); const requestBody = { broadcast: { name: data.name, subject: data.subject, preheader: data.preheader, body: data.content, html_body: true, track_opens: data.trackOpens ?? true, track_clicks: data.trackClicks ?? true, reply_to: data.replyTo, segment_ids: data.audienceIds } }; console.log("[BroadcastApiProvider] Creating broadcast:", { url: `${this.apiUrl}/api/v1/broadcasts`, method: "POST", hasToken: !!this.token, tokenLength: this.token?.length, body: JSON.stringify(requestBody, null, 2) }); const response = await fetch(`${this.apiUrl}/api/v1/broadcasts`, { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }); console.log("[BroadcastApiProvider] Response status:", response.status); console.log("[BroadcastApiProvider] Response headers:", Object.fromEntries(response.headers.entries())); if (!response.ok) { const errorText = await response.text(); console.error("[BroadcastApiProvider] Error response body:", errorText); let errorDetails; try { errorDetails = JSON.parse(errorText); console.error("[BroadcastApiProvider] Parsed error:", errorDetails); } catch { } throw new Error(`Broadcast API error: ${response.status} - ${errorText}`); } const responseText = await response.text(); console.log("[BroadcastApiProvider] Success response body:", responseText); let result; try { result = JSON.parse(responseText); } catch { throw new Error(`Failed to parse response as JSON: ${responseText}`); } console.log("[BroadcastApiProvider] Parsed result:", result); if (!result.id) { throw new Error(`Response missing expected 'id' field: ${JSON.stringify(result)}`); } return this.get(result.id.toString()); } catch (error) { if (error instanceof BroadcastProviderError) throw error; throw new BroadcastProviderError( `Failed to create broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async update(id, data) { try { const existing = await this.get(id); if (!this.canEditInStatus(existing.sendStatus)) { throw new BroadcastProviderError( `Cannot update broadcast in status: ${existing.sendStatus}`, "INVALID_STATUS" /* INVALID_STATUS */, this.name ); } const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, { method: "PATCH", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ broadcast: { name: data.name, subject: data.subject, preheader: data.preheader, body: data.content, track_opens: data.trackOpens, track_clicks: data.trackClicks, reply_to: data.replyTo, segment_ids: data.audienceIds } }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const broadcast = await response.json(); return this.transformBroadcastFromApi(broadcast); } catch (error) { if (error instanceof BroadcastProviderError) throw error; throw new BroadcastProviderError( `Failed to update broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async delete(id) { try { const existing = await this.get(id); if (!this.canEditInStatus(existing.sendStatus)) { throw new BroadcastProviderError( `Cannot delete broadcast in status: ${existing.sendStatus}`, "INVALID_STATUS" /* INVALID_STATUS */, this.name ); } const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, { method: "DELETE", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" } }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } } catch (error) { if (error instanceof BroadcastProviderError) throw error; throw new BroadcastProviderError( `Failed to delete broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async send(id, options) { try { if (options?.testMode && options.testRecipients?.length) { throw new BroadcastProviderError( "Test send is not yet implemented for Broadcast provider", "NOT_SUPPORTED" /* NOT_SUPPORTED */, this.name ); } const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}/send_broadcast`, { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ segment_ids: options?.audienceIds }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const result = await response.json(); return this.get(result.id.toString()); } catch (error) { if (error instanceof BroadcastProviderError) throw error; throw new BroadcastProviderError( `Failed to send broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async schedule(id, scheduledAt) { try { const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, { method: "PATCH", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ broadcast: { scheduled_send_at: scheduledAt.toISOString(), // TODO: Handle timezone properly scheduled_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone } }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const broadcast = await response.json(); return this.transformBroadcastFromApi(broadcast); } catch (error) { throw new BroadcastProviderError( `Failed to schedule broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async cancelSchedule(id) { try { const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, { method: "PATCH", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ broadcast: { scheduled_send_at: null, scheduled_timezone: null } }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const broadcast = await response.json(); return this.transformBroadcastFromApi(broadcast); } catch (error) { throw new BroadcastProviderError( `Failed to cancel scheduled broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async getAnalytics(_id) { throw new BroadcastProviderError( "Analytics API not yet implemented for Broadcast provider", "NOT_SUPPORTED" /* NOT_SUPPORTED */, this.name ); } getCapabilities() { return { supportsScheduling: true, supportsSegmentation: true, supportsAnalytics: false, // Not documented yet supportsABTesting: false, supportsTemplates: false, supportsPersonalization: true, supportsMultipleChannels: false, supportsChannelSegmentation: false, editableStatuses: ["draft" /* DRAFT */, "scheduled" /* SCHEDULED */], supportedContentTypes: ["html", "text"] }; } async validateConfiguration() { try { await this.list({ limit: 1 }); return true; } catch { return false; } } transformBroadcastFromApi(broadcast) { return { id: broadcast.id.toString(), name: broadcast.name, subject: broadcast.subject, preheader: broadcast.preheader, content: broadcast.body, sendStatus: this.mapBroadcastStatus(broadcast.status), trackOpens: broadcast.track_opens, trackClicks: broadcast.track_clicks, replyTo: broadcast.reply_to, recipientCount: broadcast.total_recipients, sentAt: broadcast.sent_at ? new Date(broadcast.sent_at) : void 0, scheduledAt: broadcast.scheduled_send_at ? new Date(broadcast.scheduled_send_at) : void 0, createdAt: new Date(broadcast.created_at), updatedAt: new Date(broadcast.updated_at), providerData: { broadcast }, providerId: broadcast.id.toString(), providerType: "broadcast" }; } mapBroadcastStatus(status) { const statusMap = { "draft": "draft" /* DRAFT */, "scheduled": "scheduled" /* SCHEDULED */, "queueing": "sending" /* SENDING */, "sending": "sending" /* SENDING */, "sent": "sent" /* SENT */, "failed": "failed" /* FAILED */, "partial_failure": "failed" /* FAILED */, "paused": "paused" /* PAUSED */, "aborted": "canceled" /* CANCELED */ }; return statusMap[status] || "draft" /* DRAFT */; } }; } }); // src/collections/Broadcasts.ts init_types(); // src/fields/emailContent.ts import { BoldFeature, ItalicFeature, UnderlineFeature, StrikethroughFeature, LinkFeature, OrderedListFeature, UnorderedListFeature, HeadingFeature, ParagraphFeature, AlignFeature, BlockquoteFeature, BlocksFeature, UploadFeature, FixedToolbarFeature, InlineToolbarFeature, lexicalEditor } from "@payloadcms/richtext-lexical"; // src/utils/blockValidation.ts var EMAIL_INCOMPATIBLE_TYPES = [ "chart", "dataTable", "interactive", "streamable", "video", "iframe", "form", "carousel", "tabs", "accordion", "map" ]; var validateEmailBlocks = (blocks) => { blocks.forEach((block) => { if (EMAIL_INCOMPATIBLE_TYPES.includes(block.slug)) { console.warn(`\u26A0\uFE0F Block "${block.slug}" may not be email-compatible. Consider creating an email-specific version.`); } const hasComplexFields = block.fields?.some((field) => { const complexTypes = ["code", "json", "richText", "blocks", "array"]; return complexTypes.includes(field.type); }); if (hasComplexFields) { console.warn(`\u26A0\uFE0F Block "${block.slug}" contains complex field types that may not render consistently in email clients.`); } }); }; var createEmailSafeBlocks = (customBlocks = []) => { validateEmailBlocks(customBlocks); const baseBlocks = [ { slug: "button", fields: [ { name: "text", type: "text", label: "Button Text", required: true }, { name: "url", type: "text", label: "Button URL", required: true, admin: { description: "Enter the full URL (including https://)" } }, { name: "style", type: "select", label: "Button Style", defaultValue: "primary", options: [ { label: "Primary", value: "primary" }, { label: "Secondary", value: "secondary" }, { label: "Outline", value: "outline" } ] } ], interfaceName: "EmailButton", labels: { singular: "Button", plural: "Buttons" } }, { slug: "divider", fields: [ { name: "style", type: "select", label: "Divider Style", defaultValue: "solid", options: [ { label: "Solid", value: "solid" }, { label: "Dashed", value: "dashed" }, { label: "Dotted", value: "dotted" } ] } ], interfaceName: "EmailDivider", labels: { singular: "Divider", plural: "Dividers" } } ]; return [ ...baseBlocks, ...customBlocks ]; }; // src/fields/emailContent.ts var createEmailSafeFeatures = (additionalBlocks) => { const baseBlocks = [ { slug: "button", fields: [ { name: "text", type: "text", label: "Button Text", required: true }, { name: "url", type: "text", label: "Button URL", required: true, admin: { description: "Enter the full URL (including https://)" } }, { name: "style", type: "select", label: "Button Style", defaultValue: "primary", options: [ { label: "Primary", value: "primary" }, { label: "Secondary", value: "secondary" }, { label: "Outline", value: "outline" } ] } ], interfaceName: "EmailButton", labels: { singular: "Button", plural: "Buttons" } }, { slug: "divider", fields: [ { name: "style", type: "select", label: "Divider Style", defaultValue: "solid", options: [ { label: "Solid", value: "solid" }, { label: "Dashed", value: "dashed" }, { label: "Dotted", value: "dotted" } ] } ], interfaceName: "EmailDivider", labels: { singular: "Divider", plural: "Dividers" } } ]; const allBlocks = [ ...baseBlocks, ...additionalBlocks || [] ]; return [ // Toolbars FixedToolbarFeature(), // Fixed toolbar at the top InlineToolbarFeature(), // Floating toolbar when text is selected // Basic text formatting BoldFeature(), ItalicFeature(), UnderlineFeature(), StrikethroughFeature(), // Links with enhanced configuration LinkFeature({ fields: [ { name: "url", type: "text", required: true, admin: { description: "Enter the full URL (including https://)" } }, { name: "newTab", type: "checkbox", label: "Open in new tab", defaultValue: false } ] }), // Lists OrderedListFeature(), UnorderedListFeature(), // Headings - limited to h1, h2, h3 for email compatibility HeadingFeature({ enabledHeadingSizes: ["h1", "h2", "h3"] }), // Basic paragraph and alignment ParagraphFeature(), AlignFeature(), // Blockquotes BlockquoteFeature(), // Upload feature for images UploadFeature({ collections: { media: { fields: [ { name: "caption", type: "text", admin: { description: "Optional caption for the image" } }, { name: "altText", type: "text", label: "Alt Text", required: true, admin: { description: "Alternative text for accessibility and when image cannot be displayed" } } ] } } }), // Custom blocks for email-specific content BlocksFeature({ blocks: allBlocks }) ]; }; var createEmailLexicalEditor = (customBlocks = []) => { const emailSafeBlocks = createEmailSafeBlocks(customBlocks); return lexicalEditor({ features: [ // Toolbars FixedToolbarFeature(), InlineToolbarFeature(), // Basic text formatting BoldFeature(), ItalicFeature(), UnderlineFeature(), StrikethroughFeature(), // Links with enhanced configuration LinkFeature({ fields: [ { name: "url", type: "text", required: true, admin: { description: "Enter the full URL (including https://)" } }, { name: "newTab", type: "checkbox", label: "Open in new tab", defaultValue: false } ] }), // Lists OrderedListFeature(), UnorderedListFeature(), // Headings - limited to h1, h2, h3 for email compatibility HeadingFeature({ enabledHeadingSizes: ["h1", "h2", "h3"] }), // Basic paragraph and alignment ParagraphFeature(), AlignFeature(), // Blockquotes BlockquoteFeature(), // Upload feature for images UploadFeature({ collections: { media: { fields: [ { name: "caption", type: "text", admin: { description: "Optional caption for the image" } }, { name: "altText", type: "text", label: "Alt Text", required: true, admin: { description: "Alternative text for accessibility and when image cannot be displayed" } } ] } } }), // Email-safe blocks (processed server-side) BlocksFeature({ blocks: emailSafeBlocks }) ] }); }; var emailSafeFeatures = createEmailSafeFeatures(); var createEmailContentField = (overrides) => { const editor = overrides?.editor || createEmailLexicalEditor(overrides?.additionalBlocks); return { name: "content", type: "richText", required: true, editor, admin: { description: "Email content with limited formatting for compatibility", ...overrides?.admin }, ...overrides }; }; // src/fields/broadcastInlinePreview.ts var createBroadcastInlinePreviewField = () => { return { name: "broadcastInlinePreview", type: "ui", admin: { components: { Field: "payload-plugin-newsletter/components#BroadcastInlinePreview" } } }; }; // src/utils/emailSafeHtml.ts import DOMPurify from "isomorphic-dompurify"; var EMAIL_SAFE_CONFIG = { ALLOWED_TAGS: [ "p", "br", "strong", "b", "em", "i", "u", "strike", "s", "span", "a", "h1", "h2", "h3", "ul", "ol", "li", "blockquote", "hr", "img", "div", "table", "tr", "td", "th", "tbody", "thead" ], ALLOWED_ATTR: ["href", "style", "target", "rel", "align", "src", "alt", "width", "height", "border", "cellpadding", "cellspacing"], ALLOWED_STYLES: { "*": [ "color", "background-color", "font-size", "font-weight", "font-style", "text-decoration", "text-align", "margin", "margin-top", "margin-right", "margin-bottom", "margin-left", "padding", "padding-top", "padding-right", "padding-bottom", "padding-left", "line-height", "border-left", "border-left-width", "border-left-style", "border-left-color" ] }, FORBID_TAGS: ["script", "style", "iframe", "object", "embed", "form", "input"], FORBID_ATTR: ["class", "id", "onclick", "onload", "onerror"] }; async function convertToEmailSafeHtml(editorState, options) { if (!editorState) { return ""; } const rawHtml = await lexicalToEmailHtml(editorState, options?.mediaUrl, options?.customBlockConverter); const sanitizedHtml = DOMPurify.sanitize(rawHtml, EMAIL_SAFE_CONFIG); if (options?.wrapInTemplate) { if (options.customWrapper) { return await Promise.resolve(options.customWrapper(sanitizedHtml, { preheader: options.preheader, subject: options.subject, documentData: options.documentData })); } return wrapInEmailTemplate(sanitizedHtml, options.preheader); } return sanitizedHtml; } async function lexicalToEmailHtml(editorState, mediaUrl, customBlockConverter) { const { root } = editorState; if (!root || !root.children) { return ""; } const htmlParts = await Promise.all( root.children.map((node) => convertNode(node, mediaUrl, customBlockConverter)) ); return htmlParts.join(""); } async function convertNode(node, mediaUrl, customBlockConverter) { switch (node.type) { case "paragraph": return convertParagraph(node, mediaUrl, customBlockConverter); case "heading": return convertHeading(node, mediaUrl, customBlockConverter); case "list": return convertList(node, mediaUrl, customBlockConverter); case "listitem": return convertListItem(node, mediaUrl, customBlockConverter); case "blockquote": return convertBlockquote(node, mediaUrl, customBlockConverter); case "text": return convertText(node); case "link": return convertLink(node, mediaUrl, customBlockConverter); case "linebreak": return "<br>"; case "upload": return convertUpload(node, mediaUrl); case "block": return await convertBlock(node, mediaUrl, customBlockConverter); default: if (node.children) { const childParts = await Promise.all( node.children.map((child) => convertNode(child, mediaUrl, customBlockConverter)) ); return childParts.join(""); } return ""; } } async function convertParagraph(node, mediaUrl, customBlockConverter) { const align = getAlignment(node.format); const childParts = await Promise.all( (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter)) ); const children = childParts.join(""); if (!children.trim()) { return '<p class="mobile-margin-bottom-16" style="margin: 0 0 16px 0; min-height: 1em;">&nbsp;</p>'; } return `<p class="mobile-margin-bottom-16" style="margin: 0 0 16px 0; text-align: ${align}; font-size: 16px; line-height: 1.5;">${children}</p>`; } async function convertHeading(node, mediaUrl, customBlockConverter) { const tag = node.tag || "h1"; const align = getAlignment(node.format); const childParts = await Promise.all( (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter)) ); const children = childParts.join(""); const styles2 = { h1: "font-size: 32px; font-weight: 700; margin: 0 0 24px 0; line-height: 1.2;", h2: "font-size: 24px; font-weight: 600; margin: 0 0 16px 0; line-height: 1.3;", h3: "font-size: 20px; font-weight: 600; margin: 0 0 12px 0; line-height: 1.4;" }; const mobileClasses = { h1: "mobile-font-size-24", h2: "mobile-font-size-20", h3: "mobile-font-size-16" }; const style = `${styles2[tag] || styles2.h3} text-align: ${align};`; const mobileClass = mobileClasses[tag] || mobileClasses.h3; return `<${tag} class="${mobileClass}" style="${style}">${children}</${tag}>`; } async function convertList(node, mediaUrl, customBlockConverter) { const tag = node.listType === "number" ? "ol" : "ul"; const childParts = await Promise.all( (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter)) ); const children = childParts.join(""); const style = tag === "ul" ? "margin: 0 0 16px 0; padding-left: 24px; list-style-type: disc; font-size: 16px; line-height: 1.5;" : "margin: 0 0 16px 0; padding-left: 24px; list-style-type: decimal; font-size: 16px; line-height: 1.5;"; return `<${tag} class="mobile-margin-bottom-16" style="${style}">${children}</${tag}>`; } async function convertListItem(node, mediaUrl, customBlockConverter) { const childParts = await Promise.all( (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter)) ); const children = childParts.join(""); return `<li style="margin: 0 0 8px 0;">${children}</li>`; } async function convertBlockquote(node, mediaUrl, customBlockConverter) { const childParts = await Promise.all( (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter)) ); const children = childParts.join(""); const style = "margin: 0 0 16px 0; padding-left: 16px; border-left: 4px solid #e5e7eb; color: #6b7280;"; return `<blockquote style="${style}">${children}</blockquote>`; } function convertText(node) { let text = escapeHtml(node.text || ""); if (node.format & 1) { text = `<strong>${text}</strong>`; } if (node.format & 2) { text = `<em>${text}</em>`; } if (node.format & 8) { text = `<u>${text}</u>`; } if (node.format & 4) { text = `<strike>${text}</strike>`; } return text; } async function convertLink(node, mediaUrl, customBlockConverter) { const childParts = await Promise.all( (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter)) ); const children = childParts.join(""); const url = node.fields?.url || "#"; const newTab = node.fields?.newTab ?? false; const targetAttr = newTab ? ' target="_blank"' : ""; const relAttr = newTab ? ' rel="noopener noreferrer"' : ""; return `<a href="${escapeHtml(url)}"${targetAttr}${relAttr} style="color: #2563eb; text-decoration: underline;">${children}</a>`; } function convertUpload(node, mediaUrl) { const upload = node.value; if (!upload) return ""; let src = ""; if (typeof upload === "string") { src = upload; } else if (upload.url) { src = upload.url; } else if (upload.filename && mediaUrl) { src = `${mediaUrl}/${upload.filename}`; } const alt = node.fields?.altText || upload.alt || ""; const caption = node.fields?.caption || ""; const imgHtml = `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" class="mobile-width-100" style="max-width: 100%; height: auto; display: block; margin: 0 auto; border-radius: 6px;" />`; if (caption) { return ` <div style="margin: 0 0 16px 0; text-align: center;" class="mobile-margin-bottom-16"> ${imgHtml} <p style="margin: 8px 0 0 0; font-size: 14px; color: #6b7280; font-style: italic; text-align: center;" class="mobile-font-size-14">${escapeHtml(caption)}</p> </div> `; } return `<div style="margin: 0 0 16px 0; text-align: center;" class="mobile-margin-bottom-16">${imgHtml}</div>`; } async function convertBlock(node, mediaUrl, customBlockConverter) { const blockType = node.fields?.blockName || node.blockName; if (customBlockConverter) { try { const customHtml = await customBlockConverter(node, mediaUrl); if (customHtml) { return customHtml; } } catch (error) { console.error(`Custom block converter error for ${blockType}:`, error); } } switch (blockType) { case "button": return convertButtonBlock(node.fields); case "divider": return convertDividerBlock(node.fields); default: if (node.children) { const childParts = await Promise.all( node.children.map((child) => convertNode(child, mediaUrl, customBlockConverter)) ); return childParts.join(""); } return ""; } } function convertButtonBlock(fields) { const text = fields?.text || "Click here"; const url = fields?.url || "#"; const style = fields?.style || "primary"; const styles2 = { primary: "background-color: #2563eb; color: #ffffff; border: 2px solid #2563eb;", secondary: "background-color: #6b7280; color: #ffffff; border: 2px solid #6b7280;", outline: "background-color: transparent; color: #2563eb; border: 2px solid #2563eb;" }; const buttonStyle = `${styles2[style] || styles2.primary} display: inline-block; padding: 12px 24px; font-size: 16px; font-weight: 600; text-decoration: none; border-radius: 6px; text-align: center;`; return ` <div style="margin: 0 0 16px 0; text-align: center;"> <a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="${buttonStyle}">${escapeHtml(text)}</a> </div> `; } function convertDividerBlock(fields) { const style = fields?.style || "solid"; const styles2 = { solid: "border-top: 1px solid #e5e7eb;", dashed: "border-top: 1px dashed #e5e7eb;", dotted: "border-top: 1px dotted #e5e7eb;" }; return `<hr style="${styles2[style] || styles2.solid} margin: 24px 0; border-bottom: none; border-left: none; border-right: none;" />`; } function getAlignment(format) { if (!format) return "left"; if (format & 2) return "center"; if (format & 3) return "right"; if (format & 4) return "justify"; return "left"; } function escapeHtml(text) { const map = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }; return text.replace(/[&<>"']/g, (m) => map[m]); } function wrapInEmailTemplate(content, preheader) { return `<!DOCTYPE html> <html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="x-apple-disable-message-reformatting"> <title>Newsletter</title> <!--[if mso]> <noscript> <xml> <o:OfficeDocumentSettings> <o:PixelsPerInch>96</o:PixelsPerInch> </o:OfficeDocumentSettings> </xml> </noscript> <![endif]--> <style> /* Reset and base styles */ * { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } body { margin: 0 !important; padding: 0 !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #1A1A1A; background-color: #f8f9fa; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } table { border-spacing: 0 !important; border-collapse: collapse !important; table-layout: fixed !important; margin: 0 auto !important; } table table table { table-layout: auto; } img { -ms-interpolation-mode: bicubic; max-width: 100%; height: auto; border: 0; outline: none; text-decoration: none; } /* Responsive styles */ @media only screen and (max-width: 640px) { .mobile-hide { display: none !important; } .mobile-center { text-align: center !important; } .mobile-width-100 { width: 100% !important; max-width: 100% !important; } .mobile-padding { padding: 20px !important; } .mobile-padding-sm { padding: 16px !important; } .mobile-font-size-14 { font-size: 14px !important; } .mobile-font-size-16 { font-size: 16px !important; } .mobile-font-size-20 { font-size: 20px !important; line-height: 1.3 !important; } .mobile-font-size-24 { font-size: 24px !important; line-height: 1.2 !important; } /* Stack sections on mobile */ .mobile-stack { display: block !important; width: 100% !important; } /* Mobile-specific spacing */ .mobile-margin-bottom-16 { margin-bottom: 16px !important; } .mobile-margin-bottom-20 { margin-bottom: 20px !important; } } /* Dark mode support */ @media (prefers-color-scheme: dark) { .dark-mode-bg { background-color: #1a1a1a !important; } .dark-mode-text { color: #ffffff !important; } .dark-mode-border { border-color: #333333 !important; } } /* Outlook-specific fixes */ <!--[if mso]> <style> table { border-collapse: collapse; border-spacing: 0; border: none; margin: 0; } div, p { margin: 0; } </style> <![endif]--> </style> </head> <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #1A1A1A; background-color: #f8f9fa;"> ${preheader ? ` <!-- Preheader text --> <div style="display: none; max-height: 0; overflow: hidden; font-size: 1px; line-height: 1px; color: transparent;"> ${escapeHtml(preheader)} </div> ` : ""} <!-- Main container --> <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin: 0; padding: 0; background-color: #f8f9fa;"> <tr> <td align="center" style="padding: 20px 10px;"> <!-- Email wrapper --> <table role="presentation" cellpadding="0" cellspacing="0" width="600" class="mobile-width-100" style="margin: 0 auto; max-width: 600px;"> <tr> <td class="mobile-padding" style="padding: 0;"> <!-- Content area with light background --> <div style="background-color: #ffffff; padding: 40px 30px; border-radius: 8px;" class="mobile-padding"> ${content} </div> </td> </tr> </table> </td> </tr> </table> </body> </html>`; } // src/utils/getBroadcastConfig.ts async function getBroadcastConfig(req, pluginConfig) { try { const settings = await req.payload.findGlobal({ slug: pluginConfig.settingsSlug || "newsletter-settings", req }); if (settings?.provider === "broadcast" && settings?.broadcastSettings) { return { apiUrl: settings.broadcastSettings.apiUrl || pluginConfig.providers?.broadcast?.apiUrl || "", token: settings.broadcastSettings.token || pluginConfig.providers?.broadcast?.token || "", fromAddress: settings.fromAddress || pluginConfig.providers?.broadcast?.fromAddress || "", fromName: settings.fromName || pluginConfig.providers?.broadcast?.fromName || "", replyTo: settings.replyTo || pluginConfig.providers?.broadcast?.replyTo }; } return pluginConfig.providers?.broadcast || null; } catch (error) { req.payload.logger.error("Failed to get broadcast config from settings:", error); return pluginConfig.providers?.broadcast || null; } } // src/endpoints/broadcasts/send.ts init_types(); // 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/utils/auth.ts async function getAuthenticatedUser(req) { try { const me = await req.payload.find({ collection: "users", where: { id: { equals: "me" // Special value in Payload to get current user } }, limit: 1, depth: 0 }); return me.docs[0] || null; } catch { return null; } } async function requireAdmin(req, config) { const user = await getAuthenticatedUser(req); if (!user) { return { authorized: false, error: "Authentication required" }; } if (!isAdmin(user, config)) { return { authorized: false, error: "Admin access required" }; } return { authorized: true, user }; } // src/endpoints/broadcasts/send.ts var createSendBroadcastEndpoint = (config, collectionSlug) => { return { path: "/:id/send", method: "post", handler: (async (req) => { try { const auth = await requireAdmin(req, config); if (!auth.authorized) { return Response.json({ success: false, error: auth.error }, { status: 401 }); } if (!config.features?.newsletterManagement?.enabled) { return Response.json({ success: false, error: "Broadcast management is not enabled" }, { status: 400 }); } const url = new URL(req.url || "", `http://localhost`); const pathParts = url.pathname.split("/"); const id = pathParts[pathParts.length - 2]; if (!id) { return Response.json({ success: false, error: "Broadcast ID is required" }, { status: 400 }); } const data = await (req.json?.() || Promise.resolve({})); const broadcastDoc = await req.payload.findByID({ collection: collectionSlug, id, user: auth.user }); if (!broadcastDoc || !broadcastDoc.providerId) { return Response.json({ success: false, error: "Broadcast not found or not synced with provider" }, { status: 404 }); } const providerConfig = await getBroadcastConfig(req, config); if (!providerConfig || !providerConfig.token) { return Response.json({ success: false, error: "Broadcast provider not configured in Newsletter Settings or environment variables" }, { status: 500 }); } const { BroadcastApiProvider: BroadcastApiProvider2 } = await Promise.resolve().then(() => (init_broadcast2(), broadcast_exports)); const provider = new BroadcastApiProvider2(providerConfig); const broadcast = await provider.send(broadcastDoc.providerId, data); await req.payload.update({ collection: collectionSlug, id, data: { sendStatus: "sending" /* SENDING */, sentAt: (/* @__PURE__ */ new Date()).toISOString() }, user: auth.user }); return Response.json({ success: true, message: "Broadcast sent successfully", broadcast }); } catch (error) { console.error("Failed to send broadcast:", error); if (error instanceof NewsletterProviderError) { return Response.json({ success: false, error: error.message, code: error.code }, { status: error.code === "NOT_SUPPORTED" ? 501 : 500 }); } return Response.json({ success: false, error: "Failed to send broadcast" }, { status: 500 }); } }) }; }; // src/endpoints/broadcasts/schedule.ts init_types(); var createScheduleBroadcastEndpoint = (config, collectionSlug) => { return { path: "/:id/schedule", method: "post", handler: (async (req) => { try { const auth = await requireAdmin(req, config); if (!auth.authorized) { return Response.json({ success: false, error: auth.error }, { status: 401 }); } if (!config.features?.newsletterManagement?.enabled) { return Response.json({ success: false, error: "Broadcast management is not enabled" }, { status: 400 }); } const url = new URL(req.url || "