@contentgrowth/content-emailing 0.2.0 → 0.4.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 +91 -2
- package/package.json +2 -1
- package/src/backend/EmailService.js +129 -69
- package/src/backend/EmailingCacheDO.js +466 -0
- package/src/backend/routes/tracking.js +2 -2
- package/src/common/utils.js +36 -12
- package/src/index.js +1 -1
- package/src/backend/EmailTemplateCacheDO.js +0 -363
package/README.md
CHANGED
|
@@ -36,7 +36,13 @@ In your worker or Hono app:
|
|
|
36
36
|
import { EmailService } from '@emails/backend';
|
|
37
37
|
|
|
38
38
|
// Initialize with your environment (needs DB binding)
|
|
39
|
+
// Auto-detects 'EMAIL_TEMPLATE_CACHE' Durable Object if present!
|
|
39
40
|
const emailService = new EmailService(env, {
|
|
41
|
+
// Optional: Rename tables if needed
|
|
42
|
+
emailTablePrefix: 'system_email_', // default (for templates/logs)
|
|
43
|
+
// emailSettingsTable: 'system_settings', // default (for settings)
|
|
44
|
+
// emailSettingsKeyPrefix: 'email_', // Optional: filter settings in shared table
|
|
45
|
+
|
|
40
46
|
defaults: {
|
|
41
47
|
fromName: 'My App',
|
|
42
48
|
fromAddress: 'noreply@myapp.com',
|
|
@@ -47,7 +53,90 @@ const emailService = new EmailService(env, {
|
|
|
47
53
|
});
|
|
48
54
|
```
|
|
49
55
|
|
|
50
|
-
### 2.
|
|
56
|
+
### 2.- `EmailingCacheDO.js` - Durable Object for caching templates & settings, you can enable the read-through cache using Cloudflare Durable Objects.
|
|
57
|
+
|
|
58
|
+
**The "Magic" Way (Auto-detection):**
|
|
59
|
+
1. Add a Durable Object binding named `EMAIL_TEMPLATE_CACHE` to your `wrangler.toml`.
|
|
60
|
+
2. Point it to the `EmailingCacheDO` class from this package.
|
|
61
|
+
3. Initialize `EmailService(env)` normally. It will automatically find and use the cache.
|
|
62
|
+
|
|
63
|
+
**wrangler.toml:**
|
|
64
|
+
```toml
|
|
65
|
+
[[durable_objects.bindings]]
|
|
66
|
+
name = "EMAIL_TEMPLATE_CACHE"
|
|
67
|
+
class_name = "EmailTemplateCacheDO"
|
|
68
|
+
|
|
69
|
+
[[migrations]]
|
|
70
|
+
tag = "v1"
|
|
71
|
+
new_classes = ["EmailTemplateCacheDO"]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**The Explicit Way:**
|
|
75
|
+
If you use a custom binding name or cache implementation:
|
|
76
|
+
|
|
77
|
+
```javascript
|
|
78
|
+
import { EmailService, createDOCacheProvider } from '@emails/backend';
|
|
79
|
+
|
|
80
|
+
const cache = createDOCacheProvider(env.MY_CUSTOM_BINDING);
|
|
81
|
+
const service = new EmailService(env, config, cache);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Custom Cache Provider Interface:**
|
|
85
|
+
You can also implement your own cache provider (e.g., using Redis/KV). It must match this interface:
|
|
86
|
+
|
|
87
|
+
```javascript
|
|
88
|
+
const myCustomCache = {
|
|
89
|
+
// Return template object or null
|
|
90
|
+
async getTemplate(templateId) {
|
|
91
|
+
// implementation
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// Called when template is saved/updated
|
|
95
|
+
async putTemplate(template) {
|
|
96
|
+
// implementation (usually just invalidates cache)
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Called when template is deleted
|
|
100
|
+
async deleteTemplate(templateId) {
|
|
101
|
+
// implementation
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const service = new EmailService(env, config, myCustomCache);
|
|
106
|
+
```
|
|
107
|
+
const service = new EmailService(env, config, myCustomCache);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 3. Settings Management (Hybrid Caching)
|
|
111
|
+
|
|
112
|
+
The package supports a robust caching strategy for email settings (provider API keys, SMTP config, etc.).
|
|
113
|
+
|
|
114
|
+
**Case A: Default System Settings (Zero Config)**
|
|
115
|
+
If you store settings in the `system_settings` table (default schema), the Durable Object handles everything automatically:
|
|
116
|
+
1. `EmailService` asks DO for settings.
|
|
117
|
+
2. If not cached, **DO queries D1 directly** (Read-Through).
|
|
118
|
+
3. If cached, returns instantly.
|
|
119
|
+
|
|
120
|
+
**Case B: Component/Tenant Settings (Custom Logic)**
|
|
121
|
+
If you need to load settings from a custom source (e.g., tenant tables, KV, external API), simple provide loader/updater callbacks:
|
|
122
|
+
|
|
123
|
+
```javascript
|
|
124
|
+
const emailService = new EmailService(env, {
|
|
125
|
+
// Custom Loader (Read-Aside)
|
|
126
|
+
// The service will load this, THEN cache it in the DO for you.
|
|
127
|
+
settingsLoader: async (profile, tenantId) => {
|
|
128
|
+
return await env.TENANT_DB.prepare('SELECT * FROM tenant_config WHERE id = ?').bind(tenantId).first();
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
// Custom Updater (Write-Aside)
|
|
132
|
+
// required if you use saveSettings()
|
|
133
|
+
settingsUpdater: async (profile, tenantId, settings) => {
|
|
134
|
+
await env.TENANT_DB.prepare('UPDATE tenant_config SET ...').run();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 4. Database Schema
|
|
51
140
|
|
|
52
141
|
Ensure your D1 database has the required tables. See `examples/mocks/MockD1.js` for the schema structure, or refer to the provided SQL migration files (if available).
|
|
53
142
|
|
|
@@ -56,7 +145,7 @@ Tables needed:
|
|
|
56
145
|
- `system_settings`
|
|
57
146
|
- `system_email_sends` (for logs)
|
|
58
147
|
|
|
59
|
-
###
|
|
148
|
+
### 5. Add API Routes
|
|
60
149
|
|
|
61
150
|
Mount the management routes in your Hono app:
|
|
62
151
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contentgrowth/content-emailing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Unified email delivery and template management system",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js",
|
|
9
9
|
"./backend": "./src/backend/EmailService.js",
|
|
10
|
+
"./backend/EmailingCacheDO.js": "./src/backend/EmailingCacheDO.js",
|
|
10
11
|
"./routes": "./src/backend/routes/index.js",
|
|
11
12
|
"./common": "./src/common/index.js"
|
|
12
13
|
},
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
import { marked } from 'marked';
|
|
6
6
|
import Mustache from 'mustache';
|
|
7
7
|
import { wrapInEmailTemplate } from '../common/htmlWrapper.js';
|
|
8
|
+
import { createDOCacheProvider } from './EmailingCacheDO.js';
|
|
8
9
|
|
|
9
10
|
export class EmailService {
|
|
10
11
|
/**
|
|
11
12
|
* @param {Object} env - Cloudflare environment bindings (DB, etc.)
|
|
12
13
|
* @param {Object} config - Configuration options
|
|
13
|
-
* @param {string} [config.
|
|
14
|
+
* @param {string} [config.emailTablePrefix='system_email_'] - Prefix for D1 tables
|
|
14
15
|
* @param {Object} [config.defaults] - Default settings (fromName, fromAddress)
|
|
15
16
|
* @param {Object} [cacheProvider] - Optional cache interface (DO stub or KV wrapper)
|
|
16
17
|
*/
|
|
@@ -18,12 +19,20 @@ export class EmailService {
|
|
|
18
19
|
this.env = env;
|
|
19
20
|
this.db = env.DB;
|
|
20
21
|
this.config = {
|
|
21
|
-
|
|
22
|
+
emailTablePrefix: config.emailTablePrefix || config.tableNamePrefix || 'system_email_',
|
|
22
23
|
defaults: config.defaults || {
|
|
23
24
|
fromName: 'System',
|
|
24
25
|
fromAddress: 'noreply@example.com',
|
|
25
26
|
provider: 'mailchannels'
|
|
26
27
|
},
|
|
28
|
+
// Loader function to fetch settings from backend (DB, KV, etc.)
|
|
29
|
+
// Signature: async (profile, tenantId) => SettingsObject
|
|
30
|
+
settingsLoader: config.settingsLoader || null,
|
|
31
|
+
|
|
32
|
+
// Updater function to save settings to backend
|
|
33
|
+
// Signature: async (profile, tenantId, settings) => void
|
|
34
|
+
settingsUpdater: config.settingsUpdater || null,
|
|
35
|
+
|
|
27
36
|
// Branding configuration for email templates
|
|
28
37
|
branding: {
|
|
29
38
|
brandName: config.branding?.brandName || 'Your App',
|
|
@@ -33,91 +42,94 @@ export class EmailService {
|
|
|
33
42
|
},
|
|
34
43
|
...config
|
|
35
44
|
};
|
|
36
|
-
|
|
45
|
+
|
|
46
|
+
// Cache Auto-detection:
|
|
47
|
+
// If no provider explicitly passed, try to use built-in DO provider if binding exists
|
|
48
|
+
if (!cacheProvider && env.EMAIL_TEMPLATE_CACHE) {
|
|
49
|
+
this.cache = createDOCacheProvider(env.EMAIL_TEMPLATE_CACHE);
|
|
50
|
+
} else {
|
|
51
|
+
this.cache = cacheProvider;
|
|
52
|
+
}
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
// --- Configuration & Settings ---
|
|
40
56
|
|
|
41
57
|
/**
|
|
42
58
|
* Load email configuration
|
|
43
|
-
* @param {string} profile - 'system' or 'tenant'
|
|
44
|
-
* @param {string} tenantId -
|
|
59
|
+
* @param {string} profile - 'system' or 'tenant' (or custom profile string)
|
|
60
|
+
* @param {string} tenantId - Context ID (optional)
|
|
45
61
|
* @returns {Promise<Object>} Email configuration
|
|
46
62
|
*/
|
|
47
63
|
async loadSettings(profile = 'system', tenantId = null) {
|
|
48
|
-
// 1. Try cache
|
|
49
|
-
if (this.cache &&
|
|
64
|
+
// 1. Try cache (now with Read-Through logic in DO)
|
|
65
|
+
if (this.cache && this.cache.getSettings) {
|
|
50
66
|
try {
|
|
51
|
-
const cached = await this.cache.getSettings();
|
|
67
|
+
const cached = await this.cache.getSettings(profile, tenantId);
|
|
52
68
|
if (cached) return this._normalizeConfig(cached);
|
|
53
69
|
} catch (e) {
|
|
54
|
-
|
|
70
|
+
// Ignore cache/rpc errors
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
73
|
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
let settings = null;
|
|
75
|
+
|
|
76
|
+
// 2. Use settingsLoader (Custom Worker Logic) if provided
|
|
77
|
+
if (this.config.settingsLoader) {
|
|
60
78
|
try {
|
|
61
|
-
|
|
62
|
-
const settings = await this.db
|
|
63
|
-
.prepare(`SELECT setting_key, setting_value FROM system_settings`)
|
|
64
|
-
.all();
|
|
65
|
-
|
|
66
|
-
if (settings.results && settings.results.length > 0) {
|
|
67
|
-
const config = {};
|
|
68
|
-
settings.results.forEach((row) => {
|
|
69
|
-
config[row.setting_key] = row.setting_value;
|
|
70
|
-
});
|
|
71
|
-
return this._normalizeConfig(config);
|
|
72
|
-
}
|
|
79
|
+
settings = await this.config.settingsLoader(profile, tenantId);
|
|
73
80
|
} catch (e) {
|
|
74
|
-
console.warn('[EmailService]
|
|
81
|
+
console.warn('[EmailService] settingsLoader failed:', e);
|
|
75
82
|
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
fromAddress: emailConfig.fromAddress || this.config.defaults.fromAddress,
|
|
89
|
-
fromName: emailConfig.fromName || this.config.defaults.fromName,
|
|
90
|
-
...emailConfig
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
} catch (e) {
|
|
94
|
-
console.warn('[EmailService] Tenant settings lookup failed:', e);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Note: The "Magical D1 Fallback" for system settings has been moved
|
|
86
|
+
// INSIDE the EmailTemplateCacheDO (fetchSettingsFromD1).
|
|
87
|
+
// If the DO failed to find it (or is missing), we fall back to generic defaults below.
|
|
88
|
+
|
|
89
|
+
if (settings) {
|
|
90
|
+
// 3. Populate Cache (Write-Back for custom loaded settings)
|
|
91
|
+
if (this.cache && this.cache.putSettings) {
|
|
92
|
+
try {
|
|
93
|
+
this.cache.putSettings(profile, tenantId, settings);
|
|
94
|
+
} catch (e) { /* ignore */ }
|
|
95
95
|
}
|
|
96
|
+
return this._normalizeConfig(settings);
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
//
|
|
99
|
+
// 4. Fallback to defaults
|
|
99
100
|
return {
|
|
100
101
|
...this.config.defaults,
|
|
101
102
|
};
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/**
|
|
105
|
-
* Normalize config keys
|
|
106
|
+
* Normalize config keys to standard format
|
|
107
|
+
* Handles both snake_case (DB) and camelCase inputs
|
|
106
108
|
*/
|
|
107
109
|
_normalizeConfig(config) {
|
|
108
110
|
return {
|
|
109
|
-
provider: config.email_provider || this.config.defaults.provider,
|
|
110
|
-
fromAddress: config.email_from_address || this.config.defaults.fromAddress,
|
|
111
|
-
fromName: config.email_from_name || this.config.defaults.fromName,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
111
|
+
provider: config.email_provider || config.provider || this.config.defaults.provider,
|
|
112
|
+
fromAddress: config.email_from_address || config.fromAddress || this.config.defaults.fromAddress,
|
|
113
|
+
fromName: config.email_from_name || config.fromName || this.config.defaults.fromName,
|
|
114
|
+
|
|
115
|
+
// Provider-specific settings (normalize DB keys to service keys)
|
|
116
|
+
sendgridApiKey: config.sendgrid_api_key || config.sendgridApiKey,
|
|
117
|
+
resendApiKey: config.resend_api_key || config.resendApiKey,
|
|
118
|
+
sendpulseClientId: config.sendpulse_client_id || config.sendpulseClientId,
|
|
119
|
+
sendpulseClientSecret: config.sendpulse_client_secret || config.sendpulseClientSecret,
|
|
120
|
+
|
|
121
|
+
// SMTP
|
|
122
|
+
smtpHost: config.smtp_host || config.smtpHost,
|
|
123
|
+
smtpPort: config.smtp_port || config.smtpPort,
|
|
124
|
+
smtpUsername: config.smtp_username || config.smtpUsername,
|
|
125
|
+
smtpPassword: config.smtp_password || config.smtpPassword,
|
|
126
|
+
|
|
127
|
+
// Tracking
|
|
128
|
+
trackingUrl: config.tracking_url || config.trackingUrl,
|
|
129
|
+
|
|
130
|
+
// Pass through others
|
|
131
|
+
// Pass through others
|
|
132
|
+
...config,
|
|
121
133
|
smtpSecure: config.smtp_secure === 'true',
|
|
122
134
|
};
|
|
123
135
|
}
|
|
@@ -135,20 +147,55 @@ export class EmailService {
|
|
|
135
147
|
}
|
|
136
148
|
}
|
|
137
149
|
|
|
138
|
-
const table = `${this.config.
|
|
150
|
+
const table = `${this.config.emailTablePrefix}templates`;
|
|
139
151
|
return await this.db.prepare(`SELECT * FROM ${table} WHERE template_id = ?`)
|
|
140
152
|
.bind(templateId)
|
|
141
153
|
.first();
|
|
142
154
|
}
|
|
143
155
|
|
|
144
156
|
async getAllTemplates() {
|
|
145
|
-
const table = `${this.config.
|
|
157
|
+
const table = `${this.config.emailTablePrefix}templates`;
|
|
146
158
|
const result = await this.db.prepare(`SELECT * FROM ${table} ORDER BY template_name`).all();
|
|
147
159
|
return result.results || [];
|
|
148
160
|
}
|
|
149
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Save email configuration
|
|
164
|
+
* @param {Object} settings - Config object
|
|
165
|
+
* @param {string} profile - 'system' or 'tenant'
|
|
166
|
+
* @param {string} tenantId - Context ID
|
|
167
|
+
*/
|
|
168
|
+
async saveSettings(settings, profile = 'system', tenantId = null) {
|
|
169
|
+
// 1. Convert to DB format if needed (not implemented here, assumes settingsUpdater handles it)
|
|
170
|
+
|
|
171
|
+
// 2. Use settingsUpdater if provided (Write-Aside)
|
|
172
|
+
if (this.config.settingsUpdater) {
|
|
173
|
+
try {
|
|
174
|
+
await this.config.settingsUpdater(profile, tenantId, settings);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.error('[EmailService] settingsUpdater failed:', e);
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// 3. Helper for saving to D1 (if no updater, legacy/default tables)
|
|
181
|
+
else if (profile === 'system' && this.db) {
|
|
182
|
+
// NOTE: We could implement a default D1 upsert here similar to the DO fallback,
|
|
183
|
+
// but for writing it's safer to rely on explicit updaters or the DO write-through if implemented.
|
|
184
|
+
// Currently DO supports write-through for 'system' keys.
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 4. Update Cache (Write-Invalidate)
|
|
188
|
+
if (this.cache && this.cache.invalidateSettings) {
|
|
189
|
+
try {
|
|
190
|
+
await this.cache.invalidateSettings(profile, tenantId);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.warn('[EmailService] Failed to invalidate settings cache:', e);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
150
197
|
async saveTemplate(template, userId = 'system') {
|
|
151
|
-
const table = `${this.config.
|
|
198
|
+
const table = `${this.config.emailTablePrefix}templates`;
|
|
152
199
|
const now = Math.floor(Date.now() / 1000);
|
|
153
200
|
const existing = await this.getTemplate(template.template_id);
|
|
154
201
|
|
|
@@ -184,7 +231,7 @@ export class EmailService {
|
|
|
184
231
|
}
|
|
185
232
|
|
|
186
233
|
async deleteTemplate(templateId) {
|
|
187
|
-
const table = `${this.config.
|
|
234
|
+
const table = `${this.config.emailTablePrefix}templates`;
|
|
188
235
|
await this.db.prepare(`DELETE FROM ${table} WHERE template_id = ?`).bind(templateId).run();
|
|
189
236
|
if (this.cache) {
|
|
190
237
|
await this.cache.deleteTemplate(templateId);
|
|
@@ -240,7 +287,11 @@ export class EmailService {
|
|
|
240
287
|
* @param {Object} [params.metadata] - Additional metadata
|
|
241
288
|
* @returns {Promise<Object>} Delivery result
|
|
242
289
|
*/
|
|
243
|
-
async sendEmail({ to, subject, html, text, provider, profile = 'system', tenantId = null, metadata = {} }) {
|
|
290
|
+
async sendEmail({ to, subject, html, htmlBody, text, textBody, provider, profile = 'system', tenantId = null, metadata = {} }) {
|
|
291
|
+
// Backward compatibility: accept htmlBody/textBody as aliases
|
|
292
|
+
const htmlContent = html || htmlBody;
|
|
293
|
+
const textContent = text || textBody;
|
|
294
|
+
|
|
244
295
|
try {
|
|
245
296
|
const settings = await this.loadSettings(profile, tenantId);
|
|
246
297
|
const useProvider = provider || settings.provider || 'mailchannels';
|
|
@@ -249,16 +300,16 @@ export class EmailService {
|
|
|
249
300
|
|
|
250
301
|
switch (useProvider) {
|
|
251
302
|
case 'mailchannels':
|
|
252
|
-
result = await this.sendViaMailChannels(to, subject,
|
|
303
|
+
result = await this.sendViaMailChannels(to, subject, htmlContent, textContent, settings, metadata);
|
|
253
304
|
break;
|
|
254
305
|
case 'sendgrid':
|
|
255
|
-
result = await this.sendViaSendGrid(to, subject,
|
|
306
|
+
result = await this.sendViaSendGrid(to, subject, htmlContent, textContent, settings, metadata);
|
|
256
307
|
break;
|
|
257
308
|
case 'resend':
|
|
258
|
-
result = await this.sendViaResend(to, subject,
|
|
309
|
+
result = await this.sendViaResend(to, subject, htmlContent, textContent, settings, metadata);
|
|
259
310
|
break;
|
|
260
311
|
case 'sendpulse':
|
|
261
|
-
result = await this.sendViaSendPulse(to, subject,
|
|
312
|
+
result = await this.sendViaSendPulse(to, subject, htmlContent, textContent, settings, metadata);
|
|
262
313
|
break;
|
|
263
314
|
default:
|
|
264
315
|
console.error(`[EmailService] Unknown provider: ${useProvider}`);
|
|
@@ -441,7 +492,16 @@ export class EmailService {
|
|
|
441
492
|
}
|
|
442
493
|
|
|
443
494
|
const { access_token } = tokenData;
|
|
444
|
-
|
|
495
|
+
|
|
496
|
+
// Safe base64 encoding
|
|
497
|
+
const toBase64 = (str) => {
|
|
498
|
+
if (!str) return '';
|
|
499
|
+
return Buffer.from(String(str)).toString('base64');
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// Ensure html/text are strings
|
|
503
|
+
const htmlSafe = html || '';
|
|
504
|
+
const textSafe = text || (htmlSafe ? htmlSafe.replace(/<[^>]*>/g, '') : '');
|
|
445
505
|
|
|
446
506
|
// Send the email
|
|
447
507
|
const response = await fetch('https://api.sendpulse.com/smtp/emails', {
|
|
@@ -452,8 +512,8 @@ export class EmailService {
|
|
|
452
512
|
},
|
|
453
513
|
body: JSON.stringify({
|
|
454
514
|
email: {
|
|
455
|
-
html: toBase64(
|
|
456
|
-
text: toBase64(
|
|
515
|
+
html: toBase64(htmlSafe),
|
|
516
|
+
text: toBase64(textSafe),
|
|
457
517
|
subject,
|
|
458
518
|
from: { name: settings.fromName, email: settings.fromAddress },
|
|
459
519
|
to: [{ name: metadata.recipientName || '', email: to }],
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EmailingCacheDO - Optional read-through cache for email templates and settings
|
|
3
|
+
*
|
|
4
|
+
* This is a Cloudflare Durable Object that provides caching for email templates and settings.
|
|
5
|
+
*
|
|
6
|
+
* Configurable via environment variable:
|
|
7
|
+
* - EMAIL_TABLE_PREFIX: Prefix for tables (default: 'system_email_')
|
|
8
|
+
*/
|
|
9
|
+
export class EmailingCacheDO {
|
|
10
|
+
constructor(state, env) {
|
|
11
|
+
this.state = state;
|
|
12
|
+
this.env = env;
|
|
13
|
+
this.cache = new Map(); // templateId -> { data, timestamp }
|
|
14
|
+
this.settingsCache = new Map(); // key -> { data, timestamp }
|
|
15
|
+
this.cacheTTL = 3600000; // 1 hour in milliseconds
|
|
16
|
+
// Templates use the prefix
|
|
17
|
+
this.emailTablePrefix = env.EMAIL_TABLE_PREFIX || 'system_email_';
|
|
18
|
+
// Settings use a specific table name (defaulting to system_settings)
|
|
19
|
+
// This allows decoupling template prefix from settings table
|
|
20
|
+
this.settingsTableName = env.EMAIL_SETTINGS_TABLE || 'system_settings';
|
|
21
|
+
|
|
22
|
+
// Optional: Filter settings by key prefix (e.g. 'email_')
|
|
23
|
+
// This allows sharing the system_settings table with other apps
|
|
24
|
+
this.settingsKeyPrefix = env.EMAIL_SETTINGS_KEY_PREFIX || '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Handle HTTP requests to this Durable Object
|
|
29
|
+
*/
|
|
30
|
+
async fetch(request) {
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
const path = url.pathname;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
if (path === '/get' && request.method === 'GET') {
|
|
36
|
+
return this.handleGet(request);
|
|
37
|
+
} else if (path === '/invalidate' && request.method === 'POST') {
|
|
38
|
+
return this.handleInvalidate(request);
|
|
39
|
+
} else if (path === '/clear' && request.method === 'POST') {
|
|
40
|
+
return this.handleClear(request);
|
|
41
|
+
} else if (path === '/stats' && request.method === 'GET') {
|
|
42
|
+
return this.handleStats(request);
|
|
43
|
+
}
|
|
44
|
+
// Settings endpoints
|
|
45
|
+
else if (path === '/settings/get' && request.method === 'GET') {
|
|
46
|
+
return this.handleGetSettings(request);
|
|
47
|
+
} else if (path === '/settings/put' && request.method === 'POST') {
|
|
48
|
+
return this.handlePutSettings(request);
|
|
49
|
+
} else if (path === '/settings/invalidate' && request.method === 'POST') {
|
|
50
|
+
return this.handleInvalidateSettings(request);
|
|
51
|
+
} else {
|
|
52
|
+
return new Response('Not Found', { status: 404 });
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error('[EmailingCacheDO] Error:', error);
|
|
56
|
+
return new Response(JSON.stringify({ error: error.message }), {
|
|
57
|
+
status: 500,
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get template (from cache or D1)
|
|
65
|
+
*/
|
|
66
|
+
async handleGet(request) {
|
|
67
|
+
const url = new URL(request.url);
|
|
68
|
+
const templateId = url.searchParams.get('templateId');
|
|
69
|
+
const forceRefresh = url.searchParams.get('refresh') === 'true';
|
|
70
|
+
|
|
71
|
+
if (!templateId) {
|
|
72
|
+
return new Response(JSON.stringify({ error: 'templateId is required' }), {
|
|
73
|
+
status: 400,
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check cache (unless force refresh)
|
|
79
|
+
if (!forceRefresh) {
|
|
80
|
+
const cached = this.cache.get(templateId);
|
|
81
|
+
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
|
|
82
|
+
console.log('[EmailingCacheDO] Cache HIT:', templateId);
|
|
83
|
+
return new Response(JSON.stringify({
|
|
84
|
+
template: cached.data,
|
|
85
|
+
cached: true,
|
|
86
|
+
age: Date.now() - cached.timestamp,
|
|
87
|
+
}), {
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Cache miss or expired - fetch from D1
|
|
94
|
+
console.log('[EmailingCacheDO] Cache MISS - fetching from D1:', templateId);
|
|
95
|
+
const template = await this.fetchTemplateFromD1(templateId);
|
|
96
|
+
|
|
97
|
+
if (!template) {
|
|
98
|
+
return new Response(JSON.stringify({
|
|
99
|
+
error: 'Template not found',
|
|
100
|
+
templateId
|
|
101
|
+
}), {
|
|
102
|
+
status: 404,
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Cache the result
|
|
108
|
+
this.cache.set(templateId, {
|
|
109
|
+
data: template,
|
|
110
|
+
timestamp: Date.now(),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return new Response(JSON.stringify({
|
|
114
|
+
template,
|
|
115
|
+
cached: false,
|
|
116
|
+
}), {
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Settings Cache Handlers ---
|
|
122
|
+
|
|
123
|
+
async handleGetSettings(request) {
|
|
124
|
+
const url = new URL(request.url);
|
|
125
|
+
const key = url.searchParams.get('key');
|
|
126
|
+
|
|
127
|
+
if (!key) return new Response('Key required', { status: 400 });
|
|
128
|
+
|
|
129
|
+
// Check Cache
|
|
130
|
+
const cached = this.settingsCache.get(key);
|
|
131
|
+
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
|
|
132
|
+
console.log('[EmailingCacheDO] Settings Cache HIT:', key);
|
|
133
|
+
return new Response(JSON.stringify({ settings: cached.data }), {
|
|
134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Cache Miss - Fetch from D1 (Read-Through)
|
|
139
|
+
// Only fetch from D1 for system settings (consistent with handlePutSettings)
|
|
140
|
+
if (key.startsWith('system')) {
|
|
141
|
+
console.log('[EmailingCacheDO] Settings Cache MISS - fetching from D1:', key);
|
|
142
|
+
const settings = await this.fetchSettingsFromD1(key);
|
|
143
|
+
|
|
144
|
+
// Even if null, we might want to cache the "null" result to prevent hammer?
|
|
145
|
+
// For now, only cache if we found something or explicitly handle negative caching.
|
|
146
|
+
if (settings) {
|
|
147
|
+
this.settingsCache.set(key, {
|
|
148
|
+
data: settings,
|
|
149
|
+
timestamp: Date.now()
|
|
150
|
+
});
|
|
151
|
+
return new Response(JSON.stringify({ settings }), {
|
|
152
|
+
headers: { 'Content-Type': 'application/json' },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return new Response(JSON.stringify({ settings: null }), {
|
|
158
|
+
headers: { 'Content-Type': 'application/json' },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async handlePutSettings(request) {
|
|
163
|
+
try {
|
|
164
|
+
const body = await request.json();
|
|
165
|
+
const { key, settings } = body;
|
|
166
|
+
|
|
167
|
+
if (!key || !settings) return new Response('Key and settings required', { status: 400 });
|
|
168
|
+
|
|
169
|
+
// Write to Cache
|
|
170
|
+
this.settingsCache.set(key, {
|
|
171
|
+
data: settings,
|
|
172
|
+
timestamp: Date.now()
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Write to D1 (Write-Through)
|
|
176
|
+
// Note: only supports writing to "system" (default table) currently
|
|
177
|
+
if (key.startsWith('system')) {
|
|
178
|
+
await this.saveSettingsToD1(settings);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
182
|
+
headers: { 'Content-Type': 'application/json' },
|
|
183
|
+
});
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.error('[EmailingCacheDO] PutSettings error:', e);
|
|
186
|
+
return new Response('Error parsing body/saving', { status: 400 });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async handleInvalidateSettings(request) {
|
|
191
|
+
try {
|
|
192
|
+
const body = await request.json();
|
|
193
|
+
const { key } = body;
|
|
194
|
+
|
|
195
|
+
if (!key) return new Response('Key required', { status: 400 });
|
|
196
|
+
|
|
197
|
+
const existed = this.settingsCache.has(key);
|
|
198
|
+
this.settingsCache.delete(key);
|
|
199
|
+
console.log('[EmailingCacheDO] Invalidated settings:', key, existed ? '(existed)' : '(not in cache)');
|
|
200
|
+
|
|
201
|
+
return new Response(JSON.stringify({ success: true, existed }), {
|
|
202
|
+
headers: { 'Content-Type': 'application/json' },
|
|
203
|
+
});
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.error('[EmailingCacheDO] InvalidateSettings error:', e);
|
|
206
|
+
return new Response('Error invalidated settings', { status: 400 });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Invalidate specific template(s) from cache
|
|
212
|
+
* Body: { templateId: 'template_id' } or { templateId: '*' } for all
|
|
213
|
+
*/
|
|
214
|
+
async handleInvalidate(request) {
|
|
215
|
+
const body = await request.json();
|
|
216
|
+
const { templateId } = body;
|
|
217
|
+
|
|
218
|
+
if (!templateId) {
|
|
219
|
+
return new Response(JSON.stringify({ error: 'templateId is required' }), {
|
|
220
|
+
status: 400,
|
|
221
|
+
headers: { 'Content-Type': 'application/json' },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (templateId === '*') {
|
|
226
|
+
// Invalidate all templates
|
|
227
|
+
const count = this.cache.size;
|
|
228
|
+
this.cache.clear();
|
|
229
|
+
console.log('[EmailingCacheDO] Invalidated ALL templates:', count);
|
|
230
|
+
return new Response(JSON.stringify({
|
|
231
|
+
success: true,
|
|
232
|
+
message: `Invalidated ${count} templates`,
|
|
233
|
+
}), {
|
|
234
|
+
headers: { 'Content-Type': 'application/json' },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Invalidate specific template
|
|
239
|
+
const existed = this.cache.has(templateId);
|
|
240
|
+
this.cache.delete(templateId);
|
|
241
|
+
console.log('[EmailingCacheDO] Invalidated template:', templateId, existed ? '(existed)' : '(not in cache)');
|
|
242
|
+
|
|
243
|
+
return new Response(JSON.stringify({
|
|
244
|
+
success: true,
|
|
245
|
+
message: existed ? 'Template invalidated' : 'Template was not in cache',
|
|
246
|
+
templateId,
|
|
247
|
+
}), {
|
|
248
|
+
headers: { 'Content-Type': 'application/json' },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Clear entire cache (admin operation)
|
|
254
|
+
*/
|
|
255
|
+
async handleClear(request) {
|
|
256
|
+
const count = this.cache.size + this.settingsCache.size;
|
|
257
|
+
this.cache.clear();
|
|
258
|
+
this.settingsCache.clear();
|
|
259
|
+
console.log('[EmailingCacheDO] Cache cleared:', count, 'entries (templates + settings)');
|
|
260
|
+
|
|
261
|
+
return new Response(JSON.stringify({
|
|
262
|
+
success: true,
|
|
263
|
+
message: `Cleared ${count} cached items`,
|
|
264
|
+
}), {
|
|
265
|
+
headers: { 'Content-Type': 'application/json' },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Get cache statistics
|
|
271
|
+
*/
|
|
272
|
+
async handleStats(request) {
|
|
273
|
+
const stats = {
|
|
274
|
+
templates: this.cache.size,
|
|
275
|
+
settings: this.settingsCache.size,
|
|
276
|
+
cacheTTL: this.cacheTTL,
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return new Response(JSON.stringify(stats), {
|
|
280
|
+
headers: { 'Content-Type': 'application/json' },
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Fetch template from D1
|
|
286
|
+
*/
|
|
287
|
+
async fetchTemplateFromD1(templateId) {
|
|
288
|
+
const db = this.env.DB;
|
|
289
|
+
const tableName = `${this.emailTablePrefix}templates`;
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const template = await db
|
|
293
|
+
.prepare(`SELECT * FROM ${tableName} WHERE template_id = ? AND is_active = 1`)
|
|
294
|
+
.bind(templateId)
|
|
295
|
+
.first();
|
|
296
|
+
return template;
|
|
297
|
+
} catch (e) {
|
|
298
|
+
console.error(`[EmailingCacheDO] DB Error (${tableName}):`, e);
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Fetch settings from D1
|
|
305
|
+
* Only supports default table (e.g. system_settings) for now
|
|
306
|
+
*/
|
|
307
|
+
async fetchSettingsFromD1(key) {
|
|
308
|
+
const db = this.env.DB;
|
|
309
|
+
const tableName = this.settingsTableName;
|
|
310
|
+
const prefix = this.settingsKeyPrefix;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
let results;
|
|
314
|
+
|
|
315
|
+
if (prefix) {
|
|
316
|
+
// Filter by prefix (e.g. WHERE setting_key LIKE 'email_%')
|
|
317
|
+
// Note: We need to handle both schema variants (setting_key vs key)
|
|
318
|
+
// BUT standard D1 SQL doesn't support "OR" in column names easily with LIKE parameter binding
|
|
319
|
+
// for flexible schemas.
|
|
320
|
+
// We will assume 'setting_key' matches if table is system_settings, or try both.
|
|
321
|
+
|
|
322
|
+
// Construct query dynamically based on likely schema or just try standard first
|
|
323
|
+
try {
|
|
324
|
+
results = await db.prepare(`SELECT * FROM ${tableName} WHERE setting_key LIKE ?`).bind(`${prefix}%`).all();
|
|
325
|
+
} catch (e) {
|
|
326
|
+
// Fallback to 'key' column
|
|
327
|
+
results = await db.prepare(`SELECT * FROM ${tableName} WHERE key LIKE ?`).bind(`${prefix}%`).all();
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
// No prefix, fetch all
|
|
331
|
+
results = await db.prepare(`SELECT * FROM ${tableName}`).all();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!results.results || results.results.length === 0) return null;
|
|
335
|
+
|
|
336
|
+
const settings = {};
|
|
337
|
+
results.results.forEach(row => {
|
|
338
|
+
const k = row.setting_key || row.key;
|
|
339
|
+
const v = row.setting_value || row.value;
|
|
340
|
+
if (k) settings[k] = v;
|
|
341
|
+
});
|
|
342
|
+
return settings;
|
|
343
|
+
} catch (e) {
|
|
344
|
+
console.warn(`[EmailingCacheDO] Failed to load settings from ${tableName}:`, e);
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async saveSettingsToD1(settings) {
|
|
350
|
+
const db = this.env.DB;
|
|
351
|
+
const tableName = this.settingsTableName;
|
|
352
|
+
|
|
353
|
+
// Simple upset not easy in D1 without specific keys.
|
|
354
|
+
// We'll rely on calling code/migration.
|
|
355
|
+
// For now, let's just log or try to update if possible.
|
|
356
|
+
// Actually, writing settings is complex because we don't know the schema perfectly.
|
|
357
|
+
// Let's implement a BEST EFFORT update for x0start schema
|
|
358
|
+
|
|
359
|
+
const entries = Object.entries(settings);
|
|
360
|
+
for (const [k, v] of entries) {
|
|
361
|
+
try {
|
|
362
|
+
// Heuristic: Try to determine column names based on common conventions
|
|
363
|
+
// We'll try Schema 1 (setting_key/value) first as it's the default internal convention
|
|
364
|
+
// If that fails, we might fall back to Schema 2 (key/value) in a real implementation
|
|
365
|
+
// For now, let's try a best-effort upsert based on x0start schema or generic
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
const res = await db.prepare(`UPDATE ${tableName} SET setting_value = ? WHERE setting_key = ?`).bind(v, k).run();
|
|
369
|
+
if (res.meta.changes === 0) {
|
|
370
|
+
await db.prepare(`INSERT INTO ${tableName} (setting_key, setting_value) VALUES (?, ?)`).bind(k, v).run();
|
|
371
|
+
}
|
|
372
|
+
} catch (e) {
|
|
373
|
+
// Fallback to key/value
|
|
374
|
+
await db.prepare(`INSERT OR REPLACE INTO ${tableName} (key, value) VALUES (?, ?)`).bind(k, v).run();
|
|
375
|
+
}
|
|
376
|
+
} catch (e) {
|
|
377
|
+
console.warn('[EmailingCacheDO] Failed to save setting to D1:', k);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Create a cache provider wrapper for the DO
|
|
385
|
+
*/
|
|
386
|
+
export function createDOCacheProvider(doStub, instanceName = 'global') {
|
|
387
|
+
if (!doStub) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const stub = doStub.get(doStub.idFromName(instanceName));
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
async getTemplate(templateId) {
|
|
395
|
+
try {
|
|
396
|
+
const response = await stub.fetch(`http://do/get?templateId=${templateId}`);
|
|
397
|
+
const data = await response.json();
|
|
398
|
+
return data.template || null;
|
|
399
|
+
} catch (e) {
|
|
400
|
+
console.warn('[DOCacheProvider] Failed to get template:', e);
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
async getSettings(profile, tenantId) {
|
|
406
|
+
const key = `${profile}:${tenantId || ''}`;
|
|
407
|
+
try {
|
|
408
|
+
const response = await stub.fetch(`http://do/settings/get?key=${encodeURIComponent(key)}`);
|
|
409
|
+
const data = await response.json();
|
|
410
|
+
return data.settings || null;
|
|
411
|
+
} catch (e) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
async putSettings(profile, tenantId, settings) {
|
|
417
|
+
const key = `${profile}:${tenantId || ''}`;
|
|
418
|
+
try {
|
|
419
|
+
await stub.fetch('http://do/settings/put', {
|
|
420
|
+
method: 'POST',
|
|
421
|
+
headers: { 'Content-Type': 'application/json' },
|
|
422
|
+
body: JSON.stringify({ key, settings }),
|
|
423
|
+
});
|
|
424
|
+
} catch (e) {
|
|
425
|
+
console.warn('[DOCacheProvider] Failed to cache settings:', e);
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
async putTemplate(template) {
|
|
430
|
+
try {
|
|
431
|
+
await stub.fetch('http://do/invalidate', {
|
|
432
|
+
method: 'POST',
|
|
433
|
+
headers: { 'Content-Type': 'application/json' },
|
|
434
|
+
body: JSON.stringify({ templateId: template.template_id }),
|
|
435
|
+
});
|
|
436
|
+
} catch (e) {
|
|
437
|
+
console.warn('[DOCacheProvider] Failed to invalidate template:', e);
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
async deleteTemplate(templateId) {
|
|
442
|
+
try {
|
|
443
|
+
await stub.fetch('http://do/invalidate', {
|
|
444
|
+
method: 'POST',
|
|
445
|
+
headers: { 'Content-Type': 'application/json' },
|
|
446
|
+
body: JSON.stringify({ templateId }),
|
|
447
|
+
});
|
|
448
|
+
} catch (e) {
|
|
449
|
+
console.warn('[DOCacheProvider] Failed to invalidate template:', e);
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
async invalidateSettings(profile, tenantId) {
|
|
454
|
+
const key = `${profile}:${tenantId || ''}`;
|
|
455
|
+
try {
|
|
456
|
+
await stub.fetch('http://do/settings/invalidate', {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
headers: { 'Content-Type': 'application/json' },
|
|
459
|
+
body: JSON.stringify({ key }),
|
|
460
|
+
});
|
|
461
|
+
} catch (e) {
|
|
462
|
+
console.warn('[DOCacheProvider] Failed to invalidate settings:', e);
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|
|
@@ -17,13 +17,13 @@ const TRACKING_PIXEL = new Uint8Array([
|
|
|
17
17
|
* Create email tracking routes
|
|
18
18
|
* @param {Object} env - Environment bindings
|
|
19
19
|
* @param {Object} config - Configuration
|
|
20
|
-
* @param {string} [config.
|
|
20
|
+
* @param {string} [config.emailTablePrefix='system_email_'] - Prefix for D1 tables
|
|
21
21
|
* @returns {Hono} Hono router
|
|
22
22
|
*/
|
|
23
23
|
export function createTrackingRoutes(env, config = {}) {
|
|
24
24
|
const app = new Hono();
|
|
25
25
|
const db = env.DB;
|
|
26
|
-
const tablePrefix = config.tableNamePrefix || 'system_email_';
|
|
26
|
+
const tablePrefix = config.emailTablePrefix || config.tableNamePrefix || 'system_email_';
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* GET /track/open/:token
|
package/src/common/utils.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Common helpers for email processing
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import mustache from 'mustache';
|
|
7
|
+
|
|
6
8
|
// Module-level cache for website URL
|
|
7
9
|
let cachedWebsiteUrl = null;
|
|
8
10
|
|
|
@@ -100,18 +102,40 @@ export function markdownToPlainText(markdown) {
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
/**
|
|
103
|
-
* Extract variables from a template string
|
|
104
|
-
* @param {string}
|
|
105
|
-
* @returns {string[]} Array of variable names
|
|
105
|
+
* Extract variables from a Mustache template string
|
|
106
|
+
* @param {string} templateString - The mustache template string
|
|
107
|
+
* @returns {string[]} Array of unique variable names
|
|
106
108
|
*/
|
|
107
|
-
export function extractVariables(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
variables
|
|
109
|
+
export function extractVariables(templateString) {
|
|
110
|
+
if (!templateString) return [];
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// Parse the template to get tokens
|
|
114
|
+
const tokens = mustache.parse(templateString);
|
|
115
|
+
const variables = new Set();
|
|
116
|
+
|
|
117
|
+
// Recursively extract variables from tokens
|
|
118
|
+
const collectVariables = (tokenList) => {
|
|
119
|
+
tokenList.forEach(token => {
|
|
120
|
+
const type = token[0];
|
|
121
|
+
const value = token[1];
|
|
122
|
+
|
|
123
|
+
// Types: 'name' ({{var}}), '#' (section), '^' (inverted section), '&' (unescaped)
|
|
124
|
+
if (type === 'name' || type === '#' || type === '^' || type === '&') {
|
|
125
|
+
variables.add(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Variable is in token[4] for sections
|
|
129
|
+
if ((type === '#' || type === '^') && token[4]) {
|
|
130
|
+
collectVariables(token[4]);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
collectVariables(tokens);
|
|
136
|
+
return Array.from(variables);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error('Error parsing template variables:', error);
|
|
139
|
+
return [];
|
|
114
140
|
}
|
|
115
|
-
|
|
116
|
-
return Array.from(variables);
|
|
117
141
|
}
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Backend services
|
|
2
2
|
export { EmailService } from './backend/EmailService.js';
|
|
3
|
-
export {
|
|
3
|
+
export { EmailingCacheDO, createDOCacheProvider } from './backend/EmailingCacheDO.js';
|
|
4
4
|
|
|
5
5
|
// Routes
|
|
6
6
|
export {
|
|
@@ -1,363 +0,0 @@
|
|
|
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
|
-
}
|