payload-plugin-newsletter 0.16.10 → 0.17.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,31 @@
1
+ ## [0.17.0] - 2025-07-29
2
+
3
+ ### Added
4
+ - **Custom Block Email Converter Support** - Added support for custom block email conversion in broadcasts
5
+ - New `customBlockConverter` option in `BroadcastCustomizations` interface
6
+ - Allows users to provide their own email conversion logic for custom Lexical blocks
7
+ - Converter receives block node and media URL, returns email-safe HTML
8
+ - Supports async operations for fetching external data during conversion
9
+
10
+ - **Server-Side Email Preview Generation** - Implemented server-side email preview for accurate rendering
11
+ - New `/api/broadcasts/preview` endpoint for generating email previews
12
+ - Updated BroadcastInlinePreview component to use server-side preview
13
+ - Ensures preview exactly matches what will be sent via email
14
+ - Custom block converters work in both preview and sent emails
15
+
16
+ ### Changed
17
+ - **Email Conversion Functions Now Async** - All email conversion functions are now async to support custom converters
18
+ - `convertToEmailSafeHtml` and all internal converters are now async
19
+ - Maintains backward compatibility - existing code continues to work
20
+ - Enables custom converters to perform async operations like API calls
21
+
22
+ ### Technical
23
+ - Updated `convertNode`, `convertParagraph`, `convertHeading`, etc. to be async functions
24
+ - Added `customBlockConverter` parameter throughout the email conversion pipeline
25
+ - Custom converter is called first, falls back to default handling if it returns empty
26
+ - Error handling for custom converter failures with graceful fallback
27
+ - Preview endpoint uses same conversion logic as email sending for consistency
28
+
1
29
  ## [0.16.10] - 2025-01-29
2
30
 
3
31
  ### Fixed
@@ -963,62 +963,73 @@ async function convertToEmailSafeHtml(editorState, options) {
963
963
  if (!editorState) {
964
964
  return "";
965
965
  }
966
- const rawHtml = await lexicalToEmailHtml(editorState, options?.mediaUrl);
966
+ const rawHtml = await lexicalToEmailHtml(editorState, options?.mediaUrl, options?.customBlockConverter);
967
967
  const sanitizedHtml = import_isomorphic_dompurify.default.sanitize(rawHtml, EMAIL_SAFE_CONFIG);
968
968
  if (options?.wrapInTemplate) {
969
969
  return wrapInEmailTemplate(sanitizedHtml, options.preheader);
970
970
  }
971
971
  return sanitizedHtml;
972
972
  }
973
- async function lexicalToEmailHtml(editorState, mediaUrl) {
973
+ async function lexicalToEmailHtml(editorState, mediaUrl, customBlockConverter) {
974
974
  const { root } = editorState;
975
975
  if (!root || !root.children) {
976
976
  return "";
977
977
  }
978
- const html = root.children.map((node) => convertNode(node, mediaUrl)).join("");
979
- return html;
978
+ const htmlParts = await Promise.all(
979
+ root.children.map((node) => convertNode(node, mediaUrl, customBlockConverter))
980
+ );
981
+ return htmlParts.join("");
980
982
  }
981
- function convertNode(node, mediaUrl) {
983
+ async function convertNode(node, mediaUrl, customBlockConverter) {
982
984
  switch (node.type) {
983
985
  case "paragraph":
984
- return convertParagraph(node, mediaUrl);
986
+ return convertParagraph(node, mediaUrl, customBlockConverter);
985
987
  case "heading":
986
- return convertHeading(node, mediaUrl);
988
+ return convertHeading(node, mediaUrl, customBlockConverter);
987
989
  case "list":
988
- return convertList(node, mediaUrl);
990
+ return convertList(node, mediaUrl, customBlockConverter);
989
991
  case "listitem":
990
- return convertListItem(node, mediaUrl);
992
+ return convertListItem(node, mediaUrl, customBlockConverter);
991
993
  case "blockquote":
992
- return convertBlockquote(node, mediaUrl);
994
+ return convertBlockquote(node, mediaUrl, customBlockConverter);
993
995
  case "text":
994
996
  return convertText(node);
995
997
  case "link":
996
- return convertLink(node, mediaUrl);
998
+ return convertLink(node, mediaUrl, customBlockConverter);
997
999
  case "linebreak":
998
1000
  return "<br>";
999
1001
  case "upload":
1000
1002
  return convertUpload(node, mediaUrl);
1001
1003
  case "block":
1002
- return convertBlock(node, mediaUrl);
1004
+ return await convertBlock(node, mediaUrl, customBlockConverter);
1003
1005
  default:
1004
1006
  if (node.children) {
1005
- return node.children.map((child) => convertNode(child, mediaUrl)).join("");
1007
+ const childParts = await Promise.all(
1008
+ node.children.map((child) => convertNode(child, mediaUrl, customBlockConverter))
1009
+ );
1010
+ return childParts.join("");
1006
1011
  }
1007
1012
  return "";
1008
1013
  }
1009
1014
  }
1010
- function convertParagraph(node, mediaUrl) {
1015
+ async function convertParagraph(node, mediaUrl, customBlockConverter) {
1011
1016
  const align = getAlignment(node.format);
1012
- const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
1017
+ const childParts = await Promise.all(
1018
+ (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter))
1019
+ );
1020
+ const children = childParts.join("");
1013
1021
  if (!children.trim()) {
1014
1022
  return '<p style="margin: 0 0 16px 0; min-height: 1em;">&nbsp;</p>';
1015
1023
  }
1016
1024
  return `<p style="margin: 0 0 16px 0; text-align: ${align};">${children}</p>`;
1017
1025
  }
1018
- function convertHeading(node, mediaUrl) {
1026
+ async function convertHeading(node, mediaUrl, customBlockConverter) {
1019
1027
  const tag = node.tag || "h1";
1020
1028
  const align = getAlignment(node.format);
1021
- const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
1029
+ const childParts = await Promise.all(
1030
+ (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter))
1031
+ );
1032
+ const children = childParts.join("");
1022
1033
  const styles2 = {
1023
1034
  h1: "font-size: 32px; font-weight: 700; margin: 0 0 24px 0; line-height: 1.2;",
1024
1035
  h2: "font-size: 24px; font-weight: 600; margin: 0 0 16px 0; line-height: 1.3;",
@@ -1027,18 +1038,27 @@ function convertHeading(node, mediaUrl) {
1027
1038
  const style = `${styles2[tag] || styles2.h3} text-align: ${align};`;
1028
1039
  return `<${tag} style="${style}">${children}</${tag}>`;
1029
1040
  }
1030
- function convertList(node, mediaUrl) {
1041
+ async function convertList(node, mediaUrl, customBlockConverter) {
1031
1042
  const tag = node.listType === "number" ? "ol" : "ul";
1032
- const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
1043
+ const childParts = await Promise.all(
1044
+ (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter))
1045
+ );
1046
+ const children = childParts.join("");
1033
1047
  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;";
1034
1048
  return `<${tag} style="${style}">${children}</${tag}>`;
1035
1049
  }
1036
- function convertListItem(node, mediaUrl) {
1037
- const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
1050
+ async function convertListItem(node, mediaUrl, customBlockConverter) {
1051
+ const childParts = await Promise.all(
1052
+ (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter))
1053
+ );
1054
+ const children = childParts.join("");
1038
1055
  return `<li style="margin: 0 0 8px 0;">${children}</li>`;
1039
1056
  }
1040
- function convertBlockquote(node, mediaUrl) {
1041
- const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
1057
+ async function convertBlockquote(node, mediaUrl, customBlockConverter) {
1058
+ const childParts = await Promise.all(
1059
+ (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter))
1060
+ );
1061
+ const children = childParts.join("");
1042
1062
  const style = "margin: 0 0 16px 0; padding-left: 16px; border-left: 4px solid #e5e7eb; color: #6b7280;";
1043
1063
  return `<blockquote style="${style}">${children}</blockquote>`;
1044
1064
  }
@@ -1058,8 +1078,11 @@ function convertText(node) {
1058
1078
  }
1059
1079
  return text;
1060
1080
  }
1061
- function convertLink(node, mediaUrl) {
1062
- const children = node.children?.map((child) => convertNode(child, mediaUrl)).join("") || "";
1081
+ async function convertLink(node, mediaUrl, customBlockConverter) {
1082
+ const childParts = await Promise.all(
1083
+ (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter))
1084
+ );
1085
+ const children = childParts.join("");
1063
1086
  const url = node.fields?.url || "#";
1064
1087
  const newTab = node.fields?.newTab ?? false;
1065
1088
  const targetAttr = newTab ? ' target="_blank"' : "";
@@ -1090,8 +1113,18 @@ function convertUpload(node, mediaUrl) {
1090
1113
  }
1091
1114
  return `<div style="margin: 0 0 16px 0; text-align: center;">${imgHtml}</div>`;
1092
1115
  }
1093
- function convertBlock(node, mediaUrl) {
1094
- const blockType = node.fields?.blockName;
1116
+ async function convertBlock(node, mediaUrl, customBlockConverter) {
1117
+ const blockType = node.fields?.blockName || node.blockName;
1118
+ if (customBlockConverter) {
1119
+ try {
1120
+ const customHtml = await customBlockConverter(node, mediaUrl);
1121
+ if (customHtml) {
1122
+ return customHtml;
1123
+ }
1124
+ } catch (error) {
1125
+ console.error(`Custom block converter error for ${blockType}:`, error);
1126
+ }
1127
+ }
1095
1128
  switch (blockType) {
1096
1129
  case "button":
1097
1130
  return convertButtonBlock(node.fields);
@@ -1099,7 +1132,10 @@ function convertBlock(node, mediaUrl) {
1099
1132
  return convertDividerBlock(node.fields);
1100
1133
  default:
1101
1134
  if (node.children) {
1102
- return node.children.map((child) => convertNode(child, mediaUrl)).join("");
1135
+ const childParts = await Promise.all(
1136
+ node.children.map((child) => convertNode(child, mediaUrl, customBlockConverter))
1137
+ );
1138
+ return childParts.join("");
1103
1139
  }
1104
1140
  return "";
1105
1141
  }
@@ -1473,7 +1509,9 @@ var createBroadcastsCollection = (pluginConfig) => {
1473
1509
  const { BroadcastApiProvider: BroadcastApiProvider2 } = await Promise.resolve().then(() => (init_broadcast2(), broadcast_exports));
1474
1510
  const provider = new BroadcastApiProvider2(providerConfig);
1475
1511
  req.payload.logger.info("Converting content to HTML...");
1476
- const htmlContent = await convertToEmailSafeHtml(doc.contentSection?.content);
1512
+ const htmlContent = await convertToEmailSafeHtml(doc.contentSection?.content, {
1513
+ customBlockConverter: pluginConfig.customizations?.broadcasts?.customBlockConverter
1514
+ });
1477
1515
  if (!htmlContent || htmlContent.trim() === "") {
1478
1516
  req.payload.logger.info("Skipping provider sync - content is empty after conversion");
1479
1517
  return doc;
@@ -1570,7 +1608,9 @@ var createBroadcastsCollection = (pluginConfig) => {
1570
1608
  return doc;
1571
1609
  }
1572
1610
  req.payload.logger.info("Creating broadcast in provider (deferred from initial create)...");
1573
- const htmlContent = await convertToEmailSafeHtml(doc.contentSection?.content);
1611
+ const htmlContent = await convertToEmailSafeHtml(doc.contentSection?.content, {
1612
+ customBlockConverter: pluginConfig.customizations?.broadcasts?.customBlockConverter
1613
+ });
1574
1614
  if (!htmlContent || htmlContent.trim() === "") {
1575
1615
  req.payload.logger.info("Skipping provider sync - content is empty after conversion");
1576
1616
  return doc;
@@ -1629,7 +1669,9 @@ var createBroadcastsCollection = (pluginConfig) => {
1629
1669
  updates.preheader = doc.contentSection?.preheader;
1630
1670
  }
1631
1671
  if (JSON.stringify(doc.contentSection?.content) !== JSON.stringify(previousDoc?.contentSection?.content)) {
1632
- updates.content = await convertToEmailSafeHtml(doc.contentSection?.content);
1672
+ updates.content = await convertToEmailSafeHtml(doc.contentSection?.content, {
1673
+ customBlockConverter: pluginConfig.customizations?.broadcasts?.customBlockConverter
1674
+ });
1633
1675
  }
1634
1676
  if (doc.settings?.trackOpens !== previousDoc?.settings?.trackOpens) {
1635
1677
  updates.trackOpens = doc.settings.trackOpens;