update
This commit is contained in:
@ -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
|
||||
|
@ -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}`));
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
Reference in New Issue
Block a user