payload-plugin-newsletter 0.15.0 → 0.16.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,75 @@
1
+ ## [0.16.0] - 2025-07-27
2
+
3
+ ### Changed
4
+ - **Send = Publish Workflow** - Simplified broadcast sending to use Payload's native draft/publish system
5
+ - Publishing a broadcast now automatically sends it via the configured email provider
6
+ - Removed custom Send/Schedule modal in favor of Payload's built-in UI
7
+ - Scheduled publishing supported via Payload's Jobs Queue system
8
+ - Breaking: Removed `SendBroadcastModal` and `ActionsCell` components
9
+ - **Streamlined UI** - Removed custom action buttons from broadcasts list view
10
+ - Users now use standard Payload publish/schedule functionality
11
+ - Cleaner interface that follows Payload's patterns
12
+ - Less code to maintain while providing better integration
13
+
14
+ ### Added
15
+ - **Automatic Send on Publish** - New `afterChange` hook that sends broadcasts when published
16
+ - Checks if broadcast is transitioning to published status
17
+ - Automatically calls provider's send method
18
+ - Updates broadcast status to "sending" after successful send
19
+ - Handles failures gracefully with status update to "failed"
20
+ - **Jobs Queue Documentation** - Added comprehensive setup instructions for scheduled publishing
21
+ - Vercel Cron configuration example
22
+ - Security setup with CRON_SECRET
23
+ - Step-by-step guide for enabling scheduled broadcasts
24
+
25
+ ### Removed
26
+ - **Custom UI Components** (Breaking Change)
27
+ - `SendBroadcastModal` - Custom send/schedule modal
28
+ - `ActionsCell` - Custom action buttons in list view
29
+ - `actions` field from Broadcasts collection
30
+ - These are replaced by Payload's native publish/schedule functionality
31
+
32
+ ### Technical
33
+ - Enabled `versions` configuration on Broadcasts collection with drafts and scheduled publishing
34
+ - Updated default columns to show both `_status` (Draft/Published) and `status` (send status)
35
+ - Improved TypeScript exports by removing deleted component references
36
+ - All tests passing with minor version upgrade
37
+
38
+ ### Migration Guide
39
+ If you were using the custom Send/Schedule modal:
40
+ 1. The functionality is now built into Payload's publish system
41
+ 2. To send immediately: Click "Publish"
42
+ 3. To schedule: Click "Schedule" (requires Jobs Queue setup)
43
+ 4. Remove any imports of `SendBroadcastModal` or `ActionsCell` from your code
44
+
45
+ ## [0.15.1] - 2025-07-27
46
+
47
+ ### Fixed
48
+ - **Email-Compatible Block Editor** - Resolved Next.js serialization errors with custom blocks
49
+ - Custom blocks are now processed server-side using Lexical's proven BlocksFeature pattern
50
+ - Prevents "Functions cannot be passed directly to Client Components" errors
51
+ - Maintains full email compatibility while enabling custom block functionality
52
+ - **Block Validation System** - Added validation utilities for email compatibility
53
+ - `validateEmailBlocks()` warns about potentially incompatible block types
54
+ - `createEmailSafeBlocks()` processes blocks for email-safe configurations
55
+ - Automatic detection of complex field types that may not render in email clients
56
+
57
+ ### Improved
58
+ - **Server-Side Block Processing** - Enhanced `createEmailLexicalEditor()` function
59
+ - Processes custom blocks into Lexical editor configuration before client serialization
60
+ - Clean separation between email-compatible and web-only content blocks
61
+ - Better performance through pre-configured editor instances
62
+ - **Enhanced Documentation** - Updated extension points guide with new approach
63
+ - Examples showing both legacy and new server-side processing methods
64
+ - Block validation utilities documentation
65
+ - Email compatibility best practices
66
+
67
+ ### Technical
68
+ - Added `createEmailLexicalEditor()` for server-side editor configuration
69
+ - Enhanced `createEmailContentField()` to accept pre-configured editors
70
+ - New utility exports: `validateEmailBlocks`, `createEmailSafeBlocks`
71
+ - Improved TypeScript support for custom block configurations
72
+
1
73
  ## [0.15.0] - 2025-07-27
2
74
 
3
75
  ### Added
package/README.md CHANGED
@@ -241,6 +241,50 @@ This adds a `broadcasts` collection with:
241
241
  - Custom email blocks (buttons, dividers)
242
242
  - Inline email preview with React Email
243
243
  - Automatic sync with your email provider
244
+ - Draft/publish system with scheduled publishing support
245
+
246
+ ### Send = Publish Workflow
247
+
248
+ The plugin integrates seamlessly with Payload's draft/publish system:
249
+
250
+ - **Draft**: Create and edit broadcasts without sending
251
+ - **Publish**: Publishing a broadcast automatically sends it via your configured email provider
252
+ - **Schedule**: Use Payload's scheduled publishing to send broadcasts at a future time
253
+
254
+ **How it works:**
255
+ 1. Create a broadcast and save as draft
256
+ 2. When ready, click "Publish" to send immediately
257
+ 3. Or use "Schedule" to publish (and send) at a specific date/time
258
+
259
+ **Important**: Scheduled publishing requires configuring Payload's Jobs Queue. For Vercel deployments, add this to your `vercel.json`:
260
+
261
+ ```json
262
+ {
263
+ "crons": [
264
+ {
265
+ "path": "/api/payload-jobs/run",
266
+ "schedule": "*/5 * * * *"
267
+ }
268
+ ]
269
+ }
270
+ ```
271
+
272
+ And secure the endpoint in your `payload.config.ts`:
273
+
274
+ ```typescript
275
+ export default buildConfig({
276
+ // ... other config
277
+ jobs: {
278
+ access: {
279
+ run: ({ req }) => {
280
+ if (req.user) return true
281
+ const authHeader = req.headers.get('authorization')
282
+ return authHeader === `Bearer ${process.env.CRON_SECRET}`
283
+ },
284
+ },
285
+ },
286
+ })
287
+ ```
244
288
 
245
289
  ### Custom Email Templates (v0.12.0+)
246
290
 
@@ -727,7 +771,7 @@ newsletterPlugin({
727
771
 
728
772
  ### Extending the Broadcasts Collection (v0.15.0+)
729
773
 
730
- You can extend the Broadcasts collection with additional fields and custom blocks:
774
+ You can extend the Broadcasts collection with additional fields and custom email-compatible blocks:
731
775
 
732
776
  ```typescript
733
777
  import type { Block } from 'payload'
@@ -753,7 +797,7 @@ newsletterPlugin({
753
797
  admin: { position: 'sidebar' }
754
798
  }
755
799
  ],
756
- customBlocks: [customBlock],
800
+ customBlocks: [customBlock], // Processed server-side for email compatibility
757
801
  fieldOverrides: {
758
802
  content: (defaultField) => ({
759
803
  ...defaultField,
@@ -768,6 +812,8 @@ newsletterPlugin({
768
812
  })
769
813
  ```
770
814
 
815
+ **Note**: Custom blocks are processed server-side to ensure email compatibility and prevent Next.js serialization errors.
816
+
771
817
  For complete extensibility documentation, see the [Extension Points Guide](./docs/architecture/extension-points.md).
772
818
 
773
819
  ## Troubleshooting
@@ -542,6 +542,103 @@ init_types();
542
542
 
543
543
  // src/fields/emailContent.ts
544
544
  var import_richtext_lexical = require("@payloadcms/richtext-lexical");
545
+
546
+ // src/utils/blockValidation.ts
547
+ var EMAIL_INCOMPATIBLE_TYPES = [
548
+ "chart",
549
+ "dataTable",
550
+ "interactive",
551
+ "streamable",
552
+ "video",
553
+ "iframe",
554
+ "form",
555
+ "carousel",
556
+ "tabs",
557
+ "accordion",
558
+ "map"
559
+ ];
560
+ var validateEmailBlocks = (blocks) => {
561
+ blocks.forEach((block) => {
562
+ if (EMAIL_INCOMPATIBLE_TYPES.includes(block.slug)) {
563
+ console.warn(`\u26A0\uFE0F Block "${block.slug}" may not be email-compatible. Consider creating an email-specific version.`);
564
+ }
565
+ const hasComplexFields = block.fields?.some((field) => {
566
+ const complexTypes = ["code", "json", "richText", "blocks", "array"];
567
+ return complexTypes.includes(field.type);
568
+ });
569
+ if (hasComplexFields) {
570
+ console.warn(`\u26A0\uFE0F Block "${block.slug}" contains complex field types that may not render consistently in email clients.`);
571
+ }
572
+ });
573
+ };
574
+ var createEmailSafeBlocks = (customBlocks = []) => {
575
+ validateEmailBlocks(customBlocks);
576
+ const baseBlocks = [
577
+ {
578
+ slug: "button",
579
+ fields: [
580
+ {
581
+ name: "text",
582
+ type: "text",
583
+ label: "Button Text",
584
+ required: true
585
+ },
586
+ {
587
+ name: "url",
588
+ type: "text",
589
+ label: "Button URL",
590
+ required: true,
591
+ admin: {
592
+ description: "Enter the full URL (including https://)"
593
+ }
594
+ },
595
+ {
596
+ name: "style",
597
+ type: "select",
598
+ label: "Button Style",
599
+ defaultValue: "primary",
600
+ options: [
601
+ { label: "Primary", value: "primary" },
602
+ { label: "Secondary", value: "secondary" },
603
+ { label: "Outline", value: "outline" }
604
+ ]
605
+ }
606
+ ],
607
+ interfaceName: "EmailButton",
608
+ labels: {
609
+ singular: "Button",
610
+ plural: "Buttons"
611
+ }
612
+ },
613
+ {
614
+ slug: "divider",
615
+ fields: [
616
+ {
617
+ name: "style",
618
+ type: "select",
619
+ label: "Divider Style",
620
+ defaultValue: "solid",
621
+ options: [
622
+ { label: "Solid", value: "solid" },
623
+ { label: "Dashed", value: "dashed" },
624
+ { label: "Dotted", value: "dotted" }
625
+ ]
626
+ }
627
+ ],
628
+ interfaceName: "EmailDivider",
629
+ labels: {
630
+ singular: "Divider",
631
+ plural: "Dividers"
632
+ }
633
+ }
634
+ ];
635
+ return [
636
+ ...baseBlocks,
637
+ ...customBlocks
638
+ ];
639
+ };
640
+
641
+ // src/fields/emailContent.ts
545
642
  var createEmailSafeFeatures = (additionalBlocks) => {
546
643
  const baseBlocks = [
547
644
  {
@@ -679,16 +776,89 @@ var createEmailSafeFeatures = (additionalBlocks) => {
679
776
  })
680
777
  ];
681
778
  };
779
+ var createEmailLexicalEditor = (customBlocks = []) => {
780
+ const emailSafeBlocks = createEmailSafeBlocks(customBlocks);
781
+ return (0, import_richtext_lexical.lexicalEditor)({
782
+ features: [
783
+ // Toolbars
784
+ (0, import_richtext_lexical.FixedToolbarFeature)(),
785
+ (0, import_richtext_lexical.InlineToolbarFeature)(),
786
+ // Basic text formatting
787
+ (0, import_richtext_lexical.BoldFeature)(),
788
+ (0, import_richtext_lexical.ItalicFeature)(),
789
+ (0, import_richtext_lexical.UnderlineFeature)(),
790
+ (0, import_richtext_lexical.StrikethroughFeature)(),
791
+ // Links with enhanced configuration
792
+ (0, import_richtext_lexical.LinkFeature)({
793
+ fields: [
794
+ {
795
+ name: "url",
796
+ type: "text",
797
+ required: true,
798
+ admin: {
799
+ description: "Enter the full URL (including https://)"
800
+ }
801
+ },
802
+ {
803
+ name: "newTab",
804
+ type: "checkbox",
805
+ label: "Open in new tab",
806
+ defaultValue: false
807
+ }
808
+ ]
809
+ }),
810
+ // Lists
811
+ (0, import_richtext_lexical.OrderedListFeature)(),
812
+ (0, import_richtext_lexical.UnorderedListFeature)(),
813
+ // Headings - limited to h1, h2, h3 for email compatibility
814
+ (0, import_richtext_lexical.HeadingFeature)({
815
+ enabledHeadingSizes: ["h1", "h2", "h3"]
816
+ }),
817
+ // Basic paragraph and alignment
818
+ (0, import_richtext_lexical.ParagraphFeature)(),
819
+ (0, import_richtext_lexical.AlignFeature)(),
820
+ // Blockquotes
821
+ (0, import_richtext_lexical.BlockquoteFeature)(),
822
+ // Upload feature for images
823
+ (0, import_richtext_lexical.UploadFeature)({
824
+ collections: {
825
+ media: {
826
+ fields: [
827
+ {
828
+ name: "caption",
829
+ type: "text",
830
+ admin: {
831
+ description: "Optional caption for the image"
832
+ }
833
+ },
834
+ {
835
+ name: "altText",
836
+ type: "text",
837
+ label: "Alt Text",
838
+ required: true,
839
+ admin: {
840
+ description: "Alternative text for accessibility and when image cannot be displayed"
841
+ }
842
+ }
843
+ ]
844
+ }
845
+ }
846
+ }),
847
+ // Email-safe blocks (processed server-side)
848
+ (0, import_richtext_lexical.BlocksFeature)({
849
+ blocks: emailSafeBlocks
850
+ })
851
+ ]
852
+ });
853
+ };
682
854
  var emailSafeFeatures = createEmailSafeFeatures();
683
855
  var createEmailContentField = (overrides) => {
684
- const features = createEmailSafeFeatures(overrides?.additionalBlocks);
856
+ const editor = overrides?.editor || createEmailLexicalEditor(overrides?.additionalBlocks);
685
857
  return {
686
858
  name: "content",
687
859
  type: "richText",
688
860
  required: true,
689
- editor: (0, import_richtext_lexical.lexicalEditor)({
690
- features
691
- }),
861
+ editor,
692
862
  admin: {
693
863
  description: "Email content with limited formatting for compatibility",
694
864
  ...overrides?.admin
@@ -998,6 +1168,12 @@ var createBroadcastsCollection = (pluginConfig) => {
998
1168
  const customizations = pluginConfig.customizations?.broadcasts;
999
1169
  return {
1000
1170
  slug: "broadcasts",
1171
+ versions: {
1172
+ drafts: {
1173
+ autosave: true,
1174
+ schedulePublish: true
1175
+ }
1176
+ },
1001
1177
  labels: {
1002
1178
  singular: "Broadcast",
1003
1179
  plural: "Broadcasts"
@@ -1005,7 +1181,7 @@ var createBroadcastsCollection = (pluginConfig) => {
1005
1181
  admin: {
1006
1182
  useAsTitle: "subject",
1007
1183
  description: "Individual email campaigns sent to subscribers",
1008
- defaultColumns: ["subject", "status", "sentAt", "recipientCount", "actions"]
1184
+ defaultColumns: ["subject", "_status", "status", "sentAt", "recipientCount"]
1009
1185
  },
1010
1186
  fields: [
1011
1187
  {
@@ -1040,13 +1216,15 @@ var createBroadcastsCollection = (pluginConfig) => {
1040
1216
  }
1041
1217
  },
1042
1218
  // Apply content field customization if provided
1043
- customizations?.fieldOverrides?.content ? customizations.fieldOverrides.content(createEmailContentField({
1044
- admin: { description: "Email content" },
1045
- additionalBlocks: customizations.customBlocks
1046
- })) : createEmailContentField({
1047
- admin: { description: "Email content" },
1048
- additionalBlocks: customizations?.customBlocks
1049
- })
1219
+ // Process blocks server-side to avoid client serialization issues
1220
+ (() => {
1221
+ const emailEditor = createEmailLexicalEditor(customizations?.customBlocks);
1222
+ const baseField = createEmailContentField({
1223
+ admin: { description: "Email content" },
1224
+ editor: emailEditor
1225
+ });
1226
+ return customizations?.fieldOverrides?.content ? customizations.fieldOverrides.content(baseField) : baseField;
1227
+ })()
1050
1228
  ]
1051
1229
  },
1052
1230
  {
@@ -1216,18 +1394,6 @@ var createBroadcastsCollection = (pluginConfig) => {
1216
1394
  condition: () => false
1217
1395
  // Hidden by default
1218
1396
  }
1219
- },
1220
- // UI Field for custom actions in list view
1221
- {
1222
- name: "actions",
1223
- type: "ui",
1224
- admin: {
1225
- components: {
1226
- Cell: "payload-plugin-newsletter/components#ActionsCell",
1227
- Field: "payload-plugin-newsletter/components#EmptyField"
1228
- },
1229
- disableListColumn: false
1230
- }
1231
1397
  }
1232
1398
  ],
1233
1399
  hooks: {
@@ -1341,6 +1507,48 @@ var createBroadcastsCollection = (pluginConfig) => {
1341
1507
  req.payload.logger.error("Failed to delete broadcast from provider:", error);
1342
1508
  }
1343
1509
  return doc;
1510
+ },
1511
+ // Hook to send when published
1512
+ async ({ doc, req }) => {
1513
+ if (doc._status === "published" && doc.providerId) {
1514
+ if (doc.status === "sent" || doc.status === "sending") {
1515
+ return doc;
1516
+ }
1517
+ try {
1518
+ const broadcastConfig = pluginConfig.providers?.broadcast;
1519
+ const resendConfig = pluginConfig.providers?.resend;
1520
+ if (!broadcastConfig && !resendConfig) {
1521
+ req.payload.logger.error("No provider configured for sending");
1522
+ return doc;
1523
+ }
1524
+ if (broadcastConfig) {
1525
+ const { BroadcastApiProvider: BroadcastApiProvider2 } = await Promise.resolve().then(() => (init_broadcast2(), broadcast_exports));
1526
+ const provider = new BroadcastApiProvider2(broadcastConfig);
1527
+ await provider.send(doc.providerId);
1528
+ }
1529
+ await req.payload.update({
1530
+ collection: "broadcasts",
1531
+ id: doc.id,
1532
+ data: {
1533
+ status: "sending" /* SENDING */,
1534
+ sentAt: (/* @__PURE__ */ new Date()).toISOString()
1535
+ },
1536
+ req
1537
+ });
1538
+ req.payload.logger.info(`Broadcast ${doc.id} sent successfully`);
1539
+ } catch (error) {
1540
+ req.payload.logger.error(`Failed to send broadcast ${doc.id}:`, error);
1541
+ await req.payload.update({
1542
+ collection: "broadcasts",
1543
+ id: doc.id,
1544
+ data: {
1545
+ status: "failed" /* FAILED */
1546
+ },
1547
+ req
1548
+ });
1549
+ }
1550
+ }
1551
+ return doc;
1344
1552
  }
1345
1553
  ]
1346
1554
  }