Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SMTP "close before QUIT" error reproduction

Reproduces and characterizes the error Ruby raises when an SMTP server closes the connection after accepting a message but before the client can send QUIT.

Reference: Ruby bug #13018

Tested with: Ruby 4.0.1, net-smtp 0.5.1, mail 2.9.0 on Windows 11.

What it does

  • smtp_server.rbClosingSmtpServer, a minimal threaded SMTP server that speaks just enough SMTP to accept one message, then tears the connection down in one of three ways.
  • test_mail.rb — starts the server and delivers a message through both raw Net::SMTP and the mail gem, then reports the exact exception and which phase of the SMTP conversation failed.

Run it:

ruby test_mail.rb

Or run the server standalone for manual testing:

ruby smtp_server.rb close_after_data 2525

Findings

Server behavior Phase that fails Exception (raw Net::SMTP and mail gem)
Accepts message (250 OK), then closes before QUIT — the bug-13018 case QUIT exchange, after delivery Errno::ECONNABORTED on Windows
Accepts message, then RST-resets the socket reading the DATA response Errno::ECONNRESET
Closes without sending 250 for the body reading the DATA response EOFError – end of file reached

The mail gem behaves identically to raw Net::SMTP in every case — it lets the underlying exception propagate out of Mail#deliver! unchanged.

The key point

In the bug scenario (close_after_data), the backtrace proves the message was delivered: the 250 OK after the terminating . is read successfully inside Net::SMTP#data. The exception is raised later, in Net::SMTP#getokrecv_response, when do_finish sends QUIT and tries to read a reply from the already-closed socket:

net/smtp.rb:1017  recv_response
net/smtp.rb:1008  block in getok      <- QUIT response, AFTER the message was accepted
net/smtp.rb:1027  critical

So a successfully delivered message surfaces to the application as a connection error. The exact errno depends on the OS and on whether the peer sends a FIN (graceful close) or RST (reset):

  • The original 2017 report saw EOFError / Errno::ECONNRESET.
  • On Windows (this run) a graceful close during the QUIT read shows up as Errno::ECONNABORTED.

The platform-independent, structural fact is the constant: the failure happens during QUIT, not during DATA — meaning the mail is already queued on the server when the exception is raised.

How to avoid the false failure

The danger is treating a post-delivery QUIT error as a delivery failure and re-sending the mail (causing duplicates) or alerting on a non-problem. Options, from most to least targeted:

1. Don't depend on QUIT succeeding — close instead of quitting

Net::SMTP#finish sends QUIT. If you send the message yourself, you can skip it and just drop the connection after 250 OK. The message is already accepted, so the QUIT round-trip is courtesy, not correctness:

smtp = Net::SMTP.new(host, port)
smtp.start(helo_domain) do |s|
  s.send_message(message, from, to)   # returns after the server's 250 OK
  # message is delivered here; do NOT rely on the QUIT that start{} will send
end

start { ... } still calls finish/QUIT at the end of the block and can raise. To avoid that entirely, manage the lifecycle manually and rescue only the finish step (see option 3).

2. With the mail gem — rescue the post-delivery error narrowly

mail gives you no hook between "message accepted" and "QUIT", so wrap deliver! and treat a connection error as success, because by the time these errors are raised the server has already returned 250 for the body:

require 'net/smtp'

POST_DELIVERY_ERRORS = [
  EOFError,
  Errno::ECONNRESET,
  Errno::ECONNABORTED,
  Errno::EPIPE,
  IOError
].freeze

def deliver_tolerating_early_close(mail)
  mail.deliver!
rescue *POST_DELIVERY_ERRORS => e
  # Server accepted the message (250 after DATA) then dropped the connection
  # before QUIT. The mail is delivered — log and move on, do NOT retry.
  warn "SMTP connection closed before QUIT (#{e.class}); message already accepted"
  mail
end

⚠️ Caveat: this is only safe for errors that occur after the DATA response. The same exception classes can also be raised during DATA (the reset_after_data / close_before_resp cases above), where the message was not confirmed. A blanket rescue cannot tell the two apart, so it can mask a genuine failure. If duplicate sends are worse than missed sends, this is the right trade-off; if missed sends are worse, prefer option 3.

3. Distinguish the phase — only swallow QUIT-phase errors

To be correct rather than optimistic, only treat the error as success when it happens during the QUIT exchange (after 250 OK), and let DATA-phase errors propagate as real failures. You can drive the SMTP conversation manually and rescue only the finish step:

smtp = Net::SMTP.new(host, port)
smtp.open_timeout = smtp.read_timeout = 10
smtp.start(helo_domain)
begin
  smtp.send_message(message, from, to)   # raises here = real delivery failure
ensure
  begin
    smtp.finish                          # sends QUIT
  rescue EOFError, SystemCallError, IOError => e
    warn "QUIT failed after delivery (#{e.class}); message was accepted"
  end
end

Here an exception from send_message is a true failure (retry/alert), while an exception from finish is the harmless bug-13018 case (ignore).

4. Best: confirm via the server's DATA response (the queue/message id)

This is the cleanest fix and it sidesteps the phase-detection guesswork. After the message body, the server replies with a 250 line that almost always carries a queue/message id, e.g. 250 2.0.0 OK: queued as ABC123. Net::SMTP#send_message returns that response object. So:

  • If send_message returns, you hold positive proof of acceptance (and the id). Any error during the following QUIT is provably harmless — ignore it.
  • If the server drops the connection during DATA, send_message raises and never returns — so you never get a confirmation, and the error is a genuine failure.

The presence of the confirmation response is itself the signal — no need to guess from backtraces:

PEER_CLOSED = [EOFError, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE, IOError].freeze

def deliver_confirmed(host, port, helo, message, from, to)
  smtp = Net::SMTP.new(host, port)
  smtp.open_timeout = smtp.read_timeout = 10
  smtp.start(helo)
  begin
    response = smtp.send_message(message, from, to) # raises here = real failure
    response.string.strip                            # "250 2.0.0 OK: queued as ABC123"
  ensure
    begin
      smtp.finish                                    # QUIT; may raise in the bug case
    rescue *PEER_CLOSED
      # We already have the confirmation above, so this close is harmless.
      # (If send_message had failed, we'd never reach here.)
    end
  end
end

Verified against the test server in test_message_id.rb:

=== close_after_data ===   >> CONFIRMED delivered. Server said: "250 2.0.0 OK: queued as ABC123"
=== reset_after_data ===   >> REAL FAILURE (not confirmed): Errno::ECONNRESET
=== close_before_resp ===  >> REAL FAILURE (not confirmed): EOFError

Note on which id. This uses the server's response id (its queue id), captured in the same connection — that is what proves this send was accepted. You cannot ask an SMTP server "have you already seen my Message-ID?" — SMTP has no such query. De-duplicating by the Message-ID header you generated is a separate, receiving-side concern (next section), useful only as a backstop if you retry anyway.

5. Make delivery idempotent regardless

Whichever option you pick, the most robust posture for retried sends is idempotency: include a stable Message-ID and have the receiving side deduplicate. Then even if you do retry on a false failure, no duplicate is delivered.

Recommendation

  • Best (preferred): option 4 — confirm via the server's DATA response. It's positive proof of acceptance, gives you the queue id for logging, and correctly distinguishes real DATA-phase failures with no heuristics. Requires driving Net::SMTP directly (not Mail#deliver!).
  • If you must use Mail#deliver! as-is: option 2 (tolerant rescue) — simplest, but a blanket rescue can mask a genuine DATA-phase failure.
  • Always, if you retry: option 5 (idempotent Message-ID) as a backstop.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages