emailverifier.dev
Posts

HELO vs EHLO: How SMTP Capability Discovery Works

| 6 min read | Usama Ejaz
An SMTP session uses EHLO to discover extensions, resets them at STARTTLS, then sends EHLO again inside the encrypted connection.

EHLO is the normal opening command for modern SMTP. It identifies the client and asks the server which extensions it supports. HELO is the legacy fallback: it identifies the client but does not negotiate ESMTP capabilities such as STARTTLS, AUTH, SIZE, SMTPUTF8, or PIPELINING.

That difference sounds small until a connection upgrades to TLS. Then an SMTP client has to forget the capabilities it learned before encryption and send EHLO again. Skip that second EHLO and a perfectly healthy server can reject the next command.

The failure happens after TLS, not before it

Here is a hypothetical but protocol-valid failure trace. The connection reaches TLS successfully. The mistake is the immediate MAIL FROM after the handshake:

S: 220 mail.example ESMTP ready
C: EHLO app.example
S: 250-mail.example
S: 250-SIZE 52428800
S: 250 STARTTLS
C: STARTTLS
S: 220 Ready to start TLS
... TLS handshake succeeds ...
C: MAIL FROM:<alerts@app.example>
S: 503 5.5.1 Send HELO/EHLO first

The server is not contradicting itself. RFC 3207 resets the SMTP protocol to its initial state after the TLS negotiation. Both sides must discard knowledge learned before TLS, and the client should send EHLO as its first command inside the encrypted channel.

The corrected sequence is:

S: 220 mail.example ESMTP ready
C: EHLO app.example
S: 250-mail.example
S: 250-SIZE 52428800
S: 250 STARTTLS
C: STARTTLS
S: 220 Ready to start TLS
... TLS handshake succeeds ...
C: EHLO app.example
S: 250-mail.example
S: 250-SIZE 52428800
S: 250 AUTH PLAIN LOGIN
C: MAIL FROM:<alerts@app.example>

Notice that the second capability list is different. STARTTLS is gone because the connection is already encrypted. AUTH appears only after TLS. That is a common security policy, and it is exactly why a client cannot reuse the first list.

EHLO does two jobs

Both commands introduce the sending client. EHLO also switches the conversation into Extended SMTP and asks for the server's extension list. RFC 5321 says a client should start an SMTP session with EHLO. HELO remains mandatory for servers to support because older clients still exist.

Command What it establishes What the client may infer
EHLO app.example Client identity plus an ESMTP capability exchange Only the extensions listed in this reply, for this connection state
HELO app.example Client identity using legacy SMTP No ESMTP extension support

This is the important boundary: EHLO does not enable every known extension. It lets the server advertise a specific set. The IANA SMTP registry tracks standardized extension keywords, but the server's reply is the authority for the current session.

For example, a client must not send STARTTLS merely because it knows that STARTTLS exists. It may use the command only after the server advertises it. The same reasoning applies to message-size negotiation, which I cover in the email attachment size guide.

Read the 250 reply as one framed object

EHLO usually produces one SMTP reply spread across several lines. The framing character after the status code matters:

250-mail.example Hello app.example
250-SIZE 52428800
250-PIPELINING
250 STARTTLS
   ^
   final line uses a space

A hyphen after 250 means more lines follow. A space means the reply is complete. Treating every 250 line as a separate reply can make a client stop at the greeting and miss the extensions below it.

I use one framed-reply parser, then store capabilities as case-insensitive keywords with optional parameters. This reduced version is the core of the fixture I ran for this article:

function parseEhloReply(lines) {
  const payloads = lines.map((line, index) => {
    const match = /^250([- ])(.*)$/.exec(line);
    if (!match) throw new Error(`Invalid EHLO line: ${line}`);

    const last = index === lines.length - 1;
    if (!last && match[1] !== "-") throw new Error("Early final line");
    if (last && match[1] !== " ") throw new Error("Missing final line");
    return match[2];
  });

  return new Map(payloads.slice(1).map(payload => {
    const [keyword, ...parameters] = payload.trim().split(/\s+/);
    return [keyword.toUpperCase(), parameters];
  }));
}

The executed fixture checked five cases: a normal multiline reply, a one-line reply with no extensions, an early final line, capability clearing at STARTTLS, and a different post-TLS list. All five passed.

STARTTLS invalidates the first capability list

STARTTLS creates a new security boundary. RFC 3207 requires clients and servers to discard knowledge obtained before TLS because an attacker could have altered the unencrypted negotiation. The clean state also lets a server reveal capabilities that it did not want to advertise on a clear-text connection.

A useful client state model is:

  1. Connect and wait for the 220 greeting.
  2. Send EHLO and parse the complete capability reply.
  3. If STARTTLS is advertised and the client policy calls for it, issue STARTTLS.
  4. Complete TLS, clear the stored extensions, and send EHLO again.
  5. Make later decisions only from the post-TLS capability set.

In code, the reset should be explicit:

session.extensions = parseEhloReply(preTlsReply);
await session.startTls();
session.extensions.clear();
session.extensions = parseEhloReply(await session.ehlo(clientName));

Do not merge the two maps. Pre-TLS knowledge is stale by design.

If your question is which connection mode belongs on ports 25, 465, and 587, that is a separate layer. My SMTP ports guide separates server-to-server delivery, STARTTLS submission, and implicit TLS submission.

The EHLO name identifies the SMTP client

Send the client's primary host name when one is available. If the client has no meaningful domain name, RFC 5321 permits an address literal such as [192.0.2.25] or an IPv6 literal.

The EHLO name is not the visible From: domain and not the envelope sender. It describes the SMTP client on this connection. A submission client behind a mail provider may therefore use a different EHLO identity from the domain a recipient sees in the message.

Receivers can still evaluate whether the hostname and connecting address make operational sense. That relationship is explained in the reverse DNS and email guide. A syntactically valid EHLO name does not establish trust by itself.

HELO fallback defines the feature ceiling

If a server rejects EHLO but accepts HELO, the client can continue using legacy SMTP. The cost is clear: there is no extension list. The client cannot assume STARTTLS, AUTH, SIZE, SMTPUTF8, PIPELINING, or any other ESMTP feature.

The strongest objection is that almost every modern server supports EHLO, so fallback code feels unnecessary. That may be true for a controlled submission endpoint. It is not a reason to make fallback ambiguous. Either require ESMTP and fail clearly, or fall back to HELO and deliberately disable extension-dependent behavior. Pretending HELO is a quieter spelling of EHLO creates protocol bugs that appear only at the worst boundary.

When a server returns 503 Send HELO/EHLO first immediately after a successful TLS handshake, inspect the client state before blaming credentials or DNS. The usual defect is a missing second EHLO. Once that reply arrives, every extension-dependent decision should use its capabilities and nothing carried across from the clear-text session.

Sources

Continue reading