@contentgrowth/content-emailing 0.1.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.
@@ -0,0 +1,363 @@
1
+ /**
2
+ * EmailTemplateCacheDO - Optional read-through cache for email templates
3
+ *
4
+ * This is a Cloudflare Durable Object that provides caching for email templates.
5
+ * It is OPTIONAL - to use it, add to your wrangler.toml/wrangler.json:
6
+ *
7
+ * [[durable_objects.bindings]]
8
+ * name = "EMAIL_TEMPLATE_CACHE"
9
+ * class_name = "EmailTemplateCacheDO"
10
+ *
11
+ * [[migrations]]
12
+ * tag = "v1"
13
+ * new_classes = ["EmailTemplateCacheDO"]
14
+ *
15
+ * Then pass the DO stub as cacheProvider to EmailService:
16
+ *
17
+ * const cacheProvider = createDOCacheProvider(env.EMAIL_TEMPLATE_CACHE);
18
+ * const emailService = new EmailService(env, config, cacheProvider);
19
+ *
20
+ * Caching strategy:
21
+ * - READ: Check cache → if miss, load from D1 and cache
22
+ * - WRITE: Update D1 directly → invalidate cache → next read refreshes
23
+ *
24
+ * Cache key: template_id
25
+ * TTL: 1 hour (templates rarely change)
26
+ */
27
+ export class EmailTemplateCacheDO {
28
+ constructor(state, env) {
29
+ this.state = state;
30
+ this.env = env;
31
+ this.cache = new Map(); // templateId -> { data, timestamp }
32
+ this.settingsCache = null; // { data, timestamp }
33
+ this.cacheTTL = 3600000; // 1 hour in milliseconds
34
+ }
35
+
36
+ /**
37
+ * Handle HTTP requests to this Durable Object
38
+ */
39
+ async fetch(request) {
40
+ const url = new URL(request.url);
41
+ const path = url.pathname;
42
+
43
+ try {
44
+ if (path === '/get' && request.method === 'GET') {
45
+ return this.handleGet(request);
46
+ } else if (path === '/getSettings' && request.method === 'GET') {
47
+ return this.handleGetSettings(request);
48
+ } else if (path === '/invalidate' && request.method === 'POST') {
49
+ return this.handleInvalidate(request);
50
+ } else if (path === '/clear' && request.method === 'POST') {
51
+ return this.handleClear(request);
52
+ } else if (path === '/stats' && request.method === 'GET') {
53
+ return this.handleStats(request);
54
+ } else {
55
+ return new Response('Not Found', { status: 404 });
56
+ }
57
+ } catch (error) {
58
+ console.error('[EmailTemplateCacheDO] Error:', error);
59
+ return new Response(JSON.stringify({ error: error.message }), {
60
+ status: 500,
61
+ headers: { 'Content-Type': 'application/json' },
62
+ });
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Get template (from cache or D1)
68
+ * Query params:
69
+ * - templateId: Template ID to fetch
70
+ * - refresh: Force refresh from D1 (optional)
71
+ */
72
+ async handleGet(request) {
73
+ const url = new URL(request.url);
74
+ const templateId = url.searchParams.get('templateId');
75
+ const forceRefresh = url.searchParams.get('refresh') === 'true';
76
+
77
+ if (!templateId) {
78
+ return new Response(JSON.stringify({ error: 'templateId is required' }), {
79
+ status: 400,
80
+ headers: { 'Content-Type': 'application/json' },
81
+ });
82
+ }
83
+
84
+ // Check cache (unless force refresh)
85
+ if (!forceRefresh) {
86
+ const cached = this.cache.get(templateId);
87
+ if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
88
+ console.log('[EmailTemplateCacheDO] Cache HIT:', templateId);
89
+ return new Response(JSON.stringify({
90
+ template: cached.data,
91
+ cached: true,
92
+ age: Date.now() - cached.timestamp,
93
+ }), {
94
+ headers: { 'Content-Type': 'application/json' },
95
+ });
96
+ }
97
+ }
98
+
99
+ // Cache miss or expired - fetch from D1
100
+ console.log('[EmailTemplateCacheDO] Cache MISS - fetching from D1:', templateId);
101
+ const template = await this.fetchTemplateFromD1(templateId);
102
+
103
+ if (!template) {
104
+ return new Response(JSON.stringify({
105
+ error: 'Template not found',
106
+ templateId
107
+ }), {
108
+ status: 404,
109
+ headers: { 'Content-Type': 'application/json' },
110
+ });
111
+ }
112
+
113
+ // Cache the result
114
+ this.cache.set(templateId, {
115
+ data: template,
116
+ timestamp: Date.now(),
117
+ });
118
+
119
+ return new Response(JSON.stringify({
120
+ template,
121
+ cached: false,
122
+ }), {
123
+ headers: { 'Content-Type': 'application/json' },
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Get system settings from cache
129
+ */
130
+ async handleGetSettings(request) {
131
+ const url = new URL(request.url);
132
+ const forceRefresh = url.searchParams.get('refresh') === 'true';
133
+
134
+ // Check cache
135
+ if (!forceRefresh && this.settingsCache && Date.now() - this.settingsCache.timestamp < this.cacheTTL) {
136
+ return new Response(JSON.stringify({
137
+ settings: this.settingsCache.data,
138
+ cached: true,
139
+ age: Date.now() - this.settingsCache.timestamp,
140
+ }), {
141
+ headers: { 'Content-Type': 'application/json' },
142
+ });
143
+ }
144
+
145
+ // Fetch from D1
146
+ const settings = await this.fetchSettingsFromD1();
147
+
148
+ this.settingsCache = {
149
+ data: settings,
150
+ timestamp: Date.now(),
151
+ };
152
+
153
+ return new Response(JSON.stringify({
154
+ settings,
155
+ cached: false,
156
+ }), {
157
+ headers: { 'Content-Type': 'application/json' },
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Invalidate specific template(s) from cache
163
+ * Body: { templateId: 'template_id' } or { templateId: '*' } for all
164
+ */
165
+ async handleInvalidate(request) {
166
+ const body = await request.json();
167
+ const { templateId, settings } = body;
168
+
169
+ if (settings) {
170
+ this.settingsCache = null;
171
+ console.log('[EmailTemplateCacheDO] Invalidated settings cache');
172
+ return new Response(JSON.stringify({
173
+ success: true,
174
+ message: 'Settings cache invalidated',
175
+ }), {
176
+ headers: { 'Content-Type': 'application/json' },
177
+ });
178
+ }
179
+
180
+ if (!templateId) {
181
+ return new Response(JSON.stringify({ error: 'templateId or settings flag is required' }), {
182
+ status: 400,
183
+ headers: { 'Content-Type': 'application/json' },
184
+ });
185
+ }
186
+
187
+ if (templateId === '*') {
188
+ // Invalidate all templates
189
+ const count = this.cache.size;
190
+ this.cache.clear();
191
+ console.log('[EmailTemplateCacheDO] Invalidated ALL templates:', count);
192
+ return new Response(JSON.stringify({
193
+ success: true,
194
+ message: `Invalidated ${count} templates`,
195
+ }), {
196
+ headers: { 'Content-Type': 'application/json' },
197
+ });
198
+ }
199
+
200
+ // Invalidate specific template
201
+ const existed = this.cache.has(templateId);
202
+ this.cache.delete(templateId);
203
+ console.log('[EmailTemplateCacheDO] Invalidated template:', templateId, existed ? '(existed)' : '(not in cache)');
204
+
205
+ return new Response(JSON.stringify({
206
+ success: true,
207
+ message: existed ? 'Template invalidated' : 'Template was not in cache',
208
+ templateId,
209
+ }), {
210
+ headers: { 'Content-Type': 'application/json' },
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Clear entire cache (admin operation)
216
+ */
217
+ async handleClear(request) {
218
+ const count = this.cache.size;
219
+ this.cache.clear();
220
+ this.settingsCache = null;
221
+ console.log('[EmailTemplateCacheDO] Cache cleared:', count, 'entries');
222
+
223
+ return new Response(JSON.stringify({
224
+ success: true,
225
+ message: `Cleared ${count} cached templates and settings`,
226
+ }), {
227
+ headers: { 'Content-Type': 'application/json' },
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Get cache statistics
233
+ */
234
+ async handleStats(request) {
235
+ const stats = {
236
+ cacheSize: this.cache.size,
237
+ cacheTTL: this.cacheTTL,
238
+ hasSettingsCache: !!this.settingsCache,
239
+ templates: [],
240
+ };
241
+
242
+ const now = Date.now();
243
+ for (const [templateId, entry] of this.cache.entries()) {
244
+ stats.templates.push({
245
+ templateId,
246
+ age: now - entry.timestamp,
247
+ expired: now - entry.timestamp > this.cacheTTL,
248
+ });
249
+ }
250
+
251
+ return new Response(JSON.stringify(stats), {
252
+ headers: { 'Content-Type': 'application/json' },
253
+ });
254
+ }
255
+
256
+ /**
257
+ * Fetch template from D1
258
+ */
259
+ async fetchTemplateFromD1(templateId) {
260
+ const db = this.env.DB;
261
+ const template = await db
262
+ .prepare(`SELECT * FROM system_email_templates WHERE template_id = ? AND is_active = 1`)
263
+ .bind(templateId)
264
+ .first();
265
+
266
+ return template;
267
+ }
268
+
269
+ /**
270
+ * Fetch settings from D1
271
+ */
272
+ async fetchSettingsFromD1() {
273
+ const db = this.env.DB;
274
+ const settings = await db
275
+ .prepare(`SELECT setting_key, setting_value FROM system_settings`)
276
+ .all();
277
+
278
+ const config = {};
279
+ if (settings.results) {
280
+ settings.results.forEach((row) => {
281
+ config[row.setting_key] = row.setting_value;
282
+ });
283
+ }
284
+ return config;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Create a cache provider wrapper for the DO
290
+ * This adapts the DO interface to the simpler interface expected by EmailService
291
+ *
292
+ * @param {DurableObjectStub} doStub - The DO stub from env.EMAIL_TEMPLATE_CACHE
293
+ * @returns {Object} Cache provider interface
294
+ */
295
+ export function createDOCacheProvider(doStub) {
296
+ if (!doStub) {
297
+ return null;
298
+ }
299
+
300
+ const stub = doStub.get(doStub.idFromName('global'));
301
+
302
+ return {
303
+ async getTemplate(templateId) {
304
+ try {
305
+ const response = await stub.fetch(`http://do/get?templateId=${templateId}`);
306
+ const data = await response.json();
307
+ return data.template || null;
308
+ } catch (e) {
309
+ console.warn('[DOCacheProvider] Failed to get template:', e);
310
+ return null;
311
+ }
312
+ },
313
+
314
+ async getSettings() {
315
+ try {
316
+ const response = await stub.fetch('http://do/getSettings');
317
+ const data = await response.json();
318
+ return data.settings || null;
319
+ } catch (e) {
320
+ console.warn('[DOCacheProvider] Failed to get settings:', e);
321
+ return null;
322
+ }
323
+ },
324
+
325
+ async putTemplate(template) {
326
+ // DO uses invalidation, not direct puts
327
+ // Invalidate so next read refreshes from D1
328
+ try {
329
+ await stub.fetch('http://do/invalidate', {
330
+ method: 'POST',
331
+ headers: { 'Content-Type': 'application/json' },
332
+ body: JSON.stringify({ templateId: template.template_id }),
333
+ });
334
+ } catch (e) {
335
+ console.warn('[DOCacheProvider] Failed to invalidate template:', e);
336
+ }
337
+ },
338
+
339
+ async deleteTemplate(templateId) {
340
+ try {
341
+ await stub.fetch('http://do/invalidate', {
342
+ method: 'POST',
343
+ headers: { 'Content-Type': 'application/json' },
344
+ body: JSON.stringify({ templateId }),
345
+ });
346
+ } catch (e) {
347
+ console.warn('[DOCacheProvider] Failed to invalidate template:', e);
348
+ }
349
+ },
350
+
351
+ async invalidateSettings() {
352
+ try {
353
+ await stub.fetch('http://do/invalidate', {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({ settings: true }),
357
+ });
358
+ } catch (e) {
359
+ console.warn('[DOCacheProvider] Failed to invalidate settings:', e);
360
+ }
361
+ },
362
+ };
363
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Email Routes Index
3
+ * Aggregates and exports all route factories
4
+ */
5
+ import { Hono } from 'hono';
6
+ import { createTemplateRoutes } from './templates.js';
7
+ import { createTrackingRoutes } from './tracking.js';
8
+
9
+ // Re-export individual route factories
10
+ export { createTemplateRoutes } from './templates.js';
11
+ export { createTrackingRoutes } from './tracking.js';
12
+
13
+ /**
14
+ * Create combined email routes
15
+ * @param {Object} env - Environment bindings
16
+ * @param {Object} config - Configuration
17
+ * @param {Object} cacheProvider - Optional cache provider
18
+ * @returns {Hono} Combined Hono router
19
+ */
20
+ export function createEmailRoutes(env, config = {}, cacheProvider = null) {
21
+ const app = new Hono();
22
+
23
+ // Mount template routes at /api/email
24
+ app.route('/api/email', createTemplateRoutes(env, config, cacheProvider));
25
+
26
+ // Mount tracking routes at /email
27
+ app.route('/email', createTrackingRoutes(env, config));
28
+
29
+ return app;
30
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Email Template Routes Factory
3
+ * Creates a Hono router with admin endpoints for template management.
4
+ */
5
+ import { Hono } from 'hono';
6
+ import { EmailService } from '../EmailService.js';
7
+
8
+ /**
9
+ * Create email template routes
10
+ * @param {Object} env - Environment bindings
11
+ * @param {Object} config - Configuration
12
+ * @param {Object} cacheProvider - Optional cache provider
13
+ * @returns {Hono} Hono router
14
+ */
15
+ export function createTemplateRoutes(env, config = {}, cacheProvider = null) {
16
+ const app = new Hono();
17
+ const emailService = new EmailService(env, config, cacheProvider);
18
+
19
+ // List all templates
20
+ app.get('/templates', async (c) => {
21
+ try {
22
+ const templates = await emailService.getAllTemplates();
23
+ return c.json({ success: true, templates });
24
+ } catch (err) {
25
+ return c.json({ success: false, error: err.message }, 500);
26
+ }
27
+ });
28
+
29
+ // Get single template
30
+ app.get('/templates/:id', async (c) => {
31
+ try {
32
+ const template = await emailService.getTemplate(c.req.param('id'));
33
+ if (!template) return c.json({ success: false, error: 'Template not found' }, 404);
34
+ return c.json({ success: true, template });
35
+ } catch (err) {
36
+ return c.json({ success: false, error: err.message }, 500);
37
+ }
38
+ });
39
+
40
+ // Create/Update template
41
+ app.post('/templates', async (c) => {
42
+ try {
43
+ const data = await c.req.json();
44
+ await emailService.saveTemplate(data, 'admin'); // TODO: Pass real user ID
45
+ return c.json({ success: true, message: 'Template saved' });
46
+ } catch (err) {
47
+ return c.json({ success: false, error: err.message }, 500);
48
+ }
49
+ });
50
+
51
+ // Delete template
52
+ app.delete('/templates/:id', async (c) => {
53
+ try {
54
+ await emailService.deleteTemplate(c.req.param('id'));
55
+ return c.json({ success: true, message: 'Template deleted' });
56
+ } catch (err) {
57
+ return c.json({ success: false, error: err.message }, 500);
58
+ }
59
+ });
60
+
61
+ // Preview template
62
+ app.post('/templates/:id/preview', async (c) => {
63
+ try {
64
+ const id = c.req.param('id');
65
+ const data = await c.req.json(); // Variables
66
+ const result = await emailService.renderTemplate(id, data);
67
+ return c.json({ success: true, preview: result });
68
+ } catch (err) {
69
+ return c.json({ success: false, error: err.message }, 500);
70
+ }
71
+ });
72
+
73
+ // Send test email
74
+ app.post('/templates/:id/test', async (c) => {
75
+ try {
76
+ const id = c.req.param('id');
77
+ const { to, data } = await c.req.json();
78
+
79
+ const { subject, html, plainText } = await emailService.renderTemplate(id, data);
80
+ const result = await emailService.sendEmail({
81
+ to,
82
+ subject: `[TEST] ${subject}`,
83
+ html,
84
+ text: plainText
85
+ });
86
+
87
+ if (result.success) {
88
+ return c.json({ success: true, message: 'Test email sent' });
89
+ } else {
90
+ return c.json({ success: false, error: result.error }, 500);
91
+ }
92
+ } catch (err) {
93
+ return c.json({ success: false, error: err.message }, 500);
94
+ }
95
+ });
96
+
97
+ return app;
98
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Email Tracking Routes Factory
3
+ * Creates a Hono router with tracking endpoints for the email system.
4
+ */
5
+ import { Hono } from 'hono';
6
+
7
+ // 1x1 transparent GIF pixel
8
+ const TRACKING_PIXEL = new Uint8Array([
9
+ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00,
10
+ 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x21,
11
+ 0xF9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00,
12
+ 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x01, 0x44,
13
+ 0x00, 0x3B
14
+ ]);
15
+
16
+ /**
17
+ * Create email tracking routes
18
+ * @param {Object} env - Environment bindings
19
+ * @param {Object} config - Configuration
20
+ * @param {string} [config.tableNamePrefix='system_email_'] - Prefix for D1 tables
21
+ * @returns {Hono} Hono router
22
+ */
23
+ export function createTrackingRoutes(env, config = {}) {
24
+ const app = new Hono();
25
+ const db = env.DB;
26
+ const tablePrefix = config.tableNamePrefix || 'system_email_';
27
+
28
+ /**
29
+ * GET /track/open/:token
30
+ * Track email opens via a 1x1 pixel
31
+ */
32
+ app.get('/track/open/:token', async (c) => {
33
+ const token = c.req.param('token');
34
+
35
+ try {
36
+ const sendId = token;
37
+
38
+ // Look up the send record
39
+ const send = await db
40
+ .prepare(`SELECT * FROM ${tablePrefix}sends WHERE send_id = ?`)
41
+ .bind(sendId)
42
+ .first();
43
+
44
+ if (send) {
45
+ // Log the open event
46
+ const eventId = crypto.randomUUID();
47
+ const now = Math.floor(Date.now() / 1000);
48
+
49
+ await db
50
+ .prepare(
51
+ `INSERT INTO ${tablePrefix}events (
52
+ event_id, send_id, user_id, tenant_id, email_kind, event_type, metadata, created_at
53
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
54
+ )
55
+ .bind(
56
+ eventId,
57
+ sendId,
58
+ send.user_id,
59
+ send.tenant_id,
60
+ send.email_kind,
61
+ 'opened',
62
+ JSON.stringify({
63
+ user_agent: c.req.header('user-agent'),
64
+ referer: c.req.header('referer'),
65
+ }),
66
+ now
67
+ )
68
+ .run();
69
+ }
70
+ } catch (error) {
71
+ console.error('[EmailTracking] Error tracking email open:', error);
72
+ // Don't fail the request - just serve the pixel
73
+ }
74
+
75
+ // Always return the tracking pixel
76
+ return new Response(TRACKING_PIXEL, {
77
+ headers: {
78
+ 'Content-Type': 'image/gif',
79
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
80
+ 'Pragma': 'no-cache',
81
+ 'Expires': '0',
82
+ },
83
+ });
84
+ });
85
+
86
+ /**
87
+ * POST /track/click/:token
88
+ * Track email clicks
89
+ */
90
+ app.post('/track/click/:token', async (c) => {
91
+ const token = c.req.param('token');
92
+
93
+ let url;
94
+ try {
95
+ const body = await c.req.json();
96
+ url = body.url;
97
+ } catch {
98
+ return c.json({ success: false, error: 'Invalid request body' }, 400);
99
+ }
100
+
101
+ if (!url) {
102
+ return c.json({ success: false, error: 'Missing URL' }, 400);
103
+ }
104
+
105
+ try {
106
+ const sendId = token;
107
+
108
+ // Look up the send record
109
+ const send = await db
110
+ .prepare(`SELECT * FROM ${tablePrefix}sends WHERE send_id = ?`)
111
+ .bind(sendId)
112
+ .first();
113
+
114
+ if (send) {
115
+ // Log the click event
116
+ const eventId = crypto.randomUUID();
117
+ const now = Math.floor(Date.now() / 1000);
118
+
119
+ await db
120
+ .prepare(
121
+ `INSERT INTO ${tablePrefix}events (
122
+ event_id, send_id, user_id, tenant_id, email_kind, event_type, metadata, created_at
123
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
124
+ )
125
+ .bind(
126
+ eventId,
127
+ sendId,
128
+ send.user_id,
129
+ send.tenant_id,
130
+ send.email_kind,
131
+ 'clicked',
132
+ JSON.stringify({
133
+ url: url,
134
+ user_agent: c.req.header('user-agent'),
135
+ referer: c.req.header('referer'),
136
+ }),
137
+ now
138
+ )
139
+ .run();
140
+ }
141
+
142
+ return c.json({ success: true, tracked: !!send });
143
+ } catch (error) {
144
+ console.error('[EmailTracking] Error tracking email click:', error);
145
+ return c.json({ success: false, error: 'Failed to track click' }, 500);
146
+ }
147
+ });
148
+
149
+ /**
150
+ * GET /unsubscribe/:token
151
+ * Unsubscribe a user from all email notifications
152
+ */
153
+ app.get('/unsubscribe/:token', async (c) => {
154
+ const unsubToken = c.req.param('token');
155
+
156
+ try {
157
+ // Find the user by their unsubscribe token
158
+ const prefs = await db
159
+ .prepare(`SELECT * FROM ${tablePrefix}preferences WHERE unsub_token = ?`)
160
+ .bind(unsubToken)
161
+ .first();
162
+
163
+ if (!prefs) {
164
+ return c.json({ success: false, error: 'Invalid unsubscribe link' }, 404);
165
+ }
166
+
167
+ // Check if already unsubscribed
168
+ const currentSettings = JSON.parse(prefs.email_settings || '{}');
169
+ const alreadyUnsubscribed = Object.keys(currentSettings).length === 0;
170
+
171
+ if (!alreadyUnsubscribed) {
172
+ // Disable all email types by setting email_settings to empty object
173
+ const now = Math.floor(Date.now() / 1000);
174
+ await db
175
+ .prepare(
176
+ `UPDATE ${tablePrefix}preferences
177
+ SET email_settings = '{}',
178
+ updated_at = ?
179
+ WHERE unsub_token = ?`
180
+ )
181
+ .bind(now, unsubToken)
182
+ .run();
183
+
184
+ // Log the unsubscribe event
185
+ const eventId = crypto.randomUUID();
186
+ await db
187
+ .prepare(
188
+ `INSERT INTO ${tablePrefix}events (
189
+ event_id, send_id, user_id, tenant_id, email_kind, event_type, metadata, created_at
190
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
191
+ )
192
+ .bind(
193
+ eventId,
194
+ 'unsubscribe',
195
+ prefs.user_id,
196
+ prefs.tenant_id,
197
+ 'all',
198
+ 'unsubscribed',
199
+ JSON.stringify({
200
+ user_agent: c.req.header('user-agent'),
201
+ }),
202
+ now
203
+ )
204
+ .run();
205
+ }
206
+
207
+ return c.json({ success: true, alreadyUnsubscribed });
208
+ } catch (error) {
209
+ console.error('[EmailTracking] Error processing unsubscribe:', error);
210
+ return c.json({ success: false, error: 'An error occurred' }, 500);
211
+ }
212
+ });
213
+
214
+ return app;
215
+ }