@contentgrowth/content-emailing 0.4.1 → 0.5.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.
Files changed (46) hide show
  1. package/dist/backend/EmailService.cjs +728 -0
  2. package/dist/backend/EmailService.cjs.map +1 -0
  3. package/dist/backend/EmailService.js +694 -0
  4. package/dist/backend/EmailService.js.map +1 -0
  5. package/dist/backend/EmailingCacheDO.cjs +390 -0
  6. package/dist/backend/EmailingCacheDO.cjs.map +1 -0
  7. package/dist/backend/EmailingCacheDO.js +365 -0
  8. package/dist/backend/EmailingCacheDO.js.map +1 -0
  9. package/dist/backend/routes/index.cjs +992 -0
  10. package/dist/backend/routes/index.cjs.map +1 -0
  11. package/dist/backend/routes/index.js +956 -0
  12. package/dist/backend/routes/index.js.map +1 -0
  13. package/dist/cli.cjs +53 -0
  14. package/dist/cli.cjs.map +1 -0
  15. package/dist/cli.js +53 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/common/index.cjs +267 -0
  18. package/dist/common/index.cjs.map +1 -0
  19. package/{src/common/htmlWrapper.js → dist/common/index.js} +75 -18
  20. package/dist/common/index.js.map +1 -0
  21. package/dist/index.cjs +1537 -0
  22. package/dist/index.cjs.map +1 -0
  23. package/dist/index.js +1488 -0
  24. package/dist/index.js.map +1 -0
  25. package/package.json +27 -12
  26. package/examples/.env.example +0 -16
  27. package/examples/README.md +0 -55
  28. package/examples/mocks/MockD1.js +0 -311
  29. package/examples/mocks/MockEmailSender.js +0 -64
  30. package/examples/mocks/index.js +0 -5
  31. package/examples/package-lock.json +0 -73
  32. package/examples/package.json +0 -18
  33. package/examples/portal/index.html +0 -919
  34. package/examples/server.js +0 -314
  35. package/release.sh +0 -56
  36. package/src/backend/EmailService.js +0 -537
  37. package/src/backend/EmailingCacheDO.js +0 -466
  38. package/src/backend/routes/index.js +0 -30
  39. package/src/backend/routes/templates.js +0 -98
  40. package/src/backend/routes/tracking.js +0 -215
  41. package/src/backend/routes.js +0 -98
  42. package/src/common/index.js +0 -11
  43. package/src/common/utils.js +0 -141
  44. package/src/frontend/TemplateEditor.jsx +0 -117
  45. package/src/frontend/TemplateManager.jsx +0 -117
  46. package/src/index.js +0 -24
@@ -1,537 +0,0 @@
1
- /**
2
- * Email Service
3
- * Unified service for email delivery and template management.
4
- */
5
- import { marked } from 'marked';
6
- import Mustache from 'mustache';
7
- import { wrapInEmailTemplate } from '../common/htmlWrapper.js';
8
- import { createDOCacheProvider } from './EmailingCacheDO.js';
9
-
10
- export class EmailService {
11
- /**
12
- * @param {Object} env - Cloudflare environment bindings (DB, etc.)
13
- * @param {Object} config - Configuration options
14
- * @param {string} [config.emailTablePrefix='system_email_'] - Prefix for D1 tables
15
- * @param {Object} [config.defaults] - Default settings (fromName, fromAddress)
16
- * @param {Object} [cacheProvider] - Optional cache interface (DO stub or KV wrapper)
17
- */
18
- constructor(env, config = {}, cacheProvider = null) {
19
- this.env = env;
20
- this.db = env.DB;
21
- this.config = {
22
- emailTablePrefix: config.emailTablePrefix || config.tableNamePrefix || 'system_email_',
23
- defaults: config.defaults || {
24
- fromName: 'System',
25
- fromAddress: 'noreply@example.com',
26
- provider: 'mailchannels'
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
-
36
- // Branding configuration for email templates
37
- branding: {
38
- brandName: config.branding?.brandName || 'Your App',
39
- portalUrl: config.branding?.portalUrl || 'https://app.example.com',
40
- primaryColor: config.branding?.primaryColor || '#667eea',
41
- ...config.branding
42
- },
43
- ...config
44
- };
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
- }
53
- }
54
-
55
- // --- Configuration & Settings ---
56
-
57
- /**
58
- * Load email configuration
59
- * @param {string} profile - 'system' or 'tenant' (or custom profile string)
60
- * @param {string} tenantId - Context ID (optional)
61
- * @returns {Promise<Object>} Email configuration
62
- */
63
- async loadSettings(profile = 'system', tenantId = null) {
64
- // 1. Try cache (now with Read-Through logic in DO)
65
- if (this.cache && this.cache.getSettings) {
66
- try {
67
- const cached = await this.cache.getSettings(profile, tenantId);
68
- if (cached) return this._normalizeConfig(cached);
69
- } catch (e) {
70
- // Ignore cache/rpc errors
71
- }
72
- }
73
-
74
- let settings = null;
75
-
76
- // 2. Use settingsLoader (Custom Worker Logic) if provided
77
- if (this.config.settingsLoader) {
78
- try {
79
- settings = await this.config.settingsLoader(profile, tenantId);
80
- } catch (e) {
81
- console.warn('[EmailService] settingsLoader failed:', e);
82
- }
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
- }
96
- return this._normalizeConfig(settings);
97
- }
98
-
99
- // 4. Fallback to defaults
100
- return {
101
- ...this.config.defaults,
102
- };
103
- }
104
-
105
- /**
106
- * Normalize config keys to standard format
107
- * Handles both snake_case (DB) and camelCase inputs
108
- */
109
- _normalizeConfig(config) {
110
- return {
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,
133
- smtpSecure: config.smtp_secure === 'true',
134
- };
135
- }
136
-
137
- // --- Template Management ---
138
-
139
- async getTemplate(templateId) {
140
- // Try cache first
141
- if (this.cache) {
142
- try {
143
- const cached = await this.cache.getTemplate(templateId);
144
- if (cached) return cached;
145
- } catch (e) {
146
- console.warn('[EmailService] Template cache lookup failed:', e);
147
- }
148
- }
149
-
150
- const table = `${this.config.emailTablePrefix}templates`;
151
- return await this.db.prepare(`SELECT * FROM ${table} WHERE template_id = ?`)
152
- .bind(templateId)
153
- .first();
154
- }
155
-
156
- async getAllTemplates() {
157
- const table = `${this.config.emailTablePrefix}templates`;
158
- const result = await this.db.prepare(`SELECT * FROM ${table} ORDER BY template_name`).all();
159
- return result.results || [];
160
- }
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
-
197
- async saveTemplate(template, userId = 'system') {
198
- const table = `${this.config.emailTablePrefix}templates`;
199
- const now = Math.floor(Date.now() / 1000);
200
- const existing = await this.getTemplate(template.template_id);
201
-
202
- if (existing) {
203
- await this.db.prepare(`
204
- UPDATE ${table} SET
205
- template_name = ?, template_type = ?, subject_template = ?,
206
- body_markdown = ?, variables = ?, description = ?, is_active = ?,
207
- updated_at = ?, updated_by = ?
208
- WHERE template_id = ?
209
- `).bind(
210
- template.template_name, template.template_type, template.subject_template,
211
- template.body_markdown, template.variables, template.description,
212
- template.is_active, now, userId, template.template_id
213
- ).run();
214
- } else {
215
- await this.db.prepare(`
216
- INSERT INTO ${table} (
217
- template_id, template_name, template_type, subject_template,
218
- body_markdown, variables, description, is_active, created_at, updated_at, updated_by
219
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
220
- `).bind(
221
- template.template_id, template.template_name, template.template_type,
222
- template.subject_template, template.body_markdown, template.variables || '[]',
223
- template.description, template.is_active || 1, now, now, userId
224
- ).run();
225
- }
226
-
227
- // Invalidate cache if exists
228
- if (this.cache) {
229
- await this.cache.putTemplate(template);
230
- }
231
- }
232
-
233
- async deleteTemplate(templateId) {
234
- const table = `${this.config.emailTablePrefix}templates`;
235
- await this.db.prepare(`DELETE FROM ${table} WHERE template_id = ?`).bind(templateId).run();
236
- if (this.cache) {
237
- await this.cache.deleteTemplate(templateId);
238
- }
239
- }
240
-
241
- // --- Rendering ---
242
-
243
- async renderTemplate(templateId, data) {
244
- const template = await this.getTemplate(templateId);
245
- if (!template) throw new Error(`Template not found: ${templateId}`);
246
-
247
- // Render Subject
248
- const subject = Mustache.render(template.subject_template, data);
249
-
250
- // Render Body (Markdown -> HTML)
251
- const markdown = Mustache.render(template.body_markdown, data);
252
- marked.use({ mangle: false, headerIds: false });
253
- const htmlContent = marked.parse(markdown);
254
-
255
- // Wrap in Base Template (could be another template or hardcoded default)
256
- const html = this.wrapInBaseTemplate(htmlContent, subject, data);
257
- const plainText = markdown.replace(/<[^>]*>/g, ''); // Simple strip
258
-
259
- return { subject, html, plainText };
260
- }
261
-
262
- wrapInBaseTemplate(content, subject, data = {}) {
263
- // Merge branding config with template data
264
- const templateData = {
265
- ...data,
266
- brandName: data.brandName || this.config.branding.brandName,
267
- portalUrl: data.portalUrl || this.config.branding.portalUrl,
268
- unsubscribeUrl: data.unsubscribeUrl || '{{unsubscribe_url}}'
269
- };
270
-
271
- // Use the full branded HTML wrapper from common
272
- return wrapInEmailTemplate(content, subject, templateData);
273
- }
274
-
275
- // --- Delivery ---
276
-
277
- /**
278
- * Send a single email
279
- * @param {Object} params - Email parameters
280
- * @param {string} params.to - Recipient email
281
- * @param {string} params.subject - Email subject
282
- * @param {string} params.html - HTML body
283
- * @param {string} params.text - Plain text body
284
- * @param {string} [params.provider] - Override provider
285
- * @param {string} [params.profile='system'] - 'system' or 'tenant'
286
- * @param {string} [params.tenantId] - Required if profile is 'tenant'
287
- * @param {Object} [params.metadata] - Additional metadata
288
- * @returns {Promise<Object>} Delivery result
289
- */
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
-
295
- try {
296
- const settings = await this.loadSettings(profile, tenantId);
297
- const useProvider = provider || settings.provider || 'mailchannels';
298
-
299
- let result;
300
-
301
- switch (useProvider) {
302
- case 'mailchannels':
303
- result = await this.sendViaMailChannels(to, subject, htmlContent, textContent, settings, metadata);
304
- break;
305
- case 'sendgrid':
306
- result = await this.sendViaSendGrid(to, subject, htmlContent, textContent, settings, metadata);
307
- break;
308
- case 'resend':
309
- result = await this.sendViaResend(to, subject, htmlContent, textContent, settings, metadata);
310
- break;
311
- case 'sendpulse':
312
- result = await this.sendViaSendPulse(to, subject, htmlContent, textContent, settings, metadata);
313
- break;
314
- default:
315
- console.error(`[EmailService] Unknown provider: ${useProvider}`);
316
- return { success: false, error: `Unknown email provider: ${useProvider}` };
317
- }
318
-
319
- if (result) {
320
- return { success: true, messageId: crypto.randomUUID() };
321
- } else {
322
- console.error('[EmailService] Failed to send email to:', to);
323
- return { success: false, error: 'Failed to send email' };
324
- }
325
- } catch (error) {
326
- console.error('[EmailService] Error sending email:', error);
327
- return { success: false, error: error.message };
328
- }
329
- }
330
-
331
- /**
332
- * Send multiple emails in batch
333
- * @param {Array} emails - Array of email objects
334
- * @returns {Promise<Array>} Array of delivery results
335
- */
336
- async sendBatch(emails) {
337
- console.log('[EmailService] Sending batch of', emails.length, 'emails');
338
- const results = await Promise.all(
339
- emails.map(email => this.sendEmail(email))
340
- );
341
- return results;
342
- }
343
-
344
- /**
345
- * Send email via MailChannels HTTP API
346
- * MailChannels is specifically designed for Cloudflare Workers
347
- */
348
- async sendViaMailChannels(to, subject, html, text, settings, metadata) {
349
- try {
350
- const response = await fetch('https://api.mailchannels.net/tx/v1/send', {
351
- method: 'POST',
352
- headers: { 'Content-Type': 'application/json' },
353
- body: JSON.stringify({
354
- personalizations: [{ to: [{ email: to, name: metadata.recipientName || '' }] }],
355
- from: { email: settings.fromAddress, name: settings.fromName },
356
- subject,
357
- content: [
358
- { type: 'text/plain', value: text || html.replace(/<[^>]*>/g, '') },
359
- { type: 'text/html', value: html }
360
- ]
361
- })
362
- });
363
-
364
- if (response.status === 202) {
365
- return true;
366
- } else {
367
- const contentType = response.headers.get('content-type');
368
- const errorBody = contentType?.includes('application/json')
369
- ? await response.json()
370
- : await response.text();
371
- console.error('[EmailService] MailChannels error:', response.status, errorBody);
372
- return false;
373
- }
374
- } catch (error) {
375
- console.error('[EmailService] MailChannels exception:', error.message);
376
- return false;
377
- }
378
- }
379
-
380
- /**
381
- * Send email via SendGrid HTTP API
382
- */
383
- async sendViaSendGrid(to, subject, html, text, settings, metadata) {
384
- try {
385
- if (!settings.sendgridApiKey) {
386
- console.error('[EmailService] SendGrid API key missing');
387
- return false;
388
- }
389
-
390
- const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
391
- method: 'POST',
392
- headers: {
393
- 'Authorization': `Bearer ${settings.sendgridApiKey}`,
394
- 'Content-Type': 'application/json',
395
- },
396
- body: JSON.stringify({
397
- personalizations: [{ to: [{ email: to, name: metadata.recipientName || '' }] }],
398
- from: { email: settings.fromAddress, name: settings.fromName },
399
- subject,
400
- content: [
401
- { type: 'text/html', value: html },
402
- { type: 'text/plain', value: text || html.replace(/<[^>]*>/g, '') },
403
- ],
404
- }),
405
- });
406
-
407
- if (response.status === 202) {
408
- return true;
409
- } else {
410
- const errorText = await response.text();
411
- console.error('[EmailService] SendGrid error:', response.status, errorText);
412
- return false;
413
- }
414
- } catch (error) {
415
- console.error('[EmailService] SendGrid exception:', error.message);
416
- return false;
417
- }
418
- }
419
-
420
- /**
421
- * Send email via Resend HTTP API
422
- */
423
- async sendViaResend(to, subject, html, text, settings, metadata) {
424
- try {
425
- if (!settings.resendApiKey) {
426
- console.error('[EmailService] Resend API key missing');
427
- return false;
428
- }
429
-
430
- const response = await fetch('https://api.resend.com/emails', {
431
- method: 'POST',
432
- headers: {
433
- 'Authorization': `Bearer ${settings.resendApiKey}`,
434
- 'Content-Type': 'application/json',
435
- },
436
- body: JSON.stringify({
437
- from: `${settings.fromName} <${settings.fromAddress}>`,
438
- to: [to],
439
- subject,
440
- html,
441
- text: text || html.replace(/<[^>]*>/g, ''),
442
- }),
443
- });
444
-
445
- if (response.ok) {
446
- return true;
447
- } else {
448
- const errorText = await response.text();
449
- console.error('[EmailService] Resend error:', response.status, errorText);
450
- return false;
451
- }
452
- } catch (error) {
453
- console.error('[EmailService] Resend exception:', error.message);
454
- return false;
455
- }
456
- }
457
-
458
- /**
459
- * Send email via SendPulse HTTP API
460
- * SendPulse offers 15,000 free emails/month
461
- */
462
- async sendViaSendPulse(to, subject, html, text, settings, metadata) {
463
- try {
464
- if (!settings.sendpulseClientId || !settings.sendpulseClientSecret) {
465
- console.error('[EmailService] SendPulse credentials missing');
466
- return false;
467
- }
468
-
469
- // SendPulse uses OAuth2 token-based authentication
470
- const tokenParams = new URLSearchParams({
471
- grant_type: 'client_credentials',
472
- client_id: settings.sendpulseClientId,
473
- client_secret: settings.sendpulseClientSecret,
474
- });
475
-
476
- const tokenResponse = await fetch('https://api.sendpulse.com/oauth/access_token', {
477
- method: 'POST',
478
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
479
- body: tokenParams.toString(),
480
- });
481
-
482
- if (!tokenResponse.ok) {
483
- const error = await tokenResponse.text();
484
- console.error('[EmailService] SendPulse auth error:', error);
485
- return false;
486
- }
487
-
488
- const tokenData = await tokenResponse.json();
489
- if (!tokenData.access_token) {
490
- console.error('[EmailService] SendPulse: No access token in response');
491
- return false;
492
- }
493
-
494
- const { access_token } = tokenData;
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, '') : '');
505
-
506
- // Send the email
507
- const response = await fetch('https://api.sendpulse.com/smtp/emails', {
508
- method: 'POST',
509
- headers: {
510
- 'Authorization': `Bearer ${access_token}`,
511
- 'Content-Type': 'application/json',
512
- },
513
- body: JSON.stringify({
514
- email: {
515
- html: toBase64(htmlSafe),
516
- text: toBase64(textSafe),
517
- subject,
518
- from: { name: settings.fromName, email: settings.fromAddress },
519
- to: [{ name: metadata.recipientName || '', email: to }],
520
- },
521
- }),
522
- });
523
-
524
- if (response.ok) {
525
- return true;
526
- } else {
527
- const errorText = await response.text();
528
- console.error('[EmailService] SendPulse send error:', response.status, errorText);
529
- return false;
530
- }
531
- } catch (error) {
532
- console.error('[EmailService] SendPulse exception:', error.message);
533
- return false;
534
- }
535
- }
536
- }
537
-