UNPKG

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
"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 __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;">&nbsp;</p>'; } return `<p class="mobile-margin-bottom-16" style="margin: 0 0 16px 0; text-align: ${align}; font-size: 16px; line-height: 1.5;">${children}</p>`; } async function convertHeading(node, mediaUrl, customBlockConverter) { const tag = node.tag || "h1"; const align = getAlignment(node.format); const childParts = await Promise.all( (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter)) ); const children = childParts.join(""); const 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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }; return text.replace(/[&<>"']/g, (m) => map[m]); } function wrapInEmailTemplate(content, preheader) { return `<!DOCTYPE html> <html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="x-apple-disable-message-reformatting"> <title>Newsletter</title> <!--[if mso]> <noscript> <xml> <o:OfficeDocumentSettings> <o:PixelsPerInch>96</o:PixelsPerInch> </o:OfficeDocumentSettings> </xml> </noscript> <![endif]--> <style> /* Reset and base styles */ * { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } body { margin: 0 !important; padding: 0 !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #1A1A1A; background-color: #f8f9fa; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } table { border-spacing: 0 !important; border-collapse: collapse !important; table-layout: fixed !important; margin: 0 auto !important; } table table table { table-layout: auto; } img { -ms-interpolation-mode: bicubic; max-width: 100%; height: auto; border: 0; outline: none; text-decoration: none; } /* Responsive styles */ @media only screen and (max-width: 640px) { .mobile-hide { display: none !important; } .mobile-center { text-align: center !important; } .mobile-width-100 { width: 100% !important; max-width: 100% !important; } .mobile-padding { padding: 20px !important; } .mobile-padding-sm { padding: 16px !important; } .mobile-font-size-14 { font-size: 14px !important; } .mobile-font-size-16 { font-size: 16px !important; } .mobile-font-size-20 { font-size: 20px !important; line-height: 1.3 !important; } .mobile-font-size-24 { font-size: 24px !important; line-height: 1.2 !important; } /* Stack sections on mobile */ .mobile-stack { display: block !important; width: 100% !important; } /* Mobile-specific spacing */ .mobile-margin-bottom-16 { margin-bottom: 16px !important; } .mobile-margin-bottom-20 { margin-bottom: 20px !important; } } /* Dark mode support */ @media (prefers-color-scheme: dark) { .dark-mode-bg { background-color: #1a1a1a !important; } .dark-mode-text { color: #ffffff !important; } .dark-mode-border { border-color: #333333 !important; } } /* Outlook-specific fixes */ <!--[if mso]> <style> table { border-collapse: collapse; border-spacing: 0; border: none; margin: 0; } div, p { margin: 0; } </style> <![endif]--> </style> </head> <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #1A1A1A; background-color: #f8f9fa;"> ${preheader ? ` <!-- Preheader text --> <div style="display: none; max-height: 0; overflow: hidden; font-size: 1px; line-height: 1px; color: transparent;"> ${escapeHtml(preheader)} </div> ` : ""} <!-- Main container --> <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin: 0; padding: 0; background-color: #f8f9fa;"> <tr> <td align="center" style="padding: 20px 10px;"> <!-- Email wrapper --> <table role="presentation" cellpadding="0" cellspacing="0" width="600" class="mobile-width-100" style="margin: 0 auto; max-width: 600px;"> <tr> <td class="mobile-padding" style="padding: 0;"> <!-- Content area with light background --> <div style="background-color: #ffffff; padding: 40px 30px; border-radius: 8px;" class="mobile-padding"> ${content} </div> </td> </tr> </table> </td> </tr> </table> </body> </html>`; } // src/utils/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