@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.
- package/README.md +96 -0
- package/examples/.env.example +16 -0
- package/examples/README.md +55 -0
- package/examples/mocks/MockD1.js +311 -0
- package/examples/mocks/MockEmailSender.js +64 -0
- package/examples/mocks/index.js +5 -0
- package/examples/package-lock.json +73 -0
- package/examples/package.json +18 -0
- package/examples/portal/index.html +919 -0
- package/examples/server.js +314 -0
- package/package.json +32 -0
- package/release.sh +56 -0
- package/schema.sql +63 -0
- package/src/backend/EmailService.js +474 -0
- package/src/backend/EmailTemplateCacheDO.js +363 -0
- package/src/backend/routes/index.js +30 -0
- package/src/backend/routes/templates.js +98 -0
- package/src/backend/routes/tracking.js +215 -0
- package/src/backend/routes.js +98 -0
- package/src/common/htmlWrapper.js +169 -0
- package/src/common/index.js +11 -0
- package/src/common/utils.js +117 -0
- package/src/frontend/TemplateEditor.jsx +117 -0
- package/src/frontend/TemplateManager.jsx +117 -0
- package/src/index.js +24 -0
|
@@ -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
|
+
|