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
JavaScript
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;"> </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 = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
};
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 || "