@454creative/easy-email
Version:
A framework-agnostic email service library for Node.js with observability and monitoring
595 lines • 27.7 kB
JavaScript
"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