feat: Update SMTP transport to use API key for authentication and improve error handling

This commit is contained in:
Siwat Sirichai 2025-08-19 01:57:21 +07:00
parent e22b064738
commit efb7dc43b7
4 changed files with 169 additions and 331 deletions

View file

@ -532,23 +532,41 @@ export class SMTPOverWSClient extends EventEmitter {
this.sendSMTPData('EHLO client\r\n');
// Wait for EHLO response
const onEhloResponse = (ehloMessage: SMTPFromServerMessage) => {
if (this.channelTimer) {
clearTimeout(this.channelTimer);
this.channelTimer = null;
}
const onEhloResponse = async (ehloMessage: SMTPFromServerMessage) => {
this.logger.debug('RX SMTP EHLO response', {
response: ehloMessage.data.trim(),
size: ehloMessage.data.length
});
if (ehloMessage.data.startsWith('250')) {
this.setState(ConnectionState.CHANNEL_READY);
this.emit('channelOpened');
this.logger.debug('SMTP channel ready after EHLO');
resolve();
try {
// Perform SMTP authentication
await this.performSMTPAuth();
if (this.channelTimer) {
clearTimeout(this.channelTimer);
this.channelTimer = null;
}
this.setState(ConnectionState.CHANNEL_READY);
this.emit('channelOpened');
this.logger.debug('SMTP channel ready after authentication');
resolve();
} catch (authError) {
if (this.channelTimer) {
clearTimeout(this.channelTimer);
this.channelTimer = null;
}
const error = ErrorFactory.fromChannelFailure(`SMTP authentication failed: ${(authError as Error).message}`);
this.emit('channelError', error);
reject(error);
}
} else {
if (this.channelTimer) {
clearTimeout(this.channelTimer);
this.channelTimer = null;
}
const error = ErrorFactory.fromChannelFailure(`EHLO rejected: ${ehloMessage.data.trim()}`);
this.emit('channelError', error);
reject(error);
@ -936,6 +954,43 @@ export class SMTPOverWSClient extends EventEmitter {
this.connectionStartTime = 0;
}
/**
* Perform SMTP authentication using API key
*/
private async performSMTPAuth(): Promise<void> {
return new Promise((resolve, reject) => {
// Use PLAIN authentication with "apikey" as username and API key as password
const username = 'apikey';
const password = this.config.apiKey;
// Create AUTH PLAIN credentials: \0username\0password (base64 encoded)
const credentials = Buffer.from(`\0${username}\0${password}`).toString('base64');
this.logger.debug('Performing SMTP authentication');
// Send AUTH PLAIN command
this.sendSMTPData(`AUTH PLAIN ${credentials}\r\n`);
// Wait for auth response
const onAuthResponse = (message: SMTPFromServerMessage) => {
this.logger.debug('RX SMTP auth response', {
response: message.data.trim(),
size: message.data.length
});
if (message.data.startsWith('235')) {
this.logger.debug('SMTP authentication successful');
resolve();
} else {
const error = new AuthenticationError(`SMTP AUTH failed: ${message.data.trim()}`);
reject(error);
}
};
this.once('smtp_from_server', onAuthResponse);
});
}
// Type-safe event emitter methods
public on<K extends keyof ClientEvents>(event: K, listener: ClientEvents[K]): this {
return super.on(event, listener);

View file

@ -21,10 +21,7 @@ export interface TransportOptions extends Omit<SMTPClientConfig, 'url' | 'apiKey
secure?: boolean;
/** API key for authentication */
auth: {
user: string; // API key
pass?: string; // Optional, for future use
};
apiKey: string;
/** Transport name */
name?: string;
@ -97,8 +94,17 @@ export class SMTPWSTransport extends EventEmitter {
const clientConfig: SMTPClientConfig = {
url,
apiKey: options.auth.user,
...options
apiKey: options.apiKey,
...(options.debug !== undefined && { debug: options.debug }),
...(options.maxQueueSize !== undefined && { maxQueueSize: options.maxQueueSize }),
...(options.reconnectInterval !== undefined && { reconnectInterval: options.reconnectInterval }),
...(options.maxReconnectAttempts !== undefined && { maxReconnectAttempts: options.maxReconnectAttempts }),
...(options.authTimeout !== undefined && { authTimeout: options.authTimeout }),
...(options.channelTimeout !== undefined && { channelTimeout: options.channelTimeout }),
...(options.messageTimeout !== undefined && { messageTimeout: options.messageTimeout }),
...(options.maxConcurrentMessages !== undefined && { maxConcurrentMessages: options.maxConcurrentMessages }),
...(options.logger !== undefined && { logger: options.logger }),
...(options.heartbeatInterval !== undefined && { heartbeatInterval: options.heartbeatInterval })
};
this.client = new SMTPOverWSClient(clientConfig);
@ -195,98 +201,94 @@ export class SMTPWSTransport extends EventEmitter {
* Internal method to send mail
*/
private async sendMail(mail: MailMessage): Promise<SendResult> {
const envelope = mail.message._envelope;
const envelope = this.extractEnvelope(mail);
const raw = mail.message._raw;
const messageId = this.generateMessageId();
const accepted: string[] = [];
const rejected: string[] = [];
const responses: string[] = [];
try {
// Debug envelope
console.log('DEBUG envelope:', {
from: envelope.from,
to: envelope.to,
messageId,
envelopeKeys: Object.keys(envelope || {}),
envelope: envelope
});
// EHLO is now sent automatically when channel opens
// Send MAIL FROM
const mailFromResponse = await this.client.sendSMTPCommand(`MAIL FROM: <${envelope.from}>\r\n`);
responses.push(mailFromResponse);
if (!this.isSuccessResponse(mailFromResponse)) {
throw new MessageError(`MAIL FROM rejected: ${mailFromResponse}`, messageId);
}
// Send RCPT TO for each recipient
for (const recipient of envelope.to) {
try {
const rcptResponse = await this.client.sendSMTPCommand(`RCPT TO: <${recipient}>\r\n`);
responses.push(rcptResponse);
if (this.isSuccessResponse(rcptResponse)) {
accepted.push(recipient);
} else {
rejected.push(recipient);
}
} catch (error) {
rejected.push(recipient);
responses.push(`Error for ${recipient}: ${(error as Error).message}`);
}
}
// If no recipients were accepted, fail
if (accepted.length === 0) {
throw new MessageError('All recipients were rejected', messageId);
}
// Send DATA command
const dataResponse = await this.client.sendSMTPCommand('DATA\r\n');
responses.push(dataResponse);
if (!this.isSuccessResponse(dataResponse)) {
throw new MessageError(`DATA command rejected: ${dataResponse}`, messageId);
}
// Send message content
const messageData = this.prepareMessageData(raw);
const contentResponse = await this.client.sendSMTPCommand(messageData);
responses.push(contentResponse);
if (!this.isSuccessResponse(contentResponse)) {
throw new MessageError(`Message data rejected: ${contentResponse}`, messageId);
}
// Send QUIT
try {
const quitResponse = await this.client.sendSMTPCommand('QUIT\r\n');
responses.push(quitResponse);
} catch (error) {
// QUIT failure is not critical
}
return {
envelope,
messageId,
accepted,
rejected,
pending: [],
response: responses.join('\n')
};
} catch (error) {
// Try to send RSET to clean up
try {
await this.client.sendSMTPCommand('RSET\r\n');
} catch {
// Ignore RSET failures
}
throw error;
// Build complete SMTP transaction
let smtpTransaction = '';
// MAIL FROM
smtpTransaction += `MAIL FROM: <${envelope.from}>\r\n`;
// RCPT TO for each recipient
for (const recipient of envelope.to) {
smtpTransaction += `RCPT TO: <${recipient}>\r\n`;
}
// DATA command
smtpTransaction += 'DATA\r\n';
// Message content
const messageData = this.prepareMessageData(raw);
smtpTransaction += messageData;
// QUIT
smtpTransaction += 'QUIT\r\n';
// Send complete SMTP transaction in one session
const response = await this.client.sendSMTPCommand(smtpTransaction);
return {
envelope,
messageId,
accepted: [...envelope.to],
rejected: [],
pending: [],
response
};
}
/**
* Extract envelope information from mail data
*/
private extractEnvelope(mail: MailMessage): Envelope {
// Try to get envelope from message first (if already set by nodemailer)
if (mail.message._envelope && mail.message._envelope.from && mail.message._envelope.to) {
return mail.message._envelope;
}
// Extract from mail data if envelope is not properly set
let from: string;
let to: string[] = [];
// Extract from address
if (mail.data.from) {
from = typeof mail.data.from === 'string' ? mail.data.from : mail.data.from.address;
} else if (mail.data.sender) {
from = typeof mail.data.sender === 'string' ? mail.data.sender : mail.data.sender.address;
} else {
throw new Error('No sender address specified');
}
// Extract to addresses
const addAddresses = (field: any) => {
if (!field) return;
if (typeof field === 'string') {
to.push(field);
} else if (Array.isArray(field)) {
field.forEach(addr => {
if (typeof addr === 'string') {
to.push(addr);
} else if (addr.address) {
to.push(addr.address);
}
});
} else if (field.address) {
to.push(field.address);
}
};
addAddresses(mail.data.to);
addAddresses(mail.data.cc);
addAddresses(mail.data.bcc);
if (to.length === 0) {
throw new Error('No recipient addresses specified');
}
return { from, to };
}
/**
@ -310,29 +312,21 @@ export class SMTPWSTransport extends EventEmitter {
});
}
/**
* Check if SMTP response indicates success
*/
private isSuccessResponse(response: string): boolean {
const statusCode = response.substring(0, 3);
return statusCode.startsWith('2') || statusCode.startsWith('3');
}
/**
* Prepare message data for transmission
*/
private prepareMessageData(raw: string | Buffer): string {
let messageData = raw.toString();
// Escape lines that start with a dot
messageData = messageData.replace(/\n\./g, '\n..');
// Ensure message ends with CRLF.CRLF
if (!messageData.endsWith('\r\n')) {
messageData += '\r\n';
}
messageData += '.\r\n';
// Escape lines that start with a dot
messageData = messageData.replace(/\n\./g, '\n..');
return messageData;
}
@ -342,6 +336,7 @@ export class SMTPWSTransport extends EventEmitter {
private generateMessageId(): string {
return `smtp-ws-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}
/**