UNPKG

@454creative/easy-email

Version:

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

595 lines 27.7 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.EmailService = void 0; const mail_1 = __importDefault(require("@sendgrid/mail")); const email_options_interface_1 = require("../interfaces/email-options.interface"); const errors_1 = require("../errors"); const observability_service_1 = require("./observability.service"); const provider_factory_1 = require("../config/provider-factory"); const ses_service_1 = require("./ses.service"); const constants_1 = require("../config/constants"); const email_validator_1 = require("../utils/email-validator"); /** * EmailService - A reusable service for sending emails * * This service can be used in both NodeJS/JavaScript and NestJS/TypeScript projects. */ class EmailService { /** * 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, // Support legacy constructor defaultFrom, observabilityConfig) { this.sendGridConfigured = false; this.defaultFrom = defaultFrom || { email: 'noreply@example.com' }; this.observability = observability_service_1.ObservabilityService.getInstance(observabilityConfig); this.configureProvider(providerConfig); } /** * Configure email provider based on configuration * @private */ configureProvider(providerConfig) { const config = provider_factory_1.ProviderFactory.createProviderConfig(providerConfig); provider_factory_1.ProviderFactory.validateProviderConfig(config); this.providerType = config.type; if (this.providerType === email_options_interface_1.EmailProviderType.SMTP) { this.transporter = provider_factory_1.ProviderFactory.createSmtpTransporter(config.config); } else if (this.providerType === email_options_interface_1.EmailProviderType.SENDGRID) { provider_factory_1.ProviderFactory.configureSendGrid(config.config); this.sendGridConfigured = true; } else if (this.providerType === email_options_interface_1.EmailProviderType.SES) { this.sesService = new ses_service_1.SesService(config.config); } } /** * Verify email provider connection * * @returns Promise<boolean> - True if connection is successful */ verifyConnection() { return __awaiter(this, void 0, void 0, function* () { const startTime = Date.now(); try { let success = false; if (this.providerType === email_options_interface_1.EmailProviderType.SMTP && this.transporter) { yield this.transporter.verify(); success = true; } else if (this.providerType === email_options_interface_1.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 === email_options_interface_1.EmailProviderType.SES && this.sesService) { success = yield 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(constants_1.EMAIL_CONSTANTS.LOG_MESSAGES.CONNECTION_FAILED, error); return false; } }); } /** * Validate email options * @private */ validateEmailOptions(options) { if (!options.from || !options.from.email) { throw new errors_1.ValidationError(constants_1.EMAIL_CONSTANTS.VALIDATION_MESSAGES.SENDER_REQUIRED); } if (!options.to) { throw new errors_1.ValidationError(constants_1.EMAIL_CONSTANTS.VALIDATION_MESSAGES.RECIPIENT_REQUIRED); } if (!options.subject) { throw new errors_1.ValidationError(constants_1.EMAIL_CONSTANTS.VALIDATION_MESSAGES.SUBJECT_REQUIRED); } } /** * Centralized error handler for email operations * @private */ handleError(error, operation) { console.error(`Failed to ${operation}:`, error); return { success: false, error: { message: error instanceof Error ? error.message : String(error), code: error instanceof errors_1.EmailServiceError ? error.code : 'UNKNOWN_ERROR', provider: error instanceof errors_1.ProviderError ? error.provider : undefined, details: error instanceof errors_1.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 */ sendEmail(request) { return __awaiter(this, void 0, void 0, function* () { const startTime = Date.now(); let emailOptions; try { // Validate the request const validation = (0, email_validator_1.validateEmailRequest)(request); if (!validation.isValid) { return this.createErrorResponse(new errors_1.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; if (this.providerType === email_options_interface_1.EmailProviderType.SMTP && this.transporter) { response = yield this.sendWithSmtp(emailOptions); } else if (this.providerType === email_options_interface_1.EmailProviderType.SENDGRID && this.sendGridConfigured) { response = yield this.sendWithSendGrid(emailOptions); } else if (this.providerType === email_options_interface_1.EmailProviderType.SES && this.sesService) { response = yield this.sendWithSes(emailOptions); } else { throw new errors_1.ConfigurationError(constants_1.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 */ sendEmailLegacy(options) { return __awaiter(this, void 0, void 0, function* () { 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; if (this.providerType === email_options_interface_1.EmailProviderType.SMTP && this.transporter) { response = yield this.sendWithSmtp(options); } else if (this.providerType === email_options_interface_1.EmailProviderType.SENDGRID && this.sendGridConfigured) { response = yield this.sendWithSendGrid(options); } else if (this.providerType === email_options_interface_1.EmailProviderType.SES && this.sesService) { response = yield this.sendWithSes(options); } else { throw new errors_1.ConfigurationError(constants_1.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 */ createSuccessResponse(messageId) { return { success: true, messageId, }; } /** * Create standardized error response * @private */ createErrorResponse(error, provider) { const providerError = new errors_1.ProviderError(`${provider} error: ${error instanceof Error ? error.message : String(error)}`, provider); if (provider === email_options_interface_1.EmailProviderType.SMTP) { const smtpError = error; providerError.details = { rejected: smtpError.rejected, accepted: smtpError.accepted, smtpResponse: smtpError.response, }; } else if (provider === email_options_interface_1.EmailProviderType.SENDGRID) { // For SendGrid, preserve the enhanced error details if they exist if (error instanceof errors_1.ProviderError && error.details) { providerError.details = error.details; } else { providerError.details = { sendgridResponse: error, }; } } else if (provider === email_options_interface_1.EmailProviderType.SES) { providerError.details = { sesResponse: error, }; } return { success: false, error: { message: providerError.message, code: providerError.code, provider: providerError.provider, details: providerError.details, }, }; } /** * Send an email using SMTP * * @param options - Email options * @returns Promise<EmailResponse> - Response with success status and message ID * @private */ sendWithSmtp(options) { return __awaiter(this, void 0, void 0, function* () { if (!this.transporter) { throw new errors_1.ConfigurationError(constants_1.EMAIL_CONSTANTS.VALIDATION_MESSAGES.SMTP_NOT_CONFIGURED); } try { const info = yield this.transporter.sendMail({ from: options.from ? `${options.from.name || constants_1.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, email_options_interface_1.EmailProviderType.SMTP); } }); } /** * Send an email using SendGrid * * @param options - Email options * @returns Promise<EmailResponse> - Response with success status and message ID * @private */ sendWithSendGrid(options) { return __awaiter(this, void 0, void 0, function* () { var _a, _b; try { // Validate required fields for SendGrid if (!((_a = options.from) === null || _a === void 0 ? void 0 : _a.email)) { throw new errors_1.ValidationError('Sender email is required for SendGrid'); } if (!options.to) { throw new errors_1.ValidationError('Recipient is required for SendGrid'); } if (!options.subject) { throw new errors_1.ValidationError('Subject is required for SendGrid'); } // Build content array const content = []; // 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 = { 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 = yield mail_1.default.send(msg); return this.createSuccessResponse(((_b = response[0]) === null || _b === void 0 ? void 0 : _b.headers['x-message-id']) || undefined); } catch (error) { // Enhanced error handling for SendGrid with specific error messages let errorMessage = 'SendGrid error'; let errorDetails = {}; 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.response; if (errorResponse === null || errorResponse === void 0 ? void 0 : errorResponse.body) { errorDetails = Object.assign(Object.assign({}, errorDetails), { sendgridError: errorResponse.body, statusCode: errorResponse.statusCode, headers: errorResponse.headers }); } } catch (parseError) { // If we can't parse the error, include the raw error errorDetails = Object.assign(Object.assign({}, errorDetails), { rawError: error }); } } return this.createErrorResponse(new errors_1.ProviderError(errorMessage, email_options_interface_1.EmailProviderType.SENDGRID, errorDetails), email_options_interface_1.EmailProviderType.SENDGRID); } }); } /** * Send an email using SES * * @param options - Email options * @returns Promise<EmailResponse> - Response with success status and message ID * @private */ sendWithSes(options) { return __awaiter(this, void 0, void 0, function* () { if (!this.sesService) { throw new errors_1.ConfigurationError('SES service is not configured'); } try { return yield this.sesService.sendEmail(options); } catch (error) { return this.createErrorResponse(error, email_options_interface_1.EmailProviderType.SES); } }); } /** * Encode attachment content for SendGrid * @private */ encodeAttachmentContent(content) { 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 */ sendPlainText(to, subject, text, options) { return __awaiter(this, void 0, void 0, function* () { return this.sendEmailLegacy(Object.assign(Object.assign({ to, subject, text }, options), { from: (options === null || options === void 0 ? void 0 : 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 */ sendHtml(to, subject, html, options) { return __awaiter(this, void 0, void 0, function* () { return this.sendEmailLegacy(Object.assign(Object.assign({ to, subject, html }, options), { from: (options === null || options === void 0 ? void 0 : 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 */ formatRecipients(recipients) { 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; } } exports.EmailService = EmailService; //# sourceMappingURL=email.service.js.map