@contentgrowth/content-emailing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,474 @@
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
+
8
+ export class EmailService {
9
+ /**
10
+ * @param {Object} env - Cloudflare environment bindings (DB, etc.)
11
+ * @param {Object} config - Configuration options
12
+ * @param {string} [config.tableNamePrefix='system_email_'] - Prefix for D1 tables
13
+ * @param {Object} [config.defaults] - Default settings (fromName, fromAddress)
14
+ * @param {Object} [cacheProvider] - Optional cache interface (DO stub or KV wrapper)
15
+ */
16
+ constructor(env, config = {}, cacheProvider = null) {
17
+ this.env = env;
18
+ this.db = env.DB;
19
+ this.config = {
20
+ tableNamePrefix: config.tableNamePrefix || 'system_email_',
21
+ defaults: config.defaults || {
22
+ fromName: 'System',
23
+ fromAddress: 'noreply@example.com',
24
+ provider: 'mailchannels'
25
+ },
26
+ ...config
27
+ };
28
+ this.cache = cacheProvider;
29
+ }
30
+
31
+ // --- Configuration & Settings ---
32
+
33
+ /**
34
+ * Load email configuration
35
+ * @param {string} profile - 'system' or 'tenant'
36
+ * @param {string} tenantId - Required if profile is 'tenant'
37
+ * @returns {Promise<Object>} Email configuration
38
+ */
39
+ async loadSettings(profile = 'system', tenantId = null) {
40
+ // 1. Try cache if available (for system profile)
41
+ if (this.cache && profile === 'system') {
42
+ try {
43
+ const cached = await this.cache.getSettings();
44
+ if (cached) return this._normalizeConfig(cached);
45
+ } catch (e) {
46
+ console.warn('[EmailService] Cache lookup failed:', e);
47
+ }
48
+ }
49
+
50
+ // 2. Load from D1 based on profile
51
+ if (profile === 'system') {
52
+ try {
53
+ // Try to load from system_settings table
54
+ const settings = await this.db
55
+ .prepare(`SELECT setting_key, setting_value FROM system_settings`)
56
+ .all();
57
+
58
+ if (settings.results && settings.results.length > 0) {
59
+ const config = {};
60
+ settings.results.forEach((row) => {
61
+ config[row.setting_key] = row.setting_value;
62
+ });
63
+ return this._normalizeConfig(config);
64
+ }
65
+ } catch (e) {
66
+ console.warn('[EmailService] D1 settings lookup failed:', e);
67
+ }
68
+ } else if (profile === 'tenant' && tenantId) {
69
+ try {
70
+ const tenant = await this.db
71
+ .prepare(`SELECT settings FROM tenants WHERE tenant_id = ?`)
72
+ .bind(tenantId)
73
+ .first();
74
+
75
+ if (tenant?.settings) {
76
+ const settings = JSON.parse(tenant.settings);
77
+ const emailConfig = settings.email || {};
78
+ return {
79
+ provider: emailConfig.provider || this.config.defaults.provider,
80
+ fromAddress: emailConfig.fromAddress || this.config.defaults.fromAddress,
81
+ fromName: emailConfig.fromName || this.config.defaults.fromName,
82
+ ...emailConfig
83
+ };
84
+ }
85
+ } catch (e) {
86
+ console.warn('[EmailService] Tenant settings lookup failed:', e);
87
+ }
88
+ }
89
+
90
+ // 3. Fallback to defaults
91
+ return {
92
+ ...this.config.defaults,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Normalize config keys from D1 format
98
+ */
99
+ _normalizeConfig(config) {
100
+ return {
101
+ provider: config.email_provider || this.config.defaults.provider,
102
+ fromAddress: config.email_from_address || this.config.defaults.fromAddress,
103
+ fromName: config.email_from_name || this.config.defaults.fromName,
104
+ // Provider-specific settings
105
+ sendgridApiKey: config.sendgrid_api_key,
106
+ resendApiKey: config.resend_api_key,
107
+ sendpulseClientId: config.sendpulse_client_id,
108
+ sendpulseClientSecret: config.sendpulse_client_secret,
109
+ smtpHost: config.smtp_host,
110
+ smtpPort: config.smtp_port,
111
+ smtpUsername: config.smtp_username,
112
+ smtpPassword: config.smtp_password,
113
+ smtpSecure: config.smtp_secure === 'true',
114
+ };
115
+ }
116
+
117
+ // --- Template Management ---
118
+
119
+ async getTemplate(templateId) {
120
+ // Try cache first
121
+ if (this.cache) {
122
+ try {
123
+ const cached = await this.cache.getTemplate(templateId);
124
+ if (cached) return cached;
125
+ } catch (e) {
126
+ console.warn('[EmailService] Template cache lookup failed:', e);
127
+ }
128
+ }
129
+
130
+ const table = `${this.config.tableNamePrefix}templates`;
131
+ return await this.db.prepare(`SELECT * FROM ${table} WHERE template_id = ?`)
132
+ .bind(templateId)
133
+ .first();
134
+ }
135
+
136
+ async getAllTemplates() {
137
+ const table = `${this.config.tableNamePrefix}templates`;
138
+ const result = await this.db.prepare(`SELECT * FROM ${table} ORDER BY template_name`).all();
139
+ return result.results || [];
140
+ }
141
+
142
+ async saveTemplate(template, userId = 'system') {
143
+ const table = `${this.config.tableNamePrefix}templates`;
144
+ const now = Math.floor(Date.now() / 1000);
145
+ const existing = await this.getTemplate(template.template_id);
146
+
147
+ if (existing) {
148
+ await this.db.prepare(`
149
+ UPDATE ${table} SET
150
+ template_name = ?, template_type = ?, subject_template = ?,
151
+ body_markdown = ?, variables = ?, description = ?, is_active = ?,
152
+ updated_at = ?, updated_by = ?
153
+ WHERE template_id = ?
154
+ `).bind(
155
+ template.template_name, template.template_type, template.subject_template,
156
+ template.body_markdown, template.variables, template.description,
157
+ template.is_active, now, userId, template.template_id
158
+ ).run();
159
+ } else {
160
+ await this.db.prepare(`
161
+ INSERT INTO ${table} (
162
+ template_id, template_name, template_type, subject_template,
163
+ body_markdown, variables, description, is_active, created_at, updated_at, updated_by
164
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
165
+ `).bind(
166
+ template.template_id, template.template_name, template.template_type,
167
+ template.subject_template, template.body_markdown, template.variables || '[]',
168
+ template.description, template.is_active || 1, now, now, userId
169
+ ).run();
170
+ }
171
+
172
+ // Invalidate cache if exists
173
+ if (this.cache) {
174
+ await this.cache.putTemplate(template);
175
+ }
176
+ }
177
+
178
+ async deleteTemplate(templateId) {
179
+ const table = `${this.config.tableNamePrefix}templates`;
180
+ await this.db.prepare(`DELETE FROM ${table} WHERE template_id = ?`).bind(templateId).run();
181
+ if (this.cache) {
182
+ await this.cache.deleteTemplate(templateId);
183
+ }
184
+ }
185
+
186
+ // --- Rendering ---
187
+
188
+ async renderTemplate(templateId, data) {
189
+ const template = await this.getTemplate(templateId);
190
+ if (!template) throw new Error(`Template not found: ${templateId}`);
191
+
192
+ // Render Subject
193
+ const subject = Mustache.render(template.subject_template, data);
194
+
195
+ // Render Body (Markdown -> HTML)
196
+ const markdown = Mustache.render(template.body_markdown, data);
197
+ marked.use({ mangle: false, headerIds: false });
198
+ const htmlContent = marked.parse(markdown);
199
+
200
+ // Wrap in Base Template (could be another template or hardcoded default)
201
+ const html = this.wrapInBaseTemplate(htmlContent, subject, data);
202
+ const plainText = markdown.replace(/<[^>]*>/g, ''); // Simple strip
203
+
204
+ return { subject, html, plainText };
205
+ }
206
+
207
+ wrapInBaseTemplate(content, subject, data) {
208
+ // Simple default wrapper
209
+ // In a real usage, this might load a 'base' template from DB
210
+ return `
211
+ <!DOCTYPE html>
212
+ <html>
213
+ <head><title>${subject}</title></head>
214
+ <body style="font-family: sans-serif; line-height: 1.6; color: #333;">
215
+ <div style="max-width: 600px; margin: 0 auto; padding: 20px;">
216
+ ${content}
217
+ <div style="margin-top: 20px; font-size: 12px; color: #999;">
218
+ <p>Sent via X0 Start</p>
219
+ </div>
220
+ </div>
221
+ </body>
222
+ </html>`;
223
+ }
224
+
225
+ // --- Delivery ---
226
+
227
+ /**
228
+ * Send a single email
229
+ * @param {Object} params - Email parameters
230
+ * @param {string} params.to - Recipient email
231
+ * @param {string} params.subject - Email subject
232
+ * @param {string} params.html - HTML body
233
+ * @param {string} params.text - Plain text body
234
+ * @param {string} [params.provider] - Override provider
235
+ * @param {string} [params.profile='system'] - 'system' or 'tenant'
236
+ * @param {string} [params.tenantId] - Required if profile is 'tenant'
237
+ * @param {Object} [params.metadata] - Additional metadata
238
+ * @returns {Promise<Object>} Delivery result
239
+ */
240
+ async sendEmail({ to, subject, html, text, provider, profile = 'system', tenantId = null, metadata = {} }) {
241
+ try {
242
+ const settings = await this.loadSettings(profile, tenantId);
243
+ const useProvider = provider || settings.provider || 'mailchannels';
244
+
245
+ let result;
246
+
247
+ switch (useProvider) {
248
+ case 'mailchannels':
249
+ result = await this.sendViaMailChannels(to, subject, html, text, settings, metadata);
250
+ break;
251
+ case 'sendgrid':
252
+ result = await this.sendViaSendGrid(to, subject, html, text, settings, metadata);
253
+ break;
254
+ case 'resend':
255
+ result = await this.sendViaResend(to, subject, html, text, settings, metadata);
256
+ break;
257
+ case 'sendpulse':
258
+ result = await this.sendViaSendPulse(to, subject, html, text, settings, metadata);
259
+ break;
260
+ default:
261
+ console.error(`[EmailService] Unknown provider: ${useProvider}`);
262
+ return { success: false, error: `Unknown email provider: ${useProvider}` };
263
+ }
264
+
265
+ if (result) {
266
+ return { success: true, messageId: crypto.randomUUID() };
267
+ } else {
268
+ console.error('[EmailService] Failed to send email to:', to);
269
+ return { success: false, error: 'Failed to send email' };
270
+ }
271
+ } catch (error) {
272
+ console.error('[EmailService] Error sending email:', error);
273
+ return { success: false, error: error.message };
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Send multiple emails in batch
279
+ * @param {Array} emails - Array of email objects
280
+ * @returns {Promise<Array>} Array of delivery results
281
+ */
282
+ async sendBatch(emails) {
283
+ console.log('[EmailService] Sending batch of', emails.length, 'emails');
284
+ const results = await Promise.all(
285
+ emails.map(email => this.sendEmail(email))
286
+ );
287
+ return results;
288
+ }
289
+
290
+ /**
291
+ * Send email via MailChannels HTTP API
292
+ * MailChannels is specifically designed for Cloudflare Workers
293
+ */
294
+ async sendViaMailChannels(to, subject, html, text, settings, metadata) {
295
+ try {
296
+ const response = await fetch('https://api.mailchannels.net/tx/v1/send', {
297
+ method: 'POST',
298
+ headers: { 'Content-Type': 'application/json' },
299
+ body: JSON.stringify({
300
+ personalizations: [{ to: [{ email: to, name: metadata.recipientName || '' }] }],
301
+ from: { email: settings.fromAddress, name: settings.fromName },
302
+ subject,
303
+ content: [
304
+ { type: 'text/plain', value: text || html.replace(/<[^>]*>/g, '') },
305
+ { type: 'text/html', value: html }
306
+ ]
307
+ })
308
+ });
309
+
310
+ if (response.status === 202) {
311
+ return true;
312
+ } else {
313
+ const contentType = response.headers.get('content-type');
314
+ const errorBody = contentType?.includes('application/json')
315
+ ? await response.json()
316
+ : await response.text();
317
+ console.error('[EmailService] MailChannels error:', response.status, errorBody);
318
+ return false;
319
+ }
320
+ } catch (error) {
321
+ console.error('[EmailService] MailChannels exception:', error.message);
322
+ return false;
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Send email via SendGrid HTTP API
328
+ */
329
+ async sendViaSendGrid(to, subject, html, text, settings, metadata) {
330
+ try {
331
+ if (!settings.sendgridApiKey) {
332
+ console.error('[EmailService] SendGrid API key missing');
333
+ return false;
334
+ }
335
+
336
+ const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
337
+ method: 'POST',
338
+ headers: {
339
+ 'Authorization': `Bearer ${settings.sendgridApiKey}`,
340
+ 'Content-Type': 'application/json',
341
+ },
342
+ body: JSON.stringify({
343
+ personalizations: [{ to: [{ email: to, name: metadata.recipientName || '' }] }],
344
+ from: { email: settings.fromAddress, name: settings.fromName },
345
+ subject,
346
+ content: [
347
+ { type: 'text/html', value: html },
348
+ { type: 'text/plain', value: text || html.replace(/<[^>]*>/g, '') },
349
+ ],
350
+ }),
351
+ });
352
+
353
+ if (response.status === 202) {
354
+ return true;
355
+ } else {
356
+ const errorText = await response.text();
357
+ console.error('[EmailService] SendGrid error:', response.status, errorText);
358
+ return false;
359
+ }
360
+ } catch (error) {
361
+ console.error('[EmailService] SendGrid exception:', error.message);
362
+ return false;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Send email via Resend HTTP API
368
+ */
369
+ async sendViaResend(to, subject, html, text, settings, metadata) {
370
+ try {
371
+ if (!settings.resendApiKey) {
372
+ console.error('[EmailService] Resend API key missing');
373
+ return false;
374
+ }
375
+
376
+ const response = await fetch('https://api.resend.com/emails', {
377
+ method: 'POST',
378
+ headers: {
379
+ 'Authorization': `Bearer ${settings.resendApiKey}`,
380
+ 'Content-Type': 'application/json',
381
+ },
382
+ body: JSON.stringify({
383
+ from: `${settings.fromName} <${settings.fromAddress}>`,
384
+ to: [to],
385
+ subject,
386
+ html,
387
+ text: text || html.replace(/<[^>]*>/g, ''),
388
+ }),
389
+ });
390
+
391
+ if (response.ok) {
392
+ return true;
393
+ } else {
394
+ const errorText = await response.text();
395
+ console.error('[EmailService] Resend error:', response.status, errorText);
396
+ return false;
397
+ }
398
+ } catch (error) {
399
+ console.error('[EmailService] Resend exception:', error.message);
400
+ return false;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Send email via SendPulse HTTP API
406
+ * SendPulse offers 15,000 free emails/month
407
+ */
408
+ async sendViaSendPulse(to, subject, html, text, settings, metadata) {
409
+ try {
410
+ if (!settings.sendpulseClientId || !settings.sendpulseClientSecret) {
411
+ console.error('[EmailService] SendPulse credentials missing');
412
+ return false;
413
+ }
414
+
415
+ // SendPulse uses OAuth2 token-based authentication
416
+ const tokenParams = new URLSearchParams({
417
+ grant_type: 'client_credentials',
418
+ client_id: settings.sendpulseClientId,
419
+ client_secret: settings.sendpulseClientSecret,
420
+ });
421
+
422
+ const tokenResponse = await fetch('https://api.sendpulse.com/oauth/access_token', {
423
+ method: 'POST',
424
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
425
+ body: tokenParams.toString(),
426
+ });
427
+
428
+ if (!tokenResponse.ok) {
429
+ const error = await tokenResponse.text();
430
+ console.error('[EmailService] SendPulse auth error:', error);
431
+ return false;
432
+ }
433
+
434
+ const tokenData = await tokenResponse.json();
435
+ if (!tokenData.access_token) {
436
+ console.error('[EmailService] SendPulse: No access token in response');
437
+ return false;
438
+ }
439
+
440
+ const { access_token } = tokenData;
441
+ const toBase64 = (str) => Buffer.from(str).toString('base64');
442
+
443
+ // Send the email
444
+ const response = await fetch('https://api.sendpulse.com/smtp/emails', {
445
+ method: 'POST',
446
+ headers: {
447
+ 'Authorization': `Bearer ${access_token}`,
448
+ 'Content-Type': 'application/json',
449
+ },
450
+ body: JSON.stringify({
451
+ email: {
452
+ html: toBase64(html),
453
+ text: toBase64(text || html.replace(/<[^>]*>/g, '')),
454
+ subject,
455
+ from: { name: settings.fromName, email: settings.fromAddress },
456
+ to: [{ name: metadata.recipientName || '', email: to }],
457
+ },
458
+ }),
459
+ });
460
+
461
+ if (response.ok) {
462
+ return true;
463
+ } else {
464
+ const errorText = await response.text();
465
+ console.error('[EmailService] SendPulse send error:', response.status, errorText);
466
+ return false;
467
+ }
468
+ } catch (error) {
469
+ console.error('[EmailService] SendPulse exception:', error.message);
470
+ return false;
471
+ }
472
+ }
473
+ }
474
+