@contentgrowth/content-emailing 0.7.4 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/index.cjs +1771 -0
- package/dist/backend/index.cjs.map +1 -0
- package/dist/backend/index.d.cts +70 -0
- package/dist/backend/index.d.ts +70 -0
- package/dist/backend/index.js +1722 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/routes/index.cjs +87 -42
- package/dist/backend/routes/index.cjs.map +1 -1
- package/dist/backend/routes/index.d.cts +10 -6
- package/dist/backend/routes/index.d.ts +10 -6
- package/dist/backend/routes/index.js +86 -42
- package/dist/backend/routes/index.js.map +1 -1
- package/dist/index.cjs +85 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -65
- package/dist/index.d.ts +1 -65
- package/dist/index.js +85 -42
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1771 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
20
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
21
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
22
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
23
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
24
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
25
|
+
mod
|
|
26
|
+
));
|
|
27
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
28
|
+
|
|
29
|
+
// src/backend/index.js
|
|
30
|
+
var backend_exports = {};
|
|
31
|
+
__export(backend_exports, {
|
|
32
|
+
EmailLogger: () => EmailLogger,
|
|
33
|
+
EmailService: () => EmailService,
|
|
34
|
+
EmailingCacheDO: () => EmailingCacheDO,
|
|
35
|
+
createDOCacheProvider: () => createDOCacheProvider,
|
|
36
|
+
createEmailLoggerCallback: () => createEmailLoggerCallback,
|
|
37
|
+
createEmailRoutes: () => createEmailRoutes,
|
|
38
|
+
createTemplateRoutes: () => createTemplateRoutes,
|
|
39
|
+
createTrackingRoutes: () => createTrackingRoutes,
|
|
40
|
+
encodeTrackingLinks: () => encodeTrackingLinks,
|
|
41
|
+
extractVariables: () => extractVariables,
|
|
42
|
+
getWebsiteUrl: () => getWebsiteUrl,
|
|
43
|
+
markdownToPlainText: () => markdownToPlainText,
|
|
44
|
+
resetWebsiteUrlCache: () => resetWebsiteUrlCache,
|
|
45
|
+
wrapInEmailTemplate: () => wrapInEmailTemplate
|
|
46
|
+
});
|
|
47
|
+
module.exports = __toCommonJS(backend_exports);
|
|
48
|
+
|
|
49
|
+
// src/backend/EmailService.js
|
|
50
|
+
var import_marked = require("marked");
|
|
51
|
+
var import_mustache = __toESM(require("mustache"), 1);
|
|
52
|
+
|
|
53
|
+
// src/common/htmlWrapper.js
|
|
54
|
+
function wrapInEmailTemplate(contentHtml, subject, data = {}) {
|
|
55
|
+
const portalUrl = data.portalUrl || "https://app.x0start.com";
|
|
56
|
+
const unsubscribeUrl = data.unsubscribeUrl || "{{unsubscribe_url}}";
|
|
57
|
+
const brandName = data.brandName || "X0 Start";
|
|
58
|
+
return `
|
|
59
|
+
<!DOCTYPE html>
|
|
60
|
+
<html lang="en">
|
|
61
|
+
<head>
|
|
62
|
+
<meta charset="UTF-8">
|
|
63
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
64
|
+
<title>${subject}</title>
|
|
65
|
+
<style>
|
|
66
|
+
body {
|
|
67
|
+
margin: 0;
|
|
68
|
+
padding: 0;
|
|
69
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
70
|
+
background-color: #f5f5f5;
|
|
71
|
+
line-height: 1.6;
|
|
72
|
+
}
|
|
73
|
+
.email-wrapper {
|
|
74
|
+
background-color: #f5f5f5;
|
|
75
|
+
padding: 40px 20px;
|
|
76
|
+
}
|
|
77
|
+
.email-container {
|
|
78
|
+
max-width: 600px;
|
|
79
|
+
margin: 0 auto;
|
|
80
|
+
background-color: #ffffff;
|
|
81
|
+
border-radius: 8px;
|
|
82
|
+
overflow: hidden;
|
|
83
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
84
|
+
}
|
|
85
|
+
.email-header {
|
|
86
|
+
padding: 40px 40px 20px;
|
|
87
|
+
text-align: center;
|
|
88
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
89
|
+
color: #ffffff;
|
|
90
|
+
}
|
|
91
|
+
.email-header h1 {
|
|
92
|
+
margin: 0;
|
|
93
|
+
font-size: 28px;
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
}
|
|
96
|
+
.email-content {
|
|
97
|
+
padding: 30px 40px;
|
|
98
|
+
color: #333333;
|
|
99
|
+
}
|
|
100
|
+
.email-content h1 {
|
|
101
|
+
font-size: 24px;
|
|
102
|
+
margin-top: 0;
|
|
103
|
+
margin-bottom: 20px;
|
|
104
|
+
color: #333333;
|
|
105
|
+
}
|
|
106
|
+
.email-content h2 {
|
|
107
|
+
font-size: 20px;
|
|
108
|
+
margin-top: 30px;
|
|
109
|
+
margin-bottom: 15px;
|
|
110
|
+
color: #333333;
|
|
111
|
+
}
|
|
112
|
+
.email-content h3 {
|
|
113
|
+
font-size: 16px;
|
|
114
|
+
margin-top: 20px;
|
|
115
|
+
margin-bottom: 10px;
|
|
116
|
+
color: #333333;
|
|
117
|
+
}
|
|
118
|
+
.email-content p {
|
|
119
|
+
margin: 0 0 15px;
|
|
120
|
+
color: #666666;
|
|
121
|
+
}
|
|
122
|
+
.email-content a {
|
|
123
|
+
color: #667eea;
|
|
124
|
+
text-decoration: none;
|
|
125
|
+
}
|
|
126
|
+
.email-content ul, .email-content ol {
|
|
127
|
+
margin: 0 0 15px;
|
|
128
|
+
padding-left: 25px;
|
|
129
|
+
}
|
|
130
|
+
.email-content li {
|
|
131
|
+
margin-bottom: 8px;
|
|
132
|
+
color: #666666;
|
|
133
|
+
}
|
|
134
|
+
.email-content blockquote {
|
|
135
|
+
margin: 20px 0;
|
|
136
|
+
padding: 15px 20px;
|
|
137
|
+
background-color: #f8f9fa;
|
|
138
|
+
border-left: 4px solid #667eea;
|
|
139
|
+
color: #666666;
|
|
140
|
+
}
|
|
141
|
+
.email-content code {
|
|
142
|
+
padding: 2px 6px;
|
|
143
|
+
background-color: #f8f9fa;
|
|
144
|
+
border-radius: 3px;
|
|
145
|
+
font-family: 'Courier New', monospace;
|
|
146
|
+
font-size: 14px;
|
|
147
|
+
}
|
|
148
|
+
.email-content pre {
|
|
149
|
+
padding: 15px;
|
|
150
|
+
background-color: #f8f9fa;
|
|
151
|
+
border-radius: 6px;
|
|
152
|
+
overflow-x: auto;
|
|
153
|
+
}
|
|
154
|
+
.email-content pre code {
|
|
155
|
+
padding: 0;
|
|
156
|
+
background: none;
|
|
157
|
+
}
|
|
158
|
+
.btn {
|
|
159
|
+
display: inline-block;
|
|
160
|
+
padding: 12px 24px;
|
|
161
|
+
background-color: #667eea;
|
|
162
|
+
color: #ffffff !important;
|
|
163
|
+
text-decoration: none;
|
|
164
|
+
border-radius: 6px;
|
|
165
|
+
font-weight: 600;
|
|
166
|
+
margin: 10px 0;
|
|
167
|
+
}
|
|
168
|
+
.btn:hover {
|
|
169
|
+
background-color: #5568d3;
|
|
170
|
+
}
|
|
171
|
+
.email-footer {
|
|
172
|
+
padding: 20px 40px;
|
|
173
|
+
background-color: #f8f9fa;
|
|
174
|
+
text-align: center;
|
|
175
|
+
font-size: 12px;
|
|
176
|
+
color: #666666;
|
|
177
|
+
}
|
|
178
|
+
.email-footer a {
|
|
179
|
+
color: #667eea;
|
|
180
|
+
text-decoration: none;
|
|
181
|
+
}
|
|
182
|
+
hr {
|
|
183
|
+
border: none;
|
|
184
|
+
border-top: 1px solid #e0e0e0;
|
|
185
|
+
margin: 30px 0;
|
|
186
|
+
}
|
|
187
|
+
</style>
|
|
188
|
+
</head>
|
|
189
|
+
<body>
|
|
190
|
+
<div class="email-wrapper">
|
|
191
|
+
<div class="email-container">
|
|
192
|
+
<div class="email-content">
|
|
193
|
+
${contentHtml}
|
|
194
|
+
</div>
|
|
195
|
+
<div class="email-footer">
|
|
196
|
+
<p style="margin: 0 0 10px;">
|
|
197
|
+
You're receiving this email from ${brandName}.
|
|
198
|
+
</p>
|
|
199
|
+
<p style="margin: 0;">
|
|
200
|
+
<a href="${unsubscribeUrl}">Unsubscribe</a> |
|
|
201
|
+
<a href="${portalUrl}/settings/notifications">Manage Preferences</a>
|
|
202
|
+
</p>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</body>
|
|
207
|
+
</html>
|
|
208
|
+
`.trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/backend/EmailingCacheDO.js
|
|
212
|
+
var EmailingCacheDO = class {
|
|
213
|
+
constructor(state, env) {
|
|
214
|
+
this.state = state;
|
|
215
|
+
this.env = env;
|
|
216
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
217
|
+
this.settingsCache = /* @__PURE__ */ new Map();
|
|
218
|
+
this.cacheTTL = 36e5;
|
|
219
|
+
this.emailTablePrefix = env.EMAIL_TABLE_PREFIX || "system_email_";
|
|
220
|
+
this.settingsTableName = env.EMAIL_SETTINGS_TABLE || "system_settings";
|
|
221
|
+
this.settingsKeyPrefix = env.EMAIL_SETTINGS_KEY_PREFIX || "";
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Handle HTTP requests to this Durable Object
|
|
225
|
+
*/
|
|
226
|
+
async fetch(request) {
|
|
227
|
+
const url = new URL(request.url);
|
|
228
|
+
const path = url.pathname;
|
|
229
|
+
try {
|
|
230
|
+
if (path === "/get" && request.method === "GET") {
|
|
231
|
+
return this.handleGet(request);
|
|
232
|
+
} else if (path === "/invalidate" && request.method === "POST") {
|
|
233
|
+
return this.handleInvalidate(request);
|
|
234
|
+
} else if (path === "/clear" && request.method === "POST") {
|
|
235
|
+
return this.handleClear(request);
|
|
236
|
+
} else if (path === "/stats" && request.method === "GET") {
|
|
237
|
+
return this.handleStats(request);
|
|
238
|
+
} else if (path === "/settings/get" && request.method === "GET") {
|
|
239
|
+
return this.handleGetSettings(request);
|
|
240
|
+
} else if (path === "/settings/put" && request.method === "POST") {
|
|
241
|
+
return this.handlePutSettings(request);
|
|
242
|
+
} else if (path === "/settings/invalidate" && request.method === "POST") {
|
|
243
|
+
return this.handleInvalidateSettings(request);
|
|
244
|
+
} else {
|
|
245
|
+
return new Response("Not Found", { status: 404 });
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
console.error("[EmailingCacheDO] Error:", error);
|
|
249
|
+
return new Response(JSON.stringify({ error: error.message }), {
|
|
250
|
+
status: 500,
|
|
251
|
+
headers: { "Content-Type": "application/json" }
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Get template (from cache or D1)
|
|
257
|
+
*/
|
|
258
|
+
async handleGet(request) {
|
|
259
|
+
const url = new URL(request.url);
|
|
260
|
+
const templateId = url.searchParams.get("templateId");
|
|
261
|
+
const forceRefresh = url.searchParams.get("refresh") === "true";
|
|
262
|
+
if (!templateId) {
|
|
263
|
+
return new Response(JSON.stringify({ error: "templateId is required" }), {
|
|
264
|
+
status: 400,
|
|
265
|
+
headers: { "Content-Type": "application/json" }
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (!forceRefresh) {
|
|
269
|
+
const cached = this.cache.get(templateId);
|
|
270
|
+
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
|
|
271
|
+
console.log("[EmailingCacheDO] Cache HIT:", templateId);
|
|
272
|
+
return new Response(JSON.stringify({
|
|
273
|
+
template: cached.data,
|
|
274
|
+
cached: true,
|
|
275
|
+
age: Date.now() - cached.timestamp
|
|
276
|
+
}), {
|
|
277
|
+
headers: { "Content-Type": "application/json" }
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
console.log("[EmailingCacheDO] Cache MISS - fetching from D1:", templateId);
|
|
282
|
+
const template = await this.fetchTemplateFromD1(templateId);
|
|
283
|
+
if (!template) {
|
|
284
|
+
return new Response(JSON.stringify({
|
|
285
|
+
error: "Template not found",
|
|
286
|
+
templateId
|
|
287
|
+
}), {
|
|
288
|
+
status: 404,
|
|
289
|
+
headers: { "Content-Type": "application/json" }
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
this.cache.set(templateId, {
|
|
293
|
+
data: template,
|
|
294
|
+
timestamp: Date.now()
|
|
295
|
+
});
|
|
296
|
+
return new Response(JSON.stringify({
|
|
297
|
+
template,
|
|
298
|
+
cached: false
|
|
299
|
+
}), {
|
|
300
|
+
headers: { "Content-Type": "application/json" }
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
// --- Settings Cache Handlers ---
|
|
304
|
+
async handleGetSettings(request) {
|
|
305
|
+
const url = new URL(request.url);
|
|
306
|
+
const key = url.searchParams.get("key");
|
|
307
|
+
if (!key) return new Response("Key required", { status: 400 });
|
|
308
|
+
const cached = this.settingsCache.get(key);
|
|
309
|
+
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
|
|
310
|
+
console.log("[EmailingCacheDO] Settings Cache HIT:", key);
|
|
311
|
+
return new Response(JSON.stringify({ settings: cached.data }), {
|
|
312
|
+
headers: { "Content-Type": "application/json" }
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (key.startsWith("system")) {
|
|
316
|
+
console.log("[EmailingCacheDO] Settings Cache MISS - fetching from D1:", key);
|
|
317
|
+
const settings = await this.fetchSettingsFromD1(key);
|
|
318
|
+
if (settings) {
|
|
319
|
+
this.settingsCache.set(key, {
|
|
320
|
+
data: settings,
|
|
321
|
+
timestamp: Date.now()
|
|
322
|
+
});
|
|
323
|
+
return new Response(JSON.stringify({ settings }), {
|
|
324
|
+
headers: { "Content-Type": "application/json" }
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return new Response(JSON.stringify({ settings: null }), {
|
|
329
|
+
headers: { "Content-Type": "application/json" }
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
async handlePutSettings(request) {
|
|
333
|
+
try {
|
|
334
|
+
const body = await request.json();
|
|
335
|
+
const { key, settings } = body;
|
|
336
|
+
if (!key || !settings) return new Response("Key and settings required", { status: 400 });
|
|
337
|
+
this.settingsCache.set(key, {
|
|
338
|
+
data: settings,
|
|
339
|
+
timestamp: Date.now()
|
|
340
|
+
});
|
|
341
|
+
if (key.startsWith("system")) {
|
|
342
|
+
await this.saveSettingsToD1(settings);
|
|
343
|
+
}
|
|
344
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
345
|
+
headers: { "Content-Type": "application/json" }
|
|
346
|
+
});
|
|
347
|
+
} catch (e) {
|
|
348
|
+
console.error("[EmailingCacheDO] PutSettings error:", e);
|
|
349
|
+
return new Response("Error parsing body/saving", { status: 400 });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async handleInvalidateSettings(request) {
|
|
353
|
+
try {
|
|
354
|
+
const body = await request.json();
|
|
355
|
+
const { key } = body;
|
|
356
|
+
if (!key) return new Response("Key required", { status: 400 });
|
|
357
|
+
const existed = this.settingsCache.has(key);
|
|
358
|
+
this.settingsCache.delete(key);
|
|
359
|
+
console.log("[EmailingCacheDO] Invalidated settings:", key, existed ? "(existed)" : "(not in cache)");
|
|
360
|
+
return new Response(JSON.stringify({ success: true, existed }), {
|
|
361
|
+
headers: { "Content-Type": "application/json" }
|
|
362
|
+
});
|
|
363
|
+
} catch (e) {
|
|
364
|
+
console.error("[EmailingCacheDO] InvalidateSettings error:", e);
|
|
365
|
+
return new Response("Error invalidated settings", { status: 400 });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Invalidate specific template(s) from cache
|
|
370
|
+
* Body: { templateId: 'template_id' } or { templateId: '*' } for all
|
|
371
|
+
*/
|
|
372
|
+
async handleInvalidate(request) {
|
|
373
|
+
const body = await request.json();
|
|
374
|
+
const { templateId } = body;
|
|
375
|
+
if (!templateId) {
|
|
376
|
+
return new Response(JSON.stringify({ error: "templateId is required" }), {
|
|
377
|
+
status: 400,
|
|
378
|
+
headers: { "Content-Type": "application/json" }
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
if (templateId === "*") {
|
|
382
|
+
const count = this.cache.size;
|
|
383
|
+
this.cache.clear();
|
|
384
|
+
console.log("[EmailingCacheDO] Invalidated ALL templates:", count);
|
|
385
|
+
return new Response(JSON.stringify({
|
|
386
|
+
success: true,
|
|
387
|
+
message: `Invalidated ${count} templates`
|
|
388
|
+
}), {
|
|
389
|
+
headers: { "Content-Type": "application/json" }
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const existed = this.cache.has(templateId);
|
|
393
|
+
this.cache.delete(templateId);
|
|
394
|
+
console.log("[EmailingCacheDO] Invalidated template:", templateId, existed ? "(existed)" : "(not in cache)");
|
|
395
|
+
return new Response(JSON.stringify({
|
|
396
|
+
success: true,
|
|
397
|
+
message: existed ? "Template invalidated" : "Template was not in cache",
|
|
398
|
+
templateId
|
|
399
|
+
}), {
|
|
400
|
+
headers: { "Content-Type": "application/json" }
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Clear entire cache (admin operation)
|
|
405
|
+
*/
|
|
406
|
+
async handleClear(request) {
|
|
407
|
+
const count = this.cache.size + this.settingsCache.size;
|
|
408
|
+
this.cache.clear();
|
|
409
|
+
this.settingsCache.clear();
|
|
410
|
+
console.log("[EmailingCacheDO] Cache cleared:", count, "entries (templates + settings)");
|
|
411
|
+
return new Response(JSON.stringify({
|
|
412
|
+
success: true,
|
|
413
|
+
message: `Cleared ${count} cached items`
|
|
414
|
+
}), {
|
|
415
|
+
headers: { "Content-Type": "application/json" }
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Get cache statistics
|
|
420
|
+
*/
|
|
421
|
+
async handleStats(request) {
|
|
422
|
+
const stats = {
|
|
423
|
+
templates: this.cache.size,
|
|
424
|
+
settings: this.settingsCache.size,
|
|
425
|
+
cacheTTL: this.cacheTTL
|
|
426
|
+
};
|
|
427
|
+
return new Response(JSON.stringify(stats), {
|
|
428
|
+
headers: { "Content-Type": "application/json" }
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Fetch template from D1
|
|
433
|
+
*/
|
|
434
|
+
async fetchTemplateFromD1(templateId) {
|
|
435
|
+
const db = this.env.DB;
|
|
436
|
+
const tableName = `${this.emailTablePrefix}templates`;
|
|
437
|
+
try {
|
|
438
|
+
const template = await db.prepare(`SELECT * FROM ${tableName} WHERE template_id = ? AND is_active = 1`).bind(templateId).first();
|
|
439
|
+
return template;
|
|
440
|
+
} catch (e) {
|
|
441
|
+
console.error(`[EmailingCacheDO] DB Error (${tableName}):`, e);
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Fetch settings from D1
|
|
447
|
+
* Only supports default table (e.g. system_settings) for now
|
|
448
|
+
*/
|
|
449
|
+
async fetchSettingsFromD1(key) {
|
|
450
|
+
const db = this.env.DB;
|
|
451
|
+
const tableName = this.settingsTableName;
|
|
452
|
+
const prefix = this.settingsKeyPrefix;
|
|
453
|
+
try {
|
|
454
|
+
let results;
|
|
455
|
+
if (prefix) {
|
|
456
|
+
try {
|
|
457
|
+
results = await db.prepare(`SELECT * FROM ${tableName} WHERE setting_key LIKE ?`).bind(`${prefix}%`).all();
|
|
458
|
+
} catch (e) {
|
|
459
|
+
results = await db.prepare(`SELECT * FROM ${tableName} WHERE key LIKE ?`).bind(`${prefix}%`).all();
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
results = await db.prepare(`SELECT * FROM ${tableName}`).all();
|
|
463
|
+
}
|
|
464
|
+
if (!results.results || results.results.length === 0) return null;
|
|
465
|
+
const settings = {};
|
|
466
|
+
results.results.forEach((row) => {
|
|
467
|
+
const k = row.setting_key || row.key;
|
|
468
|
+
const v = row.setting_value || row.value;
|
|
469
|
+
if (k) settings[k] = v;
|
|
470
|
+
});
|
|
471
|
+
return settings;
|
|
472
|
+
} catch (e) {
|
|
473
|
+
console.warn(`[EmailingCacheDO] Failed to load settings from ${tableName}:`, e);
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async saveSettingsToD1(settings) {
|
|
478
|
+
const db = this.env.DB;
|
|
479
|
+
const tableName = this.settingsTableName;
|
|
480
|
+
const entries = Object.entries(settings);
|
|
481
|
+
for (const [k, v] of entries) {
|
|
482
|
+
try {
|
|
483
|
+
try {
|
|
484
|
+
const res = await db.prepare(`UPDATE ${tableName} SET setting_value = ? WHERE setting_key = ?`).bind(v, k).run();
|
|
485
|
+
if (res.meta.changes === 0) {
|
|
486
|
+
await db.prepare(`INSERT INTO ${tableName} (setting_key, setting_value) VALUES (?, ?)`).bind(k, v).run();
|
|
487
|
+
}
|
|
488
|
+
} catch (e) {
|
|
489
|
+
await db.prepare(`INSERT OR REPLACE INTO ${tableName} (key, value) VALUES (?, ?)`).bind(k, v).run();
|
|
490
|
+
}
|
|
491
|
+
} catch (e) {
|
|
492
|
+
console.warn("[EmailingCacheDO] Failed to save setting to D1:", k);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
function createDOCacheProvider(doStub, instanceName = "global") {
|
|
498
|
+
if (!doStub) {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
const stub = doStub.get(doStub.idFromName(instanceName));
|
|
502
|
+
return {
|
|
503
|
+
async getTemplate(templateId) {
|
|
504
|
+
try {
|
|
505
|
+
const response = await stub.fetch(`http://do/get?templateId=${templateId}`);
|
|
506
|
+
const data = await response.json();
|
|
507
|
+
return data.template || null;
|
|
508
|
+
} catch (e) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
async getSettings(profile, tenantId) {
|
|
513
|
+
const key = `${profile}:${tenantId || ""}`;
|
|
514
|
+
try {
|
|
515
|
+
const response = await stub.fetch(`http://do/settings/get?key=${encodeURIComponent(key)}`);
|
|
516
|
+
const data = await response.json();
|
|
517
|
+
return data.settings || null;
|
|
518
|
+
} catch (e) {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
async putSettings(profile, tenantId, settings) {
|
|
523
|
+
const key = `${profile}:${tenantId || ""}`;
|
|
524
|
+
try {
|
|
525
|
+
await stub.fetch("http://do/settings/put", {
|
|
526
|
+
method: "POST",
|
|
527
|
+
headers: { "Content-Type": "application/json" },
|
|
528
|
+
body: JSON.stringify({ key, settings })
|
|
529
|
+
});
|
|
530
|
+
} catch (e) {
|
|
531
|
+
console.warn("[DOCacheProvider] Failed to cache settings:", e);
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
async putTemplate(template) {
|
|
535
|
+
try {
|
|
536
|
+
await stub.fetch("http://do/invalidate", {
|
|
537
|
+
method: "POST",
|
|
538
|
+
headers: { "Content-Type": "application/json" },
|
|
539
|
+
body: JSON.stringify({ templateId: template.template_id })
|
|
540
|
+
});
|
|
541
|
+
} catch (e) {
|
|
542
|
+
console.warn("[DOCacheProvider] Failed to invalidate template:", e);
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
async deleteTemplate(templateId) {
|
|
546
|
+
try {
|
|
547
|
+
await stub.fetch("http://do/invalidate", {
|
|
548
|
+
method: "POST",
|
|
549
|
+
headers: { "Content-Type": "application/json" },
|
|
550
|
+
body: JSON.stringify({ templateId })
|
|
551
|
+
});
|
|
552
|
+
} catch (e) {
|
|
553
|
+
console.warn("[DOCacheProvider] Failed to invalidate template:", e);
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
async invalidateSettings(profile, tenantId) {
|
|
557
|
+
const key = `${profile}:${tenantId || ""}`;
|
|
558
|
+
try {
|
|
559
|
+
await stub.fetch("http://do/settings/invalidate", {
|
|
560
|
+
method: "POST",
|
|
561
|
+
headers: { "Content-Type": "application/json" },
|
|
562
|
+
body: JSON.stringify({ key })
|
|
563
|
+
});
|
|
564
|
+
} catch (e) {
|
|
565
|
+
console.warn("[DOCacheProvider] Failed to invalidate settings:", e);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// src/backend/EmailLogger.js
|
|
572
|
+
var EmailLogger = class {
|
|
573
|
+
/**
|
|
574
|
+
* @param {Object} db - D1 database binding
|
|
575
|
+
* @param {Object} options - Configuration options
|
|
576
|
+
* @param {string} [options.tableName='system_email_logs'] - Table name for logs
|
|
577
|
+
*/
|
|
578
|
+
constructor(db, options = {}) {
|
|
579
|
+
this.db = db;
|
|
580
|
+
this.tableName = options.tableName || "system_email_logs";
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Creates a logger callback function for use with EmailService.
|
|
584
|
+
* Usage: new EmailService(env, { emailLogger: emailLogger.createCallback() })
|
|
585
|
+
*/
|
|
586
|
+
createCallback() {
|
|
587
|
+
return async (entry) => {
|
|
588
|
+
await this.log(entry);
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Log an email event (pending, sent, or failed)
|
|
593
|
+
* @param {Object} entry - Log entry
|
|
594
|
+
*/
|
|
595
|
+
async log(entry) {
|
|
596
|
+
const {
|
|
597
|
+
event,
|
|
598
|
+
recipientEmail,
|
|
599
|
+
recipientUserId,
|
|
600
|
+
templateId,
|
|
601
|
+
subject,
|
|
602
|
+
provider,
|
|
603
|
+
messageId,
|
|
604
|
+
batchId,
|
|
605
|
+
error,
|
|
606
|
+
errorCode,
|
|
607
|
+
metadata
|
|
608
|
+
} = entry;
|
|
609
|
+
try {
|
|
610
|
+
if (event === "pending") {
|
|
611
|
+
const id = crypto.randomUUID().replace(/-/g, "");
|
|
612
|
+
await this.db.prepare(`
|
|
613
|
+
INSERT INTO ${this.tableName}
|
|
614
|
+
(id, batch_id, recipient_email, recipient_user_id, template_id, subject, status, metadata, created_at)
|
|
615
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, strftime('%s', 'now'))
|
|
616
|
+
`).bind(
|
|
617
|
+
id,
|
|
618
|
+
batchId || null,
|
|
619
|
+
recipientEmail,
|
|
620
|
+
recipientUserId || null,
|
|
621
|
+
templateId || "direct",
|
|
622
|
+
subject || null,
|
|
623
|
+
metadata ? JSON.stringify(metadata) : null
|
|
624
|
+
).run();
|
|
625
|
+
} else if (event === "sent") {
|
|
626
|
+
await this.db.prepare(`
|
|
627
|
+
UPDATE ${this.tableName}
|
|
628
|
+
SET status = 'sent',
|
|
629
|
+
provider = ?,
|
|
630
|
+
provider_message_id = ?,
|
|
631
|
+
sent_at = strftime('%s', 'now')
|
|
632
|
+
WHERE recipient_email = ?
|
|
633
|
+
AND template_id = ?
|
|
634
|
+
AND status = 'pending'
|
|
635
|
+
ORDER BY created_at DESC
|
|
636
|
+
LIMIT 1
|
|
637
|
+
`).bind(
|
|
638
|
+
provider || null,
|
|
639
|
+
messageId || null,
|
|
640
|
+
recipientEmail,
|
|
641
|
+
templateId || "direct"
|
|
642
|
+
).run();
|
|
643
|
+
} else if (event === "failed") {
|
|
644
|
+
await this.db.prepare(`
|
|
645
|
+
UPDATE ${this.tableName}
|
|
646
|
+
SET status = 'failed',
|
|
647
|
+
provider = ?,
|
|
648
|
+
error_message = ?,
|
|
649
|
+
error_code = ?
|
|
650
|
+
WHERE recipient_email = ?
|
|
651
|
+
AND template_id = ?
|
|
652
|
+
AND status = 'pending'
|
|
653
|
+
ORDER BY created_at DESC
|
|
654
|
+
LIMIT 1
|
|
655
|
+
`).bind(
|
|
656
|
+
provider || null,
|
|
657
|
+
error || null,
|
|
658
|
+
errorCode || null,
|
|
659
|
+
recipientEmail,
|
|
660
|
+
templateId || "direct"
|
|
661
|
+
).run();
|
|
662
|
+
}
|
|
663
|
+
} catch (e) {
|
|
664
|
+
console.error("[EmailLogger] Failed to log:", e);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Query email logs with filtering
|
|
669
|
+
* @param {Object} options - Query options
|
|
670
|
+
*/
|
|
671
|
+
async query(options = {}) {
|
|
672
|
+
const {
|
|
673
|
+
recipientEmail,
|
|
674
|
+
recipientUserId,
|
|
675
|
+
templateId,
|
|
676
|
+
status,
|
|
677
|
+
batchId,
|
|
678
|
+
limit = 50,
|
|
679
|
+
offset = 0
|
|
680
|
+
} = options;
|
|
681
|
+
const conditions = [];
|
|
682
|
+
const bindings = [];
|
|
683
|
+
if (recipientEmail) {
|
|
684
|
+
conditions.push("recipient_email = ?");
|
|
685
|
+
bindings.push(recipientEmail);
|
|
686
|
+
}
|
|
687
|
+
if (recipientUserId) {
|
|
688
|
+
conditions.push("recipient_user_id = ?");
|
|
689
|
+
bindings.push(recipientUserId);
|
|
690
|
+
}
|
|
691
|
+
if (templateId) {
|
|
692
|
+
conditions.push("template_id = ?");
|
|
693
|
+
bindings.push(templateId);
|
|
694
|
+
}
|
|
695
|
+
if (status) {
|
|
696
|
+
conditions.push("status = ?");
|
|
697
|
+
bindings.push(status);
|
|
698
|
+
}
|
|
699
|
+
if (batchId) {
|
|
700
|
+
conditions.push("batch_id = ?");
|
|
701
|
+
bindings.push(batchId);
|
|
702
|
+
}
|
|
703
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
704
|
+
const countResult = await this.db.prepare(
|
|
705
|
+
`SELECT COUNT(*) as count FROM ${this.tableName} ${whereClause}`
|
|
706
|
+
).bind(...bindings).first();
|
|
707
|
+
const { results } = await this.db.prepare(`
|
|
708
|
+
SELECT id, batch_id, recipient_email, recipient_user_id, template_id, subject,
|
|
709
|
+
status, provider, provider_message_id, error_message, error_code, metadata,
|
|
710
|
+
created_at, sent_at
|
|
711
|
+
FROM ${this.tableName}
|
|
712
|
+
${whereClause}
|
|
713
|
+
ORDER BY created_at DESC
|
|
714
|
+
LIMIT ? OFFSET ?
|
|
715
|
+
`).bind(...bindings, limit, offset).all();
|
|
716
|
+
const logs = (results || []).map((row) => ({
|
|
717
|
+
id: row.id,
|
|
718
|
+
batchId: row.batch_id,
|
|
719
|
+
recipientEmail: row.recipient_email,
|
|
720
|
+
recipientUserId: row.recipient_user_id,
|
|
721
|
+
templateId: row.template_id,
|
|
722
|
+
subject: row.subject,
|
|
723
|
+
status: row.status,
|
|
724
|
+
provider: row.provider,
|
|
725
|
+
providerMessageId: row.provider_message_id,
|
|
726
|
+
errorMessage: row.error_message,
|
|
727
|
+
errorCode: row.error_code,
|
|
728
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : void 0,
|
|
729
|
+
createdAt: row.created_at,
|
|
730
|
+
sentAt: row.sent_at
|
|
731
|
+
}));
|
|
732
|
+
return { logs, total: countResult?.count || 0 };
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Get email sending statistics
|
|
736
|
+
* @param {number} sinceDays - Number of days to look back
|
|
737
|
+
*/
|
|
738
|
+
async getStats(sinceDays = 7) {
|
|
739
|
+
const sinceTimestamp = Math.floor(Date.now() / 1e3) - sinceDays * 24 * 60 * 60;
|
|
740
|
+
const statusResult = await this.db.prepare(`
|
|
741
|
+
SELECT status, COUNT(*) as count
|
|
742
|
+
FROM ${this.tableName}
|
|
743
|
+
WHERE created_at >= ?
|
|
744
|
+
GROUP BY status
|
|
745
|
+
`).bind(sinceTimestamp).all();
|
|
746
|
+
const templateResult = await this.db.prepare(`
|
|
747
|
+
SELECT template_id, COUNT(*) as count
|
|
748
|
+
FROM ${this.tableName}
|
|
749
|
+
WHERE created_at >= ?
|
|
750
|
+
GROUP BY template_id
|
|
751
|
+
`).bind(sinceTimestamp).all();
|
|
752
|
+
const stats = {
|
|
753
|
+
total: 0,
|
|
754
|
+
sent: 0,
|
|
755
|
+
failed: 0,
|
|
756
|
+
pending: 0,
|
|
757
|
+
byTemplate: {}
|
|
758
|
+
};
|
|
759
|
+
(statusResult.results || []).forEach((row) => {
|
|
760
|
+
const count = row.count || 0;
|
|
761
|
+
stats.total += count;
|
|
762
|
+
if (row.status === "sent") stats.sent = count;
|
|
763
|
+
if (row.status === "failed") stats.failed = count;
|
|
764
|
+
if (row.status === "pending") stats.pending = count;
|
|
765
|
+
});
|
|
766
|
+
(templateResult.results || []).forEach((row) => {
|
|
767
|
+
stats.byTemplate[row.template_id] = row.count;
|
|
768
|
+
});
|
|
769
|
+
return stats;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Get recent failed emails for debugging
|
|
773
|
+
* @param {number} limit - Number of failed emails to retrieve
|
|
774
|
+
*/
|
|
775
|
+
async getRecentFailures(limit = 20) {
|
|
776
|
+
const { results } = await this.db.prepare(`
|
|
777
|
+
SELECT id, recipient_email, template_id, subject, error_message, error_code, created_at
|
|
778
|
+
FROM ${this.tableName}
|
|
779
|
+
WHERE status = 'failed'
|
|
780
|
+
ORDER BY created_at DESC
|
|
781
|
+
LIMIT ?
|
|
782
|
+
`).bind(limit).all();
|
|
783
|
+
return results || [];
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
function createEmailLoggerCallback(db, tableName = "system_email_logs") {
|
|
787
|
+
const logger = new EmailLogger(db, { tableName });
|
|
788
|
+
return logger.createCallback();
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// src/backend/EmailService.js
|
|
792
|
+
var EmailService = class {
|
|
793
|
+
/**
|
|
794
|
+
* @param {Object} env - Cloudflare environment bindings (DB, etc.)
|
|
795
|
+
* @param {Object} config - Configuration options
|
|
796
|
+
* @param {string} [config.emailTablePrefix='system_email_'] - Prefix for D1 tables
|
|
797
|
+
* @param {Object} [config.defaults] - Default settings (fromName, fromAddress)
|
|
798
|
+
* @param {boolean|Function} [config.emailLogger] - Email logger: true (default), false (disabled), or custom callback
|
|
799
|
+
* @param {Object} [cacheProvider] - Optional cache interface (DO stub or KV wrapper)
|
|
800
|
+
*/
|
|
801
|
+
constructor(env, config = {}, cacheProvider = null) {
|
|
802
|
+
this.env = env;
|
|
803
|
+
this.db = env.DB;
|
|
804
|
+
this.config = {
|
|
805
|
+
emailTablePrefix: config.emailTablePrefix || config.tableNamePrefix || "system_email_",
|
|
806
|
+
defaults: config.defaults || {
|
|
807
|
+
fromName: "System",
|
|
808
|
+
fromAddress: "noreply@example.com",
|
|
809
|
+
provider: "mailchannels"
|
|
810
|
+
},
|
|
811
|
+
// Loader function to fetch settings from backend (DB, KV, etc.)
|
|
812
|
+
// Signature: async (profile, tenantId) => SettingsObject
|
|
813
|
+
settingsLoader: config.settingsLoader || null,
|
|
814
|
+
// Updater function to save settings to backend
|
|
815
|
+
// Signature: async (profile, tenantId, settings) => void
|
|
816
|
+
settingsUpdater: config.settingsUpdater || null,
|
|
817
|
+
// Branding configuration for email templates
|
|
818
|
+
branding: {
|
|
819
|
+
brandName: config.branding?.brandName || "Your App",
|
|
820
|
+
portalUrl: config.branding?.portalUrl || "https://app.example.com",
|
|
821
|
+
primaryColor: config.branding?.primaryColor || "#667eea",
|
|
822
|
+
...config.branding
|
|
823
|
+
},
|
|
824
|
+
...config
|
|
825
|
+
};
|
|
826
|
+
if (config.emailLogger === false) {
|
|
827
|
+
this.emailLogger = null;
|
|
828
|
+
} else if (typeof config.emailLogger === "function") {
|
|
829
|
+
this.emailLogger = config.emailLogger;
|
|
830
|
+
} else if (env.DB) {
|
|
831
|
+
const logger = new EmailLogger(env.DB);
|
|
832
|
+
this.emailLogger = logger.createCallback();
|
|
833
|
+
} else {
|
|
834
|
+
this.emailLogger = null;
|
|
835
|
+
}
|
|
836
|
+
if (!cacheProvider && env.EMAIL_TEMPLATE_CACHE) {
|
|
837
|
+
this.cache = createDOCacheProvider(env.EMAIL_TEMPLATE_CACHE);
|
|
838
|
+
} else {
|
|
839
|
+
this.cache = cacheProvider;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// --- Configuration & Settings ---
|
|
843
|
+
/**
|
|
844
|
+
* Load email configuration
|
|
845
|
+
* @param {string} profile - 'system' or 'tenant' (or custom profile string)
|
|
846
|
+
* @param {string} tenantId - Context ID (optional)
|
|
847
|
+
* @returns {Promise<Object>} Email configuration
|
|
848
|
+
*/
|
|
849
|
+
async loadSettings(profile = "system", tenantId = null) {
|
|
850
|
+
if (this.cache && this.cache.getSettings) {
|
|
851
|
+
try {
|
|
852
|
+
const cached = await this.cache.getSettings(profile, tenantId);
|
|
853
|
+
if (cached) return this._normalizeConfig(cached);
|
|
854
|
+
} catch (e) {
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
let settings = null;
|
|
858
|
+
if (this.config.settingsLoader) {
|
|
859
|
+
try {
|
|
860
|
+
settings = await this.config.settingsLoader(profile, tenantId);
|
|
861
|
+
} catch (e) {
|
|
862
|
+
console.warn("[EmailService] settingsLoader failed:", e);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (settings) {
|
|
866
|
+
if (this.cache && this.cache.putSettings) {
|
|
867
|
+
try {
|
|
868
|
+
this.cache.putSettings(profile, tenantId, settings);
|
|
869
|
+
} catch (e) {
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return this._normalizeConfig(settings);
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
...this.config.defaults
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Normalize config keys to standard format
|
|
880
|
+
* Handles both snake_case (DB) and camelCase inputs
|
|
881
|
+
*/
|
|
882
|
+
_normalizeConfig(config) {
|
|
883
|
+
return {
|
|
884
|
+
provider: config.email_provider || config.provider || this.config.defaults.provider,
|
|
885
|
+
fromAddress: config.email_from_address || config.fromAddress || this.config.defaults.fromAddress,
|
|
886
|
+
fromName: config.email_from_name || config.fromName || this.config.defaults.fromName,
|
|
887
|
+
// Provider-specific settings (normalize DB keys to service keys)
|
|
888
|
+
sendgridApiKey: config.sendgrid_api_key || config.sendgridApiKey,
|
|
889
|
+
resendApiKey: config.resend_api_key || config.resendApiKey,
|
|
890
|
+
sendpulseClientId: config.sendpulse_client_id || config.sendpulseClientId,
|
|
891
|
+
sendpulseClientSecret: config.sendpulse_client_secret || config.sendpulseClientSecret,
|
|
892
|
+
// SMTP
|
|
893
|
+
smtpHost: config.smtp_host || config.smtpHost,
|
|
894
|
+
smtpPort: config.smtp_port || config.smtpPort,
|
|
895
|
+
smtpUsername: config.smtp_username || config.smtpUsername,
|
|
896
|
+
smtpPassword: config.smtp_password || config.smtpPassword,
|
|
897
|
+
// Tracking
|
|
898
|
+
trackingUrl: config.tracking_url || config.trackingUrl,
|
|
899
|
+
// Pass through others
|
|
900
|
+
// Pass through others
|
|
901
|
+
...config,
|
|
902
|
+
smtpSecure: config.smtp_secure === "true"
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
// --- Template Management ---
|
|
906
|
+
async getTemplate(templateId) {
|
|
907
|
+
if (this.cache) {
|
|
908
|
+
try {
|
|
909
|
+
const cached = await this.cache.getTemplate(templateId);
|
|
910
|
+
if (cached) return cached;
|
|
911
|
+
} catch (e) {
|
|
912
|
+
console.warn("[EmailService] Template cache lookup failed:", e);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
const table = `${this.config.emailTablePrefix}templates`;
|
|
916
|
+
return await this.db.prepare(`SELECT * FROM ${table} WHERE template_id = ?`).bind(templateId).first();
|
|
917
|
+
}
|
|
918
|
+
async getAllTemplates() {
|
|
919
|
+
const table = `${this.config.emailTablePrefix}templates`;
|
|
920
|
+
const result = await this.db.prepare(`SELECT * FROM ${table} ORDER BY template_name`).all();
|
|
921
|
+
return result.results || [];
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Save email configuration
|
|
925
|
+
* @param {Object} settings - Config object
|
|
926
|
+
* @param {string} profile - 'system' or 'tenant'
|
|
927
|
+
* @param {string} tenantId - Context ID
|
|
928
|
+
*/
|
|
929
|
+
async saveSettings(settings, profile = "system", tenantId = null) {
|
|
930
|
+
if (this.config.settingsUpdater) {
|
|
931
|
+
try {
|
|
932
|
+
await this.config.settingsUpdater(profile, tenantId, settings);
|
|
933
|
+
} catch (e) {
|
|
934
|
+
console.error("[EmailService] settingsUpdater failed:", e);
|
|
935
|
+
throw e;
|
|
936
|
+
}
|
|
937
|
+
} else if (profile === "system" && this.db) {
|
|
938
|
+
}
|
|
939
|
+
if (this.cache && this.cache.invalidateSettings) {
|
|
940
|
+
try {
|
|
941
|
+
await this.cache.invalidateSettings(profile, tenantId);
|
|
942
|
+
} catch (e) {
|
|
943
|
+
console.warn("[EmailService] Failed to invalidate settings cache:", e);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
async saveTemplate(template, userId = "system") {
|
|
948
|
+
const table = `${this.config.emailTablePrefix}templates`;
|
|
949
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
950
|
+
const existing = await this.getTemplate(template.template_id);
|
|
951
|
+
if (existing) {
|
|
952
|
+
await this.db.prepare(`
|
|
953
|
+
UPDATE ${table} SET
|
|
954
|
+
template_name = ?, template_type = ?, subject_template = ?,
|
|
955
|
+
body_markdown = ?, variables = ?, description = ?, is_active = ?,
|
|
956
|
+
updated_at = ?, updated_by = ?
|
|
957
|
+
WHERE template_id = ?
|
|
958
|
+
`).bind(
|
|
959
|
+
template.template_name,
|
|
960
|
+
template.template_type,
|
|
961
|
+
template.subject_template,
|
|
962
|
+
template.body_markdown,
|
|
963
|
+
template.variables,
|
|
964
|
+
template.description,
|
|
965
|
+
template.is_active,
|
|
966
|
+
now,
|
|
967
|
+
userId,
|
|
968
|
+
template.template_id
|
|
969
|
+
).run();
|
|
970
|
+
} else {
|
|
971
|
+
await this.db.prepare(`
|
|
972
|
+
INSERT INTO ${table} (
|
|
973
|
+
template_id, template_name, template_type, subject_template,
|
|
974
|
+
body_markdown, variables, description, is_active, created_at, updated_at, updated_by
|
|
975
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
976
|
+
`).bind(
|
|
977
|
+
template.template_id,
|
|
978
|
+
template.template_name,
|
|
979
|
+
template.template_type,
|
|
980
|
+
template.subject_template,
|
|
981
|
+
template.body_markdown,
|
|
982
|
+
template.variables || "[]",
|
|
983
|
+
template.description,
|
|
984
|
+
template.is_active || 1,
|
|
985
|
+
now,
|
|
986
|
+
now,
|
|
987
|
+
userId
|
|
988
|
+
).run();
|
|
989
|
+
}
|
|
990
|
+
if (this.cache) {
|
|
991
|
+
await this.cache.putTemplate(template);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
async deleteTemplate(templateId) {
|
|
995
|
+
const table = `${this.config.emailTablePrefix}templates`;
|
|
996
|
+
await this.db.prepare(`DELETE FROM ${table} WHERE template_id = ?`).bind(templateId).run();
|
|
997
|
+
if (this.cache) {
|
|
998
|
+
await this.cache.deleteTemplate(templateId);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
// --- Rendering ---
|
|
1002
|
+
/**
|
|
1003
|
+
* Pre-process template data to auto-format URLs
|
|
1004
|
+
* Scans for strings starting with http:// or https:// and wraps them in Markdown links
|
|
1005
|
+
*/
|
|
1006
|
+
_preprocessData(data) {
|
|
1007
|
+
if (!data || typeof data !== "object") return data;
|
|
1008
|
+
const processed = { ...data };
|
|
1009
|
+
for (const [key, value] of Object.entries(processed)) {
|
|
1010
|
+
if (typeof value === "string" && (value.startsWith("http://") || value.startsWith("https://"))) {
|
|
1011
|
+
if (!value.trim().startsWith("[") && !value.includes("](")) {
|
|
1012
|
+
processed[key] = `[${value}](${value})`;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return processed;
|
|
1017
|
+
}
|
|
1018
|
+
async renderTemplate(templateId, data) {
|
|
1019
|
+
const template = await this.getTemplate(templateId);
|
|
1020
|
+
if (!template) throw new Error(`Template not found: ${templateId}`);
|
|
1021
|
+
const processedData = this._preprocessData(data);
|
|
1022
|
+
const subject = import_mustache.default.render(template.subject_template, processedData);
|
|
1023
|
+
let markdown = import_mustache.default.render(template.body_markdown, processedData);
|
|
1024
|
+
markdown = markdown.replace(/\\n/g, "\n");
|
|
1025
|
+
import_marked.marked.use({
|
|
1026
|
+
mangle: false,
|
|
1027
|
+
headerIds: false,
|
|
1028
|
+
breaks: true
|
|
1029
|
+
// Convert single line breaks to <br>
|
|
1030
|
+
});
|
|
1031
|
+
const htmlContent = import_marked.marked.parse(markdown);
|
|
1032
|
+
const html = this.wrapInBaseTemplate(htmlContent, subject, data);
|
|
1033
|
+
const plainText = markdown.replace(/<[^>]*>/g, "");
|
|
1034
|
+
return { subject, html, plainText };
|
|
1035
|
+
}
|
|
1036
|
+
wrapInBaseTemplate(content, subject, data = {}) {
|
|
1037
|
+
const templateData = {
|
|
1038
|
+
...data,
|
|
1039
|
+
brandName: data.brandName || this.config.branding.brandName,
|
|
1040
|
+
portalUrl: data.portalUrl || this.config.branding.portalUrl,
|
|
1041
|
+
unsubscribeUrl: data.unsubscribeUrl || "{{unsubscribe_url}}"
|
|
1042
|
+
};
|
|
1043
|
+
return wrapInEmailTemplate(content, subject, templateData);
|
|
1044
|
+
}
|
|
1045
|
+
// --- Delivery ---
|
|
1046
|
+
/**
|
|
1047
|
+
* Send an email using a template
|
|
1048
|
+
* @param {string} templateId - Template ID
|
|
1049
|
+
* @param {Object} data - Template variables
|
|
1050
|
+
* @param {Object} options - Sending options (to, provider, etc.)
|
|
1051
|
+
* @returns {Promise<Object>} Delivery result
|
|
1052
|
+
*/
|
|
1053
|
+
async sendViaTemplate(templateId, data, options) {
|
|
1054
|
+
try {
|
|
1055
|
+
const { subject, html, plainText } = await this.renderTemplate(templateId, data);
|
|
1056
|
+
return await this.sendEmail({
|
|
1057
|
+
...options,
|
|
1058
|
+
subject,
|
|
1059
|
+
// Can be overridden by options.subject if needed, but usually template subject is preferred
|
|
1060
|
+
html,
|
|
1061
|
+
text: plainText,
|
|
1062
|
+
metadata: {
|
|
1063
|
+
...options.metadata,
|
|
1064
|
+
templateId
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
console.error(`[EmailService] sendViaTemplate failed for ${templateId}:`, error);
|
|
1069
|
+
return { success: false, error: error.message };
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Send a single email
|
|
1074
|
+
* @param {Object} params - Email parameters
|
|
1075
|
+
* @param {string} params.to - Recipient email
|
|
1076
|
+
* @param {string} params.subject - Email subject
|
|
1077
|
+
* @param {string} params.html - HTML body
|
|
1078
|
+
* @param {string} params.text - Plain text body
|
|
1079
|
+
* @param {string} [params.provider] - Override provider
|
|
1080
|
+
* @param {string} [params.profile='system'] - 'system' or 'tenant'
|
|
1081
|
+
* @param {string} [params.tenantId] - Required if profile is 'tenant'
|
|
1082
|
+
* @param {Object} [params.metadata] - Additional metadata
|
|
1083
|
+
* @returns {Promise<Object>} Delivery result
|
|
1084
|
+
*/
|
|
1085
|
+
async sendEmail({ to, subject, html, htmlBody, text, textBody, provider, profile = "system", tenantId = null, metadata = {}, batchId = null, userId = null }) {
|
|
1086
|
+
const htmlContent = html || htmlBody;
|
|
1087
|
+
const textContent = text || textBody;
|
|
1088
|
+
const templateId = metadata?.templateId || "direct";
|
|
1089
|
+
if (this.emailLogger) {
|
|
1090
|
+
try {
|
|
1091
|
+
await this.emailLogger({
|
|
1092
|
+
event: "pending",
|
|
1093
|
+
recipientEmail: to,
|
|
1094
|
+
recipientUserId: userId,
|
|
1095
|
+
templateId,
|
|
1096
|
+
subject,
|
|
1097
|
+
batchId,
|
|
1098
|
+
metadata
|
|
1099
|
+
});
|
|
1100
|
+
} catch (e) {
|
|
1101
|
+
console.warn("[EmailService] emailLogger pending failed:", e);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
try {
|
|
1105
|
+
const settings = await this.loadSettings(profile, tenantId);
|
|
1106
|
+
const useProvider = provider || settings.provider || "mailchannels";
|
|
1107
|
+
let result;
|
|
1108
|
+
let providerMessageId = null;
|
|
1109
|
+
switch (useProvider) {
|
|
1110
|
+
case "mailchannels":
|
|
1111
|
+
result = await this.sendViaMailChannels(to, subject, htmlContent, textContent, settings, metadata);
|
|
1112
|
+
break;
|
|
1113
|
+
case "sendgrid":
|
|
1114
|
+
result = await this.sendViaSendGrid(to, subject, htmlContent, textContent, settings, metadata);
|
|
1115
|
+
break;
|
|
1116
|
+
case "resend":
|
|
1117
|
+
result = await this.sendViaResend(to, subject, htmlContent, textContent, settings, metadata);
|
|
1118
|
+
if (result && typeof result === "object" && result.id) {
|
|
1119
|
+
providerMessageId = result.id;
|
|
1120
|
+
result = true;
|
|
1121
|
+
}
|
|
1122
|
+
break;
|
|
1123
|
+
case "sendpulse":
|
|
1124
|
+
result = await this.sendViaSendPulse(to, subject, htmlContent, textContent, settings, metadata);
|
|
1125
|
+
break;
|
|
1126
|
+
default:
|
|
1127
|
+
console.error(`[EmailService] Unknown provider: ${useProvider}`);
|
|
1128
|
+
if (this.emailLogger) {
|
|
1129
|
+
try {
|
|
1130
|
+
await this.emailLogger({
|
|
1131
|
+
event: "failed",
|
|
1132
|
+
recipientEmail: to,
|
|
1133
|
+
recipientUserId: userId,
|
|
1134
|
+
templateId,
|
|
1135
|
+
subject,
|
|
1136
|
+
provider: useProvider,
|
|
1137
|
+
batchId,
|
|
1138
|
+
error: `Unknown email provider: ${useProvider}`,
|
|
1139
|
+
metadata
|
|
1140
|
+
});
|
|
1141
|
+
} catch (e) {
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return { success: false, error: `Unknown email provider: ${useProvider}` };
|
|
1145
|
+
}
|
|
1146
|
+
if (result) {
|
|
1147
|
+
const messageId = providerMessageId || crypto.randomUUID();
|
|
1148
|
+
if (this.emailLogger) {
|
|
1149
|
+
try {
|
|
1150
|
+
await this.emailLogger({
|
|
1151
|
+
event: "sent",
|
|
1152
|
+
recipientEmail: to,
|
|
1153
|
+
recipientUserId: userId,
|
|
1154
|
+
templateId,
|
|
1155
|
+
subject,
|
|
1156
|
+
provider: useProvider,
|
|
1157
|
+
messageId,
|
|
1158
|
+
batchId,
|
|
1159
|
+
metadata
|
|
1160
|
+
});
|
|
1161
|
+
} catch (e) {
|
|
1162
|
+
console.warn("[EmailService] emailLogger sent failed:", e);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return { success: true, messageId };
|
|
1166
|
+
} else {
|
|
1167
|
+
console.error("[EmailService] Failed to send email to:", to);
|
|
1168
|
+
if (this.emailLogger) {
|
|
1169
|
+
try {
|
|
1170
|
+
await this.emailLogger({
|
|
1171
|
+
event: "failed",
|
|
1172
|
+
recipientEmail: to,
|
|
1173
|
+
recipientUserId: userId,
|
|
1174
|
+
templateId,
|
|
1175
|
+
subject,
|
|
1176
|
+
provider: useProvider,
|
|
1177
|
+
batchId,
|
|
1178
|
+
error: "Failed to send email",
|
|
1179
|
+
metadata
|
|
1180
|
+
});
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
return { success: false, error: "Failed to send email" };
|
|
1185
|
+
}
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
console.error("[EmailService] Error sending email:", error);
|
|
1188
|
+
if (this.emailLogger) {
|
|
1189
|
+
try {
|
|
1190
|
+
await this.emailLogger({
|
|
1191
|
+
event: "failed",
|
|
1192
|
+
recipientEmail: to,
|
|
1193
|
+
recipientUserId: userId,
|
|
1194
|
+
templateId,
|
|
1195
|
+
subject,
|
|
1196
|
+
batchId,
|
|
1197
|
+
error: error.message,
|
|
1198
|
+
metadata
|
|
1199
|
+
});
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return { success: false, error: error.message };
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Send multiple emails in batch
|
|
1208
|
+
* @param {Array} emails - Array of email objects
|
|
1209
|
+
* @returns {Promise<Array>} Array of delivery results
|
|
1210
|
+
*/
|
|
1211
|
+
async sendBatch(emails) {
|
|
1212
|
+
console.log("[EmailService] Sending batch of", emails.length, "emails");
|
|
1213
|
+
const results = await Promise.all(
|
|
1214
|
+
emails.map((email) => this.sendEmail(email))
|
|
1215
|
+
);
|
|
1216
|
+
return results;
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Send email via MailChannels HTTP API
|
|
1220
|
+
* MailChannels is specifically designed for Cloudflare Workers
|
|
1221
|
+
*/
|
|
1222
|
+
async sendViaMailChannels(to, subject, html, text, settings, metadata) {
|
|
1223
|
+
try {
|
|
1224
|
+
const response = await fetch("https://api.mailchannels.net/tx/v1/send", {
|
|
1225
|
+
method: "POST",
|
|
1226
|
+
headers: { "Content-Type": "application/json" },
|
|
1227
|
+
body: JSON.stringify({
|
|
1228
|
+
personalizations: [{ to: [{ email: to, name: metadata.recipientName || "" }] }],
|
|
1229
|
+
from: { email: settings.fromAddress, name: settings.fromName },
|
|
1230
|
+
subject,
|
|
1231
|
+
content: [
|
|
1232
|
+
{ type: "text/plain", value: text || html.replace(/<[^>]*>/g, "") },
|
|
1233
|
+
{ type: "text/html", value: html }
|
|
1234
|
+
]
|
|
1235
|
+
})
|
|
1236
|
+
});
|
|
1237
|
+
if (response.status === 202) {
|
|
1238
|
+
return true;
|
|
1239
|
+
} else {
|
|
1240
|
+
const contentType = response.headers.get("content-type");
|
|
1241
|
+
const errorBody = contentType?.includes("application/json") ? await response.json() : await response.text();
|
|
1242
|
+
console.error("[EmailService] MailChannels error:", response.status, errorBody);
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
console.error("[EmailService] MailChannels exception:", error.message);
|
|
1247
|
+
return false;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Send email via SendGrid HTTP API
|
|
1252
|
+
*/
|
|
1253
|
+
async sendViaSendGrid(to, subject, html, text, settings, metadata) {
|
|
1254
|
+
try {
|
|
1255
|
+
if (!settings.sendgridApiKey) {
|
|
1256
|
+
console.error("[EmailService] SendGrid API key missing");
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|
|
1259
|
+
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
|
|
1260
|
+
method: "POST",
|
|
1261
|
+
headers: {
|
|
1262
|
+
"Authorization": `Bearer ${settings.sendgridApiKey}`,
|
|
1263
|
+
"Content-Type": "application/json"
|
|
1264
|
+
},
|
|
1265
|
+
body: JSON.stringify({
|
|
1266
|
+
personalizations: [{ to: [{ email: to, name: metadata.recipientName || "" }] }],
|
|
1267
|
+
from: { email: settings.fromAddress, name: settings.fromName },
|
|
1268
|
+
subject,
|
|
1269
|
+
content: [
|
|
1270
|
+
{ type: "text/html", value: html },
|
|
1271
|
+
{ type: "text/plain", value: text || html.replace(/<[^>]*>/g, "") }
|
|
1272
|
+
]
|
|
1273
|
+
})
|
|
1274
|
+
});
|
|
1275
|
+
if (response.status === 202) {
|
|
1276
|
+
return true;
|
|
1277
|
+
} else {
|
|
1278
|
+
const errorText = await response.text();
|
|
1279
|
+
console.error("[EmailService] SendGrid error:", response.status, errorText);
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
console.error("[EmailService] SendGrid exception:", error.message);
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Send email via Resend HTTP API
|
|
1289
|
+
*/
|
|
1290
|
+
async sendViaResend(to, subject, html, text, settings, metadata) {
|
|
1291
|
+
try {
|
|
1292
|
+
if (!settings.resendApiKey) {
|
|
1293
|
+
console.error("[EmailService] Resend API key missing");
|
|
1294
|
+
return false;
|
|
1295
|
+
}
|
|
1296
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
1297
|
+
method: "POST",
|
|
1298
|
+
headers: {
|
|
1299
|
+
"Authorization": `Bearer ${settings.resendApiKey}`,
|
|
1300
|
+
"Content-Type": "application/json"
|
|
1301
|
+
},
|
|
1302
|
+
body: JSON.stringify({
|
|
1303
|
+
from: `${settings.fromName} <${settings.fromAddress}>`,
|
|
1304
|
+
to: [to],
|
|
1305
|
+
subject,
|
|
1306
|
+
html,
|
|
1307
|
+
text: text || html.replace(/<[^>]*>/g, "")
|
|
1308
|
+
})
|
|
1309
|
+
});
|
|
1310
|
+
if (response.ok) {
|
|
1311
|
+
return true;
|
|
1312
|
+
} else {
|
|
1313
|
+
const errorText = await response.text();
|
|
1314
|
+
console.error("[EmailService] Resend error:", response.status, errorText);
|
|
1315
|
+
return false;
|
|
1316
|
+
}
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
console.error("[EmailService] Resend exception:", error.message);
|
|
1319
|
+
return false;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Send email via SendPulse HTTP API
|
|
1324
|
+
* SendPulse offers 15,000 free emails/month
|
|
1325
|
+
*/
|
|
1326
|
+
async sendViaSendPulse(to, subject, html, text, settings, metadata) {
|
|
1327
|
+
try {
|
|
1328
|
+
if (!settings.sendpulseClientId || !settings.sendpulseClientSecret) {
|
|
1329
|
+
console.error("[EmailService] SendPulse credentials missing");
|
|
1330
|
+
return false;
|
|
1331
|
+
}
|
|
1332
|
+
const tokenParams = new URLSearchParams({
|
|
1333
|
+
grant_type: "client_credentials",
|
|
1334
|
+
client_id: settings.sendpulseClientId,
|
|
1335
|
+
client_secret: settings.sendpulseClientSecret
|
|
1336
|
+
});
|
|
1337
|
+
const tokenResponse = await fetch("https://api.sendpulse.com/oauth/access_token", {
|
|
1338
|
+
method: "POST",
|
|
1339
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1340
|
+
body: tokenParams.toString()
|
|
1341
|
+
});
|
|
1342
|
+
if (!tokenResponse.ok) {
|
|
1343
|
+
const error = await tokenResponse.text();
|
|
1344
|
+
console.error("[EmailService] SendPulse auth error:", error);
|
|
1345
|
+
return false;
|
|
1346
|
+
}
|
|
1347
|
+
const tokenData = await tokenResponse.json();
|
|
1348
|
+
if (!tokenData.access_token) {
|
|
1349
|
+
console.error("[EmailService] SendPulse: No access token in response");
|
|
1350
|
+
return false;
|
|
1351
|
+
}
|
|
1352
|
+
const { access_token } = tokenData;
|
|
1353
|
+
const toBase64 = (str) => {
|
|
1354
|
+
if (!str) return "";
|
|
1355
|
+
try {
|
|
1356
|
+
return btoa(unescape(encodeURIComponent(String(str))));
|
|
1357
|
+
} catch (e) {
|
|
1358
|
+
console.error("[EmailService] Base64 encoding failed:", e);
|
|
1359
|
+
return "";
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
const htmlSafe = html || "";
|
|
1363
|
+
const textSafe = text || (htmlSafe ? htmlSafe.replace(/<[^>]*>/g, "") : "");
|
|
1364
|
+
const response = await fetch("https://api.sendpulse.com/smtp/emails", {
|
|
1365
|
+
method: "POST",
|
|
1366
|
+
headers: {
|
|
1367
|
+
"Authorization": `Bearer ${access_token}`,
|
|
1368
|
+
"Content-Type": "application/json"
|
|
1369
|
+
},
|
|
1370
|
+
body: JSON.stringify({
|
|
1371
|
+
email: {
|
|
1372
|
+
html: toBase64(htmlSafe),
|
|
1373
|
+
text: toBase64(textSafe),
|
|
1374
|
+
subject,
|
|
1375
|
+
from: { name: settings.fromName, email: settings.fromAddress },
|
|
1376
|
+
to: [{ name: metadata.recipientName || "", email: to }]
|
|
1377
|
+
}
|
|
1378
|
+
})
|
|
1379
|
+
});
|
|
1380
|
+
if (response.ok) {
|
|
1381
|
+
return true;
|
|
1382
|
+
} else {
|
|
1383
|
+
const errorText = await response.text();
|
|
1384
|
+
console.error("[EmailService] SendPulse send error:", response.status, errorText);
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
} catch (error) {
|
|
1388
|
+
console.error("[EmailService] SendPulse exception:", error.message);
|
|
1389
|
+
return false;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
// src/backend/routes/index.js
|
|
1395
|
+
var import_hono4 = require("hono");
|
|
1396
|
+
|
|
1397
|
+
// src/backend/routes/templates.js
|
|
1398
|
+
var import_hono = require("hono");
|
|
1399
|
+
function createTemplateRoutes(config = {}) {
|
|
1400
|
+
const app = new import_hono.Hono();
|
|
1401
|
+
app.get("/", async (c) => {
|
|
1402
|
+
const emailService = new EmailService(c.env, config);
|
|
1403
|
+
try {
|
|
1404
|
+
const templates = await emailService.getAllTemplates();
|
|
1405
|
+
return c.json(templates);
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
return c.json({ error: err.message }, 500);
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
app.get("/:id", async (c) => {
|
|
1411
|
+
const emailService = new EmailService(c.env, config);
|
|
1412
|
+
try {
|
|
1413
|
+
const template = await emailService.getTemplate(c.req.param("id"));
|
|
1414
|
+
if (!template) return c.json({ error: "Template not found" }, 404);
|
|
1415
|
+
return c.json(template);
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
return c.json({ error: err.message }, 500);
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
app.post("/", async (c) => {
|
|
1421
|
+
const emailService = new EmailService(c.env, config);
|
|
1422
|
+
try {
|
|
1423
|
+
const data = await c.req.json();
|
|
1424
|
+
let userId = "admin";
|
|
1425
|
+
const user = c.get("dbUser") || c.get("user");
|
|
1426
|
+
if (user && user.id) userId = user.id;
|
|
1427
|
+
if (!data.template_id || !data.subject_template) {
|
|
1428
|
+
return c.json({ error: "Missing required fields (template_id, subject_template)" }, 400);
|
|
1429
|
+
}
|
|
1430
|
+
const result = await emailService.saveTemplate(data, userId);
|
|
1431
|
+
return c.json({ success: true, message: "Template saved", result });
|
|
1432
|
+
} catch (err) {
|
|
1433
|
+
return c.json({ error: err.message }, 500);
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
app.post("/send-test", async (c) => {
|
|
1437
|
+
const emailService = new EmailService(c.env, config);
|
|
1438
|
+
try {
|
|
1439
|
+
const { template_id, to, variables } = await c.req.json();
|
|
1440
|
+
let tid = template_id;
|
|
1441
|
+
if (!tid || !to) {
|
|
1442
|
+
return c.json({ error: "Missing required fields (template_id, to)" }, 400);
|
|
1443
|
+
}
|
|
1444
|
+
const user = c.get("dbUser") || c.get("user");
|
|
1445
|
+
const recipientUserId = user?.id || null;
|
|
1446
|
+
const result = await emailService.sendViaTemplate(tid, variables || {}, {
|
|
1447
|
+
to,
|
|
1448
|
+
profile: "test",
|
|
1449
|
+
recipientUserId
|
|
1450
|
+
});
|
|
1451
|
+
return c.json({ success: true, result });
|
|
1452
|
+
} catch (err) {
|
|
1453
|
+
return c.json({ error: `Failed to send test email: ${err.message}` }, 500);
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
app.delete("/:id", async (c) => {
|
|
1457
|
+
const emailService = new EmailService(c.env, config);
|
|
1458
|
+
try {
|
|
1459
|
+
await emailService.deleteTemplate(c.req.param("id"));
|
|
1460
|
+
return c.json({ success: true, message: "Template deleted" });
|
|
1461
|
+
} catch (err) {
|
|
1462
|
+
return c.json({ error: err.message }, 500);
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
app.post("/:id/preview", async (c) => {
|
|
1466
|
+
const emailService = new EmailService(c.env, config);
|
|
1467
|
+
try {
|
|
1468
|
+
const id = c.req.param("id");
|
|
1469
|
+
const data = await c.req.json();
|
|
1470
|
+
const result = await emailService.renderTemplate(id, data);
|
|
1471
|
+
return c.json({ success: true, preview: result });
|
|
1472
|
+
} catch (err) {
|
|
1473
|
+
return c.json({ error: err.message }, 500);
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
return app;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// src/backend/routes/tracking.js
|
|
1480
|
+
var import_hono2 = require("hono");
|
|
1481
|
+
var TRACKING_PIXEL = new Uint8Array([
|
|
1482
|
+
71,
|
|
1483
|
+
73,
|
|
1484
|
+
70,
|
|
1485
|
+
56,
|
|
1486
|
+
57,
|
|
1487
|
+
97,
|
|
1488
|
+
1,
|
|
1489
|
+
0,
|
|
1490
|
+
1,
|
|
1491
|
+
0,
|
|
1492
|
+
128,
|
|
1493
|
+
0,
|
|
1494
|
+
0,
|
|
1495
|
+
0,
|
|
1496
|
+
0,
|
|
1497
|
+
0,
|
|
1498
|
+
255,
|
|
1499
|
+
255,
|
|
1500
|
+
255,
|
|
1501
|
+
33,
|
|
1502
|
+
249,
|
|
1503
|
+
4,
|
|
1504
|
+
1,
|
|
1505
|
+
0,
|
|
1506
|
+
0,
|
|
1507
|
+
0,
|
|
1508
|
+
0,
|
|
1509
|
+
44,
|
|
1510
|
+
0,
|
|
1511
|
+
0,
|
|
1512
|
+
0,
|
|
1513
|
+
0,
|
|
1514
|
+
1,
|
|
1515
|
+
0,
|
|
1516
|
+
1,
|
|
1517
|
+
0,
|
|
1518
|
+
0,
|
|
1519
|
+
2,
|
|
1520
|
+
1,
|
|
1521
|
+
68,
|
|
1522
|
+
0,
|
|
1523
|
+
59
|
|
1524
|
+
]);
|
|
1525
|
+
function createTrackingRoutes(env, config = {}) {
|
|
1526
|
+
const app = new import_hono2.Hono();
|
|
1527
|
+
const db = env.DB;
|
|
1528
|
+
const tablePrefix = config.emailTablePrefix || config.tableNamePrefix || "system_email_";
|
|
1529
|
+
app.get("/track/open/:token", async (c) => {
|
|
1530
|
+
const token = c.req.param("token");
|
|
1531
|
+
try {
|
|
1532
|
+
const sendId = token;
|
|
1533
|
+
const send = await db.prepare(`SELECT * FROM ${tablePrefix}sends WHERE send_id = ?`).bind(sendId).first();
|
|
1534
|
+
if (send) {
|
|
1535
|
+
const eventId = crypto.randomUUID();
|
|
1536
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1537
|
+
await db.prepare(
|
|
1538
|
+
`INSERT INTO ${tablePrefix}events (
|
|
1539
|
+
event_id, send_id, user_id, tenant_id, email_kind, event_type, metadata, created_at
|
|
1540
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1541
|
+
).bind(
|
|
1542
|
+
eventId,
|
|
1543
|
+
sendId,
|
|
1544
|
+
send.user_id,
|
|
1545
|
+
send.tenant_id,
|
|
1546
|
+
send.email_kind,
|
|
1547
|
+
"opened",
|
|
1548
|
+
JSON.stringify({
|
|
1549
|
+
user_agent: c.req.header("user-agent"),
|
|
1550
|
+
referer: c.req.header("referer")
|
|
1551
|
+
}),
|
|
1552
|
+
now
|
|
1553
|
+
).run();
|
|
1554
|
+
}
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
console.error("[EmailTracking] Error tracking email open:", error);
|
|
1557
|
+
}
|
|
1558
|
+
return new Response(TRACKING_PIXEL, {
|
|
1559
|
+
headers: {
|
|
1560
|
+
"Content-Type": "image/gif",
|
|
1561
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
1562
|
+
"Pragma": "no-cache",
|
|
1563
|
+
"Expires": "0"
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
});
|
|
1567
|
+
app.post("/track/click/:token", async (c) => {
|
|
1568
|
+
const token = c.req.param("token");
|
|
1569
|
+
let url;
|
|
1570
|
+
try {
|
|
1571
|
+
const body = await c.req.json();
|
|
1572
|
+
url = body.url;
|
|
1573
|
+
} catch {
|
|
1574
|
+
return c.json({ success: false, error: "Invalid request body" }, 400);
|
|
1575
|
+
}
|
|
1576
|
+
if (!url) {
|
|
1577
|
+
return c.json({ success: false, error: "Missing URL" }, 400);
|
|
1578
|
+
}
|
|
1579
|
+
try {
|
|
1580
|
+
const sendId = token;
|
|
1581
|
+
const send = await db.prepare(`SELECT * FROM ${tablePrefix}sends WHERE send_id = ?`).bind(sendId).first();
|
|
1582
|
+
if (send) {
|
|
1583
|
+
const eventId = crypto.randomUUID();
|
|
1584
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1585
|
+
await db.prepare(
|
|
1586
|
+
`INSERT INTO ${tablePrefix}events (
|
|
1587
|
+
event_id, send_id, user_id, tenant_id, email_kind, event_type, metadata, created_at
|
|
1588
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1589
|
+
).bind(
|
|
1590
|
+
eventId,
|
|
1591
|
+
sendId,
|
|
1592
|
+
send.user_id,
|
|
1593
|
+
send.tenant_id,
|
|
1594
|
+
send.email_kind,
|
|
1595
|
+
"clicked",
|
|
1596
|
+
JSON.stringify({
|
|
1597
|
+
url,
|
|
1598
|
+
user_agent: c.req.header("user-agent"),
|
|
1599
|
+
referer: c.req.header("referer")
|
|
1600
|
+
}),
|
|
1601
|
+
now
|
|
1602
|
+
).run();
|
|
1603
|
+
}
|
|
1604
|
+
return c.json({ success: true, tracked: !!send });
|
|
1605
|
+
} catch (error) {
|
|
1606
|
+
console.error("[EmailTracking] Error tracking email click:", error);
|
|
1607
|
+
return c.json({ success: false, error: "Failed to track click" }, 500);
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
app.get("/unsubscribe/:token", async (c) => {
|
|
1611
|
+
const unsubToken = c.req.param("token");
|
|
1612
|
+
try {
|
|
1613
|
+
const prefs = await db.prepare(`SELECT * FROM ${tablePrefix}preferences WHERE unsub_token = ?`).bind(unsubToken).first();
|
|
1614
|
+
if (!prefs) {
|
|
1615
|
+
return c.json({ success: false, error: "Invalid unsubscribe link" }, 404);
|
|
1616
|
+
}
|
|
1617
|
+
const currentSettings = JSON.parse(prefs.email_settings || "{}");
|
|
1618
|
+
const alreadyUnsubscribed = Object.keys(currentSettings).length === 0;
|
|
1619
|
+
if (!alreadyUnsubscribed) {
|
|
1620
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1621
|
+
await db.prepare(
|
|
1622
|
+
`UPDATE ${tablePrefix}preferences
|
|
1623
|
+
SET email_settings = '{}',
|
|
1624
|
+
updated_at = ?
|
|
1625
|
+
WHERE unsub_token = ?`
|
|
1626
|
+
).bind(now, unsubToken).run();
|
|
1627
|
+
const eventId = crypto.randomUUID();
|
|
1628
|
+
await db.prepare(
|
|
1629
|
+
`INSERT INTO ${tablePrefix}events (
|
|
1630
|
+
event_id, send_id, user_id, tenant_id, email_kind, event_type, metadata, created_at
|
|
1631
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1632
|
+
).bind(
|
|
1633
|
+
eventId,
|
|
1634
|
+
"unsubscribe",
|
|
1635
|
+
prefs.user_id,
|
|
1636
|
+
prefs.tenant_id,
|
|
1637
|
+
"all",
|
|
1638
|
+
"unsubscribed",
|
|
1639
|
+
JSON.stringify({
|
|
1640
|
+
user_agent: c.req.header("user-agent")
|
|
1641
|
+
}),
|
|
1642
|
+
now
|
|
1643
|
+
).run();
|
|
1644
|
+
}
|
|
1645
|
+
return c.json({ success: true, alreadyUnsubscribed });
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
console.error("[EmailTracking] Error processing unsubscribe:", error);
|
|
1648
|
+
return c.json({ success: false, error: "An error occurred" }, 500);
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
return app;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// src/backend/routes/logs.js
|
|
1655
|
+
var import_hono3 = require("hono");
|
|
1656
|
+
function createLogRoutes(config = {}) {
|
|
1657
|
+
const app = new import_hono3.Hono();
|
|
1658
|
+
app.get("/", async (c) => {
|
|
1659
|
+
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
|
|
1660
|
+
const offset = parseInt(c.req.query("offset") || "0");
|
|
1661
|
+
const env = c.env;
|
|
1662
|
+
try {
|
|
1663
|
+
const table = `${config.emailTablePrefix || "system_email_"}logs`;
|
|
1664
|
+
const { results } = await env.DB.prepare(`
|
|
1665
|
+
SELECT * FROM ${table}
|
|
1666
|
+
ORDER BY created_at DESC
|
|
1667
|
+
LIMIT ? OFFSET ?
|
|
1668
|
+
`).bind(limit, offset).all();
|
|
1669
|
+
const countResult = await env.DB.prepare(`SELECT COUNT(*) as exact_count FROM ${table}`).first();
|
|
1670
|
+
return c.json({
|
|
1671
|
+
logs: results.map((row) => ({
|
|
1672
|
+
...row,
|
|
1673
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : null
|
|
1674
|
+
})),
|
|
1675
|
+
total: countResult.exact_count,
|
|
1676
|
+
limit,
|
|
1677
|
+
offset
|
|
1678
|
+
});
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
console.error("Failed to fetch logs:", error);
|
|
1681
|
+
return c.json({ error: "Failed to fetch logs" }, 500);
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
return app;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// src/backend/routes/index.js
|
|
1688
|
+
function createEmailRoutes(config = {}, cacheProvider = null) {
|
|
1689
|
+
const app = new import_hono4.Hono();
|
|
1690
|
+
app.route("/templates", createTemplateRoutes(config, cacheProvider));
|
|
1691
|
+
app.route("/logs", createLogRoutes(config));
|
|
1692
|
+
return app;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// src/common/utils.js
|
|
1696
|
+
var import_mustache2 = __toESM(require("mustache"), 1);
|
|
1697
|
+
var cachedWebsiteUrl = null;
|
|
1698
|
+
function getWebsiteUrl(env) {
|
|
1699
|
+
if (cachedWebsiteUrl) {
|
|
1700
|
+
return cachedWebsiteUrl;
|
|
1701
|
+
}
|
|
1702
|
+
const domain = env.DOMAIN || "x0start.com";
|
|
1703
|
+
const isDev = env.ENVIRONMENT === "development" || !env.ENVIRONMENT;
|
|
1704
|
+
const protocol = isDev ? "http" : "https";
|
|
1705
|
+
cachedWebsiteUrl = `${protocol}://www.${domain}`;
|
|
1706
|
+
return cachedWebsiteUrl;
|
|
1707
|
+
}
|
|
1708
|
+
function resetWebsiteUrlCache() {
|
|
1709
|
+
cachedWebsiteUrl = null;
|
|
1710
|
+
}
|
|
1711
|
+
function encodeTrackingLinks(html, sendId, env = null, websiteUrl = null) {
|
|
1712
|
+
const baseUrl = websiteUrl || (env ? getWebsiteUrl(env) : "https://www.x0start.com");
|
|
1713
|
+
const decodeHtmlEntities = (text) => {
|
|
1714
|
+
return text.replace(///g, "/").replace(/:/g, ":").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
1715
|
+
};
|
|
1716
|
+
return html.replace(/href="([^"]+)"/g, (match, url) => {
|
|
1717
|
+
if (url.startsWith("mailto:") || url.startsWith("tel:") || url.startsWith("#") || url.includes("{{") || // Skip template variables
|
|
1718
|
+
url.includes("/email/unsubscribe/") || url.includes("/email/track/")) {
|
|
1719
|
+
return match;
|
|
1720
|
+
}
|
|
1721
|
+
const decodedUrl = decodeHtmlEntities(url);
|
|
1722
|
+
const encodedUrl = Buffer.from(decodedUrl).toString("base64");
|
|
1723
|
+
const trackingUrl = `${baseUrl}/r/${sendId}?url=${encodedUrl}`;
|
|
1724
|
+
return `href="${trackingUrl}"`;
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
function markdownToPlainText(markdown) {
|
|
1728
|
+
return markdown.replace(/#{1,6}\s+/g, "").replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/\[(.+?)\]\((.+?)\)/g, "$1: $2").replace(/`(.+?)`/g, "$1").replace(/>\s+/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
1729
|
+
}
|
|
1730
|
+
function extractVariables(templateString) {
|
|
1731
|
+
if (!templateString) return [];
|
|
1732
|
+
try {
|
|
1733
|
+
const tokens = import_mustache2.default.parse(templateString);
|
|
1734
|
+
const variables = /* @__PURE__ */ new Set();
|
|
1735
|
+
const collectVariables = (tokenList) => {
|
|
1736
|
+
tokenList.forEach((token) => {
|
|
1737
|
+
const type = token[0];
|
|
1738
|
+
const value = token[1];
|
|
1739
|
+
if (type === "name" || type === "#" || type === "^" || type === "&") {
|
|
1740
|
+
variables.add(value);
|
|
1741
|
+
}
|
|
1742
|
+
if ((type === "#" || type === "^") && token[4]) {
|
|
1743
|
+
collectVariables(token[4]);
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
};
|
|
1747
|
+
collectVariables(tokens);
|
|
1748
|
+
return Array.from(variables);
|
|
1749
|
+
} catch (error) {
|
|
1750
|
+
console.error("Error parsing template variables:", error);
|
|
1751
|
+
return [];
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1755
|
+
0 && (module.exports = {
|
|
1756
|
+
EmailLogger,
|
|
1757
|
+
EmailService,
|
|
1758
|
+
EmailingCacheDO,
|
|
1759
|
+
createDOCacheProvider,
|
|
1760
|
+
createEmailLoggerCallback,
|
|
1761
|
+
createEmailRoutes,
|
|
1762
|
+
createTemplateRoutes,
|
|
1763
|
+
createTrackingRoutes,
|
|
1764
|
+
encodeTrackingLinks,
|
|
1765
|
+
extractVariables,
|
|
1766
|
+
getWebsiteUrl,
|
|
1767
|
+
markdownToPlainText,
|
|
1768
|
+
resetWebsiteUrlCache,
|
|
1769
|
+
wrapInEmailTemplate
|
|
1770
|
+
});
|
|
1771
|
+
//# sourceMappingURL=index.cjs.map
|