feat: Update SMTP transport to use API key for authentication and improve error handling
This commit is contained in:
parent
e22b064738
commit
efb7dc43b7
4 changed files with 169 additions and 331 deletions
|
@ -1,210 +0,0 @@
|
|||
#!/usr/bin/env ts-node
|
||||
|
||||
/**
|
||||
* Bulk email sending example using Nodemailer transport
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
import { createTransport } from '../src/index';
|
||||
|
||||
async function bulkEmailExample() {
|
||||
console.log('Bulk Email Example using SMTP WebSocket Transport\n');
|
||||
|
||||
// Create the WebSocket transport
|
||||
const transport = createTransport({
|
||||
host: 'localhost',
|
||||
port: 3000,
|
||||
auth: {
|
||||
user: 'your-api-key-here'
|
||||
},
|
||||
maxQueueSize: 1000, // Handle large queues
|
||||
debug: false // Disable debug for bulk operations
|
||||
});
|
||||
|
||||
const transporter = nodemailer.createTransporter(transport);
|
||||
|
||||
// Sample recipient list
|
||||
const recipients = [
|
||||
{ email: 'user1@example.com', name: 'User One' },
|
||||
{ email: 'user2@example.com', name: 'User Two' },
|
||||
{ email: 'user3@example.com', name: 'User Three' },
|
||||
{ email: 'user4@example.com', name: 'User Four' },
|
||||
{ email: 'user5@example.com', name: 'User Five' }
|
||||
];
|
||||
|
||||
console.log(`Sending emails to ${recipients.length} recipients...\n`);
|
||||
|
||||
const results = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
// Send emails concurrently (transport handles queuing automatically)
|
||||
const emailPromises = recipients.map(async (recipient, index) => {
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: 'newsletter@example.com',
|
||||
to: recipient.email,
|
||||
subject: `Newsletter #${index + 1} - ${new Date().toLocaleDateString()}`,
|
||||
text: `Hello ${recipient.name}!\n\nThis is your personalized newsletter.\n\nBest regards,\nThe Newsletter Team`,
|
||||
html: `
|
||||
<h2>Hello ${recipient.name}!</h2>
|
||||
<p>This is your personalized newsletter for ${new Date().toLocaleDateString()}.</p>
|
||||
<p>This email was delivered via our SMTP WebSocket transport system.</p>
|
||||
<hr>
|
||||
<p><small>Newsletter #${index + 1} | Sent at ${new Date().toLocaleTimeString()}</small></p>
|
||||
`
|
||||
});
|
||||
|
||||
console.log(`Email ${index + 1}/${recipients.length} sent to ${recipient.email}`);
|
||||
return {
|
||||
success: true,
|
||||
recipient: recipient.email,
|
||||
messageId: info.messageId,
|
||||
response: info.response
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to send email ${index + 1} to ${recipient.email}:`, (error as Error).message);
|
||||
return {
|
||||
success: false,
|
||||
recipient: recipient.email,
|
||||
error: (error as Error).message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all emails to complete
|
||||
const emailResults = await Promise.allSettled(emailPromises);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Process results
|
||||
let successful = 0;
|
||||
let failed = 0;
|
||||
|
||||
emailResults.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
results.push(result.value);
|
||||
if (result.value.success) {
|
||||
successful++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} else {
|
||||
failed++;
|
||||
results.push({
|
||||
success: false,
|
||||
error: result.reason.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Display summary
|
||||
console.log('\n--- Bulk Email Results ---');
|
||||
console.log(`Total emails: ${recipients.length}`);
|
||||
console.log(`Successful: ${successful}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Duration: ${(duration / 1000).toFixed(2)} seconds`);
|
||||
console.log(`Average time per email: ${(duration / recipients.length).toFixed(0)}ms`);
|
||||
|
||||
// Display failed emails if any
|
||||
if (failed > 0) {
|
||||
console.log('\nFailed emails:');
|
||||
results.forEach((result, index) => {
|
||||
if (!result.success) {
|
||||
console.log(` ${index + 1}. ${result.recipient || 'Unknown'}: ${result.error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close transport
|
||||
await transport.close();
|
||||
console.log('\nTransport closed');
|
||||
}
|
||||
|
||||
// Advanced bulk email with throttling
|
||||
async function throttledBulkEmail() {
|
||||
console.log('\nThrottled Bulk Email Example\n');
|
||||
|
||||
const transport = createTransport({
|
||||
host: 'localhost',
|
||||
port: 3000,
|
||||
auth: {
|
||||
user: 'your-api-key-here'
|
||||
}
|
||||
});
|
||||
|
||||
const transporter = nodemailer.createTransporter(transport);
|
||||
|
||||
// Generate larger recipient list
|
||||
const recipients = Array.from({ length: 20 }, (_, i) => ({
|
||||
email: `user${i + 1}@example.com`,
|
||||
name: `User ${i + 1}`
|
||||
}));
|
||||
|
||||
console.log(`Sending throttled emails to ${recipients.length} recipients...`);
|
||||
console.log('Processing 5 emails at a time with 1 second delay between batches\n');
|
||||
|
||||
const batchSize = 5;
|
||||
const batches = [];
|
||||
|
||||
for (let i = 0; i < recipients.length; i += batchSize) {
|
||||
batches.push(recipients.slice(i, i + batchSize));
|
||||
}
|
||||
|
||||
let totalSuccessful = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
|
||||
const batch = batches[batchIndex];
|
||||
console.log(`Processing batch ${batchIndex + 1}/${batches.length} (${batch.length} emails)...`);
|
||||
|
||||
const batchPromises = batch.map(async (recipient) => {
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: 'batch@example.com',
|
||||
to: recipient.email,
|
||||
subject: `Batch Email - ${recipient.name}`,
|
||||
text: `Hello ${recipient.name}, this is a batch email.`
|
||||
});
|
||||
return { success: true, email: recipient.email };
|
||||
} catch (error) {
|
||||
return { success: false, email: recipient.email, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
|
||||
const batchSuccessful = batchResults.filter(r => r.success).length;
|
||||
const batchFailed = batchResults.filter(r => !r.success).length;
|
||||
|
||||
totalSuccessful += batchSuccessful;
|
||||
totalFailed += batchFailed;
|
||||
|
||||
console.log(`Batch ${batchIndex + 1} complete: ${batchSuccessful} successful, ${batchFailed} failed`);
|
||||
|
||||
// Wait between batches (except for the last one)
|
||||
if (batchIndex < batches.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nThrottled bulk email complete: ${totalSuccessful} successful, ${totalFailed} failed`);
|
||||
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
// Run the examples
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
try {
|
||||
await bulkEmailExample();
|
||||
await throttledBulkEmail();
|
||||
console.log('\nBulk email examples completed successfully');
|
||||
} catch (error) {
|
||||
console.error('\nExamples failed:', error);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export { bulkEmailExample, throttledBulkEmail };
|
|
@ -13,12 +13,10 @@ async function nodemailerTransportExample() {
|
|||
// Create the WebSocket transport
|
||||
const transport = createTransport({
|
||||
host: '192.168.0.62',
|
||||
apiKey: 'cebc9a7f-4e0c-4fda-9dd0-85f48c02800c',
|
||||
port: 80,
|
||||
secure: false, // Set to true for wss://
|
||||
auth: {
|
||||
user: 'cebc9a7f-4e0c-4fda-9dd0-85f48c02800c' // Your SMTP relay API key
|
||||
},
|
||||
debug: true
|
||||
debug: false
|
||||
});
|
||||
|
||||
// Create Nodemailer transporter
|
||||
|
@ -39,8 +37,8 @@ async function nodemailerTransportExample() {
|
|||
console.log('Sending test email...');
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
from: 'cudconnex@satitm.chula.ac.th',
|
||||
to: 'siwat.s@chula.ac.th',
|
||||
subject: 'Test Email via SMTP WebSocket',
|
||||
text: 'This email was sent using the SMTP WebSocket transport!',
|
||||
html: `
|
||||
|
|
|
@ -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);
|
||||
|
|
201
src/transport.ts
201
src/transport.ts
|
@ -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
|
||||
});
|
||||
// Build complete SMTP transaction
|
||||
let smtpTransaction = '';
|
||||
|
||||
// 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);
|
||||
// MAIL FROM
|
||||
smtpTransaction += `MAIL FROM: <${envelope.from}>\r\n`;
|
||||
|
||||
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;
|
||||
// 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)}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue