payload-plugin-newsletter
Version:
Complete newsletter management plugin for Payload CMS with subscriber management, magic link authentication, and email service integration
658 lines (629 loc) • 22.7 kB
JavaScript
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 __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/exports/utils.ts
var utils_exports = {};
__export(utils_exports, {
EMAIL_SAFE_CONFIG: () => EMAIL_SAFE_CONFIG,
convertToEmailSafeHtml: () => convertToEmailSafeHtml,
getBroadcastConfig: () => getBroadcastConfig,
getResendConfig: () => getResendConfig,
validateEmailHtml: () => validateEmailHtml
});
module.exports = __toCommonJS(utils_exports);
// 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 styles = {
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 = `${styles[tag] || styles.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 styles = {
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 = `${styles[style] || styles.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 styles = {
solid: "border-top: 1px solid #e5e7eb;",
dashed: "border-top: 1px dashed #e5e7eb;",
dotted: "border-top: 1px dotted #e5e7eb;"
};
return `<hr style="${styles[style] || styles.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/validateEmailHtml.ts
function validateEmailHtml(html) {
const warnings = [];
const errors = [];
const sizeInBytes = new Blob([html]).size;
if (sizeInBytes > 102400) {
warnings.push(`Email size (${Math.round(sizeInBytes / 1024)}KB) exceeds Gmail's 102KB limit - email may be clipped`);
}
if (html.includes("position:") && (html.includes("position: absolute") || html.includes("position: fixed"))) {
errors.push("Absolute/fixed positioning is not supported in most email clients");
}
if (html.includes("display: flex") || html.includes("display: grid")) {
errors.push("Flexbox and Grid layouts are not supported in many email clients");
}
if (html.includes("@media")) {
warnings.push("Media queries may not work in all email clients");
}
const hasJavaScript = html.includes("<script") || html.includes("onclick") || html.includes("onload") || html.includes("javascript:");
if (hasJavaScript) {
errors.push("JavaScript is not supported in email and will be stripped by email clients");
}
const hasExternalStyles = html.includes("<link") && html.includes("stylesheet");
if (hasExternalStyles) {
errors.push("External stylesheets are not supported - use inline styles only");
}
if (html.includes("<form") || html.includes("<input") || html.includes("<button")) {
errors.push("Forms and form elements are not reliably supported in email");
}
const unsupportedTags = [
"video",
"audio",
"iframe",
"embed",
"object",
"canvas",
"svg"
];
for (const tag of unsupportedTags) {
if (html.includes(`<${tag}`)) {
errors.push(`<${tag}> tags are not supported in email`);
}
}
const imageCount = (html.match(/<img/g) || []).length;
const linkCount = (html.match(/<a/g) || []).length;
if (imageCount > 20) {
warnings.push(`High number of images (${imageCount}) may affect email performance`);
}
const imagesWithoutAlt = (html.match(/<img(?![^>]*\balt\s*=)[^>]*>/g) || []).length;
if (imagesWithoutAlt > 0) {
warnings.push(`${imagesWithoutAlt} image(s) missing alt text - important for accessibility`);
}
const linksWithoutTarget = (html.match(/<a(?![^>]*\btarget\s*=)[^>]*>/g) || []).length;
if (linksWithoutTarget > 0) {
warnings.push(`${linksWithoutTarget} link(s) missing target="_blank" attribute`);
}
if (html.includes("margin: auto") || html.includes("margin:auto")) {
warnings.push('margin: auto is not supported in Outlook - use align="center" or tables for centering');
}
if (html.includes("background-image")) {
warnings.push("Background images are not reliably supported - consider using <img> tags instead");
}
if (html.match(/\d+\s*(rem|em)/)) {
warnings.push("rem/em units may render inconsistently - use px for reliable sizing");
}
if (html.match(/margin[^:]*:\s*-\d+/)) {
errors.push("Negative margins are not supported in many email clients");
}
const personalizationTags = html.match(/\{\{([^}]+)\}\}/g) || [];
const validTags = ["subscriber.name", "subscriber.email", "subscriber.firstName", "subscriber.lastName"];
for (const tag of personalizationTags) {
const tagContent = tag.replace(/[{}]/g, "").trim();
if (!validTags.includes(tagContent)) {
warnings.push(`Unknown personalization tag: ${tag}`);
}
}
return {
valid: errors.length === 0,
warnings,
errors,
stats: {
sizeInBytes,
imageCount,
linkCount,
hasExternalStyles,
hasJavaScript
}
};
}
// src/utils/getBroadcastConfig.ts
async function getBroadcastConfig(req, pluginConfig) {
try {
const settings = await req.payload.findGlobal({
slug: pluginConfig.settingsSlug || "newsletter-settings",
req
});
if (settings?.provider === "broadcast" && settings?.broadcastSettings) {
return {
apiUrl: settings.broadcastSettings.apiUrl || pluginConfig.providers?.broadcast?.apiUrl || "",
token: settings.broadcastSettings.token || pluginConfig.providers?.broadcast?.token || "",
fromAddress: settings.fromAddress || pluginConfig.providers?.broadcast?.fromAddress || "",
fromName: settings.fromName || pluginConfig.providers?.broadcast?.fromName || "",
replyTo: settings.replyTo || pluginConfig.providers?.broadcast?.replyTo
};
}
return pluginConfig.providers?.broadcast || null;
} catch (error) {
req.payload.logger.error("Failed to get broadcast config from settings:", error);
return pluginConfig.providers?.broadcast || null;
}
}
// src/utils/getResendConfig.ts
async function getResendConfig(req, pluginConfig) {
try {
const settings = await req.payload.findGlobal({
slug: pluginConfig.settingsSlug || "newsletter-settings",
req
});
if (settings?.provider === "resend" && settings?.resendSettings) {
return {
apiKey: settings.resendSettings.apiKey || pluginConfig.providers?.resend?.apiKey || "",
fromAddress: settings.fromAddress || pluginConfig.providers?.resend?.fromAddress || "",
fromName: settings.fromName || pluginConfig.providers?.resend?.fromName || "",
audienceIds: settings.resendSettings.audienceIds || pluginConfig.providers?.resend?.audienceIds
};
}
return pluginConfig.providers?.resend || null;
} catch (error) {
req.payload.logger.error("Failed to get resend config from settings:", error);
return pluginConfig.providers?.resend || null;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
EMAIL_SAFE_CONFIG,
convertToEmailSafeHtml,
getBroadcastConfig,
getResendConfig,
validateEmailHtml
});
//# sourceMappingURL=utils.cjs.map
;