@454creative/easy-email
Version:
A framework-agnostic email service library for Node.js with observability and monitoring
384 lines (339 loc) • 11.6 kB
text/typescript
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();
}
}
}