payload-plugin-newsletter 0.20.7 → 0.21.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,30 @@
1
+ ## [0.21.0] - 2025-01-31
2
+
3
+ ### Added
4
+ - **Webhook Support**: Real-time webhook integration for Broadcast provider
5
+ - Automatic updates for subscriber events (subscribed/unsubscribed)
6
+ - Real-time broadcast status updates (scheduled, in_progress, sent, etc.)
7
+ - HMAC-SHA256 signature verification for security
8
+ - Webhook configuration UI in Newsletter Settings
9
+ - Manual webhook registration with setup instructions
10
+
11
+ ### Changed
12
+ - **BREAKING**: Removed polling-based unsubscribe synchronization
13
+ - Webhooks now provide real-time updates instead of scheduled polling
14
+ - Removed `unsubscribeSync` configuration option
15
+ - Removed `afterUnsubscribeSync` hook
16
+ - Removed sync job infrastructure
17
+
18
+ ### Security
19
+ - Added webhook signature verification with timestamp validation
20
+ - Webhook secrets stored securely in Newsletter Settings
21
+
22
+ ### Migration Guide
23
+ If you were using unsubscribe sync:
24
+ 1. Remove any `unsubscribeSync` configuration from your plugin setup
25
+ 2. Configure webhooks in your Broadcast dashboard (see README)
26
+ 3. Add the webhook secret to your Newsletter Settings
27
+
1
28
  ## [0.20.7] - 2025-08-02
2
29
 
3
30
  ### Added
package/README.md CHANGED
@@ -17,7 +17,7 @@ A complete newsletter management plugin for [Payload CMS](https://github.com/pay
17
17
  - 🌍 **Internationalization** - Multi-language support built-in
18
18
  - 📊 **Analytics Ready** - UTM tracking and signup metadata collection
19
19
  - ⚙️ **Admin UI Configuration** - Manage email settings through Payload admin panel
20
- - 🔄 **Bidirectional Sync** - Sync unsubscribes from email services back to Payload
20
+ - 🔄 **Real-time Webhook Sync** - Receive subscriber and broadcast events from email services via webhooks
21
21
  - 👁️ **Email Preview** - Real-time preview with desktop/mobile views (v0.9.0+)
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+)
@@ -654,27 +654,44 @@ This adds a "Newsletter Scheduling" group to your articles with:
654
654
  - Audience segment selection
655
655
  - Send status tracking
656
656
 
657
- ## Unsubscribe Sync
657
+ ## Webhook Configuration (Broadcast)
658
658
 
659
- The plugin supports bidirectional synchronization of unsubscribe states between Payload and your email service:
659
+ The plugin supports real-time webhook integration with Broadcast for instant updates:
660
660
 
661
- ```typescript
662
- features: {
663
- unsubscribeSync: {
664
- enabled: true,
665
- schedule: '0 * * * *', // Hourly sync
666
- queue: 'newsletter-sync' // Optional custom queue name
667
- }
668
- }
669
- ```
661
+ ### Automatic Updates
662
+
663
+ When configured, the plugin automatically receives and processes:
664
+ - **Subscriber Events**: `subscribed`, `unsubscribed`
665
+ - **Broadcast Events**: All status changes (`scheduled`, `in_progress`, `sent`, etc.)
666
+
667
+ ### Setup Instructions
668
+
669
+ 1. **Save your Newsletter Settings** in the Payload admin to generate a webhook URL
670
+ 2. **Configure in Broadcast**:
671
+ - Go to your Broadcast dashboard → "Webhook Endpoints"
672
+ - Click "Add Webhook Endpoint"
673
+ - Paste the webhook URL from Payload
674
+ - Select events:
675
+ - Subscriber Events: `subscribed`, `unsubscribed`
676
+ - Broadcast Events: All
677
+ - Create the webhook and copy the webhook secret
678
+ 3. **Add the webhook secret** to your Newsletter Settings in Payload
679
+ 4. **Save and verify** the webhook connection
680
+
681
+ ### Security
682
+
683
+ Webhooks are secured with:
684
+ - HMAC-SHA256 signature verification
685
+ - Timestamp validation (5-minute window)
686
+ - Secret key stored in Newsletter Settings
687
+
688
+ ### Data Flow
670
689
 
671
- This feature:
672
- - Polls your email service for unsubscribed users
673
- - Updates their status in Payload automatically
674
- - Supports both Broadcast and Resend providers
675
- - Can run on a schedule or be triggered manually
690
+ - **Subscriber events** update the subscriber's status and metadata
691
+ - **Broadcast events** update the broadcast's status and delivery metrics
692
+ - All updates happen in real-time without polling
676
693
 
677
- For more details, see the [Unsubscribe Sync documentation](./docs/unsubscribe-sync.md).
694
+ **Note**: Email engagement events (opens, clicks) remain in Broadcast for analytics.
678
695
 
679
696
  ## Email Providers
680
697
 
package/dist/admin.d.ts CHANGED
@@ -22,4 +22,6 @@ interface EmailPreviewProps {
22
22
  }
23
23
  declare const EmailPreview: React.FC<EmailPreviewProps>;
24
24
 
25
- export { BroadcastInlinePreview, type BroadcastInlinePreviewProps, EmailPreview, type EmailPreviewProps, StatusBadge, type StatusBadgeProps };
25
+ declare const WebhookConfiguration: React.FC;
26
+
27
+ export { BroadcastInlinePreview, type BroadcastInlinePreviewProps, EmailPreview, type EmailPreviewProps, StatusBadge, type StatusBadgeProps, WebhookConfiguration };
package/dist/admin.js CHANGED
@@ -188,8 +188,153 @@ var EmailPreview = ({
188
188
  ] })
189
189
  ] });
190
190
  };
191
+
192
+ // src/admin/components/WebhookConfiguration.tsx
193
+ import { useState as useState2 } from "react";
194
+ import { useFormFields as useFormFields2 } from "@payloadcms/ui";
195
+ import { Button } from "@payloadcms/ui";
196
+ import { toast } from "@payloadcms/ui";
197
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
198
+ var WebhookConfiguration = () => {
199
+ const [showInstructions, setShowInstructions] = useState2(false);
200
+ const [verifying, setVerifying] = useState2(false);
201
+ const fields = useFormFields2(([fields2]) => ({
202
+ webhookUrl: fields2?.broadcastSettings?.webhookUrl,
203
+ webhookStatus: fields2?.broadcastSettings?.webhookStatus,
204
+ lastWebhookReceived: fields2?.broadcastSettings?.lastWebhookReceived
205
+ }));
206
+ const handleVerify = async () => {
207
+ setVerifying(true);
208
+ try {
209
+ const response = await fetch("/api/newsletter/webhooks/verify", {
210
+ method: "POST",
211
+ headers: {
212
+ "Content-Type": "application/json"
213
+ }
214
+ });
215
+ const data = await response.json();
216
+ if (data.success) {
217
+ toast.success(data.message || "Webhook verified");
218
+ } else {
219
+ toast.error(data.error || "Verification failed");
220
+ }
221
+ } catch (error) {
222
+ toast.error("Failed to verify webhook");
223
+ } finally {
224
+ setVerifying(false);
225
+ }
226
+ };
227
+ const getStatusColor = (status) => {
228
+ switch (status) {
229
+ case "verified":
230
+ return "green";
231
+ case "configured":
232
+ return "yellow";
233
+ case "error":
234
+ return "red";
235
+ default:
236
+ return "gray";
237
+ }
238
+ };
239
+ const getStatusLabel = (status) => {
240
+ switch (status) {
241
+ case "verified":
242
+ return "Verified \u2713";
243
+ case "configured":
244
+ return "Configured";
245
+ case "error":
246
+ return "Error";
247
+ default:
248
+ return "Not Configured";
249
+ }
250
+ };
251
+ return /* @__PURE__ */ jsx4("div", { className: "field-type", children: /* @__PURE__ */ jsxs3("div", { style: { marginBottom: "1rem" }, children: [
252
+ /* @__PURE__ */ jsx4("label", { className: "field-label", children: "Webhook Configuration" }),
253
+ /* @__PURE__ */ jsxs3("div", { style: {
254
+ background: "#f5f5f5",
255
+ padding: "1rem",
256
+ borderRadius: "4px",
257
+ marginTop: "0.5rem"
258
+ }, children: [
259
+ /* @__PURE__ */ jsxs3("div", { style: { marginBottom: "0.5rem" }, children: [
260
+ /* @__PURE__ */ jsx4("strong", { children: "Webhook URL:" }),
261
+ /* @__PURE__ */ jsx4("code", { style: {
262
+ display: "block",
263
+ padding: "0.5rem",
264
+ background: "#fff",
265
+ border: "1px solid #ddd",
266
+ borderRadius: "4px",
267
+ marginTop: "0.25rem",
268
+ fontSize: "0.875rem"
269
+ }, children: fields.webhookUrl || "Save settings to generate URL" })
270
+ ] }),
271
+ /* @__PURE__ */ jsxs3("div", { style: { marginBottom: "0.5rem" }, children: [
272
+ /* @__PURE__ */ jsx4("strong", { children: "Status:" }),
273
+ " ",
274
+ /* @__PURE__ */ jsx4("span", { style: { color: getStatusColor(fields.webhookStatus || "not_configured") }, children: getStatusLabel(fields.webhookStatus || "not_configured") })
275
+ ] }),
276
+ fields.lastWebhookReceived && /* @__PURE__ */ jsxs3("div", { style: { fontSize: "0.875rem", color: "#666" }, children: [
277
+ "Last event: ",
278
+ new Date(fields.lastWebhookReceived).toLocaleString()
279
+ ] })
280
+ ] }),
281
+ /* @__PURE__ */ jsxs3("div", { style: { marginTop: "1rem", display: "flex", gap: "0.5rem" }, children: [
282
+ /* @__PURE__ */ jsxs3(
283
+ Button,
284
+ {
285
+ onClick: () => setShowInstructions(!showInstructions),
286
+ buttonStyle: "secondary",
287
+ size: "small",
288
+ children: [
289
+ showInstructions ? "Hide" : "Show",
290
+ " Instructions"
291
+ ]
292
+ }
293
+ ),
294
+ /* @__PURE__ */ jsx4(
295
+ Button,
296
+ {
297
+ onClick: handleVerify,
298
+ buttonStyle: "primary",
299
+ size: "small",
300
+ disabled: verifying || !fields.webhookUrl,
301
+ children: verifying ? "Verifying..." : "Verify Webhook"
302
+ }
303
+ )
304
+ ] }),
305
+ showInstructions && /* @__PURE__ */ jsxs3("div", { style: {
306
+ marginTop: "1rem",
307
+ padding: "1rem",
308
+ background: "#f0f8ff",
309
+ border: "1px solid #b0d4ff",
310
+ borderRadius: "4px"
311
+ }, children: [
312
+ /* @__PURE__ */ jsx4("h4", { style: { marginTop: 0 }, children: "Configure Broadcast Webhook" }),
313
+ /* @__PURE__ */ jsxs3("ol", { children: [
314
+ /* @__PURE__ */ jsx4("li", { children: "Copy the webhook URL above" }),
315
+ /* @__PURE__ */ jsx4("li", { children: "Go to your Broadcast dashboard" }),
316
+ /* @__PURE__ */ jsx4("li", { children: 'Navigate to "Webhook Endpoints"' }),
317
+ /* @__PURE__ */ jsx4("li", { children: 'Click "Add Webhook Endpoint"' }),
318
+ /* @__PURE__ */ jsx4("li", { children: "Paste the URL" }),
319
+ /* @__PURE__ */ jsxs3("li", { children: [
320
+ "Select these events:",
321
+ /* @__PURE__ */ jsxs3("ul", { children: [
322
+ /* @__PURE__ */ jsx4("li", { children: "Subscriber Events: subscribed, unsubscribed" }),
323
+ /* @__PURE__ */ jsx4("li", { children: "Broadcast Events: All" })
324
+ ] })
325
+ ] }),
326
+ /* @__PURE__ */ jsx4("li", { children: 'Click "Create Webhook"' }),
327
+ /* @__PURE__ */ jsx4("li", { children: "Copy the webhook secret shown" }),
328
+ /* @__PURE__ */ jsx4("li", { children: "Paste it in the Webhook Secret field below" }),
329
+ /* @__PURE__ */ jsx4("li", { children: "Save these settings" }),
330
+ /* @__PURE__ */ jsx4("li", { children: 'Click "Verify Webhook" to test the connection' })
331
+ ] })
332
+ ] })
333
+ ] }) });
334
+ };
191
335
  export {
192
336
  BroadcastInlinePreview,
193
337
  EmailPreview,
194
- StatusBadge
338
+ StatusBadge,
339
+ WebhookConfiguration
195
340
  };
@@ -2154,6 +2154,14 @@ var createBroadcastsCollection = (pluginConfig) => {
2154
2154
  condition: (data) => hasProviders && data?.providerId
2155
2155
  }
2156
2156
  },
2157
+ {
2158
+ name: "externalId",
2159
+ type: "text",
2160
+ admin: {
2161
+ readOnly: true,
2162
+ description: "External ID for webhook integration"
2163
+ }
2164
+ },
2157
2165
  {
2158
2166
  name: "providerData",
2159
2167
  type: "json",
@@ -2162,6 +2170,77 @@ var createBroadcastsCollection = (pluginConfig) => {
2162
2170
  condition: () => false
2163
2171
  // Hidden by default
2164
2172
  }
2173
+ },
2174
+ // Webhook tracking fields
2175
+ {
2176
+ name: "webhookData",
2177
+ type: "group",
2178
+ label: "Webhook Data",
2179
+ admin: {
2180
+ condition: () => false
2181
+ // Hidden by default, used for webhook tracking
2182
+ },
2183
+ fields: [
2184
+ {
2185
+ name: "lastWebhookEvent",
2186
+ type: "text",
2187
+ admin: {
2188
+ readOnly: true
2189
+ }
2190
+ },
2191
+ {
2192
+ name: "lastWebhookEventAt",
2193
+ type: "date",
2194
+ admin: {
2195
+ readOnly: true
2196
+ }
2197
+ },
2198
+ {
2199
+ name: "hasWarnings",
2200
+ type: "checkbox",
2201
+ defaultValue: false
2202
+ },
2203
+ {
2204
+ name: "failureReason",
2205
+ type: "text"
2206
+ },
2207
+ {
2208
+ name: "sentCount",
2209
+ type: "number"
2210
+ },
2211
+ {
2212
+ name: "totalCount",
2213
+ type: "number"
2214
+ },
2215
+ {
2216
+ name: "failedCount",
2217
+ type: "number"
2218
+ },
2219
+ {
2220
+ name: "remainingCount",
2221
+ type: "number"
2222
+ },
2223
+ {
2224
+ name: "sendingStartedAt",
2225
+ type: "date"
2226
+ },
2227
+ {
2228
+ name: "failedAt",
2229
+ type: "date"
2230
+ },
2231
+ {
2232
+ name: "abortedAt",
2233
+ type: "date"
2234
+ },
2235
+ {
2236
+ name: "abortReason",
2237
+ type: "text"
2238
+ },
2239
+ {
2240
+ name: "pausedAt",
2241
+ type: "date"
2242
+ }
2243
+ ]
2165
2244
  }
2166
2245
  ],
2167
2246
  hooks: {
@@ -2803,6 +2882,15 @@ var createSubscribersCollection = (pluginConfig) => {
2803
2882
  type: "date",
2804
2883
  hidden: true
2805
2884
  },
2885
+ // External ID for webhook integration
2886
+ {
2887
+ name: "externalId",
2888
+ type: "text",
2889
+ admin: {
2890
+ description: "ID from email service provider",
2891
+ readOnly: true
2892
+ }
2893
+ },
2806
2894
  // Subscription status
2807
2895
  {
2808
2896
  name: "subscriptionStatus",
@@ -2818,6 +2906,14 @@ var createSubscribersCollection = (pluginConfig) => {
2818
2906
  description: "Current subscription status"
2819
2907
  }
2820
2908
  },
2909
+ {
2910
+ name: "subscribedAt",
2911
+ type: "date",
2912
+ admin: {
2913
+ description: "When the user subscribed",
2914
+ readOnly: true
2915
+ }
2916
+ },
2821
2917
  {
2822
2918
  name: "unsubscribedAt",
2823
2919
  type: "date",
@@ -2827,6 +2923,15 @@ var createSubscribersCollection = (pluginConfig) => {
2827
2923
  readOnly: true
2828
2924
  }
2829
2925
  },
2926
+ {
2927
+ name: "unsubscribeReason",
2928
+ type: "text",
2929
+ admin: {
2930
+ condition: (data) => data?.subscriptionStatus === "unsubscribed",
2931
+ description: "Reason for unsubscribing",
2932
+ readOnly: true
2933
+ }
2934
+ },
2830
2935
  // Email preferences
2831
2936
  {
2832
2937
  name: "emailPreferences",