diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 5ac208a..779bcf1 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -42,7 +42,7 @@ def initialize(response, message: nil) @message = message else @response = nil - @message = message || response + @message = message || response end end @@ -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 @@ -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 @@ -510,6 +530,7 @@ def debug_output=(arg) # * Net::SMTPServerBusy # * Net::SMTPSyntaxError # * Net::SMTPFatalError + # * Net::SMTPMailboxPermanentlyUnavailable # * Net::SMTPUnknownError # * Net::OpenTimeout # * Net::ReadTimeout @@ -583,6 +604,7 @@ def started? # * Net::SMTPServerBusy # * Net::SMTPSyntaxError # * Net::SMTPFatalError + # * Net::SMTPMailboxPermanentlyUnavailable # * Net::SMTPUnknownError # * Net::OpenTimeout # * Net::ReadTimeout @@ -752,9 +774,11 @@ def do_finish # # This method may raise: # + # * Net::SMTPAuthenticationError # * Net::SMTPServerBusy # * Net::SMTPSyntaxError # * Net::SMTPFatalError + # * Net::SMTPMailboxPermanentlyUnavailable # * Net::SMTPUnknownError # * Net::ReadTimeout # * IOError @@ -762,7 +786,7 @@ def do_finish 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 @@ -808,6 +832,7 @@ def send_message(msgstr, from_addr, *to_addrs) # * Net::SMTPServerBusy # * Net::SMTPSyntaxError # * Net::SMTPFatalError + # * Net::SMTPMailboxPermanentlyUnavailable # * Net::SMTPUnknownError # * Net::ReadTimeout # * IOError @@ -815,7 +840,7 @@ def send_message(msgstr, from_addr, *to_addrs) 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 @@ -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+ @@ -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 diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index b418b3f..ea8e03d 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -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) @@ -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 @@ -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