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 +27 -0
- package/README.md +35 -18
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +146 -1
- package/dist/collections.cjs +105 -0
- package/dist/collections.cjs.map +1 -1
- package/dist/collections.js +105 -0
- package/dist/collections.js.map +1 -1
- package/dist/server.d.ts +0 -25
- package/dist/server.js +590 -131
- package/dist/types.d.cts +1 -26
- package/dist/types.d.ts +1 -26
- 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,
|
|
@@ -4165,6 +4570,14 @@ var createBroadcastsCollection = (pluginConfig) => {
|
|
|
4165
4570
|
condition: (data) => hasProviders && data?.providerId
|
|
4166
4571
|
}
|
|
4167
4572
|
},
|
|
4573
|
+
{
|
|
4574
|
+
name: "externalId",
|
|
4575
|
+
type: "text",
|
|
4576
|
+
admin: {
|
|
4577
|
+
readOnly: true,
|
|
4578
|
+
description: "External ID for webhook integration"
|
|
4579
|
+
}
|
|
4580
|
+
},
|
|
4168
4581
|
{
|
|
4169
4582
|
name: "providerData",
|
|
4170
4583
|
type: "json",
|
|
@@ -4173,6 +4586,77 @@ var createBroadcastsCollection = (pluginConfig) => {
|
|
|
4173
4586
|
condition: () => false
|
|
4174
4587
|
// Hidden by default
|
|
4175
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
|
+
]
|
|
4176
4660
|
}
|
|
4177
4661
|
],
|
|
4178
4662
|
hooks: {
|
|
@@ -4796,7 +5280,6 @@ var newsletterPlugin = (pluginConfig) => (incomingConfig) => {
|
|
|
4796
5280
|
});
|
|
4797
5281
|
}
|
|
4798
5282
|
const endpoints = createNewsletterEndpoints(config);
|
|
4799
|
-
const syncJob = config.features?.unsubscribeSync?.enabled ? createUnsubscribeSyncJob(config) : null;
|
|
4800
5283
|
const modifiedConfig = {
|
|
4801
5284
|
...incomingConfig,
|
|
4802
5285
|
collections,
|
|
@@ -4808,31 +5291,7 @@ var newsletterPlugin = (pluginConfig) => (incomingConfig) => {
|
|
|
4808
5291
|
...incomingConfig.endpoints || [],
|
|
4809
5292
|
...endpoints
|
|
4810
5293
|
],
|
|
4811
|
-
jobs:
|
|
4812
|
-
...incomingConfig.jobs || {},
|
|
4813
|
-
tasks: [
|
|
4814
|
-
...incomingConfig.jobs?.tasks || [],
|
|
4815
|
-
syncJob
|
|
4816
|
-
],
|
|
4817
|
-
// Add cron schedule if specified
|
|
4818
|
-
autoRun: config.features?.unsubscribeSync?.schedule ? Array.isArray(incomingConfig.jobs?.autoRun) ? [...incomingConfig.jobs.autoRun, {
|
|
4819
|
-
cron: config.features.unsubscribeSync.schedule,
|
|
4820
|
-
queue: "newsletter-sync",
|
|
4821
|
-
limit: 100
|
|
4822
|
-
}] : typeof incomingConfig.jobs?.autoRun === "function" ? async (payload) => {
|
|
4823
|
-
const autoRunFn = incomingConfig.jobs.autoRun;
|
|
4824
|
-
const existingConfigs = await autoRunFn(payload);
|
|
4825
|
-
return [...existingConfigs, {
|
|
4826
|
-
cron: config.features.unsubscribeSync.schedule,
|
|
4827
|
-
queue: "newsletter-sync",
|
|
4828
|
-
limit: 100
|
|
4829
|
-
}];
|
|
4830
|
-
} : [{
|
|
4831
|
-
cron: config.features.unsubscribeSync.schedule,
|
|
4832
|
-
queue: "newsletter-sync",
|
|
4833
|
-
limit: 100
|
|
4834
|
-
}] : incomingConfig.jobs?.autoRun
|
|
4835
|
-
} : incomingConfig.jobs,
|
|
5294
|
+
jobs: incomingConfig.jobs,
|
|
4836
5295
|
onInit: async (payload) => {
|
|
4837
5296
|
try {
|
|
4838
5297
|
const settings = await payload.findGlobal({
|