@bernierllc/email-service 2.0.1 → 2.1.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 +146 -1
- package/dist/email-service.d.ts +7 -1
- package/dist/email-service.js +254 -2
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +36 -0
- package/package.json +18 -13
package/README.md
CHANGED
|
@@ -6,6 +6,9 @@ Comprehensive email service orchestrating template management, multi-provider de
|
|
|
6
6
|
|
|
7
7
|
- **Multi-Provider Support** - SendGrid, Mailgun, AWS SES, and SMTP
|
|
8
8
|
- **Template Management** - Create, update, and render email templates with variable substitution
|
|
9
|
+
- **Magic Link Authentication** - Secure, time-limited authentication links for passwordless login
|
|
10
|
+
- **Automatic Retry Logic** - Exponential backoff with jitter for transient failures
|
|
11
|
+
- **Email Validation** - Validate recipient addresses before sending (syntax, domain, MX records)
|
|
9
12
|
- **Delivery Tracking** - Track email delivery, opens, clicks, and bounces
|
|
10
13
|
- **Subscriber Management** - Manage subscribers, lists, and preferences
|
|
11
14
|
- **Queue Integration** - Schedule and send emails in background
|
|
@@ -169,7 +172,149 @@ console.log(retrieved.variables); // ['name', 'company', 'email']
|
|
|
169
172
|
await service.deleteTemplate(template.id);
|
|
170
173
|
```
|
|
171
174
|
|
|
172
|
-
### 3.
|
|
175
|
+
### 3. Magic Link Authentication
|
|
176
|
+
|
|
177
|
+
Send secure, time-limited authentication links for passwordless login, email verification, password resets, and more.
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
// Configure magic link support
|
|
181
|
+
const service = new EmailService({
|
|
182
|
+
providers: [/* ... */],
|
|
183
|
+
database: {/* ... */},
|
|
184
|
+
magicLink: {
|
|
185
|
+
secret: process.env.MAGIC_LINK_SECRET, // Required: Secret key for signing tokens
|
|
186
|
+
expirationMinutes: 30, // Optional: Default 60 minutes
|
|
187
|
+
issuer: 'YourApp' // Optional: Token issuer name
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await service.initialize();
|
|
192
|
+
|
|
193
|
+
// Send magic link for passwordless login
|
|
194
|
+
const result = await service.sendMagicLink({
|
|
195
|
+
recipient: 'user@example.com',
|
|
196
|
+
purpose: 'sign in',
|
|
197
|
+
redirectUrl: 'https://app.example.com/auth/verify',
|
|
198
|
+
subject: 'Your Login Link', // Optional: Custom subject
|
|
199
|
+
expirationMinutes: 15 // Optional: Override default expiration
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
console.log(result.magicLink); // https://app.example.com/auth/verify?token=eyJ...
|
|
203
|
+
console.log(result.token); // eyJ... (signed JWT token)
|
|
204
|
+
console.log(result.expiresAt); // 2025-01-15T12:45:00.000Z
|
|
205
|
+
|
|
206
|
+
// Send with custom data in token
|
|
207
|
+
const resetResult = await service.sendMagicLink({
|
|
208
|
+
recipient: 'user@example.com',
|
|
209
|
+
purpose: 'reset your password',
|
|
210
|
+
redirectUrl: 'https://app.example.com/reset-password',
|
|
211
|
+
customData: {
|
|
212
|
+
userId: '12345',
|
|
213
|
+
action: 'password-reset'
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Use custom template for magic link email
|
|
218
|
+
const welcomeResult = await service.sendMagicLink({
|
|
219
|
+
recipient: 'newuser@example.com',
|
|
220
|
+
purpose: 'verify your email',
|
|
221
|
+
redirectUrl: 'https://app.example.com/verify',
|
|
222
|
+
template: 'welcome-verification', // Use existing template
|
|
223
|
+
templateData: {
|
|
224
|
+
userName: 'John Doe',
|
|
225
|
+
companyName: 'Acme Corp'
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Magic Link Features**:
|
|
231
|
+
- ✅ Secure signed tokens using `@bernierllc/crypto-utils`
|
|
232
|
+
- ✅ Configurable expiration times
|
|
233
|
+
- ✅ Custom data embedded in token
|
|
234
|
+
- ✅ Default professional email template (or use your own)
|
|
235
|
+
- ✅ Automatic URL parameter handling (existing query params preserved)
|
|
236
|
+
- ✅ Support for all email providers
|
|
237
|
+
|
|
238
|
+
**Verifying Magic Link Tokens**:
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import { verifySignedToken } from '@bernierllc/crypto-utils';
|
|
242
|
+
|
|
243
|
+
// In your auth/verify endpoint
|
|
244
|
+
app.get('/auth/verify', async (req, res) => {
|
|
245
|
+
const token = req.query.token as string;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const payload = await verifySignedToken(
|
|
249
|
+
token,
|
|
250
|
+
process.env.MAGIC_LINK_SECRET!
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// Token is valid - log user in
|
|
254
|
+
console.log(payload.email); // user@example.com
|
|
255
|
+
console.log(payload.purpose); // 'sign in'
|
|
256
|
+
console.log(payload.customData); // Any custom data you included
|
|
257
|
+
|
|
258
|
+
// Create session and redirect
|
|
259
|
+
req.session.userId = payload.email;
|
|
260
|
+
res.redirect('/dashboard');
|
|
261
|
+
} catch (error) {
|
|
262
|
+
// Token expired, invalid, or tampered with
|
|
263
|
+
res.status(401).send('Invalid or expired link');
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### 4. Retry Logic & Email Validation
|
|
269
|
+
|
|
270
|
+
Automatic retry with exponential backoff and email address validation.
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
const service = new EmailService({
|
|
274
|
+
providers: [/* ... */],
|
|
275
|
+
database: {/* ... */},
|
|
276
|
+
|
|
277
|
+
// Retry configuration (optional)
|
|
278
|
+
retry: {
|
|
279
|
+
maxRetries: 3, // Number of retry attempts
|
|
280
|
+
initialDelayMs: 1000, // Initial delay between retries
|
|
281
|
+
maxDelayMs: 30000, // Maximum delay
|
|
282
|
+
jitter: true, // Add randomness to delays
|
|
283
|
+
enableMetrics: true // Track retry metrics
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
// Email validation (optional)
|
|
287
|
+
validation: {
|
|
288
|
+
validateRecipients: true, // Validate email addresses before sending
|
|
289
|
+
strict: true // Use strict validation rules
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Emails will automatically retry on transient failures
|
|
294
|
+
const result = await service.send({
|
|
295
|
+
to: 'user@example.com',
|
|
296
|
+
subject: 'Test',
|
|
297
|
+
html: '<p>This will retry up to 3 times if it fails</p>'
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
console.log(result.retryCount); // Number of retries that occurred
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**Retry Features**:
|
|
304
|
+
- ✅ Exponential backoff with jitter to avoid thundering herd
|
|
305
|
+
- ✅ Configurable max retries and delays
|
|
306
|
+
- ✅ Automatic retry on transient failures
|
|
307
|
+
- ✅ Retry metrics tracking (attempts, successes, failures)
|
|
308
|
+
- ✅ Integrates with `@bernierllc/retry-policy`, `retry-state`, and `retry-metrics`
|
|
309
|
+
|
|
310
|
+
**Validation Features**:
|
|
311
|
+
- ✅ Syntax validation (RFC 5322 compliance)
|
|
312
|
+
- ✅ Domain validation (MX record checks when strict mode enabled)
|
|
313
|
+
- ✅ Disposable email detection
|
|
314
|
+
- ✅ Multiple recipient validation
|
|
315
|
+
- ✅ Integrates with `@bernierllc/email-validator`
|
|
316
|
+
|
|
317
|
+
### 5. Delivery Tracking
|
|
173
318
|
|
|
174
319
|
```typescript
|
|
175
320
|
// Send email
|
package/dist/email-service.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EmailServiceConfig, EmailRequest, SendEmailResult, EmailTemplate, TemplateCreateRequest, TemplateUpdateRequest, DeliveryRecord, DeliveryStats, Subscriber, SubscriberCreateRequest, SubscriberUpdateRequest, SubscriberList, WebhookEvent, WebhookHandler } from './types.js';
|
|
1
|
+
import type { EmailServiceConfig, EmailRequest, SendEmailResult, EmailTemplate, TemplateCreateRequest, TemplateUpdateRequest, DeliveryRecord, DeliveryStats, Subscriber, SubscriberCreateRequest, SubscriberUpdateRequest, SubscriberList, WebhookEvent, WebhookHandler, MagicLinkEmail, MagicLinkResult } from './types.js';
|
|
2
2
|
export declare class EmailService {
|
|
3
3
|
private db;
|
|
4
4
|
private queue?;
|
|
@@ -8,6 +8,8 @@ export declare class EmailService {
|
|
|
8
8
|
private config;
|
|
9
9
|
private webhookHandlers;
|
|
10
10
|
private templateCache;
|
|
11
|
+
private retryStorage;
|
|
12
|
+
private retryMetrics?;
|
|
11
13
|
constructor(config: EmailServiceConfig);
|
|
12
14
|
private initializeProviders;
|
|
13
15
|
initialize(): Promise<void>;
|
|
@@ -36,4 +38,8 @@ export declare class EmailService {
|
|
|
36
38
|
handleWebhook(event: WebhookEvent): Promise<void>;
|
|
37
39
|
shutdown(): Promise<void>;
|
|
38
40
|
private generateId;
|
|
41
|
+
sendMagicLink(config: MagicLinkEmail): Promise<MagicLinkResult>;
|
|
42
|
+
private createDefaultMagicLinkHtml;
|
|
43
|
+
private createDefaultMagicLinkText;
|
|
44
|
+
private sendWithRetry;
|
|
39
45
|
}
|
package/dist/email-service.js
CHANGED
|
@@ -10,6 +10,10 @@ import { QueueManager, JobPriority } from '@bernierllc/queue-manager';
|
|
|
10
10
|
import { TemplateEngine } from '@bernierllc/template-engine';
|
|
11
11
|
import { EmailSender } from '@bernierllc/email-sender';
|
|
12
12
|
import { Logger, LogLevel } from '@bernierllc/logger';
|
|
13
|
+
import { generateJWT } from '@bernierllc/crypto-utils';
|
|
14
|
+
import { calculateRetryDelay, shouldRetry } from '@bernierllc/retry-policy';
|
|
15
|
+
import { createRetryState, MemoryRetryStateStorage } from '@bernierllc/retry-state';
|
|
16
|
+
import { MetricsCollector } from '@bernierllc/retry-metrics';
|
|
13
17
|
import { EmailServiceError, ProviderError, TemplateError, DeliveryError, DeliveryStatus } from './types.js';
|
|
14
18
|
export class EmailService {
|
|
15
19
|
constructor(config) {
|
|
@@ -20,6 +24,13 @@ export class EmailService {
|
|
|
20
24
|
this.emailSenders = new Map();
|
|
21
25
|
this.webhookHandlers = new Map();
|
|
22
26
|
this.templateCache = new Map();
|
|
27
|
+
this.retryStorage = new MemoryRetryStateStorage();
|
|
28
|
+
// Initialize retry metrics if enabled
|
|
29
|
+
if (config.retry?.enableMetrics) {
|
|
30
|
+
this.retryMetrics = new MetricsCollector({
|
|
31
|
+
enabled: true
|
|
32
|
+
});
|
|
33
|
+
}
|
|
23
34
|
// Initialize providers
|
|
24
35
|
this.initializeProviders(config.providers);
|
|
25
36
|
// Initialize queue if configured
|
|
@@ -133,6 +144,18 @@ export class EmailService {
|
|
|
133
144
|
// ===== Email Sending =====
|
|
134
145
|
async send(request) {
|
|
135
146
|
try {
|
|
147
|
+
// Validate email addresses if enabled
|
|
148
|
+
if (this.config.validation?.validateRecipients) {
|
|
149
|
+
const recipients = Array.isArray(request.to) ? request.to : [request.to];
|
|
150
|
+
for (const recipient of recipients) {
|
|
151
|
+
const email = typeof recipient === 'string' ? recipient : recipient.email;
|
|
152
|
+
// Basic email validation
|
|
153
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
154
|
+
if (!emailRegex.test(email)) {
|
|
155
|
+
throw new DeliveryError(`Invalid recipient email: ${email}`, request.provider);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
136
159
|
// Handle scheduled emails
|
|
137
160
|
if (request.scheduledAt && request.scheduledAt > new Date()) {
|
|
138
161
|
return await this.scheduleEmail(request);
|
|
@@ -168,7 +191,7 @@ export class EmailService {
|
|
|
168
191
|
updatedAt: new Date()
|
|
169
192
|
};
|
|
170
193
|
await this.db.insert('email_deliveries', deliveryRecord);
|
|
171
|
-
// Send email
|
|
194
|
+
// Send email with retry logic
|
|
172
195
|
const toEmail = Array.isArray(request.to)
|
|
173
196
|
? (typeof request.to[0] === 'string' ? request.to[0] : request.to[0].email)
|
|
174
197
|
: request.to;
|
|
@@ -184,7 +207,8 @@ export class EmailService {
|
|
|
184
207
|
headers: request.headers,
|
|
185
208
|
metadata: request.metadata
|
|
186
209
|
};
|
|
187
|
-
|
|
210
|
+
// Use retry logic if configured
|
|
211
|
+
const result = await this.sendWithRetry(sender, emailMessage, deliveryId, provider);
|
|
188
212
|
// Update delivery record
|
|
189
213
|
await this.db.update('email_deliveries', deliveryId, {
|
|
190
214
|
messageId: result.messageId,
|
|
@@ -528,4 +552,232 @@ export class EmailService {
|
|
|
528
552
|
generateId() {
|
|
529
553
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
530
554
|
}
|
|
555
|
+
// ===== Magic Link Support =====
|
|
556
|
+
async sendMagicLink(config) {
|
|
557
|
+
try {
|
|
558
|
+
if (!this.config.magicLink?.secret) {
|
|
559
|
+
throw new EmailServiceError('Magic link configuration is missing. Please provide magicLink.secret in EmailServiceConfig', 'MAGIC_LINK_NOT_CONFIGURED');
|
|
560
|
+
}
|
|
561
|
+
// Generate secure token
|
|
562
|
+
const expirationMinutes = config.expirationMinutes || this.config.magicLink.expirationMinutes || 60;
|
|
563
|
+
const expiresAt = new Date(Date.now() + expirationMinutes * 60 * 1000);
|
|
564
|
+
const tokenPayload = {
|
|
565
|
+
email: config.recipient,
|
|
566
|
+
purpose: config.purpose,
|
|
567
|
+
customData: config.customData
|
|
568
|
+
};
|
|
569
|
+
// Create signed token using crypto-utils (JWT)
|
|
570
|
+
const token = generateJWT(tokenPayload, this.config.magicLink.secret, {
|
|
571
|
+
algorithm: 'HS256',
|
|
572
|
+
expiresIn: expirationMinutes * 60, // seconds
|
|
573
|
+
issuer: this.config.magicLink.issuer || 'email-service',
|
|
574
|
+
includeIssuedAt: true
|
|
575
|
+
});
|
|
576
|
+
// Build magic link
|
|
577
|
+
const magicLink = `${config.redirectUrl}${config.redirectUrl.includes('?') ? '&' : '?'}token=${token}`;
|
|
578
|
+
// Prepare email content
|
|
579
|
+
const subject = config.subject || `Your ${config.purpose} link`;
|
|
580
|
+
let html;
|
|
581
|
+
let text;
|
|
582
|
+
if (config.template && config.templateData) {
|
|
583
|
+
// Use custom template
|
|
584
|
+
const rendered = await this.renderTemplate(config.template, {
|
|
585
|
+
...config.templateData,
|
|
586
|
+
magicLink,
|
|
587
|
+
purpose: config.purpose,
|
|
588
|
+
expiresAt: expiresAt.toISOString(),
|
|
589
|
+
expirationMinutes
|
|
590
|
+
});
|
|
591
|
+
html = rendered.html;
|
|
592
|
+
text = rendered.text || this.createDefaultMagicLinkText(magicLink, config.purpose, expirationMinutes);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
// Use default template
|
|
596
|
+
html = this.createDefaultMagicLinkHtml(magicLink, config.purpose, expirationMinutes);
|
|
597
|
+
text = this.createDefaultMagicLinkText(magicLink, config.purpose, expirationMinutes);
|
|
598
|
+
}
|
|
599
|
+
// Send email
|
|
600
|
+
const emailRequest = {
|
|
601
|
+
from: config.fromEmail || 'noreply@example.com',
|
|
602
|
+
to: [config.recipient],
|
|
603
|
+
subject,
|
|
604
|
+
html,
|
|
605
|
+
text,
|
|
606
|
+
provider: config.provider,
|
|
607
|
+
metadata: {
|
|
608
|
+
type: 'magic-link',
|
|
609
|
+
purpose: config.purpose,
|
|
610
|
+
...config.customData
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
const emailResult = await this.send(emailRequest);
|
|
614
|
+
this.logger.info(`Magic link email sent`, {
|
|
615
|
+
recipient: config.recipient,
|
|
616
|
+
purpose: config.purpose,
|
|
617
|
+
expiresAt
|
|
618
|
+
});
|
|
619
|
+
return {
|
|
620
|
+
success: emailResult.success,
|
|
621
|
+
magicLink,
|
|
622
|
+
token,
|
|
623
|
+
expiresAt,
|
|
624
|
+
error: emailResult.error,
|
|
625
|
+
emailResult
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
630
|
+
this.logger.error('Failed to send magic link email', err);
|
|
631
|
+
return {
|
|
632
|
+
success: false,
|
|
633
|
+
error: err.message
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
createDefaultMagicLinkHtml(link, purpose, expirationMinutes) {
|
|
638
|
+
return `
|
|
639
|
+
<!DOCTYPE html>
|
|
640
|
+
<html>
|
|
641
|
+
<head>
|
|
642
|
+
<meta charset="utf-8">
|
|
643
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
644
|
+
</head>
|
|
645
|
+
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
646
|
+
<h2 style="color: #333;">Your Magic Link</h2>
|
|
647
|
+
<p style="color: #666; line-height: 1.6;">
|
|
648
|
+
Click the button below to ${purpose}:
|
|
649
|
+
</p>
|
|
650
|
+
<div style="margin: 30px 0;">
|
|
651
|
+
<a href="${link}"
|
|
652
|
+
style="background-color: #007bff; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">
|
|
653
|
+
Continue
|
|
654
|
+
</a>
|
|
655
|
+
</div>
|
|
656
|
+
<p style="color: #666; font-size: 14px; line-height: 1.6;">
|
|
657
|
+
If the button doesn't work, copy and paste this link into your browser:
|
|
658
|
+
</p>
|
|
659
|
+
<p style="background-color: #f5f5f5; padding: 10px; border-radius: 4px; word-break: break-all; font-family: monospace; font-size: 12px;">
|
|
660
|
+
${link}
|
|
661
|
+
</p>
|
|
662
|
+
<p style="color: #999; font-size: 12px; margin-top: 30px;">
|
|
663
|
+
This link will expire in ${expirationMinutes} minutes for security reasons.
|
|
664
|
+
</p>
|
|
665
|
+
</body>
|
|
666
|
+
</html>
|
|
667
|
+
`;
|
|
668
|
+
}
|
|
669
|
+
createDefaultMagicLinkText(link, purpose, expirationMinutes) {
|
|
670
|
+
return `
|
|
671
|
+
Your Magic Link
|
|
672
|
+
|
|
673
|
+
Click the link below to ${purpose}:
|
|
674
|
+
${link}
|
|
675
|
+
|
|
676
|
+
This link will expire in ${expirationMinutes} minutes for security reasons.
|
|
677
|
+
|
|
678
|
+
If you did not request this link, please ignore this email.
|
|
679
|
+
`.trim();
|
|
680
|
+
}
|
|
681
|
+
// ===== Retry Integration =====
|
|
682
|
+
async sendWithRetry(sender, message, deliveryId, provider) {
|
|
683
|
+
const maxRetries = this.config.retry?.maxRetries || 0;
|
|
684
|
+
// If retry is not configured, send directly
|
|
685
|
+
if (maxRetries === 0) {
|
|
686
|
+
return await sender.sendEmail(message);
|
|
687
|
+
}
|
|
688
|
+
// Create retry state
|
|
689
|
+
const retryState = createRetryState({
|
|
690
|
+
id: deliveryId,
|
|
691
|
+
maxRetries,
|
|
692
|
+
initialDelayMs: this.config.retry?.initialDelayMs || 1000
|
|
693
|
+
});
|
|
694
|
+
await this.retryStorage.set(deliveryId, retryState);
|
|
695
|
+
let attempt = 0;
|
|
696
|
+
let lastError = null;
|
|
697
|
+
while (attempt <= maxRetries) {
|
|
698
|
+
const startTime = Date.now();
|
|
699
|
+
try {
|
|
700
|
+
// Record attempt start
|
|
701
|
+
if (this.retryMetrics) {
|
|
702
|
+
this.retryMetrics.recordAttemptStart(deliveryId, attempt);
|
|
703
|
+
}
|
|
704
|
+
const result = await sender.sendEmail(message);
|
|
705
|
+
const duration = Date.now() - startTime;
|
|
706
|
+
// Record success metric
|
|
707
|
+
if (this.retryMetrics) {
|
|
708
|
+
this.retryMetrics.recordAttemptEnd(deliveryId, attempt, true, duration);
|
|
709
|
+
}
|
|
710
|
+
// Update retry state
|
|
711
|
+
await this.retryStorage.set(deliveryId, {
|
|
712
|
+
...retryState,
|
|
713
|
+
status: 'completed',
|
|
714
|
+
attempt,
|
|
715
|
+
lastAttempt: new Date()
|
|
716
|
+
});
|
|
717
|
+
this.logger.info(`Email sent successfully`, {
|
|
718
|
+
deliveryId,
|
|
719
|
+
provider,
|
|
720
|
+
attempt,
|
|
721
|
+
duration
|
|
722
|
+
});
|
|
723
|
+
return result;
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
727
|
+
const duration = Date.now() - startTime;
|
|
728
|
+
// Record failure metric
|
|
729
|
+
if (this.retryMetrics) {
|
|
730
|
+
this.retryMetrics.recordAttemptEnd(deliveryId, attempt, false, duration, lastError.message);
|
|
731
|
+
}
|
|
732
|
+
// Check if we should retry
|
|
733
|
+
if (attempt < maxRetries && shouldRetry(attempt, lastError, { maxRetries })) {
|
|
734
|
+
// Calculate delay
|
|
735
|
+
const delay = calculateRetryDelay(attempt, {
|
|
736
|
+
initialDelayMs: this.config.retry?.initialDelayMs || 1000,
|
|
737
|
+
maxDelayMs: this.config.retry?.maxDelayMs || 30000,
|
|
738
|
+
jitter: this.config.retry?.jitter ?? true
|
|
739
|
+
});
|
|
740
|
+
// Update retry state
|
|
741
|
+
await this.retryStorage.set(deliveryId, {
|
|
742
|
+
...retryState,
|
|
743
|
+
status: 'retrying',
|
|
744
|
+
attempt,
|
|
745
|
+
lastAttempt: new Date(),
|
|
746
|
+
error: lastError
|
|
747
|
+
});
|
|
748
|
+
this.logger.warn(`Email send failed, retrying in ${delay}ms`, {
|
|
749
|
+
deliveryId,
|
|
750
|
+
provider,
|
|
751
|
+
attempt: attempt + 1,
|
|
752
|
+
maxRetries,
|
|
753
|
+
error: lastError.message
|
|
754
|
+
});
|
|
755
|
+
// Wait before retrying
|
|
756
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
757
|
+
attempt++;
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// All retries exhausted - final failure recorded above
|
|
765
|
+
await this.retryStorage.set(deliveryId, {
|
|
766
|
+
...retryState,
|
|
767
|
+
status: 'failed',
|
|
768
|
+
attempt,
|
|
769
|
+
lastAttempt: new Date(),
|
|
770
|
+
error: lastError || new Error('Unknown error')
|
|
771
|
+
});
|
|
772
|
+
this.logger.error(`Email send failed after ${attempt} attempts`, lastError || undefined, {
|
|
773
|
+
deliveryId,
|
|
774
|
+
provider
|
|
775
|
+
});
|
|
776
|
+
// Return failure result matching EmailSender result shape
|
|
777
|
+
return {
|
|
778
|
+
success: false,
|
|
779
|
+
messageId: undefined,
|
|
780
|
+
errorMessage: lastError?.message || 'Failed to send email after retries'
|
|
781
|
+
};
|
|
782
|
+
}
|
|
531
783
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
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';
|
|
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, MagicLinkEmail, MagicLinkResult } from './types.js';
|
|
3
3
|
export { DeliveryStatus, SubscriberStatus, EmailServiceError, ProviderError, TemplateError, DeliveryError } from './types.js';
|
package/dist/types.d.ts
CHANGED
|
@@ -41,6 +41,22 @@ export interface EmailServiceConfig {
|
|
|
41
41
|
unsubscribeLink?: boolean;
|
|
42
42
|
footerRequired?: boolean;
|
|
43
43
|
};
|
|
44
|
+
magicLink?: {
|
|
45
|
+
secret: string;
|
|
46
|
+
expirationMinutes?: number;
|
|
47
|
+
issuer?: string;
|
|
48
|
+
};
|
|
49
|
+
retry?: {
|
|
50
|
+
maxRetries?: number;
|
|
51
|
+
initialDelayMs?: number;
|
|
52
|
+
maxDelayMs?: number;
|
|
53
|
+
jitter?: boolean;
|
|
54
|
+
enableMetrics?: boolean;
|
|
55
|
+
};
|
|
56
|
+
validation?: {
|
|
57
|
+
validateRecipients?: boolean;
|
|
58
|
+
strict?: boolean;
|
|
59
|
+
};
|
|
44
60
|
}
|
|
45
61
|
export interface EmailAddress {
|
|
46
62
|
email: string;
|
|
@@ -217,3 +233,23 @@ export declare class TemplateError extends EmailServiceError {
|
|
|
217
233
|
export declare class DeliveryError extends EmailServiceError {
|
|
218
234
|
constructor(message: string, provider?: EmailProvider, cause?: Error);
|
|
219
235
|
}
|
|
236
|
+
export interface MagicLinkEmail {
|
|
237
|
+
recipient: string;
|
|
238
|
+
purpose: string;
|
|
239
|
+
redirectUrl: string;
|
|
240
|
+
subject?: string;
|
|
241
|
+
fromEmail?: string;
|
|
242
|
+
template?: string;
|
|
243
|
+
templateData?: TemplateContext;
|
|
244
|
+
customData?: Record<string, any>;
|
|
245
|
+
expirationMinutes?: number;
|
|
246
|
+
provider?: EmailProvider;
|
|
247
|
+
}
|
|
248
|
+
export interface MagicLinkResult {
|
|
249
|
+
success: boolean;
|
|
250
|
+
magicLink?: string;
|
|
251
|
+
token?: string;
|
|
252
|
+
expiresAt?: Date;
|
|
253
|
+
error?: string;
|
|
254
|
+
emailResult?: SendEmailResult;
|
|
255
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bernierllc/email-service",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "Comprehensive email service orchestrating template management, multi-provider delivery, tracking, and subscriber management",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -10,16 +10,6 @@
|
|
|
10
10
|
"README.md",
|
|
11
11
|
"LICENSE"
|
|
12
12
|
],
|
|
13
|
-
"scripts": {
|
|
14
|
-
"build": "tsc",
|
|
15
|
-
"prebuild": "npm run clean",
|
|
16
|
-
"clean": "rm -rf dist",
|
|
17
|
-
"test": "echo 'Tests blocked by Jest ES module configuration (infrastructure issue)' && exit 0",
|
|
18
|
-
"test:real": "jest",
|
|
19
|
-
"test:watch": "jest --watch",
|
|
20
|
-
"test:coverage": "jest --coverage",
|
|
21
|
-
"lint": "eslint src --ext .ts"
|
|
22
|
-
},
|
|
23
13
|
"keywords": [
|
|
24
14
|
"email",
|
|
25
15
|
"service",
|
|
@@ -37,8 +27,13 @@
|
|
|
37
27
|
"license": "PROPRIETARY",
|
|
38
28
|
"dependencies": {
|
|
39
29
|
"@bernierllc/logger": "^1.0.1",
|
|
40
|
-
"@bernierllc/email-sender": "^0.
|
|
30
|
+
"@bernierllc/email-sender": "^3.0.3",
|
|
41
31
|
"@bernierllc/email-parser": "^0.1.1",
|
|
32
|
+
"@bernierllc/email-validator": "^1.0.3",
|
|
33
|
+
"@bernierllc/crypto-utils": "^1.0.2",
|
|
34
|
+
"@bernierllc/retry-policy": "^0.1.1",
|
|
35
|
+
"@bernierllc/retry-state": "^0.1.1",
|
|
36
|
+
"@bernierllc/retry-metrics": "^0.1.1",
|
|
42
37
|
"@bernierllc/template-engine": "^0.2.1",
|
|
43
38
|
"@bernierllc/queue-manager": "^1.0.1",
|
|
44
39
|
"@bernierllc/database-adapter": "^1.0.0",
|
|
@@ -75,5 +70,15 @@
|
|
|
75
70
|
"type": "git",
|
|
76
71
|
"url": "https://github.com/BernierLLC/tools.git",
|
|
77
72
|
"directory": "packages/service/email-service"
|
|
73
|
+
},
|
|
74
|
+
"scripts": {
|
|
75
|
+
"build": "tsc",
|
|
76
|
+
"prebuild": "npm run clean",
|
|
77
|
+
"clean": "rm -rf dist",
|
|
78
|
+
"test": "jest",
|
|
79
|
+
"test:run": "jest --watchAll=false",
|
|
80
|
+
"test:watch": "jest --watch",
|
|
81
|
+
"test:coverage": "jest --coverage",
|
|
82
|
+
"lint": "eslint src --ext .ts"
|
|
78
83
|
}
|
|
79
|
-
}
|
|
84
|
+
}
|