@volchoklv/newsletter-kit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +505 -0
- package/dist/adapters/email/index.d.ts +119 -0
- package/dist/adapters/email/index.js +417 -0
- package/dist/adapters/email/index.js.map +1 -0
- package/dist/adapters/storage/index.d.ts +215 -0
- package/dist/adapters/storage/index.js +415 -0
- package/dist/adapters/storage/index.js.map +1 -0
- package/dist/components/index.d.ts +198 -0
- package/dist/components/index.js +505 -0
- package/dist/components/index.js.map +1 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +1762 -0
- package/dist/index.js.map +1 -0
- package/dist/server/index.d.ts +77 -0
- package/dist/server/index.js +530 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types-BmajlhNp.d.ts +226 -0
- package/package.json +95 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/utils/crypto.ts
|
|
4
|
+
import { randomBytes } from "crypto";
|
|
5
|
+
function generateToken(length = 32) {
|
|
6
|
+
return randomBytes(length).toString("hex");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// src/utils/validation.ts
|
|
10
|
+
function isValidEmail(email) {
|
|
11
|
+
if (!email || typeof email !== "string") return false;
|
|
12
|
+
const trimmed = email.trim();
|
|
13
|
+
if (trimmed.length === 0 || trimmed.length > 254) return false;
|
|
14
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
15
|
+
if (!emailRegex.test(trimmed)) return false;
|
|
16
|
+
const [localPart, domain] = trimmed.split("@");
|
|
17
|
+
if (!localPart || localPart.length > 64) return false;
|
|
18
|
+
if (localPart.startsWith(".") || localPart.endsWith(".")) return false;
|
|
19
|
+
if (localPart.includes("..")) return false;
|
|
20
|
+
if (!domain || domain.length > 253) return false;
|
|
21
|
+
if (domain.startsWith(".") || domain.endsWith(".")) return false;
|
|
22
|
+
if (domain.startsWith("-") || domain.endsWith("-")) return false;
|
|
23
|
+
if (!domain.includes(".")) return false;
|
|
24
|
+
const tld = domain.split(".").pop();
|
|
25
|
+
if (!tld || tld.length < 2) return false;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
function normalizeEmail(email, options = {}) {
|
|
29
|
+
let normalized = email.toLowerCase().trim();
|
|
30
|
+
if (options.normalizeGmail) {
|
|
31
|
+
const [localPart, domain] = normalized.split("@");
|
|
32
|
+
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
33
|
+
const cleanLocal = localPart.split("+")[0].replace(/\./g, "");
|
|
34
|
+
normalized = `${cleanLocal}@gmail.com`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return normalized;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/utils/rate-limit.ts
|
|
41
|
+
function createRateLimiter(config) {
|
|
42
|
+
const { max, windowSeconds, identifier } = config;
|
|
43
|
+
const store = /* @__PURE__ */ new Map();
|
|
44
|
+
setInterval(() => {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
for (const [key, entry] of store.entries()) {
|
|
47
|
+
if (entry.resetAt < now) {
|
|
48
|
+
store.delete(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}, windowSeconds * 1e3);
|
|
52
|
+
return {
|
|
53
|
+
/**
|
|
54
|
+
* Check if request should be rate limited
|
|
55
|
+
* Returns true if request is allowed, false if rate limited
|
|
56
|
+
*/
|
|
57
|
+
async check(req) {
|
|
58
|
+
const id = identifier ? identifier(req) : getClientIP(req);
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const windowMs = windowSeconds * 1e3;
|
|
61
|
+
let entry = store.get(id);
|
|
62
|
+
if (!entry || entry.resetAt < now) {
|
|
63
|
+
entry = {
|
|
64
|
+
count: 0,
|
|
65
|
+
resetAt: now + windowMs
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
entry.count++;
|
|
69
|
+
store.set(id, entry);
|
|
70
|
+
const allowed = entry.count <= max;
|
|
71
|
+
const remaining = Math.max(0, max - entry.count);
|
|
72
|
+
return {
|
|
73
|
+
allowed,
|
|
74
|
+
remaining,
|
|
75
|
+
resetAt: new Date(entry.resetAt)
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
/**
|
|
79
|
+
* Get current limit status without incrementing
|
|
80
|
+
*/
|
|
81
|
+
async status(req) {
|
|
82
|
+
const id = identifier ? identifier(req) : getClientIP(req);
|
|
83
|
+
const entry = store.get(id);
|
|
84
|
+
if (!entry || entry.resetAt < Date.now()) {
|
|
85
|
+
return { remaining: max, resetAt: null };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
remaining: Math.max(0, max - entry.count),
|
|
89
|
+
resetAt: new Date(entry.resetAt)
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
/**
|
|
93
|
+
* Reset rate limit for an identifier
|
|
94
|
+
*/
|
|
95
|
+
async reset(req) {
|
|
96
|
+
const id = identifier ? identifier(req) : getClientIP(req);
|
|
97
|
+
store.delete(id);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function getClientIP(req) {
|
|
102
|
+
const headers = req.headers;
|
|
103
|
+
const cfConnectingIP = headers.get("cf-connecting-ip");
|
|
104
|
+
if (cfConnectingIP) return cfConnectingIP;
|
|
105
|
+
const xForwardedFor = headers.get("x-forwarded-for");
|
|
106
|
+
if (xForwardedFor) {
|
|
107
|
+
return xForwardedFor.split(",")[0].trim();
|
|
108
|
+
}
|
|
109
|
+
const xRealIP = headers.get("x-real-ip");
|
|
110
|
+
if (xRealIP) return xRealIP;
|
|
111
|
+
const xVercelForwardedFor = headers.get("x-vercel-forwarded-for");
|
|
112
|
+
if (xVercelForwardedFor) return xVercelForwardedFor.split(",")[0].trim();
|
|
113
|
+
return "unknown";
|
|
114
|
+
}
|
|
115
|
+
function createRateLimitHeaders(limit, remaining, resetAt) {
|
|
116
|
+
const headers = new Headers();
|
|
117
|
+
headers.set("X-RateLimit-Limit", limit.toString());
|
|
118
|
+
headers.set("X-RateLimit-Remaining", remaining.toString());
|
|
119
|
+
headers.set("X-RateLimit-Reset", Math.floor(resetAt.getTime() / 1e3).toString());
|
|
120
|
+
return headers;
|
|
121
|
+
}
|
|
122
|
+
var defaultRateLimitConfig = {
|
|
123
|
+
max: 5,
|
|
124
|
+
windowSeconds: 60
|
|
125
|
+
// 5 requests per minute
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// src/adapters/storage/memory.ts
|
|
129
|
+
function createMemoryAdapter() {
|
|
130
|
+
const subscribers = /* @__PURE__ */ new Map();
|
|
131
|
+
const tokenIndex = /* @__PURE__ */ new Map();
|
|
132
|
+
return {
|
|
133
|
+
async createSubscriber(input, token) {
|
|
134
|
+
const email = input.email.toLowerCase();
|
|
135
|
+
const confirmToken = token || generateToken();
|
|
136
|
+
const now = /* @__PURE__ */ new Date();
|
|
137
|
+
const existing = subscribers.get(email);
|
|
138
|
+
if (existing?.id) {
|
|
139
|
+
for (const [t, e] of tokenIndex.entries()) {
|
|
140
|
+
if (e === email) {
|
|
141
|
+
tokenIndex.delete(t);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const subscriber = {
|
|
147
|
+
id: existing?.id || crypto.randomUUID(),
|
|
148
|
+
email,
|
|
149
|
+
status: "pending",
|
|
150
|
+
source: input.source,
|
|
151
|
+
tags: input.tags || [],
|
|
152
|
+
metadata: input.metadata,
|
|
153
|
+
consentIp: input.ip,
|
|
154
|
+
consentAt: now,
|
|
155
|
+
confirmedAt: void 0,
|
|
156
|
+
unsubscribedAt: void 0,
|
|
157
|
+
createdAt: existing?.createdAt || now,
|
|
158
|
+
updatedAt: now
|
|
159
|
+
};
|
|
160
|
+
subscribers.set(email, subscriber);
|
|
161
|
+
tokenIndex.set(confirmToken, email);
|
|
162
|
+
return { ...subscriber, id: confirmToken };
|
|
163
|
+
},
|
|
164
|
+
async getSubscriberByEmail(email) {
|
|
165
|
+
return subscribers.get(email.toLowerCase()) || null;
|
|
166
|
+
},
|
|
167
|
+
async getSubscriberByToken(token) {
|
|
168
|
+
const email = tokenIndex.get(token);
|
|
169
|
+
if (!email) return null;
|
|
170
|
+
return subscribers.get(email) || null;
|
|
171
|
+
},
|
|
172
|
+
async confirmSubscriber(token) {
|
|
173
|
+
const email = tokenIndex.get(token);
|
|
174
|
+
if (!email) return null;
|
|
175
|
+
const subscriber = subscribers.get(email);
|
|
176
|
+
if (!subscriber) return null;
|
|
177
|
+
const updated = {
|
|
178
|
+
...subscriber,
|
|
179
|
+
status: "confirmed",
|
|
180
|
+
confirmedAt: /* @__PURE__ */ new Date(),
|
|
181
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
182
|
+
};
|
|
183
|
+
subscribers.set(email, updated);
|
|
184
|
+
tokenIndex.delete(token);
|
|
185
|
+
return updated;
|
|
186
|
+
},
|
|
187
|
+
async unsubscribe(email) {
|
|
188
|
+
const subscriber = subscribers.get(email.toLowerCase());
|
|
189
|
+
if (!subscriber) return false;
|
|
190
|
+
const updated = {
|
|
191
|
+
...subscriber,
|
|
192
|
+
status: "unsubscribed",
|
|
193
|
+
unsubscribedAt: /* @__PURE__ */ new Date(),
|
|
194
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
195
|
+
};
|
|
196
|
+
subscribers.set(email.toLowerCase(), updated);
|
|
197
|
+
return true;
|
|
198
|
+
},
|
|
199
|
+
async listSubscribers(options) {
|
|
200
|
+
let results = Array.from(subscribers.values());
|
|
201
|
+
if (options?.status) {
|
|
202
|
+
results = results.filter((s) => s.status === options.status);
|
|
203
|
+
}
|
|
204
|
+
if (options?.source) {
|
|
205
|
+
results = results.filter((s) => s.source === options.source);
|
|
206
|
+
}
|
|
207
|
+
if (options?.tags?.length) {
|
|
208
|
+
results = results.filter(
|
|
209
|
+
(s) => options.tags.some((tag) => s.tags?.includes(tag))
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
213
|
+
const total = results.length;
|
|
214
|
+
const offset = options?.offset || 0;
|
|
215
|
+
const limit = options?.limit || 100;
|
|
216
|
+
return {
|
|
217
|
+
subscribers: results.slice(offset, offset + limit),
|
|
218
|
+
total
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
async deleteSubscriber(email) {
|
|
222
|
+
const normalizedEmail = email.toLowerCase();
|
|
223
|
+
const existed = subscribers.has(normalizedEmail);
|
|
224
|
+
subscribers.delete(normalizedEmail);
|
|
225
|
+
for (const [token, e] of tokenIndex.entries()) {
|
|
226
|
+
if (e === normalizedEmail) {
|
|
227
|
+
tokenIndex.delete(token);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return existed;
|
|
232
|
+
},
|
|
233
|
+
async updateSubscriber(email, data) {
|
|
234
|
+
const normalizedEmail = email.toLowerCase();
|
|
235
|
+
const subscriber = subscribers.get(normalizedEmail);
|
|
236
|
+
if (!subscriber) return null;
|
|
237
|
+
const updated = {
|
|
238
|
+
...subscriber,
|
|
239
|
+
...data.source !== void 0 && { source: data.source },
|
|
240
|
+
...data.tags !== void 0 && { tags: data.tags },
|
|
241
|
+
...data.metadata !== void 0 && { metadata: data.metadata },
|
|
242
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
243
|
+
};
|
|
244
|
+
subscribers.set(normalizedEmail, updated);
|
|
245
|
+
return updated;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/server/handlers.ts
|
|
251
|
+
function createNewsletterHandlers(config) {
|
|
252
|
+
const {
|
|
253
|
+
emailAdapter,
|
|
254
|
+
storageAdapter = createMemoryAdapter(),
|
|
255
|
+
doubleOptIn = true,
|
|
256
|
+
baseUrl,
|
|
257
|
+
confirmPath = "/api/newsletter/confirm",
|
|
258
|
+
unsubscribePath = "/api/newsletter/unsubscribe",
|
|
259
|
+
honeypotField = "website",
|
|
260
|
+
rateLimit = defaultRateLimitConfig,
|
|
261
|
+
validateEmail: customValidateEmail,
|
|
262
|
+
allowedSources,
|
|
263
|
+
defaultTags = [],
|
|
264
|
+
onSubscribe,
|
|
265
|
+
onConfirm,
|
|
266
|
+
onUnsubscribe,
|
|
267
|
+
onError
|
|
268
|
+
} = config;
|
|
269
|
+
const rateLimiter = rateLimit ? createRateLimiter(rateLimit) : null;
|
|
270
|
+
function jsonResponse(data, status = 200, headers) {
|
|
271
|
+
const responseHeaders = new Headers(headers);
|
|
272
|
+
responseHeaders.set("Content-Type", "application/json");
|
|
273
|
+
return new Response(JSON.stringify(data), {
|
|
274
|
+
status,
|
|
275
|
+
headers: responseHeaders
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
async function handleSubscribe(req) {
|
|
279
|
+
const body = await req.json();
|
|
280
|
+
const { email, source, tags = [], metadata } = body;
|
|
281
|
+
if (honeypotField && body[honeypotField]) {
|
|
282
|
+
return {
|
|
283
|
+
success: true,
|
|
284
|
+
message: doubleOptIn ? "Please check your email to confirm your subscription." : "You have been subscribed!"
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (!email || !isValidEmail(email)) {
|
|
288
|
+
return {
|
|
289
|
+
success: false,
|
|
290
|
+
message: "Please enter a valid email address."
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (customValidateEmail) {
|
|
294
|
+
const isValid = await customValidateEmail(email);
|
|
295
|
+
if (!isValid) {
|
|
296
|
+
return {
|
|
297
|
+
success: false,
|
|
298
|
+
message: "This email address is not allowed."
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (source && allowedSources && !allowedSources.includes(source)) {
|
|
303
|
+
return {
|
|
304
|
+
success: false,
|
|
305
|
+
message: "Invalid subscription source."
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const normalizedEmail = normalizeEmail(email);
|
|
309
|
+
const ip = getClientIP(req);
|
|
310
|
+
try {
|
|
311
|
+
const existing = await storageAdapter.getSubscriberByEmail(normalizedEmail);
|
|
312
|
+
if (existing?.status === "confirmed") {
|
|
313
|
+
return {
|
|
314
|
+
success: true,
|
|
315
|
+
message: "You are already subscribed!",
|
|
316
|
+
subscriber: existing
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const token = generateToken();
|
|
320
|
+
const subscriber = await storageAdapter.createSubscriber(
|
|
321
|
+
{
|
|
322
|
+
email: normalizedEmail,
|
|
323
|
+
source,
|
|
324
|
+
tags: [...defaultTags, ...tags],
|
|
325
|
+
metadata,
|
|
326
|
+
ip
|
|
327
|
+
},
|
|
328
|
+
token
|
|
329
|
+
);
|
|
330
|
+
if (doubleOptIn) {
|
|
331
|
+
const confirmUrl = `${baseUrl}${confirmPath}?token=${token}`;
|
|
332
|
+
await emailAdapter.sendConfirmation(normalizedEmail, token, confirmUrl);
|
|
333
|
+
if (emailAdapter.notifyAdmin) {
|
|
334
|
+
await emailAdapter.notifyAdmin(subscriber);
|
|
335
|
+
}
|
|
336
|
+
if (onSubscribe) {
|
|
337
|
+
await onSubscribe(subscriber);
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
success: true,
|
|
341
|
+
message: "Please check your email to confirm your subscription.",
|
|
342
|
+
subscriber,
|
|
343
|
+
requiresConfirmation: true
|
|
344
|
+
};
|
|
345
|
+
} else {
|
|
346
|
+
const confirmed = await storageAdapter.confirmSubscriber(token);
|
|
347
|
+
await emailAdapter.sendWelcome(normalizedEmail);
|
|
348
|
+
if (emailAdapter.notifyAdmin && confirmed) {
|
|
349
|
+
await emailAdapter.notifyAdmin(confirmed);
|
|
350
|
+
}
|
|
351
|
+
if (onSubscribe && confirmed) {
|
|
352
|
+
await onSubscribe(confirmed);
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
success: true,
|
|
356
|
+
message: "You have been subscribed!",
|
|
357
|
+
subscriber: confirmed || subscriber,
|
|
358
|
+
requiresConfirmation: false
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (onError) {
|
|
363
|
+
await onError(error, "subscribe");
|
|
364
|
+
}
|
|
365
|
+
console.error("[newsletter-kit] Subscribe error:", error);
|
|
366
|
+
return {
|
|
367
|
+
success: false,
|
|
368
|
+
message: "Something went wrong. Please try again."
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async function handleConfirm(token) {
|
|
373
|
+
if (!token) {
|
|
374
|
+
return {
|
|
375
|
+
success: false,
|
|
376
|
+
message: "Invalid confirmation link."
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
const subscriber = await storageAdapter.confirmSubscriber(token);
|
|
381
|
+
if (!subscriber) {
|
|
382
|
+
return {
|
|
383
|
+
success: false,
|
|
384
|
+
message: "This confirmation link is invalid or has expired."
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
await emailAdapter.sendWelcome(subscriber.email);
|
|
388
|
+
if (onConfirm) {
|
|
389
|
+
await onConfirm(subscriber);
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
success: true,
|
|
393
|
+
message: "Your subscription has been confirmed!",
|
|
394
|
+
subscriber
|
|
395
|
+
};
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (onError) {
|
|
398
|
+
await onError(error, "confirm");
|
|
399
|
+
}
|
|
400
|
+
console.error("[newsletter-kit] Confirm error:", error);
|
|
401
|
+
return {
|
|
402
|
+
success: false,
|
|
403
|
+
message: "Something went wrong. Please try again."
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async function handleUnsubscribe(email) {
|
|
408
|
+
if (!email || !isValidEmail(email)) {
|
|
409
|
+
return {
|
|
410
|
+
success: false,
|
|
411
|
+
message: "Invalid email address."
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
const normalizedEmail = normalizeEmail(email);
|
|
415
|
+
try {
|
|
416
|
+
const success = await storageAdapter.unsubscribe(normalizedEmail);
|
|
417
|
+
if (!success) {
|
|
418
|
+
return {
|
|
419
|
+
success: false,
|
|
420
|
+
message: "Email not found in our list."
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (emailAdapter.sendUnsubscribed) {
|
|
424
|
+
const resubscribeUrl = `${baseUrl}`;
|
|
425
|
+
await emailAdapter.sendUnsubscribed(normalizedEmail, resubscribeUrl);
|
|
426
|
+
}
|
|
427
|
+
if (onUnsubscribe) {
|
|
428
|
+
await onUnsubscribe(normalizedEmail);
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
success: true,
|
|
432
|
+
message: "You have been unsubscribed."
|
|
433
|
+
};
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (onError) {
|
|
436
|
+
await onError(error, "unsubscribe");
|
|
437
|
+
}
|
|
438
|
+
console.error("[newsletter-kit] Unsubscribe error:", error);
|
|
439
|
+
return {
|
|
440
|
+
success: false,
|
|
441
|
+
message: "Something went wrong. Please try again."
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
/**
|
|
447
|
+
* POST /api/newsletter/subscribe
|
|
448
|
+
*/
|
|
449
|
+
subscribe: async (req) => {
|
|
450
|
+
if (rateLimiter) {
|
|
451
|
+
const { allowed, remaining, resetAt } = await rateLimiter.check(req);
|
|
452
|
+
const headers = createRateLimitHeaders(rateLimit.max, remaining, resetAt);
|
|
453
|
+
if (!allowed) {
|
|
454
|
+
return jsonResponse(
|
|
455
|
+
{
|
|
456
|
+
success: false,
|
|
457
|
+
message: "Too many requests. Please try again later."
|
|
458
|
+
},
|
|
459
|
+
429,
|
|
460
|
+
headers
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const result = await handleSubscribe(req);
|
|
465
|
+
return jsonResponse(result, result.success ? 200 : 400);
|
|
466
|
+
},
|
|
467
|
+
/**
|
|
468
|
+
* GET /api/newsletter/confirm?token=xxx
|
|
469
|
+
*
|
|
470
|
+
* Can also return a redirect for better UX
|
|
471
|
+
*/
|
|
472
|
+
confirm: async (req) => {
|
|
473
|
+
const url = new URL(req.url);
|
|
474
|
+
const token = url.searchParams.get("token");
|
|
475
|
+
const result = await handleConfirm(token || "");
|
|
476
|
+
const acceptsJson = req.headers.get("accept")?.includes("application/json");
|
|
477
|
+
if (acceptsJson) {
|
|
478
|
+
return jsonResponse(result, result.success ? 200 : 400);
|
|
479
|
+
}
|
|
480
|
+
const redirectUrl = new URL(baseUrl);
|
|
481
|
+
redirectUrl.searchParams.set("confirmed", result.success ? "true" : "false");
|
|
482
|
+
if (!result.success) {
|
|
483
|
+
redirectUrl.searchParams.set("error", result.message);
|
|
484
|
+
}
|
|
485
|
+
return Response.redirect(redirectUrl.toString(), 302);
|
|
486
|
+
},
|
|
487
|
+
/**
|
|
488
|
+
* POST /api/newsletter/unsubscribe
|
|
489
|
+
* Also supports GET with email query param
|
|
490
|
+
*/
|
|
491
|
+
unsubscribe: async (req) => {
|
|
492
|
+
let email;
|
|
493
|
+
if (req.method === "GET") {
|
|
494
|
+
const url = new URL(req.url);
|
|
495
|
+
email = url.searchParams.get("email") || "";
|
|
496
|
+
} else {
|
|
497
|
+
const body = await req.json();
|
|
498
|
+
email = body.email;
|
|
499
|
+
}
|
|
500
|
+
const result = await handleUnsubscribe(email);
|
|
501
|
+
return jsonResponse(result, result.success ? 200 : 400);
|
|
502
|
+
},
|
|
503
|
+
/**
|
|
504
|
+
* Internal handlers for custom implementations
|
|
505
|
+
*/
|
|
506
|
+
handlers: {
|
|
507
|
+
subscribe: handleSubscribe,
|
|
508
|
+
confirm: handleConfirm,
|
|
509
|
+
unsubscribe: handleUnsubscribe
|
|
510
|
+
},
|
|
511
|
+
/**
|
|
512
|
+
* Access to storage adapter for admin features
|
|
513
|
+
*/
|
|
514
|
+
storage: storageAdapter,
|
|
515
|
+
/**
|
|
516
|
+
* Get subscriber by email (for admin/API use)
|
|
517
|
+
*/
|
|
518
|
+
getSubscriber: async (email) => {
|
|
519
|
+
return storageAdapter.getSubscriberByEmail(normalizeEmail(email));
|
|
520
|
+
},
|
|
521
|
+
/**
|
|
522
|
+
* List subscribers (for admin/export use)
|
|
523
|
+
*/
|
|
524
|
+
listSubscribers: storageAdapter.listSubscribers
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
export {
|
|
528
|
+
createNewsletterHandlers
|
|
529
|
+
};
|
|
530
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/crypto.ts","../../src/utils/validation.ts","../../src/utils/rate-limit.ts","../../src/adapters/storage/memory.ts","../../src/server/handlers.ts"],"sourcesContent":["import { randomBytes } from 'crypto';\n\n/**\n * Generate a secure random token for email confirmation\n */\nexport function generateToken(length: number = 32): string {\n return randomBytes(length).toString('hex');\n}\n\n/**\n * Generate a URL-safe token\n */\nexport function generateUrlSafeToken(length: number = 32): string {\n return randomBytes(length)\n .toString('base64')\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=/g, '');\n}\n","/**\n * Validate email format\n * Uses a reasonable regex that catches most issues without being overly strict\n */\nexport function isValidEmail(email: string): boolean {\n if (!email || typeof email !== 'string') return false;\n\n // Trim and check length\n const trimmed = email.trim();\n if (trimmed.length === 0 || trimmed.length > 254) return false;\n\n // Basic format check\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n if (!emailRegex.test(trimmed)) return false;\n\n // Check for common typos/issues\n const [localPart, domain] = trimmed.split('@');\n\n // Local part checks\n if (!localPart || localPart.length > 64) return false;\n if (localPart.startsWith('.') || localPart.endsWith('.')) return false;\n if (localPart.includes('..')) return false;\n\n // Domain checks\n if (!domain || domain.length > 253) return false;\n if (domain.startsWith('.') || domain.endsWith('.')) return false;\n if (domain.startsWith('-') || domain.endsWith('-')) return false;\n if (!domain.includes('.')) return false;\n\n // TLD should be at least 2 chars\n const tld = domain.split('.').pop();\n if (!tld || tld.length < 2) return false;\n\n return true;\n}\n\n/**\n * Normalize email address\n * - Lowercase\n * - Trim whitespace\n * - Remove dots from Gmail local part (optional)\n */\nexport function normalizeEmail(\n email: string,\n options: { normalizeGmail?: boolean } = {}\n): string {\n let normalized = email.toLowerCase().trim();\n\n if (options.normalizeGmail) {\n const [localPart, domain] = normalized.split('@');\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n // Remove dots and everything after + in local part\n const cleanLocal = localPart.split('+')[0].replace(/\\./g, '');\n normalized = `${cleanLocal}@gmail.com`;\n }\n }\n\n return normalized;\n}\n\n/**\n * Check if email is from a disposable domain\n * This is a basic list - for production, consider using a service\n */\nconst DISPOSABLE_DOMAINS = new Set([\n 'tempmail.com',\n 'throwaway.email',\n 'guerrillamail.com',\n 'sharklasers.com',\n 'mailinator.com',\n 'yopmail.com',\n '10minutemail.com',\n 'temp-mail.org',\n 'fakeinbox.com',\n 'trashmail.com',\n 'getnada.com',\n 'maildrop.cc',\n 'dispostable.com',\n 'tempail.com',\n 'mohmal.com',\n]);\n\nexport function isDisposableEmail(email: string): boolean {\n const domain = email.toLowerCase().split('@')[1];\n return DISPOSABLE_DOMAINS.has(domain);\n}\n\n/**\n * Sanitize string input to prevent XSS\n */\nexport function sanitizeString(input: string): string {\n return input\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n","import type { RateLimitConfig } from '../types';\n\ninterface RateLimitEntry {\n count: number;\n resetAt: number;\n}\n\n/**\n * Simple in-memory rate limiter\n * \n * For production with multiple server instances, consider:\n * - Redis-based rate limiting\n * - Upstash Ratelimit\n * - Arcjet\n */\nexport function createRateLimiter(config: RateLimitConfig) {\n const { max, windowSeconds, identifier } = config;\n const store = new Map<string, RateLimitEntry>();\n\n // Cleanup old entries periodically\n setInterval(() => {\n const now = Date.now();\n for (const [key, entry] of store.entries()) {\n if (entry.resetAt < now) {\n store.delete(key);\n }\n }\n }, windowSeconds * 1000);\n\n return {\n /**\n * Check if request should be rate limited\n * Returns true if request is allowed, false if rate limited\n */\n async check(req: Request): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {\n const id = identifier ? identifier(req) : getClientIP(req);\n const now = Date.now();\n const windowMs = windowSeconds * 1000;\n\n let entry = store.get(id);\n\n // If no entry or window expired, create new entry\n if (!entry || entry.resetAt < now) {\n entry = {\n count: 0,\n resetAt: now + windowMs,\n };\n }\n\n entry.count++;\n store.set(id, entry);\n\n const allowed = entry.count <= max;\n const remaining = Math.max(0, max - entry.count);\n\n return {\n allowed,\n remaining,\n resetAt: new Date(entry.resetAt),\n };\n },\n\n /**\n * Get current limit status without incrementing\n */\n async status(req: Request): Promise<{ remaining: number; resetAt: Date | null }> {\n const id = identifier ? identifier(req) : getClientIP(req);\n const entry = store.get(id);\n\n if (!entry || entry.resetAt < Date.now()) {\n return { remaining: max, resetAt: null };\n }\n\n return {\n remaining: Math.max(0, max - entry.count),\n resetAt: new Date(entry.resetAt),\n };\n },\n\n /**\n * Reset rate limit for an identifier\n */\n async reset(req: Request): Promise<void> {\n const id = identifier ? identifier(req) : getClientIP(req);\n store.delete(id);\n },\n };\n}\n\n/**\n * Extract client IP from request\n */\nexport function getClientIP(req: Request): string {\n // Check common headers set by proxies/load balancers\n const headers = req.headers;\n\n // Cloudflare\n const cfConnectingIP = headers.get('cf-connecting-ip');\n if (cfConnectingIP) return cfConnectingIP;\n\n // Standard proxy headers\n const xForwardedFor = headers.get('x-forwarded-for');\n if (xForwardedFor) {\n // Take the first IP (original client)\n return xForwardedFor.split(',')[0].trim();\n }\n\n const xRealIP = headers.get('x-real-ip');\n if (xRealIP) return xRealIP;\n\n // Vercel\n const xVercelForwardedFor = headers.get('x-vercel-forwarded-for');\n if (xVercelForwardedFor) return xVercelForwardedFor.split(',')[0].trim();\n\n // Fallback - this will be the server's IP in most proxy scenarios\n return 'unknown';\n}\n\n/**\n * Create rate limit headers for response\n */\nexport function createRateLimitHeaders(\n limit: number,\n remaining: number,\n resetAt: Date\n): Headers {\n const headers = new Headers();\n headers.set('X-RateLimit-Limit', limit.toString());\n headers.set('X-RateLimit-Remaining', remaining.toString());\n headers.set('X-RateLimit-Reset', Math.floor(resetAt.getTime() / 1000).toString());\n return headers;\n}\n\n/**\n * Default rate limit config\n */\nexport const defaultRateLimitConfig: RateLimitConfig = {\n max: 5,\n windowSeconds: 60, // 5 requests per minute\n};\n","import type { StorageAdapter, Subscriber, SubscribeInput, SubscriptionStatus } from '../../types';\nimport { generateToken } from '../../utils/crypto';\n\n/**\n * In-memory storage adapter\n * \n * Useful for:\n * - Development and testing\n * - Simple sites that don't need persistence\n * - When you only want to send emails without storing subscribers\n * \n * WARNING: Data is lost when the server restarts!\n * \n * @example\n * ```ts\n * import { createMemoryAdapter } from '@volchok/newsletter-kit/adapters/storage';\n * \n * const storageAdapter = createMemoryAdapter();\n * ```\n */\nexport function createMemoryAdapter(): StorageAdapter {\n const subscribers = new Map<string, Subscriber>();\n const tokenIndex = new Map<string, string>(); // token -> email\n\n return {\n async createSubscriber(input: SubscribeInput, token?: string): Promise<Subscriber> {\n const email = input.email.toLowerCase();\n const confirmToken = token || generateToken();\n const now = new Date();\n\n // Remove old token if exists\n const existing = subscribers.get(email);\n if (existing?.id) {\n // Find and remove old token\n for (const [t, e] of tokenIndex.entries()) {\n if (e === email) {\n tokenIndex.delete(t);\n break;\n }\n }\n }\n\n const subscriber: Subscriber = {\n id: existing?.id || crypto.randomUUID(),\n email,\n status: 'pending',\n source: input.source,\n tags: input.tags || [],\n metadata: input.metadata,\n consentIp: input.ip,\n consentAt: now,\n confirmedAt: undefined,\n unsubscribedAt: undefined,\n createdAt: existing?.createdAt || now,\n updatedAt: now,\n };\n\n subscribers.set(email, subscriber);\n tokenIndex.set(confirmToken, email);\n\n // Return with token attached (hack for internal use)\n return { ...subscriber, id: confirmToken } as Subscriber;\n },\n\n async getSubscriberByEmail(email: string): Promise<Subscriber | null> {\n return subscribers.get(email.toLowerCase()) || null;\n },\n\n async getSubscriberByToken(token: string): Promise<Subscriber | null> {\n const email = tokenIndex.get(token);\n if (!email) return null;\n return subscribers.get(email) || null;\n },\n\n async confirmSubscriber(token: string): Promise<Subscriber | null> {\n const email = tokenIndex.get(token);\n if (!email) return null;\n\n const subscriber = subscribers.get(email);\n if (!subscriber) return null;\n\n const updated: Subscriber = {\n ...subscriber,\n status: 'confirmed',\n confirmedAt: new Date(),\n updatedAt: new Date(),\n };\n\n subscribers.set(email, updated);\n tokenIndex.delete(token);\n\n return updated;\n },\n\n async unsubscribe(email: string): Promise<boolean> {\n const subscriber = subscribers.get(email.toLowerCase());\n if (!subscriber) return false;\n\n const updated: Subscriber = {\n ...subscriber,\n status: 'unsubscribed',\n unsubscribedAt: new Date(),\n updatedAt: new Date(),\n };\n\n subscribers.set(email.toLowerCase(), updated);\n return true;\n },\n\n async listSubscribers(options?: {\n status?: SubscriptionStatus;\n source?: string;\n tags?: string[];\n limit?: number;\n offset?: number;\n }): Promise<{ subscribers: Subscriber[]; total: number }> {\n let results = Array.from(subscribers.values());\n\n if (options?.status) {\n results = results.filter((s) => s.status === options.status);\n }\n if (options?.source) {\n results = results.filter((s) => s.source === options.source);\n }\n if (options?.tags?.length) {\n results = results.filter((s) =>\n options.tags!.some((tag) => s.tags?.includes(tag))\n );\n }\n\n // Sort by createdAt desc\n results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());\n\n const total = results.length;\n const offset = options?.offset || 0;\n const limit = options?.limit || 100;\n\n return {\n subscribers: results.slice(offset, offset + limit),\n total,\n };\n },\n\n async deleteSubscriber(email: string): Promise<boolean> {\n const normalizedEmail = email.toLowerCase();\n const existed = subscribers.has(normalizedEmail);\n subscribers.delete(normalizedEmail);\n\n // Clean up token index\n for (const [token, e] of tokenIndex.entries()) {\n if (e === normalizedEmail) {\n tokenIndex.delete(token);\n break;\n }\n }\n\n return existed;\n },\n\n async updateSubscriber(email: string, data: Partial<Subscriber>): Promise<Subscriber | null> {\n const normalizedEmail = email.toLowerCase();\n const subscriber = subscribers.get(normalizedEmail);\n if (!subscriber) return null;\n\n const updated: Subscriber = {\n ...subscriber,\n ...(data.source !== undefined && { source: data.source }),\n ...(data.tags !== undefined && { tags: data.tags }),\n ...(data.metadata !== undefined && { metadata: data.metadata }),\n updatedAt: new Date(),\n };\n\n subscribers.set(normalizedEmail, updated);\n return updated;\n },\n };\n}\n\n/**\n * No-op storage adapter\n * \n * Use when you don't want to store subscribers at all,\n * only send emails (e.g., when using Mailchimp which handles storage)\n */\nexport function createNoopAdapter(): StorageAdapter {\n return {\n async createSubscriber(input: SubscribeInput): Promise<Subscriber> {\n return {\n id: 'noop',\n email: input.email,\n status: 'pending',\n source: input.source,\n tags: input.tags,\n metadata: input.metadata,\n consentIp: input.ip,\n consentAt: new Date(),\n createdAt: new Date(),\n updatedAt: new Date(),\n };\n },\n async getSubscriberByEmail(): Promise<Subscriber | null> {\n return null;\n },\n async getSubscriberByToken(): Promise<Subscriber | null> {\n return null;\n },\n async confirmSubscriber(): Promise<Subscriber | null> {\n return null;\n },\n async unsubscribe(): Promise<boolean> {\n return true;\n },\n };\n}\n","import type {\n NewsletterConfig,\n SubscribeRequest,\n SubscribeResult,\n ConfirmResult,\n UnsubscribeResult,\n APIResponse,\n Subscriber,\n} from '../types';\nimport { generateToken } from '../utils/crypto';\nimport { isValidEmail, normalizeEmail } from '../utils/validation';\nimport { createRateLimiter, getClientIP, createRateLimitHeaders, defaultRateLimitConfig } from '../utils/rate-limit';\nimport { createMemoryAdapter } from '../adapters/storage/memory';\n\n/**\n * Create newsletter API handlers for Next.js App Router\n * \n * @example\n * ```ts\n * // lib/newsletter.ts\n * import { createNewsletterHandlers } from '@volchok/newsletter-kit/server';\n * import { createResendAdapter } from '@volchok/newsletter-kit/adapters/email';\n * import { createPrismaAdapter } from '@volchok/newsletter-kit/adapters/storage';\n * \n * export const newsletter = createNewsletterHandlers({\n * emailAdapter: createResendAdapter({\n * apiKey: process.env.RESEND_API_KEY!,\n * from: 'newsletter@example.com',\n * }),\n * storageAdapter: createPrismaAdapter({ prisma }),\n * baseUrl: process.env.NEXT_PUBLIC_URL!,\n * doubleOptIn: true,\n * });\n * \n * // app/api/newsletter/subscribe/route.ts\n * import { newsletter } from '@/lib/newsletter';\n * \n * export const POST = newsletter.subscribe;\n * ```\n */\nexport function createNewsletterHandlers(config: NewsletterConfig) {\n const {\n emailAdapter,\n storageAdapter = createMemoryAdapter(),\n doubleOptIn = true,\n baseUrl,\n confirmPath = '/api/newsletter/confirm',\n unsubscribePath = '/api/newsletter/unsubscribe',\n honeypotField = 'website',\n rateLimit = defaultRateLimitConfig,\n validateEmail: customValidateEmail,\n allowedSources,\n defaultTags = [],\n onSubscribe,\n onConfirm,\n onUnsubscribe,\n onError,\n } = config;\n\n const rateLimiter = rateLimit ? createRateLimiter(rateLimit) : null;\n\n /**\n * Helper to create JSON response\n */\n function jsonResponse<T>(\n data: APIResponse<T>,\n status: number = 200,\n headers?: Headers\n ): Response {\n const responseHeaders = new Headers(headers);\n responseHeaders.set('Content-Type', 'application/json');\n\n return new Response(JSON.stringify(data), {\n status,\n headers: responseHeaders,\n });\n }\n\n /**\n * Handle subscription request\n */\n async function handleSubscribe(req: Request): Promise<SubscribeResult> {\n const body = (await req.json()) as SubscribeRequest;\n const { email, source, tags = [], metadata } = body;\n\n // Honeypot check\n if (honeypotField && body[honeypotField]) {\n // Bot detected - return success to not reveal detection\n return {\n success: true,\n message: doubleOptIn\n ? 'Please check your email to confirm your subscription.'\n : 'You have been subscribed!',\n };\n }\n\n // Validate email\n if (!email || !isValidEmail(email)) {\n return {\n success: false,\n message: 'Please enter a valid email address.',\n };\n }\n\n // Custom validation\n if (customValidateEmail) {\n const isValid = await customValidateEmail(email);\n if (!isValid) {\n return {\n success: false,\n message: 'This email address is not allowed.',\n };\n }\n }\n\n // Validate source\n if (source && allowedSources && !allowedSources.includes(source)) {\n return {\n success: false,\n message: 'Invalid subscription source.',\n };\n }\n\n const normalizedEmail = normalizeEmail(email);\n const ip = getClientIP(req);\n\n try {\n // Check if already subscribed\n const existing = await storageAdapter.getSubscriberByEmail(normalizedEmail);\n\n if (existing?.status === 'confirmed') {\n return {\n success: true,\n message: 'You are already subscribed!',\n subscriber: existing,\n };\n }\n\n // Create/update subscriber\n const token = generateToken();\n const subscriber = await storageAdapter.createSubscriber(\n {\n email: normalizedEmail,\n source,\n tags: [...defaultTags, ...tags],\n metadata,\n ip,\n },\n token\n );\n\n if (doubleOptIn) {\n // Send confirmation email\n const confirmUrl = `${baseUrl}${confirmPath}?token=${token}`;\n await emailAdapter.sendConfirmation(normalizedEmail, token, confirmUrl);\n\n // Notify admin if configured\n if (emailAdapter.notifyAdmin) {\n await emailAdapter.notifyAdmin(subscriber);\n }\n\n // Callback\n if (onSubscribe) {\n await onSubscribe(subscriber);\n }\n\n return {\n success: true,\n message: 'Please check your email to confirm your subscription.',\n subscriber,\n requiresConfirmation: true,\n };\n } else {\n // Direct subscription (no double opt-in)\n const confirmed = await storageAdapter.confirmSubscriber(token);\n\n // Send welcome email\n await emailAdapter.sendWelcome(normalizedEmail);\n\n // Notify admin if configured\n if (emailAdapter.notifyAdmin && confirmed) {\n await emailAdapter.notifyAdmin(confirmed);\n }\n\n // Callback\n if (onSubscribe && confirmed) {\n await onSubscribe(confirmed);\n }\n\n return {\n success: true,\n message: 'You have been subscribed!',\n subscriber: confirmed || subscriber,\n requiresConfirmation: false,\n };\n }\n } catch (error) {\n if (onError) {\n await onError(error as Error, 'subscribe');\n }\n console.error('[newsletter-kit] Subscribe error:', error);\n return {\n success: false,\n message: 'Something went wrong. Please try again.',\n };\n }\n }\n\n /**\n * Handle confirmation request\n */\n async function handleConfirm(token: string): Promise<ConfirmResult> {\n if (!token) {\n return {\n success: false,\n message: 'Invalid confirmation link.',\n };\n }\n\n try {\n const subscriber = await storageAdapter.confirmSubscriber(token);\n\n if (!subscriber) {\n return {\n success: false,\n message: 'This confirmation link is invalid or has expired.',\n };\n }\n\n // Send welcome email\n await emailAdapter.sendWelcome(subscriber.email);\n\n // Callback\n if (onConfirm) {\n await onConfirm(subscriber);\n }\n\n return {\n success: true,\n message: 'Your subscription has been confirmed!',\n subscriber,\n };\n } catch (error) {\n if (onError) {\n await onError(error as Error, 'confirm');\n }\n console.error('[newsletter-kit] Confirm error:', error);\n return {\n success: false,\n message: 'Something went wrong. Please try again.',\n };\n }\n }\n\n /**\n * Handle unsubscribe request\n */\n async function handleUnsubscribe(email: string): Promise<UnsubscribeResult> {\n if (!email || !isValidEmail(email)) {\n return {\n success: false,\n message: 'Invalid email address.',\n };\n }\n\n const normalizedEmail = normalizeEmail(email);\n\n try {\n const success = await storageAdapter.unsubscribe(normalizedEmail);\n\n if (!success) {\n return {\n success: false,\n message: 'Email not found in our list.',\n };\n }\n\n // Send unsubscribe confirmation if adapter supports it\n if (emailAdapter.sendUnsubscribed) {\n const resubscribeUrl = `${baseUrl}`;\n await emailAdapter.sendUnsubscribed(normalizedEmail, resubscribeUrl);\n }\n\n // Callback\n if (onUnsubscribe) {\n await onUnsubscribe(normalizedEmail);\n }\n\n return {\n success: true,\n message: 'You have been unsubscribed.',\n };\n } catch (error) {\n if (onError) {\n await onError(error as Error, 'unsubscribe');\n }\n console.error('[newsletter-kit] Unsubscribe error:', error);\n return {\n success: false,\n message: 'Something went wrong. Please try again.',\n };\n }\n }\n\n // Return Next.js route handlers\n return {\n /**\n * POST /api/newsletter/subscribe\n */\n subscribe: async (req: Request): Promise<Response> => {\n // Rate limiting\n if (rateLimiter) {\n const { allowed, remaining, resetAt } = await rateLimiter.check(req);\n const headers = createRateLimitHeaders(rateLimit!.max, remaining, resetAt);\n\n if (!allowed) {\n return jsonResponse(\n {\n success: false,\n message: 'Too many requests. Please try again later.',\n },\n 429,\n headers\n );\n }\n }\n\n const result = await handleSubscribe(req);\n return jsonResponse(result, result.success ? 200 : 400);\n },\n\n /**\n * GET /api/newsletter/confirm?token=xxx\n * \n * Can also return a redirect for better UX\n */\n confirm: async (req: Request): Promise<Response> => {\n const url = new URL(req.url);\n const token = url.searchParams.get('token');\n\n const result = await handleConfirm(token || '');\n\n // Check if client wants JSON or redirect\n const acceptsJson = req.headers.get('accept')?.includes('application/json');\n\n if (acceptsJson) {\n return jsonResponse(result, result.success ? 200 : 400);\n }\n\n // Redirect to success/error page\n const redirectUrl = new URL(baseUrl);\n redirectUrl.searchParams.set('confirmed', result.success ? 'true' : 'false');\n if (!result.success) {\n redirectUrl.searchParams.set('error', result.message);\n }\n\n return Response.redirect(redirectUrl.toString(), 302);\n },\n\n /**\n * POST /api/newsletter/unsubscribe\n * Also supports GET with email query param\n */\n unsubscribe: async (req: Request): Promise<Response> => {\n let email: string;\n\n if (req.method === 'GET') {\n const url = new URL(req.url);\n email = url.searchParams.get('email') || '';\n } else {\n const body = await req.json();\n email = body.email;\n }\n\n const result = await handleUnsubscribe(email);\n return jsonResponse(result, result.success ? 200 : 400);\n },\n\n /**\n * Internal handlers for custom implementations\n */\n handlers: {\n subscribe: handleSubscribe,\n confirm: handleConfirm,\n unsubscribe: handleUnsubscribe,\n },\n\n /**\n * Access to storage adapter for admin features\n */\n storage: storageAdapter,\n\n /**\n * Get subscriber by email (for admin/API use)\n */\n getSubscriber: async (email: string): Promise<Subscriber | null> => {\n return storageAdapter.getSubscriberByEmail(normalizeEmail(email));\n },\n\n /**\n * List subscribers (for admin/export use)\n */\n listSubscribers: storageAdapter.listSubscribers,\n };\n}\n\nexport type NewsletterHandlers = ReturnType<typeof createNewsletterHandlers>;\n"],"mappings":"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","names":[]}
|