payload-plugin-newsletter 0.4.4 → 0.6.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.
Files changed (150) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/ESM_FIX_SUMMARY.md +74 -0
  3. package/dist/client.cjs +899 -0
  4. package/dist/client.cjs.map +1 -0
  5. package/dist/client.d.cts +52 -0
  6. package/dist/client.d.ts +52 -0
  7. package/dist/client.js +867 -0
  8. package/dist/client.js.map +1 -0
  9. package/dist/components.cjs +899 -0
  10. package/dist/components.cjs.map +1 -0
  11. package/dist/components.d.cts +4 -0
  12. package/dist/components.d.ts +4 -0
  13. package/dist/components.js +867 -0
  14. package/dist/components.js.map +1 -0
  15. package/dist/index.cjs +1928 -0
  16. package/dist/index.cjs.map +1 -0
  17. package/dist/index.d.cts +11 -0
  18. package/dist/index.d.ts +6 -5
  19. package/dist/index.js +1891 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/types.cjs +19 -0
  22. package/dist/types.cjs.map +1 -0
  23. package/dist/{types/index.d.ts → types.d.cts} +20 -18
  24. package/dist/types.d.ts +350 -0
  25. package/dist/types.js +1 -0
  26. package/dist/types.js.map +1 -0
  27. package/package.json +40 -17
  28. package/dist/.tsbuildinfo +0 -1
  29. package/dist/collections/NewsletterSettings.d.ts +0 -4
  30. package/dist/collections/NewsletterSettings.d.ts.map +0 -1
  31. package/dist/collections/Subscribers.d.ts +0 -4
  32. package/dist/collections/Subscribers.d.ts.map +0 -1
  33. package/dist/components/MagicLinkVerify.d.ts +0 -27
  34. package/dist/components/MagicLinkVerify.d.ts.map +0 -1
  35. package/dist/components/NewsletterForm.d.ts +0 -5
  36. package/dist/components/NewsletterForm.d.ts.map +0 -1
  37. package/dist/components/PreferencesForm.d.ts +0 -5
  38. package/dist/components/PreferencesForm.d.ts.map +0 -1
  39. package/dist/components/index.d.ts +0 -5
  40. package/dist/components/index.d.ts.map +0 -1
  41. package/dist/endpoints/index.d.ts +0 -4
  42. package/dist/endpoints/index.d.ts.map +0 -1
  43. package/dist/endpoints/preferences.d.ts +0 -5
  44. package/dist/endpoints/preferences.d.ts.map +0 -1
  45. package/dist/endpoints/subscribe.d.ts +0 -4
  46. package/dist/endpoints/subscribe.d.ts.map +0 -1
  47. package/dist/endpoints/unsubscribe.d.ts +0 -4
  48. package/dist/endpoints/unsubscribe.d.ts.map +0 -1
  49. package/dist/endpoints/verify-magic-link.d.ts +0 -4
  50. package/dist/endpoints/verify-magic-link.d.ts.map +0 -1
  51. package/dist/exports/client.d.ts +0 -6
  52. package/dist/exports/client.d.ts.map +0 -1
  53. package/dist/exports/components.d.ts +0 -2
  54. package/dist/exports/components.d.ts.map +0 -1
  55. package/dist/exports/types.d.ts +0 -2
  56. package/dist/exports/types.d.ts.map +0 -1
  57. package/dist/fields/newsletterScheduling.d.ts +0 -4
  58. package/dist/fields/newsletterScheduling.d.ts.map +0 -1
  59. package/dist/hooks/useNewsletterAuth.d.ts +0 -16
  60. package/dist/hooks/useNewsletterAuth.d.ts.map +0 -1
  61. package/dist/index.d.ts.map +0 -1
  62. package/dist/providers/broadcast.d.ts +0 -19
  63. package/dist/providers/broadcast.d.ts.map +0 -1
  64. package/dist/providers/index.d.ts +0 -23
  65. package/dist/providers/index.d.ts.map +0 -1
  66. package/dist/providers/resend.d.ts +0 -20
  67. package/dist/providers/resend.d.ts.map +0 -1
  68. package/dist/providers/types.d.ts +0 -46
  69. package/dist/providers/types.d.ts.map +0 -1
  70. package/dist/src/collections/NewsletterSettings.js +0 -390
  71. package/dist/src/collections/NewsletterSettings.js.map +0 -1
  72. package/dist/src/collections/Subscribers.js +0 -309
  73. package/dist/src/collections/Subscribers.js.map +0 -1
  74. package/dist/src/components/MagicLinkVerify.js +0 -180
  75. package/dist/src/components/MagicLinkVerify.js.map +0 -1
  76. package/dist/src/components/NewsletterForm.js +0 -326
  77. package/dist/src/components/NewsletterForm.js.map +0 -1
  78. package/dist/src/components/PreferencesForm.js +0 -524
  79. package/dist/src/components/PreferencesForm.js.map +0 -1
  80. package/dist/src/components/index.js +0 -5
  81. package/dist/src/components/index.js.map +0 -1
  82. package/dist/src/endpoints/index.js +0 -17
  83. package/dist/src/endpoints/index.js.map +0 -1
  84. package/dist/src/endpoints/preferences.js +0 -136
  85. package/dist/src/endpoints/preferences.js.map +0 -1
  86. package/dist/src/endpoints/subscribe.js +0 -162
  87. package/dist/src/endpoints/subscribe.js.map +0 -1
  88. package/dist/src/endpoints/unsubscribe.js +0 -105
  89. package/dist/src/endpoints/unsubscribe.js.map +0 -1
  90. package/dist/src/endpoints/verify-magic-link.js +0 -103
  91. package/dist/src/endpoints/verify-magic-link.js.map +0 -1
  92. package/dist/src/exports/client.js +0 -7
  93. package/dist/src/exports/client.js.map +0 -1
  94. package/dist/src/exports/components.js +0 -6
  95. package/dist/src/exports/components.js.map +0 -1
  96. package/dist/src/exports/types.js +0 -3
  97. package/dist/src/exports/types.js.map +0 -1
  98. package/dist/src/fields/newsletterScheduling.js +0 -194
  99. package/dist/src/fields/newsletterScheduling.js.map +0 -1
  100. package/dist/src/hooks/useNewsletterAuth.js +0 -112
  101. package/dist/src/hooks/useNewsletterAuth.js.map +0 -1
  102. package/dist/src/index.js +0 -130
  103. package/dist/src/index.js.map +0 -1
  104. package/dist/src/providers/broadcast.js +0 -158
  105. package/dist/src/providers/broadcast.js.map +0 -1
  106. package/dist/src/providers/index.js +0 -63
  107. package/dist/src/providers/index.js.map +0 -1
  108. package/dist/src/providers/resend.js +0 -122
  109. package/dist/src/providers/resend.js.map +0 -1
  110. package/dist/src/providers/types.js +0 -12
  111. package/dist/src/providers/types.js.map +0 -1
  112. package/dist/src/templates/BaseTemplate.js +0 -105
  113. package/dist/src/templates/BaseTemplate.js.map +0 -1
  114. package/dist/src/templates/MagicLinkTemplate.js +0 -178
  115. package/dist/src/templates/MagicLinkTemplate.js.map +0 -1
  116. package/dist/src/templates/NewsletterTemplate.js +0 -150
  117. package/dist/src/templates/NewsletterTemplate.js.map +0 -1
  118. package/dist/src/templates/WelcomeTemplate.js +0 -192
  119. package/dist/src/templates/WelcomeTemplate.js.map +0 -1
  120. package/dist/src/templates/index.js +0 -6
  121. package/dist/src/templates/index.js.map +0 -1
  122. package/dist/src/types/index.js +0 -3
  123. package/dist/src/types/index.js.map +0 -1
  124. package/dist/src/utils/access.js +0 -80
  125. package/dist/src/utils/access.js.map +0 -1
  126. package/dist/src/utils/jwt.js +0 -91
  127. package/dist/src/utils/jwt.js.map +0 -1
  128. package/dist/src/utils/rate-limiter.js +0 -43
  129. package/dist/src/utils/rate-limiter.js.map +0 -1
  130. package/dist/src/utils/validation.js +0 -148
  131. package/dist/src/utils/validation.js.map +0 -1
  132. package/dist/templates/BaseTemplate.d.ts +0 -45
  133. package/dist/templates/BaseTemplate.d.ts.map +0 -1
  134. package/dist/templates/MagicLinkTemplate.d.ts +0 -67
  135. package/dist/templates/MagicLinkTemplate.d.ts.map +0 -1
  136. package/dist/templates/NewsletterTemplate.d.ts +0 -112
  137. package/dist/templates/NewsletterTemplate.d.ts.map +0 -1
  138. package/dist/templates/WelcomeTemplate.d.ts +0 -55
  139. package/dist/templates/WelcomeTemplate.d.ts.map +0 -1
  140. package/dist/templates/index.d.ts +0 -7
  141. package/dist/templates/index.d.ts.map +0 -1
  142. package/dist/types/index.d.ts.map +0 -1
  143. package/dist/utils/access.d.ts +0 -15
  144. package/dist/utils/access.d.ts.map +0 -1
  145. package/dist/utils/jwt.d.ts +0 -32
  146. package/dist/utils/jwt.d.ts.map +0 -1
  147. package/dist/utils/rate-limiter.d.ts +0 -15
  148. package/dist/utils/rate-limiter.d.ts.map +0 -1
  149. package/dist/utils/validation.d.ts +0 -33
  150. package/dist/utils/validation.d.ts.map +0 -1
package/dist/index.js ADDED
@@ -0,0 +1,1891 @@
1
+ // src/utils/access.ts
2
+ var isAdmin = (user, config) => {
3
+ if (!user || user.collection !== "users") {
4
+ return false;
5
+ }
6
+ if (config?.access?.isAdmin) {
7
+ return config.access.isAdmin(user);
8
+ }
9
+ if (user.roles?.includes("admin")) {
10
+ return true;
11
+ }
12
+ if (user.isAdmin === true) {
13
+ return true;
14
+ }
15
+ if (user.role === "admin") {
16
+ return true;
17
+ }
18
+ if (user.admin === true) {
19
+ return true;
20
+ }
21
+ return false;
22
+ };
23
+ var adminOnly = (config) => ({ req }) => {
24
+ const user = req.user;
25
+ return isAdmin(user, config);
26
+ };
27
+ var adminOrSelf = (config) => ({ req, id }) => {
28
+ const user = req.user;
29
+ if (!user) {
30
+ if (!id) {
31
+ return {
32
+ id: {
33
+ equals: "unauthorized-no-access"
34
+ }
35
+ };
36
+ }
37
+ return false;
38
+ }
39
+ if (isAdmin(user, config)) {
40
+ return true;
41
+ }
42
+ if (user.collection === "subscribers") {
43
+ if (!id) {
44
+ return {
45
+ id: {
46
+ equals: user.id
47
+ }
48
+ };
49
+ }
50
+ return id === user.id;
51
+ }
52
+ if (!id) {
53
+ return {
54
+ id: {
55
+ equals: "unauthorized-no-access"
56
+ }
57
+ };
58
+ }
59
+ return false;
60
+ };
61
+
62
+ // src/collections/Subscribers.ts
63
+ var createSubscribersCollection = (pluginConfig) => {
64
+ const slug = pluginConfig.subscribersSlug || "subscribers";
65
+ const defaultFields = [
66
+ // Core fields
67
+ {
68
+ name: "email",
69
+ type: "email",
70
+ required: true,
71
+ unique: true,
72
+ admin: {
73
+ description: "Subscriber email address"
74
+ }
75
+ },
76
+ {
77
+ name: "name",
78
+ type: "text",
79
+ admin: {
80
+ description: "Subscriber full name"
81
+ }
82
+ },
83
+ {
84
+ name: "locale",
85
+ type: "select",
86
+ options: pluginConfig.i18n?.locales?.map((locale) => ({
87
+ label: locale.toUpperCase(),
88
+ value: locale
89
+ })) || [
90
+ { label: "EN", value: "en" }
91
+ ],
92
+ defaultValue: pluginConfig.i18n?.defaultLocale || "en",
93
+ admin: {
94
+ description: "Preferred language for communications"
95
+ }
96
+ },
97
+ // Authentication fields (hidden from admin UI)
98
+ {
99
+ name: "magicLinkToken",
100
+ type: "text",
101
+ hidden: true
102
+ },
103
+ {
104
+ name: "magicLinkTokenExpiry",
105
+ type: "date",
106
+ hidden: true
107
+ },
108
+ // Subscription status
109
+ {
110
+ name: "subscriptionStatus",
111
+ type: "select",
112
+ options: [
113
+ { label: "Active", value: "active" },
114
+ { label: "Unsubscribed", value: "unsubscribed" },
115
+ { label: "Pending", value: "pending" }
116
+ ],
117
+ defaultValue: "pending",
118
+ required: true,
119
+ admin: {
120
+ description: "Current subscription status"
121
+ }
122
+ },
123
+ {
124
+ name: "unsubscribedAt",
125
+ type: "date",
126
+ admin: {
127
+ condition: (data) => data?.subscriptionStatus === "unsubscribed",
128
+ description: "When the user unsubscribed",
129
+ readOnly: true
130
+ }
131
+ },
132
+ // Email preferences
133
+ {
134
+ name: "emailPreferences",
135
+ type: "group",
136
+ fields: [
137
+ {
138
+ name: "newsletter",
139
+ type: "checkbox",
140
+ defaultValue: true,
141
+ label: "Newsletter",
142
+ admin: {
143
+ description: "Receive regular newsletter updates"
144
+ }
145
+ },
146
+ {
147
+ name: "announcements",
148
+ type: "checkbox",
149
+ defaultValue: true,
150
+ label: "Announcements",
151
+ admin: {
152
+ description: "Receive important announcements"
153
+ }
154
+ }
155
+ ],
156
+ admin: {
157
+ description: "Email communication preferences"
158
+ }
159
+ },
160
+ // Source tracking
161
+ {
162
+ name: "source",
163
+ type: "text",
164
+ admin: {
165
+ description: "Where the subscriber signed up from"
166
+ }
167
+ }
168
+ ];
169
+ if (pluginConfig.features?.utmTracking?.enabled) {
170
+ const utmFields = pluginConfig.features.utmTracking.fields || [
171
+ "source",
172
+ "medium",
173
+ "campaign",
174
+ "content",
175
+ "term"
176
+ ];
177
+ defaultFields.push({
178
+ name: "utmParameters",
179
+ type: "group",
180
+ fields: utmFields.map((field) => ({
181
+ name: field,
182
+ type: "text",
183
+ admin: {
184
+ description: `UTM ${field} parameter`
185
+ }
186
+ })),
187
+ admin: {
188
+ description: "UTM tracking parameters"
189
+ }
190
+ });
191
+ }
192
+ defaultFields.push({
193
+ name: "signupMetadata",
194
+ type: "group",
195
+ fields: [
196
+ {
197
+ name: "ipAddress",
198
+ type: "text",
199
+ admin: {
200
+ readOnly: true
201
+ }
202
+ },
203
+ {
204
+ name: "userAgent",
205
+ type: "text",
206
+ admin: {
207
+ readOnly: true
208
+ }
209
+ },
210
+ {
211
+ name: "referrer",
212
+ type: "text",
213
+ admin: {
214
+ readOnly: true
215
+ }
216
+ },
217
+ {
218
+ name: "signupPage",
219
+ type: "text",
220
+ admin: {
221
+ readOnly: true
222
+ }
223
+ }
224
+ ],
225
+ admin: {
226
+ description: "Technical information about signup"
227
+ }
228
+ });
229
+ if (pluginConfig.features?.leadMagnets?.enabled) {
230
+ defaultFields.push({
231
+ name: "leadMagnet",
232
+ type: "relationship",
233
+ relationTo: pluginConfig.features.leadMagnets.collection || "media",
234
+ admin: {
235
+ description: "Lead magnet downloaded at signup"
236
+ }
237
+ });
238
+ }
239
+ let fields = defaultFields;
240
+ if (pluginConfig.fields?.overrides) {
241
+ fields = pluginConfig.fields.overrides({ defaultFields });
242
+ }
243
+ if (pluginConfig.fields?.additional) {
244
+ fields = [...fields, ...pluginConfig.fields.additional];
245
+ }
246
+ const subscribersCollection = {
247
+ slug,
248
+ labels: {
249
+ singular: "Subscriber",
250
+ plural: "Subscribers"
251
+ },
252
+ admin: {
253
+ useAsTitle: "email",
254
+ defaultColumns: ["email", "name", "subscriptionStatus", "createdAt"],
255
+ group: "Newsletter"
256
+ },
257
+ fields,
258
+ hooks: {
259
+ afterChange: [
260
+ async ({ doc, req, operation, previousDoc }) => {
261
+ if (operation === "create") {
262
+ const emailService = req.payload.newsletterEmailService;
263
+ if (emailService) {
264
+ try {
265
+ await emailService.addContact(doc);
266
+ } catch {
267
+ }
268
+ }
269
+ if (doc.subscriptionStatus === "active" && emailService) {
270
+ try {
271
+ } catch {
272
+ }
273
+ }
274
+ if (pluginConfig.hooks?.afterSubscribe) {
275
+ await pluginConfig.hooks.afterSubscribe({ doc, req });
276
+ }
277
+ }
278
+ if (operation === "update" && previousDoc) {
279
+ const emailService = req.payload.newsletterEmailService;
280
+ if (doc.subscriptionStatus !== previousDoc.subscriptionStatus && emailService) {
281
+ try {
282
+ await emailService.updateContact(doc);
283
+ } catch {
284
+ }
285
+ }
286
+ if (doc.subscriptionStatus === "unsubscribed" && previousDoc.subscriptionStatus !== "unsubscribed") {
287
+ doc.unsubscribedAt = (/* @__PURE__ */ new Date()).toISOString();
288
+ if (pluginConfig.hooks?.afterUnsubscribe) {
289
+ await pluginConfig.hooks.afterUnsubscribe({ doc, req });
290
+ }
291
+ }
292
+ }
293
+ }
294
+ ],
295
+ beforeDelete: [
296
+ async ({ id, req }) => {
297
+ const emailService = req.payload.newsletterEmailService;
298
+ if (emailService) {
299
+ try {
300
+ const doc = await req.payload.findByID({
301
+ collection: slug,
302
+ id
303
+ });
304
+ await emailService.removeContact(doc.email);
305
+ } catch {
306
+ }
307
+ }
308
+ }
309
+ ]
310
+ },
311
+ access: {
312
+ create: () => true,
313
+ // Public can subscribe
314
+ read: adminOrSelf(pluginConfig),
315
+ update: adminOrSelf(pluginConfig),
316
+ delete: adminOnly(pluginConfig)
317
+ },
318
+ timestamps: true
319
+ };
320
+ return subscribersCollection;
321
+ };
322
+
323
+ // src/globals/NewsletterSettings.ts
324
+ var createNewsletterSettingsGlobal = (pluginConfig) => {
325
+ const slug = pluginConfig.settingsSlug || "newsletter-settings";
326
+ return {
327
+ slug,
328
+ label: "Newsletter Settings",
329
+ admin: {
330
+ group: "Newsletter",
331
+ description: "Configure email provider settings and templates"
332
+ },
333
+ fields: [
334
+ {
335
+ type: "tabs",
336
+ tabs: [
337
+ {
338
+ label: "Provider Settings",
339
+ fields: [
340
+ {
341
+ name: "provider",
342
+ type: "select",
343
+ label: "Email Provider",
344
+ required: true,
345
+ options: [
346
+ { label: "Resend", value: "resend" },
347
+ { label: "Broadcast (Self-Hosted)", value: "broadcast" }
348
+ ],
349
+ defaultValue: pluginConfig.providers.default,
350
+ admin: {
351
+ description: "Choose which email service to use"
352
+ }
353
+ },
354
+ {
355
+ name: "resendSettings",
356
+ type: "group",
357
+ label: "Resend Settings",
358
+ admin: {
359
+ condition: (data) => data?.provider === "resend"
360
+ },
361
+ fields: [
362
+ {
363
+ name: "apiKey",
364
+ type: "text",
365
+ label: "API Key",
366
+ required: true,
367
+ admin: {
368
+ description: "Your Resend API key"
369
+ }
370
+ },
371
+ {
372
+ name: "audienceIds",
373
+ type: "array",
374
+ label: "Audience IDs by Locale",
375
+ fields: [
376
+ {
377
+ name: "locale",
378
+ type: "select",
379
+ label: "Locale",
380
+ required: true,
381
+ options: pluginConfig.i18n?.locales?.map((locale) => ({
382
+ label: locale.toUpperCase(),
383
+ value: locale
384
+ })) || [
385
+ { label: "EN", value: "en" }
386
+ ]
387
+ },
388
+ {
389
+ name: "production",
390
+ type: "text",
391
+ label: "Production Audience ID"
392
+ },
393
+ {
394
+ name: "development",
395
+ type: "text",
396
+ label: "Development Audience ID"
397
+ }
398
+ ]
399
+ }
400
+ ]
401
+ },
402
+ {
403
+ name: "broadcastSettings",
404
+ type: "group",
405
+ label: "Broadcast Settings",
406
+ admin: {
407
+ condition: (data) => data?.provider === "broadcast"
408
+ },
409
+ fields: [
410
+ {
411
+ name: "apiUrl",
412
+ type: "text",
413
+ label: "API URL",
414
+ required: true,
415
+ admin: {
416
+ description: "Your Broadcast instance URL"
417
+ }
418
+ },
419
+ {
420
+ name: "productionToken",
421
+ type: "text",
422
+ label: "Production Token",
423
+ admin: {
424
+ description: "Token for production environment"
425
+ }
426
+ },
427
+ {
428
+ name: "developmentToken",
429
+ type: "text",
430
+ label: "Development Token",
431
+ admin: {
432
+ description: "Token for development environment"
433
+ }
434
+ }
435
+ ]
436
+ },
437
+ {
438
+ name: "fromAddress",
439
+ type: "email",
440
+ label: "From Address",
441
+ required: true,
442
+ admin: {
443
+ description: "Default sender email address"
444
+ }
445
+ },
446
+ {
447
+ name: "fromName",
448
+ type: "text",
449
+ label: "From Name",
450
+ required: true,
451
+ admin: {
452
+ description: "Default sender name"
453
+ }
454
+ },
455
+ {
456
+ name: "replyTo",
457
+ type: "email",
458
+ label: "Reply-To Address",
459
+ admin: {
460
+ description: "Optional reply-to email address"
461
+ }
462
+ }
463
+ ]
464
+ },
465
+ {
466
+ label: "Email Templates",
467
+ fields: [
468
+ {
469
+ name: "emailTemplates",
470
+ type: "group",
471
+ label: "Email Templates",
472
+ fields: [
473
+ {
474
+ name: "welcome",
475
+ type: "group",
476
+ label: "Welcome Email",
477
+ fields: [
478
+ {
479
+ name: "enabled",
480
+ type: "checkbox",
481
+ label: "Send Welcome Email",
482
+ defaultValue: true
483
+ },
484
+ {
485
+ name: "subject",
486
+ type: "text",
487
+ label: "Subject Line",
488
+ defaultValue: "Welcome to {{fromName}}!",
489
+ admin: {
490
+ condition: (data) => data?.emailTemplates?.welcome?.enabled
491
+ }
492
+ },
493
+ {
494
+ name: "preheader",
495
+ type: "text",
496
+ label: "Preheader Text",
497
+ admin: {
498
+ condition: (data) => data?.emailTemplates?.welcome?.enabled
499
+ }
500
+ }
501
+ ]
502
+ },
503
+ {
504
+ name: "magicLink",
505
+ type: "group",
506
+ label: "Magic Link Email",
507
+ fields: [
508
+ {
509
+ name: "subject",
510
+ type: "text",
511
+ label: "Subject Line",
512
+ defaultValue: "Sign in to {{fromName}}"
513
+ },
514
+ {
515
+ name: "preheader",
516
+ type: "text",
517
+ label: "Preheader Text",
518
+ defaultValue: "Click the link to access your preferences"
519
+ },
520
+ {
521
+ name: "expirationTime",
522
+ type: "select",
523
+ label: "Link Expiration",
524
+ defaultValue: "7d",
525
+ options: [
526
+ { label: "1 hour", value: "1h" },
527
+ { label: "24 hours", value: "24h" },
528
+ { label: "7 days", value: "7d" },
529
+ { label: "30 days", value: "30d" }
530
+ ]
531
+ }
532
+ ]
533
+ }
534
+ ]
535
+ }
536
+ ]
537
+ },
538
+ {
539
+ label: "Subscription Settings",
540
+ fields: [
541
+ {
542
+ name: "subscriptionSettings",
543
+ type: "group",
544
+ label: "Subscription Settings",
545
+ fields: [
546
+ {
547
+ name: "requireDoubleOptIn",
548
+ type: "checkbox",
549
+ label: "Require Double Opt-In",
550
+ defaultValue: false,
551
+ admin: {
552
+ description: "Require email confirmation before activating subscriptions"
553
+ }
554
+ },
555
+ {
556
+ name: "allowedDomains",
557
+ type: "array",
558
+ label: "Allowed Email Domains",
559
+ admin: {
560
+ description: "Leave empty to allow all domains"
561
+ },
562
+ fields: [
563
+ {
564
+ name: "domain",
565
+ type: "text",
566
+ label: "Domain",
567
+ required: true,
568
+ admin: {
569
+ placeholder: "example.com"
570
+ }
571
+ }
572
+ ]
573
+ },
574
+ {
575
+ name: "maxSubscribersPerIP",
576
+ type: "number",
577
+ label: "Max Subscribers per IP",
578
+ defaultValue: 10,
579
+ min: 1,
580
+ admin: {
581
+ description: "Maximum number of subscriptions allowed from a single IP address"
582
+ }
583
+ }
584
+ ]
585
+ }
586
+ ]
587
+ }
588
+ ]
589
+ }
590
+ ],
591
+ hooks: {
592
+ beforeChange: [
593
+ async ({ data, req }) => {
594
+ if (!req.user || req.user.collection !== "users") {
595
+ throw new Error("Only administrators can modify newsletter settings");
596
+ }
597
+ return data;
598
+ }
599
+ ],
600
+ afterChange: [
601
+ async ({ doc, req }) => {
602
+ if (req.payload.newsletterEmailService) {
603
+ try {
604
+ console.warn("Newsletter settings updated, reinitializing service...");
605
+ } catch {
606
+ }
607
+ }
608
+ return doc;
609
+ }
610
+ ]
611
+ },
612
+ access: {
613
+ read: () => true,
614
+ // Settings can be read publicly for validation
615
+ update: adminOnly(pluginConfig)
616
+ }
617
+ };
618
+ };
619
+
620
+ // src/providers/resend.ts
621
+ import { Resend } from "resend";
622
+
623
+ // src/providers/types.ts
624
+ var EmailProviderError = class extends Error {
625
+ constructor(message, provider, originalError) {
626
+ super(message);
627
+ this.name = "EmailProviderError";
628
+ this.provider = provider;
629
+ this.originalError = originalError;
630
+ }
631
+ };
632
+
633
+ // src/providers/resend.ts
634
+ var ResendProvider = class {
635
+ constructor(config) {
636
+ this.client = new Resend(config.apiKey);
637
+ this.audienceIds = config.audienceIds || {};
638
+ this.fromAddress = config.fromAddress;
639
+ this.fromName = config.fromName;
640
+ this.isDevelopment = process.env.NODE_ENV !== "production";
641
+ }
642
+ getProvider() {
643
+ return "resend";
644
+ }
645
+ async send(params) {
646
+ try {
647
+ const from = params.from || {
648
+ email: this.fromAddress,
649
+ name: this.fromName
650
+ };
651
+ if (!params.html && !params.text) {
652
+ throw new Error("Either html or text content is required");
653
+ }
654
+ await this.client.emails.send({
655
+ from: `${from.name} <${from.email}>`,
656
+ to: Array.isArray(params.to) ? params.to : [params.to],
657
+ subject: params.subject,
658
+ html: params.html || "",
659
+ text: params.text,
660
+ replyTo: params.replyTo
661
+ });
662
+ } catch (error) {
663
+ throw new EmailProviderError(
664
+ `Failed to send email via Resend: ${error instanceof Error ? error.message : "Unknown error"}`,
665
+ "resend",
666
+ error
667
+ );
668
+ }
669
+ }
670
+ async addContact(contact) {
671
+ try {
672
+ const audienceId = this.getAudienceId(contact.locale);
673
+ if (!audienceId) {
674
+ console.warn(`No audience ID configured for locale: ${contact.locale}`);
675
+ return;
676
+ }
677
+ await this.client.contacts.create({
678
+ email: contact.email,
679
+ firstName: contact.name?.split(" ")[0],
680
+ lastName: contact.name?.split(" ").slice(1).join(" "),
681
+ unsubscribed: contact.subscriptionStatus === "unsubscribed",
682
+ audienceId
683
+ });
684
+ } catch (error) {
685
+ throw new EmailProviderError(
686
+ `Failed to add contact to Resend: ${error instanceof Error ? error.message : "Unknown error"}`,
687
+ "resend",
688
+ error
689
+ );
690
+ }
691
+ }
692
+ async updateContact(contact) {
693
+ try {
694
+ const audienceId = this.getAudienceId(contact.locale);
695
+ if (!audienceId) {
696
+ console.warn(`No audience ID configured for locale: ${contact.locale}`);
697
+ return;
698
+ }
699
+ const contacts = await this.client.contacts.list({ audienceId });
700
+ const existingContact = contacts.data?.data?.find((c) => c.email === contact.email);
701
+ if (existingContact) {
702
+ await this.client.contacts.update({
703
+ id: existingContact.id,
704
+ audienceId,
705
+ firstName: contact.name?.split(" ")[0],
706
+ lastName: contact.name?.split(" ").slice(1).join(" "),
707
+ unsubscribed: contact.subscriptionStatus === "unsubscribed"
708
+ });
709
+ } else {
710
+ await this.addContact(contact);
711
+ }
712
+ } catch (error) {
713
+ throw new EmailProviderError(
714
+ `Failed to update contact in Resend: ${error instanceof Error ? error.message : "Unknown error"}`,
715
+ "resend",
716
+ error
717
+ );
718
+ }
719
+ }
720
+ async removeContact(email) {
721
+ try {
722
+ for (const locale in this.audienceIds) {
723
+ const audienceId = this.getAudienceId(locale);
724
+ if (!audienceId) continue;
725
+ const contacts = await this.client.contacts.list({ audienceId });
726
+ const contact = contacts.data?.data?.find((c) => c.email === email);
727
+ if (contact) {
728
+ await this.client.contacts.update({
729
+ id: contact.id,
730
+ audienceId,
731
+ unsubscribed: true
732
+ });
733
+ break;
734
+ }
735
+ }
736
+ } catch (error) {
737
+ throw new EmailProviderError(
738
+ `Failed to remove contact from Resend: ${error instanceof Error ? error.message : "Unknown error"}`,
739
+ "resend",
740
+ error
741
+ );
742
+ }
743
+ }
744
+ getAudienceId(locale) {
745
+ const localeKey = locale || "en";
746
+ if (!this.audienceIds) return void 0;
747
+ const localeConfig = this.audienceIds[localeKey];
748
+ if (!localeConfig) return void 0;
749
+ const audienceId = this.isDevelopment ? localeConfig.development || localeConfig.production : localeConfig.production || localeConfig.development;
750
+ return audienceId;
751
+ }
752
+ };
753
+
754
+ // src/providers/broadcast.ts
755
+ var BroadcastProvider = class {
756
+ constructor(config) {
757
+ this.apiUrl = config.apiUrl.replace(/\/$/, "");
758
+ this.isDevelopment = process.env.NODE_ENV !== "production";
759
+ this.token = this.isDevelopment ? config.tokens.development || config.tokens.production || "" : config.tokens.production || config.tokens.development || "";
760
+ this.fromAddress = config.fromAddress;
761
+ this.fromName = config.fromName;
762
+ }
763
+ getProvider() {
764
+ return "broadcast";
765
+ }
766
+ async send(params) {
767
+ try {
768
+ const from = params.from || {
769
+ email: this.fromAddress,
770
+ name: this.fromName
771
+ };
772
+ const recipients = Array.isArray(params.to) ? params.to : [params.to];
773
+ const response = await fetch(`${this.apiUrl}/api/v1/emails`, {
774
+ method: "POST",
775
+ headers: {
776
+ "Authorization": `Bearer ${this.token}`,
777
+ "Content-Type": "application/json"
778
+ },
779
+ body: JSON.stringify({
780
+ from_email: from.email,
781
+ from_name: from.name,
782
+ to: recipients,
783
+ subject: params.subject,
784
+ html_body: params.html,
785
+ text_body: params.text,
786
+ reply_to: params.replyTo
787
+ })
788
+ });
789
+ if (!response.ok) {
790
+ const error = await response.text();
791
+ throw new Error(`Broadcast API error: ${response.status} - ${error}`);
792
+ }
793
+ } catch (error) {
794
+ throw new EmailProviderError(
795
+ `Failed to send email via Broadcast: ${error instanceof Error ? error.message : "Unknown error"}`,
796
+ "broadcast",
797
+ error
798
+ );
799
+ }
800
+ }
801
+ async addContact(contact) {
802
+ try {
803
+ const response = await fetch(`${this.apiUrl}/api/v1/contacts`, {
804
+ method: "POST",
805
+ headers: {
806
+ "Authorization": `Bearer ${this.token}`,
807
+ "Content-Type": "application/json"
808
+ },
809
+ body: JSON.stringify({
810
+ email: contact.email,
811
+ name: contact.name,
812
+ status: contact.subscriptionStatus === "active" ? "subscribed" : "unsubscribed",
813
+ metadata: {
814
+ locale: contact.locale,
815
+ source: contact.source,
816
+ ...contact.utmParameters
817
+ }
818
+ })
819
+ });
820
+ if (!response.ok) {
821
+ const error = await response.text();
822
+ throw new Error(`Broadcast API error: ${response.status} - ${error}`);
823
+ }
824
+ } catch (error) {
825
+ throw new EmailProviderError(
826
+ `Failed to add contact to Broadcast: ${error instanceof Error ? error.message : "Unknown error"}`,
827
+ "broadcast",
828
+ error
829
+ );
830
+ }
831
+ }
832
+ async updateContact(contact) {
833
+ try {
834
+ const searchResponse = await fetch(
835
+ `${this.apiUrl}/api/v1/contacts?email=${encodeURIComponent(contact.email)}`,
836
+ {
837
+ headers: {
838
+ "Authorization": `Bearer ${this.token}`
839
+ }
840
+ }
841
+ );
842
+ if (!searchResponse.ok) {
843
+ await this.addContact(contact);
844
+ return;
845
+ }
846
+ const contacts = await searchResponse.json();
847
+ const existingContact = contacts.data?.[0];
848
+ if (!existingContact) {
849
+ await this.addContact(contact);
850
+ return;
851
+ }
852
+ const response = await fetch(`${this.apiUrl}/api/v1/contacts/${existingContact.id}`, {
853
+ method: "PUT",
854
+ headers: {
855
+ "Authorization": `Bearer ${this.token}`,
856
+ "Content-Type": "application/json"
857
+ },
858
+ body: JSON.stringify({
859
+ email: contact.email,
860
+ name: contact.name,
861
+ status: contact.subscriptionStatus === "active" ? "subscribed" : "unsubscribed",
862
+ metadata: {
863
+ locale: contact.locale,
864
+ source: contact.source,
865
+ ...contact.utmParameters
866
+ }
867
+ })
868
+ });
869
+ if (!response.ok) {
870
+ const error = await response.text();
871
+ throw new Error(`Broadcast API error: ${response.status} - ${error}`);
872
+ }
873
+ } catch (error) {
874
+ throw new EmailProviderError(
875
+ `Failed to update contact in Broadcast: ${error instanceof Error ? error.message : "Unknown error"}`,
876
+ "broadcast",
877
+ error
878
+ );
879
+ }
880
+ }
881
+ async removeContact(email) {
882
+ try {
883
+ const searchResponse = await fetch(
884
+ `${this.apiUrl}/api/v1/contacts?email=${encodeURIComponent(email)}`,
885
+ {
886
+ headers: {
887
+ "Authorization": `Bearer ${this.token}`
888
+ }
889
+ }
890
+ );
891
+ if (!searchResponse.ok) {
892
+ return;
893
+ }
894
+ const contacts = await searchResponse.json();
895
+ const contact = contacts.data?.[0];
896
+ if (!contact) {
897
+ return;
898
+ }
899
+ const response = await fetch(`${this.apiUrl}/api/v1/contacts/${contact.id}`, {
900
+ method: "DELETE",
901
+ headers: {
902
+ "Authorization": `Bearer ${this.token}`
903
+ }
904
+ });
905
+ if (!response.ok) {
906
+ const error = await response.text();
907
+ throw new Error(`Broadcast API error: ${response.status} - ${error}`);
908
+ }
909
+ } catch (error) {
910
+ throw new EmailProviderError(
911
+ `Failed to remove contact from Broadcast: ${error instanceof Error ? error.message : "Unknown error"}`,
912
+ "broadcast",
913
+ error
914
+ );
915
+ }
916
+ }
917
+ };
918
+
919
+ // src/providers/index.ts
920
+ var EmailService = class {
921
+ constructor(config) {
922
+ this.provider = this.createProvider(config);
923
+ }
924
+ createProvider(config) {
925
+ const baseConfig = {
926
+ fromAddress: config.fromAddress,
927
+ fromName: config.fromName
928
+ };
929
+ switch (config.provider) {
930
+ case "resend":
931
+ if (!config.resend) {
932
+ throw new Error("Resend configuration is required when using Resend provider");
933
+ }
934
+ return new ResendProvider({
935
+ ...config.resend,
936
+ ...baseConfig
937
+ });
938
+ case "broadcast":
939
+ if (!config.broadcast) {
940
+ throw new Error("Broadcast configuration is required when using Broadcast provider");
941
+ }
942
+ return new BroadcastProvider({
943
+ ...config.broadcast,
944
+ ...baseConfig
945
+ });
946
+ default:
947
+ throw new Error(`Unknown email provider: ${config.provider}`);
948
+ }
949
+ }
950
+ async send(params) {
951
+ return this.provider.send(params);
952
+ }
953
+ async addContact(contact) {
954
+ return this.provider.addContact(contact);
955
+ }
956
+ async updateContact(contact) {
957
+ return this.provider.updateContact(contact);
958
+ }
959
+ async removeContact(email) {
960
+ return this.provider.removeContact(email);
961
+ }
962
+ getProvider() {
963
+ return this.provider.getProvider();
964
+ }
965
+ /**
966
+ * Update the provider configuration
967
+ * Useful when settings are changed in the admin UI
968
+ */
969
+ updateConfig(config) {
970
+ this.provider = this.createProvider(config);
971
+ }
972
+ };
973
+ function createEmailService(config) {
974
+ return new EmailService(config);
975
+ }
976
+
977
+ // src/utils/validation.ts
978
+ import DOMPurify from "isomorphic-dompurify";
979
+ function isValidEmail(email) {
980
+ if (!email || typeof email !== "string") return false;
981
+ const trimmed = email.trim();
982
+ if (trimmed.length > 255) return false;
983
+ if (trimmed.includes("<") || trimmed.includes(">")) return false;
984
+ if (trimmed.includes("javascript:")) return false;
985
+ if (trimmed.includes("data:")) return false;
986
+ const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
987
+ if (!emailRegex.test(trimmed)) return false;
988
+ const parts = trimmed.split("@");
989
+ if (parts.length !== 2) return false;
990
+ const [localPart, domain] = parts;
991
+ if (localPart.length > 64 || localPart.length === 0) return false;
992
+ if (localPart.startsWith(".") || localPart.endsWith(".")) return false;
993
+ if (domain.startsWith(".") || domain.endsWith(".")) return false;
994
+ if (domain.includes("..")) return false;
995
+ if (localPart.includes("..")) return false;
996
+ return true;
997
+ }
998
+ function isDomainAllowed(email, allowedDomains) {
999
+ if (!isValidEmail(email)) {
1000
+ return false;
1001
+ }
1002
+ if (!allowedDomains || allowedDomains.length === 0) {
1003
+ return true;
1004
+ }
1005
+ const domain = email.split("@")[1]?.toLowerCase();
1006
+ if (!domain) return false;
1007
+ return allowedDomains.some(
1008
+ (allowedDomain) => domain === allowedDomain.toLowerCase()
1009
+ );
1010
+ }
1011
+ function sanitizeInput(input) {
1012
+ if (!input) return "";
1013
+ let cleaned = DOMPurify.sanitize(input, {
1014
+ ALLOWED_TAGS: [],
1015
+ ALLOWED_ATTR: [],
1016
+ KEEP_CONTENT: true
1017
+ });
1018
+ cleaned = cleaned.replace(/javascript:/gi, "").replace(/data:/gi, "").replace(/vbscript:/gi, "").replace(/file:\/\//gi, "").replace(/onload/gi, "").replace(/onerror/gi, "").replace(/onclick/gi, "").replace(/onmouseover/gi, "").replace(/alert\(/gi, "").replace(/prompt\(/gi, "").replace(/confirm\(/gi, "").replace(/\|/g, "").replace(/;/g, "").replace(/`/g, "").replace(/&&/g, "").replace(/\$\(/g, "").replace(/\.\./g, "").replace(/\/..\//g, "").replace(/\0/g, "");
1019
+ return cleaned.trim();
1020
+ }
1021
+ function extractUTMParams(searchParams) {
1022
+ const utmParams = {};
1023
+ const utmKeys = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"];
1024
+ utmKeys.forEach((key) => {
1025
+ const value = searchParams.get(key);
1026
+ if (value) {
1027
+ const shortKey = key.replace("utm_", "");
1028
+ utmParams[shortKey] = value;
1029
+ }
1030
+ });
1031
+ return utmParams;
1032
+ }
1033
+ function isValidSource(source) {
1034
+ if (!source || typeof source !== "string") return false;
1035
+ const allowedSources = [
1036
+ "website",
1037
+ "api",
1038
+ "import",
1039
+ "admin",
1040
+ "signup-form",
1041
+ "magic-link",
1042
+ "preferences",
1043
+ "external"
1044
+ ];
1045
+ return allowedSources.includes(source);
1046
+ }
1047
+ function validateSubscriberData(data) {
1048
+ const errors = [];
1049
+ if (!data.email) {
1050
+ errors.push("Email is required");
1051
+ } else if (!isValidEmail(data.email)) {
1052
+ errors.push("Invalid email format");
1053
+ }
1054
+ if (data.name && data.name.length > 100) {
1055
+ errors.push("Name is too long (max 100 characters)");
1056
+ }
1057
+ if (data.source !== void 0) {
1058
+ if (!data.source || data.source.length === 0) {
1059
+ errors.push("Source cannot be empty");
1060
+ } else if (data.source.length > 50) {
1061
+ errors.push("Source is too long (max 50 characters)");
1062
+ } else if (!isValidSource(data.source)) {
1063
+ errors.push("Invalid source value");
1064
+ }
1065
+ }
1066
+ return {
1067
+ valid: errors.length === 0,
1068
+ errors
1069
+ };
1070
+ }
1071
+
1072
+ // src/endpoints/subscribe.ts
1073
+ var createSubscribeEndpoint = (config) => {
1074
+ return {
1075
+ path: "/newsletter/subscribe",
1076
+ method: "post",
1077
+ handler: async (req, res) => {
1078
+ try {
1079
+ const {
1080
+ email,
1081
+ name,
1082
+ source,
1083
+ preferences,
1084
+ leadMagnet,
1085
+ surveyResponses,
1086
+ metadata = {}
1087
+ } = req.body;
1088
+ const trimmedEmail = email?.trim();
1089
+ const validation = validateSubscriberData({ email: trimmedEmail, name, source });
1090
+ if (!validation.valid) {
1091
+ return res.status(400).json({
1092
+ success: false,
1093
+ errors: validation.errors
1094
+ });
1095
+ }
1096
+ const settings = await req.payload.findGlobal({
1097
+ slug: config.settingsSlug || "newsletter-settings",
1098
+ overrideAccess: false
1099
+ // No user context for public endpoint
1100
+ });
1101
+ const allowedDomains = settings?.subscriptionSettings?.allowedDomains?.map((d) => d.domain) || [];
1102
+ if (!isDomainAllowed(trimmedEmail, allowedDomains)) {
1103
+ return res.status(400).json({
1104
+ success: false,
1105
+ error: "Email domain not allowed"
1106
+ });
1107
+ }
1108
+ const existing = await req.payload.find({
1109
+ collection: config.subscribersSlug || "subscribers",
1110
+ where: {
1111
+ email: {
1112
+ equals: trimmedEmail.toLowerCase()
1113
+ }
1114
+ },
1115
+ overrideAccess: true
1116
+ // Need to check for duplicates in public endpoint
1117
+ });
1118
+ if (existing.docs.length > 0) {
1119
+ const subscriber2 = existing.docs[0];
1120
+ if (subscriber2.subscriptionStatus === "unsubscribed") {
1121
+ return res.status(400).json({
1122
+ success: false,
1123
+ error: "This email has been unsubscribed. Please contact support to resubscribe."
1124
+ });
1125
+ }
1126
+ return res.status(400).json({
1127
+ success: false,
1128
+ error: "Already subscribed",
1129
+ subscriber: {
1130
+ id: subscriber2.id,
1131
+ email: subscriber2.email,
1132
+ subscriptionStatus: subscriber2.subscriptionStatus
1133
+ }
1134
+ });
1135
+ }
1136
+ const ipAddress = req.ip || req.connection.remoteAddress;
1137
+ const maxPerIP = settings?.subscriptionSettings?.maxSubscribersPerIP || 10;
1138
+ const ipSubscribers = await req.payload.find({
1139
+ collection: config.subscribersSlug || "subscribers",
1140
+ where: {
1141
+ "signupMetadata.ipAddress": {
1142
+ equals: ipAddress
1143
+ }
1144
+ },
1145
+ overrideAccess: true
1146
+ // Need to check IP limits in public endpoint
1147
+ });
1148
+ if (ipSubscribers.docs.length >= maxPerIP) {
1149
+ return res.status(429).json({
1150
+ success: false,
1151
+ error: "Too many subscriptions from this IP address"
1152
+ });
1153
+ }
1154
+ const referer = req.headers.referer || req.headers.referrer || "";
1155
+ let utmParams = {};
1156
+ if (referer) {
1157
+ try {
1158
+ utmParams = extractUTMParams(new URL(referer).searchParams);
1159
+ } catch {
1160
+ }
1161
+ }
1162
+ const subscriberData = {
1163
+ email: trimmedEmail.toLowerCase(),
1164
+ name: name ? sanitizeInput(name) : void 0,
1165
+ locale: metadata.locale || config.i18n?.defaultLocale || "en",
1166
+ subscriptionStatus: settings?.subscriptionSettings?.requireDoubleOptIn ? "pending" : "active",
1167
+ source: source || "api",
1168
+ emailPreferences: {
1169
+ newsletter: true,
1170
+ announcements: true,
1171
+ ...preferences || {}
1172
+ },
1173
+ signupMetadata: {
1174
+ ipAddress,
1175
+ userAgent: req.headers["user-agent"],
1176
+ referrer: referer,
1177
+ signupPage: metadata.signupPage || referer
1178
+ }
1179
+ };
1180
+ if (config.features?.utmTracking?.enabled && Object.keys(utmParams).length > 0) {
1181
+ subscriberData.utmParameters = utmParams;
1182
+ }
1183
+ if (config.features?.leadMagnets?.enabled && leadMagnet) {
1184
+ subscriberData.leadMagnet = leadMagnet;
1185
+ }
1186
+ const subscriber = await req.payload.create({
1187
+ collection: config.subscribersSlug || "subscribers",
1188
+ data: subscriberData,
1189
+ overrideAccess: true
1190
+ // Public endpoint needs to create subscribers
1191
+ });
1192
+ if (config.features?.surveys?.enabled && surveyResponses) {
1193
+ }
1194
+ if (settings?.subscriptionSettings?.requireDoubleOptIn) {
1195
+ }
1196
+ res.json({
1197
+ success: true,
1198
+ subscriber: {
1199
+ id: subscriber.id,
1200
+ email: subscriber.email,
1201
+ subscriptionStatus: subscriber.subscriptionStatus
1202
+ },
1203
+ message: settings?.subscriptionSettings?.requireDoubleOptIn ? "Please check your email to confirm your subscription" : "Successfully subscribed"
1204
+ });
1205
+ } catch {
1206
+ res.status(500).json({
1207
+ success: false,
1208
+ error: "Failed to subscribe. Please try again."
1209
+ });
1210
+ }
1211
+ }
1212
+ };
1213
+ };
1214
+
1215
+ // src/utils/jwt.ts
1216
+ import jwt from "jsonwebtoken";
1217
+ function getJWTSecret() {
1218
+ const secret = process.env.JWT_SECRET || process.env.PAYLOAD_SECRET;
1219
+ if (!secret) {
1220
+ console.warn(
1221
+ "WARNING: No JWT_SECRET or PAYLOAD_SECRET found in environment variables. Magic link authentication will not work properly. Please set JWT_SECRET in your environment."
1222
+ );
1223
+ return "INSECURE_DEVELOPMENT_SECRET_PLEASE_SET_JWT_SECRET";
1224
+ }
1225
+ return secret;
1226
+ }
1227
+ function verifyMagicLinkToken(token) {
1228
+ try {
1229
+ const payload = jwt.verify(token, getJWTSecret(), {
1230
+ issuer: "payload-newsletter-plugin"
1231
+ });
1232
+ if (payload.type !== "magic-link") {
1233
+ throw new Error("Invalid token type");
1234
+ }
1235
+ return payload;
1236
+ } catch (error) {
1237
+ if (error instanceof Error && error.name === "TokenExpiredError") {
1238
+ throw new Error("Magic link has expired. Please request a new one.");
1239
+ }
1240
+ if (error instanceof Error && error.name === "JsonWebTokenError") {
1241
+ throw new Error("Invalid magic link token");
1242
+ }
1243
+ throw error;
1244
+ }
1245
+ }
1246
+ function generateSessionToken(subscriberId, email) {
1247
+ const payload = {
1248
+ subscriberId,
1249
+ email,
1250
+ type: "session"
1251
+ };
1252
+ return jwt.sign(payload, getJWTSecret(), {
1253
+ expiresIn: "30d",
1254
+ issuer: "payload-newsletter-plugin"
1255
+ });
1256
+ }
1257
+ function verifySessionToken(token) {
1258
+ try {
1259
+ const payload = jwt.verify(token, getJWTSecret(), {
1260
+ issuer: "payload-newsletter-plugin"
1261
+ });
1262
+ if (payload.type !== "session") {
1263
+ throw new Error("Invalid token type");
1264
+ }
1265
+ return payload;
1266
+ } catch (error) {
1267
+ if (error instanceof Error && error.name === "TokenExpiredError") {
1268
+ throw new Error("Session has expired. Please sign in again.");
1269
+ }
1270
+ if (error instanceof Error && error.name === "JsonWebTokenError") {
1271
+ throw new Error("Invalid session token");
1272
+ }
1273
+ throw error;
1274
+ }
1275
+ }
1276
+
1277
+ // src/endpoints/verify-magic-link.ts
1278
+ var createVerifyMagicLinkEndpoint = (config) => {
1279
+ return {
1280
+ path: "/newsletter/verify-magic-link",
1281
+ method: "post",
1282
+ handler: async (req, res) => {
1283
+ try {
1284
+ const { token } = req.body;
1285
+ if (!token) {
1286
+ return res.status(400).json({
1287
+ success: false,
1288
+ error: "Token is required"
1289
+ });
1290
+ }
1291
+ let payload;
1292
+ try {
1293
+ payload = verifyMagicLinkToken(token);
1294
+ } catch (error) {
1295
+ return res.status(401).json({
1296
+ success: false,
1297
+ error: error instanceof Error ? error.message : "Invalid token"
1298
+ });
1299
+ }
1300
+ const subscriber = await req.payload.findByID({
1301
+ collection: config.subscribersSlug || "subscribers",
1302
+ id: payload.subscriberId
1303
+ // Keep overrideAccess: true for token verification
1304
+ });
1305
+ if (!subscriber) {
1306
+ return res.status(404).json({
1307
+ success: false,
1308
+ error: "Subscriber not found"
1309
+ });
1310
+ }
1311
+ if (subscriber.email !== payload.email) {
1312
+ return res.status(401).json({
1313
+ success: false,
1314
+ error: "Invalid token"
1315
+ });
1316
+ }
1317
+ if (subscriber.subscriptionStatus === "unsubscribed") {
1318
+ return res.status(403).json({
1319
+ success: false,
1320
+ error: "This email has been unsubscribed"
1321
+ });
1322
+ }
1323
+ const syntheticUser = {
1324
+ collection: "subscribers",
1325
+ id: subscriber.id,
1326
+ email: subscriber.email
1327
+ };
1328
+ if (subscriber.subscriptionStatus === "pending") {
1329
+ await req.payload.update({
1330
+ collection: config.subscribersSlug || "subscribers",
1331
+ id: subscriber.id,
1332
+ data: {
1333
+ subscriptionStatus: "active"
1334
+ },
1335
+ overrideAccess: false,
1336
+ user: syntheticUser
1337
+ });
1338
+ }
1339
+ await req.payload.update({
1340
+ collection: config.subscribersSlug || "subscribers",
1341
+ id: subscriber.id,
1342
+ data: {
1343
+ magicLinkToken: null,
1344
+ magicLinkTokenExpiry: null
1345
+ },
1346
+ overrideAccess: false,
1347
+ user: syntheticUser
1348
+ });
1349
+ const sessionToken = generateSessionToken(
1350
+ String(subscriber.id),
1351
+ subscriber.email
1352
+ );
1353
+ res.json({
1354
+ success: true,
1355
+ sessionToken,
1356
+ subscriber: {
1357
+ id: subscriber.id,
1358
+ email: subscriber.email,
1359
+ name: subscriber.name,
1360
+ locale: subscriber.locale,
1361
+ emailPreferences: subscriber.emailPreferences
1362
+ }
1363
+ });
1364
+ } catch (error) {
1365
+ console.error("Verify magic link error:", error);
1366
+ res.status(500).json({
1367
+ success: false,
1368
+ error: "Failed to verify magic link"
1369
+ });
1370
+ }
1371
+ }
1372
+ };
1373
+ };
1374
+
1375
+ // src/endpoints/preferences.ts
1376
+ var createPreferencesEndpoint = (config) => {
1377
+ return {
1378
+ path: "/newsletter/preferences",
1379
+ method: "get",
1380
+ handler: async (req, res) => {
1381
+ try {
1382
+ const authHeader = req.headers.authorization;
1383
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
1384
+ return res.status(401).json({
1385
+ success: false,
1386
+ error: "Authorization required"
1387
+ });
1388
+ }
1389
+ const token = authHeader.substring(7);
1390
+ let payload;
1391
+ try {
1392
+ payload = verifySessionToken(token);
1393
+ } catch (error) {
1394
+ return res.status(401).json({
1395
+ success: false,
1396
+ error: error instanceof Error ? error.message : "Invalid token"
1397
+ });
1398
+ }
1399
+ const subscriber = await req.payload.findByID({
1400
+ collection: config.subscribersSlug || "subscribers",
1401
+ id: payload.subscriberId,
1402
+ overrideAccess: false,
1403
+ user: {
1404
+ collection: "subscribers",
1405
+ id: payload.subscriberId,
1406
+ email: payload.email
1407
+ }
1408
+ });
1409
+ if (!subscriber) {
1410
+ return res.status(404).json({
1411
+ success: false,
1412
+ error: "Subscriber not found"
1413
+ });
1414
+ }
1415
+ res.json({
1416
+ success: true,
1417
+ subscriber: {
1418
+ id: subscriber.id,
1419
+ email: subscriber.email,
1420
+ name: subscriber.name,
1421
+ locale: subscriber.locale,
1422
+ emailPreferences: subscriber.emailPreferences,
1423
+ subscriptionStatus: subscriber.subscriptionStatus
1424
+ }
1425
+ });
1426
+ } catch (error) {
1427
+ console.error("Get preferences error:", error);
1428
+ res.status(500).json({
1429
+ success: false,
1430
+ error: "Failed to get preferences"
1431
+ });
1432
+ }
1433
+ }
1434
+ };
1435
+ };
1436
+ var createUpdatePreferencesEndpoint = (config) => {
1437
+ return {
1438
+ path: "/newsletter/preferences",
1439
+ method: "post",
1440
+ handler: async (req, res) => {
1441
+ try {
1442
+ const authHeader = req.headers.authorization;
1443
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
1444
+ return res.status(401).json({
1445
+ success: false,
1446
+ error: "Authorization required"
1447
+ });
1448
+ }
1449
+ const token = authHeader.substring(7);
1450
+ let payload;
1451
+ try {
1452
+ payload = verifySessionToken(token);
1453
+ } catch (error) {
1454
+ return res.status(401).json({
1455
+ success: false,
1456
+ error: error instanceof Error ? error.message : "Invalid token"
1457
+ });
1458
+ }
1459
+ const { name, locale, emailPreferences } = req.body;
1460
+ const updateData = {};
1461
+ if (name !== void 0) {
1462
+ updateData.name = name;
1463
+ }
1464
+ if (locale !== void 0) {
1465
+ updateData.locale = locale;
1466
+ }
1467
+ if (emailPreferences !== void 0) {
1468
+ updateData.emailPreferences = emailPreferences;
1469
+ }
1470
+ const subscriber = await req.payload.update({
1471
+ collection: config.subscribersSlug || "subscribers",
1472
+ id: payload.subscriberId,
1473
+ data: updateData,
1474
+ overrideAccess: false,
1475
+ user: {
1476
+ collection: "subscribers",
1477
+ id: payload.subscriberId,
1478
+ email: payload.email
1479
+ }
1480
+ });
1481
+ res.json({
1482
+ success: true,
1483
+ subscriber: {
1484
+ id: subscriber.id,
1485
+ email: subscriber.email,
1486
+ name: subscriber.name,
1487
+ locale: subscriber.locale,
1488
+ emailPreferences: subscriber.emailPreferences,
1489
+ subscriptionStatus: subscriber.subscriptionStatus
1490
+ }
1491
+ });
1492
+ } catch (error) {
1493
+ console.error("Update preferences error:", error);
1494
+ res.status(500).json({
1495
+ success: false,
1496
+ error: "Failed to update preferences"
1497
+ });
1498
+ }
1499
+ }
1500
+ };
1501
+ };
1502
+
1503
+ // src/endpoints/unsubscribe.ts
1504
+ var createUnsubscribeEndpoint = (config) => {
1505
+ return {
1506
+ path: "/newsletter/unsubscribe",
1507
+ method: "post",
1508
+ handler: async (req, res) => {
1509
+ try {
1510
+ const { email, token } = req.body;
1511
+ if (!email && !token) {
1512
+ return res.status(400).json({
1513
+ success: false,
1514
+ error: "Email or token is required"
1515
+ });
1516
+ }
1517
+ let subscriber;
1518
+ if (token) {
1519
+ try {
1520
+ const jwt2 = await import("jsonwebtoken");
1521
+ const payload = jwt2.verify(
1522
+ token,
1523
+ process.env.JWT_SECRET || process.env.PAYLOAD_SECRET || ""
1524
+ );
1525
+ if (payload.type !== "unsubscribe") {
1526
+ throw new Error("Invalid token type");
1527
+ }
1528
+ subscriber = await req.payload.findByID({
1529
+ collection: config.subscribersSlug || "subscribers",
1530
+ id: payload.subscriberId
1531
+ });
1532
+ } catch {
1533
+ return res.status(401).json({
1534
+ success: false,
1535
+ error: "Invalid or expired unsubscribe link"
1536
+ });
1537
+ }
1538
+ } else {
1539
+ if (!isValidEmail(email)) {
1540
+ return res.status(400).json({
1541
+ success: false,
1542
+ error: "Invalid email format"
1543
+ });
1544
+ }
1545
+ const result = await req.payload.find({
1546
+ collection: config.subscribersSlug || "subscribers",
1547
+ where: {
1548
+ email: {
1549
+ equals: email.toLowerCase()
1550
+ }
1551
+ }
1552
+ });
1553
+ if (result.docs.length === 0) {
1554
+ return res.json({
1555
+ success: true,
1556
+ message: "If this email was subscribed, it has been unsubscribed."
1557
+ });
1558
+ }
1559
+ subscriber = result.docs[0];
1560
+ }
1561
+ if (!subscriber) {
1562
+ return res.json({
1563
+ success: true,
1564
+ message: "If this email was subscribed, it has been unsubscribed."
1565
+ });
1566
+ }
1567
+ if (subscriber.subscriptionStatus === "unsubscribed") {
1568
+ return res.json({
1569
+ success: true,
1570
+ message: "Already unsubscribed"
1571
+ });
1572
+ }
1573
+ await req.payload.update({
1574
+ collection: config.subscribersSlug || "subscribers",
1575
+ id: subscriber.id,
1576
+ data: {
1577
+ subscriptionStatus: "unsubscribed",
1578
+ unsubscribedAt: (/* @__PURE__ */ new Date()).toISOString()
1579
+ },
1580
+ overrideAccess: false,
1581
+ user: {
1582
+ collection: "subscribers",
1583
+ id: subscriber.id,
1584
+ email: subscriber.email
1585
+ }
1586
+ });
1587
+ res.json({
1588
+ success: true,
1589
+ message: "Successfully unsubscribed"
1590
+ });
1591
+ } catch (error) {
1592
+ console.error("Unsubscribe error:", error);
1593
+ res.status(500).json({
1594
+ success: false,
1595
+ error: "Failed to unsubscribe. Please try again."
1596
+ });
1597
+ }
1598
+ }
1599
+ };
1600
+ };
1601
+
1602
+ // src/endpoints/index.ts
1603
+ function createNewsletterEndpoints(config) {
1604
+ const endpoints = [
1605
+ createSubscribeEndpoint(config),
1606
+ createUnsubscribeEndpoint(config)
1607
+ ];
1608
+ if (config.auth?.enabled !== false) {
1609
+ endpoints.push(
1610
+ createVerifyMagicLinkEndpoint(config),
1611
+ createPreferencesEndpoint(config),
1612
+ createUpdatePreferencesEndpoint(config)
1613
+ );
1614
+ }
1615
+ return endpoints;
1616
+ }
1617
+
1618
+ // src/fields/newsletterScheduling.ts
1619
+ function createNewsletterSchedulingFields(config) {
1620
+ const groupName = config.features?.newsletterScheduling?.fields?.groupName || "newsletterScheduling";
1621
+ const contentField = config.features?.newsletterScheduling?.fields?.contentField || "content";
1622
+ const createMarkdownField = config.features?.newsletterScheduling?.fields?.createMarkdownField !== false;
1623
+ const fields = [
1624
+ {
1625
+ name: groupName,
1626
+ type: "group",
1627
+ label: "Newsletter Scheduling",
1628
+ admin: {
1629
+ condition: (data, { user }) => user?.collection === "users"
1630
+ // Only show for admin users
1631
+ },
1632
+ fields: [
1633
+ {
1634
+ name: "scheduled",
1635
+ type: "checkbox",
1636
+ label: "Schedule for Newsletter",
1637
+ defaultValue: false,
1638
+ admin: {
1639
+ description: "Schedule this content to be sent as a newsletter"
1640
+ }
1641
+ },
1642
+ {
1643
+ name: "scheduledDate",
1644
+ type: "date",
1645
+ label: "Send Date",
1646
+ required: true,
1647
+ admin: {
1648
+ date: {
1649
+ pickerAppearance: "dayAndTime"
1650
+ },
1651
+ condition: (data) => data?.[groupName]?.scheduled,
1652
+ description: "When to send this newsletter"
1653
+ }
1654
+ },
1655
+ {
1656
+ name: "sentDate",
1657
+ type: "date",
1658
+ label: "Sent Date",
1659
+ admin: {
1660
+ readOnly: true,
1661
+ condition: (data) => data?.[groupName]?.sendStatus === "sent",
1662
+ description: "When this newsletter was sent"
1663
+ }
1664
+ },
1665
+ {
1666
+ name: "sendStatus",
1667
+ type: "select",
1668
+ label: "Status",
1669
+ options: [
1670
+ { label: "Draft", value: "draft" },
1671
+ { label: "Scheduled", value: "scheduled" },
1672
+ { label: "Sending", value: "sending" },
1673
+ { label: "Sent", value: "sent" },
1674
+ { label: "Failed", value: "failed" }
1675
+ ],
1676
+ defaultValue: "draft",
1677
+ admin: {
1678
+ readOnly: true,
1679
+ description: "Current send status"
1680
+ }
1681
+ },
1682
+ {
1683
+ name: "emailSubject",
1684
+ type: "text",
1685
+ label: "Email Subject",
1686
+ required: true,
1687
+ admin: {
1688
+ condition: (data) => data?.[groupName]?.scheduled,
1689
+ description: "Subject line for the newsletter email"
1690
+ }
1691
+ },
1692
+ {
1693
+ name: "preheader",
1694
+ type: "text",
1695
+ label: "Email Preheader",
1696
+ admin: {
1697
+ condition: (data) => data?.[groupName]?.scheduled,
1698
+ description: "Preview text that appears after the subject line"
1699
+ }
1700
+ },
1701
+ {
1702
+ name: "segments",
1703
+ type: "select",
1704
+ label: "Target Segments",
1705
+ hasMany: true,
1706
+ options: [
1707
+ { label: "All Subscribers", value: "all" },
1708
+ ...config.i18n?.locales?.map((locale) => ({
1709
+ label: `${locale.toUpperCase()} Subscribers`,
1710
+ value: locale
1711
+ })) || []
1712
+ ],
1713
+ defaultValue: ["all"],
1714
+ admin: {
1715
+ condition: (data) => data?.[groupName]?.scheduled,
1716
+ description: "Which subscriber segments to send to"
1717
+ }
1718
+ },
1719
+ {
1720
+ name: "testEmails",
1721
+ type: "array",
1722
+ label: "Test Email Recipients",
1723
+ admin: {
1724
+ condition: (data) => data?.[groupName]?.scheduled && data?.[groupName]?.sendStatus === "draft",
1725
+ description: "Send test emails before scheduling"
1726
+ },
1727
+ fields: [
1728
+ {
1729
+ name: "email",
1730
+ type: "email",
1731
+ required: true
1732
+ }
1733
+ ]
1734
+ }
1735
+ ]
1736
+ }
1737
+ ];
1738
+ if (createMarkdownField) {
1739
+ fields.push(createMarkdownFieldInternal({
1740
+ name: `${contentField}Markdown`,
1741
+ richTextField: contentField,
1742
+ label: "Email Content (Markdown)",
1743
+ admin: {
1744
+ position: "sidebar",
1745
+ condition: (data) => Boolean(data?.[contentField] && data?.[groupName]?.scheduled),
1746
+ description: "Markdown version for email rendering",
1747
+ readOnly: true
1748
+ }
1749
+ }));
1750
+ }
1751
+ return fields;
1752
+ }
1753
+ function createMarkdownFieldInternal(config) {
1754
+ return {
1755
+ name: config.name,
1756
+ type: "textarea",
1757
+ label: config.label || "Markdown",
1758
+ admin: {
1759
+ ...config.admin,
1760
+ description: config.admin?.description || "Auto-generated from rich text content"
1761
+ },
1762
+ hooks: {
1763
+ afterRead: [
1764
+ async ({ data }) => {
1765
+ if (data?.[config.richTextField]) {
1766
+ try {
1767
+ const { convertLexicalToMarkdown } = await import("@payloadcms/richtext-lexical");
1768
+ return convertLexicalToMarkdown({
1769
+ data: data[config.richTextField]
1770
+ });
1771
+ } catch {
1772
+ return "";
1773
+ }
1774
+ }
1775
+ return "";
1776
+ }
1777
+ ],
1778
+ beforeChange: [
1779
+ () => {
1780
+ return null;
1781
+ }
1782
+ ]
1783
+ }
1784
+ };
1785
+ }
1786
+
1787
+ // src/index.ts
1788
+ var newsletterPlugin = (pluginConfig) => (incomingConfig) => {
1789
+ const config = {
1790
+ enabled: true,
1791
+ subscribersSlug: "subscribers",
1792
+ settingsSlug: "newsletter-settings",
1793
+ auth: {
1794
+ enabled: true,
1795
+ tokenExpiration: "7d",
1796
+ magicLinkPath: "/newsletter/verify",
1797
+ ...pluginConfig.auth
1798
+ },
1799
+ ...pluginConfig
1800
+ };
1801
+ if (!config.enabled) {
1802
+ return incomingConfig;
1803
+ }
1804
+ const subscribersCollection = createSubscribersCollection(config);
1805
+ const settingsGlobal = createNewsletterSettingsGlobal(config);
1806
+ let collections = [...incomingConfig.collections || [], subscribersCollection];
1807
+ if (config.features?.newsletterScheduling?.enabled) {
1808
+ const targetCollections = config.features.newsletterScheduling.collections || "articles";
1809
+ const collectionsToExtend = Array.isArray(targetCollections) ? targetCollections : [targetCollections];
1810
+ const schedulingFields = createNewsletterSchedulingFields(config);
1811
+ collections = collections.map((collection) => {
1812
+ if (collectionsToExtend.includes(collection.slug)) {
1813
+ return {
1814
+ ...collection,
1815
+ fields: [
1816
+ ...collection.fields,
1817
+ ...schedulingFields
1818
+ ]
1819
+ };
1820
+ }
1821
+ return collection;
1822
+ });
1823
+ }
1824
+ const endpoints = createNewsletterEndpoints(config);
1825
+ const modifiedConfig = {
1826
+ ...incomingConfig,
1827
+ collections,
1828
+ globals: [
1829
+ ...incomingConfig.globals || [],
1830
+ settingsGlobal
1831
+ ],
1832
+ endpoints: [
1833
+ ...incomingConfig.endpoints || [],
1834
+ ...endpoints
1835
+ ],
1836
+ onInit: async (payload) => {
1837
+ try {
1838
+ const settings = await payload.findGlobal({
1839
+ slug: config.settingsSlug || "newsletter-settings"
1840
+ });
1841
+ let emailServiceConfig;
1842
+ if (settings) {
1843
+ emailServiceConfig = {
1844
+ provider: settings.provider || config.providers.default,
1845
+ fromAddress: settings.fromAddress || config.providers.resend?.fromAddress || config.providers.broadcast?.fromAddress || "noreply@example.com",
1846
+ fromName: settings.fromName || config.providers.resend?.fromName || config.providers.broadcast?.fromName || "Newsletter",
1847
+ replyTo: settings.replyTo,
1848
+ resend: settings.provider === "resend" ? {
1849
+ apiKey: settings.resendSettings?.apiKey || config.providers.resend?.apiKey || "",
1850
+ audienceIds: settings.resendSettings?.audienceIds?.reduce((acc, item) => {
1851
+ acc[item.locale] = {
1852
+ production: item.production,
1853
+ development: item.development
1854
+ };
1855
+ return acc;
1856
+ }, {}) || config.providers.resend?.audienceIds
1857
+ } : config.providers.resend,
1858
+ broadcast: settings.provider === "broadcast" ? {
1859
+ apiUrl: settings.broadcastSettings?.apiUrl || config.providers.broadcast?.apiUrl || "",
1860
+ tokens: {
1861
+ production: settings.broadcastSettings?.productionToken || config.providers.broadcast?.tokens.production,
1862
+ development: settings.broadcastSettings?.developmentToken || config.providers.broadcast?.tokens.development
1863
+ }
1864
+ } : config.providers.broadcast
1865
+ };
1866
+ } else {
1867
+ emailServiceConfig = {
1868
+ provider: config.providers.default,
1869
+ fromAddress: config.providers.resend?.fromAddress || config.providers.broadcast?.fromAddress || "noreply@example.com",
1870
+ fromName: config.providers.resend?.fromName || config.providers.broadcast?.fromName || "Newsletter",
1871
+ resend: config.providers.resend,
1872
+ broadcast: config.providers.broadcast
1873
+ };
1874
+ }
1875
+ payload.newsletterEmailService = createEmailService(emailServiceConfig);
1876
+ console.warn("Newsletter plugin initialized with", payload.newsletterEmailService.getProvider(), "provider");
1877
+ } catch (error) {
1878
+ console.error("Failed to initialize newsletter email service:", error);
1879
+ }
1880
+ if (incomingConfig.onInit) {
1881
+ await incomingConfig.onInit(payload);
1882
+ }
1883
+ }
1884
+ };
1885
+ return modifiedConfig;
1886
+ };
1887
+ export {
1888
+ newsletterPlugin as default,
1889
+ newsletterPlugin
1890
+ };
1891
+ //# sourceMappingURL=index.js.map