SMTP клиент на Node.js — net и tls

Свой SMTP-клиент на чистых сокетах: TCP/TLS как взаимозаменяемая стратегия, конечный автомат поверх диалога с сервером.

Что это

SMTP (Simple Mail Transfer Protocol) — текстовый line-based протокол поверх TCP. Сервер шлёт коды (220, 250, 354), клиент отвечает командами (HELO, MAIL FROM, RCPT TO, DATA).

В ноде SMTP-клиент пишется на двух модулях:

  • net — обычный TCP (порт 25, plain SMTP).
  • tls — для STARTTLS (587) и SMTPS (465).

API / Пример

const transports = {
  tcp: require('net'),
  tls: require('tls'),
};

class SmtpClient {
  constructor({ host, port, secure }) {
    this.host = host;
    this.port = port;
    this.transport = secure ? transports.tls : transports.tcp;
    this.socket = null;
    this.currentCommand = null;
  }

  connect {
    return new Promise((resolve, reject) => {
      this.socket = this.transport.connect(this.port, this.host);
      this.socket.once('connect', resolve);
      this.socket.once('error', reject);
      this.socket.on('data', (chunk) => this.processChunk(chunk));
    });
  }

  processChunk(chunk) { /* парсит коды ответа, диспатчит */ }
}

Принципы рефакторинга (по автору)

  1. TCP и TLS — стратегии в коллекции — общий интерфейс сокета.
  2. События не методами классаonData подписывается в configureSocket, там же отписывается.
  3. Promise не в свойствах — блокировка (AsyncLock) — отдельная ответственность в utils.
  4. while не нужен, достаточно if для следующей команды.
  5. removeAllListeners по имени события — снять подписки при upgrade сокета на TLS (STARTTLS).
  6. destroy через await, не emit изнутри — иначе порядок completion ломается.
  7. 30% кода — бизнес-логика, остальное — инфраструктура.

Производительность / Подводные камни

  • STARTTLS — апгрейд plain сокета до TLS на лету (tls.connect({ socket: plainSocket, ... })). Перед апгрейдом снять все слушатели data.
  • CRLF — SMTP использует \r\n (как HTTP).
  • Многострочные ответы250-First line\r\n250 Last line\r\n (тире vs пробел).
  • Hostname вычислять один раз в конструкторе, не на каждое сообщение.
  • currentCommand — глобальный стейт — выносить в очередь команд, чтобы можно было параллелить (с pipelining).

🎓 Источники

  • 🎓 Летняя школа 2022 созвон #13 — ревью SMTP (tcp и tls) · 2022-08-10
    • Тезисы: коллекция стратегий tcp/tls с одинаковым интерфейсом; promise в свойствах — антипаттерн; события не методами класса; подписался → там и отпишись; rev STARTTLS — снять все listeners; ООП плохо подходит к асинхронности.
    • Цитата: «События — не методы. Все callback должны быть внутри configure socket: где подписался, там и отписывайся на том же методе.»

См. также