@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.
- package/README.md +96 -0
- package/examples/.env.example +16 -0
- package/examples/README.md +55 -0
- package/examples/mocks/MockD1.js +311 -0
- package/examples/mocks/MockEmailSender.js +64 -0
- package/examples/mocks/index.js +5 -0
- package/examples/package-lock.json +73 -0
- package/examples/package.json +18 -0
- package/examples/portal/index.html +919 -0
- package/examples/server.js +314 -0
- package/package.json +32 -0
- package/release.sh +56 -0
- package/schema.sql +63 -0
- package/src/backend/EmailService.js +474 -0
- package/src/backend/EmailTemplateCacheDO.js +363 -0
- package/src/backend/routes/index.js +30 -0
- package/src/backend/routes/templates.js +98 -0
- package/src/backend/routes/tracking.js +215 -0
- package/src/backend/routes.js +98 -0
- package/src/common/htmlWrapper.js +169 -0
- package/src/common/index.js +11 -0
- package/src/common/utils.js +117 -0
- package/src/frontend/TemplateEditor.jsx +117 -0
- package/src/frontend/TemplateManager.jsx +117 -0
- package/src/index.js +24 -0
|
@@ -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
|
+
}
|