@bernierllc/email-service 2.0.1
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 +578 -0
- package/dist/email-service.d.ts +39 -0
- package/dist/email-service.js +531 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +9 -0
- package/dist/types.d.ts +219 -0
- package/dist/types.js +55 -0
- package/package.json +79 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
import { DatabaseManager } from '@bernierllc/database-adapter';
|
|
9
|
+
import { QueueManager, JobPriority } from '@bernierllc/queue-manager';
|
|
10
|
+
import { TemplateEngine } from '@bernierllc/template-engine';
|
|
11
|
+
import { EmailSender } from '@bernierllc/email-sender';
|
|
12
|
+
import { Logger, LogLevel } from '@bernierllc/logger';
|
|
13
|
+
import { EmailServiceError, ProviderError, TemplateError, DeliveryError, DeliveryStatus } from './types.js';
|
|
14
|
+
export class EmailService {
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.db = new DatabaseManager(config.database);
|
|
18
|
+
this.logger = new Logger({ level: LogLevel.INFO, transports: [], context: { service: 'email-service' } });
|
|
19
|
+
this.templateEngine = new TemplateEngine({});
|
|
20
|
+
this.emailSenders = new Map();
|
|
21
|
+
this.webhookHandlers = new Map();
|
|
22
|
+
this.templateCache = new Map();
|
|
23
|
+
// Initialize providers
|
|
24
|
+
this.initializeProviders(config.providers);
|
|
25
|
+
// Initialize queue if configured
|
|
26
|
+
if (config.queue) {
|
|
27
|
+
this.queue = new QueueManager('email-service', config.queue);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
initializeProviders(providers) {
|
|
31
|
+
for (const providerConfig of providers) {
|
|
32
|
+
try {
|
|
33
|
+
const sender = new EmailSender({
|
|
34
|
+
provider: providerConfig.type,
|
|
35
|
+
apiKey: providerConfig.apiKey,
|
|
36
|
+
domain: providerConfig.domain,
|
|
37
|
+
region: providerConfig.region,
|
|
38
|
+
fromEmail: 'noreply@example.com',
|
|
39
|
+
host: providerConfig.smtp?.host,
|
|
40
|
+
port: providerConfig.smtp?.port,
|
|
41
|
+
secure: providerConfig.smtp?.secure,
|
|
42
|
+
username: providerConfig.smtp?.auth.user,
|
|
43
|
+
password: providerConfig.smtp?.auth.pass
|
|
44
|
+
});
|
|
45
|
+
this.emailSenders.set(providerConfig.type, sender);
|
|
46
|
+
this.logger.info(`Initialized ${providerConfig.type} provider`);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
50
|
+
this.logger.error(`Failed to initialize ${providerConfig.type} provider`, err);
|
|
51
|
+
throw new ProviderError(`Failed to initialize ${providerConfig.type}`, providerConfig.type, err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async initialize() {
|
|
56
|
+
try {
|
|
57
|
+
await this.db.connect();
|
|
58
|
+
await this.createTables();
|
|
59
|
+
this.logger.info('Email service initialized successfully');
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
63
|
+
this.logger.error('Failed to initialize email service', err);
|
|
64
|
+
throw new EmailServiceError('Initialization failed', 'INIT_ERROR', undefined, err);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async createTables() {
|
|
68
|
+
// Templates table
|
|
69
|
+
await this.db.execute(`
|
|
70
|
+
CREATE TABLE IF NOT EXISTS email_templates (
|
|
71
|
+
id TEXT PRIMARY KEY,
|
|
72
|
+
name TEXT UNIQUE NOT NULL,
|
|
73
|
+
subject TEXT NOT NULL,
|
|
74
|
+
html TEXT NOT NULL,
|
|
75
|
+
text TEXT,
|
|
76
|
+
variables TEXT NOT NULL,
|
|
77
|
+
version INTEGER DEFAULT 1,
|
|
78
|
+
created_at TEXT NOT NULL,
|
|
79
|
+
updated_at TEXT NOT NULL
|
|
80
|
+
)
|
|
81
|
+
`);
|
|
82
|
+
// Delivery records table
|
|
83
|
+
await this.db.execute(`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS email_deliveries (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
message_id TEXT NOT NULL,
|
|
87
|
+
to_address TEXT NOT NULL,
|
|
88
|
+
from_address TEXT NOT NULL,
|
|
89
|
+
subject TEXT NOT NULL,
|
|
90
|
+
provider TEXT NOT NULL,
|
|
91
|
+
status TEXT NOT NULL,
|
|
92
|
+
template_id TEXT,
|
|
93
|
+
metadata TEXT,
|
|
94
|
+
sent_at TEXT,
|
|
95
|
+
delivered_at TEXT,
|
|
96
|
+
opened_at TEXT,
|
|
97
|
+
clicked_at TEXT,
|
|
98
|
+
bounced_at TEXT,
|
|
99
|
+
error TEXT,
|
|
100
|
+
created_at TEXT NOT NULL,
|
|
101
|
+
updated_at TEXT NOT NULL
|
|
102
|
+
)
|
|
103
|
+
`);
|
|
104
|
+
// Subscribers table
|
|
105
|
+
await this.db.execute(`
|
|
106
|
+
CREATE TABLE IF NOT EXISTS subscribers (
|
|
107
|
+
id TEXT PRIMARY KEY,
|
|
108
|
+
email TEXT UNIQUE NOT NULL,
|
|
109
|
+
name TEXT,
|
|
110
|
+
status TEXT NOT NULL,
|
|
111
|
+
lists TEXT NOT NULL,
|
|
112
|
+
tags TEXT,
|
|
113
|
+
metadata TEXT,
|
|
114
|
+
subscribed_at TEXT NOT NULL,
|
|
115
|
+
unsubscribed_at TEXT,
|
|
116
|
+
created_at TEXT NOT NULL,
|
|
117
|
+
updated_at TEXT NOT NULL
|
|
118
|
+
)
|
|
119
|
+
`);
|
|
120
|
+
// Subscriber lists table
|
|
121
|
+
await this.db.execute(`
|
|
122
|
+
CREATE TABLE IF NOT EXISTS subscriber_lists (
|
|
123
|
+
id TEXT PRIMARY KEY,
|
|
124
|
+
name TEXT UNIQUE NOT NULL,
|
|
125
|
+
description TEXT,
|
|
126
|
+
subscriber_count INTEGER DEFAULT 0,
|
|
127
|
+
created_at TEXT NOT NULL,
|
|
128
|
+
updated_at TEXT NOT NULL
|
|
129
|
+
)
|
|
130
|
+
`);
|
|
131
|
+
this.logger.info('Database tables created successfully');
|
|
132
|
+
}
|
|
133
|
+
// ===== Email Sending =====
|
|
134
|
+
async send(request) {
|
|
135
|
+
try {
|
|
136
|
+
// Handle scheduled emails
|
|
137
|
+
if (request.scheduledAt && request.scheduledAt > new Date()) {
|
|
138
|
+
return await this.scheduleEmail(request);
|
|
139
|
+
}
|
|
140
|
+
// Process template if specified
|
|
141
|
+
let html = request.html;
|
|
142
|
+
let text = request.text;
|
|
143
|
+
let subject = request.subject;
|
|
144
|
+
if (request.templateId) {
|
|
145
|
+
const rendered = await this.renderTemplate(request.templateId, request.templateData || {});
|
|
146
|
+
html = rendered.html;
|
|
147
|
+
text = rendered.text;
|
|
148
|
+
subject = rendered.subject;
|
|
149
|
+
}
|
|
150
|
+
// Select provider
|
|
151
|
+
const provider = request.provider || this.config.defaultProvider || this.config.providers[0].type;
|
|
152
|
+
const sender = this.emailSenders.get(provider);
|
|
153
|
+
if (!sender) {
|
|
154
|
+
throw new ProviderError(`Provider ${provider} not configured`, provider);
|
|
155
|
+
}
|
|
156
|
+
// Create delivery record
|
|
157
|
+
const deliveryId = this.generateId();
|
|
158
|
+
const deliveryRecord = {
|
|
159
|
+
id: deliveryId,
|
|
160
|
+
to: Array.isArray(request.to) ? request.to[0].toString() : request.to,
|
|
161
|
+
from: typeof request.from === 'string' ? request.from : request.from.email,
|
|
162
|
+
subject,
|
|
163
|
+
provider,
|
|
164
|
+
status: DeliveryStatus.SENDING,
|
|
165
|
+
templateId: request.templateId,
|
|
166
|
+
metadata: request.metadata,
|
|
167
|
+
createdAt: new Date(),
|
|
168
|
+
updatedAt: new Date()
|
|
169
|
+
};
|
|
170
|
+
await this.db.insert('email_deliveries', deliveryRecord);
|
|
171
|
+
// Send email
|
|
172
|
+
const toEmail = Array.isArray(request.to)
|
|
173
|
+
? (typeof request.to[0] === 'string' ? request.to[0] : request.to[0].email)
|
|
174
|
+
: request.to;
|
|
175
|
+
const fromEmail = typeof request.from === 'string' ? request.from : request.from.email;
|
|
176
|
+
const fromName = typeof request.from === 'string' ? undefined : request.from.name;
|
|
177
|
+
const emailMessage = {
|
|
178
|
+
toEmail,
|
|
179
|
+
fromEmail,
|
|
180
|
+
fromName,
|
|
181
|
+
subject,
|
|
182
|
+
htmlContent: html,
|
|
183
|
+
textContent: text,
|
|
184
|
+
headers: request.headers,
|
|
185
|
+
metadata: request.metadata
|
|
186
|
+
};
|
|
187
|
+
const result = await sender.sendEmail(emailMessage);
|
|
188
|
+
// Update delivery record
|
|
189
|
+
await this.db.update('email_deliveries', deliveryId, {
|
|
190
|
+
messageId: result.messageId,
|
|
191
|
+
status: result.success ? DeliveryStatus.SENT : DeliveryStatus.FAILED,
|
|
192
|
+
sentAt: result.success ? new Date() : undefined,
|
|
193
|
+
error: result.errorMessage,
|
|
194
|
+
updatedAt: new Date()
|
|
195
|
+
});
|
|
196
|
+
this.logger.info(`Email sent via ${provider}`, { deliveryId, messageId: result.messageId });
|
|
197
|
+
return {
|
|
198
|
+
success: result.success,
|
|
199
|
+
messageId: result.messageId,
|
|
200
|
+
deliveryId,
|
|
201
|
+
provider,
|
|
202
|
+
error: result.errorMessage,
|
|
203
|
+
timestamp: new Date()
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
208
|
+
this.logger.error('Failed to send email', err);
|
|
209
|
+
throw new DeliveryError(err.message, request.provider, err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async scheduleEmail(request) {
|
|
213
|
+
if (!this.queue) {
|
|
214
|
+
throw new EmailServiceError('Queue not configured for scheduled emails', 'QUEUE_NOT_CONFIGURED');
|
|
215
|
+
}
|
|
216
|
+
const delay = request.scheduledAt.getTime() - Date.now();
|
|
217
|
+
const priority = request.priority === 'high' ? JobPriority.HIGH :
|
|
218
|
+
request.priority === 'low' ? JobPriority.LOW :
|
|
219
|
+
JobPriority.NORMAL;
|
|
220
|
+
await this.queue.add('send-email', request, {
|
|
221
|
+
priority,
|
|
222
|
+
delay
|
|
223
|
+
});
|
|
224
|
+
this.logger.info('Email scheduled for delivery', { scheduledAt: request.scheduledAt });
|
|
225
|
+
return {
|
|
226
|
+
success: true,
|
|
227
|
+
queuedForLater: true,
|
|
228
|
+
timestamp: new Date()
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
async sendBulk(requests) {
|
|
232
|
+
const results = [];
|
|
233
|
+
for (const request of requests) {
|
|
234
|
+
try {
|
|
235
|
+
const result = await this.send(request);
|
|
236
|
+
results.push(result);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
results.push({
|
|
240
|
+
success: false,
|
|
241
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
242
|
+
timestamp: new Date()
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return results;
|
|
247
|
+
}
|
|
248
|
+
// ===== Template Management =====
|
|
249
|
+
async createTemplate(request) {
|
|
250
|
+
try {
|
|
251
|
+
const id = this.generateId();
|
|
252
|
+
const now = new Date();
|
|
253
|
+
// Extract variables from template
|
|
254
|
+
const variables = this.extractVariables(request.html);
|
|
255
|
+
const template = {
|
|
256
|
+
id,
|
|
257
|
+
name: request.name,
|
|
258
|
+
subject: request.subject,
|
|
259
|
+
html: request.html,
|
|
260
|
+
text: request.text,
|
|
261
|
+
variables,
|
|
262
|
+
version: 1,
|
|
263
|
+
createdAt: now,
|
|
264
|
+
updatedAt: now
|
|
265
|
+
};
|
|
266
|
+
await this.db.insert('email_templates', {
|
|
267
|
+
...template,
|
|
268
|
+
variables: JSON.stringify(variables)
|
|
269
|
+
});
|
|
270
|
+
if (this.config.templates?.cacheEnabled) {
|
|
271
|
+
this.templateCache.set(id, template);
|
|
272
|
+
}
|
|
273
|
+
this.logger.info(`Template created: ${request.name}`, { id });
|
|
274
|
+
return template;
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
278
|
+
this.logger.error('Failed to create template', err);
|
|
279
|
+
throw new TemplateError(err.message, err);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async getTemplate(id) {
|
|
283
|
+
// Check cache first
|
|
284
|
+
if (this.config.templates?.cacheEnabled && this.templateCache.has(id)) {
|
|
285
|
+
return this.templateCache.get(id);
|
|
286
|
+
}
|
|
287
|
+
const template = await this.db.findById('email_templates', id);
|
|
288
|
+
if (!template) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
const parsed = {
|
|
292
|
+
...template,
|
|
293
|
+
variables: JSON.parse(template.variables),
|
|
294
|
+
createdAt: new Date(template.created_at),
|
|
295
|
+
updatedAt: new Date(template.updated_at)
|
|
296
|
+
};
|
|
297
|
+
if (this.config.templates?.cacheEnabled) {
|
|
298
|
+
this.templateCache.set(id, parsed);
|
|
299
|
+
}
|
|
300
|
+
return parsed;
|
|
301
|
+
}
|
|
302
|
+
async updateTemplate(id, update) {
|
|
303
|
+
const existing = await this.getTemplate(id);
|
|
304
|
+
if (!existing) {
|
|
305
|
+
throw new TemplateError(`Template ${id} not found`);
|
|
306
|
+
}
|
|
307
|
+
const html = update.html || existing.html;
|
|
308
|
+
const variables = this.extractVariables(html);
|
|
309
|
+
const updated = await this.db.update('email_templates', id, {
|
|
310
|
+
subject: update.subject || existing.subject,
|
|
311
|
+
html,
|
|
312
|
+
text: update.text || existing.text,
|
|
313
|
+
variables: JSON.stringify(variables),
|
|
314
|
+
version: existing.version + 1,
|
|
315
|
+
updatedAt: new Date()
|
|
316
|
+
});
|
|
317
|
+
// Invalidate cache
|
|
318
|
+
this.templateCache.delete(id);
|
|
319
|
+
const result = {
|
|
320
|
+
id: updated.id,
|
|
321
|
+
name: updated.name,
|
|
322
|
+
subject: updated.subject,
|
|
323
|
+
html: updated.html,
|
|
324
|
+
text: updated.text,
|
|
325
|
+
variables: JSON.parse(updated.variables),
|
|
326
|
+
version: updated.version,
|
|
327
|
+
createdAt: new Date(updated.created_at),
|
|
328
|
+
updatedAt: new Date(updated.updated_at)
|
|
329
|
+
};
|
|
330
|
+
return result;
|
|
331
|
+
}
|
|
332
|
+
async deleteTemplate(id) {
|
|
333
|
+
const deleted = await this.db.delete('email_templates', id);
|
|
334
|
+
this.templateCache.delete(id);
|
|
335
|
+
return deleted;
|
|
336
|
+
}
|
|
337
|
+
async renderTemplate(templateId, data) {
|
|
338
|
+
const template = await this.getTemplate(templateId);
|
|
339
|
+
if (!template) {
|
|
340
|
+
throw new TemplateError(`Template ${templateId} not found`);
|
|
341
|
+
}
|
|
342
|
+
const html = await this.templateEngine.render(template.html, data);
|
|
343
|
+
const text = template.text ? await this.templateEngine.render(template.text, data) : undefined;
|
|
344
|
+
const subject = await this.templateEngine.render(template.subject, data);
|
|
345
|
+
return { html, text, subject };
|
|
346
|
+
}
|
|
347
|
+
extractVariables(template) {
|
|
348
|
+
const regex = /\{\{([^}]+)\}\}/g;
|
|
349
|
+
const variables = new Set();
|
|
350
|
+
let match;
|
|
351
|
+
while ((match = regex.exec(template)) !== null) {
|
|
352
|
+
variables.add(match[1].trim());
|
|
353
|
+
}
|
|
354
|
+
return Array.from(variables);
|
|
355
|
+
}
|
|
356
|
+
// ===== Delivery Tracking =====
|
|
357
|
+
async getDelivery(id) {
|
|
358
|
+
const record = await this.db.findById('email_deliveries', id);
|
|
359
|
+
if (!record) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
return this.parseDeliveryRecord(record);
|
|
363
|
+
}
|
|
364
|
+
async trackOpen(deliveryId) {
|
|
365
|
+
await this.db.update('email_deliveries', deliveryId, {
|
|
366
|
+
status: DeliveryStatus.OPENED,
|
|
367
|
+
openedAt: new Date(),
|
|
368
|
+
updatedAt: new Date()
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
async trackClick(deliveryId) {
|
|
372
|
+
await this.db.update('email_deliveries', deliveryId, {
|
|
373
|
+
status: DeliveryStatus.CLICKED,
|
|
374
|
+
clickedAt: new Date(),
|
|
375
|
+
updatedAt: new Date()
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
async getDeliveryStats(startDate, endDate) {
|
|
379
|
+
const records = await this.db.findMany('email_deliveries');
|
|
380
|
+
let filtered = records;
|
|
381
|
+
if (startDate || endDate) {
|
|
382
|
+
filtered = records.filter(r => {
|
|
383
|
+
const date = new Date(r.created_at);
|
|
384
|
+
if (startDate && date < startDate)
|
|
385
|
+
return false;
|
|
386
|
+
if (endDate && date > endDate)
|
|
387
|
+
return false;
|
|
388
|
+
return true;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const total = filtered.length;
|
|
392
|
+
const sent = filtered.filter(r => r.status !== DeliveryStatus.QUEUED && r.status !== DeliveryStatus.SENDING).length;
|
|
393
|
+
const delivered = filtered.filter(r => r.status === DeliveryStatus.DELIVERED).length;
|
|
394
|
+
const opened = filtered.filter(r => r.opened_at).length;
|
|
395
|
+
const clicked = filtered.filter(r => r.clicked_at).length;
|
|
396
|
+
const bounced = filtered.filter(r => r.status === DeliveryStatus.BOUNCED).length;
|
|
397
|
+
const failed = filtered.filter(r => r.status === DeliveryStatus.FAILED).length;
|
|
398
|
+
return {
|
|
399
|
+
total,
|
|
400
|
+
sent,
|
|
401
|
+
delivered,
|
|
402
|
+
opened,
|
|
403
|
+
clicked,
|
|
404
|
+
bounced,
|
|
405
|
+
failed,
|
|
406
|
+
openRate: sent > 0 ? (opened / sent) * 100 : 0,
|
|
407
|
+
clickRate: sent > 0 ? (clicked / sent) * 100 : 0,
|
|
408
|
+
bounceRate: sent > 0 ? (bounced / sent) * 100 : 0
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
parseDeliveryRecord(record) {
|
|
412
|
+
return {
|
|
413
|
+
...record,
|
|
414
|
+
metadata: record.metadata ? JSON.parse(record.metadata) : undefined,
|
|
415
|
+
createdAt: new Date(record.created_at),
|
|
416
|
+
updatedAt: new Date(record.updated_at),
|
|
417
|
+
sentAt: record.sent_at ? new Date(record.sent_at) : undefined,
|
|
418
|
+
deliveredAt: record.delivered_at ? new Date(record.delivered_at) : undefined,
|
|
419
|
+
openedAt: record.opened_at ? new Date(record.opened_at) : undefined,
|
|
420
|
+
clickedAt: record.clicked_at ? new Date(record.clicked_at) : undefined,
|
|
421
|
+
bouncedAt: record.bounced_at ? new Date(record.bounced_at) : undefined
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
// ===== Subscriber Management =====
|
|
425
|
+
async createSubscriber(request) {
|
|
426
|
+
const id = this.generateId();
|
|
427
|
+
const now = new Date();
|
|
428
|
+
const subscriber = await this.db.insert('subscribers', {
|
|
429
|
+
id,
|
|
430
|
+
email: request.email,
|
|
431
|
+
name: request.name,
|
|
432
|
+
status: 'active',
|
|
433
|
+
lists: JSON.stringify(request.lists || []),
|
|
434
|
+
tags: request.tags ? JSON.stringify(request.tags) : null,
|
|
435
|
+
metadata: request.metadata ? JSON.stringify(request.metadata) : null,
|
|
436
|
+
subscribedAt: now,
|
|
437
|
+
createdAt: now,
|
|
438
|
+
updatedAt: now
|
|
439
|
+
});
|
|
440
|
+
this.logger.info(`Subscriber created: ${request.email}`, { id });
|
|
441
|
+
return this.parseSubscriber(subscriber);
|
|
442
|
+
}
|
|
443
|
+
async getSubscriber(id) {
|
|
444
|
+
const subscriber = await this.db.findById('subscribers', id);
|
|
445
|
+
return subscriber ? this.parseSubscriber(subscriber) : null;
|
|
446
|
+
}
|
|
447
|
+
async updateSubscriber(id, update) {
|
|
448
|
+
const updateData = {
|
|
449
|
+
updatedAt: new Date()
|
|
450
|
+
};
|
|
451
|
+
if (update.name !== undefined)
|
|
452
|
+
updateData.name = update.name;
|
|
453
|
+
if (update.status !== undefined)
|
|
454
|
+
updateData.status = update.status;
|
|
455
|
+
if (update.lists !== undefined)
|
|
456
|
+
updateData.lists = JSON.stringify(update.lists);
|
|
457
|
+
if (update.tags !== undefined)
|
|
458
|
+
updateData.tags = JSON.stringify(update.tags);
|
|
459
|
+
if (update.metadata !== undefined)
|
|
460
|
+
updateData.metadata = JSON.stringify(update.metadata);
|
|
461
|
+
const updated = await this.db.update('subscribers', id, updateData);
|
|
462
|
+
return this.parseSubscriber(updated);
|
|
463
|
+
}
|
|
464
|
+
async unsubscribe(email) {
|
|
465
|
+
const subscriber = await this.db.findOne('subscribers', { email });
|
|
466
|
+
if (!subscriber) {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
await this.db.update('subscribers', subscriber.id, {
|
|
470
|
+
status: 'unsubscribed',
|
|
471
|
+
unsubscribedAt: new Date(),
|
|
472
|
+
updatedAt: new Date()
|
|
473
|
+
});
|
|
474
|
+
this.logger.info(`Subscriber unsubscribed: ${email}`);
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
parseSubscriber(record) {
|
|
478
|
+
return {
|
|
479
|
+
...record,
|
|
480
|
+
lists: JSON.parse(record.lists),
|
|
481
|
+
tags: record.tags ? JSON.parse(record.tags) : undefined,
|
|
482
|
+
metadata: record.metadata ? JSON.parse(record.metadata) : undefined,
|
|
483
|
+
subscribedAt: new Date(record.subscribed_at || record.subscribedAt),
|
|
484
|
+
unsubscribedAt: record.unsubscribed_at ? new Date(record.unsubscribed_at) : undefined,
|
|
485
|
+
createdAt: new Date(record.created_at || record.createdAt),
|
|
486
|
+
updatedAt: new Date(record.updated_at || record.updatedAt)
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
async createList(name, description) {
|
|
490
|
+
const id = this.generateId();
|
|
491
|
+
const now = new Date();
|
|
492
|
+
return await this.db.insert('subscriber_lists', {
|
|
493
|
+
id,
|
|
494
|
+
name,
|
|
495
|
+
description,
|
|
496
|
+
subscriberCount: 0,
|
|
497
|
+
createdAt: now,
|
|
498
|
+
updatedAt: now
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
// ===== Webhooks =====
|
|
502
|
+
registerWebhookHandler(event, handler) {
|
|
503
|
+
if (!this.webhookHandlers.has(event)) {
|
|
504
|
+
this.webhookHandlers.set(event, []);
|
|
505
|
+
}
|
|
506
|
+
this.webhookHandlers.get(event).push(handler);
|
|
507
|
+
}
|
|
508
|
+
async handleWebhook(event) {
|
|
509
|
+
const handlers = this.webhookHandlers.get(event.event) || [];
|
|
510
|
+
for (const handler of handlers) {
|
|
511
|
+
try {
|
|
512
|
+
await handler(event);
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
516
|
+
this.logger.error(`Webhook handler error for ${event.event}`, err);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// ===== Lifecycle =====
|
|
521
|
+
async shutdown() {
|
|
522
|
+
if (this.queue) {
|
|
523
|
+
await this.queue.close();
|
|
524
|
+
}
|
|
525
|
+
await this.db.disconnect();
|
|
526
|
+
this.logger.info('Email service shutdown complete');
|
|
527
|
+
}
|
|
528
|
+
generateId() {
|
|
529
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
530
|
+
}
|
|
531
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { EmailService } from './email-service.js';
|
|
2
|
+
export type { EmailProvider, ProviderConfig, SMTPConfig, RateLimitConfig, EmailServiceConfig, EmailAddress, EmailAttachment, EmailRequest, EmailResult, SendEmailResult, EmailTemplate, TemplateCreateRequest, TemplateUpdateRequest, DeliveryRecord, DeliveryEvent, DeliveryStats, Subscriber, SubscriberCreateRequest, SubscriberUpdateRequest, SubscriberList, WebhookEvent, WebhookHandler } from './types.js';
|
|
3
|
+
export { DeliveryStatus, SubscriberStatus, EmailServiceError, ProviderError, TemplateError, DeliveryError } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
export { EmailService } from './email-service.js';
|
|
9
|
+
export { DeliveryStatus, SubscriberStatus, EmailServiceError, ProviderError, TemplateError, DeliveryError } from './types.js';
|