payload-plugin-newsletter 0.20.6 → 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 +71 -0
- package/README.md +35 -18
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +157 -2
- package/dist/collections.cjs +131 -2
- package/dist/collections.cjs.map +1 -1
- package/dist/collections.js +131 -2
- package/dist/collections.js.map +1 -1
- package/dist/server.d.ts +6 -29
- package/dist/server.js +616 -133
- package/dist/types.d.cts +8 -30
- package/dist/types.d.ts +8 -30
- package/dist/utils.cjs +2 -1
- package/dist/utils.cjs.map +1 -1
- package/dist/utils.d.cts +2 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +2 -1
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -369,6 +369,15 @@ var createSubscribersCollection = (pluginConfig) => {
|
|
|
369
369
|
type: "date",
|
|
370
370
|
hidden: true
|
|
371
371
|
},
|
|
372
|
+
// External ID for webhook integration
|
|
373
|
+
{
|
|
374
|
+
name: "externalId",
|
|
375
|
+
type: "text",
|
|
376
|
+
admin: {
|
|
377
|
+
description: "ID from email service provider",
|
|
378
|
+
readOnly: true
|
|
379
|
+
}
|
|
380
|
+
},
|
|
372
381
|
// Subscription status
|
|
373
382
|
{
|
|
374
383
|
name: "subscriptionStatus",
|
|
@@ -384,6 +393,14 @@ var createSubscribersCollection = (pluginConfig) => {
|
|
|
384
393
|
description: "Current subscription status"
|
|
385
394
|
}
|
|
386
395
|
},
|
|
396
|
+
{
|
|
397
|
+
name: "subscribedAt",
|
|
398
|
+
type: "date",
|
|
399
|
+
admin: {
|
|
400
|
+
description: "When the user subscribed",
|
|
401
|
+
readOnly: true
|
|
402
|
+
}
|
|
403
|
+
},
|
|
387
404
|
{
|
|
388
405
|
name: "unsubscribedAt",
|
|
389
406
|
type: "date",
|
|
@@ -393,6 +410,15 @@ var createSubscribersCollection = (pluginConfig) => {
|
|
|
393
410
|
readOnly: true
|
|
394
411
|
}
|
|
395
412
|
},
|
|
413
|
+
{
|
|
414
|
+
name: "unsubscribeReason",
|
|
415
|
+
type: "text",
|
|
416
|
+
admin: {
|
|
417
|
+
condition: (data) => data?.subscriptionStatus === "unsubscribed",
|
|
418
|
+
description: "Reason for unsubscribing",
|
|
419
|
+
readOnly: true
|
|
420
|
+
}
|
|
421
|
+
},
|
|
396
422
|
// Email preferences
|
|
397
423
|
{
|
|
398
424
|
name: "emailPreferences",
|
|
@@ -725,6 +751,75 @@ var createNewsletterSettingsGlobal = (pluginConfig) => {
|
|
|
725
751
|
admin: {
|
|
726
752
|
description: "Your Broadcast API token"
|
|
727
753
|
}
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
type: "collapsible",
|
|
757
|
+
label: "Webhook Configuration",
|
|
758
|
+
fields: [
|
|
759
|
+
{
|
|
760
|
+
name: "webhookUrl",
|
|
761
|
+
type: "text",
|
|
762
|
+
label: "Webhook URL",
|
|
763
|
+
admin: {
|
|
764
|
+
readOnly: true,
|
|
765
|
+
description: "Copy this URL to your Broadcast webhook settings",
|
|
766
|
+
placeholder: "URL will be generated after save"
|
|
767
|
+
},
|
|
768
|
+
hooks: {
|
|
769
|
+
beforeChange: [
|
|
770
|
+
({ req }) => {
|
|
771
|
+
const host = req.headers.get("host") || "localhost:3000";
|
|
772
|
+
const protocol = req.headers.get("x-forwarded-proto") || "http";
|
|
773
|
+
const baseUrl = process.env.PAYLOAD_PUBLIC_SERVER_URL || `${protocol}://${host}`;
|
|
774
|
+
return `${baseUrl}/api/newsletter/webhooks/broadcast`;
|
|
775
|
+
}
|
|
776
|
+
]
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
name: "webhookConfiguration",
|
|
781
|
+
type: "ui",
|
|
782
|
+
admin: {
|
|
783
|
+
components: {
|
|
784
|
+
Field: "payload-plugin-newsletter/admin#WebhookConfiguration"
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
name: "webhookSecret",
|
|
790
|
+
type: "text",
|
|
791
|
+
label: "Webhook Secret",
|
|
792
|
+
admin: {
|
|
793
|
+
description: "Paste the webhook secret from Broadcast here"
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: "webhookStatus",
|
|
798
|
+
type: "select",
|
|
799
|
+
label: "Status",
|
|
800
|
+
options: [
|
|
801
|
+
{ label: "Not Configured", value: "not_configured" },
|
|
802
|
+
{ label: "Configured", value: "configured" },
|
|
803
|
+
{ label: "Verified", value: "verified" },
|
|
804
|
+
{ label: "Error", value: "error" }
|
|
805
|
+
],
|
|
806
|
+
defaultValue: "not_configured",
|
|
807
|
+
admin: {
|
|
808
|
+
readOnly: true
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
name: "lastWebhookReceived",
|
|
813
|
+
type: "date",
|
|
814
|
+
label: "Last Event Received",
|
|
815
|
+
admin: {
|
|
816
|
+
readOnly: true,
|
|
817
|
+
date: {
|
|
818
|
+
displayFormat: "yyyy-MM-dd HH:mm:ss"
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
]
|
|
728
823
|
}
|
|
729
824
|
]
|
|
730
825
|
},
|
|
@@ -2332,6 +2427,415 @@ var createBroadcastManagementEndpoints = (_config) => {
|
|
|
2332
2427
|
return [];
|
|
2333
2428
|
};
|
|
2334
2429
|
|
|
2430
|
+
// src/utils/webhooks/signature.ts
|
|
2431
|
+
import crypto from "crypto";
|
|
2432
|
+
function verifyBroadcastWebhookSignature(payload, signature, timestamp, secret) {
|
|
2433
|
+
try {
|
|
2434
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2435
|
+
const webhookTimestamp = parseInt(timestamp, 10);
|
|
2436
|
+
if (isNaN(webhookTimestamp)) {
|
|
2437
|
+
console.error("[Webhook Signature] Invalid timestamp format");
|
|
2438
|
+
return false;
|
|
2439
|
+
}
|
|
2440
|
+
if (Math.abs(now - webhookTimestamp) > 300) {
|
|
2441
|
+
console.error("[Webhook Signature] Timestamp too old or in future");
|
|
2442
|
+
return false;
|
|
2443
|
+
}
|
|
2444
|
+
const signatureParts = signature.split(",");
|
|
2445
|
+
if (signatureParts.length !== 2 || signatureParts[0] !== "v1") {
|
|
2446
|
+
console.error("[Webhook Signature] Invalid signature format");
|
|
2447
|
+
return false;
|
|
2448
|
+
}
|
|
2449
|
+
const actualSignature = signatureParts[1];
|
|
2450
|
+
const signedPayload = `${timestamp}.${payload}`;
|
|
2451
|
+
const expectedSignature = crypto.createHmac("sha256", secret).update(signedPayload).digest("base64");
|
|
2452
|
+
const actualBuffer = Buffer.from(actualSignature);
|
|
2453
|
+
const expectedBuffer = Buffer.from(expectedSignature);
|
|
2454
|
+
if (actualBuffer.length !== expectedBuffer.length) {
|
|
2455
|
+
return false;
|
|
2456
|
+
}
|
|
2457
|
+
return crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
|
2458
|
+
} catch (error) {
|
|
2459
|
+
console.error("[Webhook Signature] Verification error:", error);
|
|
2460
|
+
return false;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// src/types/webhooks.ts
|
|
2465
|
+
var WEBHOOK_EVENT_TYPES = {
|
|
2466
|
+
// Subscriber events
|
|
2467
|
+
SUBSCRIBER_SUBSCRIBED: "subscriber.subscribed",
|
|
2468
|
+
SUBSCRIBER_UNSUBSCRIBED: "subscriber.unsubscribed",
|
|
2469
|
+
// Broadcast events
|
|
2470
|
+
BROADCAST_SCHEDULED: "broadcast.scheduled",
|
|
2471
|
+
BROADCAST_QUEUEING: "broadcast.queueing",
|
|
2472
|
+
BROADCAST_SENDING: "broadcast.sending",
|
|
2473
|
+
BROADCAST_SENT: "broadcast.sent",
|
|
2474
|
+
BROADCAST_FAILED: "broadcast.failed",
|
|
2475
|
+
BROADCAST_PARTIAL_FAILURE: "broadcast.partial_failure",
|
|
2476
|
+
BROADCAST_ABORTED: "broadcast.aborted",
|
|
2477
|
+
BROADCAST_PAUSED: "broadcast.paused"
|
|
2478
|
+
};
|
|
2479
|
+
function isSubscriberEvent(event) {
|
|
2480
|
+
return event.type.startsWith("subscriber.");
|
|
2481
|
+
}
|
|
2482
|
+
function isBroadcastEvent(event) {
|
|
2483
|
+
return event.type.startsWith("broadcast.");
|
|
2484
|
+
}
|
|
2485
|
+
function isHandledEvent(event) {
|
|
2486
|
+
return Object.values(WEBHOOK_EVENT_TYPES).includes(event.type);
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// src/webhooks/handlers/subscriber.ts
|
|
2490
|
+
async function handleSubscriberEvent(event, req, config) {
|
|
2491
|
+
const { payload } = req;
|
|
2492
|
+
const subscribersSlug = config.subscribersSlug || "subscribers";
|
|
2493
|
+
switch (event.type) {
|
|
2494
|
+
case WEBHOOK_EVENT_TYPES.SUBSCRIBER_SUBSCRIBED:
|
|
2495
|
+
await handleSubscriberSubscribed(event, payload, subscribersSlug);
|
|
2496
|
+
break;
|
|
2497
|
+
case WEBHOOK_EVENT_TYPES.SUBSCRIBER_UNSUBSCRIBED:
|
|
2498
|
+
await handleSubscriberUnsubscribed(event, payload, subscribersSlug);
|
|
2499
|
+
break;
|
|
2500
|
+
default:
|
|
2501
|
+
console.warn("[Subscriber Handler] Unhandled event type:", event.type);
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
async function handleSubscriberSubscribed(event, payload, subscribersSlug) {
|
|
2505
|
+
const { data } = event;
|
|
2506
|
+
try {
|
|
2507
|
+
const existing = await payload.find({
|
|
2508
|
+
collection: subscribersSlug,
|
|
2509
|
+
where: {
|
|
2510
|
+
email: {
|
|
2511
|
+
equals: data.email
|
|
2512
|
+
}
|
|
2513
|
+
},
|
|
2514
|
+
limit: 1
|
|
2515
|
+
});
|
|
2516
|
+
if (existing.docs.length > 0) {
|
|
2517
|
+
const subscriber = existing.docs[0];
|
|
2518
|
+
await payload.update({
|
|
2519
|
+
collection: subscribersSlug,
|
|
2520
|
+
id: subscriber.id,
|
|
2521
|
+
data: {
|
|
2522
|
+
subscriptionStatus: "active",
|
|
2523
|
+
subscribedAt: data.subscribed_at,
|
|
2524
|
+
// Store Broadcast ID for future reference
|
|
2525
|
+
externalId: data.id,
|
|
2526
|
+
// Update attributes if provided
|
|
2527
|
+
...data.attributes && { attributes: data.attributes }
|
|
2528
|
+
}
|
|
2529
|
+
});
|
|
2530
|
+
console.log("[Subscriber Handler] Reactivated subscriber:", data.email);
|
|
2531
|
+
} else {
|
|
2532
|
+
await payload.create({
|
|
2533
|
+
collection: subscribersSlug,
|
|
2534
|
+
data: {
|
|
2535
|
+
email: data.email,
|
|
2536
|
+
name: data.name,
|
|
2537
|
+
subscriptionStatus: "active",
|
|
2538
|
+
subscribedAt: data.subscribed_at,
|
|
2539
|
+
externalId: data.id,
|
|
2540
|
+
attributes: data.attributes || {}
|
|
2541
|
+
}
|
|
2542
|
+
});
|
|
2543
|
+
console.log("[Subscriber Handler] Created new subscriber:", data.email);
|
|
2544
|
+
}
|
|
2545
|
+
} catch (error) {
|
|
2546
|
+
console.error("[Subscriber Handler] Error handling subscribed event:", error);
|
|
2547
|
+
throw error;
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
async function handleSubscriberUnsubscribed(event, payload, subscribersSlug) {
|
|
2551
|
+
const { data } = event;
|
|
2552
|
+
try {
|
|
2553
|
+
const existing = await payload.find({
|
|
2554
|
+
collection: subscribersSlug,
|
|
2555
|
+
where: {
|
|
2556
|
+
email: {
|
|
2557
|
+
equals: data.email
|
|
2558
|
+
}
|
|
2559
|
+
},
|
|
2560
|
+
limit: 1
|
|
2561
|
+
});
|
|
2562
|
+
if (existing.docs.length > 0) {
|
|
2563
|
+
const subscriber = existing.docs[0];
|
|
2564
|
+
await payload.update({
|
|
2565
|
+
collection: subscribersSlug,
|
|
2566
|
+
id: subscriber.id,
|
|
2567
|
+
data: {
|
|
2568
|
+
subscriptionStatus: "unsubscribed",
|
|
2569
|
+
unsubscribedAt: data.unsubscribed_at,
|
|
2570
|
+
unsubscribeReason: data.reason
|
|
2571
|
+
}
|
|
2572
|
+
});
|
|
2573
|
+
console.log("[Subscriber Handler] Unsubscribed:", data.email);
|
|
2574
|
+
} else {
|
|
2575
|
+
console.warn("[Subscriber Handler] Subscriber not found for unsubscribe:", data.email);
|
|
2576
|
+
}
|
|
2577
|
+
} catch (error) {
|
|
2578
|
+
console.error("[Subscriber Handler] Error handling unsubscribed event:", error);
|
|
2579
|
+
throw error;
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// src/webhooks/handlers/broadcast.ts
|
|
2584
|
+
var STATUS_MAP = {
|
|
2585
|
+
"broadcast.scheduled": "scheduled",
|
|
2586
|
+
"broadcast.queueing": "sending",
|
|
2587
|
+
"broadcast.sending": "sending",
|
|
2588
|
+
"broadcast.sent": "sent",
|
|
2589
|
+
"broadcast.failed": "failed",
|
|
2590
|
+
"broadcast.partial_failure": "sent",
|
|
2591
|
+
// With warning flag
|
|
2592
|
+
"broadcast.aborted": "canceled",
|
|
2593
|
+
"broadcast.paused": "paused"
|
|
2594
|
+
};
|
|
2595
|
+
async function handleBroadcastEvent(event, req, _config) {
|
|
2596
|
+
const { payload } = req;
|
|
2597
|
+
const broadcastsSlug = "broadcasts";
|
|
2598
|
+
const status = STATUS_MAP[event.type];
|
|
2599
|
+
if (!status) {
|
|
2600
|
+
console.warn("[Broadcast Handler] Unknown event type:", event.type);
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
try {
|
|
2604
|
+
await updateBroadcastStatus(event, status, payload, broadcastsSlug);
|
|
2605
|
+
} catch (error) {
|
|
2606
|
+
console.error("[Broadcast Handler] Error handling event:", {
|
|
2607
|
+
type: event.type,
|
|
2608
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
2609
|
+
});
|
|
2610
|
+
throw error;
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
async function updateBroadcastStatus(event, status, payload, broadcastsSlug) {
|
|
2614
|
+
const { data } = event;
|
|
2615
|
+
const broadcastId = data.broadcast_id;
|
|
2616
|
+
if (!broadcastId) {
|
|
2617
|
+
console.error("[Broadcast Handler] No broadcast_id in event data");
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
try {
|
|
2621
|
+
const existing = await payload.find({
|
|
2622
|
+
collection: broadcastsSlug,
|
|
2623
|
+
where: {
|
|
2624
|
+
externalId: {
|
|
2625
|
+
equals: broadcastId
|
|
2626
|
+
}
|
|
2627
|
+
},
|
|
2628
|
+
limit: 1
|
|
2629
|
+
});
|
|
2630
|
+
if (existing.docs.length === 0) {
|
|
2631
|
+
console.warn("[Broadcast Handler] Broadcast not found:", broadcastId);
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
const broadcast = existing.docs[0];
|
|
2635
|
+
const updateData = {
|
|
2636
|
+
status,
|
|
2637
|
+
lastWebhookEvent: event.type,
|
|
2638
|
+
lastWebhookEventAt: event.occurred_at
|
|
2639
|
+
};
|
|
2640
|
+
switch (event.type) {
|
|
2641
|
+
case WEBHOOK_EVENT_TYPES.BROADCAST_SCHEDULED:
|
|
2642
|
+
updateData.scheduledAt = event.data.scheduled_for;
|
|
2643
|
+
break;
|
|
2644
|
+
case WEBHOOK_EVENT_TYPES.BROADCAST_SENDING: {
|
|
2645
|
+
const sendingEvent = event;
|
|
2646
|
+
updateData.sentCount = sendingEvent.data.sent_count;
|
|
2647
|
+
updateData.totalCount = sendingEvent.data.total_count;
|
|
2648
|
+
break;
|
|
2649
|
+
}
|
|
2650
|
+
case WEBHOOK_EVENT_TYPES.BROADCAST_SENT: {
|
|
2651
|
+
const sentEvent = event;
|
|
2652
|
+
updateData.sentCount = sentEvent.data.sent_count;
|
|
2653
|
+
updateData.sentAt = sentEvent.data.completed_at;
|
|
2654
|
+
updateData.sendingStartedAt = sentEvent.data.started_at;
|
|
2655
|
+
break;
|
|
2656
|
+
}
|
|
2657
|
+
case WEBHOOK_EVENT_TYPES.BROADCAST_FAILED: {
|
|
2658
|
+
const failedEvent = event;
|
|
2659
|
+
updateData.failureReason = failedEvent.data.error;
|
|
2660
|
+
updateData.failedAt = failedEvent.data.failed_at;
|
|
2661
|
+
break;
|
|
2662
|
+
}
|
|
2663
|
+
case WEBHOOK_EVENT_TYPES.BROADCAST_PARTIAL_FAILURE: {
|
|
2664
|
+
const partialFailureEvent = event;
|
|
2665
|
+
updateData.sentCount = partialFailureEvent.data.sent_count;
|
|
2666
|
+
updateData.failedCount = partialFailureEvent.data.failed_count;
|
|
2667
|
+
updateData.totalCount = partialFailureEvent.data.total_count;
|
|
2668
|
+
updateData.hasWarnings = true;
|
|
2669
|
+
break;
|
|
2670
|
+
}
|
|
2671
|
+
case WEBHOOK_EVENT_TYPES.BROADCAST_ABORTED: {
|
|
2672
|
+
const abortedEvent = event;
|
|
2673
|
+
updateData.abortedAt = abortedEvent.data.aborted_at;
|
|
2674
|
+
updateData.abortReason = abortedEvent.data.reason;
|
|
2675
|
+
break;
|
|
2676
|
+
}
|
|
2677
|
+
case WEBHOOK_EVENT_TYPES.BROADCAST_PAUSED: {
|
|
2678
|
+
const pausedEvent = event;
|
|
2679
|
+
updateData.pausedAt = pausedEvent.data.paused_at;
|
|
2680
|
+
updateData.sentCount = pausedEvent.data.sent_count;
|
|
2681
|
+
updateData.remainingCount = pausedEvent.data.remaining_count;
|
|
2682
|
+
break;
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
await payload.update({
|
|
2686
|
+
collection: broadcastsSlug,
|
|
2687
|
+
id: broadcast.id,
|
|
2688
|
+
data: updateData
|
|
2689
|
+
});
|
|
2690
|
+
console.log("[Broadcast Handler] Updated broadcast status:", {
|
|
2691
|
+
broadcastId,
|
|
2692
|
+
status,
|
|
2693
|
+
event: event.type
|
|
2694
|
+
});
|
|
2695
|
+
} catch (error) {
|
|
2696
|
+
console.error("[Broadcast Handler] Error updating broadcast:", error);
|
|
2697
|
+
throw error;
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
// src/webhooks/router.ts
|
|
2702
|
+
async function routeWebhookEvent(event, req, config) {
|
|
2703
|
+
try {
|
|
2704
|
+
if (!isHandledEvent(event)) {
|
|
2705
|
+
console.log("[Webhook Router] Ignoring unhandled event type:", event.type);
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
const handledEvent = event;
|
|
2709
|
+
if (isSubscriberEvent(handledEvent)) {
|
|
2710
|
+
await handleSubscriberEvent(handledEvent, req, config);
|
|
2711
|
+
} else if (isBroadcastEvent(handledEvent)) {
|
|
2712
|
+
await handleBroadcastEvent(handledEvent, req, config);
|
|
2713
|
+
} else {
|
|
2714
|
+
console.warn("[Webhook Router] Event type not routed:", event.type);
|
|
2715
|
+
}
|
|
2716
|
+
} catch (error) {
|
|
2717
|
+
console.error("[Webhook Router] Error routing event:", {
|
|
2718
|
+
type: event.type,
|
|
2719
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
2720
|
+
});
|
|
2721
|
+
throw error;
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
// src/endpoints/webhooks/broadcast.ts
|
|
2726
|
+
var createBroadcastWebhookEndpoint = (config) => {
|
|
2727
|
+
return {
|
|
2728
|
+
path: "/webhooks/broadcast",
|
|
2729
|
+
method: "post",
|
|
2730
|
+
handler: async (req) => {
|
|
2731
|
+
try {
|
|
2732
|
+
const settings = await req.payload.findGlobal({
|
|
2733
|
+
slug: config.settingsSlug || "newsletter-settings"
|
|
2734
|
+
});
|
|
2735
|
+
const webhookSecret = settings?.broadcastSettings?.webhookSecret;
|
|
2736
|
+
if (!webhookSecret) {
|
|
2737
|
+
console.error("[Broadcast Webhook] No webhook secret configured");
|
|
2738
|
+
return Response.json({ error: "Webhook not configured" }, { status: 401 });
|
|
2739
|
+
}
|
|
2740
|
+
const headers = req.headers;
|
|
2741
|
+
const signature = headers.get("x-broadcast-signature");
|
|
2742
|
+
const timestamp = headers.get("x-broadcast-timestamp");
|
|
2743
|
+
if (!signature || !timestamp) {
|
|
2744
|
+
console.error("[Broadcast Webhook] Missing signature or timestamp");
|
|
2745
|
+
return Response.json({ error: "Invalid request" }, { status: 401 });
|
|
2746
|
+
}
|
|
2747
|
+
let rawBodyString;
|
|
2748
|
+
let rawBody;
|
|
2749
|
+
if (typeof req.json === "function") {
|
|
2750
|
+
rawBody = await req.json();
|
|
2751
|
+
rawBodyString = JSON.stringify(rawBody);
|
|
2752
|
+
} else {
|
|
2753
|
+
console.error("[Broadcast Webhook] Request does not support json() method");
|
|
2754
|
+
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
2755
|
+
}
|
|
2756
|
+
const isValid = verifyBroadcastWebhookSignature(
|
|
2757
|
+
rawBodyString,
|
|
2758
|
+
signature,
|
|
2759
|
+
timestamp,
|
|
2760
|
+
webhookSecret
|
|
2761
|
+
);
|
|
2762
|
+
if (!isValid) {
|
|
2763
|
+
console.error("[Broadcast Webhook] Invalid signature");
|
|
2764
|
+
return Response.json({ error: "Invalid signature" }, { status: 401 });
|
|
2765
|
+
}
|
|
2766
|
+
const data = rawBody;
|
|
2767
|
+
console.log("[Broadcast Webhook] Verified event:", {
|
|
2768
|
+
type: data.type,
|
|
2769
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2770
|
+
});
|
|
2771
|
+
await routeWebhookEvent(data, req, config);
|
|
2772
|
+
await req.payload.updateGlobal({
|
|
2773
|
+
slug: config.settingsSlug || "newsletter-settings",
|
|
2774
|
+
data: {
|
|
2775
|
+
broadcastSettings: {
|
|
2776
|
+
...settings?.broadcastSettings || {},
|
|
2777
|
+
lastWebhookReceived: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2778
|
+
webhookStatus: "verified"
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
});
|
|
2782
|
+
return Response.json({ success: true }, { status: 200 });
|
|
2783
|
+
} catch (error) {
|
|
2784
|
+
console.error("[Broadcast Webhook] Error processing webhook:", error);
|
|
2785
|
+
return Response.json({ success: false }, { status: 200 });
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
};
|
|
2789
|
+
};
|
|
2790
|
+
|
|
2791
|
+
// src/endpoints/webhooks/verify.ts
|
|
2792
|
+
var createWebhookVerifyEndpoint = (config) => {
|
|
2793
|
+
return {
|
|
2794
|
+
path: "/webhooks/verify",
|
|
2795
|
+
method: "post",
|
|
2796
|
+
handler: async (req) => {
|
|
2797
|
+
try {
|
|
2798
|
+
const settings = await req.payload.findGlobal({
|
|
2799
|
+
slug: config.settingsSlug || "newsletter-settings"
|
|
2800
|
+
});
|
|
2801
|
+
if (!settings?.broadcastSettings?.webhookSecret) {
|
|
2802
|
+
return Response.json({
|
|
2803
|
+
success: false,
|
|
2804
|
+
error: "Webhook secret not configured"
|
|
2805
|
+
}, { status: 400 });
|
|
2806
|
+
}
|
|
2807
|
+
const apiUrl = settings.broadcastSettings.apiUrl;
|
|
2808
|
+
const token = settings.broadcastSettings.token;
|
|
2809
|
+
if (!apiUrl || !token) {
|
|
2810
|
+
return Response.json({
|
|
2811
|
+
success: false,
|
|
2812
|
+
error: "Broadcast API not configured"
|
|
2813
|
+
}, { status: 400 });
|
|
2814
|
+
}
|
|
2815
|
+
await req.payload.updateGlobal({
|
|
2816
|
+
slug: config.settingsSlug || "newsletter-settings",
|
|
2817
|
+
data: {
|
|
2818
|
+
broadcastSettings: {
|
|
2819
|
+
...settings.broadcastSettings,
|
|
2820
|
+
webhookStatus: "configured"
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
});
|
|
2824
|
+
return Response.json({
|
|
2825
|
+
success: true,
|
|
2826
|
+
message: "Webhook configuration verified. Please configure the webhook in Broadcast."
|
|
2827
|
+
});
|
|
2828
|
+
} catch (error) {
|
|
2829
|
+
console.error("[Webhook Verify] Error:", error);
|
|
2830
|
+
return Response.json({
|
|
2831
|
+
success: false,
|
|
2832
|
+
error: "Failed to verify webhook configuration"
|
|
2833
|
+
}, { status: 500 });
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
};
|
|
2837
|
+
};
|
|
2838
|
+
|
|
2335
2839
|
// src/endpoints/index.ts
|
|
2336
2840
|
function createNewsletterEndpoints(config) {
|
|
2337
2841
|
const endpoints = [
|
|
@@ -2349,6 +2853,12 @@ function createNewsletterEndpoints(config) {
|
|
|
2349
2853
|
);
|
|
2350
2854
|
}
|
|
2351
2855
|
endpoints.push(...createBroadcastManagementEndpoints(config));
|
|
2856
|
+
if (config.providers?.broadcast) {
|
|
2857
|
+
endpoints.push(
|
|
2858
|
+
createBroadcastWebhookEndpoint(config),
|
|
2859
|
+
createWebhookVerifyEndpoint(config)
|
|
2860
|
+
);
|
|
2861
|
+
}
|
|
2352
2862
|
return endpoints;
|
|
2353
2863
|
}
|
|
2354
2864
|
|
|
@@ -2521,111 +3031,6 @@ function createMarkdownFieldInternal(config) {
|
|
|
2521
3031
|
};
|
|
2522
3032
|
}
|
|
2523
3033
|
|
|
2524
|
-
// src/jobs/sync-unsubscribes.ts
|
|
2525
|
-
var createUnsubscribeSyncJob = (pluginConfig) => {
|
|
2526
|
-
return {
|
|
2527
|
-
slug: "sync-unsubscribes",
|
|
2528
|
-
label: "Sync Unsubscribes from Email Service",
|
|
2529
|
-
handler: async ({ req }) => {
|
|
2530
|
-
const subscribersSlug = pluginConfig.subscribersSlug || "subscribers";
|
|
2531
|
-
const emailService = req.payload.newsletterEmailService;
|
|
2532
|
-
if (!emailService) {
|
|
2533
|
-
console.error("Email service not configured");
|
|
2534
|
-
return {
|
|
2535
|
-
output: {
|
|
2536
|
-
syncedCount: 0
|
|
2537
|
-
}
|
|
2538
|
-
};
|
|
2539
|
-
}
|
|
2540
|
-
let syncedCount = 0;
|
|
2541
|
-
try {
|
|
2542
|
-
if (emailService.getProvider() === "broadcast") {
|
|
2543
|
-
console.warn("Starting Broadcast unsubscribe sync...");
|
|
2544
|
-
const broadcastConfig = pluginConfig.providers?.broadcast;
|
|
2545
|
-
if (!broadcastConfig) {
|
|
2546
|
-
throw new Error("Broadcast configuration not found");
|
|
2547
|
-
}
|
|
2548
|
-
const apiUrl = broadcastConfig.apiUrl.replace(/\/$/, "");
|
|
2549
|
-
const token = broadcastConfig.token;
|
|
2550
|
-
let page = 1;
|
|
2551
|
-
let hasMore = true;
|
|
2552
|
-
while (hasMore) {
|
|
2553
|
-
const response = await fetch(
|
|
2554
|
-
`${apiUrl}/api/v1/subscribers.json?page=${page}`,
|
|
2555
|
-
{
|
|
2556
|
-
headers: {
|
|
2557
|
-
"Authorization": `Bearer ${token}`
|
|
2558
|
-
}
|
|
2559
|
-
}
|
|
2560
|
-
);
|
|
2561
|
-
if (!response.ok) {
|
|
2562
|
-
throw new Error(`Broadcast API error: ${response.status}`);
|
|
2563
|
-
}
|
|
2564
|
-
const data = await response.json();
|
|
2565
|
-
const broadcastSubscribers = data.subscribers || [];
|
|
2566
|
-
for (const broadcastSub of broadcastSubscribers) {
|
|
2567
|
-
const payloadSubscribers = await req.payload.find({
|
|
2568
|
-
collection: subscribersSlug,
|
|
2569
|
-
where: {
|
|
2570
|
-
email: {
|
|
2571
|
-
equals: broadcastSub.email
|
|
2572
|
-
}
|
|
2573
|
-
},
|
|
2574
|
-
limit: 1
|
|
2575
|
-
});
|
|
2576
|
-
if (payloadSubscribers.docs.length > 0) {
|
|
2577
|
-
const payloadSub = payloadSubscribers.docs[0];
|
|
2578
|
-
const broadcastUnsubscribed = !broadcastSub.is_active || broadcastSub.unsubscribed_at;
|
|
2579
|
-
const payloadUnsubscribed = payloadSub.subscriptionStatus === "unsubscribed";
|
|
2580
|
-
if (broadcastUnsubscribed && !payloadUnsubscribed) {
|
|
2581
|
-
await req.payload.update({
|
|
2582
|
-
collection: subscribersSlug,
|
|
2583
|
-
id: payloadSub.id,
|
|
2584
|
-
data: {
|
|
2585
|
-
subscriptionStatus: "unsubscribed",
|
|
2586
|
-
unsubscribedAt: broadcastSub.unsubscribed_at || (/* @__PURE__ */ new Date()).toISOString()
|
|
2587
|
-
}
|
|
2588
|
-
});
|
|
2589
|
-
syncedCount++;
|
|
2590
|
-
console.warn(`Unsubscribed: ${broadcastSub.email}`);
|
|
2591
|
-
}
|
|
2592
|
-
}
|
|
2593
|
-
}
|
|
2594
|
-
if (data.pagination && data.pagination.current < data.pagination.total_pages) {
|
|
2595
|
-
page++;
|
|
2596
|
-
} else {
|
|
2597
|
-
hasMore = false;
|
|
2598
|
-
}
|
|
2599
|
-
}
|
|
2600
|
-
console.warn(`Broadcast sync complete. Unsubscribed ${syncedCount} contacts.`);
|
|
2601
|
-
}
|
|
2602
|
-
if (emailService.getProvider() === "resend") {
|
|
2603
|
-
console.warn("Starting Resend unsubscribe sync...");
|
|
2604
|
-
const resendConfig = pluginConfig.providers?.resend;
|
|
2605
|
-
if (!resendConfig) {
|
|
2606
|
-
throw new Error("Resend configuration not found");
|
|
2607
|
-
}
|
|
2608
|
-
console.warn("Resend polling implementation needed - webhooks recommended");
|
|
2609
|
-
}
|
|
2610
|
-
if (pluginConfig.hooks?.afterUnsubscribeSync) {
|
|
2611
|
-
await pluginConfig.hooks.afterUnsubscribeSync({
|
|
2612
|
-
req,
|
|
2613
|
-
syncedCount
|
|
2614
|
-
});
|
|
2615
|
-
}
|
|
2616
|
-
} catch (error) {
|
|
2617
|
-
console.error("Unsubscribe sync error:", error);
|
|
2618
|
-
throw error;
|
|
2619
|
-
}
|
|
2620
|
-
return {
|
|
2621
|
-
output: {
|
|
2622
|
-
syncedCount
|
|
2623
|
-
}
|
|
2624
|
-
};
|
|
2625
|
-
}
|
|
2626
|
-
};
|
|
2627
|
-
};
|
|
2628
|
-
|
|
2629
3034
|
// src/fields/emailContent.ts
|
|
2630
3035
|
import {
|
|
2631
3036
|
BoldFeature,
|
|
@@ -3055,7 +3460,8 @@ async function convertToEmailSafeHtml(editorState, options) {
|
|
|
3055
3460
|
if (options.customWrapper) {
|
|
3056
3461
|
return await Promise.resolve(options.customWrapper(sanitizedHtml, {
|
|
3057
3462
|
preheader: options.preheader,
|
|
3058
|
-
subject: options.subject
|
|
3463
|
+
subject: options.subject,
|
|
3464
|
+
documentData: options.documentData
|
|
3059
3465
|
}));
|
|
3060
3466
|
}
|
|
3061
3467
|
return wrapInEmailTemplate(sanitizedHtml, options.preheader);
|
|
@@ -3877,7 +4283,7 @@ var createBroadcastPreviewEndpoint = (config, _collectionSlug) => {
|
|
|
3877
4283
|
handler: async (req) => {
|
|
3878
4284
|
try {
|
|
3879
4285
|
const data = await (req.json?.() || Promise.resolve({}));
|
|
3880
|
-
const { content, preheader, subject } = data;
|
|
4286
|
+
const { content, preheader, subject, documentData } = data;
|
|
3881
4287
|
if (!content) {
|
|
3882
4288
|
return Response.json({
|
|
3883
4289
|
success: false,
|
|
@@ -3893,6 +4299,8 @@ var createBroadcastPreviewEndpoint = (config, _collectionSlug) => {
|
|
|
3893
4299
|
preheader,
|
|
3894
4300
|
subject,
|
|
3895
4301
|
mediaUrl,
|
|
4302
|
+
documentData,
|
|
4303
|
+
// Pass all document data
|
|
3896
4304
|
customBlockConverter: config.customizations?.broadcasts?.customBlockConverter,
|
|
3897
4305
|
customWrapper: emailPreviewConfig?.customWrapper
|
|
3898
4306
|
});
|
|
@@ -4162,6 +4570,14 @@ var createBroadcastsCollection = (pluginConfig) => {
|
|
|
4162
4570
|
condition: (data) => hasProviders && data?.providerId
|
|
4163
4571
|
}
|
|
4164
4572
|
},
|
|
4573
|
+
{
|
|
4574
|
+
name: "externalId",
|
|
4575
|
+
type: "text",
|
|
4576
|
+
admin: {
|
|
4577
|
+
readOnly: true,
|
|
4578
|
+
description: "External ID for webhook integration"
|
|
4579
|
+
}
|
|
4580
|
+
},
|
|
4165
4581
|
{
|
|
4166
4582
|
name: "providerData",
|
|
4167
4583
|
type: "json",
|
|
@@ -4170,6 +4586,77 @@ var createBroadcastsCollection = (pluginConfig) => {
|
|
|
4170
4586
|
condition: () => false
|
|
4171
4587
|
// Hidden by default
|
|
4172
4588
|
}
|
|
4589
|
+
},
|
|
4590
|
+
// Webhook tracking fields
|
|
4591
|
+
{
|
|
4592
|
+
name: "webhookData",
|
|
4593
|
+
type: "group",
|
|
4594
|
+
label: "Webhook Data",
|
|
4595
|
+
admin: {
|
|
4596
|
+
condition: () => false
|
|
4597
|
+
// Hidden by default, used for webhook tracking
|
|
4598
|
+
},
|
|
4599
|
+
fields: [
|
|
4600
|
+
{
|
|
4601
|
+
name: "lastWebhookEvent",
|
|
4602
|
+
type: "text",
|
|
4603
|
+
admin: {
|
|
4604
|
+
readOnly: true
|
|
4605
|
+
}
|
|
4606
|
+
},
|
|
4607
|
+
{
|
|
4608
|
+
name: "lastWebhookEventAt",
|
|
4609
|
+
type: "date",
|
|
4610
|
+
admin: {
|
|
4611
|
+
readOnly: true
|
|
4612
|
+
}
|
|
4613
|
+
},
|
|
4614
|
+
{
|
|
4615
|
+
name: "hasWarnings",
|
|
4616
|
+
type: "checkbox",
|
|
4617
|
+
defaultValue: false
|
|
4618
|
+
},
|
|
4619
|
+
{
|
|
4620
|
+
name: "failureReason",
|
|
4621
|
+
type: "text"
|
|
4622
|
+
},
|
|
4623
|
+
{
|
|
4624
|
+
name: "sentCount",
|
|
4625
|
+
type: "number"
|
|
4626
|
+
},
|
|
4627
|
+
{
|
|
4628
|
+
name: "totalCount",
|
|
4629
|
+
type: "number"
|
|
4630
|
+
},
|
|
4631
|
+
{
|
|
4632
|
+
name: "failedCount",
|
|
4633
|
+
type: "number"
|
|
4634
|
+
},
|
|
4635
|
+
{
|
|
4636
|
+
name: "remainingCount",
|
|
4637
|
+
type: "number"
|
|
4638
|
+
},
|
|
4639
|
+
{
|
|
4640
|
+
name: "sendingStartedAt",
|
|
4641
|
+
type: "date"
|
|
4642
|
+
},
|
|
4643
|
+
{
|
|
4644
|
+
name: "failedAt",
|
|
4645
|
+
type: "date"
|
|
4646
|
+
},
|
|
4647
|
+
{
|
|
4648
|
+
name: "abortedAt",
|
|
4649
|
+
type: "date"
|
|
4650
|
+
},
|
|
4651
|
+
{
|
|
4652
|
+
name: "abortReason",
|
|
4653
|
+
type: "text"
|
|
4654
|
+
},
|
|
4655
|
+
{
|
|
4656
|
+
name: "pausedAt",
|
|
4657
|
+
type: "date"
|
|
4658
|
+
}
|
|
4659
|
+
]
|
|
4173
4660
|
}
|
|
4174
4661
|
],
|
|
4175
4662
|
hooks: {
|
|
@@ -4192,7 +4679,14 @@ var createBroadcastsCollection = (pluginConfig) => {
|
|
|
4192
4679
|
const provider = new BroadcastApiProvider2(providerConfig);
|
|
4193
4680
|
req.payload.logger.info("Populating media fields and converting content to HTML...");
|
|
4194
4681
|
const populatedContent = await populateMediaFields(doc.contentSection?.content, req.payload, pluginConfig);
|
|
4682
|
+
const emailPreviewConfig = pluginConfig.customizations?.broadcasts?.emailPreview;
|
|
4195
4683
|
const htmlContent = await convertToEmailSafeHtml(populatedContent, {
|
|
4684
|
+
wrapInTemplate: emailPreviewConfig?.wrapInTemplate ?? true,
|
|
4685
|
+
customWrapper: emailPreviewConfig?.customWrapper,
|
|
4686
|
+
preheader: doc.contentSection?.preheader,
|
|
4687
|
+
subject: doc.subject,
|
|
4688
|
+
documentData: doc,
|
|
4689
|
+
// Pass entire document
|
|
4196
4690
|
customBlockConverter: pluginConfig.customizations?.broadcasts?.customBlockConverter
|
|
4197
4691
|
});
|
|
4198
4692
|
if (!htmlContent || htmlContent.trim() === "") {
|
|
@@ -4292,7 +4786,14 @@ var createBroadcastsCollection = (pluginConfig) => {
|
|
|
4292
4786
|
}
|
|
4293
4787
|
req.payload.logger.info("Creating broadcast in provider (deferred from initial create)...");
|
|
4294
4788
|
const populatedContent = await populateMediaFields(doc.contentSection?.content, req.payload, pluginConfig);
|
|
4789
|
+
const emailPreviewConfig = pluginConfig.customizations?.broadcasts?.emailPreview;
|
|
4295
4790
|
const htmlContent = await convertToEmailSafeHtml(populatedContent, {
|
|
4791
|
+
wrapInTemplate: emailPreviewConfig?.wrapInTemplate ?? true,
|
|
4792
|
+
customWrapper: emailPreviewConfig?.customWrapper,
|
|
4793
|
+
preheader: doc.contentSection?.preheader,
|
|
4794
|
+
subject: doc.subject,
|
|
4795
|
+
documentData: doc,
|
|
4796
|
+
// Pass entire document
|
|
4296
4797
|
customBlockConverter: pluginConfig.customizations?.broadcasts?.customBlockConverter
|
|
4297
4798
|
});
|
|
4298
4799
|
if (!htmlContent || htmlContent.trim() === "") {
|
|
@@ -4354,7 +4855,14 @@ var createBroadcastsCollection = (pluginConfig) => {
|
|
|
4354
4855
|
}
|
|
4355
4856
|
if (JSON.stringify(doc.contentSection?.content) !== JSON.stringify(previousDoc?.contentSection?.content)) {
|
|
4356
4857
|
const populatedContent = await populateMediaFields(doc.contentSection?.content, req.payload, pluginConfig);
|
|
4858
|
+
const emailPreviewConfig = pluginConfig.customizations?.broadcasts?.emailPreview;
|
|
4357
4859
|
updates.content = await convertToEmailSafeHtml(populatedContent, {
|
|
4860
|
+
wrapInTemplate: emailPreviewConfig?.wrapInTemplate ?? true,
|
|
4861
|
+
customWrapper: emailPreviewConfig?.customWrapper,
|
|
4862
|
+
preheader: doc.contentSection?.preheader,
|
|
4863
|
+
subject: doc.subject,
|
|
4864
|
+
documentData: doc,
|
|
4865
|
+
// Pass entire document
|
|
4358
4866
|
customBlockConverter: pluginConfig.customizations?.broadcasts?.customBlockConverter
|
|
4359
4867
|
});
|
|
4360
4868
|
}
|
|
@@ -4772,7 +5280,6 @@ var newsletterPlugin = (pluginConfig) => (incomingConfig) => {
|
|
|
4772
5280
|
});
|
|
4773
5281
|
}
|
|
4774
5282
|
const endpoints = createNewsletterEndpoints(config);
|
|
4775
|
-
const syncJob = config.features?.unsubscribeSync?.enabled ? createUnsubscribeSyncJob(config) : null;
|
|
4776
5283
|
const modifiedConfig = {
|
|
4777
5284
|
...incomingConfig,
|
|
4778
5285
|
collections,
|
|
@@ -4784,31 +5291,7 @@ var newsletterPlugin = (pluginConfig) => (incomingConfig) => {
|
|
|
4784
5291
|
...incomingConfig.endpoints || [],
|
|
4785
5292
|
...endpoints
|
|
4786
5293
|
],
|
|
4787
|
-
jobs:
|
|
4788
|
-
...incomingConfig.jobs || {},
|
|
4789
|
-
tasks: [
|
|
4790
|
-
...incomingConfig.jobs?.tasks || [],
|
|
4791
|
-
syncJob
|
|
4792
|
-
],
|
|
4793
|
-
// Add cron schedule if specified
|
|
4794
|
-
autoRun: config.features?.unsubscribeSync?.schedule ? Array.isArray(incomingConfig.jobs?.autoRun) ? [...incomingConfig.jobs.autoRun, {
|
|
4795
|
-
cron: config.features.unsubscribeSync.schedule,
|
|
4796
|
-
queue: "newsletter-sync",
|
|
4797
|
-
limit: 100
|
|
4798
|
-
}] : typeof incomingConfig.jobs?.autoRun === "function" ? async (payload) => {
|
|
4799
|
-
const autoRunFn = incomingConfig.jobs.autoRun;
|
|
4800
|
-
const existingConfigs = await autoRunFn(payload);
|
|
4801
|
-
return [...existingConfigs, {
|
|
4802
|
-
cron: config.features.unsubscribeSync.schedule,
|
|
4803
|
-
queue: "newsletter-sync",
|
|
4804
|
-
limit: 100
|
|
4805
|
-
}];
|
|
4806
|
-
} : [{
|
|
4807
|
-
cron: config.features.unsubscribeSync.schedule,
|
|
4808
|
-
queue: "newsletter-sync",
|
|
4809
|
-
limit: 100
|
|
4810
|
-
}] : incomingConfig.jobs?.autoRun
|
|
4811
|
-
} : incomingConfig.jobs,
|
|
5294
|
+
jobs: incomingConfig.jobs,
|
|
4812
5295
|
onInit: async (payload) => {
|
|
4813
5296
|
try {
|
|
4814
5297
|
const settings = await payload.findGlobal({
|