diff --git a/examples/bulk-email.ts b/examples/bulk-email.ts deleted file mode 100644 index 054594e..0000000 --- a/examples/bulk-email.ts +++ /dev/null @@ -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: ` -

Hello ${recipient.name}!

-

This is your personalized newsletter for ${new Date().toLocaleDateString()}.

-

This email was delivered via our SMTP WebSocket transport system.

-
-

Newsletter #${index + 1} | Sent at ${new Date().toLocaleTimeString()}

- ` - }); - - 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 }; \ No newline at end of file diff --git a/examples/nodemailer-transport.ts b/examples/nodemailer-transport.ts index ef00c47..1c3f425 100644 --- a/examples/nodemailer-transport.ts +++ b/examples/nodemailer-transport.ts @@ -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: ` diff --git a/src/client.ts b/src/client.ts index ef574b6..806e214 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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 { + 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(event: K, listener: ClientEvents[K]): this { return super.on(event, listener); diff --git a/src/transport.ts b/src/transport.ts index b070881..f59e786 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -21,10 +21,7 @@ export interface TransportOptions extends Omit { - 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)}`; } + } /**