payload-plugin-newsletter 0.17.1 → 0.17.3

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/dist/index.js CHANGED
@@ -2856,270 +2856,677 @@ var createSignoutEndpoint = (_config) => {
2856
2856
  };
2857
2857
  };
2858
2858
 
2859
- // src/endpoints/broadcasts/send.ts
2860
- init_types();
2859
+ // src/endpoints/broadcasts/index.ts
2860
+ var createBroadcastManagementEndpoints = (config) => {
2861
+ return [];
2862
+ };
2861
2863
 
2862
- // src/utils/auth.ts
2863
- async function getAuthenticatedUser(req) {
2864
- try {
2865
- const me = await req.payload.find({
2866
- collection: "users",
2867
- where: {
2868
- id: {
2869
- equals: "me"
2870
- // Special value in Payload to get current user
2871
- }
2872
- },
2873
- limit: 1,
2874
- depth: 0
2875
- });
2876
- return me.docs[0] || null;
2877
- } catch {
2878
- return null;
2879
- }
2880
- }
2881
- async function requireAdmin(req, config) {
2882
- const user = await getAuthenticatedUser(req);
2883
- if (!user) {
2884
- return {
2885
- authorized: false,
2886
- error: "Authentication required"
2887
- };
2888
- }
2889
- if (!isAdmin(user, config)) {
2890
- return {
2891
- authorized: false,
2892
- error: "Admin access required"
2893
- };
2864
+ // src/endpoints/index.ts
2865
+ function createNewsletterEndpoints(config) {
2866
+ const endpoints = [
2867
+ createSubscribeEndpoint(config),
2868
+ createUnsubscribeEndpoint(config)
2869
+ ];
2870
+ if (config.auth?.enabled !== false) {
2871
+ endpoints.push(
2872
+ createVerifyMagicLinkEndpoint(config),
2873
+ createPreferencesEndpoint(config),
2874
+ createUpdatePreferencesEndpoint(config),
2875
+ createSigninEndpoint(config),
2876
+ createMeEndpoint(config),
2877
+ createSignoutEndpoint(config)
2878
+ );
2894
2879
  }
2895
- return {
2896
- authorized: true,
2897
- user
2898
- };
2880
+ endpoints.push(...createBroadcastManagementEndpoints(config));
2881
+ return endpoints;
2899
2882
  }
2900
2883
 
2901
- // src/utils/getBroadcastConfig.ts
2902
- async function getBroadcastConfig(req, pluginConfig) {
2903
- try {
2904
- const settings = await req.payload.findGlobal({
2905
- slug: pluginConfig.settingsSlug || "newsletter-settings",
2906
- req
2907
- });
2908
- if (settings?.provider === "broadcast" && settings?.broadcastSettings) {
2909
- return {
2910
- apiUrl: settings.broadcastSettings.apiUrl || pluginConfig.providers?.broadcast?.apiUrl || "",
2911
- token: settings.broadcastSettings.token || pluginConfig.providers?.broadcast?.token || "",
2912
- fromAddress: settings.fromAddress || pluginConfig.providers?.broadcast?.fromAddress || "",
2913
- fromName: settings.fromName || pluginConfig.providers?.broadcast?.fromName || "",
2914
- replyTo: settings.replyTo || pluginConfig.providers?.broadcast?.replyTo
2915
- };
2884
+ // src/fields/newsletterScheduling.ts
2885
+ function createNewsletterSchedulingFields(config) {
2886
+ const groupName = config.features?.newsletterScheduling?.fields?.groupName || "newsletterScheduling";
2887
+ const contentField = config.features?.newsletterScheduling?.fields?.contentField || "content";
2888
+ const createMarkdownField = config.features?.newsletterScheduling?.fields?.createMarkdownField !== false;
2889
+ const fields = [
2890
+ {
2891
+ name: groupName,
2892
+ type: "group",
2893
+ label: "Newsletter Scheduling",
2894
+ admin: {
2895
+ condition: (data, { user }) => user?.collection === "users"
2896
+ // Only show for admin users
2897
+ },
2898
+ fields: [
2899
+ {
2900
+ name: "scheduled",
2901
+ type: "checkbox",
2902
+ label: "Schedule for Newsletter",
2903
+ defaultValue: false,
2904
+ admin: {
2905
+ description: "Schedule this content to be sent as a newsletter"
2906
+ }
2907
+ },
2908
+ {
2909
+ name: "scheduledDate",
2910
+ type: "date",
2911
+ label: "Send Date",
2912
+ required: true,
2913
+ admin: {
2914
+ date: {
2915
+ pickerAppearance: "dayAndTime"
2916
+ },
2917
+ condition: (data) => data?.[groupName]?.scheduled,
2918
+ description: "When to send this newsletter"
2919
+ }
2920
+ },
2921
+ {
2922
+ name: "sentDate",
2923
+ type: "date",
2924
+ label: "Sent Date",
2925
+ admin: {
2926
+ readOnly: true,
2927
+ condition: (data) => data?.[groupName]?.sendStatus === "sent",
2928
+ description: "When this newsletter was sent"
2929
+ }
2930
+ },
2931
+ {
2932
+ name: "sendStatus",
2933
+ type: "select",
2934
+ label: "Status",
2935
+ options: [
2936
+ { label: "Draft", value: "draft" },
2937
+ { label: "Scheduled", value: "scheduled" },
2938
+ { label: "Sending", value: "sending" },
2939
+ { label: "Sent", value: "sent" },
2940
+ { label: "Failed", value: "failed" }
2941
+ ],
2942
+ defaultValue: "draft",
2943
+ admin: {
2944
+ readOnly: true,
2945
+ description: "Current send status"
2946
+ }
2947
+ },
2948
+ {
2949
+ name: "emailSubject",
2950
+ type: "text",
2951
+ label: "Email Subject",
2952
+ required: true,
2953
+ admin: {
2954
+ condition: (data) => data?.[groupName]?.scheduled,
2955
+ description: "Subject line for the newsletter email"
2956
+ }
2957
+ },
2958
+ {
2959
+ name: "preheader",
2960
+ type: "text",
2961
+ label: "Email Preheader",
2962
+ admin: {
2963
+ condition: (data) => data?.[groupName]?.scheduled,
2964
+ description: "Preview text that appears after the subject line"
2965
+ }
2966
+ },
2967
+ {
2968
+ name: "segments",
2969
+ type: "select",
2970
+ label: "Target Segments",
2971
+ hasMany: true,
2972
+ options: [
2973
+ { label: "All Subscribers", value: "all" },
2974
+ ...config.i18n?.locales?.map((locale) => ({
2975
+ label: `${locale.toUpperCase()} Subscribers`,
2976
+ value: locale
2977
+ })) || []
2978
+ ],
2979
+ defaultValue: ["all"],
2980
+ admin: {
2981
+ condition: (data) => data?.[groupName]?.scheduled,
2982
+ description: "Which subscriber segments to send to"
2983
+ }
2984
+ },
2985
+ {
2986
+ name: "testEmails",
2987
+ type: "array",
2988
+ label: "Test Email Recipients",
2989
+ admin: {
2990
+ condition: (data) => data?.[groupName]?.scheduled && data?.[groupName]?.sendStatus === "draft",
2991
+ description: "Send test emails before scheduling"
2992
+ },
2993
+ fields: [
2994
+ {
2995
+ name: "email",
2996
+ type: "email",
2997
+ required: true
2998
+ }
2999
+ ]
3000
+ }
3001
+ ]
2916
3002
  }
2917
- return pluginConfig.providers?.broadcast || null;
2918
- } catch (error) {
2919
- req.payload.logger.error("Failed to get broadcast config from settings:", error);
2920
- return pluginConfig.providers?.broadcast || null;
3003
+ ];
3004
+ if (createMarkdownField) {
3005
+ fields.push(createMarkdownFieldInternal({
3006
+ name: `${contentField}Markdown`,
3007
+ richTextField: contentField,
3008
+ label: "Email Content (Markdown)",
3009
+ admin: {
3010
+ position: "sidebar",
3011
+ condition: (data) => Boolean(data?.[contentField] && data?.[groupName]?.scheduled),
3012
+ description: "Markdown version for email rendering",
3013
+ readOnly: true
3014
+ }
3015
+ }));
2921
3016
  }
3017
+ return fields;
2922
3018
  }
2923
-
2924
- // src/endpoints/broadcasts/send.ts
2925
- var createSendBroadcastEndpoint = (config, collectionSlug) => {
3019
+ function createMarkdownFieldInternal(config) {
2926
3020
  return {
2927
- path: `/${collectionSlug}/:id/send`,
2928
- method: "post",
2929
- handler: async (req) => {
2930
- try {
2931
- const auth = await requireAdmin(req, config);
2932
- if (!auth.authorized) {
2933
- return Response.json({
2934
- success: false,
2935
- error: auth.error
2936
- }, { status: 401 });
3021
+ name: config.name,
3022
+ type: "textarea",
3023
+ label: config.label || "Markdown",
3024
+ admin: {
3025
+ ...config.admin,
3026
+ description: config.admin?.description || "Auto-generated from rich text content"
3027
+ },
3028
+ hooks: {
3029
+ afterRead: [
3030
+ async ({ data }) => {
3031
+ if (data?.[config.richTextField]) {
3032
+ try {
3033
+ const { convertLexicalToMarkdown } = await import("@payloadcms/richtext-lexical");
3034
+ return convertLexicalToMarkdown({
3035
+ data: data[config.richTextField]
3036
+ });
3037
+ } catch {
3038
+ return "";
3039
+ }
3040
+ }
3041
+ return "";
2937
3042
  }
2938
- if (!config.features?.newsletterManagement?.enabled) {
2939
- return Response.json({
2940
- success: false,
2941
- error: "Broadcast management is not enabled"
2942
- }, { status: 400 });
3043
+ ],
3044
+ beforeChange: [
3045
+ () => {
3046
+ return null;
2943
3047
  }
2944
- const url = new URL(req.url || "", `http://localhost`);
2945
- const pathParts = url.pathname.split("/");
2946
- const id = pathParts[pathParts.length - 2];
2947
- if (!id) {
2948
- return Response.json({
2949
- success: false,
2950
- error: "Broadcast ID is required"
2951
- }, { status: 400 });
2952
- }
2953
- const data = await (req.json?.() || Promise.resolve({}));
2954
- const broadcastDoc = await req.payload.findByID({
2955
- collection: collectionSlug,
2956
- id,
2957
- user: auth.user
2958
- });
2959
- if (!broadcastDoc || !broadcastDoc.providerId) {
2960
- return Response.json({
2961
- success: false,
2962
- error: "Broadcast not found or not synced with provider"
2963
- }, { status: 404 });
2964
- }
2965
- const providerConfig = await getBroadcastConfig(req, config);
2966
- if (!providerConfig || !providerConfig.token) {
2967
- return Response.json({
2968
- success: false,
2969
- error: "Broadcast provider not configured in Newsletter Settings or environment variables"
2970
- }, { status: 500 });
2971
- }
2972
- const { BroadcastApiProvider: BroadcastApiProvider2 } = await Promise.resolve().then(() => (init_broadcast2(), broadcast_exports));
2973
- const provider = new BroadcastApiProvider2(providerConfig);
2974
- const broadcast = await provider.send(broadcastDoc.providerId, data);
2975
- await req.payload.update({
2976
- collection: collectionSlug,
2977
- id,
2978
- data: {
2979
- sendStatus: "sending" /* SENDING */,
2980
- sentAt: (/* @__PURE__ */ new Date()).toISOString()
2981
- },
2982
- user: auth.user
2983
- });
2984
- return Response.json({
2985
- success: true,
2986
- message: "Broadcast sent successfully",
2987
- broadcast
2988
- });
2989
- } catch (error) {
2990
- console.error("Failed to send broadcast:", error);
2991
- if (error instanceof NewsletterProviderError) {
2992
- return Response.json({
2993
- success: false,
2994
- error: error.message,
2995
- code: error.code
2996
- }, { status: error.code === "NOT_SUPPORTED" ? 501 : 500 });
2997
- }
2998
- return Response.json({
2999
- success: false,
3000
- error: "Failed to send broadcast"
3001
- }, { status: 500 });
3002
- }
3048
+ ]
3003
3049
  }
3004
3050
  };
3005
- };
3051
+ }
3006
3052
 
3007
- // src/endpoints/broadcasts/schedule.ts
3008
- init_types();
3009
- var createScheduleBroadcastEndpoint = (config, collectionSlug) => {
3053
+ // src/jobs/sync-unsubscribes.ts
3054
+ var createUnsubscribeSyncJob = (pluginConfig) => {
3010
3055
  return {
3011
- path: `/${collectionSlug}/:id/schedule`,
3012
- method: "post",
3013
- handler: async (req) => {
3056
+ slug: "sync-unsubscribes",
3057
+ label: "Sync Unsubscribes from Email Service",
3058
+ handler: async ({ req }) => {
3059
+ const subscribersSlug = pluginConfig.subscribersSlug || "subscribers";
3060
+ const emailService = req.payload.newsletterEmailService;
3061
+ if (!emailService) {
3062
+ console.error("Email service not configured");
3063
+ return {
3064
+ output: {
3065
+ syncedCount: 0
3066
+ }
3067
+ };
3068
+ }
3069
+ let syncedCount = 0;
3014
3070
  try {
3015
- const auth = await requireAdmin(req, config);
3016
- if (!auth.authorized) {
3017
- return Response.json({
3018
- success: false,
3019
- error: auth.error
3020
- }, { status: 401 });
3021
- }
3022
- if (!config.features?.newsletterManagement?.enabled) {
3023
- return Response.json({
3024
- success: false,
3025
- error: "Broadcast management is not enabled"
3026
- }, { status: 400 });
3027
- }
3028
- const url = new URL(req.url || "", `http://localhost`);
3029
- const pathParts = url.pathname.split("/");
3030
- const id = pathParts[pathParts.length - 2];
3031
- if (!id) {
3032
- return Response.json({
3033
- success: false,
3034
- error: "Broadcast ID is required"
3035
- }, { status: 400 });
3036
- }
3037
- const data = await (req.json?.() || Promise.resolve({}));
3038
- const { scheduledAt } = data;
3039
- if (!scheduledAt) {
3040
- return Response.json({
3041
- success: false,
3042
- error: "scheduledAt is required"
3043
- }, { status: 400 });
3044
- }
3045
- const scheduledDate = new Date(scheduledAt);
3046
- if (isNaN(scheduledDate.getTime())) {
3047
- return Response.json({
3048
- success: false,
3049
- error: "Invalid scheduledAt date"
3050
- }, { status: 400 });
3051
- }
3052
- if (scheduledDate <= /* @__PURE__ */ new Date()) {
3053
- return Response.json({
3054
- success: false,
3055
- error: "scheduledAt must be in the future"
3056
- }, { status: 400 });
3071
+ if (emailService.getProvider() === "broadcast") {
3072
+ console.warn("Starting Broadcast unsubscribe sync...");
3073
+ const broadcastConfig = pluginConfig.providers?.broadcast;
3074
+ if (!broadcastConfig) {
3075
+ throw new Error("Broadcast configuration not found");
3076
+ }
3077
+ const apiUrl = broadcastConfig.apiUrl.replace(/\/$/, "");
3078
+ const token = broadcastConfig.token;
3079
+ let page = 1;
3080
+ let hasMore = true;
3081
+ while (hasMore) {
3082
+ const response = await fetch(
3083
+ `${apiUrl}/api/v1/subscribers.json?page=${page}`,
3084
+ {
3085
+ headers: {
3086
+ "Authorization": `Bearer ${token}`
3087
+ }
3088
+ }
3089
+ );
3090
+ if (!response.ok) {
3091
+ throw new Error(`Broadcast API error: ${response.status}`);
3092
+ }
3093
+ const data = await response.json();
3094
+ const broadcastSubscribers = data.subscribers || [];
3095
+ for (const broadcastSub of broadcastSubscribers) {
3096
+ const payloadSubscribers = await req.payload.find({
3097
+ collection: subscribersSlug,
3098
+ where: {
3099
+ email: {
3100
+ equals: broadcastSub.email
3101
+ }
3102
+ },
3103
+ limit: 1
3104
+ });
3105
+ if (payloadSubscribers.docs.length > 0) {
3106
+ const payloadSub = payloadSubscribers.docs[0];
3107
+ const broadcastUnsubscribed = !broadcastSub.is_active || broadcastSub.unsubscribed_at;
3108
+ const payloadUnsubscribed = payloadSub.subscriptionStatus === "unsubscribed";
3109
+ if (broadcastUnsubscribed && !payloadUnsubscribed) {
3110
+ await req.payload.update({
3111
+ collection: subscribersSlug,
3112
+ id: payloadSub.id,
3113
+ data: {
3114
+ subscriptionStatus: "unsubscribed",
3115
+ unsubscribedAt: broadcastSub.unsubscribed_at || (/* @__PURE__ */ new Date()).toISOString()
3116
+ }
3117
+ });
3118
+ syncedCount++;
3119
+ console.warn(`Unsubscribed: ${broadcastSub.email}`);
3120
+ }
3121
+ }
3122
+ }
3123
+ if (data.pagination && data.pagination.current < data.pagination.total_pages) {
3124
+ page++;
3125
+ } else {
3126
+ hasMore = false;
3127
+ }
3128
+ }
3129
+ console.warn(`Broadcast sync complete. Unsubscribed ${syncedCount} contacts.`);
3057
3130
  }
3058
- const broadcastDoc = await req.payload.findByID({
3059
- collection: collectionSlug,
3060
- id,
3061
- user: auth.user
3062
- });
3063
- if (!broadcastDoc || !broadcastDoc.providerId) {
3064
- return Response.json({
3065
- success: false,
3066
- error: "Broadcast not found or not synced with provider"
3067
- }, { status: 404 });
3131
+ if (emailService.getProvider() === "resend") {
3132
+ console.warn("Starting Resend unsubscribe sync...");
3133
+ const resendConfig = pluginConfig.providers?.resend;
3134
+ if (!resendConfig) {
3135
+ throw new Error("Resend configuration not found");
3136
+ }
3137
+ console.warn("Resend polling implementation needed - webhooks recommended");
3068
3138
  }
3069
- const providerConfig = config.providers?.broadcast;
3070
- if (!providerConfig) {
3071
- return Response.json({
3072
- success: false,
3073
- error: "Broadcast provider not configured"
3074
- }, { status: 500 });
3139
+ if (pluginConfig.hooks?.afterUnsubscribeSync) {
3140
+ await pluginConfig.hooks.afterUnsubscribeSync({
3141
+ req,
3142
+ syncedCount
3143
+ });
3075
3144
  }
3076
- const { BroadcastApiProvider: BroadcastApiProvider2 } = await Promise.resolve().then(() => (init_broadcast2(), broadcast_exports));
3077
- const provider = new BroadcastApiProvider2(providerConfig);
3078
- const broadcast = await provider.schedule(broadcastDoc.providerId, scheduledDate);
3079
- await req.payload.update({
3080
- collection: collectionSlug,
3081
- id,
3082
- data: {
3083
- sendStatus: "scheduled" /* SCHEDULED */,
3084
- scheduledAt: scheduledDate.toISOString()
3085
- },
3086
- user: auth.user
3087
- });
3088
- return Response.json({
3089
- success: true,
3090
- message: `Broadcast scheduled for ${scheduledDate.toISOString()}`,
3091
- broadcast
3092
- });
3093
3145
  } catch (error) {
3094
- console.error("Failed to schedule broadcast:", error);
3095
- if (error instanceof NewsletterProviderError) {
3096
- return Response.json({
3097
- success: false,
3098
- error: error.message,
3099
- code: error.code
3100
- }, { status: error.code === "NOT_SUPPORTED" ? 501 : 500 });
3101
- }
3102
- return Response.json({
3103
- success: false,
3104
- error: "Failed to schedule broadcast"
3105
- }, { status: 500 });
3146
+ console.error("Unsubscribe sync error:", error);
3147
+ throw error;
3106
3148
  }
3149
+ return {
3150
+ output: {
3151
+ syncedCount
3152
+ }
3153
+ };
3107
3154
  }
3108
3155
  };
3109
3156
  };
3110
3157
 
3111
- // src/utils/emailSafeHtml.ts
3112
- import DOMPurify2 from "isomorphic-dompurify";
3113
- var EMAIL_SAFE_CONFIG = {
3114
- ALLOWED_TAGS: [
3115
- "p",
3116
- "br",
3117
- "strong",
3118
- "b",
3119
- "em",
3120
- "i",
3121
- "u",
3122
- "strike",
3158
+ // src/collections/Broadcasts.ts
3159
+ init_types();
3160
+
3161
+ // src/fields/emailContent.ts
3162
+ import {
3163
+ BoldFeature,
3164
+ ItalicFeature,
3165
+ UnderlineFeature,
3166
+ StrikethroughFeature,
3167
+ LinkFeature,
3168
+ OrderedListFeature,
3169
+ UnorderedListFeature,
3170
+ HeadingFeature,
3171
+ ParagraphFeature,
3172
+ AlignFeature,
3173
+ BlockquoteFeature,
3174
+ BlocksFeature,
3175
+ UploadFeature,
3176
+ FixedToolbarFeature,
3177
+ InlineToolbarFeature,
3178
+ lexicalEditor
3179
+ } from "@payloadcms/richtext-lexical";
3180
+
3181
+ // src/utils/blockValidation.ts
3182
+ var EMAIL_INCOMPATIBLE_TYPES = [
3183
+ "chart",
3184
+ "dataTable",
3185
+ "interactive",
3186
+ "streamable",
3187
+ "video",
3188
+ "iframe",
3189
+ "form",
3190
+ "carousel",
3191
+ "tabs",
3192
+ "accordion",
3193
+ "map"
3194
+ ];
3195
+ var validateEmailBlocks = (blocks) => {
3196
+ blocks.forEach((block) => {
3197
+ if (EMAIL_INCOMPATIBLE_TYPES.includes(block.slug)) {
3198
+ console.warn(`\u26A0\uFE0F Block "${block.slug}" may not be email-compatible. Consider creating an email-specific version.`);
3199
+ }
3200
+ const hasComplexFields = block.fields?.some((field) => {
3201
+ const complexTypes = ["code", "json", "richText", "blocks", "array"];
3202
+ return complexTypes.includes(field.type);
3203
+ });
3204
+ if (hasComplexFields) {
3205
+ console.warn(`\u26A0\uFE0F Block "${block.slug}" contains complex field types that may not render consistently in email clients.`);
3206
+ }
3207
+ });
3208
+ };
3209
+ var createEmailSafeBlocks = (customBlocks = []) => {
3210
+ validateEmailBlocks(customBlocks);
3211
+ const baseBlocks = [
3212
+ {
3213
+ slug: "button",
3214
+ fields: [
3215
+ {
3216
+ name: "text",
3217
+ type: "text",
3218
+ label: "Button Text",
3219
+ required: true
3220
+ },
3221
+ {
3222
+ name: "url",
3223
+ type: "text",
3224
+ label: "Button URL",
3225
+ required: true,
3226
+ admin: {
3227
+ description: "Enter the full URL (including https://)"
3228
+ }
3229
+ },
3230
+ {
3231
+ name: "style",
3232
+ type: "select",
3233
+ label: "Button Style",
3234
+ defaultValue: "primary",
3235
+ options: [
3236
+ { label: "Primary", value: "primary" },
3237
+ { label: "Secondary", value: "secondary" },
3238
+ { label: "Outline", value: "outline" }
3239
+ ]
3240
+ }
3241
+ ],
3242
+ interfaceName: "EmailButton",
3243
+ labels: {
3244
+ singular: "Button",
3245
+ plural: "Buttons"
3246
+ }
3247
+ },
3248
+ {
3249
+ slug: "divider",
3250
+ fields: [
3251
+ {
3252
+ name: "style",
3253
+ type: "select",
3254
+ label: "Divider Style",
3255
+ defaultValue: "solid",
3256
+ options: [
3257
+ { label: "Solid", value: "solid" },
3258
+ { label: "Dashed", value: "dashed" },
3259
+ { label: "Dotted", value: "dotted" }
3260
+ ]
3261
+ }
3262
+ ],
3263
+ interfaceName: "EmailDivider",
3264
+ labels: {
3265
+ singular: "Divider",
3266
+ plural: "Dividers"
3267
+ }
3268
+ }
3269
+ ];
3270
+ return [
3271
+ ...baseBlocks,
3272
+ ...customBlocks
3273
+ ];
3274
+ };
3275
+
3276
+ // src/fields/emailContent.ts
3277
+ var createEmailSafeFeatures = (additionalBlocks) => {
3278
+ const baseBlocks = [
3279
+ {
3280
+ slug: "button",
3281
+ fields: [
3282
+ {
3283
+ name: "text",
3284
+ type: "text",
3285
+ label: "Button Text",
3286
+ required: true
3287
+ },
3288
+ {
3289
+ name: "url",
3290
+ type: "text",
3291
+ label: "Button URL",
3292
+ required: true,
3293
+ admin: {
3294
+ description: "Enter the full URL (including https://)"
3295
+ }
3296
+ },
3297
+ {
3298
+ name: "style",
3299
+ type: "select",
3300
+ label: "Button Style",
3301
+ defaultValue: "primary",
3302
+ options: [
3303
+ { label: "Primary", value: "primary" },
3304
+ { label: "Secondary", value: "secondary" },
3305
+ { label: "Outline", value: "outline" }
3306
+ ]
3307
+ }
3308
+ ],
3309
+ interfaceName: "EmailButton",
3310
+ labels: {
3311
+ singular: "Button",
3312
+ plural: "Buttons"
3313
+ }
3314
+ },
3315
+ {
3316
+ slug: "divider",
3317
+ fields: [
3318
+ {
3319
+ name: "style",
3320
+ type: "select",
3321
+ label: "Divider Style",
3322
+ defaultValue: "solid",
3323
+ options: [
3324
+ { label: "Solid", value: "solid" },
3325
+ { label: "Dashed", value: "dashed" },
3326
+ { label: "Dotted", value: "dotted" }
3327
+ ]
3328
+ }
3329
+ ],
3330
+ interfaceName: "EmailDivider",
3331
+ labels: {
3332
+ singular: "Divider",
3333
+ plural: "Dividers"
3334
+ }
3335
+ }
3336
+ ];
3337
+ const allBlocks = [
3338
+ ...baseBlocks,
3339
+ ...additionalBlocks || []
3340
+ ];
3341
+ return [
3342
+ // Toolbars
3343
+ FixedToolbarFeature(),
3344
+ // Fixed toolbar at the top
3345
+ InlineToolbarFeature(),
3346
+ // Floating toolbar when text is selected
3347
+ // Basic text formatting
3348
+ BoldFeature(),
3349
+ ItalicFeature(),
3350
+ UnderlineFeature(),
3351
+ StrikethroughFeature(),
3352
+ // Links with enhanced configuration
3353
+ LinkFeature({
3354
+ fields: [
3355
+ {
3356
+ name: "url",
3357
+ type: "text",
3358
+ required: true,
3359
+ admin: {
3360
+ description: "Enter the full URL (including https://)"
3361
+ }
3362
+ },
3363
+ {
3364
+ name: "newTab",
3365
+ type: "checkbox",
3366
+ label: "Open in new tab",
3367
+ defaultValue: false
3368
+ }
3369
+ ]
3370
+ }),
3371
+ // Lists
3372
+ OrderedListFeature(),
3373
+ UnorderedListFeature(),
3374
+ // Headings - limited to h1, h2, h3 for email compatibility
3375
+ HeadingFeature({
3376
+ enabledHeadingSizes: ["h1", "h2", "h3"]
3377
+ }),
3378
+ // Basic paragraph and alignment
3379
+ ParagraphFeature(),
3380
+ AlignFeature(),
3381
+ // Blockquotes
3382
+ BlockquoteFeature(),
3383
+ // Upload feature for images
3384
+ UploadFeature({
3385
+ collections: {
3386
+ media: {
3387
+ fields: [
3388
+ {
3389
+ name: "caption",
3390
+ type: "text",
3391
+ admin: {
3392
+ description: "Optional caption for the image"
3393
+ }
3394
+ },
3395
+ {
3396
+ name: "altText",
3397
+ type: "text",
3398
+ label: "Alt Text",
3399
+ required: true,
3400
+ admin: {
3401
+ description: "Alternative text for accessibility and when image cannot be displayed"
3402
+ }
3403
+ }
3404
+ ]
3405
+ }
3406
+ }
3407
+ }),
3408
+ // Custom blocks for email-specific content
3409
+ BlocksFeature({
3410
+ blocks: allBlocks
3411
+ })
3412
+ ];
3413
+ };
3414
+ var createEmailLexicalEditor = (customBlocks = []) => {
3415
+ const emailSafeBlocks = createEmailSafeBlocks(customBlocks);
3416
+ return lexicalEditor({
3417
+ features: [
3418
+ // Toolbars
3419
+ FixedToolbarFeature(),
3420
+ InlineToolbarFeature(),
3421
+ // Basic text formatting
3422
+ BoldFeature(),
3423
+ ItalicFeature(),
3424
+ UnderlineFeature(),
3425
+ StrikethroughFeature(),
3426
+ // Links with enhanced configuration
3427
+ LinkFeature({
3428
+ fields: [
3429
+ {
3430
+ name: "url",
3431
+ type: "text",
3432
+ required: true,
3433
+ admin: {
3434
+ description: "Enter the full URL (including https://)"
3435
+ }
3436
+ },
3437
+ {
3438
+ name: "newTab",
3439
+ type: "checkbox",
3440
+ label: "Open in new tab",
3441
+ defaultValue: false
3442
+ }
3443
+ ]
3444
+ }),
3445
+ // Lists
3446
+ OrderedListFeature(),
3447
+ UnorderedListFeature(),
3448
+ // Headings - limited to h1, h2, h3 for email compatibility
3449
+ HeadingFeature({
3450
+ enabledHeadingSizes: ["h1", "h2", "h3"]
3451
+ }),
3452
+ // Basic paragraph and alignment
3453
+ ParagraphFeature(),
3454
+ AlignFeature(),
3455
+ // Blockquotes
3456
+ BlockquoteFeature(),
3457
+ // Upload feature for images
3458
+ UploadFeature({
3459
+ collections: {
3460
+ media: {
3461
+ fields: [
3462
+ {
3463
+ name: "caption",
3464
+ type: "text",
3465
+ admin: {
3466
+ description: "Optional caption for the image"
3467
+ }
3468
+ },
3469
+ {
3470
+ name: "altText",
3471
+ type: "text",
3472
+ label: "Alt Text",
3473
+ required: true,
3474
+ admin: {
3475
+ description: "Alternative text for accessibility and when image cannot be displayed"
3476
+ }
3477
+ }
3478
+ ]
3479
+ }
3480
+ }
3481
+ }),
3482
+ // Email-safe blocks (processed server-side)
3483
+ BlocksFeature({
3484
+ blocks: emailSafeBlocks
3485
+ })
3486
+ ]
3487
+ });
3488
+ };
3489
+ var emailSafeFeatures = createEmailSafeFeatures();
3490
+ var createEmailContentField = (overrides) => {
3491
+ const editor = overrides?.editor || createEmailLexicalEditor(overrides?.additionalBlocks);
3492
+ return {
3493
+ name: "content",
3494
+ type: "richText",
3495
+ required: true,
3496
+ editor,
3497
+ admin: {
3498
+ description: "Email content with limited formatting for compatibility",
3499
+ ...overrides?.admin
3500
+ },
3501
+ ...overrides
3502
+ };
3503
+ };
3504
+
3505
+ // src/fields/broadcastInlinePreview.ts
3506
+ var createBroadcastInlinePreviewField = () => {
3507
+ return {
3508
+ name: "broadcastInlinePreview",
3509
+ type: "ui",
3510
+ admin: {
3511
+ components: {
3512
+ Field: "payload-plugin-newsletter/components#BroadcastInlinePreview"
3513
+ }
3514
+ }
3515
+ };
3516
+ };
3517
+
3518
+ // src/utils/emailSafeHtml.ts
3519
+ import DOMPurify2 from "isomorphic-dompurify";
3520
+ var EMAIL_SAFE_CONFIG = {
3521
+ ALLOWED_TAGS: [
3522
+ "p",
3523
+ "br",
3524
+ "strong",
3525
+ "b",
3526
+ "em",
3527
+ "i",
3528
+ "u",
3529
+ "strike",
3123
3530
  "s",
3124
3531
  "span",
3125
3532
  "a",
@@ -3363,859 +3770,443 @@ function convertButtonBlock(fields) {
3363
3770
  const buttonStyle = `${styles2[style] || styles2.primary} display: inline-block; padding: 12px 24px; font-size: 16px; font-weight: 600; text-decoration: none; border-radius: 6px; text-align: center;`;
3364
3771
  return `
3365
3772
  <div style="margin: 0 0 16px 0; text-align: center;">
3366
- <a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="${buttonStyle}">${escapeHtml(text)}</a>
3367
- </div>
3368
- `;
3369
- }
3370
- function convertDividerBlock(fields) {
3371
- const style = fields?.style || "solid";
3372
- const styles2 = {
3373
- solid: "border-top: 1px solid #e5e7eb;",
3374
- dashed: "border-top: 1px dashed #e5e7eb;",
3375
- dotted: "border-top: 1px dotted #e5e7eb;"
3376
- };
3377
- return `<hr style="${styles2[style] || styles2.solid} margin: 24px 0; border-bottom: none; border-left: none; border-right: none;" />`;
3378
- }
3379
- function getAlignment(format) {
3380
- if (!format) return "left";
3381
- if (format & 2) return "center";
3382
- if (format & 3) return "right";
3383
- if (format & 4) return "justify";
3384
- return "left";
3385
- }
3386
- function escapeHtml(text) {
3387
- const map = {
3388
- "&": "&amp;",
3389
- "<": "&lt;",
3390
- ">": "&gt;",
3391
- '"': "&quot;",
3392
- "'": "&#039;"
3393
- };
3394
- return text.replace(/[&<>"']/g, (m) => map[m]);
3395
- }
3396
- function wrapInEmailTemplate(content, preheader) {
3397
- return `<!DOCTYPE html>
3398
- <html lang="en">
3399
- <head>
3400
- <meta charset="UTF-8">
3401
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
3402
- <title>Email</title>
3403
- <!--[if mso]>
3404
- <noscript>
3405
- <xml>
3406
- <o:OfficeDocumentSettings>
3407
- <o:PixelsPerInch>96</o:PixelsPerInch>
3408
- </o:OfficeDocumentSettings>
3409
- </xml>
3410
- </noscript>
3411
- <![endif]-->
3412
- </head>
3413
- <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #333333; background-color: #f3f4f6;">
3414
- ${preheader ? `<div style="display: none; max-height: 0; overflow: hidden;">${escapeHtml(preheader)}</div>` : ""}
3415
- <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin: 0; padding: 0;">
3416
- <tr>
3417
- <td align="center" style="padding: 20px 0;">
3418
- <table role="presentation" cellpadding="0" cellspacing="0" width="600" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden;">
3419
- <tr>
3420
- <td style="padding: 40px 30px;">
3421
- ${content}
3422
- </td>
3423
- </tr>
3424
- </table>
3425
- </td>
3426
- </tr>
3427
- </table>
3428
- </body>
3429
- </html>`;
3430
- }
3431
-
3432
- // src/endpoints/broadcasts/test.ts
3433
- var createTestBroadcastEndpoint = (config, collectionSlug) => {
3434
- return {
3435
- path: `/${collectionSlug}/:id/test`,
3436
- method: "post",
3437
- handler: async (req) => {
3438
- try {
3439
- const auth = await requireAdmin(req, config);
3440
- if (!auth.authorized) {
3441
- return Response.json({
3442
- success: false,
3443
- error: auth.error
3444
- }, { status: 401 });
3445
- }
3446
- const url = new URL(req.url || "", `http://localhost`);
3447
- const pathParts = url.pathname.split("/");
3448
- const id = pathParts[pathParts.length - 2];
3449
- if (!id) {
3450
- return Response.json({
3451
- success: false,
3452
- error: "Broadcast ID is required"
3453
- }, { status: 400 });
3454
- }
3455
- const data = await (req.json?.() || Promise.resolve({}));
3456
- const testEmail = data.email || auth.user.email;
3457
- if (!testEmail) {
3458
- return Response.json({
3459
- success: false,
3460
- error: "No email address available for test send"
3461
- }, { status: 400 });
3462
- }
3463
- const broadcast = await req.payload.findByID({
3464
- collection: collectionSlug,
3465
- id,
3466
- user: auth.user
3467
- });
3468
- if (!broadcast) {
3469
- return Response.json({
3470
- success: false,
3471
- error: "Broadcast not found"
3472
- }, { status: 404 });
3473
- }
3474
- const htmlContent = await convertToEmailSafeHtml(broadcast.content, {
3475
- wrapInTemplate: true,
3476
- preheader: broadcast.preheader,
3477
- customBlockConverter: config.customizations?.broadcasts?.customBlockConverter
3478
- });
3479
- const emailService = req.payload.newsletterEmailService;
3480
- if (!emailService) {
3481
- return Response.json({
3482
- success: false,
3483
- error: "Email service is not configured"
3484
- }, { status: 500 });
3485
- }
3486
- const providerConfig = config.providers.default === "resend" ? config.providers.resend : config.providers.broadcast;
3487
- const fromEmail = providerConfig?.fromAddress || providerConfig?.fromEmail || "noreply@example.com";
3488
- const fromName = providerConfig?.fromName || "Newsletter";
3489
- const replyTo = broadcast.settings?.replyTo || providerConfig?.replyTo;
3490
- await emailService.send({
3491
- to: testEmail,
3492
- from: fromEmail,
3493
- fromName,
3494
- replyTo,
3495
- subject: `[TEST] ${broadcast.subject}`,
3496
- html: htmlContent,
3497
- trackOpens: false,
3498
- trackClicks: false
3499
- });
3500
- return Response.json({
3501
- success: true,
3502
- message: `Test email sent to ${testEmail}`
3503
- });
3504
- } catch (error) {
3505
- console.error("Failed to send test broadcast:", error);
3506
- return Response.json({
3507
- success: false,
3508
- error: "Failed to send test email"
3509
- }, { status: 500 });
3510
- }
3511
- }
3512
- };
3513
- };
3514
-
3515
- // src/endpoints/broadcasts/preview.ts
3516
- var createBroadcastPreviewEndpoint = (config, collectionSlug) => {
3517
- return {
3518
- path: `/${collectionSlug}/preview`,
3519
- method: "post",
3520
- handler: async (req) => {
3521
- try {
3522
- const data = await (req.json?.() || Promise.resolve({}));
3523
- const { content, preheader, subject } = data;
3524
- if (!content) {
3525
- return Response.json({
3526
- success: false,
3527
- error: "Content is required for preview"
3528
- }, { status: 400 });
3529
- }
3530
- const mediaUrl = req.payload.config.serverURL ? `${req.payload.config.serverURL}/api/media` : "/api/media";
3531
- const htmlContent = await convertToEmailSafeHtml(content, {
3532
- wrapInTemplate: true,
3533
- preheader,
3534
- mediaUrl,
3535
- customBlockConverter: config.customizations?.broadcasts?.customBlockConverter
3536
- });
3537
- return Response.json({
3538
- success: true,
3539
- preview: {
3540
- subject: subject || "Preview",
3541
- preheader: preheader || "",
3542
- html: htmlContent
3543
- }
3544
- });
3545
- } catch (error) {
3546
- console.error("Failed to generate email preview:", error);
3547
- return Response.json({
3548
- success: false,
3549
- error: "Failed to generate email preview"
3550
- }, { status: 500 });
3551
- }
3552
- }
3773
+ <a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="${buttonStyle}">${escapeHtml(text)}</a>
3774
+ </div>
3775
+ `;
3776
+ }
3777
+ function convertDividerBlock(fields) {
3778
+ const style = fields?.style || "solid";
3779
+ const styles2 = {
3780
+ solid: "border-top: 1px solid #e5e7eb;",
3781
+ dashed: "border-top: 1px dashed #e5e7eb;",
3782
+ dotted: "border-top: 1px dotted #e5e7eb;"
3553
3783
  };
3554
- };
3555
-
3556
- // src/endpoints/broadcasts/index.ts
3557
- var createBroadcastManagementEndpoints = (config) => {
3558
- if (!config.features?.newsletterManagement?.enabled) {
3559
- return [];
3560
- }
3561
- const collectionSlug = config.features.newsletterManagement.collections?.broadcasts || "broadcasts";
3562
- return [
3563
- createSendBroadcastEndpoint(config, collectionSlug),
3564
- createScheduleBroadcastEndpoint(config, collectionSlug),
3565
- createTestBroadcastEndpoint(config, collectionSlug),
3566
- createBroadcastPreviewEndpoint(config, collectionSlug)
3567
- ];
3568
- };
3784
+ return `<hr style="${styles2[style] || styles2.solid} margin: 24px 0; border-bottom: none; border-left: none; border-right: none;" />`;
3785
+ }
3786
+ function getAlignment(format) {
3787
+ if (!format) return "left";
3788
+ if (format & 2) return "center";
3789
+ if (format & 3) return "right";
3790
+ if (format & 4) return "justify";
3791
+ return "left";
3792
+ }
3793
+ function escapeHtml(text) {
3794
+ const map = {
3795
+ "&": "&amp;",
3796
+ "<": "&lt;",
3797
+ ">": "&gt;",
3798
+ '"': "&quot;",
3799
+ "'": "&#039;"
3800
+ };
3801
+ return text.replace(/[&<>"']/g, (m) => map[m]);
3802
+ }
3803
+ function wrapInEmailTemplate(content, preheader) {
3804
+ return `<!DOCTYPE html>
3805
+ <html lang="en">
3806
+ <head>
3807
+ <meta charset="UTF-8">
3808
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
3809
+ <title>Email</title>
3810
+ <!--[if mso]>
3811
+ <noscript>
3812
+ <xml>
3813
+ <o:OfficeDocumentSettings>
3814
+ <o:PixelsPerInch>96</o:PixelsPerInch>
3815
+ </o:OfficeDocumentSettings>
3816
+ </xml>
3817
+ </noscript>
3818
+ <![endif]-->
3819
+ </head>
3820
+ <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #333333; background-color: #f3f4f6;">
3821
+ ${preheader ? `<div style="display: none; max-height: 0; overflow: hidden;">${escapeHtml(preheader)}</div>` : ""}
3822
+ <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin: 0; padding: 0;">
3823
+ <tr>
3824
+ <td align="center" style="padding: 20px 0;">
3825
+ <table role="presentation" cellpadding="0" cellspacing="0" width="600" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden;">
3826
+ <tr>
3827
+ <td style="padding: 40px 30px;">
3828
+ ${content}
3829
+ </td>
3830
+ </tr>
3831
+ </table>
3832
+ </td>
3833
+ </tr>
3834
+ </table>
3835
+ </body>
3836
+ </html>`;
3837
+ }
3569
3838
 
3570
- // src/endpoints/index.ts
3571
- function createNewsletterEndpoints(config) {
3572
- const endpoints = [
3573
- createSubscribeEndpoint(config),
3574
- createUnsubscribeEndpoint(config)
3575
- ];
3576
- if (config.auth?.enabled !== false) {
3577
- endpoints.push(
3578
- createVerifyMagicLinkEndpoint(config),
3579
- createPreferencesEndpoint(config),
3580
- createUpdatePreferencesEndpoint(config),
3581
- createSigninEndpoint(config),
3582
- createMeEndpoint(config),
3583
- createSignoutEndpoint(config)
3584
- );
3839
+ // src/utils/getBroadcastConfig.ts
3840
+ async function getBroadcastConfig(req, pluginConfig) {
3841
+ try {
3842
+ const settings = await req.payload.findGlobal({
3843
+ slug: pluginConfig.settingsSlug || "newsletter-settings",
3844
+ req
3845
+ });
3846
+ if (settings?.provider === "broadcast" && settings?.broadcastSettings) {
3847
+ return {
3848
+ apiUrl: settings.broadcastSettings.apiUrl || pluginConfig.providers?.broadcast?.apiUrl || "",
3849
+ token: settings.broadcastSettings.token || pluginConfig.providers?.broadcast?.token || "",
3850
+ fromAddress: settings.fromAddress || pluginConfig.providers?.broadcast?.fromAddress || "",
3851
+ fromName: settings.fromName || pluginConfig.providers?.broadcast?.fromName || "",
3852
+ replyTo: settings.replyTo || pluginConfig.providers?.broadcast?.replyTo
3853
+ };
3854
+ }
3855
+ return pluginConfig.providers?.broadcast || null;
3856
+ } catch (error) {
3857
+ req.payload.logger.error("Failed to get broadcast config from settings:", error);
3858
+ return pluginConfig.providers?.broadcast || null;
3585
3859
  }
3586
- endpoints.push(...createBroadcastManagementEndpoints(config));
3587
- return endpoints;
3588
3860
  }
3589
3861
 
3590
- // src/fields/newsletterScheduling.ts
3591
- function createNewsletterSchedulingFields(config) {
3592
- const groupName = config.features?.newsletterScheduling?.fields?.groupName || "newsletterScheduling";
3593
- const contentField = config.features?.newsletterScheduling?.fields?.contentField || "content";
3594
- const createMarkdownField = config.features?.newsletterScheduling?.fields?.createMarkdownField !== false;
3595
- const fields = [
3596
- {
3597
- name: groupName,
3598
- type: "group",
3599
- label: "Newsletter Scheduling",
3600
- admin: {
3601
- condition: (data, { user }) => user?.collection === "users"
3602
- // Only show for admin users
3603
- },
3604
- fields: [
3605
- {
3606
- name: "scheduled",
3607
- type: "checkbox",
3608
- label: "Schedule for Newsletter",
3609
- defaultValue: false,
3610
- admin: {
3611
- description: "Schedule this content to be sent as a newsletter"
3612
- }
3613
- },
3614
- {
3615
- name: "scheduledDate",
3616
- type: "date",
3617
- label: "Send Date",
3618
- required: true,
3619
- admin: {
3620
- date: {
3621
- pickerAppearance: "dayAndTime"
3622
- },
3623
- condition: (data) => data?.[groupName]?.scheduled,
3624
- description: "When to send this newsletter"
3625
- }
3626
- },
3627
- {
3628
- name: "sentDate",
3629
- type: "date",
3630
- label: "Sent Date",
3631
- admin: {
3632
- readOnly: true,
3633
- condition: (data) => data?.[groupName]?.sendStatus === "sent",
3634
- description: "When this newsletter was sent"
3635
- }
3636
- },
3637
- {
3638
- name: "sendStatus",
3639
- type: "select",
3640
- label: "Status",
3641
- options: [
3642
- { label: "Draft", value: "draft" },
3643
- { label: "Scheduled", value: "scheduled" },
3644
- { label: "Sending", value: "sending" },
3645
- { label: "Sent", value: "sent" },
3646
- { label: "Failed", value: "failed" }
3647
- ],
3648
- defaultValue: "draft",
3649
- admin: {
3650
- readOnly: true,
3651
- description: "Current send status"
3652
- }
3653
- },
3654
- {
3655
- name: "emailSubject",
3656
- type: "text",
3657
- label: "Email Subject",
3658
- required: true,
3659
- admin: {
3660
- condition: (data) => data?.[groupName]?.scheduled,
3661
- description: "Subject line for the newsletter email"
3662
- }
3663
- },
3664
- {
3665
- name: "preheader",
3666
- type: "text",
3667
- label: "Email Preheader",
3668
- admin: {
3669
- condition: (data) => data?.[groupName]?.scheduled,
3670
- description: "Preview text that appears after the subject line"
3671
- }
3672
- },
3673
- {
3674
- name: "segments",
3675
- type: "select",
3676
- label: "Target Segments",
3677
- hasMany: true,
3678
- options: [
3679
- { label: "All Subscribers", value: "all" },
3680
- ...config.i18n?.locales?.map((locale) => ({
3681
- label: `${locale.toUpperCase()} Subscribers`,
3682
- value: locale
3683
- })) || []
3684
- ],
3685
- defaultValue: ["all"],
3686
- admin: {
3687
- condition: (data) => data?.[groupName]?.scheduled,
3688
- description: "Which subscriber segments to send to"
3689
- }
3690
- },
3691
- {
3692
- name: "testEmails",
3693
- type: "array",
3694
- label: "Test Email Recipients",
3695
- admin: {
3696
- condition: (data) => data?.[groupName]?.scheduled && data?.[groupName]?.sendStatus === "draft",
3697
- description: "Send test emails before scheduling"
3698
- },
3699
- fields: [
3700
- {
3701
- name: "email",
3702
- type: "email",
3703
- required: true
3704
- }
3705
- ]
3862
+ // src/endpoints/broadcasts/send.ts
3863
+ init_types();
3864
+
3865
+ // src/utils/auth.ts
3866
+ async function getAuthenticatedUser(req) {
3867
+ try {
3868
+ const me = await req.payload.find({
3869
+ collection: "users",
3870
+ where: {
3871
+ id: {
3872
+ equals: "me"
3873
+ // Special value in Payload to get current user
3706
3874
  }
3707
- ]
3708
- }
3709
- ];
3710
- if (createMarkdownField) {
3711
- fields.push(createMarkdownFieldInternal({
3712
- name: `${contentField}Markdown`,
3713
- richTextField: contentField,
3714
- label: "Email Content (Markdown)",
3715
- admin: {
3716
- position: "sidebar",
3717
- condition: (data) => Boolean(data?.[contentField] && data?.[groupName]?.scheduled),
3718
- description: "Markdown version for email rendering",
3719
- readOnly: true
3720
- }
3721
- }));
3875
+ },
3876
+ limit: 1,
3877
+ depth: 0
3878
+ });
3879
+ return me.docs[0] || null;
3880
+ } catch {
3881
+ return null;
3722
3882
  }
3723
- return fields;
3724
3883
  }
3725
- function createMarkdownFieldInternal(config) {
3884
+ async function requireAdmin(req, config) {
3885
+ const user = await getAuthenticatedUser(req);
3886
+ if (!user) {
3887
+ return {
3888
+ authorized: false,
3889
+ error: "Authentication required"
3890
+ };
3891
+ }
3892
+ if (!isAdmin(user, config)) {
3893
+ return {
3894
+ authorized: false,
3895
+ error: "Admin access required"
3896
+ };
3897
+ }
3726
3898
  return {
3727
- name: config.name,
3728
- type: "textarea",
3729
- label: config.label || "Markdown",
3730
- admin: {
3731
- ...config.admin,
3732
- description: config.admin?.description || "Auto-generated from rich text content"
3733
- },
3734
- hooks: {
3735
- afterRead: [
3736
- async ({ data }) => {
3737
- if (data?.[config.richTextField]) {
3738
- try {
3739
- const { convertLexicalToMarkdown } = await import("@payloadcms/richtext-lexical");
3740
- return convertLexicalToMarkdown({
3741
- data: data[config.richTextField]
3742
- });
3743
- } catch {
3744
- return "";
3745
- }
3746
- }
3747
- return "";
3748
- }
3749
- ],
3750
- beforeChange: [
3751
- () => {
3752
- return null;
3753
- }
3754
- ]
3755
- }
3899
+ authorized: true,
3900
+ user
3756
3901
  };
3757
3902
  }
3758
3903
 
3759
- // src/jobs/sync-unsubscribes.ts
3760
- var createUnsubscribeSyncJob = (pluginConfig) => {
3904
+ // src/endpoints/broadcasts/send.ts
3905
+ var createSendBroadcastEndpoint = (config, collectionSlug) => {
3761
3906
  return {
3762
- slug: "sync-unsubscribes",
3763
- label: "Sync Unsubscribes from Email Service",
3764
- handler: async ({ req }) => {
3765
- const subscribersSlug = pluginConfig.subscribersSlug || "subscribers";
3766
- const emailService = req.payload.newsletterEmailService;
3767
- if (!emailService) {
3768
- console.error("Email service not configured");
3769
- return {
3770
- output: {
3771
- syncedCount: 0
3772
- }
3773
- };
3774
- }
3775
- let syncedCount = 0;
3907
+ path: "/:id/send",
3908
+ method: "post",
3909
+ handler: async (req) => {
3776
3910
  try {
3777
- if (emailService.getProvider() === "broadcast") {
3778
- console.warn("Starting Broadcast unsubscribe sync...");
3779
- const broadcastConfig = pluginConfig.providers?.broadcast;
3780
- if (!broadcastConfig) {
3781
- throw new Error("Broadcast configuration not found");
3782
- }
3783
- const apiUrl = broadcastConfig.apiUrl.replace(/\/$/, "");
3784
- const token = broadcastConfig.token;
3785
- let page = 1;
3786
- let hasMore = true;
3787
- while (hasMore) {
3788
- const response = await fetch(
3789
- `${apiUrl}/api/v1/subscribers.json?page=${page}`,
3790
- {
3791
- headers: {
3792
- "Authorization": `Bearer ${token}`
3793
- }
3794
- }
3795
- );
3796
- if (!response.ok) {
3797
- throw new Error(`Broadcast API error: ${response.status}`);
3798
- }
3799
- const data = await response.json();
3800
- const broadcastSubscribers = data.subscribers || [];
3801
- for (const broadcastSub of broadcastSubscribers) {
3802
- const payloadSubscribers = await req.payload.find({
3803
- collection: subscribersSlug,
3804
- where: {
3805
- email: {
3806
- equals: broadcastSub.email
3807
- }
3808
- },
3809
- limit: 1
3810
- });
3811
- if (payloadSubscribers.docs.length > 0) {
3812
- const payloadSub = payloadSubscribers.docs[0];
3813
- const broadcastUnsubscribed = !broadcastSub.is_active || broadcastSub.unsubscribed_at;
3814
- const payloadUnsubscribed = payloadSub.subscriptionStatus === "unsubscribed";
3815
- if (broadcastUnsubscribed && !payloadUnsubscribed) {
3816
- await req.payload.update({
3817
- collection: subscribersSlug,
3818
- id: payloadSub.id,
3819
- data: {
3820
- subscriptionStatus: "unsubscribed",
3821
- unsubscribedAt: broadcastSub.unsubscribed_at || (/* @__PURE__ */ new Date()).toISOString()
3822
- }
3823
- });
3824
- syncedCount++;
3825
- console.warn(`Unsubscribed: ${broadcastSub.email}`);
3826
- }
3827
- }
3828
- }
3829
- if (data.pagination && data.pagination.current < data.pagination.total_pages) {
3830
- page++;
3831
- } else {
3832
- hasMore = false;
3833
- }
3834
- }
3835
- console.warn(`Broadcast sync complete. Unsubscribed ${syncedCount} contacts.`);
3836
- }
3837
- if (emailService.getProvider() === "resend") {
3838
- console.warn("Starting Resend unsubscribe sync...");
3839
- const resendConfig = pluginConfig.providers?.resend;
3840
- if (!resendConfig) {
3841
- throw new Error("Resend configuration not found");
3842
- }
3843
- console.warn("Resend polling implementation needed - webhooks recommended");
3911
+ const auth = await requireAdmin(req, config);
3912
+ if (!auth.authorized) {
3913
+ return Response.json({
3914
+ success: false,
3915
+ error: auth.error
3916
+ }, { status: 401 });
3844
3917
  }
3845
- if (pluginConfig.hooks?.afterUnsubscribeSync) {
3846
- await pluginConfig.hooks.afterUnsubscribeSync({
3847
- req,
3848
- syncedCount
3849
- });
3918
+ if (!config.features?.newsletterManagement?.enabled) {
3919
+ return Response.json({
3920
+ success: false,
3921
+ error: "Broadcast management is not enabled"
3922
+ }, { status: 400 });
3850
3923
  }
3851
- } catch (error) {
3852
- console.error("Unsubscribe sync error:", error);
3853
- throw error;
3854
- }
3855
- return {
3856
- output: {
3857
- syncedCount
3924
+ const url = new URL(req.url || "", `http://localhost`);
3925
+ const pathParts = url.pathname.split("/");
3926
+ const id = pathParts[pathParts.length - 2];
3927
+ if (!id) {
3928
+ return Response.json({
3929
+ success: false,
3930
+ error: "Broadcast ID is required"
3931
+ }, { status: 400 });
3858
3932
  }
3859
- };
3860
- }
3861
- };
3862
- };
3863
-
3864
- // src/collections/Broadcasts.ts
3865
- init_types();
3866
-
3867
- // src/fields/emailContent.ts
3868
- import {
3869
- BoldFeature,
3870
- ItalicFeature,
3871
- UnderlineFeature,
3872
- StrikethroughFeature,
3873
- LinkFeature,
3874
- OrderedListFeature,
3875
- UnorderedListFeature,
3876
- HeadingFeature,
3877
- ParagraphFeature,
3878
- AlignFeature,
3879
- BlockquoteFeature,
3880
- BlocksFeature,
3881
- UploadFeature,
3882
- FixedToolbarFeature,
3883
- InlineToolbarFeature,
3884
- lexicalEditor
3885
- } from "@payloadcms/richtext-lexical";
3886
-
3887
- // src/utils/blockValidation.ts
3888
- var EMAIL_INCOMPATIBLE_TYPES = [
3889
- "chart",
3890
- "dataTable",
3891
- "interactive",
3892
- "streamable",
3893
- "video",
3894
- "iframe",
3895
- "form",
3896
- "carousel",
3897
- "tabs",
3898
- "accordion",
3899
- "map"
3900
- ];
3901
- var validateEmailBlocks = (blocks) => {
3902
- blocks.forEach((block) => {
3903
- if (EMAIL_INCOMPATIBLE_TYPES.includes(block.slug)) {
3904
- console.warn(`\u26A0\uFE0F Block "${block.slug}" may not be email-compatible. Consider creating an email-specific version.`);
3905
- }
3906
- const hasComplexFields = block.fields?.some((field) => {
3907
- const complexTypes = ["code", "json", "richText", "blocks", "array"];
3908
- return complexTypes.includes(field.type);
3909
- });
3910
- if (hasComplexFields) {
3911
- console.warn(`\u26A0\uFE0F Block "${block.slug}" contains complex field types that may not render consistently in email clients.`);
3912
- }
3913
- });
3914
- };
3915
- var createEmailSafeBlocks = (customBlocks = []) => {
3916
- validateEmailBlocks(customBlocks);
3917
- const baseBlocks = [
3918
- {
3919
- slug: "button",
3920
- fields: [
3921
- {
3922
- name: "text",
3923
- type: "text",
3924
- label: "Button Text",
3925
- required: true
3926
- },
3927
- {
3928
- name: "url",
3929
- type: "text",
3930
- label: "Button URL",
3931
- required: true,
3932
- admin: {
3933
- description: "Enter the full URL (including https://)"
3934
- }
3935
- },
3936
- {
3937
- name: "style",
3938
- type: "select",
3939
- label: "Button Style",
3940
- defaultValue: "primary",
3941
- options: [
3942
- { label: "Primary", value: "primary" },
3943
- { label: "Secondary", value: "secondary" },
3944
- { label: "Outline", value: "outline" }
3945
- ]
3933
+ const data = await (req.json?.() || Promise.resolve({}));
3934
+ const broadcastDoc = await req.payload.findByID({
3935
+ collection: collectionSlug,
3936
+ id,
3937
+ user: auth.user
3938
+ });
3939
+ if (!broadcastDoc || !broadcastDoc.providerId) {
3940
+ return Response.json({
3941
+ success: false,
3942
+ error: "Broadcast not found or not synced with provider"
3943
+ }, { status: 404 });
3946
3944
  }
3947
- ],
3948
- interfaceName: "EmailButton",
3949
- labels: {
3950
- singular: "Button",
3951
- plural: "Buttons"
3952
- }
3953
- },
3954
- {
3955
- slug: "divider",
3956
- fields: [
3957
- {
3958
- name: "style",
3959
- type: "select",
3960
- label: "Divider Style",
3961
- defaultValue: "solid",
3962
- options: [
3963
- { label: "Solid", value: "solid" },
3964
- { label: "Dashed", value: "dashed" },
3965
- { label: "Dotted", value: "dotted" }
3966
- ]
3945
+ const providerConfig = await getBroadcastConfig(req, config);
3946
+ if (!providerConfig || !providerConfig.token) {
3947
+ return Response.json({
3948
+ success: false,
3949
+ error: "Broadcast provider not configured in Newsletter Settings or environment variables"
3950
+ }, { status: 500 });
3967
3951
  }
3968
- ],
3969
- interfaceName: "EmailDivider",
3970
- labels: {
3971
- singular: "Divider",
3972
- plural: "Dividers"
3952
+ const { BroadcastApiProvider: BroadcastApiProvider2 } = await Promise.resolve().then(() => (init_broadcast2(), broadcast_exports));
3953
+ const provider = new BroadcastApiProvider2(providerConfig);
3954
+ const broadcast = await provider.send(broadcastDoc.providerId, data);
3955
+ await req.payload.update({
3956
+ collection: collectionSlug,
3957
+ id,
3958
+ data: {
3959
+ sendStatus: "sending" /* SENDING */,
3960
+ sentAt: (/* @__PURE__ */ new Date()).toISOString()
3961
+ },
3962
+ user: auth.user
3963
+ });
3964
+ return Response.json({
3965
+ success: true,
3966
+ message: "Broadcast sent successfully",
3967
+ broadcast
3968
+ });
3969
+ } catch (error) {
3970
+ console.error("Failed to send broadcast:", error);
3971
+ if (error instanceof NewsletterProviderError) {
3972
+ return Response.json({
3973
+ success: false,
3974
+ error: error.message,
3975
+ code: error.code
3976
+ }, { status: error.code === "NOT_SUPPORTED" ? 501 : 500 });
3977
+ }
3978
+ return Response.json({
3979
+ success: false,
3980
+ error: "Failed to send broadcast"
3981
+ }, { status: 500 });
3973
3982
  }
3974
3983
  }
3975
- ];
3976
- return [
3977
- ...baseBlocks,
3978
- ...customBlocks
3979
- ];
3984
+ };
3980
3985
  };
3981
3986
 
3982
- // src/fields/emailContent.ts
3983
- var createEmailSafeFeatures = (additionalBlocks) => {
3984
- const baseBlocks = [
3985
- {
3986
- slug: "button",
3987
- fields: [
3988
- {
3989
- name: "text",
3990
- type: "text",
3991
- label: "Button Text",
3992
- required: true
3993
- },
3994
- {
3995
- name: "url",
3996
- type: "text",
3997
- label: "Button URL",
3998
- required: true,
3999
- admin: {
4000
- description: "Enter the full URL (including https://)"
4001
- }
4002
- },
4003
- {
4004
- name: "style",
4005
- type: "select",
4006
- label: "Button Style",
4007
- defaultValue: "primary",
4008
- options: [
4009
- { label: "Primary", value: "primary" },
4010
- { label: "Secondary", value: "secondary" },
4011
- { label: "Outline", value: "outline" }
4012
- ]
3987
+ // src/endpoints/broadcasts/schedule.ts
3988
+ init_types();
3989
+ var createScheduleBroadcastEndpoint = (config, collectionSlug) => {
3990
+ return {
3991
+ path: "/:id/schedule",
3992
+ method: "post",
3993
+ handler: async (req) => {
3994
+ try {
3995
+ const auth = await requireAdmin(req, config);
3996
+ if (!auth.authorized) {
3997
+ return Response.json({
3998
+ success: false,
3999
+ error: auth.error
4000
+ }, { status: 401 });
4013
4001
  }
4014
- ],
4015
- interfaceName: "EmailButton",
4016
- labels: {
4017
- singular: "Button",
4018
- plural: "Buttons"
4019
- }
4020
- },
4021
- {
4022
- slug: "divider",
4023
- fields: [
4024
- {
4025
- name: "style",
4026
- type: "select",
4027
- label: "Divider Style",
4028
- defaultValue: "solid",
4029
- options: [
4030
- { label: "Solid", value: "solid" },
4031
- { label: "Dashed", value: "dashed" },
4032
- { label: "Dotted", value: "dotted" }
4033
- ]
4002
+ if (!config.features?.newsletterManagement?.enabled) {
4003
+ return Response.json({
4004
+ success: false,
4005
+ error: "Broadcast management is not enabled"
4006
+ }, { status: 400 });
4034
4007
  }
4035
- ],
4036
- interfaceName: "EmailDivider",
4037
- labels: {
4038
- singular: "Divider",
4039
- plural: "Dividers"
4040
- }
4041
- }
4042
- ];
4043
- const allBlocks = [
4044
- ...baseBlocks,
4045
- ...additionalBlocks || []
4046
- ];
4047
- return [
4048
- // Toolbars
4049
- FixedToolbarFeature(),
4050
- // Fixed toolbar at the top
4051
- InlineToolbarFeature(),
4052
- // Floating toolbar when text is selected
4053
- // Basic text formatting
4054
- BoldFeature(),
4055
- ItalicFeature(),
4056
- UnderlineFeature(),
4057
- StrikethroughFeature(),
4058
- // Links with enhanced configuration
4059
- LinkFeature({
4060
- fields: [
4061
- {
4062
- name: "url",
4063
- type: "text",
4064
- required: true,
4065
- admin: {
4066
- description: "Enter the full URL (including https://)"
4067
- }
4068
- },
4069
- {
4070
- name: "newTab",
4071
- type: "checkbox",
4072
- label: "Open in new tab",
4073
- defaultValue: false
4008
+ const url = new URL(req.url || "", `http://localhost`);
4009
+ const pathParts = url.pathname.split("/");
4010
+ const id = pathParts[pathParts.length - 2];
4011
+ if (!id) {
4012
+ return Response.json({
4013
+ success: false,
4014
+ error: "Broadcast ID is required"
4015
+ }, { status: 400 });
4074
4016
  }
4075
- ]
4076
- }),
4077
- // Lists
4078
- OrderedListFeature(),
4079
- UnorderedListFeature(),
4080
- // Headings - limited to h1, h2, h3 for email compatibility
4081
- HeadingFeature({
4082
- enabledHeadingSizes: ["h1", "h2", "h3"]
4083
- }),
4084
- // Basic paragraph and alignment
4085
- ParagraphFeature(),
4086
- AlignFeature(),
4087
- // Blockquotes
4088
- BlockquoteFeature(),
4089
- // Upload feature for images
4090
- UploadFeature({
4091
- collections: {
4092
- media: {
4093
- fields: [
4094
- {
4095
- name: "caption",
4096
- type: "text",
4097
- admin: {
4098
- description: "Optional caption for the image"
4099
- }
4100
- },
4101
- {
4102
- name: "altText",
4103
- type: "text",
4104
- label: "Alt Text",
4105
- required: true,
4106
- admin: {
4107
- description: "Alternative text for accessibility and when image cannot be displayed"
4108
- }
4109
- }
4110
- ]
4017
+ const data = await (req.json?.() || Promise.resolve({}));
4018
+ const { scheduledAt } = data;
4019
+ if (!scheduledAt) {
4020
+ return Response.json({
4021
+ success: false,
4022
+ error: "scheduledAt is required"
4023
+ }, { status: 400 });
4024
+ }
4025
+ const scheduledDate = new Date(scheduledAt);
4026
+ if (isNaN(scheduledDate.getTime())) {
4027
+ return Response.json({
4028
+ success: false,
4029
+ error: "Invalid scheduledAt date"
4030
+ }, { status: 400 });
4031
+ }
4032
+ if (scheduledDate <= /* @__PURE__ */ new Date()) {
4033
+ return Response.json({
4034
+ success: false,
4035
+ error: "scheduledAt must be in the future"
4036
+ }, { status: 400 });
4037
+ }
4038
+ const broadcastDoc = await req.payload.findByID({
4039
+ collection: collectionSlug,
4040
+ id,
4041
+ user: auth.user
4042
+ });
4043
+ if (!broadcastDoc || !broadcastDoc.providerId) {
4044
+ return Response.json({
4045
+ success: false,
4046
+ error: "Broadcast not found or not synced with provider"
4047
+ }, { status: 404 });
4111
4048
  }
4112
- }
4113
- }),
4114
- // Custom blocks for email-specific content
4115
- BlocksFeature({
4116
- blocks: allBlocks
4117
- })
4118
- ];
4119
- };
4120
- var createEmailLexicalEditor = (customBlocks = []) => {
4121
- const emailSafeBlocks = createEmailSafeBlocks(customBlocks);
4122
- return lexicalEditor({
4123
- features: [
4124
- // Toolbars
4125
- FixedToolbarFeature(),
4126
- InlineToolbarFeature(),
4127
- // Basic text formatting
4128
- BoldFeature(),
4129
- ItalicFeature(),
4130
- UnderlineFeature(),
4131
- StrikethroughFeature(),
4132
- // Links with enhanced configuration
4133
- LinkFeature({
4134
- fields: [
4135
- {
4136
- name: "url",
4137
- type: "text",
4138
- required: true,
4139
- admin: {
4140
- description: "Enter the full URL (including https://)"
4141
- }
4049
+ const providerConfig = config.providers?.broadcast;
4050
+ if (!providerConfig) {
4051
+ return Response.json({
4052
+ success: false,
4053
+ error: "Broadcast provider not configured"
4054
+ }, { status: 500 });
4055
+ }
4056
+ const { BroadcastApiProvider: BroadcastApiProvider2 } = await Promise.resolve().then(() => (init_broadcast2(), broadcast_exports));
4057
+ const provider = new BroadcastApiProvider2(providerConfig);
4058
+ const broadcast = await provider.schedule(broadcastDoc.providerId, scheduledDate);
4059
+ await req.payload.update({
4060
+ collection: collectionSlug,
4061
+ id,
4062
+ data: {
4063
+ sendStatus: "scheduled" /* SCHEDULED */,
4064
+ scheduledAt: scheduledDate.toISOString()
4142
4065
  },
4143
- {
4144
- name: "newTab",
4145
- type: "checkbox",
4146
- label: "Open in new tab",
4147
- defaultValue: false
4148
- }
4149
- ]
4150
- }),
4151
- // Lists
4152
- OrderedListFeature(),
4153
- UnorderedListFeature(),
4154
- // Headings - limited to h1, h2, h3 for email compatibility
4155
- HeadingFeature({
4156
- enabledHeadingSizes: ["h1", "h2", "h3"]
4157
- }),
4158
- // Basic paragraph and alignment
4159
- ParagraphFeature(),
4160
- AlignFeature(),
4161
- // Blockquotes
4162
- BlockquoteFeature(),
4163
- // Upload feature for images
4164
- UploadFeature({
4165
- collections: {
4166
- media: {
4167
- fields: [
4168
- {
4169
- name: "caption",
4170
- type: "text",
4171
- admin: {
4172
- description: "Optional caption for the image"
4173
- }
4174
- },
4175
- {
4176
- name: "altText",
4177
- type: "text",
4178
- label: "Alt Text",
4179
- required: true,
4180
- admin: {
4181
- description: "Alternative text for accessibility and when image cannot be displayed"
4182
- }
4183
- }
4184
- ]
4185
- }
4066
+ user: auth.user
4067
+ });
4068
+ return Response.json({
4069
+ success: true,
4070
+ message: `Broadcast scheduled for ${scheduledDate.toISOString()}`,
4071
+ broadcast
4072
+ });
4073
+ } catch (error) {
4074
+ console.error("Failed to schedule broadcast:", error);
4075
+ if (error instanceof NewsletterProviderError) {
4076
+ return Response.json({
4077
+ success: false,
4078
+ error: error.message,
4079
+ code: error.code
4080
+ }, { status: error.code === "NOT_SUPPORTED" ? 501 : 500 });
4186
4081
  }
4187
- }),
4188
- // Email-safe blocks (processed server-side)
4189
- BlocksFeature({
4190
- blocks: emailSafeBlocks
4191
- })
4192
- ]
4193
- });
4082
+ return Response.json({
4083
+ success: false,
4084
+ error: "Failed to schedule broadcast"
4085
+ }, { status: 500 });
4086
+ }
4087
+ }
4088
+ };
4194
4089
  };
4195
- var emailSafeFeatures = createEmailSafeFeatures();
4196
- var createEmailContentField = (overrides) => {
4197
- const editor = overrides?.editor || createEmailLexicalEditor(overrides?.additionalBlocks);
4090
+
4091
+ // src/endpoints/broadcasts/test.ts
4092
+ var createTestBroadcastEndpoint = (config, collectionSlug) => {
4198
4093
  return {
4199
- name: "content",
4200
- type: "richText",
4201
- required: true,
4202
- editor,
4203
- admin: {
4204
- description: "Email content with limited formatting for compatibility",
4205
- ...overrides?.admin
4206
- },
4207
- ...overrides
4094
+ path: "/:id/test",
4095
+ method: "post",
4096
+ handler: async (req) => {
4097
+ try {
4098
+ const auth = await requireAdmin(req, config);
4099
+ if (!auth.authorized) {
4100
+ return Response.json({
4101
+ success: false,
4102
+ error: auth.error
4103
+ }, { status: 401 });
4104
+ }
4105
+ const url = new URL(req.url || "", `http://localhost`);
4106
+ const pathParts = url.pathname.split("/");
4107
+ const id = pathParts[pathParts.length - 2];
4108
+ if (!id) {
4109
+ return Response.json({
4110
+ success: false,
4111
+ error: "Broadcast ID is required"
4112
+ }, { status: 400 });
4113
+ }
4114
+ const data = await (req.json?.() || Promise.resolve({}));
4115
+ const testEmail = data.email || auth.user.email;
4116
+ if (!testEmail) {
4117
+ return Response.json({
4118
+ success: false,
4119
+ error: "No email address available for test send"
4120
+ }, { status: 400 });
4121
+ }
4122
+ const broadcast = await req.payload.findByID({
4123
+ collection: collectionSlug,
4124
+ id,
4125
+ user: auth.user
4126
+ });
4127
+ if (!broadcast) {
4128
+ return Response.json({
4129
+ success: false,
4130
+ error: "Broadcast not found"
4131
+ }, { status: 404 });
4132
+ }
4133
+ const htmlContent = await convertToEmailSafeHtml(broadcast.content, {
4134
+ wrapInTemplate: true,
4135
+ preheader: broadcast.preheader,
4136
+ customBlockConverter: config.customizations?.broadcasts?.customBlockConverter
4137
+ });
4138
+ const emailService = req.payload.newsletterEmailService;
4139
+ if (!emailService) {
4140
+ return Response.json({
4141
+ success: false,
4142
+ error: "Email service is not configured"
4143
+ }, { status: 500 });
4144
+ }
4145
+ const providerConfig = config.providers.default === "resend" ? config.providers.resend : config.providers.broadcast;
4146
+ const fromEmail = providerConfig?.fromAddress || providerConfig?.fromEmail || "noreply@example.com";
4147
+ const fromName = providerConfig?.fromName || "Newsletter";
4148
+ const replyTo = broadcast.settings?.replyTo || providerConfig?.replyTo;
4149
+ await emailService.send({
4150
+ to: testEmail,
4151
+ from: fromEmail,
4152
+ fromName,
4153
+ replyTo,
4154
+ subject: `[TEST] ${broadcast.subject}`,
4155
+ html: htmlContent,
4156
+ trackOpens: false,
4157
+ trackClicks: false
4158
+ });
4159
+ return Response.json({
4160
+ success: true,
4161
+ message: `Test email sent to ${testEmail}`
4162
+ });
4163
+ } catch (error) {
4164
+ console.error("Failed to send test broadcast:", error);
4165
+ return Response.json({
4166
+ success: false,
4167
+ error: "Failed to send test email"
4168
+ }, { status: 500 });
4169
+ }
4170
+ }
4208
4171
  };
4209
4172
  };
4210
4173
 
4211
- // src/fields/broadcastInlinePreview.ts
4212
- var createBroadcastInlinePreviewField = () => {
4174
+ // src/endpoints/broadcasts/preview.ts
4175
+ var createBroadcastPreviewEndpoint = (config, collectionSlug) => {
4213
4176
  return {
4214
- name: "broadcastInlinePreview",
4215
- type: "ui",
4216
- admin: {
4217
- components: {
4218
- Field: "payload-plugin-newsletter/components#BroadcastInlinePreview"
4177
+ path: "/preview",
4178
+ method: "post",
4179
+ handler: async (req) => {
4180
+ try {
4181
+ const data = await (req.json?.() || Promise.resolve({}));
4182
+ const { content, preheader, subject } = data;
4183
+ if (!content) {
4184
+ return Response.json({
4185
+ success: false,
4186
+ error: "Content is required for preview"
4187
+ }, { status: 400 });
4188
+ }
4189
+ const mediaUrl = req.payload.config.serverURL ? `${req.payload.config.serverURL}/api/media` : "/api/media";
4190
+ const htmlContent = await convertToEmailSafeHtml(content, {
4191
+ wrapInTemplate: true,
4192
+ preheader,
4193
+ mediaUrl,
4194
+ customBlockConverter: config.customizations?.broadcasts?.customBlockConverter
4195
+ });
4196
+ return Response.json({
4197
+ success: true,
4198
+ preview: {
4199
+ subject: subject || "Preview",
4200
+ preheader: preheader || "",
4201
+ html: htmlContent
4202
+ }
4203
+ });
4204
+ } catch (error) {
4205
+ console.error("Failed to generate email preview:", error);
4206
+ return Response.json({
4207
+ success: false,
4208
+ error: "Failed to generate email preview"
4209
+ }, { status: 500 });
4219
4210
  }
4220
4211
  }
4221
4212
  };
@@ -4225,8 +4216,9 @@ var createBroadcastInlinePreviewField = () => {
4225
4216
  var createBroadcastsCollection = (pluginConfig) => {
4226
4217
  const hasProviders = !!(pluginConfig.providers?.broadcast || pluginConfig.providers?.resend);
4227
4218
  const customizations = pluginConfig.customizations?.broadcasts;
4219
+ const collectionSlug = "broadcasts";
4228
4220
  return {
4229
- slug: "broadcasts",
4221
+ slug: collectionSlug,
4230
4222
  access: {
4231
4223
  read: () => true,
4232
4224
  // Public read access
@@ -4255,6 +4247,12 @@ var createBroadcastsCollection = (pluginConfig) => {
4255
4247
  description: "Individual email campaigns sent to subscribers",
4256
4248
  defaultColumns: ["subject", "_status", "sendStatus", "sentAt", "recipientCount"]
4257
4249
  },
4250
+ endpoints: [
4251
+ createSendBroadcastEndpoint(pluginConfig, collectionSlug),
4252
+ createScheduleBroadcastEndpoint(pluginConfig, collectionSlug),
4253
+ createTestBroadcastEndpoint(pluginConfig, collectionSlug),
4254
+ createBroadcastPreviewEndpoint(pluginConfig, collectionSlug)
4255
+ ],
4258
4256
  fields: [
4259
4257
  {
4260
4258
  name: "subject",