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.
smtp_server.rb—ClosingSmtpServer, 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 rawNet::SMTPand themailgem, then reports the exact exception and which phase of the SMTP conversation failed.
Run it:
ruby test_mail.rbOr run the server standalone for manual testing:
ruby smtp_server.rb close_after_data 2525| 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.
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#getok → recv_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.
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:
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
endstart { ... } 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).
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 duringDATA(thereset_after_data/close_before_respcases 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.
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
endHere an exception from send_message is a true failure (retry/alert), while an
exception from finish is the harmless bug-13018 case (ignore).
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_messagereturns, you hold positive proof of acceptance (and the id). Any error during the followingQUITis provably harmless — ignore it. - If the server drops the connection during
DATA,send_messageraises 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
endVerified 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 theMessage-IDheader you generated is a separate, receiving-side concern (next section), useful only as a backstop if you retry anyway.
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.
- 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::SMTPdirectly (notMail#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.