UNPKG

payload-plugin-newsletter

Version:

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

298 lines (297 loc) 9.82 kB
// src/utils/emailSafeHtml.ts import DOMPurify from "isomorphic-dompurify"; var EMAIL_SAFE_CONFIG = { ALLOWED_TAGS: [ "p", "br", "strong", "b", "em", "i", "u", "strike", "s", "span", "a", "h1", "h2", "h3", "ul", "ol", "li", "blockquote", "hr" ], ALLOWED_ATTR: ["href", "style", "target", "rel", "align"], 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) { const rawHtml = await lexicalToEmailHtml(editorState); const sanitizedHtml = DOMPurify.sanitize(rawHtml, EMAIL_SAFE_CONFIG); if (options?.wrapInTemplate) { return wrapInEmailTemplate(sanitizedHtml, options.preheader); } return sanitizedHtml; } async function lexicalToEmailHtml(editorState) { const { root } = editorState; if (!root || !root.children) { return ""; } const html = root.children.map((node) => convertNode(node)).join(""); return html; } function convertNode(node) { switch (node.type) { case "paragraph": return convertParagraph(node); case "heading": return convertHeading(node); case "list": return convertList(node); case "listitem": return convertListItem(node); case "blockquote": return convertBlockquote(node); case "text": return convertText(node); case "link": return convertLink(node); case "linebreak": return "<br>"; default: if (node.children) { return node.children.map(convertNode).join(""); } return ""; } } function convertParagraph(node) { const align = getAlignment(node.format); const children = node.children?.map(convertNode).join("") || ""; if (!children.trim()) { return '<p style="margin: 0 0 16px 0; min-height: 1em;">&nbsp;</p>'; } return `<p style="margin: 0 0 16px 0; text-align: ${align};">${children}</p>`; } function convertHeading(node) { const tag = node.tag || "h1"; const align = getAlignment(node.format); const children = node.children?.map(convertNode).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 style = `${styles[tag] || styles.h3} text-align: ${align};`; return `<${tag} style="${style}">${children}</${tag}>`; } function convertList(node) { const tag = node.listType === "number" ? "ol" : "ul"; const children = node.children?.map(convertNode).join("") || ""; const style = tag === "ul" ? "margin: 0 0 16px 0; padding-left: 24px; list-style-type: disc;" : "margin: 0 0 16px 0; padding-left: 24px; list-style-type: decimal;"; return `<${tag} style="${style}">${children}</${tag}>`; } function convertListItem(node) { const children = node.children?.map(convertNode).join("") || ""; return `<li style="margin: 0 0 8px 0;">${children}</li>`; } function convertBlockquote(node) { const children = node.children?.map(convertNode).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; } function convertLink(node) { const children = node.children?.map(convertNode).join("") || ""; const url = node.fields?.url || "#"; return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="color: #2563eb; text-decoration: underline;">${children}</a>`; } 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"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Email</title> <!--[if mso]> <noscript> <xml> <o:OfficeDocumentSettings> <o:PixelsPerInch>96</o:PixelsPerInch> </o:OfficeDocumentSettings> </xml> </noscript> <![endif]--> </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: #333333; background-color: #f3f4f6;"> ${preheader ? `<div style="display: none; max-height: 0; overflow: hidden;">${escapeHtml(preheader)}</div>` : ""} <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin: 0; padding: 0;"> <tr> <td align="center" style="padding: 20px 0;"> <table role="presentation" cellpadding="0" cellspacing="0" width="600" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden;"> <tr> <td style="padding: 40px 30px;"> ${content} </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 } }; } export { EMAIL_SAFE_CONFIG, convertToEmailSafeHtml, validateEmailHtml }; //# sourceMappingURL=utils.js.map