payload-plugin-newsletter 0.9.2 → 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,78 @@
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
+
33
+ ## [0.10.0] - 2025-07-20
34
+
35
+ ### Changed
36
+ - **BREAKING**: Simplified plugin architecture to single-channel design
37
+ - Removed Channels collection entirely
38
+ - Each Payload instance now connects to a single Broadcast channel
39
+ - Channel is determined by the API token (Broadcast tokens are channel-specific)
40
+ - Removed `channelId` field from Broadcast type
41
+ - Removed channel management methods from providers
42
+ - Updated newsletter management configuration
43
+ - Removed `collections.channels` from plugin config
44
+ - Broadcasts no longer have channel relationships
45
+ - All broadcasts use the global provider configuration
46
+ - Improved provider capabilities
47
+ - Set `supportsMultipleChannels` to `false` for all providers
48
+ - Removed channel-specific error codes (`CHANNEL_NOT_FOUND`, `INVALID_CHANNEL`)
49
+
50
+ ### Added
51
+ - Comprehensive documentation for single-channel architecture (`docs/guides/single-channel-broadcast.md`)
52
+ - Clear migration path from multi-channel to single-channel design
53
+
54
+ ### Removed
55
+ - Channels collection (`src/collections/Channels.ts`)
56
+ - Channel utility functions (`src/providers/utils/getChannelProvider.ts`)
57
+ - Channel type definitions (`src/types/channel.ts`)
58
+ - Channel imports and references throughout the codebase
59
+
60
+ ## [0.9.3] - 2025-07-20
61
+
62
+ ### Changed
63
+ - Simplified Broadcast provider configuration to use a single token instead of separate development/production tokens
64
+ - Changed `tokens: { production, development }` to `token` field
65
+ - Users should now manage different tokens via environment variables
66
+ - Improved settings configuration hierarchy
67
+ - Settings from Payload admin UI now properly override config defaults
68
+ - Added support for configuring `fromAddress`, `fromName`, and `replyTo` in admin UI
69
+ - Enhanced reply-to email handling
70
+ - Broadcast provider now supports fallback chain: request → settings → from address
71
+ - Added `replyTo` field support to match Broadcast API capabilities
72
+
73
+ ### Fixed
74
+ - Fixed Broadcast provider tests to match new single token configuration
75
+
1
76
  ## [0.9.2] - 2025-07-20
2
77
 
3
78
  ### Fixed
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
@@ -587,17 +592,18 @@ providers: {
587
592
  providers: {
588
593
  default: 'broadcast',
589
594
  broadcast: {
590
- apiUrl: 'https://broadcast.yoursite.com',
591
- tokens: {
592
- production: process.env.BROADCAST_TOKEN,
593
- development: process.env.BROADCAST_DEV_TOKEN,
594
- },
595
+ apiUrl: process.env.BROADCAST_API_URL,
596
+ token: process.env.BROADCAST_TOKEN,
597
+ // Optional: These can be set here as defaults or configured in the admin UI
595
598
  fromAddress: 'hello@yoursite.com',
596
599
  fromName: 'Your Newsletter',
600
+ replyTo: 'replies@yoursite.com',
597
601
  },
598
602
  }
599
603
  ```
600
604
 
605
+ **Note**: Settings configured in the Payload admin UI take precedence over these config values. The config values serve as defaults when settings haven't been configured yet.
606
+
601
607
  ## TypeScript
602
608
 
603
609
  The plugin is fully typed. Import types as needed:
@@ -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";
@@ -1210,7 +1288,6 @@ var EmailPreview = ({
1210
1288
  content,
1211
1289
  subject,
1212
1290
  preheader,
1213
- channel,
1214
1291
  mode = "desktop",
1215
1292
  onValidation
1216
1293
  }) => {
@@ -1233,7 +1310,7 @@ var EmailPreview = ({
1233
1310
  const personalizedHtml = replacePersonalizationTags(emailHtml, SAMPLE_DATA);
1234
1311
  const previewHtml = addEmailHeader(personalizedHtml, {
1235
1312
  subject,
1236
- from: channel ? `${channel.fromName} <${channel.fromEmail}>` : "Newsletter <noreply@example.com>",
1313
+ from: "Newsletter <noreply@example.com>",
1237
1314
  to: SAMPLE_DATA["subscriber.email"]
1238
1315
  });
1239
1316
  setHtml(previewHtml);
@@ -1248,7 +1325,7 @@ var EmailPreview = ({
1248
1325
  }
1249
1326
  };
1250
1327
  convertContent();
1251
- }, [content, subject, preheader, channel, onValidation]);
1328
+ }, [content, subject, preheader, onValidation]);
1252
1329
  (0, import_react5.useEffect)(() => {
1253
1330
  if (iframeRef.current && html) {
1254
1331
  const doc = iframeRef.current.contentDocument;
@@ -1485,7 +1562,6 @@ var EmailPreviewField = () => {
1485
1562
  content: fields.content?.value || null,
1486
1563
  subject: fields.subject?.value || "Email Subject",
1487
1564
  preheader: fields.preheader?.value,
1488
- channel: fields.channel?.value,
1489
1565
  mode: previewMode,
1490
1566
  onValidation: handleValidation
1491
1567
  }
@@ -1505,8 +1581,7 @@ var BroadcastEditor = (props) => {
1505
1581
  const [validationSummary, setValidationSummary] = (0, import_react7.useState)("");
1506
1582
  const fields = (0, import_ui2.useFormFields)(([fields2]) => ({
1507
1583
  subject: fields2.subject,
1508
- preheader: fields2.preheader,
1509
- channel: fields2.channel
1584
+ preheader: fields2.preheader
1510
1585
  }));
1511
1586
  const handleValidation = (0, import_react7.useCallback)((result) => {
1512
1587
  setIsValid(result.valid);
@@ -1644,7 +1719,6 @@ var BroadcastEditor = (props) => {
1644
1719
  content: value,
1645
1720
  subject: fields.subject?.value || "Email Subject",
1646
1721
  preheader: fields.preheader?.value,
1647
- channel: fields.channel?.value,
1648
1722
  mode: previewMode,
1649
1723
  onValidation: handleValidation
1650
1724
  }