Quellcode durchsuchen

fix: properly send EHLO after STARTTLS in SMTP #29

The SMTP protocol requires sending EHLO again after the TLS connection
is established via STARTTLS. The previous implementation was trying to
send EHLO immediately after initiating the TLS upgrade, but before the
TLS handshake completed.

This fix adds a 'secureConnect' event listener to wait for the TLS
handshake to complete before sending the EHLO command.

Fixes the error: '503 5.5.1 Error: send HELO/EHLO first'
Claude vor 9 Monaten
Ursprung
Commit
5d4f614fe1
1 geänderte Dateien mit 212 neuen und 0 gelöschten Zeilen
  1. 212 0
      src/emailSender.js

+ 212 - 0
src/emailSender.js

@@ -0,0 +1,212 @@
+/**
+ * SMTP Email Sender using Node.js built-in modules
+ * Zero external dependencies - uses net and tls modules
+ */
+import net from 'net';
+import tls from 'tls';
+import logger from './logger.js';
+
+export class EmailSender {
+  constructor(config) {
+    this.host = config.host;
+    this.port = config.port;
+    this.secure = config.secure || false; // Use TLS from start (port 465)
+    this.user = config.user;
+    this.pass = config.pass;
+    this.from = config.from || config.user;
+  }
+
+  /**
+   * Send an email
+   * @param {Object} options - Email options
+   * @param {string} options.to - Recipient email address
+   * @param {string} options.subject - Email subject
+   * @param {string} options.text - Plain text body
+   * @param {string} options.html - HTML body (optional)
+   */
+  async send(options) {
+    const { to, subject, text, html } = options;
+
+    if (!to || !subject || (!text && !html)) {
+      throw new Error('Missing required email fields: to, subject, and text/html');
+    }
+
+    return new Promise((resolve, reject) => {
+      let socket;
+      let buffer = '';
+      let step = 0;
+
+      const steps = [
+        { command: null, expected: 220 }, // Server greeting
+        { command: `EHLO ${this.host}\r\n`, expected: 250 }, // EHLO
+        { command: 'STARTTLS\r\n', expected: 220 }, // STARTTLS (if not secure)
+        { command: `EHLO ${this.host}\r\n`, expected: 250 }, // EHLO after STARTTLS
+        { command: 'AUTH LOGIN\r\n', expected: 334 }, // AUTH LOGIN
+        { command: `${Buffer.from(this.user).toString('base64')}\r\n`, expected: 334 }, // Username
+        { command: `${Buffer.from(this.pass).toString('base64')}\r\n`, expected: 235 }, // Password
+        { command: `MAIL FROM:<${this.from}>\r\n`, expected: 250 }, // MAIL FROM
+        { command: `RCPT TO:<${to}>\r\n`, expected: 250 }, // RCPT TO
+        { command: 'DATA\r\n', expected: 354 }, // DATA
+        { command: null, expected: 250 }, // Email content + end
+      ];
+
+      // Adjust steps if already secure (skip STARTTLS)
+      if (this.secure) {
+        steps.splice(2, 2); // Remove STARTTLS and second EHLO
+      }
+
+      const onData = (data) => {
+        buffer += data.toString();
+
+        // Process complete responses (ending with \r\n)
+        const lines = buffer.split('\r\n');
+        buffer = lines.pop(); // Keep incomplete line in buffer
+
+        for (const line of lines) {
+          if (!line) continue;
+
+          const code = parseInt(line.substring(0, 3));
+          logger.info(`SMTP: ${line}`);
+
+          // Check if this is a multi-line response (format: "250-...")
+          if (line[3] === '-') {
+            continue; // Wait for final line
+          }
+
+          const currentStep = steps[step];
+
+          if (code !== currentStep.expected) {
+            const error = new Error(`SMTP error at step ${step}: ${line}`);
+            socket.end();
+            return reject(error);
+          }
+
+          step++;
+
+          if (step < steps.length) {
+            const nextStep = steps[step];
+
+            if (nextStep.command === null && step === 0) {
+              // Server greeting received, send EHLO
+              step++;
+              socket.write(steps[step].command);
+            } else if (nextStep.command === null && step === steps.length - 1) {
+              // Send email content
+              const message = this._buildMessage(to, subject, text, html);
+              socket.write(message);
+              socket.write('\r\n.\r\n'); // End of DATA
+            } else if (nextStep.command === 'STARTTLS\r\n' && this.secure) {
+              // Skip STARTTLS if already secure
+              step++;
+              socket.write(steps[step].command);
+            } else if (currentStep.command === 'STARTTLS\r\n' && code === 220) {
+              // Upgrade to TLS
+              const secureSocket = tls.connect({
+                socket: socket,
+                servername: this.host,
+              });
+
+              secureSocket.on('secureConnect', () => {
+                // TLS handshake complete, now send EHLO
+                logger.info('TLS connection established');
+                secureSocket.write(nextStep.command);
+              });
+
+              secureSocket.on('data', onData);
+              secureSocket.on('error', (err) => {
+                logger.error('TLS error:', err);
+                reject(err);
+              });
+
+              socket = secureSocket;
+            } else if (nextStep.command) {
+              socket.write(nextStep.command);
+            }
+          } else {
+            // All steps completed successfully
+            socket.end();
+            logger.info('Email sent successfully');
+            resolve();
+          }
+        }
+      };
+
+      const onError = (err) => {
+        logger.error('SMTP connection error:', err);
+        reject(err);
+      };
+
+      // Create connection
+      if (this.secure) {
+        socket = tls.connect({
+          host: this.host,
+          port: this.port,
+          servername: this.host,
+        });
+      } else {
+        socket = net.connect({
+          host: this.host,
+          port: this.port,
+        });
+      }
+
+      socket.on('data', onData);
+      socket.on('error', onError);
+      socket.on('end', () => {
+        if (step < steps.length) {
+          reject(new Error('Connection closed unexpectedly'));
+        }
+      });
+
+      // Set timeout
+      socket.setTimeout(30000, () => {
+        socket.end();
+        reject(new Error('SMTP connection timeout'));
+      });
+    });
+  }
+
+  /**
+   * Build email message in RFC 5322 format
+   */
+  _buildMessage(to, subject, text, html) {
+    const boundary = `----=_Part${Date.now()}`;
+    const date = new Date().toUTCString();
+
+    let message = '';
+    message += `From: ${this.from}\r\n`;
+    message += `To: ${to}\r\n`;
+    message += `Subject: ${subject}\r\n`;
+    message += `Date: ${date}\r\n`;
+    message += `MIME-Version: 1.0\r\n`;
+
+    if (html) {
+      // Multipart message with both text and HTML
+      message += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n`;
+      message += `\r\n`;
+      message += `--${boundary}\r\n`;
+      message += `Content-Type: text/plain; charset=UTF-8\r\n`;
+      message += `Content-Transfer-Encoding: 7bit\r\n`;
+      message += `\r\n`;
+      message += `${text}\r\n`;
+      message += `\r\n`;
+      message += `--${boundary}\r\n`;
+      message += `Content-Type: text/html; charset=UTF-8\r\n`;
+      message += `Content-Transfer-Encoding: 7bit\r\n`;
+      message += `\r\n`;
+      message += `${html}\r\n`;
+      message += `\r\n`;
+      message += `--${boundary}--\r\n`;
+    } else {
+      // Plain text only
+      message += `Content-Type: text/plain; charset=UTF-8\r\n`;
+      message += `Content-Transfer-Encoding: 7bit\r\n`;
+      message += `\r\n`;
+      message += `${text}\r\n`;
+    }
+
+    return message;
+  }
+}
+
+export default EmailSender;