|
|
@@ -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;
|