@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 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. Database Schema
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
- ### 3. Add API Routes
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.2.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.tableNamePrefix='system_email_'] - Prefix for D1 tables
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
- tableNamePrefix: config.tableNamePrefix || 'system_email_',
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
- this.cache = cacheProvider;
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 - Required if profile is 'tenant'
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 if available (for system profile)
49
- if (this.cache && profile === 'system') {
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
- console.warn('[EmailService] Cache lookup failed:', e);
70
+ // Ignore cache/rpc errors
55
71
  }
56
72
  }
57
73
 
58
- // 2. Load from D1 based on profile
59
- if (profile === 'system') {
74
+ let settings = null;
75
+
76
+ // 2. Use settingsLoader (Custom Worker Logic) if provided
77
+ if (this.config.settingsLoader) {
60
78
  try {
61
- // Try to load from system_settings table
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] D1 settings lookup failed:', e);
81
+ console.warn('[EmailService] settingsLoader failed:', e);
75
82
  }
76
- } else if (profile === 'tenant' && tenantId) {
77
- try {
78
- const tenant = await this.db
79
- .prepare(`SELECT settings FROM tenants WHERE tenant_id = ?`)
80
- .bind(tenantId)
81
- .first();
82
-
83
- if (tenant?.settings) {
84
- const settings = JSON.parse(tenant.settings);
85
- const emailConfig = settings.email || {};
86
- return {
87
- provider: emailConfig.provider || this.config.defaults.provider,
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
- // 3. Fallback to defaults
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 from D1 format
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
- // Provider-specific settings
113
- sendgridApiKey: config.sendgrid_api_key,
114
- resendApiKey: config.resend_api_key,
115
- sendpulseClientId: config.sendpulse_client_id,
116
- sendpulseClientSecret: config.sendpulse_client_secret,
117
- smtpHost: config.smtp_host,
118
- smtpPort: config.smtp_port,
119
- smtpUsername: config.smtp_username,
120
- smtpPassword: config.smtp_password,
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.tableNamePrefix}templates`;
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.tableNamePrefix}templates`;
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.tableNamePrefix}templates`;
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.tableNamePrefix}templates`;
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, html, text, settings, metadata);
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, html, text, settings, metadata);
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, html, text, settings, metadata);
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, html, text, settings, metadata);
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
- const toBase64 = (str) => Buffer.from(str).toString('base64');
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(html),
456
- text: toBase64(text || html.replace(/<[^>]*>/g, '')),
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.tableNamePrefix='system_email_'] - Prefix for D1 tables
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
@@ -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} template - Template with {{variable}} placeholders
105
- * @returns {string[]} Array of variable names found
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(template) {
108
- const regex = /\{\{([^}]+)\}\}/g;
109
- const variables = new Set();
110
- let match;
111
-
112
- while ((match = regex.exec(template)) !== null) {
113
- variables.add(match[1].trim());
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 { EmailTemplateCacheDO, createDOCacheProvider } from './backend/EmailTemplateCacheDO.js';
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
- }