@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.
- package/dist/backend/EmailService.cjs +728 -0
- package/dist/backend/EmailService.cjs.map +1 -0
- package/dist/backend/EmailService.js +694 -0
- package/dist/backend/EmailService.js.map +1 -0
- package/dist/backend/EmailingCacheDO.cjs +390 -0
- package/dist/backend/EmailingCacheDO.cjs.map +1 -0
- package/dist/backend/EmailingCacheDO.js +365 -0
- package/dist/backend/EmailingCacheDO.js.map +1 -0
- package/dist/backend/routes/index.cjs +992 -0
- package/dist/backend/routes/index.cjs.map +1 -0
- package/dist/backend/routes/index.js +956 -0
- package/dist/backend/routes/index.js.map +1 -0
- package/dist/cli.cjs +53 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.js +53 -0
- package/dist/cli.js.map +1 -0
- package/dist/common/index.cjs +267 -0
- package/dist/common/index.cjs.map +1 -0
- package/{src/common/htmlWrapper.js → dist/common/index.js} +75 -18
- package/dist/common/index.js.map +1 -0
- package/dist/index.cjs +1537 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +1488 -0
- package/dist/index.js.map +1 -0
- package/package.json +27 -12
- package/examples/.env.example +0 -16
- package/examples/README.md +0 -55
- package/examples/mocks/MockD1.js +0 -311
- package/examples/mocks/MockEmailSender.js +0 -64
- package/examples/mocks/index.js +0 -5
- package/examples/package-lock.json +0 -73
- package/examples/package.json +0 -18
- package/examples/portal/index.html +0 -919
- package/examples/server.js +0 -314
- package/release.sh +0 -56
- package/src/backend/EmailService.js +0 -537
- package/src/backend/EmailingCacheDO.js +0 -466
- package/src/backend/routes/index.js +0 -30
- package/src/backend/routes/templates.js +0 -98
- package/src/backend/routes/tracking.js +0 -215
- package/src/backend/routes.js +0 -98
- package/src/common/index.js +0 -11
- package/src/common/utils.js +0 -141
- package/src/frontend/TemplateEditor.jsx +0 -117
- package/src/frontend/TemplateManager.jsx +0 -117
- 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
|
-
|