Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle, in the rcptto_list method, a mixture of recipient statuses. #52

Open
wants to merge 25 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0c3dfb4
try sending a message with multiple recipients to the fake server
zdAlexM Mar 24, 2023
a33eefd
make test pass
zdAlexM Mar 24, 2023
f68ca25
make a meaningful assertion
zdAlexM Mar 24, 2023
d4ab4f9
actually save recipient during RCPT stage
zdAlexM Mar 24, 2023
04b96d3
try sending a message to a rejected recipient
zdAlexM Mar 24, 2023
1a432dc
expect a syntax error with a server claiming bad address syntax
zdAlexM Mar 24, 2023
d17497a
try sending a message to a server with a temporary error for a recipient
zdAlexM Mar 24, 2023
e38b81c
expect a SMTPServerBusy error when an RCPT TO command is rejected
zdAlexM Mar 24, 2023
33ba7c2
be more realistic about return code for temporary failure
zdAlexM Mar 27, 2023
2c50b7e
Add comments pertaining to SMTP error classes
zdAlexM Mar 27, 2023
f8e4b64
test for Net::SMTPMailboxPermanentlyUnavailable
zdAlexM Mar 27, 2023
30b3797
Add documentation for Net::SMTPMailboxPermanentlyUnavailable
zdAlexM Mar 27, 2023
8f5934d
make sure it's the RCPT TO that fails
zdAlexM Mar 27, 2023
d3beb53
make test pass
zdAlexM Mar 27, 2023
e973334
add more rcptto_list tests
zdAlexM Mar 27, 2023
9871caf
rcppto_list takes a block upon success, actually
zdAlexM Mar 27, 2023
788b88d
these are both ArgumentError cases
zdAlexM Mar 27, 2023
9f46846
make tests pass
zdAlexM Mar 28, 2023
f82ba43
test for mixed recipient status
zdAlexM Mar 28, 2023
ee5d0ad
test to ensure one string and one-element list are the same
zdAlexM Mar 28, 2023
ceccdaf
in send_message and open_message_stream, flatten to_addrs list
zdAlexM Mar 28, 2023
86dcfa8
rework rcptto_list, adding Net::SMTPMixedRecipientStatus exception
zdAlexM Mar 28, 2023
dccfdf6
improve documentation
zdAlexM Mar 28, 2023
cbaedf3
make tests pass
zdAlexM Mar 28, 2023
cd439ec
Make superclass of SMTPMailboxPermanentlyUnavailable as documented
zdAlexM Mar 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 100 additions & 20 deletions lib/net/smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def initialize(response, message: nil)
@message = message
else
@response = nil
@message = message || response
@message = message || response
end
end

Expand All @@ -51,7 +51,7 @@ def message
end
end

# Represents an SMTP authentication error.
# Represents an SMTP authentication error (error code 53x).
class SMTPAuthenticationError < ProtoAuthError
include SMTPError
end
Expand All @@ -61,16 +61,36 @@ class SMTPServerBusy < ProtoServerError
include SMTPError
end

# Represents an SMTP command syntax error (error code 500)
# Represents an SMTP command syntax error (error code 50x)
class SMTPSyntaxError < ProtoSyntaxError
include SMTPError
end

# Represents a fatal SMTP error (error code 5xx, except for 500)
# Represents a fatal SMTP error (error code 5xx, except for 50x, 53x, and 55x)
class SMTPFatalError < ProtoFatalError
include SMTPError
end

# Represents a fatal SMTP error pertaining to the mailbox (error code 55x)
class SMTPMailboxPermanentlyUnavailable < SMTPFatalError
include SMTPError
end

# A synthetic status, raised from `rcptto_list`, when multiple recipients are given,
# and some have succeeded, but others encountered 50x (syntax error) or 55x (permanent
# mailbox error).
class SMTPMixedRecipientStatus < SMTPMailboxPermanentlyUnavailable
include SMTPError
attr_reader :ok, :permerror, :bad_syntax

def initialize(ok_addrs, permerror_addrs, bad_syntax_addrs)
@ok = ok_addrs
@permerror = permerror_addrs
@bad_syntax = bad_syntax_addrs
super("Mixed recipient status: #{@ok.length} ok, #{@permerror.length} permanent failure, #{@bad_syntax.length} bad syntax")
end
end

# Unexpected reply code returned from server.
class SMTPUnknownError < ProtoUnknownError
include SMTPError
Expand Down Expand Up @@ -510,6 +530,7 @@ def debug_output=(arg)
# * Net::SMTPServerBusy
# * Net::SMTPSyntaxError
# * Net::SMTPFatalError
# * Net::SMTPMailboxPermanentlyUnavailable
# * Net::SMTPUnknownError
# * Net::OpenTimeout
# * Net::ReadTimeout
Expand Down Expand Up @@ -583,6 +604,7 @@ def started?
# * Net::SMTPServerBusy
# * Net::SMTPSyntaxError
# * Net::SMTPFatalError
# * Net::SMTPMailboxPermanentlyUnavailable
# * Net::SMTPUnknownError
# * Net::OpenTimeout
# * Net::ReadTimeout
Expand Down Expand Up @@ -752,17 +774,19 @@ def do_finish
#
# This method may raise:
#
# * Net::SMTPAuthenticationError
# * Net::SMTPServerBusy
# * Net::SMTPSyntaxError
# * Net::SMTPFatalError
# * Net::SMTPMailboxPermanentlyUnavailable
# * Net::SMTPUnknownError
# * Net::ReadTimeout
# * IOError
#
def send_message(msgstr, from_addr, *to_addrs)
raise IOError, 'closed session' unless @socket
mailfrom from_addr
rcptto_list(to_addrs) {data msgstr}
rcptto_list(to_addrs.flatten) {data msgstr}
end

alias send_mail send_message
Expand Down Expand Up @@ -808,14 +832,15 @@ def send_message(msgstr, from_addr, *to_addrs)
# * Net::SMTPServerBusy
# * Net::SMTPSyntaxError
# * Net::SMTPFatalError
# * Net::SMTPMailboxPermanentlyUnavailable
# * Net::SMTPUnknownError
# * Net::ReadTimeout
# * IOError
#
def open_message_stream(from_addr, *to_addrs, &block) # :yield: stream
raise IOError, 'closed session' unless @socket
mailfrom from_addr
rcptto_list(to_addrs) {data(&block)}
rcptto_list(to_addrs.flatten) {data(&block)}
end

alias ready open_message_stream # obsolete
Expand Down Expand Up @@ -942,25 +967,79 @@ def mailfrom(from_addr)
getok((["MAIL FROM:<#{addr.address}>"] + addr.parameters).join(' '))
end

# Submit a list of addresses to the SMTP server, handling common issues.
#
# Each address is protected against authentication errors (53x), syntax
# errors (50x), and mailbox permanent errors (55x); if all recipients
# encounter the same error, then an error of that type is raised. Otherwise,
# +Net::SMTPMixedRecipientStatus+ is raised.
#
# If all recipients are accepted, this method yields to a provided block,
# which can then call `DATA` to deliver a message.
#
# +to_addrs+ is an +Enumerable+ of +String+ or +Net::SMTP::Address+.
#
# Raises:
# * Net::SMTPSyntaxError
# * Net::SMTPServerBusy
# * Net::SMTPAuthenticationError
# * Net::SMTPMailboxPermanentlyUnavailable
# * Net::SMTPMixedRecipientStatus
def rcptto_list(to_addrs)
raise ArgumentError, 'mail destination not given' if to_addrs.empty?
ok_users = []
unknown_users = []

# In the case of one recipient, pass it down to the plain rcptto (which
# will either succeed or raise one of the same exceptions that this method
# would have raised), yield to the block (which is expected to call data),
# and return early, so as to avoid having to carefully handle the
# single-recipient case here.
if to_addrs.length == 1
rcptto(to_addrs.first)
return yield
end

ok_addrs = []
unauthenticated_addrs = []
permanent_error_addrs = []
syntactically_invalid = []

to_addrs.flatten.each do |addr|
begin
rcptto addr
rescue SMTPAuthenticationError
unknown_users << addr.to_s.dump
else
ok_users << addr
end
rcptto addr
rescue SMTPSyntaxError
syntactically_invalid << addr
rescue SMTPAuthenticationError
unauthenticated_addrs << addr
rescue SMTPMailboxPermanentlyUnavailable
permanent_error_addrs << addr
else
ok_addrs << addr
end
raise ArgumentError, 'mail destination not given' if ok_users.empty?
ret = yield
unless unknown_users.empty?
raise SMTPAuthenticationError, "failed to deliver for #{unknown_users.join(', ')}"

# If any one of these addrs requires authentication, raise a specific error.
unless unauthenticated_addrs.empty?
addresses = unauthenticated_addrs.map { |addr| addr.to_s.dump }.join(', ')
raise SMTPAuthenticationError, "failed to authenticate for #{addresses}"
end

# If all recipients were accepted by the server, yield to the block, which
# is probably going to call `data` to deliver a message.
return yield if ok_addrs.length == to_addrs.length

# Now, we have eliminated all single-recipient cases, the successful case
# of all recipients being accepted, and the case of any number of
# authentication errors, so at this point there are any number of possible
# mixed permanently-unavailable, syntactically-invalid, and OK recipients.
# If all of the errors are of one type, raise a specific exception.
# Otherwise, raise SMTPMixedRecipientStatus.
if permanent_error_addrs.length == to_addrs.length
addresses = permanent_error_addrs.map { |addr| addr.to_s.dump }.join(', ')
raise SMTPMailboxPermanentlyUnavailable, "all recipients were permanently undeliverable: #{addresses}"
elsif syntactically_invalid.length == to_addrs.length
addresses = syntactically_invalid.map { |addr| addr.to_s.dump }.join(', ')
raise SMTPSyntaxError, "all recipients had syntax errors: #{addresses}"
else
raise SMTPMixedRecipientStatus.new(ok_addrs, permanent_error_addrs, syntactically_invalid)
end
ret
end

# +to_addr+ is +String+ or +Net::SMTP::Address+
Expand Down Expand Up @@ -1165,6 +1244,7 @@ def exception_class
when /\A4/ then SMTPServerBusy
when /\A50/ then SMTPSyntaxError
when /\A53/ then SMTPAuthenticationError
when /\A55/ then SMTPMailboxPermanentlyUnavailable
when /\A5/ then SMTPFatalError
else SMTPUnknownError
end
Expand Down
127 changes: 127 additions & 0 deletions test/net/smtp/test_smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,112 @@ def test_start_instance_invalid_number_of_arguments
assert_equal('wrong number of arguments (given 5, expected 0..4)', err.message)
end

def test_send_message_rcpt_to_string
port = fake_server_start
smtp = Net::SMTP.new('localhost', port)
smtp.start do |conn|
conn.send_message "test", "me@example.org", "you@example.net"
end
assert_equal %w[you@example.net], @recipients
end

def test_send_message_rcpt_to_single_list
port = fake_server_start
smtp = Net::SMTP.new('localhost', port)
smtp.start do |conn|
conn.send_message "test", "me@example.org", ["you@example.net"]
end
assert_equal %w[you@example.net], @recipients
end

def test_send_message_rcpt_to_two_of_them
port = fake_server_start
smtp = Net::SMTP.new('localhost', port)
smtp.start do |conn|
conn.send_message "test", "me@example.org", ["you@example.net", "friend@example.net"]
end
assert_equal %w[you@example.net friend@example.net], @recipients
end

def test_rcpt_to_bad_recipient
port = fake_server_start
smtp = Net::SMTP.new('localhost', port)
smtp.start do |conn|
assert_raise Net::SMTPMixedRecipientStatus do
conn.send_message "test", "me@example.org", ["you@example.net", "-friend@example.net"]
end
end
end

def test_rcpt_to_temporary_failure_recipient
port = fake_server_start
smtp = Net::SMTP.new('localhost', port)
smtp.start do |conn|
assert_raise Net::SMTPServerBusy do
conn.send_message "test", "me@example.org", ["~you@example.net", "friend@example.net"]
end
end
end

def test_rcpt_to_one_nonexistent_recipient_send_message
port = fake_server_start
smtp = Net::SMTP.new('localhost', port)
smtp.start do |conn|
assert_raise Net::SMTPMixedRecipientStatus do
conn.send_message "test", "me@example.org", ["nonexistent@example.net", "friend@example.net"]
end
end
assert_equal ['friend@example.net'], @recipients
end

def test_rcpt_to_nonexistent_recipient_rcptto
port = fake_server_start
smtp = Net::SMTP.new('localhost', port)
smtp.start do |conn|
conn.mailfrom "me@example.org"
assert_raise Net::SMTPMixedRecipientStatus do
conn.rcptto_list ["friend@example.net", "nonexistent@example.net"] do end
end
end
assert_equal ["friend@example.net"], @recipients
end

def test_rcptto_list_empty_list
port = fake_server_start
smtp = Net::SMTP.new('localhost', port)
smtp.start do |conn|
conn.mailfrom "me@example.org"
assert_raise ArgumentError do
conn.rcptto_list [] do end
end
end
assert_empty @recipients
end

def test_rcptto_list_all_nonexistent_recipients
port = fake_server_start
smtp = Net::SMTP.new('localhost', port)
smtp.start do |conn|
conn.mailfrom "me@example.org"
assert_raise Net::SMTPMailboxPermanentlyUnavailable do
conn.rcptto_list ["nonexistent1@example.net", "nonexistent2@example.net"] do end
end
end
assert_empty @recipients
end

def test_rcptto_list_some_nonexistent_recipients
port = fake_server_start
smtp = Net::SMTP.new('localhost', port)
smtp.start do |conn|
conn.mailfrom "me@example.org"
assert_raise Net::SMTPMixedRecipientStatus do
conn.rcptto_list ["friend@example.net", "nonexistent1@example.net", "nonexistent2@example.net", "friend@example.com"] do end
end
end
assert_equal ["friend@example.net", "friend@example.com"], @recipients
end

private

def accept(servers)
Expand All @@ -568,6 +674,7 @@ def accept(servers)

def fake_server_start(helo: 'localhost', user: nil, password: nil, tls: false, starttls: false, authtype: 'PLAIN')
@starttls_started = false
@recipients = []
servers = Socket.tcp_server_sockets('localhost', 0)
@server_threads << Thread.start do
Thread.current.abort_on_exception = true
Expand Down Expand Up @@ -631,6 +738,26 @@ def fake_server_start(helo: 'localhost', user: nil, password: nil, tls: false, s
else
sock.puts "535 5.7.8 Error: authentication failed: authentication failure\r\n"
end
when /\AMAIL FROM: *<.*>/
sock.puts "250 2.1.0 Okay\r\n"
when /\ARCPT TO: *<(.*)>/
if $1.start_with? "-"
sock.puts "501 5.1.3 Bad recipient address syntax\r\n"
elsif $1.start_with? "~"
sock.puts "450 4.2.1 Try again\r\n"
elsif $1.start_with? "nonexistent"
sock.puts "550 5.1.1 User unknown\r\n"
else
@recipients << $1
sock.puts "250 2.1.5 Okay\r\n"
end
when "DATA"
sock.puts "354 Continue (finish with dot)\r\n"
loop do
line = sock.gets
break if line == ".\r\n"
end
sock.puts "250 2.6.0 Okay\r\n"
when "QUIT"
sock.puts "221 2.0.0 Bye\r\n"
sock.close
Expand Down