UNPKG

@454creative/easy-email

Version:

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

655 lines (589 loc) 22.1 kB
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; } }