payload-plugin-newsletter 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## [0.11.0] - 2025-07-20
2
+
3
+ ### Added
4
+ - Enhanced rich text editor for broadcast emails with:
5
+ - Fixed toolbar showing available formatting options
6
+ - Inline toolbar for text selection
7
+ - Image upload support with Media collection integration
8
+ - Custom email blocks (Button and Divider)
9
+ - Enhanced link feature with "open in new tab" option
10
+ - Comprehensive image handling in email HTML conversion:
11
+ - Responsive images with proper email-safe HTML
12
+ - Support for captions and alt text
13
+ - Automatic media URL handling for different storage backends
14
+ - Media collection setup documentation (`docs/guides/media-collection-setup.md`)
15
+ - Prerequisites section in README mentioning Media collection requirement
16
+
17
+ ### Changed
18
+ - Removed 'name' field from Broadcasts collection (now uses 'subject' as title)
19
+ - Updated Broadcast collection admin UI:
20
+ - Uses subject as the display title
21
+ - Shows recipientCount in default columns instead of name
22
+ - Enhanced `convertToEmailSafeHtml` utility to handle:
23
+ - Upload nodes for images
24
+ - Custom block nodes (button and divider)
25
+ - Media URL configuration
26
+ - Improved link handling with target attribute support
27
+ - Broadcast API sync now uses subject as the name field
28
+
29
+ ### Fixed
30
+ - Broadcast hooks now properly handle the absence of name field
31
+ - Email preview components updated to work without channel references
32
+
1
33
  ## [0.10.0] - 2025-07-20
2
34
 
3
35
  ### Changed
package/README.md CHANGED
@@ -22,6 +22,11 @@ A complete newsletter management plugin for [Payload CMS](https://github.com/pay
22
22
  - ✅ **Email Validation** - Built-in validation for email client compatibility (v0.9.0+)
23
23
  - 📝 **Email-Safe Editor** - Rich text editor limited to email-compatible features (v0.9.0+)
24
24
 
25
+ ## Prerequisites
26
+
27
+ - Payload CMS v3.0.0 or higher
28
+ - A Media collection configured in your Payload project (required for image support in broadcasts)
29
+
25
30
  ## Quick Start
26
31
 
27
32
  ### 1. Install the plugin
@@ -917,9 +917,17 @@ var EMAIL_SAFE_CONFIG = {
917
917
  "ol",
918
918
  "li",
919
919
  "blockquote",
920
- "hr"
920
+ "hr",
921
+ "img",
922
+ "div",
923
+ "table",
924
+ "tr",
925
+ "td",
926
+ "th",
927
+ "tbody",
928
+ "thead"
921
929
  ],
922
- ALLOWED_ATTR: ["href", "style", "target", "rel", "align"],
930
+ ALLOWED_ATTR: ["href", "style", "target", "rel", "align", "src", "alt", "width", "height", "border", "cellpadding", "cellspacing"],
923
931
  ALLOWED_STYLES: {
924
932
  "*": [
925
933
  "color",
@@ -950,58 +958,62 @@ var EMAIL_SAFE_CONFIG = {
950
958
  FORBID_ATTR: ["class", "id", "onclick", "onload", "onerror"]
951
959
  };
952
960
  async function convertToEmailSafeHtml(editorState, options) {
953
- const rawHtml = await lexicalToEmailHtml(editorState);
961
+ const rawHtml = await lexicalToEmailHtml(editorState, options?.mediaUrl);
954
962
  const sanitizedHtml = import_isomorphic_dompurify.default.sanitize(rawHtml, EMAIL_SAFE_CONFIG);
955
963
  if (options?.wrapInTemplate) {
956
964
  return wrapInEmailTemplate(sanitizedHtml, options.preheader);
957
965
  }
958
966
  return sanitizedHtml;
959
967
  }
960
- async function lexicalToEmailHtml(editorState) {
968
+ async function lexicalToEmailHtml(editorState, mediaUrl) {
961
969
  const { root } = editorState;
962
970
  if (!root || !root.children) {
963
971
  return "";
964
972
  }
965
- const html = root.children.map((node) => convertNode(node)).join("");
973
+ const html = root.children.map((node) => convertNode(node, mediaUrl)).join("");
966
974
  return html;
967
975
  }
968
- function convertNode(node) {
976
+ function convertNode(node, mediaUrl) {
969
977
  switch (node.type) {
970
978
  case "paragraph":
971
- return convertParagraph(node);
979
+ return convertParagraph(node, mediaUrl);
972
980
  case "heading":
973
- return convertHeading(node);
981
+ return convertHeading(node, mediaUrl);
974
982
  case "list":
975
- return convertList(node);
983
+ return convertList(node, mediaUrl);
976
984
  case "listitem":
977
- return convertListItem(node);
985
+ return convertListItem(node, mediaUrl);
978
986
  case "blockquote":
979
- return convertBlockquote(node);
987
+ return convertBlockquote(node, mediaUrl);
980
988
  case "text":
981
989
  return convertText(node);
982
990
  case "link":
983
- return convertLink(node);
991
+ return convertLink(node, mediaUrl);
984
992
  case "linebreak":
985
993
  return "<br>";
994
+ case "upload":
995
+ return convertUpload(node, mediaUrl);
996
+ case "block":
997
+ return convertBlock(node, mediaUrl);
986
998
  default:
987
999
  if (node.children) {
988
- return node.children.map(convertNode).join("");
1000
+ return node.children.map((child) => convertNode(child, mediaUrl)).join("");
989
1001
  }
990
1002
  return "";
991
1003
  }
992
1004
  }
993
- function convertParagraph(node) {
1005
+ function convertParagraph(node, mediaUrl) {
994
1006
  const align = getAlignment(node.format);
995
- const children = node.children?.map(convertNode).join("") || "";
1007
+ const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
996
1008
  if (!children.trim()) {
997
1009
  return '<p style="margin: 0 0 16px 0; min-height: 1em;">&nbsp;</p>';
998
1010
  }
999
1011
  return `<p style="margin: 0 0 16px 0; text-align: ${align};">${children}</p>`;
1000
1012
  }
1001
- function convertHeading(node) {
1013
+ function convertHeading(node, mediaUrl) {
1002
1014
  const tag = node.tag || "h1";
1003
1015
  const align = getAlignment(node.format);
1004
- const children = node.children?.map(convertNode).join("") || "";
1016
+ const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
1005
1017
  const styles = {
1006
1018
  h1: "font-size: 32px; font-weight: 700; margin: 0 0 24px 0; line-height: 1.2;",
1007
1019
  h2: "font-size: 24px; font-weight: 600; margin: 0 0 16px 0; line-height: 1.3;",
@@ -1010,18 +1022,18 @@ function convertHeading(node) {
1010
1022
  const style = `${styles[tag] || styles.h3} text-align: ${align};`;
1011
1023
  return `<${tag} style="${style}">${children}</${tag}>`;
1012
1024
  }
1013
- function convertList(node) {
1025
+ function convertList(node, mediaUrl) {
1014
1026
  const tag = node.listType === "number" ? "ol" : "ul";
1015
- const children = node.children?.map(convertNode).join("") || "";
1027
+ const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
1016
1028
  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;";
1017
1029
  return `<${tag} style="${style}">${children}</${tag}>`;
1018
1030
  }
1019
- function convertListItem(node) {
1020
- const children = node.children?.map(convertNode).join("") || "";
1031
+ function convertListItem(node, mediaUrl) {
1032
+ const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
1021
1033
  return `<li style="margin: 0 0 8px 0;">${children}</li>`;
1022
1034
  }
1023
- function convertBlockquote(node) {
1024
- const children = node.children?.map(convertNode).join("") || "";
1035
+ function convertBlockquote(node, mediaUrl) {
1036
+ const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
1025
1037
  const style = "margin: 0 0 16px 0; padding-left: 16px; border-left: 4px solid #e5e7eb; color: #6b7280;";
1026
1038
  return `<blockquote style="${style}">${children}</blockquote>`;
1027
1039
  }
@@ -1041,10 +1053,76 @@ function convertText(node) {
1041
1053
  }
1042
1054
  return text;
1043
1055
  }
1044
- function convertLink(node) {
1045
- const children = node.children?.map(convertNode).join("") || "";
1056
+ function convertLink(node, mediaUrl) {
1057
+ const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
1046
1058
  const url = node.fields?.url || "#";
1047
- return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="color: #2563eb; text-decoration: underline;">${children}</a>`;
1059
+ const newTab = node.fields?.newTab ?? false;
1060
+ const targetAttr = newTab ? ' target="_blank"' : "";
1061
+ const relAttr = newTab ? ' rel="noopener noreferrer"' : "";
1062
+ return `<a href="${escapeHtml(url)}"${targetAttr}${relAttr} style="color: #2563eb; text-decoration: underline;">${children}</a>`;
1063
+ }
1064
+ function convertUpload(node, mediaUrl) {
1065
+ const upload = node.value;
1066
+ if (!upload) return "";
1067
+ let src = "";
1068
+ if (typeof upload === "string") {
1069
+ src = upload;
1070
+ } else if (upload.url) {
1071
+ src = upload.url;
1072
+ } else if (upload.filename && mediaUrl) {
1073
+ src = `${mediaUrl}/${upload.filename}`;
1074
+ }
1075
+ const alt = node.fields?.altText || upload.alt || "";
1076
+ const caption = node.fields?.caption || "";
1077
+ const imgHtml = `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" />`;
1078
+ if (caption) {
1079
+ return `
1080
+ <div style="margin: 0 0 16px 0; text-align: center;">
1081
+ ${imgHtml}
1082
+ <p style="margin: 8px 0 0 0; font-size: 14px; color: #6b7280; font-style: italic;">${escapeHtml(caption)}</p>
1083
+ </div>
1084
+ `;
1085
+ }
1086
+ return `<div style="margin: 0 0 16px 0; text-align: center;">${imgHtml}</div>`;
1087
+ }
1088
+ function convertBlock(node, mediaUrl) {
1089
+ const blockType = node.fields?.blockName;
1090
+ switch (blockType) {
1091
+ case "button":
1092
+ return convertButtonBlock(node.fields);
1093
+ case "divider":
1094
+ return convertDividerBlock(node.fields);
1095
+ default:
1096
+ if (node.children) {
1097
+ return node.children.map((child) => convertNode(child, mediaUrl)).join("");
1098
+ }
1099
+ return "";
1100
+ }
1101
+ }
1102
+ function convertButtonBlock(fields) {
1103
+ const text = fields?.text || "Click here";
1104
+ const url = fields?.url || "#";
1105
+ const style = fields?.style || "primary";
1106
+ const styles = {
1107
+ primary: "background-color: #2563eb; color: #ffffff; border: 2px solid #2563eb;",
1108
+ secondary: "background-color: #6b7280; color: #ffffff; border: 2px solid #6b7280;",
1109
+ outline: "background-color: transparent; color: #2563eb; border: 2px solid #2563eb;"
1110
+ };
1111
+ 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;`;
1112
+ return `
1113
+ <div style="margin: 0 0 16px 0; text-align: center;">
1114
+ <a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="${buttonStyle}">${escapeHtml(text)}</a>
1115
+ </div>
1116
+ `;
1117
+ }
1118
+ function convertDividerBlock(fields) {
1119
+ const style = fields?.style || "solid";
1120
+ const styles = {
1121
+ solid: "border-top: 1px solid #e5e7eb;",
1122
+ dashed: "border-top: 1px dashed #e5e7eb;",
1123
+ dotted: "border-top: 1px dotted #e5e7eb;"
1124
+ };
1125
+ return `<hr style="${styles[style] || styles.solid} margin: 24px 0; border-bottom: none; border-left: none; border-right: none;" />`;
1048
1126
  }
1049
1127
  function getAlignment(format) {
1050
1128
  if (!format) return "left";