@454creative/easy-email
Version:
A framework-agnostic email service library for Node.js with observability and monitoring
538 lines (474 loc) • 14.8 kB
text/typescript
import {
EmailEvent,
ObservabilityConfig,
EmailMetrics,
AuditLog,
} from '../interfaces/observability.interface';
import {
EmailOptions,
EmailResponse,
EmailProviderType,
} from '../interfaces/email-options.interface';
// Simple UUID generator (in production, use a proper UUID library)
function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export class ObservabilityService {
private static instance: ObservabilityService;
private config: ObservabilityConfig;
private events: EmailEvent[] = [];
private metrics: EmailMetrics;
private auditLogs: AuditLog[] = [];
private constructor(config: ObservabilityConfig = { enabled: true }) {
this.config = config;
this.metrics = this.initializeMetrics();
}
static getInstance(config?: ObservabilityConfig): ObservabilityService {
if (!ObservabilityService.instance) {
ObservabilityService.instance = new ObservabilityService(config);
}
return ObservabilityService.instance;
}
private initializeMetrics(): EmailMetrics {
return {
totalSent: 0,
totalFailed: 0,
successRate: 0,
averageSendTime: 0,
providerBreakdown: {
smtp: { sent: 0, failed: 0, avgTime: 0 },
sendgrid: { sent: 0, failed: 0, avgTime: 0 },
ses: { sent: 0, failed: 0, avgTime: 0 },
},
last24Hours: {
sent: 0,
failed: 0,
avgTime: 0,
},
sesMetrics: {
quotaUsage: 0,
bounceRate: 0,
complaintRate: 0,
deliveryRate: 0,
lastQuotaCheck: undefined,
},
};
}
/**
* Track email sending attempt
*/
trackEmailAttempt(
options: EmailOptions,
provider: EmailProviderType,
_startTime: number
): string {
if (!this.config.enabled) return '';
const eventId = generateUUID();
const event = this.createEmailEvent(eventId, 'email_sent', provider, options);
this.events.push(event);
// Don't log here - wait for completion to log with complete information
return eventId;
}
/**
* Track email success
*/
trackEmailSuccess(
eventId: string,
response: EmailResponse,
duration: number,
provider: EmailProviderType
): void {
if (!this.config.enabled) return;
const event = this.events.find(e => e.id === eventId);
if (event) {
event.messageId = response.messageId;
event.duration = duration;
event.type = 'email_sent';
this.updateMetrics(provider, true, duration);
this.createAuditLog('email_sent', response, provider);
this.logEvent(event); // Log the completed event with duration information
}
}
/**
* Track email failure
*/
trackEmailFailure(
eventId: string,
error: any,
duration: number,
provider: EmailProviderType
): void {
if (!this.config.enabled) return;
const event = this.events.find(e => e.id === eventId);
if (event) {
event.type = 'email_failed';
event.duration = duration;
event.error = {
message: error.message || 'Unknown error',
code: error.code,
details: error.details,
};
this.updateMetrics(provider, false, duration);
this.createAuditLog('email_failed', { success: false, error }, provider);
this.logEvent(event); // Log the completed event with duration and error information
}
}
/**
* Track connection verification
*/
trackConnectionVerification(
provider: EmailProviderType,
success: boolean,
duration?: number
): void {
if (!this.config.enabled) return;
const event = this.createConnectionEvent(provider, success, duration);
this.events.push(event);
this.logEvent(event);
}
/**
* Track SES quota usage
*/
trackSesQuotaUsage(quotaData: {
max24HourSend: number;
sentLast24Hours: number;
maxSendRate: number;
}): void {
if (!this.config.enabled || !this.metrics.sesMetrics) return;
const usagePercent = (quotaData.sentLast24Hours / quotaData.max24HourSend) * 100;
this.metrics.sesMetrics.quotaUsage = usagePercent;
this.metrics.sesMetrics.lastQuotaCheck = new Date().toISOString();
// Log quota usage event
const event: EmailEvent = {
id: generateUUID(),
timestamp: new Date().toISOString(),
type: 'connection_verified',
provider: 'ses',
metadata: {
quotaUsage: usagePercent,
max24HourSend: quotaData.max24HourSend,
sentLast24Hours: quotaData.sentLast24Hours,
maxSendRate: quotaData.maxSendRate,
},
};
this.events.push(event);
this.logEvent(event);
}
/**
* Track SES sending statistics
*/
trackSesStatistics(statsData: {
deliveryAttempts: number;
bounces: number;
complaints: number;
rejects: number;
}): void {
if (!this.config.enabled || !this.metrics.sesMetrics) return;
const totalAttempts = statsData.deliveryAttempts;
const successfulDeliveries = totalAttempts - statsData.bounces - statsData.complaints - statsData.rejects;
this.metrics.sesMetrics.deliveryRate = totalAttempts > 0 ? (successfulDeliveries / totalAttempts) * 100 : 0;
this.metrics.sesMetrics.bounceRate = totalAttempts > 0 ? (statsData.bounces / totalAttempts) * 100 : 0;
this.metrics.sesMetrics.complaintRate = totalAttempts > 0 ? (statsData.complaints / totalAttempts) * 100 : 0;
// Log statistics event
const event: EmailEvent = {
id: generateUUID(),
timestamp: new Date().toISOString(),
type: 'connection_verified',
provider: 'ses',
metadata: {
deliveryRate: this.metrics.sesMetrics.deliveryRate,
bounceRate: this.metrics.sesMetrics.bounceRate,
complaintRate: this.metrics.sesMetrics.complaintRate,
deliveryAttempts: statsData.deliveryAttempts,
bounces: statsData.bounces,
complaints: statsData.complaints,
rejects: statsData.rejects,
},
};
this.events.push(event);
this.logEvent(event);
}
/**
* Track SES error with specific error type
*/
trackSesError(
error: any,
errorType: 'MessageRejected' | 'MailFromDomainNotVerified' | 'ConfigurationSetDoesNotExist' | 'TemplateDoesNotExist' | 'AccountSendingPaused' | 'SendingPaused' | 'MessageTooLarge' | 'InvalidParameterValue' | 'ValidationError' | 'ThrottlingException' | 'ServiceUnavailable' | 'InternalError' | 'NetworkError' | 'TimeoutError',
duration: number
): void {
if (!this.config.enabled) return;
const event: EmailEvent = {
id: generateUUID(),
timestamp: new Date().toISOString(),
type: 'email_failed',
provider: 'ses',
duration,
error: {
message: error.message || String(error),
code: errorType,
details: {
sesErrorType: errorType,
originalError: error,
},
},
metadata: {
errorType,
duration,
},
};
this.events.push(event);
this.updateMetrics(EmailProviderType.SES, false, duration);
this.logEvent(event);
}
/**
* Get current metrics
*/
getMetrics(): EmailMetrics {
return { ...this.metrics };
}
/**
* Get SES-specific metrics
*/
getSesMetrics() {
return this.metrics.sesMetrics;
}
/**
* Get metrics for a specific provider
*/
getProviderMetrics(provider: EmailProviderType) {
let providerKey: 'smtp' | 'sendgrid' | 'ses';
switch (provider) {
case EmailProviderType.SMTP:
providerKey = 'smtp';
break;
case EmailProviderType.SENDGRID:
providerKey = 'sendgrid';
break;
case EmailProviderType.SES:
providerKey = 'ses';
break;
default:
providerKey = 'smtp';
}
return this.metrics.providerBreakdown[providerKey];
}
/**
* Get events within time range
*/
getEvents(startTime?: Date, endTime?: Date): EmailEvent[] {
let filteredEvents = [...this.events];
if (startTime) {
filteredEvents = filteredEvents.filter(e => new Date(e.timestamp) >= startTime);
}
if (endTime) {
filteredEvents = filteredEvents.filter(e => new Date(e.timestamp) <= endTime);
}
return filteredEvents;
}
/**
* Get audit logs
*/
getAuditLogs(startTime?: Date, endTime?: Date): AuditLog[] {
let filteredLogs = [...this.auditLogs];
if (startTime) {
filteredLogs = filteredLogs.filter(log => new Date(log.timestamp) >= startTime);
}
if (endTime) {
filteredLogs = filteredLogs.filter(log => new Date(log.timestamp) <= endTime);
}
return filteredLogs;
}
/**
* Clear all data (useful for testing)
*/
clearData(): void {
this.events = [];
this.auditLogs = [];
this.metrics = this.initializeMetrics();
}
private sanitizeRecipients(recipients: EmailOptions['to']): string {
if (!this.config.includeSensitiveData) {
return '[REDACTED]';
}
if (Array.isArray(recipients)) {
return recipients.map(r => r.email).join(', ');
}
return recipients.email;
}
private updateMetrics(provider: EmailProviderType, success: boolean, duration: number): void {
let providerKey: 'smtp' | 'sendgrid' | 'ses';
switch (provider) {
case EmailProviderType.SMTP:
providerKey = 'smtp';
break;
case EmailProviderType.SENDGRID:
providerKey = 'sendgrid';
break;
case EmailProviderType.SES:
providerKey = 'ses';
break;
default:
providerKey = 'smtp'; // fallback
}
this.metrics.totalSent++;
if (success) {
this.metrics.providerBreakdown[providerKey].sent++;
} else {
this.metrics.totalFailed++;
this.metrics.providerBreakdown[providerKey].failed++;
}
// Update average send time
const currentAvg = this.metrics.providerBreakdown[providerKey].avgTime;
const currentCount =
this.metrics.providerBreakdown[providerKey].sent +
this.metrics.providerBreakdown[providerKey].failed;
this.metrics.providerBreakdown[providerKey].avgTime =
(currentAvg * (currentCount - 1) + duration) / currentCount;
// Update overall success rate
this.metrics.successRate =
(this.metrics.totalSent - this.metrics.totalFailed) / this.metrics.totalSent;
// Update 24-hour metrics (simplified - in production you'd want more sophisticated time tracking)
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const recentEvents = this.events.filter(e => new Date(e.timestamp) >= oneDayAgo);
this.metrics.last24Hours.sent = recentEvents.filter(e => e.type === 'email_sent').length;
this.metrics.last24Hours.failed = recentEvents.filter(e => e.type === 'email_failed').length;
if (recentEvents.length > 0) {
const recentDurations = recentEvents.filter(e => e.duration).map(e => e.duration!);
this.metrics.last24Hours.avgTime =
recentDurations.reduce((a, b) => a + b, 0) / recentDurations.length;
}
// Call custom metrics collector if provided
if (this.config.metricsCollector) {
this.config.metricsCollector(this.metrics);
}
}
private createAuditLog(
action: AuditLog['action'],
response: EmailResponse,
provider: EmailProviderType
): void {
let providerKey: 'smtp' | 'sendgrid' | 'ses';
switch (provider) {
case EmailProviderType.SMTP:
providerKey = 'smtp';
break;
case EmailProviderType.SENDGRID:
providerKey = 'sendgrid';
break;
case EmailProviderType.SES:
providerKey = 'ses';
break;
default:
providerKey = 'smtp'; // fallback
}
const auditLog: AuditLog = {
timestamp: new Date().toISOString(),
action,
emailDetails: {
to: [], // Would be populated from original options
subject: '', // Would be populated from original options
provider: providerKey,
messageId: response.messageId,
},
metadata: {
success: response.success,
error: response.error,
},
};
this.auditLogs.push(auditLog);
}
private logEvent(event: EmailEvent): void {
if (this.config.customLogger) {
this.config.customLogger(event);
} else {
console.log(`[EMAIL-EVENT] ${event.type.toUpperCase()}: ${event.provider}`, {
recipient: event.recipient,
subject: event.subject,
duration: event.duration,
error: event.error,
});
}
}
/**
* Create email event with common properties
* @private
*/
private createEmailEvent(
eventId: string,
type: 'email_sent' | 'email_failed',
provider: EmailProviderType,
options: EmailOptions
): EmailEvent {
let providerKey: 'smtp' | 'sendgrid' | 'ses';
switch (provider) {
case EmailProviderType.SMTP:
providerKey = 'smtp';
break;
case EmailProviderType.SENDGRID:
providerKey = 'sendgrid';
break;
case EmailProviderType.SES:
providerKey = 'ses';
break;
default:
providerKey = 'smtp'; // fallback
}
return {
id: eventId,
timestamp: new Date().toISOString(),
type,
provider: providerKey,
recipient: this.sanitizeRecipients(options.to),
subject: options.subject,
metadata: {
hasAttachments: !!options.attachments?.length,
hasHtml: !!options.html,
hasText: !!options.text,
ccCount: Array.isArray(options.cc) ? options.cc.length : options.cc ? 1 : 0,
bccCount: Array.isArray(options.bcc) ? options.bcc.length : options.bcc ? 1 : 0,
},
};
}
/**
* Create connection verification event
* @private
*/
private createConnectionEvent(
provider: EmailProviderType,
success: boolean,
duration?: number
): EmailEvent {
let providerKey: 'smtp' | 'sendgrid' | 'ses';
switch (provider) {
case EmailProviderType.SMTP:
providerKey = 'smtp';
break;
case EmailProviderType.SENDGRID:
providerKey = 'sendgrid';
break;
case EmailProviderType.SES:
providerKey = 'ses';
break;
default:
providerKey = 'smtp'; // fallback
}
return {
id: generateUUID(),
timestamp: new Date().toISOString(),
type: success ? 'connection_verified' : 'connection_failed',
provider: providerKey,
duration,
error: success
? undefined
: {
message: 'Connection verification failed',
code: 'CONNECTION_FAILED',
},
};
}
}