payload-plugin-newsletter
Version:
Complete newsletter management plugin for Payload CMS with subscriber management, magic link authentication, and email service integration
3,871 lines • 132 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __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 });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/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/exports/collections.ts
var collections_exports = {};
__export(collections_exports, {
createBroadcastsCollection: () => createBroadcastsCollection,
createSubscribersCollection: () => createSubscribersCollection
});
module.exports = __toCommonJS(collections_exports);
// src/collections/Broadcasts.ts
init_types();
// src/fields/emailContent.ts
var import_richtext_lexical = require("@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
(0, import_richtext_lexical.FixedToolbarFeature)(),
// Fixed toolbar at the top
(0, import_richtext_lexical.InlineToolbarFeature)(),
// Floating toolbar when text is selected
// Basic text formatting
(0, import_richtext_lexical.BoldFeature)(),
(0, import_richtext_lexical.ItalicFeature)(),
(0, import_richtext_lexical.UnderlineFeature)(),
(0, import_richtext_lexical.StrikethroughFeature)(),
// Links - use default fields to ensure drawer UI works correctly
(0, import_richtext_lexical.LinkFeature)(),
// Lists
(0, import_richtext_lexical.OrderedListFeature)(),
(0, import_richtext_lexical.UnorderedListFeature)(),
// Headings - limited to h1, h2, h3 for email compatibility
(0, import_richtext_lexical.HeadingFeature)({
enabledHeadingSizes: ["h1", "h2", "h3"]
}),
// Basic paragraph and alignment
(0, import_richtext_lexical.ParagraphFeature)(),
(0, import_richtext_lexical.AlignFeature)(),
// Blockquotes
(0, import_richtext_lexical.BlockquoteFeature)(),
// Upload feature for images
(0, import_richtext_lexical.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
(0, import_richtext_lexical.BlocksFeature)({
blocks: allBlocks
})
];
};
var createEmailLexicalEditor = (customBlocks = []) => {
const emailSafeBlocks = createEmailSafeBlocks(customBlocks);
return (0, import_richtext_lexical.lexicalEditor)({
features: [
// Toolbars
(0, import_richtext_lexical.FixedToolbarFeature)(),
(0, import_richtext_lexical.InlineToolbarFeature)(),
// Basic text formatting
(0, import_richtext_lexical.BoldFeature)(),
(0, import_richtext_lexical.ItalicFeature)(),
(0, import_richtext_lexical.UnderlineFeature)(),
(0, import_richtext_lexical.StrikethroughFeature)(),
// Links - use default fields to ensure drawer UI works correctly
(0, import_richtext_lexical.LinkFeature)(),
// Lists
(0, import_richtext_lexical.OrderedListFeature)(),
(0, import_richtext_lexical.UnorderedListFeature)(),
// Headings - limited to h1, h2, h3 for email compatibility
(0, import_richtext_lexical.HeadingFeature)({
enabledHeadingSizes: ["h1", "h2", "h3"]
}),
// Basic paragraph and alignment
(0, import_richtext_lexical.ParagraphFeature)(),
(0, import_richtext_lexical.AlignFeature)(),
// Blockquotes
(0, import_richtext_lexical.BlockquoteFeature)(),
// Upload feature for images
(0, import_richtext_lexical.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)
(0, import_richtext_lexical.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/fields/broadcastSchedule.ts
var createBroadcastScheduleField = () => {
return {
name: "scheduleControls",
type: "ui",
label: "Scheduling",
admin: {
components: {
Field: "payload-plugin-newsletter/components#BroadcastScheduleField"
}
}
};
};
// src/utils/emailSafeHtml.ts
var import_isomorphic_dompurify = __toESM(require("isomorphic-dompurify"), 1);
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 = import_isomorphic_dompurify.default.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
var BROADCAST_CONFIG_CACHE_KEY = "broadcastConfig";
async function getBroadcastConfig(req, pluginConfig) {
const cached = req.context?.[BROADCAST_CONFIG_CACHE_KEY];
if (cached !== void 0) {
return cached;
}
try {
const settings = await req.payload.findGlobal({
slug: pluginConfig.settingsSlug || "newsletter-settings",
req
});
let result = null;
if (settings?.provider === "broadcast" && settings?.broadcastSettings) {
result = {
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
};
} else {
result = pluginConfig.providers?.broadcast || null;
}
if (req.context) {
req.context[BROADCAST_CONFIG_CACHE_KEY] = result;
}
return result;
} catch (error) {
req.payload.logger.error({ error: String(error) }, "Failed to get broadcast config from settings");
const fallback = pluginConfig.providers?.broadcast || null;
if (req.context) {
req.context[BROADCAST_CONFIG_CACHE_KEY] = fallback;
}
return fallback;
}
}
// src/utils/getProvider.ts
init_broadcast();
async function getBroadcastProvider(req, pluginConfig) {
const config = await getBroadcastConfig(req, pluginConfig);
if (!config?.token) {
throw new BroadcastProviderError(
"Broadcast provider not configured. Check Newsletter Settings or environment variables.",
"CONFIGURATION_ERROR" /* CONFIGURATION_ERROR */,
"broadcast"
);
}
const { BroadcastApiProvider: BroadcastApiProvider2 } = await Promise.resolve().then(() => (init_broadcast2(), broadcast_exports));
return new BroadcastApiProvider2(config);
}
// src/utils/mediaPopulation.ts
async function populateMediaFields(content, payload, config) {
if (!content || typeof content !== "object") return content;
const typedContent = content;
if (typedContent.root?.children) {
for (const child of typedContent.root.children) {
await populateBlockMediaFields(child, payload, config);
}
}
return content;
}
async function populateBlockMediaFields(node, payload, config) {
if (!node || typeof node !== "object") return;
const typedNode = node;
if (typedNode.type === "block" && typedNode.fields) {
const blockType = typedNode.fields.blockType || typedNode.fields.blockName;
const customBlocks = config.customizations?.broadcasts?.customBlocks || [];
const blockConfig = customBlocks.find((b) => b.slug === blockType);
if (blockConfig && blockConfig.fields) {
for (const field of blockConfig.fields) {
if (field.type === "upload" && field.relationTo && typedNode.fields[field.name]) {
const fieldValue = typedNode.fields[field.name];
const collectionName = Array.isArray(field.relationTo) ? field.relationTo[0] : field.relationTo;
if (typeof fieldValue === "string" && fieldValue.match(/^[a-f0-9]{24}$/i)) {
try {
const media = await payload.findByID({
collection: collectionName,
id: fieldValue,
depth: 0
});
if (media) {
typedNode.fields[field.name] = media;
payload.logger?.info(
{
mediaId: fieldValue,
mediaUrl: media.url,
filename: media.filename
},
`Populated ${field.name} for block ${blockType}`
);
}
} catch (error) {
payload.logger?.error(
{ error: String(error) },
`Failed to populate ${field.name} for block ${blockType}`
);
}
}
}
if (field.type === "array" && field.fields) {
const arrayValue = typedNode.fields[field.name];
if (Array.isArray(arrayValue)) {
for (const arrayItem of arrayValue) {
if (arrayItem && typeof arrayItem === "object") {
const typedArrayItem = arrayItem;
for (const arrayField of field.fields) {
if (arrayField.type === "upload" && arrayField.relationTo && typedArrayItem[arrayField.name]) {
const arrayFieldValue = typedArrayItem[arrayField.name];
const arrayCollectionName = Array.isArray(arrayField.relationTo) ? arrayField.relationTo[0] : arrayField.relationTo;
if (typeof arrayFieldValue === "string" && arrayFieldValue.match(/^[a-f0-9]{24}$/i)) {
try {
const media = await payload.findByID({
collection: arrayCollectionName,
id: arrayFieldValue,
depth: 0
});
if (media) {
typedArrayItem[arrayField.name] = media;
payload.logger?.info(
{
mediaId: arrayFieldValue,
mediaUrl: media.url,
filename: media.filename
},
`Populated array ${arrayField.name} for block ${blockType}`
);
}
} catch (error) {
payload.logger?.error(
{ error: String(error) },
`Failed to populate array ${arrayField.name} for block ${blockType}`
);
}
}
}
}
}
}
}
}
if (field.type === "richText" && typedNode.fields[field.name]) {
await populateRichTextUploads(typedNode.fields[field.name], payload);
payload.logger?.info(`Processed rich text field ${field.name} for upload nodes`);
}
}
}
}
if (typedNode.children) {
for (const child of typedNode.children) {
await populateBlockMediaFields(child, payload, config);
}
}
}
async function populateRichTextUploads(content, payload) {
if (!content || typeof content !== "object") return;
const typedContent = content;
if (typedContent.root?.children) {
await processNodeArray(typedContent.root.children);
}
if (Array.isArray(content)) {
await processNodeArray(content);
}
async function processNodeArray(nodes) {
await Promise.all(nodes.map(processNode));
}
async function processNode(node) {
if (!node || typeof node !== "object") return;
const typedNode = node;
if (typedNode.type === "upload" && typedNode.relationTo === "media" && typeof typedNode.value === "string" && typedNode.value.match(/^[a-f0-9]{24}$/i)) {
try {
const media = await payload.findByID({
collection: "media",
id: typedNode.value,
depth: 0
});
if (media) {
typedNode.value = media;
payload.logger?.info(
{
mediaId: typedNode.value,
mediaUrl: media.url,
filename: media.filename
},
"Populated rich text upload node"
);
}
} catch (error) {
payload.logger?.error(
{ error: String(error) },
`Failed to populate rich text upload ${typedNode.value}`
);
}
}
if (typedNode.children && Array.isArray(typedNode.children)) {
await processNodeArray(typedNode.children);
}
if (typedNode.root?.children && Array.isArray(typedNode.root.children)) {
await processNodeArray(typedNode.root.children);
}
}
}
// src/endpoints/broadcasts/preview.ts
var createBroadcastPreviewEndpoint = (config, _collectionSlug) => {
return {
path: "/preview",
method: "post",
handler: (async (req) => {
try {
const data = await (req.json?.() || Promise.resolve({}));
const { content, preheader, subject, documentData } = data;
if (!content) {
return Response.json({
success: false,
error: "Content is required for preview"
}, { status: 400 });
}
const mediaUrl = req.payload.config.serverURL ? `${req.payload.config.serverURL}/api/media` : "/api/media";
req.payload.logger?.info("Populating media fields for email preview...");
const populatedContent = await populateMediaFields(content, req.payload, config);
const emailPreviewConfig = config.customizations?.broadcasts?.emailPreview;
const htmlContent = await convertToEmailSafeHtml(populatedContent, {
wrapInTemplate: emailPreviewConfig?.wrapInTemplate ?? true,
preheader,
subject,
mediaUrl,
documentData,
// Pass all document data
customBlockConverter: config.customizations?.broadcasts?.customBlockConverter,
customWrapper: emailPreviewConfig?.customWrapper
});
return Response.json({
success: true,
html: htmlContent,
preview: {
subject: subject || "Preview",
preheader: preheader || "",
html: htmlContent
}
});
} catch (error) {
console.error("Failed to generate email preview:", error);
return Response.json({
success: false,
error: "Failed to generate email preview"
}, { status: 500 });
}
})
};
};
// src/utils/getErrorMessage.ts
init_broadcast();
function getErrorMessage(error) {
if (error instanceof BroadcastProviderError) {
return `${error.code}: ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function getErrorDetails(error) {
if (error instanceof Error) {
return {
message: error.message,
name: error.name,
stack: error.stack,
...error instanceof BroadcastProviderError ? { code: error.code } : {}
};
}
return { message: String(error) };
}
// src/utils/broadcast-sync.ts
function shouldSyncToProvider(doc) {
return Boolean(doc.subject && doc.contentSection?.content);
}
async function buildProviderCreateData(doc, req, pluginConfig, providerConfig) {
const populatedContent = await populateMediaFields(
doc.contentSection?.content,
req.payload,
pluginConfig
);
const htmlContent = await convertToEmailSafeHtml(populatedContent, {
wrapInTemplate: pluginConfig.customizations?.broadcasts?.emailPreview?.wrapInTemplate ?? true,
customWrapper: pluginConfig.customizations?.broadcasts?.emailPreview?.customWrapper,
preheader: doc.contentSection?.preheader,
subject: doc.subject,
documentData: doc,
customBlockConverter: pluginConfig.customizations?.broadcasts?.customBlockConverter
});
return {
name: doc.subject,
subject: doc.subject,
preheader: doc.contentSection?.preheader || "",
content: htmlContent,
trackOpens: doc.settings?.trackOpens ?? true,
trackClicks: doc.settings?.trackClicks ?? true,
replyTo: doc.settings?.replyTo || providerConfig?.replyTo,
audienceIds: doc.audienceIds?.map((a) => a.audienceId) || []
};
}
async function syncBroadcastToProvider(doc, req, pluginConfig, providerConfig, provider) {
try {
const createData = await buildProviderCreateData(doc, req, pluginConfig, providerConfig);
const providerBroadcast = await provider.create(createData);
return {
success: true,
providerId: providerBroadcast.id,
externalId: providerBroadcast.id,
providerData: providerBroadcast.providerData
};
} catch (error) {
return {
success: false,
error: getErrorMessage(error)
};
}
}
// src/utils/scheduling-state.ts
init_broadcast();
function draftState() {
return { sendStatus: "draft" /* DRAFT */, scheduledAt: null };
}
function scheduledState(scheduledAt) {
return { sendStatus: "scheduled" /* SCHEDULED */, scheduledAt: scheduledAt.toISOString() };
}
function sendingState() {
return { sendStatus: "sending" /* SENDING */ };
}
function failedState() {
return { sendStatus: "failed" /* FAILED */ };
}
function detectStateTransition(previousDoc, currentDoc) {
const wasUnpublished = !previousDoc?._status || previousDoc._status === "draft";
const isNowPublished = currentDoc._status === "published";
const wasScheduled = previousDoc?._status === "draft" && !!previousDoc?.publishedAt;
const isNoLongerScheduled = currentDoc._status === "draft" && !currentDoc.publishedAt;
const scheduleTimeChanged = previousDoc?.publishedAt !== currentDoc.publishedAt;
const publishedAtDate = currentDoc.publishedAt ? new Date(currentDoc.publishedAt) : null;
const isManualPublish = wasUnpublished && isNowPublished && publishedAtDate !== null && publishedAtDate.getTime() > Date.now();
return {
wasUnpublished,
isNowPublished,
wasScheduled,
isNoLongerScheduled,
scheduleTimeChanged,
isManualPublish
};
}
function areScheduledTimesEqual(a, b) {
if (!a || !b) return false;
return new Date(a).getTime() === b.getTime();
}
function parseScheduledDate(dateStr) {
if (!dateStr) return null;
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return null;
return date;
}
// src/utils/idempotency.ts
var completedOperations = /* @__PURE__ */ new Map();
var DEFAULT_TTL_MS = 5 * 60 * 1e3;
function generateIdempotencyKey(broadcastId, operation, params) {
const paramStr = params ? Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join("&") : "";
return `${broadcastId}:${operation}:${paramStr}`;
}
function getCompletedOperation(key, ttlMs = DEFAULT_TTL_MS) {
const entry = completedOperations.get(key);
if (!entry) {
return void 0;
}
if (Date.now() - entry.completedAt > ttlMs) {
completedOperations.delete(key);
return void 0;
}
return entry.result;
}
function markOperationCompleted(key, result) {
completedOperations.set(key, {
completedAt: Date.now(),
result
});
if (completedOperations.size > 100) {
cleanupExpiredEntries();
}
}
function cleanupExpiredEntries(ttlMs = DEFAULT_TTL_MS) {
const now = Date.now();
for (const [key, entry] of completedOperations.entries()) {
if (now - entry.completedAt > ttlMs) {
completedOperations.delete(key);
}
}
}
// 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
init_broadcast();
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 provider = await getBroadcastProvider(req, config);
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 BroadcastProviderError) {
return Response.json({
success: false,
error: error.message,
code: error.code
}, { status: error.code === "CONFIGURATION_ERROR" ? 500 : 500 });
}
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();
init_broadcast();
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 || "", `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 { scheduledAt } = data;
if (!scheduledAt) {
return Response.json({
success: false,
error: "scheduledAt is required"
}, { status: 400 });
}
const scheduledDate = new Date(scheduledAt);
if (isNaN(scheduledDate.getTime())) {
return Response.json({
success: false,
error: "Invalid scheduledAt date"
}, { status: 400 });
}
if (scheduledDate <= /* @__PURE__ */ new Date()) {
return Response.json({
success: false,
error: "scheduledAt must be in the future"
}, { status: 400 });
}
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 provider = await getBroadcastProvider(req, config);
const broadcast = await provider.schedule(broadcastDoc.providerId, scheduledDate);
await req.payload.update({
collection: collectionSlug,
id,
data: scheduledState(scheduledDate),
user: auth.user
});
return Response.json({
success: true,
message: `Broadcast scheduled for ${scheduledDate.toISOString()}`,
broadcast
});
} catch (error) {
console.error("Failed to schedule broadcast:", error);
if (error instanceof BroadcastProviderError) {
return Response.json({
success: false,
error: error.message,
code: error.code
}, { status: error.code === "CONFIGURATION_ERROR" ? 500 : 500 });
}
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 schedule broadcast"
}, { status: 500 });
}
})
};
};
var createCancelScheduleBroadcastEndpoint = (config, collectionSlug) => {
return {
path: "/:id/schedule",
method: "delete",
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 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 });
}
if (broadcastDoc.sendStatus !== "scheduled" /* SCHEDULED */) {
return Response.json({
success: false,
error: `Cannot cancel: broadcast is not scheduled (current status: ${broadcastDoc.sendStatus})`
}, { status: 400 });
}
const provider = await getBroadcastProvider(req, config);
await provider.cancelSchedule(broadcastDoc.providerId);
await req.payload.update({
collection: collectionSlug,
id,
data: draftState(),
user: auth.user
});
return Response.json({
success: true,
message: "Broadcast schedule cancelled"
});
} catch (error) {
console.error("Failed to cancel scheduled 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 cancel scheduled broadcast"
}, { status: 500 });
}
})
};
};
// src/endpoints/broadcasts/retry-sync.ts
init_broadcast();
var createRetrySyncEndpoint = (config, collectionSlug) => {
return {
path: "/:id/retry-sync",
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 });
}
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 broadcastDoc = await req.payload.findByID({
collection: collectionSlug,
id,
user: auth.user
});
if (!broadcastDoc) {
return Response.json({
success: false,
error: "Broadcast not found"
}, { status: 404 });
}
const provider = await getBroadcastProvider(req, config);
const providerConfig = await getBroadcastConfig(req, config);
if (!broadcastDoc.providerId) {
if (!broadcastDoc.subject || !broadcastDoc.contentSection?.content) {
return Response.json({
success: false,
error: "Broadcast must have subject and content to sync"
}, { status: 400 });
}
const htmlContent2 = await convertToEmailSafeHtml(
await populateMediaFields(broadcastDoc.contentSection.content, req.payload, config),
{
wrapInTemplate: config.customizations?.broadcasts?.emailPreview?.wrapInTemplate ?? true,
customWrapper: config.customizations?.broadcasts?.emailPreview?.customWrapper,
preheader: broadcastDoc.contentSection?.preheader,
subject: broadcastDoc.subject,
documentData: broadcastDoc,
customBlockConverter: config.customizations?.broadcasts?.customBlockConverter
}
);
const createData = {
name: broadcastDoc.subject,
subject: broadcastDoc.subject,
preheader: broadcastDoc.contentSection?.preheader || "",
content: htmlContent2,
trackOpens: broadcastDoc.settings?.trackOpens ?? true,
trackClicks: broadcastDoc.settings?.trackClicks ?? true,
replyTo: broadcastDoc.settings?.replyTo || providerConfig?.replyTo,
audienceIds: broadcastDoc.audienceIds?.map((a) => a.audienceId) || []
};
const providerBroadcast = await provider.create(createData);
await req.payload.update({
collection: collectionSlug,
id,
data: {
providerId: providerBroadcast.id,
externalId: providerBroadcast.id,
providerData: providerBroadcast.providerData,
providerSyncStatus: "synced",
providerSyncError: null,
lastSyncAttempt: (/* @__PURE__ */ new Date()).toISOString()
},
user: auth.user
});
return Response.json({
success: true,
message: "Broadcast created and synced with provider"
});
}
const populatedContent = await populateMediaFields(
broadcastDoc.contentSection?.content,
req.payload,
config
);
const emailPreviewConfig = config.customizations?.broadcasts?.emailPreview;
const htmlContent = await convertToEmailSafeHtml(populatedContent, {
wrapInTemplate: emailPreviewConfig?.wrapInTemplate ?? true,
customWrapper: emailPreviewConfig?.customWrapper,
preheader: broadcastDoc.contentSection?.preheader,
subject: broadcastDoc.subject,
documentData: broadcastDoc,
customBlockConverter: config.customizations?.broadcasts?.customBlockConverter
});
const updates = {
name: broadcastDoc.subject,
subject: broadcastDoc.subject,
preheader: broadcastDoc.contentSection?.preheader,
content: htmlContent,
trackOpens: broadcastDoc.settings?.trackOpens,
trackClicks: broadcastDoc.settings?.trackClicks,
replyTo: broadcastDoc.settings?.replyTo || providerConfig?.replyTo,
audienceIds: broadcastDoc.audienceIds?.map((a) => a.audienceId)
};
await provider.update(broadcastDoc.providerId, updates);
await req.payload.update({
collection: collectionSlug,
id,
data: {
providerSyncStatus: "synced",
providerSyncError: null,
lastSyncAttempt: (/* @__PURE__ */ new Date()).toISOString()
},
user: auth.user
});
return Response.json({
success: true,
message: "Broadcast synced with provider successfully"
});
} catch (error) {
console.error("Failed to retry sync:", error);
try {
const url = new URL(req.url || "", `http://localhost`);
const pathParts = url.pathname.split("/");
const id = pathParts[pathParts.length - 2];
if (id) {
await req.payload.update({
collection: collectionSlug,
id,
data: {
providerSyncStatus: "failed",
providerSyncError: getErrorMessage(error),
lastSyncAttempt: (/* @__PURE__ */ new Date()).toISOString()
}
});
}
} catch {
}
if (error instanceof BroadcastProviderError) {
return Response.json({
success: false,
error: error.message,
code: error.code
}, { status: 500 });
}
return Response.json({
success: false,
error: getErrorMessage(error)
}, { status: 500 });
}
})
};
};
// src/endpoints/broadcasts/test.ts
var createTestBroadcastEndpoint = (config, collectionSlug) => {
return {
path: "/:id/test",
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 });
}
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 testEmail = data.email || auth.user.email;
if (!testEmail) {
return Response.json({
success: false,
error: "No email address available for test send"
}, { status: 400 });
}
const broadcast = await req.payload.findByID({
collection: collectionSlug,
id,
user: auth.user
});
if (!broadcast) {
return Response.json({
success: false,
error: "Broadcast not found"
}, { status: 404 });
}
const htmlContent = await convertToEmailSafeHtml(broadcast.content, {
wrapInTemplate: true,
preheader: broadcast.preheader,
customBlockConverter: config.customizations?.broadcasts?.customBlockConverter
});
const emailService = req.payload.newsletterEmailService;
if (!emailService) {
return Response.json({
success: false,
error: "Email service is not configured"
}, { status: 500 });
}
const providerConfig = config.providers.default === "resend" ? config.providers.resend : config.providers.broadcast;
const fromEmail = providerConfig?.fromAddress || providerConfig?.fromEmail || "noreply@example.com";
const fromName = providerConfig?.fromName || "Newsletter";
const replyTo = broadcast.settings?.replyTo || providerConfig?.replyTo;
await emailService.send({
to: testEmail,
from: fromEmail,
fromName,
replyTo,
subject: `[TEST] ${broadcast.subject}`,
html: htmlContent,
trackOpens: false,
trackClicks: false
});
return Response.json({
success: true,
message: `Test email sent to ${testEmail}`
});
} catch (error) {
console.error("Failed to send test broadcast:", error);
return Response.json({
success: false,
error: "Failed to send test email"
}, { status: 500 });
}
})
};
};
// src/collections/Broadcasts.ts
var createBroadcastsCollection = (pluginConfig) => {
const hasProviders = !!(pluginConfig.providers?.broadcast || pluginConfig.providers?.resend);
const customizations = pluginConfig.customizations?.broadcasts;
const collectionSlug = "broadcasts";
return {
slug: collectionSlug,
access: {
read: () => true,
// Public read access
create: ({ req: { user } }) => {
return Boolean(user);
},
update: ({ req: { user } }) => {
return Boolean(user);
},
delete: ({ req: { user } }) => {
return Boolean(user);
}
},
versions: {
drafts: {
autosave: true,
schedulePublish: true
}
},
labels: {
singular: "Broadcast",
plural: "Broadcasts"
},
admin: {
useAsTitle: "subject",
description: "Individual email campaigns sent to subscribers",
defaultColumns: ["subject", "_status", "sendStatus", "sentAt", "recipientCount"]
},
endpoints: [
createSendBroadcastEndpoint(pluginConfig, collectionSlug),
createScheduleBroadcastEndpoint(pluginConfig, collectionSlug),
createCancelScheduleBroadcastEndpoint(pluginConfig, collectionSlug),
createRetrySyncEndpoint(pluginConfig, collectionSlug),
createTestBroadcastEndpoint(pluginConfig, collectionSlug),
createBroadcastPreviewEndpoint(pluginConfig, collectionSlug)
],
fields: [
{
name: "subject",
type: "text",
required: true,
admin: {
description: "Email subject line"
}
},
{
name: "emailOnly",
type: "checkbox",
label: "Email Only",
defaultValue: false,
admin: {
description: "Check this to send via email only (won't be published on website). Use the plugin's Schedule Send button instead of Payload's Schedule Publish."
}
},
// Add any additional fields from customizations after subject
...customizations?.additionalFields || [],
{
type: "row",
fields: [
{
name: "contentSection",
type: "group",
label: false,
admin: {
width: "50%",
style: {
paddingRight: "1rem"
}
},
fields: [
{
name: "preheader",
type: "text",
admin: {
description: "Preview text shown in email clients"
}
},
// Apply content field customization if provided
// Process blocks server-side to avoid client serialization issues
(() => {
const emailEditor = createEmailLexicalEditor(customizations?.customBlocks);
const baseField = createEmailContentField({
admin: { description: "Email content" },
editor: emailEditor
});
return customizations?.fieldOverrides?.content ? customizations.fieldOverrides.content(baseField) : baseField;
})()
]
},
{
name: "previewSection",
type: "group",
label: false,
admin: {
width: "50%"
},
fields: [
createBroadcastInlinePreviewField()
]
}
]
},
{
name: "sendStatus",
type: "select",
label: "Send Status",
required: true,
defaultValue: "draft" /* DRAFT */,
options: [
{ label: "Draft", value: "draft" /* DRAFT */ },
{ label: "Scheduled", value: "scheduled" /* SCHEDULED */ },
{ label: "Sending", value: "sending" /* SENDING */ },
{ label: "Sent", value: "sent" /* SENT */ },
{ label: "Failed", value: "failed" /* FAILED */ },
{ label: "Paused", value: "paused" /* PAUSED */ },
{ label: "Canceled", value: "canceled" /* CANCELED */ }
],
admin: {
readOnly: true,
description: "The status of the email send operation",
components: {
Cell: "payload-plugin-newsletter/components#StatusBadge"
}
}
},
// Scheduling controls - shows schedule/cancel buttons based on status
createBroadcastScheduleField(),
{
name: "settings",
type: "group",
fields: [
{
name: "trackOpens",
type: "checkbox",
defaultValue: true,
admin: {
description: "Track when recipients open this email"
}
},
{
name: "trackClicks",
type: "checkbox",
defaultValue: true,
admin: {
description: "Track when recipients click links"
}
},
{
name: "replyTo",
type: "email",
admin: {
description: "Override the channel reply-to address for this broadcast"
}
}
]
},
{
name: "audienceIds",
type: "array",
fields: [
{
name: "audienceId",
type: "text",
required: true
}
],
admin: {
description: "Target specific audience segments",
condition: () => {
return hasProviders;
}
}
},
{
name: "analytics",
type: "group",
admin: {
readOnly: true,
condition: (data) => data?.sendStatus === "sent" /* SENT */
},
fields: [
{
name: "recipientCount",
type: "number",
defaultValue: 0
},
{
name: "sent",
type: "number",
defaultValue: 0
},
{
name: "delivered",
type: "number",
defaultValue: 0
},
{
name: "opened",
type: "number",
defaultValue: 0
},
{
name: "clicked",
type: "number",
defaultValue: 0
},
{
name: "bounced",
type: "number",
defaultValue: 0
},
{
name: "complained",
type: "number",
defaultValue: 0
},
{
name: "unsubscribed",
type: "number",
defaultValue: 0
}
]
},
{
name: "sentAt",
type: "date",
admin: {
readOnly: true,
date: {
displayFormat: "MMM d, yyyy h:mm a"
}
}
},
{
name: "scheduledAt",
type: "date",
admin: {
condition: (data) => data?.sendStatus === "scheduled" /* SCHEDULED */,
date: {
displayFormat: "MMM d, yyyy h:mm a"
}
}
},
{
name: "providerId",
type: "text",
admin: {
readOnly: true,
description: "ID from the email provider",
condition: (data) => hasProviders && data?.providerId
}
},
{
name: "externalId",
type: "text",
admin: {
readOnly: true,
description: "External ID for webhook integration"
}
},
{
name: "providerData",
type: "json",
admin: {
readOnly: true,
condition: () => false
// Hidden by default
}
},
// Provider sync status tracking
{
name: "providerSyncStatus",
type: "select",
label: "Provider Sync Status",
defaultValue: "pending",
options: [
{ label: "Pending", value: "pending" },
{ label: "Synced", value: "synced" },
{ label: "Failed", value: "failed" }
],
admin: {
readOnly: true,
description: "Status of sync with email provider",
condition: (data) => hasProviders && data?.providerId,
components: {
Field: "payload-plugin-newsletter/components#SyncStatusField"
}
}
},
{
name: "providerSyncError",
type: "text",
admin: {
readOnly: true,
condition: (data) => data?.providerSyncStatus === "failed",
description: "Error message from last sync attempt"
}
},
{
name: "lastSyncAttempt",
type: "date",
admin: {
readOnly: true,
condition: () => false
// Hidden, used for tracking
}
},
// Webhook tracking fields
{
name: "webhookData",
type: "group",
label: "Webhook Data",
admin: {
condition: () => false
// Hidden by default, used for webhook tracking
},
fields: [
{
name: "lastWebhookEvent",
type: "text",
admin: {
readOnly: true
}
},
{
name: "lastWebhookEventAt",
type: "date",
admin: {
readOnly: true
}
},
{
name: "hasWarnings",
type: "checkbox",
defaultValue: false
},
{
name: "failureReason",
type: "text"
},
{
name: "sentCount",
type: "number"
},
{
name: "totalCount",
type: "number"
},
{
name: "failedCount",
type: "number"
},
{
name: "remainingCount",
type: "number"
},
{
name: "sendingStartedAt",
type: "date"
},
{
name: "failedAt",
type: "date"
},
{
name: "abortedAt",
type: "date"
},
{
name: "abortReason",
type: "text"
},
{
name: "pausedAt",
type: "date"
},
{
name: "webhookEvents",
type: "array",
label: "Webhook Event Log",
maxRows: 10,
admin: {
readOnly: true,
description: "Recent webhook events for debugging"
},
fields: [
{
name: "eventType",
type: "text",
admin: { readOnly: true }
},
{
name: "receivedAt",
type: "date",
admin: { readOnly: true }
},
{
name: "eventPayload",
type: "json",
admin: { readOnly: true }
}
]
}
]
}
],
hooks: {
// Sync with provider on create and update
afterChange: [
async ({ doc, operation, req, previousDoc, context }) => {
if (!hasProviders) return doc;
const hookContext = context;
if (hookContext?.isSchedulingUpdate) {
return doc;
}
req.payload.logger.info({
operation,
hasProviderId: !!doc.providerId,
hasExternalId: !!doc.externalId,
sendStatus: doc.sendStatus,
publishStatus: doc._status,
hasSubject: !!doc.subject,
hasContent: !!doc.contentSection?.content
}, `Broadcast afterChange ${operation} hook triggered`);
if (operation === "create") {
if (!shouldSyncToProvider(doc)) {
req.payload.logger.info(
{ broadcastId: doc.id },
"Broadcast created without sufficient content - skipping provider sync"
);
return doc;
}
try {
const provider = await getBroadcastProvider(req, pluginConfig);
const providerConfig = await getBroadcastConfig(req, pluginConfig);
const syncResult = await syncBroadcastToProvider(
doc,
req,
pluginConfig,
providerConfig,
provider
);
if (syncResult.success) {
req.payload.logger.info(
{ broadcastId: doc.id, providerId: syncResult.providerId },
"Broadcast synced to provider on CREATE"
);
await req.payload.update({
collection: collectionSlug,
id: doc.id,
data: {
providerId: syncResult.providerId,
externalId: syncResult.externalId,
providerData: syncResult.providerData,
providerSyncStatus: "synced",
providerSyncError: null,
lastSyncAttempt: (/* @__PURE__ */ new Date()).toISOString()
},
context: { isSchedulingUpdate: true }
});
return {
...doc,
providerId: syncResult.providerId,
externalId: syncResult.externalId,
providerData: syncResult.providerData,
providerSyncStatus: "synced",
providerSyncError: null
};
} else {
req.payload.logger.error(
{ broadcastId: doc.id, error: syncResult.error },
"Failed to sync broadcast to provider on CREATE"
);
await req.payload.update({
collection: collectionSlug,
id: doc.id,
data: {
providerSyncStatus: "failed",
providerSyncError: syncResult.error,
lastSyncAttempt: (/* @__PURE__ */ new Date()).toISOString()
},
context: { isSchedulingUpdate: true }
});
return {
...doc,
providerSyncStatus: "failed",
providerSyncError: syncResult.error
};
}
} catch (error) {
req.payload.logger.error(
{ broadcastId: doc.id, error: getErrorDetails(error) },
"Unexpected error during CREATE sync"
);
return doc;
}
}
if (operation === "update") {
try {
const provider = await getBroadcastProvider(req, pluginConfig);
const providerConfig = await getBroadcastConfig(req, pluginConfig);
if (!doc.providerId && !doc.externalId && shouldSyncToProvider(doc)) {
req.payload.logger.info("Creating broadcast in provider on first update with content");
const syncResult = await syncBroadcastToProvider(
doc,
req,
pluginConfig,
providerConfig,
provider
);
if (syncResult.success) {
req.payload.logger.info(
{ broadcastId: doc.id, providerId: syncResult.providerId },
"Broadcast synced to provider on UPDATE"
);
await req.payload.update({
collection: collectionSlug,
id: doc.id,
data: {
providerId: syncResult.providerId,
externalId: syncResult.externalId,
providerData: syncResult.providerData,
providerSyncStatus: "synced",
providerSyncError: null,
lastSyncAttempt: (/* @__PURE__ */ new Date()).toISOString()
},
context: { isSchedulingUpdate: true }
});
return {
...doc,
providerId: syncResult.providerId,
externalId: syncResult.externalId,
providerData: syncResult.providerData,
providerSyncStatus: "synced",
providerSyncError: null
};
} else {
req.payload.logger.error(
{ broadcastId: doc.id, error: syncResult.error },
"Failed to create broadcast in provider during UPDATE"
);
}
}
if (!doc.providerId) {
req.payload.logger.info(`Broadcast ${doc.id} has no providerId and insufficient content for creation - skipping sync`);
return doc;
}
if (doc.providerId) {
const capabilities = provider.getCapabilities();
const sendStatus = doc.sendStatus || "draft" /* DRAFT */;
if (!capabilities.editableStatuses.includes(sendStatus)) {
req.payload.logger.info(`Skipping sync for broadcast in status: ${sendStatus}`);
return doc;
}
const contentChanged = doc.subject !== previousDoc?.subject || doc.contentSection?.preheader !== previousDoc?.contentSection?.preheader || JSON.stringify(doc.contentSection?.content) !== JSON.stringify(previousDoc?.contentSection?.content) || doc.settings?.trackOpens !== previousDoc?.settings?.trackOpens || doc.settings?.trackClicks !== previousDoc?.settings?.trackClicks || doc.settings?.replyTo !== previousDoc?.settings?.replyTo || JSON.stringify(doc.audienceIds) !== JSON.stringify(previousDoc?.audienceIds);
if (contentChanged) {
const updates = {};
if (doc.subject !== previousDoc?.subject) {
updates.name = doc.subject;
updates.subject = doc.subject;
}
if (doc.contentSection?.preheader !== previousDoc?.contentSection?.preheader) {
updates.preheader = doc.contentSection?.preheader;
}
if (JSON.stringify(doc.contentSection?.content) !== JSON.stringify(previousDoc?.contentSection?.content)) {
const populatedContent = await populateMediaFields(doc.contentSection?.content, req.payload, pluginConfig);
const emailPreviewConfig = pluginConfig.customizations?.broadcasts?.emailPreview;
updates.content = await convertToEmailSafeHtml(populatedContent, {
wrapInTemplate: emailPreviewConfig?.wrapInTemplate ?? true,
customWrapper: emailPreviewConfig?.customWrapper,
preheader: doc.contentSection?.preheader,
subject: doc.subject,
documentData: doc,
// Pass entire document
customBlockConverter: pluginConfig.customizations?.broadcasts?.customBlockConverter
});
}
if (doc.settings?.trackOpens !== previousDoc?.settings?.trackOpens) {
updates.trackOpens = doc.settings.trackOpens;
}
if (doc.settings?.trackClicks !== previousDoc?.settings?.trackClicks) {
updates.trackClicks = doc.settings.trackClicks;
}
if (doc.settings?.replyTo !== previousDoc?.settings?.replyTo) {
updates.replyTo = doc.settings.replyTo || providerConfig?.replyTo;
}
if (JSON.stringify(doc.audienceIds) !== JSON.stringify(previousDoc?.audienceIds)) {
updates.audienceIds = doc.audienceIds?.map((a) => a.audienceId);
}
req.payload.logger.info({
providerId: doc.providerId,
updates
}, "Syncing broadcast updates to provider");
await provider.update(doc.providerId, updates);
await req.payload.update({
collection: collectionSlug,
id: doc.id,
data: {
providerSyncStatus: "synced",
providerSyncError: null,
lastSyncAttempt: (/* @__PURE__ */ new Date()).toISOString()
},
context: { isSchedulingUpdate: true }
});
req.payload.logger.info(`Broadcast ${doc.id} synced to provider successfully`);
} else {
req.payload.logger.info("No content changes to sync to provider");
}
}
} catch (error) {
req.payload.logger.error(
{
broadcastId: doc.id,
subject: doc.subject,
error: getErrorDetails(error)
},
"Failed to sync broadcast to provider"
);
try {
await req.payload.update({
collection: collectionSlug,
id: doc.id,
data: {
providerSyncStatus: "failed",
providerSyncError: getErrorMessage(error),
lastSyncAttempt: (/* @__PURE__ */ new Date()).toISOString()
},
context: { isSchedulingUpdate: true }
});
} catch (updateError) {
req.payload.logger.error(
{ broadcastId: doc.id, error: getErrorDetails(updateError) },
"Failed to update sync status after sync failure"
);
}
}
}
return doc;
},
// Unified scheduling hook: syncs email scheduling with Payload publish scheduling
async ({ doc, operation, previousDoc, req, context }) => {
const hookContext = context;
if (hookContext?.isSchedulingUpdate) {
return doc;
}
if (operation !== "update") {
return doc;
}
if (!hasProviders) {
return doc;
}
const broadcastDoc = doc;
if (!broadcastDoc.providerId) {
return doc;
}
if (broadcastDoc.emailOnly === true) {
return doc;
}
if (broadcastDoc.sendStatus === "sent" /* SENT */ || broadcastDoc.sendStatus === "sending" /* SENDING */) {
return doc;
}
const transition = detectStateTransition(
previousDoc,
broadcastDoc
);
const publishedAt = parseScheduledDate(broadcastDoc.publishedAt);
const now = /* @__PURE__ */ new Date();
if (broadcastDoc.sendStatus === "scheduled" /* SCHEDULED */ && areScheduledTimesEqual(broadcastDoc.scheduledAt, publishedAt)) {
return doc;
}
if (transition.wasUnpublished && transition.isNowPublished) {
try {
const provider = await getBroadcastProvider(req, pluginConfig);
if (transition.isManualPublish) {
req.payload.logger.info(
{
broadcastId: broadcastDoc.id,
action: "manualPublishOverride",
scheduledFor: broadcastDoc.publishedAt
},
"Manual publish detected, sending immediately instead of waiting for scheduled time"
);
}
if (publishedAt && publishedAt.getTime() > now.getTime() && !transition.isManualPublish) {
const scheduleKey = generateIdempotencyKey(broadcastDoc.id, "schedule", {
time: publishedAt.getTime()
});
const cached = getCompletedOperation(scheduleKey);
if (cached) {
req.payload.logger.info(
{ broadcastId: broadcastDoc.id, idempotencyKey: scheduleKey },
"Schedule operation already completed (idempotency check), updating local state only"
);
} else {
await provider.schedule(broadcastDoc.providerId, publishedAt);
markOperationCompleted(scheduleKey, { scheduledAt: publishedAt.toISOString() });
}
await req.payload.update({
collection: "broadcasts",
id: broadcastDoc.id,
data: scheduledState(publishedAt),
context: { isSchedulingUpdate: true }
});
req.payload.logger.info(
{
broadcastId: broadcastDoc.id,
scheduledAt: publishedAt.toISOString()
},
"Email scheduled to sync with website publish"
);
} else {
const sendKey = generateIdempotencyKey(broadcastDoc.id, "send", {});
const cached = getCompletedOperation(sendKey);
if (cached) {
req.payload.logger.info(
{ broadcastId: broadcastDoc.id, idempotencyKey: sendKey },
"Send operation already completed (idempotency check), updating local state only"
);
} else {
await provider.send(broadcastDoc.providerId);
markOperationCompleted(sendKey, { sentAt: (/* @__PURE__ */ new Date()).toISOString() });
}
await req.payload.update({
collection: "broadcasts",
id: broadcastDoc.id,
data: {
...sendingState(),
sentAt: (/* @__PURE__ */ new Date()).toISOString()
},
context: { isSchedulingUpdate: true }
});
req.payload.logger.info(
{ broadcastId: broadcastDoc.id },
"Broadcast sent successfully on publish"
);
}
} catch (error) {
req.payload.logger.error(
{
broadcastId: broadcastDoc.id,
error: getErrorDetails(error)
},
"Failed to schedule/send email for published broadcast"
);
await req.payload.update({
collection: "broadcasts",
id: broadcastDoc.id,
data: {
...failedState(),
// Store error in webhookData.failureReason since we don't have a sendError field
webhookData: {
...doc.webhookData || {},
failureReason: `Email operation failed: ${getErrorMessage(error)}`
}
},
context: { isSchedulingUpdate: true }
});
}
return doc;
}
if (transition.wasScheduled && transition.isNoLongerScheduled && broadcastDoc.sendStatus === "scheduled" /* SCHEDULED */) {
try {
const provider = await getBroadcastProvider(req, pluginConfig);
const cancelKey = generateIdempotencyKey(broadcastDoc.id, "cancel", {});
const cached = getCompletedOperation(cancelKey);
if (cached) {
req.payload.logger.info(
{ broadcastId: broadcastDoc.id, idempotencyKey: cancelKey },
"Cancel operation already completed (idempotency check), updating local state only"
);
} else {
await provider.cancelSchedule(broadcastDoc.providerId);
markOperationCompleted(cancelKey, { cancelledAt: (/* @__PURE__ */ new Date()).toISOString() });
}
await req.payload.update({
collection: "broadcasts",
id: broadcastDoc.id,
data: draftState(),
context: { isSchedulingUpdate: true }
});
req.payload.logger.info(
{ broadcastId: broadcastDoc.id },
"Email schedule cancelled (publish schedule removed)"
);
} catch (error) {
req.payload.logger.error(
{
broadcastId: broadcastDoc.id,
error: getErrorDetails(error)
},
"Failed to cancel scheduled email - email may still send at original time"
);
}
return doc;
}
if (transition.scheduleTimeChanged && publishedAt && broadcastDoc.sendStatus === "scheduled" /* SCHEDULED */) {
try {
const provider = await getBroadcastProvider(req, pluginConfig);
const previousScheduledAt = broadcastDoc.scheduledAt;
const rescheduleKey = generateIdempotencyKey(broadcastDoc.id, "schedule", {
from: previousScheduledAt ? new Date(previousScheduledAt).getTime() : 0,
to: publishedAt.getTime()
});
const cached = getCompletedOperation(rescheduleKey);
if (cached) {
req.payload.logger.info(
{ broadcastId: broadcastDoc.id, idempotencyKey: rescheduleKey },
"Reschedule operation already completed (idempotency check), updating local state only"
);
} else {
await provider.cancelSchedule(broadcastDoc.providerId);
await provider.schedule(broadcastDoc.providerId, publishedAt);
markOperationCompleted(rescheduleKey, {
rescheduledAt: (/* @__PURE__ */ new Date()).toISOString(),
newTime: publishedAt.toISOString()
});
}
await req.payload.update({
collection: "broadcasts",
id: broadcastDoc.id,
data: {
scheduledAt: publishedAt.toISOString()
},
context: { isSchedulingUpdate: true }
});
req.payload.logger.info(
{
broadcastId: broadcastDoc.id,
newScheduledAt: publishedAt.toISOString()
},
"Email rescheduled to new publish time"
);
} catch (error) {
req.payload.logger.error(
{
broadcastId: broadcastDoc.id,
newScheduledAt: publishedAt.toISOString(),
error: getErrorDetails(error)
},
"Failed to reschedule email - may send at original time or not at all"
);
}
return doc;
}
return doc;
}
],
// beforeChange hooks can be added here if needed
beforeChange: [],
// Handle deletion
afterDelete: [
async ({ doc, req }) => {
if (!hasProviders || !doc?.providerId) return doc;
try {
const provider = await getBroadcastProvider(req, pluginConfig);
const capabilities = provider.getCapabilities();
if (capabilities.editableStatuses.includes(doc.sendStatus)) {
await provider.delete(doc.providerId);
}
} catch (error) {
if (error instanceof Error) {
req.payload.logger.error("Failed to delete broadcast from provider:", {
message: error.message,
stack: error.stack,
name: error.name,
// If it's a BroadcastProviderError, it might have additional details
...error.details
});
} else {
req.payload.logger.error({ error: String(error) }, "Failed to delete broadcast from provider");
}
}
return doc;
}
]
}
};
};
// src/emails/render.tsx
var import_render = require("@react-email/render");
// src/emails/MagicLink.tsx
var import_components = require("@react-email/components");
// src/emails/styles.ts
var styles = {
main: {
backgroundColor: "#f6f9fc",
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif'
},
container: {
backgroundColor: "#ffffff",
border: "1px solid #f0f0f0",
borderRadius: "5px",
margin: "0 auto",
padding: "45px",
marginBottom: "64px",
maxWidth: "500px"
},
heading: {
fontSize: "24px",
letterSpacing: "-0.5px",
lineHeight: "1.3",
fontWeight: "600",
color: "#484848",
margin: "0 0 20px",
padding: "0"
},
text: {
fontSize: "16px",
lineHeight: "26px",
fontWeight: "400",
color: "#484848",
margin: "16px 0"
},
button: {
backgroundColor: "#000000",
borderRadius: "5px",
color: "#fff",
fontSize: "16px",
fontWeight: "bold",
textDecoration: "none",
textAlign: "center",
display: "block",
width: "100%",
padding: "14px 20px",
margin: "30px 0"
},
link: {
color: "#2754C5",
fontSize: "14px",
textDecoration: "underline",
wordBreak: "break-all"
},
hr: {
borderColor: "#e6ebf1",
margin: "30px 0"
},
footer: {
fontSize: "14px",
lineHeight: "24px",
color: "#9ca2ac",
textAlign: "center",
margin: "0"
},
code: {
display: "inline-block",
padding: "16px",
width: "100%",
backgroundColor: "#f4f4f4",
borderRadius: "5px",
border: "1px solid #eee",
fontSize: "14px",
fontFamily: "monospace",
textAlign: "center",
margin: "24px 0"
}
};
// src/emails/MagicLink.tsx
var import_jsx_runtime = require("react/jsx-runtime");
var MagicLinkEmail = ({
magicLink,
email,
siteName = "Newsletter",
expiresIn = "24 hours"
}) => {
const previewText = `Sign in to ${siteName}`;
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_components.Html, { children: [
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_components.Head, {}),
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_components.Preview, { children: previewText }),
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_components.Body, { style: styles.main, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_components.Container, { style: styles.container, children: [
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_components.Text, { style: styles.heading, children: [
"Sign in to ",
siteName
] }),
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_components.Text, { style: styles.text, children: [
"Hi ",
email.split("@")[0],
","
] }),
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_components.Text, { style: styles.text, children: [
"We received a request to sign in to your ",
siteName,
" account. Click the button below to complete your sign in:"
] }),
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_components.Button, { href: magicLink, style: styles.button, children: [
"Sign in to ",
siteName
] }),
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_components.Text, { style: styles.text, children: "Or copy and paste this URL into your browser:" }),
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { style: styles.code, children: magicLink }),
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_components.Hr, { style: styles.hr }),
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_components.Text, { style: styles.footer, children: [
"This link will expire in ",
expiresIn,
". If you didn't request this email, you can safely ignore it."
] })
] }) })
] });
};
// src/emails/Welcome.tsx
var import_components2 = require("@react-email/components");
var import_jsx_runtime2 = require("react/jsx-runtime");
var WelcomeEmail = ({
email,
siteName = "Newsletter",
preferencesUrl
}) => {
const previewText = `Welcome to ${siteName}!`;
const firstName = email.split("@")[0];
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_components2.Html, { children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_components2.Head, {}),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_components2.Preview, { children: previewText }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_components2.Body, { style: styles.main, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_components2.Container, { style: styles.container, children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_components2.Text, { style: styles.heading, children: [
"Welcome to ",
siteName,
"! \u{1F389}"
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_components2.Text, { style: styles.text, children: [
"Hi ",
firstName,
","
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_components2.Text, { style: styles.text, children: [
"Thanks for subscribing to ",
siteName,
"! We're excited to have you as part of our community."
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_components2.Text, { style: styles.text, children: "You'll receive our newsletter based on your preferences. Speaking of which, you can update your preferences anytime:" }),
preferencesUrl && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_components2.Button, { href: preferencesUrl, style: styles.button, children: "Manage Preferences" }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_components2.Text, { style: styles.text, children: "Here's what you can expect from us:" }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_components2.Text, { style: styles.text, children: [
"\u2022 Regular updates based on your chosen frequency",
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("br", {}),
"\u2022 Content tailored to your interests",
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("br", {}),
"\u2022 Easy unsubscribe options in every email",
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("br", {}),
"\u2022 Your privacy respected always"
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_components2.Hr, { style: styles.hr }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_components2.Text, { style: styles.footer, children: "If you have any questions, feel free to reply to this email. We're here to help!" })
] }) })
] });
};
// src/emails/SignIn.tsx
var import_jsx_runtime3 = require("react/jsx-runtime");
var SignInEmail = (props) => {
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MagicLinkEmail, { ...props });
};
// src/emails/render.tsx
var import_jsx_runtime4 = require("react/jsx-runtime");
async function renderEmail(template, data, config) {
try {
if (config?.customTemplates) {
const customTemplate = config.customTemplates[template];
if (customTemplate) {
const CustomComponent = customTemplate;
return (0, import_render.render)(/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(CustomComponent, { ...data }));
}
}
switch (template) {
case "magic-link": {
const magicLinkData = data;
return (0, import_render.render)(
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
MagicLinkEmail,
{
magicLink: magicLinkData.magicLink || magicLinkData.verificationUrl || magicLinkData.magicLinkUrl || "",
email: magicLinkData.email || "",
siteName: magicLinkData.siteName,
expiresIn: magicLinkData.expiresIn
}
)
);
}
case "signin": {
const signinData = data;
return (0, import_render.render)(
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
SignInEmail,
{
magicLink: signinData.magicLink || signinData.verificationUrl || signinData.magicLinkUrl || "",
email: signinData.email || "",
siteName: signinData.siteName,
expiresIn: signinData.expiresIn
}
)
);
}
case "welcome": {
const welcomeData = data;
return (0, import_render.render)(
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
WelcomeEmail,
{
email: welcomeData.email || "",
siteName: welcomeData.siteName,
preferencesUrl: welcomeData.preferencesUrl
}
)
);
}
default:
throw new Error(`Unknown email template: ${template}`);
}
} catch (error) {
console.error(`Failed to render email template ${template}:`, error);
throw error;
}
}
// src/collections/Subscribers.ts
var createSubscribersCollection = (pluginConfig) => {
const slug = pluginConfig.subscribersSlug || "subscribers";
const defaultFields = [
// Core fields
{
name: "email",
type: "email",
required: true,
unique: true,
admin: {
description: "Subscriber email address"
}
},
{
name: "name",
type: "text",
admin: {
description: "Subscriber full name"
}
},
{
name: "locale",
type: "select",
options: pluginConfig.i18n?.locales?.map((locale) => ({
label: locale.toUpperCase(),
value: locale
})) || [
{ label: "EN", value: "en" }
],
defaultValue: pluginConfig.i18n?.defaultLocale || "en",
admin: {
description: "Preferred language for communications"
}
},
// Authentication fields (hidden from admin UI)
{
name: "magicLinkToken",
type: "text",
hidden: true
},
{
name: "magicLinkTokenExpiry",
type: "date",
hidden: true
},
// External ID for webhook integration
{
name: "externalId",
type: "text",
admin: {
description: "ID from email service provider",
readOnly: true
}
},
// Subscription status
{
name: "subscriptionStatus",
type: "select",
options: [
{ label: "Active", value: "active" },
{ label: "Unsubscribed", value: "unsubscribed" },
{ label: "Pending", value: "pending" }
],
defaultValue: "pending",
required: true,
admin: {
description: "Current subscription status"
}
},
{
name: "subscribedAt",
type: "date",
admin: {
description: "When the user subscribed",
readOnly: true
}
},
{
name: "unsubscribedAt",
type: "date",
admin: {
condition: (data) => data?.subscriptionStatus === "unsubscribed",
description: "When the user unsubscribed",
readOnly: true
}
},
{
name: "unsubscribeReason",
type: "text",
admin: {
condition: (data) => data?.subscriptionStatus === "unsubscribed",
description: "Reason for unsubscribing",
readOnly: true
}
},
// Email preferences
{
name: "emailPreferences",
type: "group",
fields: [
{
name: "newsletter",
type: "checkbox",
defaultValue: true,
label: "Newsletter",
admin: {
description: "Receive regular newsletter updates"
}
},
{
name: "announcements",
type: "checkbox",
defaultValue: true,
label: "Announcements",
admin: {
description: "Receive important announcements"
}
}
],
admin: {
description: "Email communication preferences"
}
},
// Source tracking
{
name: "source",
type: "text",
admin: {
description: "Where the subscriber signed up from"
}
},
// Import tracking
{
name: "importedFromProvider",
type: "checkbox",
defaultValue: false,
admin: {
description: "Indicates this subscriber was imported from an external provider via webhook",
position: "sidebar",
readOnly: true
}
}
];
if (pluginConfig.features?.utmTracking?.enabled) {
const utmFields = pluginConfig.features.utmTracking.fields || [
"source",
"medium",
"campaign",
"content",
"term"
];
defaultFields.push({
name: "utmParameters",
type: "group",
fields: utmFields.map((field) => ({
name: field,
type: "text",
admin: {
description: `UTM ${field} parameter`
}
})),
admin: {
description: "UTM tracking parameters"
}
});
}
defaultFields.push({
name: "signupMetadata",
type: "group",
fields: [
{
name: "ipAddress",
type: "text",
admin: {
readOnly: true
}
},
{
name: "userAgent",
type: "text",
admin: {
readOnly: true
}
},
{
name: "referrer",
type: "text",
admin: {
readOnly: true
}
},
{
name: "signupPage",
type: "text",
admin: {
readOnly: true
}
}
],
admin: {
description: "Technical information about signup"
}
});
if (pluginConfig.features?.leadMagnets?.enabled) {
defaultFields.push({
name: "leadMagnet",
type: "relationship",
relationTo: pluginConfig.features.leadMagnets.collection || "media",
admin: {
description: "Lead magnet downloaded at signup"
}
});
}
let fields = defaultFields;
if (pluginConfig.fields?.overrides) {
fields = pluginConfig.fields.overrides({ defaultFields });
}
if (pluginConfig.fields?.additional) {
fields = [...fields, ...pluginConfig.fields.additional];
}
const subscribersCollection = {
slug,
labels: {
singular: "Subscriber",
plural: "Subscribers"
},
admin: {
useAsTitle: "email",
defaultColumns: ["email", "name", "subscriptionStatus", "createdAt"],
group: "Newsletter"
},
fields,
hooks: {
afterChange: [
async ({ doc, req, operation, previousDoc }) => {
if (operation === "create") {
const emailService = req.payload.newsletterEmailService;
console.log("[Newsletter Plugin] Creating subscriber:", {
email: doc.email,
hasEmailService: !!emailService
});
if (emailService) {
try {
await emailService.addContact(doc);
console.log("[Newsletter Plugin] Successfully added contact to email service");
} catch (error) {
console.error("[Newsletter Plugin] Failed to add contact to email service:", error);
}
} else {
console.warn("[Newsletter Plugin] No email service configured for subscriber creation");
}
if (doc.subscriptionStatus === "active" && emailService && !doc.importedFromProvider) {
try {
const settings = await req.payload.findGlobal({
slug: pluginConfig.settingsSlug || "newsletter-settings"
});
const serverURL = req.payload.config.serverURL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "";
const html = await renderEmail("welcome", {
email: doc.email,
siteName: settings?.brandSettings?.siteName || "Newsletter",
preferencesUrl: `${serverURL}/account/preferences`
// This could be customized
}, pluginConfig);
await emailService.send({
to: doc.email,
subject: settings?.brandSettings?.siteName ? `Welcome to ${settings.brandSettings.siteName}!` : "Welcome!",
html
});
console.warn(`Welcome email sent to: ${doc.email}`);
} catch (error) {
console.error("Failed to send welcome email:", error);
}
}
if (pluginConfig.hooks?.afterSubscribe) {
await pluginConfig.hooks.afterSubscribe({ doc, req });
}
}
if (operation === "update" && previousDoc) {
const emailService = req.payload.newsletterEmailService;
if (doc.subscriptionStatus !== previousDoc.subscriptionStatus) {
console.log("[Newsletter Plugin] Subscription status changed:", {
email: doc.email,
from: previousDoc.subscriptionStatus,
to: doc.subscriptionStatus,
hasEmailService: !!emailService
});
if (emailService) {
try {
await emailService.updateContact(doc);
console.log("[Newsletter Plugin] Successfully updated contact in email service");
} catch (error) {
console.error("[Newsletter Plugin] Failed to update contact in email service:", error);
}
} else {
console.warn("[Newsletter Plugin] No email service configured");
}
}
if (doc.subscriptionStatus === "active" && previousDoc.subscriptionStatus === "unsubscribed" && !doc.importedFromProvider && emailService) {
try {
const settings = await req.payload.findGlobal({
slug: pluginConfig.settingsSlug || "newsletter-settings"
});
const serverURL = req.payload.config.serverURL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "";
const html = await renderEmail("welcome", {
email: doc.email,
siteName: settings?.brandSettings?.siteName || "Newsletter",
preferencesUrl: `${serverURL}/account/preferences`
}, pluginConfig);
await emailService.send({
to: doc.email,
subject: settings?.brandSettings?.siteName ? `Welcome back to ${settings.brandSettings.siteName}!` : "Welcome back!",
html
});
console.warn(`Welcome email sent to resubscribed user: ${doc.email}`);
} catch (error) {
console.error("Failed to send resubscription welcome email:", error);
}
if (pluginConfig.hooks?.afterSubscribe) {
await pluginConfig.hooks.afterSubscribe({ doc, req });
}
}
if (doc.subscriptionStatus === "unsubscribed" && previousDoc.subscriptionStatus !== "unsubscribed") {
doc.unsubscribedAt = (/* @__PURE__ */ new Date()).toISOString();
if (pluginConfig.hooks?.afterUnsubscribe) {
await pluginConfig.hooks.afterUnsubscribe({ doc, req });
}
}
}
}
],
beforeDelete: [
async ({ id, req }) => {
const emailService = req.payload.newsletterEmailService;
if (emailService) {
try {
const doc = await req.payload.findByID({
collection: slug,
id
});
await emailService.removeContact(doc.email);
} catch {
}
}
}
]
},
access: {
create: () => true,
// Public can subscribe
read: adminOrSelf(pluginConfig),
update: adminOrSelf(pluginConfig),
delete: adminOnly(pluginConfig)
},
timestamps: true
};
return subscribersCollection;
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
createBroadcastsCollection,
createSubscribersCollection
});
//# sourceMappingURL=collections.cjs.map