@454creative/easy-email
Version:
A framework-agnostic email service library for Node.js with observability and monitoring
655 lines (589 loc) • 22.1 kB
text/typescript
import { Transporter, SentMessageInfo } from 'nodemailer';
import sgMail from '@sendgrid/mail';
import {
EmailOptions,
EmailResponse,
SmtpConfig,
SendGridConfig,
SesConfig,
EmailProviderType,
EmailProviderConfig,
} from '../interfaces/email-options.interface';
import { EmailRequest } from '../interfaces/email-request.interface';
import { ConfigurationError, ProviderError, ValidationError, EmailServiceError } from '../errors';
import { IEmailService } from '../interfaces/email-service.interface';
import { ObservabilityService } from './observability.service';
import { ObservabilityConfig } from '../interfaces/observability.interface';
import { ProviderFactory } from '../config/provider-factory';
import { SesService } from './ses.service';
import { EMAIL_CONSTANTS } from '../config/constants';
import { validateEmailRequest } from '../utils/email-validator';
/**
* EmailService - A reusable service for sending emails
*
* This service can be used in both NodeJS/JavaScript and NestJS/TypeScript projects.
*/
export class EmailService implements IEmailService {
private transporter?: Transporter;
private defaultFrom?: { email: string; name?: string };
private providerType!: EmailProviderType;
private sendGridConfigured: boolean = false;
private sesService?: SesService;
private observability: ObservabilityService;
/**
* Create an instance of EmailService
*
* @param providerConfig - Email provider configuration (SMTP, SendGrid, or SES)
* @param defaultFrom - Default sender email and name
* @param observabilityConfig - Optional observability configuration
*/
constructor(
providerConfig: EmailProviderConfig | SmtpConfig, // Support legacy constructor
defaultFrom?: { email: string; name?: string },
observabilityConfig?: ObservabilityConfig
) {
this.defaultFrom = defaultFrom || { email: 'noreply@example.com' };
this.observability = ObservabilityService.getInstance(observabilityConfig);
this.configureProvider(providerConfig);
}
/**
* Configure email provider based on configuration
* @private
*/
private configureProvider(providerConfig: EmailProviderConfig | SmtpConfig): void {
const config = ProviderFactory.createProviderConfig(providerConfig);
ProviderFactory.validateProviderConfig(config);
this.providerType = config.type;
if (this.providerType === EmailProviderType.SMTP) {
this.transporter = ProviderFactory.createSmtpTransporter(config.config as SmtpConfig);
} else if (this.providerType === EmailProviderType.SENDGRID) {
ProviderFactory.configureSendGrid(config.config as SendGridConfig);
this.sendGridConfigured = true;
} else if (this.providerType === EmailProviderType.SES) {
this.sesService = new SesService(config.config as SesConfig);
}
}
/**
* Verify email provider connection
*
* @returns Promise<boolean> - True if connection is successful
*/
async verifyConnection(): Promise<boolean> {
const startTime = Date.now();
try {
let success = false;
if (this.providerType === EmailProviderType.SMTP && this.transporter) {
await this.transporter.verify();
success = true;
} else if (this.providerType === EmailProviderType.SENDGRID && this.sendGridConfigured) {
// SendGrid doesn't have a direct verification method
// We could send a test email, but for now we'll just check if it's configured
success = this.sendGridConfigured;
} else if (this.providerType === EmailProviderType.SES && this.sesService) {
success = await this.sesService.verifyConnection();
}
const duration = Date.now() - startTime;
this.observability.trackConnectionVerification(this.providerType, success, duration);
return success;
} catch (error) {
const duration = Date.now() - startTime;
this.observability.trackConnectionVerification(this.providerType, false, duration);
console.error(EMAIL_CONSTANTS.LOG_MESSAGES.CONNECTION_FAILED, error);
return false;
}
}
/**
* Validate email options
* @private
*/
private validateEmailOptions(options: EmailOptions): void {
if (!options.from || !options.from.email) {
throw new ValidationError(EMAIL_CONSTANTS.VALIDATION_MESSAGES.SENDER_REQUIRED);
}
if (!options.to) {
throw new ValidationError(EMAIL_CONSTANTS.VALIDATION_MESSAGES.RECIPIENT_REQUIRED);
}
if (!options.subject) {
throw new ValidationError(EMAIL_CONSTANTS.VALIDATION_MESSAGES.SUBJECT_REQUIRED);
}
}
/**
* Centralized error handler for email operations
* @private
*/
private handleError(error: any, operation: string): EmailResponse {
console.error(`Failed to ${operation}:`, error);
return {
success: false,
error: {
message: error instanceof Error ? error.message : String(error),
code: error instanceof EmailServiceError ? error.code : 'UNKNOWN_ERROR',
provider: error instanceof ProviderError ? (error.provider as EmailProviderType) : undefined,
details: error instanceof EmailServiceError ? error.details : undefined,
},
};
}
/**
* Send email using the new EmailRequest interface
*
* @param request - Email request
* @returns Promise<EmailResponse> - Response with success status and message ID
*/
async sendEmail(request: EmailRequest): Promise<EmailResponse> {
const startTime = Date.now();
let emailOptions: EmailOptions | undefined;
try {
// Validate the request
const validation = validateEmailRequest(request);
if (!validation.isValid) {
return this.createErrorResponse(
new ValidationError(`Email validation failed: ${validation.errors.join(', ')}`),
this.providerType
);
}
// Convert to EmailOptions format
emailOptions = {
to: Array.isArray(request.to)
? request.to.map(email => ({ email }))
: { email: request.to },
subject: request.subject,
text: request.text,
html: request.html,
from: request.from ? { email: request.from } : (this.defaultFrom || { email: 'noreply@example.com' }),
cc: request.cc ? (Array.isArray(request.cc)
? request.cc.map(email => ({ email }))
: [{ email: request.cc }]) : undefined,
bcc: request.bcc ? (Array.isArray(request.bcc)
? request.bcc.map(email => ({ email }))
: [{ email: request.bcc }]) : undefined,
replyTo: request.replyTo,
attachments: request.attachments,
headers: request.headers,
};
const eventId = this.observability.trackEmailAttempt(emailOptions, this.providerType, startTime);
let response: EmailResponse;
if (this.providerType === EmailProviderType.SMTP && this.transporter) {
response = await this.sendWithSmtp(emailOptions);
} else if (this.providerType === EmailProviderType.SENDGRID && this.sendGridConfigured) {
response = await this.sendWithSendGrid(emailOptions);
} else if (this.providerType === EmailProviderType.SES && this.sesService) {
response = await this.sendWithSes(emailOptions);
} else {
throw new ConfigurationError(EMAIL_CONSTANTS.VALIDATION_MESSAGES.NO_PROVIDER_CONFIGURED);
}
const duration = Date.now() - startTime;
this.observability.trackEmailSuccess(eventId, response, duration, this.providerType);
return response;
} catch (error) {
const duration = Date.now() - startTime;
if (emailOptions) {
const eventId = this.observability.trackEmailAttempt(emailOptions, this.providerType, startTime);
this.observability.trackEmailFailure(eventId, error, duration, this.providerType);
}
return this.handleError(error, 'send email');
}
}
/**
* Send email using legacy EmailOptions interface (for backward compatibility)
*
* @param options - Email options
* @returns Promise<EmailResponse> - Response with success status and message ID
*/
async sendEmailLegacy(options: EmailOptions): Promise<EmailResponse> {
const startTime = Date.now();
const eventId = this.observability.trackEmailAttempt(options, this.providerType, startTime);
try {
// Use default from if not provided
if (!options.from && this.defaultFrom) {
options.from = this.defaultFrom;
}
// Validate required options
this.validateEmailOptions(options);
let response: EmailResponse;
if (this.providerType === EmailProviderType.SMTP && this.transporter) {
response = await this.sendWithSmtp(options);
} else if (this.providerType === EmailProviderType.SENDGRID && this.sendGridConfigured) {
response = await this.sendWithSendGrid(options);
} else if (this.providerType === EmailProviderType.SES && this.sesService) {
response = await this.sendWithSes(options);
} else {
throw new ConfigurationError(EMAIL_CONSTANTS.VALIDATION_MESSAGES.NO_PROVIDER_CONFIGURED);
}
const duration = Date.now() - startTime;
this.observability.trackEmailSuccess(eventId, response, duration, this.providerType);
return response;
} catch (error) {
const duration = Date.now() - startTime;
this.observability.trackEmailFailure(eventId, error, duration, this.providerType);
return this.handleError(error, 'send email');
}
}
/**
* Create standardized email response
* @private
*/
private createSuccessResponse(messageId?: string): EmailResponse {
return {
success: true,
messageId,
};
}
/**
* Create standardized error response
* @private
*/
private createErrorResponse(error: any, provider: EmailProviderType): EmailResponse {
const providerError = new ProviderError(
`${provider} error: ${error instanceof Error ? error.message : String(error)}`,
provider
);
if (provider === EmailProviderType.SMTP) {
const smtpError = error as SentMessageInfo;
providerError.details = {
rejected: smtpError.rejected,
accepted: smtpError.accepted,
smtpResponse: smtpError.response,
};
} else if (provider === EmailProviderType.SENDGRID) {
// For SendGrid, preserve the enhanced error details if they exist
if (error instanceof ProviderError && error.details) {
providerError.details = error.details;
} else {
providerError.details = {
sendgridResponse: error,
};
}
} else if (provider === EmailProviderType.SES) {
providerError.details = {
sesResponse: error,
};
}
return {
success: false,
error: {
message: providerError.message,
code: providerError.code,
provider: providerError.provider as EmailProviderType,
details: providerError.details,
},
};
}
/**
* Send an email using SMTP
*
* @param options - Email options
* @returns Promise<EmailResponse> - Response with success status and message ID
* @private
*/
protected async sendWithSmtp(options: EmailOptions): Promise<EmailResponse> {
if (!this.transporter) {
throw new ConfigurationError(EMAIL_CONSTANTS.VALIDATION_MESSAGES.SMTP_NOT_CONFIGURED);
}
try {
const info = await this.transporter.sendMail({
from: options.from ? `${options.from.name || EMAIL_CONSTANTS.DEFAULTS.FROM_NAME} <${options.from.email}>` : undefined,
to: this.formatRecipients(options.to),
cc: options.cc ? this.formatRecipients(options.cc) : undefined,
bcc: options.bcc ? this.formatRecipients(options.bcc) : undefined,
subject: options.subject,
text: options.text,
html: options.html,
attachments: options.attachments,
replyTo: options.replyTo,
headers: options.headers,
});
return this.createSuccessResponse(info.messageId);
} catch (error) {
return this.createErrorResponse(error, EmailProviderType.SMTP);
}
}
/**
* Send an email using SendGrid
*
* @param options - Email options
* @returns Promise<EmailResponse> - Response with success status and message ID
* @private
*/
private async sendWithSendGrid(options: EmailOptions): Promise<EmailResponse> {
try {
// Validate required fields for SendGrid
if (!options.from?.email) {
throw new ValidationError('Sender email is required for SendGrid');
}
if (!options.to) {
throw new ValidationError('Recipient is required for SendGrid');
}
if (!options.subject) {
throw new ValidationError('Subject is required for SendGrid');
}
// Build content array
const content: Array<{ type: string; value: string }> = [];
// Add text content (SendGrid requires at least one content type)
if (options.text) {
content.push({
type: 'text/plain',
value: options.text,
});
}
// Add HTML content
if (options.html) {
content.push({
type: 'text/html',
value: options.html,
});
}
// Ensure at least one content type is provided
if (content.length === 0) {
content.push({
type: 'text/plain',
value: 'No content provided',
});
}
// Build SendGrid message
const msg: any = {
from: {
email: options.from.email,
name: options.from.name || '',
},
subject: options.subject,
content,
};
// Handle recipients
if (Array.isArray(options.to)) {
msg.personalizations = [
{
to: options.to.map(recipient => ({
email: recipient.email,
name: recipient.name || '',
})),
},
];
} else {
msg.personalizations = [
{
to: [
{
email: options.to.email,
name: options.to.name || '',
},
],
},
];
}
// Handle CC recipients
if (options.cc) {
if (Array.isArray(options.cc)) {
msg.personalizations[0].cc = options.cc.map(recipient => ({
email: recipient.email,
name: recipient.name || '',
}));
} else {
msg.personalizations[0].cc = [
{
email: options.cc.email,
name: options.cc.name || '',
},
];
}
}
// Handle BCC recipients
if (options.bcc) {
if (Array.isArray(options.bcc)) {
msg.personalizations[0].bcc = options.bcc.map(recipient => ({
email: recipient.email,
name: recipient.name || '',
}));
} else {
msg.personalizations[0].bcc = [
{
email: options.bcc.email,
name: options.bcc.name || '',
},
];
}
}
// Handle reply-to
if (options.replyTo) {
msg.replyTo = {
email: options.replyTo,
};
}
// Handle custom headers
if (options.headers) {
msg.personalizations[0].headers = options.headers;
}
// Handle attachments
if (options.attachments && options.attachments.length > 0) {
msg.attachments = options.attachments.map(attachment => ({
filename: attachment.filename,
content: this.encodeAttachmentContent(attachment.content),
type: attachment.contentType || 'application/octet-stream',
disposition: 'attachment',
}));
}
// Send the email
const response = await sgMail.send(msg);
return this.createSuccessResponse(response[0]?.headers['x-message-id'] || undefined);
} catch (error) {
// Enhanced error handling for SendGrid with specific error messages
let errorMessage = 'SendGrid error';
let errorDetails: any = {};
if (error instanceof Error) {
errorMessage = error.message;
// Enhanced SendGrid error analysis
const errorMessageLower = error.message.toLowerCase();
// Check for specific SendGrid error types
if (errorMessageLower.includes('unauthorized') || errorMessageLower.includes('401')) {
errorMessage = 'SendGrid API key is invalid or unauthorized. Please check your API key and ensure it has the correct permissions.';
errorDetails = {
errorType: 'INVALID_API_KEY',
suggestion: 'Verify your SendGrid API key in the SendGrid dashboard and ensure it has "Mail Send" permissions.',
statusCode: 401
};
} else if (errorMessageLower.includes('forbidden') || errorMessageLower.includes('403')) {
errorMessage = 'SendGrid access forbidden. This usually means the sender email is not verified or the API key lacks proper permissions.';
errorDetails = {
errorType: 'FORBIDDEN',
suggestion: 'Verify your sender email in SendGrid dashboard and check API key permissions.',
statusCode: 403
};
} else if (errorMessageLower.includes('bad request') || errorMessageLower.includes('400')) {
errorMessage = 'SendGrid bad request. This could be due to invalid email format, missing required fields, or unverified sender.';
errorDetails = {
errorType: 'BAD_REQUEST',
suggestion: 'Check email format, verify sender email, and ensure all required fields are provided.',
statusCode: 400
};
} else if (errorMessageLower.includes('rate limit') || errorMessageLower.includes('429') || errorMessageLower.includes('too many requests')) {
errorMessage = 'SendGrid rate limit exceeded. Please wait before sending more emails.';
errorDetails = {
errorType: 'RATE_LIMIT_EXCEEDED',
suggestion: 'Implement rate limiting or upgrade your SendGrid plan.',
statusCode: 429
};
} else if (errorMessageLower.includes('quota exceeded')) {
errorMessage = 'SendGrid sending quota exceeded. Please upgrade your plan or wait until quota resets.';
errorDetails = {
errorType: 'QUOTA_EXCEEDED',
suggestion: 'Check your SendGrid account limits or upgrade your plan.',
statusCode: 429
};
}
// Try to extract SendGrid error response details
try {
const errorResponse = (error as any).response;
if (errorResponse?.body) {
errorDetails = {
...errorDetails,
sendgridError: errorResponse.body,
statusCode: errorResponse.statusCode,
headers: errorResponse.headers,
};
}
} catch (parseError) {
// If we can't parse the error, include the raw error
errorDetails = {
...errorDetails,
rawError: error
};
}
}
return this.createErrorResponse(
new ProviderError(errorMessage, EmailProviderType.SENDGRID, errorDetails),
EmailProviderType.SENDGRID
);
}
}
/**
* Send an email using SES
*
* @param options - Email options
* @returns Promise<EmailResponse> - Response with success status and message ID
* @private
*/
private async sendWithSes(options: EmailOptions): Promise<EmailResponse> {
if (!this.sesService) {
throw new ConfigurationError('SES service is not configured');
}
try {
return await this.sesService.sendEmail(options);
} catch (error) {
return this.createErrorResponse(error, EmailProviderType.SES);
}
}
/**
* Encode attachment content for SendGrid
* @private
*/
private encodeAttachmentContent(content: any): string {
if (content instanceof Buffer) {
return content.toString('base64');
} else if (typeof content === 'string') {
return Buffer.from(content).toString('base64');
} else {
return Buffer.from('').toString('base64');
}
}
/**
* Send a plain text email
*
* @param to - Recipient(s)
* @param subject - Email subject
* @param text - Plain text content
* @param options - Additional email options
* @returns Promise<EmailResponse> - Response with success status and message ID
*/
async sendPlainText(
to: EmailOptions['to'],
subject: string,
text: string,
options?: Partial<EmailOptions>
): Promise<EmailResponse> {
return this.sendEmailLegacy({
to,
subject,
text,
...options,
from: options?.from || this.defaultFrom || { email: 'noreply@example.com' },
});
}
/**
* Send an HTML email
*
* @param to - Recipient(s)
* @param subject - Email subject
* @param html - HTML content
* @param options - Additional email options
* @returns Promise<EmailResponse> - Response with success status and message ID
*/
async sendHtml(
to: EmailOptions['to'],
subject: string,
html: string,
options?: Partial<EmailOptions>
): Promise<EmailResponse> {
return this.sendEmailLegacy({
to,
subject,
html,
...options,
from: options?.from || this.defaultFrom || { email: 'noreply@example.com' },
});
}
/**
* Format recipients to the format expected by nodemailer
*
* @param recipients - Single recipient or array of recipients
* @returns string - Formatted recipients string
*/
private formatRecipients(recipients: EmailOptions['to']): string {
if (Array.isArray(recipients)) {
return recipients
.map(recipient =>
recipient.name ? `${recipient.name} <${recipient.email}>` : recipient.email
)
.join(', ');
}
return recipients.name ? `${recipients.name} <${recipients.email}>` : recipients.email;
}
}