emailverifier.dev
Posts

SMTP Timeout Errors: Diagnose DNS, Connection, Greeting, and Command Delays

| 7 min read | Usama Ejaz
An outbound email passes through five timed checkpoints for DNS, TCP connection, server greeting, command exchange, and final acceptance.

An SMTP timeout does not mean the recipient mailbox is invalid. It means a specific stage did not finish before your timer expired. Record whether the delay happened during DNS resolution, TCP connection, the server's 220 greeting, an SMTP command, TLS negotiation, or the final response after DATA. Each stage has different evidence and a different safe next step.

I would never store one generic smtp_timeout result. It removes the only clue that makes the incident diagnosable, then tempts the application to suppress a real address.

The word “timeout” hides six different states

Start with the last completed boundary. These hypothetical log lines describe different failures even though a dashboard might flatten all of them into “SMTP timed out.”

Last completed boundaryWhat is missingWhat it proves
Domain accepted by your applicationMX or address lookupDNS did not produce usable routing evidence in time
MX host and IP selectedTCP connectionNo socket was established with that address and port
TCP connected220 greetingThe server accepted a connection but did not announce SMTP readiness in time
Greeting receivedReply to EHLO, MAIL, or RCPTThe session exists, but one command has no complete reply yet
STARTTLS acceptedTLS handshake or post-TLS EHLOThe secure session did not reach the next SMTP state
Final dot after message data sentFinal 250 or failure replyAcceptance is ambiguous; the server may have the message

A refusal is not a timeout. ECONNREFUSED means the destination actively rejected the TCP connection. An SMTP 421, 451, or 550 is also a reply, not silence. Preserve those outcomes separately.

Put a timer around each SMTP boundary

RFC 5321 requires per-command timeouts instead of one timer for the whole transaction. Its recommended minimums explain why a single aggressive socket timeout can misclassify a healthy but busy server:

  • initial 220 greeting: 5 minutes;
  • MAIL FROM: 5 minutes;
  • RCPT TO: 5 minutes;
  • DATA initiation while waiting for 354: 2 minutes;
  • each data block: 3 minutes;
  • message termination while waiting for the final reply: 10 minutes.

Those are SMTP implementation recommendations, not a requirement to keep a signup HTTP request open for ten minutes. A web application can use a shorter synchronous budget and move inconclusive work to a queue. The important part is semantic: a local deadline creates an unknown transport result, not proof that the address cannot receive mail.

In Node.js, socket.setTimeout() reports idle time but does not close the connection. Your handler must destroy or end the socket, and it should attach the current phase before doing so. Otherwise every idle event reaches your logs without context.

Separate routing failure from connection failure

SMTP delivery starts before the socket. The sender first finds the destination route, orders MX hosts by preference, and resolves usable addresses. RFC 5321 defines that mail-routing process, while Node.js exposes MX records through dnsPromises.resolveMx().

Log the resolver error, selected MX host, preference, IP family, address, port, and elapsed time. “Domain has no usable mail route” and “first IP did not connect” are different results. The existing MX priority guide owns host ordering and failover.

Port selection matters too. Server-to-server delivery normally targets the destination MX on port 25. Ports 465 and 587 are submission paths for an authenticated client and its provider. The SMTP ports guide explains that boundary. Trying the wrong service can create a perfectly repeatable timeout that says nothing about the mailbox.

A delayed 220 greeting is not a failed TCP connection

RFC 5321 explicitly tells clients to distinguish a failed connection from a delay before the initial 220. A busy server may accept TCP and wait before announcing that it is ready.

12:00:00.000 mx_selected   mx1.example.net 203.0.113.24:25
12:00:00.081 tcp_connected
12:00:05.083 timeout       phase=greeting expected=220

This hypothetical trace proves that DNS and TCP succeeded. It does not prove the SMTP service is permanently broken, and it never reached a recipient command. Changing the email address or suppressing it would be unrelated to the evidence.

After a complete greeting, capture the command that is waiting and the reply lines already received. SMTP replies can be multiline. The HELO and EHLO guide shows why a client must wait for the terminating reply line instead of treating the first 250- line as completion.

TLS creates another explicit phase

If the server advertises STARTTLS and the client uses it, give the handshake its own state and timer. After a successful handshake, RFC 3207 resets the SMTP session: the client discards the pre-TLS capability list and sends EHLO again.

That produces two distinct failures:

  • the TLS handshake itself did not complete;
  • TLS completed, but the post-TLS SMTP greeting or command did not.

Keeping them separate stops a certificate, protocol, or network problem from being reported as an SMTP recipient failure.

The final DATA timeout is the dangerous one

After the terminating dot, the server may accept and store the message before its 250 reply reaches you. RFC 5321 gives this phase a longer recommended timeout because a false timeout can create duplicate delivery.

If the connection disappears here, do not record “not sent.” Record an ambiguous handoff. Reconcile with your provider or retry using a stable downstream idempotency key when the interface supports one. The duplicate transactional email guide owns the outbox and crash-window design for this boundary.

Classify the event before changing address state

I use a small state classifier between the transport client and retry policy. The networking layer supplies the phase; the classifier refuses to turn silence into a mailbox verdict.

export function classifySmtpFailure(event) {
  if (event.type === 'socket_error' && event.code === 'ECONNREFUSED') {
    return { state: 'connection_refused', retry: true }
  }

  if (event.type === 'timeout') {
    const states = {
      connect: 'connect_timeout',
      greeting: 'greeting_timeout',
      command: 'command_timeout',
      tls: 'tls_timeout',
      data_final: 'data_final_timeout'
    }
    if (!states[event.phase]) throw new Error('Unknown SMTP phase')
    return { state: states[event.phase], retry: true }
  }

  if (event.type === 'smtp_reply') {
    const replyClass = Math.floor(event.code / 100)
    if (replyClass === 4) return { state: 'smtp_temporary_failure', retry: true }
    if (replyClass === 5) return { state: 'smtp_permanent_failure', retry: false }
    if (replyClass === 2) return { state: 'smtp_success', retry: false }
  }

  return { state: 'review', retry: false }
}

I ran eight deterministic fixtures through the full classifier. Connect, greeting, command, TLS, and final-response timeouts remained retryable transport states. ECONNREFUSED stayed distinct, a 451 became a temporary SMTP failure, and a 550 became a permanent failure for that attempt. All 8 fixtures passed.

The strongest fair objection is that all of these states may lead to the same immediate action: try again later. That is sometimes true. They still need separate names because their alerts, fallback hosts, TLS investigation, duplicate risk, and long-term policy are different.

What should the production log keep?

For each attempt, keep the domain, MX host and preference, resolved address and family, port, phase, command, elapsed time, timeout budget, socket error, complete SMTP reply, TLS outcome, attempt number, and queue ID. Redact message content and credentials. Keep the evidence that explains the state.

Then let the SMTP retry queue decide when another attempt is appropriate. A timeout during one route may justify another MX host or later attempt. It should not silently become mailbox_invalid.

Email verification has the same boundary. A clear recipient rejection can support a confirmed failure. DNS trouble, connection silence, policy blocks, catch-all behavior, and inconclusive SMTP sessions must remain risk or unknown evidence. The API result-handling guide shows how to preserve that distinction in product logic.

Continue reading