UNPKG

@454creative/easy-email

Version:

A framework-agnostic email service library for Node.js with observability and monitoring

384 lines (339 loc) 11.6 kB
import { SESClient, SendEmailCommand, SendEmailCommandInput } from '@aws-sdk/client-ses'; import { fromEnv } from '@aws-sdk/credential-providers'; import { EmailOptions, EmailResponse, EmailRecipient, SesConfig, EmailProviderType, } from '../interfaces/email-options.interface'; import { ObservabilityService } from './observability.service'; /** * SES Email Service Implementation * Handles email sending through AWS SES with comprehensive error handling */ export class SesService { private client: SESClient; private config: SesConfig; private observability: ObservabilityService; constructor(config: SesConfig) { this.config = config; this.client = this.createSesClient(); this.observability = ObservabilityService.getInstance(); } /** * Create SES client with proper configuration */ private createSesClient(): SESClient { const clientConfig: any = { region: this.config.region, apiVersion: this.config.apiVersion || '2010-12-01', maxAttempts: this.config.maxRetries || 3, }; // Configure credentials if (this.config.credentials) { clientConfig.credentials = this.config.credentials; } else if (this.config.accessKeyId && this.config.secretAccessKey) { clientConfig.credentials = { accessKeyId: this.config.accessKeyId, secretAccessKey: this.config.secretAccessKey, sessionToken: this.config.sessionToken, }; } else { // Use AWS credential providers chain clientConfig.credentials = fromEnv(); } // Configure endpoint if provided if (this.config.endpoint) { clientConfig.endpoint = this.config.endpoint; } // Configure HTTP options if (this.config.httpOptions) { clientConfig.requestHandler = { httpOptions: this.config.httpOptions, }; } return new SESClient(clientConfig); } /** * Send email using SES */ async sendEmail(options: EmailOptions): Promise<EmailResponse> { const startTime = Date.now(); const eventId = this.observability.trackEmailAttempt(options, EmailProviderType.SES, startTime); try { const command = this.buildSendEmailCommand(options); const result = await this.client.send(command); const duration = Date.now() - startTime; const response: EmailResponse = { success: true, messageId: result.MessageId, }; this.observability.trackEmailSuccess(eventId, response, duration, EmailProviderType.SES); return response; } catch (error: any) { const duration = Date.now() - startTime; const response = this.handleSesError(error, 'send email'); this.observability.trackEmailFailure(eventId, error, duration, EmailProviderType.SES); // Track SES-specific error if available if (error.name) { this.observability.trackSesError(error, error.name as any, duration); } return response; } } /** * Build SES SendEmailCommand from EmailOptions */ private buildSendEmailCommand(options: EmailOptions): SendEmailCommand { const input: SendEmailCommandInput = { Source: this.formatEmailAddress(options.from), Destination: { ToAddresses: this.formatRecipients(options.to), CcAddresses: options.cc ? this.formatRecipients(options.cc) : undefined, BccAddresses: options.bcc ? this.formatRecipients(options.bcc) : undefined, }, Message: { Subject: { Data: options.subject, Charset: 'UTF-8', }, Body: this.buildMessageBody(options), }, }; // Add reply-to if specified if (options.replyTo) { input.ReplyToAddresses = [options.replyTo]; } // Add configuration set if specified in headers if (options.headers?.configurationSetName) { input.ConfigurationSetName = options.headers.configurationSetName; } // Add tags if specified in headers if (options.headers?.tags) { try { const tags = JSON.parse(options.headers.tags); if (Array.isArray(tags)) { input.Tags = tags.map(tag => ({ Name: tag.name || tag.Name, Value: tag.value || tag.Value, })); } } catch (error) { // Ignore invalid tags format } } return new SendEmailCommand(input); } /** * Build message body for SES */ private buildMessageBody(options: EmailOptions): any { const body: any = {}; if (options.text) { body.Text = { Data: options.text, Charset: 'UTF-8', }; } if (options.html) { body.Html = { Data: options.html, Charset: 'UTF-8', }; } // SES doesn't support attachments in the same way as SMTP/SendGrid // Attachments would need to be handled differently (e.g., as links or embedded content) if (options.attachments && options.attachments.length > 0) { console.warn('SES attachments are not supported in this implementation. Consider using S3 links or embedding content.'); } return body; } /** * Format email address for SES */ private formatEmailAddress(recipient: EmailRecipient): string { if (typeof recipient === 'string') { return recipient; } return recipient.name ? `${recipient.name} <${recipient.email}>` : recipient.email; } /** * Format recipients for SES */ private formatRecipients(recipients: EmailRecipient | EmailRecipient[]): string[] { if (Array.isArray(recipients)) { return recipients.map(recipient => this.formatEmailAddress(recipient)); } return [this.formatEmailAddress(recipients)]; } /** * Handle SES-specific errors */ private handleSesError(error: any, operation: string): EmailResponse { console.error(`SES ${operation} failed:`, error); let errorMessage = `SES ${operation} failed`; let errorCode = 'SES_ERROR'; let provider = EmailProviderType.SES; // Handle specific AWS SES errors if (error.name) { switch (error.name) { case 'MessageRejected': errorMessage = 'Email message was rejected by SES'; errorCode = 'MESSAGE_REJECTED'; break; case 'MailFromDomainNotVerified': errorMessage = 'Sender domain is not verified in SES'; errorCode = 'DOMAIN_NOT_VERIFIED'; break; case 'ConfigurationSetDoesNotExist': errorMessage = 'SES configuration set does not exist'; errorCode = 'CONFIGURATION_SET_NOT_FOUND'; break; case 'TemplateDoesNotExist': errorMessage = 'SES template does not exist'; errorCode = 'TEMPLATE_NOT_FOUND'; break; case 'AccountSendingPaused': errorMessage = 'SES account sending is paused'; errorCode = 'ACCOUNT_PAUSED'; break; case 'SendingPaused': errorMessage = 'SES sending is paused'; errorCode = 'SENDING_PAUSED'; break; case 'MessageTooLarge': errorMessage = 'Email message is too large for SES'; errorCode = 'MESSAGE_TOO_LARGE'; break; case 'InvalidParameterValue': errorMessage = 'Invalid parameter value provided to SES'; errorCode = 'INVALID_PARAMETER'; break; case 'InvalidParameter': errorMessage = 'Invalid parameter provided to SES'; errorCode = 'INVALID_PARAMETER'; break; case 'ValidationError': errorMessage = 'SES validation error'; errorCode = 'VALIDATION_ERROR'; break; case 'ThrottlingException': errorMessage = 'SES rate limit exceeded'; errorCode = 'RATE_LIMIT_EXCEEDED'; break; case 'ServiceUnavailable': errorMessage = 'SES service is temporarily unavailable'; errorCode = 'SERVICE_UNAVAILABLE'; break; case 'InternalFailure': errorMessage = 'SES internal error'; errorCode = 'INTERNAL_ERROR'; break; case 'NetworkError': errorMessage = 'Network error connecting to SES'; errorCode = 'NETWORK_ERROR'; break; case 'TimeoutError': errorMessage = 'SES request timed out'; errorCode = 'TIMEOUT_ERROR'; break; default: errorMessage = error.message || `SES ${operation} failed`; errorCode = error.name || 'SES_ERROR'; } } return { success: false, error: { message: errorMessage, code: errorCode, provider, details: { sesResponse: { name: error.name, message: error.message, code: error.$metadata?.httpStatusCode, requestId: error.$metadata?.requestId, cfId: error.$metadata?.cfId, extendedRequestId: error.$metadata?.extendedRequestId, }, }, }, }; } /** * Verify SES connection */ async verifyConnection(): Promise<boolean> { const startTime = Date.now(); try { // SES doesn't have a direct "verify" method like SMTP // We'll try to get the sending quota as a connection test const { GetSendQuotaCommand } = await import('@aws-sdk/client-ses'); const command = new GetSendQuotaCommand({}); await this.client.send(command); const duration = Date.now() - startTime; this.observability.trackConnectionVerification(EmailProviderType.SES, true, duration); return true; } catch (error: any) { const duration = Date.now() - startTime; this.observability.trackConnectionVerification(EmailProviderType.SES, false, duration); console.error('SES connection verification failed:', error); return false; } } /** * Get SES sending statistics */ async getSendingStatistics(): Promise<any> { try { const { GetSendStatisticsCommand } = await import('@aws-sdk/client-ses'); const command = new GetSendStatisticsCommand({}); const result = await this.client.send(command); // Track statistics in observability if (result.SendDataPoints && result.SendDataPoints.length > 0) { const latestStats = result.SendDataPoints[0]; this.observability.trackSesStatistics({ deliveryAttempts: latestStats.DeliveryAttempts || 0, bounces: latestStats.Bounces || 0, complaints: latestStats.Complaints || 0, rejects: latestStats.Rejects || 0, }); } return result.SendDataPoints; } catch (error: any) { console.error('Failed to get SES sending statistics:', error); return null; } } /** * Get SES sending quota */ async getSendingQuota(): Promise<any> { try { const { GetSendQuotaCommand } = await import('@aws-sdk/client-ses'); const command = new GetSendQuotaCommand({}); const result = await this.client.send(command); const quotaData = { max24HourSend: result.Max24HourSend || 0, sentLast24Hours: result.SentLast24Hours || 0, maxSendRate: result.MaxSendRate || 0, }; // Track quota usage in observability this.observability.trackSesQuotaUsage(quotaData); return quotaData; } catch (error: any) { console.error('Failed to get SES sending quota:', error); return null; } } /** * Destroy SES client */ destroy(): void { if (this.client) { this.client.destroy(); } } }