This commit is contained in:
2025-05-25 11:18:12 +00:00
parent 58f4a123d2
commit 5b33623c2d
15 changed files with 832 additions and 764 deletions

View File

@ -102,6 +102,7 @@ export class Email {
/**
* Validates an email address using smartmail's EmailAddressValidator
* For constructor validation, we only check syntax to avoid delays
* Supports RFC-compliant addresses including display names and bounce addresses.
*
* @param email The email address to validate
* @returns boolean indicating if the email is valid
@ -109,8 +110,69 @@ export class Email {
private isValidEmail(email: string): boolean {
if (!email || typeof email !== 'string') return false;
// Use smartmail's validation for better accuracy
return Email.emailValidator.isValidFormat(email);
// Handle empty return path (bounce address)
if (email === '<>' || email === '') {
return true; // Empty return path is valid for bounces per RFC 5321
}
// Extract email from display name format
const extractedEmail = this.extractEmailAddress(email);
if (!extractedEmail) return false;
// Convert IDN (International Domain Names) to ASCII for validation
let emailToValidate = extractedEmail;
const atIndex = extractedEmail.indexOf('@');
if (atIndex > 0) {
const localPart = extractedEmail.substring(0, atIndex);
const domainPart = extractedEmail.substring(atIndex + 1);
// Check if domain contains non-ASCII characters
if (/[^\x00-\x7F]/.test(domainPart)) {
try {
// Convert IDN to ASCII using the URL API (built-in punycode support)
const url = new URL(`http://${domainPart}`);
emailToValidate = `${localPart}@${url.hostname}`;
} catch (e) {
// If conversion fails, allow the original domain
// This supports testing and edge cases
emailToValidate = extractedEmail;
}
}
}
// Use smartmail's validation for the ASCII-converted email address
return Email.emailValidator.isValidFormat(emailToValidate);
}
/**
* Extracts the email address from a string that may contain a display name.
* Handles formats like:
* - simple@example.com
* - "John Doe" <john@example.com>
* - John Doe <john@example.com>
*
* @param emailString The email string to parse
* @returns The extracted email address or null
*/
private extractEmailAddress(emailString: string): string | null {
if (!emailString || typeof emailString !== 'string') return null;
emailString = emailString.trim();
// Handle empty return path first
if (emailString === '<>' || emailString === '') {
return '';
}
// Check for angle brackets format - updated regex to handle empty content
const angleMatch = emailString.match(/<([^>]*)>/);
if (angleMatch) {
// If matched but content is empty (e.g., <>), return empty string
return angleMatch[1].trim() || '';
}
// If no angle brackets, assume it's a plain email
return emailString.trim();
}
/**
@ -161,7 +223,11 @@ export class Email {
*/
public getFromDomain(): string | null {
try {
const parts = this.from.split('@');
const emailAddress = this.extractEmailAddress(this.from);
if (!emailAddress || emailAddress === '') {
return null;
}
const parts = emailAddress.split('@');
if (parts.length !== 2 || !parts[1]) {
return null;
}
@ -171,6 +237,84 @@ export class Email {
return null;
}
}
/**
* Gets the clean from email address without display name
* @returns The email address without display name
*/
public getFromAddress(): string {
const extracted = this.extractEmailAddress(this.from);
// Return extracted value if not null (including empty string for bounce messages)
const address = extracted !== null ? extracted : this.from;
// Convert IDN to ASCII for SMTP protocol
return this.convertIDNToASCII(address);
}
/**
* Converts IDN (International Domain Names) to ASCII
* @param email The email address to convert
* @returns The email with ASCII-converted domain
*/
private convertIDNToASCII(email: string): string {
if (!email || email === '') return email;
const atIndex = email.indexOf('@');
if (atIndex <= 0) return email;
const localPart = email.substring(0, atIndex);
const domainPart = email.substring(atIndex + 1);
// Check if domain contains non-ASCII characters
if (/[^\x00-\x7F]/.test(domainPart)) {
try {
// Convert IDN to ASCII using the URL API (built-in punycode support)
const url = new URL(`http://${domainPart}`);
return `${localPart}@${url.hostname}`;
} catch (e) {
// If conversion fails, return original
return email;
}
}
return email;
}
/**
* Gets clean to email addresses without display names
* @returns Array of email addresses without display names
*/
public getToAddresses(): string[] {
return this.to.map(email => {
const extracted = this.extractEmailAddress(email);
const address = extracted !== null ? extracted : email;
return this.convertIDNToASCII(address);
});
}
/**
* Gets clean cc email addresses without display names
* @returns Array of email addresses without display names
*/
public getCcAddresses(): string[] {
return this.cc.map(email => {
const extracted = this.extractEmailAddress(email);
const address = extracted !== null ? extracted : email;
return this.convertIDNToASCII(address);
});
}
/**
* Gets clean bcc email addresses without display names
* @returns Array of email addresses without display names
*/
public getBccAddresses(): string[] {
return this.bcc.map(email => {
const extracted = this.extractEmailAddress(email);
const address = extracted !== null ? extracted : email;
return this.convertIDNToASCII(address);
});
}
/**
* Gets all recipients (to, cc, bcc) as a unique array

View File

@ -54,7 +54,10 @@ export class CommandHandler extends EventEmitter {
* Send MAIL FROM command
*/
public async sendMailFrom(connection: ISmtpConnection, fromAddress: string): Promise<ISmtpResponse> {
const command = `${SMTP_COMMANDS.MAIL_FROM}:<${fromAddress}>`;
// Handle empty return path for bounce messages
const command = fromAddress === ''
? `${SMTP_COMMANDS.MAIL_FROM}:<>`
: `${SMTP_COMMANDS.MAIL_FROM}:<${fromAddress}>`;
return this.sendCommand(connection, command);
}
@ -77,15 +80,19 @@ export class CommandHandler extends EventEmitter {
* Send email data content
*/
public async sendDataContent(connection: ISmtpConnection, emailData: string): Promise<ISmtpResponse> {
// Ensure email data ends with CRLF.CRLF
let data = emailData;
// Normalize line endings to CRLF
let data = emailData.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n/g, '\r\n');
// Ensure email data ends with CRLF
if (!data.endsWith(LINE_ENDINGS.CRLF)) {
data += LINE_ENDINGS.CRLF;
}
data += '.' + LINE_ENDINGS.CRLF;
// Perform dot stuffing (escape lines starting with a dot)
data = data.replace(/\n\./g, '\n..');
data = data.replace(/\r\n\./g, '\r\n..');
// Add termination sequence
data += '.' + LINE_ENDINGS.CRLF;
return this.sendRawData(connection, data);
}
@ -306,7 +313,7 @@ export class CommandHandler extends EventEmitter {
const response = parseSmtpResponse(this.responseBuffer);
this.responseBuffer = '';
if (isSuccessCode(response.code) || response.code >= 400) {
if (isSuccessCode(response.code) || (response.code >= 300 && response.code < 400) || response.code >= 400) {
this.pendingCommand.resolve(response);
} else {
this.pendingCommand.reject(new Error(`Command failed: ${response.message}`));

View File

@ -57,20 +57,27 @@ export class SmtpClient extends EventEmitter {
*/
public async sendMail(email: Email): Promise<ISmtpSendResult> {
const startTime = Date.now();
const fromAddress = email.from;
const recipients = Array.isArray(email.to) ? email.to : [email.to];
// Extract clean email addresses without display names for SMTP operations
const fromAddress = email.getFromAddress();
const recipients = email.getToAddresses();
const ccRecipients = email.getCcAddresses();
const bccRecipients = email.getBccAddresses();
// Combine all recipients for SMTP operations
const allRecipients = [...recipients, ...ccRecipients, ...bccRecipients];
// Validate email addresses
if (!validateSender(fromAddress)) {
throw new Error(`Invalid sender address: ${fromAddress}`);
}
const recipientErrors = validateRecipients(recipients);
const recipientErrors = validateRecipients(allRecipients);
if (recipientErrors.length > 0) {
throw new Error(`Invalid recipients: ${recipientErrors.join(', ')}`);
}
logEmailSend('start', recipients, this.options);
logEmailSend('start', allRecipients, this.options);
let connection: ISmtpConnection | null = null;
const result: ISmtpSendResult = {
@ -79,7 +86,7 @@ export class SmtpClient extends EventEmitter {
rejectedRecipients: [],
envelope: {
from: fromAddress,
to: recipients
to: allRecipients
}
};
@ -114,8 +121,8 @@ export class SmtpClient extends EventEmitter {
throw new Error(`MAIL FROM failed: ${mailFromResponse.message}`);
}
// Send RCPT TO for each recipient
for (const recipient of recipients) {
// Send RCPT TO for each recipient (includes TO, CC, and BCC)
for (const recipient of allRecipients) {
try {
const rcptResponse = await this.commandHandler.sendRcptTo(connection, recipient);
if (rcptResponse.code >= 400) {

View File

@ -8,12 +8,28 @@ import type { ISmtpClientOptions, ISmtpAuthOptions } from '../interfaces.js';
/**
* Validate email address format
* Supports RFC-compliant addresses including empty return paths for bounces
*/
export function validateEmailAddress(email: string): boolean {
if (!email || typeof email !== 'string') {
if (typeof email !== 'string') {
return false;
}
return REGEX_PATTERNS.EMAIL_ADDRESS.test(email.trim());
const trimmed = email.trim();
// Handle empty return path for bounce messages (RFC 5321)
if (trimmed === '' || trimmed === '<>') {
return true;
}
// Handle display name formats
const angleMatch = trimmed.match(/<([^>]+)>/);
if (angleMatch) {
return REGEX_PATTERNS.EMAIL_ADDRESS.test(angleMatch[1]);
}
// Regular email validation
return REGEX_PATTERNS.EMAIL_ADDRESS.test(trimmed);
}
/**