From 0c3dfb4c252c7d11c6e882b1bd437ff3811aa80a Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Fri, 24 Mar 2023 13:08:31 -0700 Subject: [PATCH 01/25] try sending a message with multiple recipients to the fake server --- test/net/smtp/test_smtp.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index b418b3f..50916d6 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -560,6 +560,14 @@ def test_start_instance_invalid_number_of_arguments assert_equal('wrong number of arguments (given 5, expected 0..4)', err.message) end + def test_rcpt_to + 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 + end + private def accept(servers) From a33eefd96149b6fa2648905adf649520dfae128a Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Fri, 24 Mar 2023 13:10:18 -0700 Subject: [PATCH 02/25] make test pass --- test/net/smtp/test_smtp.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index 50916d6..228ad1b 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -639,6 +639,23 @@ 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 "400 4.0.0 Try again\r\n" + else + 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 From f68ca25eae8b8bdfccef25d65cf00051e2205e90 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Fri, 24 Mar 2023 13:17:10 -0700 Subject: [PATCH 03/25] make a meaningful assertion --- test/net/smtp/test_smtp.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index 228ad1b..9ade0fc 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -566,6 +566,7 @@ def test_rcpt_to 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 private From d4ab4f99f4c5ba0d60c797f5b658d916f030c9ec Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Fri, 24 Mar 2023 13:17:31 -0700 Subject: [PATCH 04/25] actually save recipient during RCPT stage --- test/net/smtp/test_smtp.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index 9ade0fc..fdf3cc2 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -577,6 +577,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 @@ -648,6 +649,7 @@ def fake_server_start(helo: 'localhost', user: nil, password: nil, tls: false, s elsif $1.start_with? "~" sock.puts "400 4.0.0 Try again\r\n" else + @recipients << $1 sock.puts "250 2.1.5 Okay\r\n" end when "DATA" From 04b96d3964b88ab35edbe642dc2efae81ee481e2 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Fri, 24 Mar 2023 13:41:01 -0700 Subject: [PATCH 05/25] try sending a message to a rejected recipient --- test/net/smtp/test_smtp.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index fdf3cc2..a5753cb 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -569,6 +569,14 @@ def test_rcpt_to 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| + conn.send_message "test", "me@example.org", ["-you@example.net", "friend@example.net"] + end + end + private def accept(servers) From 1a432dc7fa20f2e9726112a790c1247687741c84 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Fri, 24 Mar 2023 13:42:28 -0700 Subject: [PATCH 06/25] expect a syntax error with a server claiming bad address syntax --- test/net/smtp/test_smtp.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index a5753cb..10e2d54 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -573,7 +573,9 @@ def test_rcpt_to_bad_recipient 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"] + assert_raise Net::SMTPSyntaxError do + conn.send_message "test", "me@example.org", ["you@example.net", "-friend@example.net"] + end end end From d17497ad5a1fc4ab00105cfbddcee3fa03b47ba8 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Fri, 24 Mar 2023 13:43:42 -0700 Subject: [PATCH 07/25] try sending a message to a server with a temporary error for a recipient --- test/net/smtp/test_smtp.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index 10e2d54..4966f39 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -579,6 +579,14 @@ def test_rcpt_to_bad_recipient end end + def test_rcpt_to_temporary_failure_recipient + 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 + end + private def accept(servers) From e38b81cec79c52f4fe6a3ab6cbc82d017d870afe Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Fri, 24 Mar 2023 13:44:18 -0700 Subject: [PATCH 08/25] expect a SMTPServerBusy error when an RCPT TO command is rejected --- test/net/smtp/test_smtp.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index 4966f39..b62e8d0 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -583,7 +583,9 @@ def test_rcpt_to_temporary_failure_recipient 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"] + assert_raise Net::SMTPServerBusy do + conn.send_message "test", "me@example.org", ["~you@example.net", "friend@example.net"] + end end end From 33ba7c2d1240106f4fc7584ddff27257ddb5fdac Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Mon, 27 Mar 2023 13:45:02 -0700 Subject: [PATCH 09/25] be more realistic about return code for temporary failure --- test/net/smtp/test_smtp.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index b62e8d0..cb5c3f6 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -667,7 +667,7 @@ def fake_server_start(helo: 'localhost', user: nil, password: nil, tls: false, s if $1.start_with? "-" sock.puts "501 5.1.3 Bad recipient address syntax\r\n" elsif $1.start_with? "~" - sock.puts "400 4.0.0 Try again\r\n" + sock.puts "450 4.2.1 Try again\r\n" else @recipients << $1 sock.puts "250 2.1.5 Okay\r\n" From 2c50b7e1adf2e00b7066a82917f43e79ae0e3cc5 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Mon, 27 Mar 2023 13:49:29 -0700 Subject: [PATCH 10/25] Add comments pertaining to SMTP error classes --- lib/net/smtp.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 5ac208a..6042599 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 @@ -66,7 +66,7 @@ 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 500 and 53x) class SMTPFatalError < ProtoFatalError include SMTPError end From f8e4b642b98dc0586102c9ff3160df0fc1dd5f62 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Mon, 27 Mar 2023 13:49:58 -0700 Subject: [PATCH 11/25] test for Net::SMTPMailboxPermanentlyUnavailable --- test/net/smtp/test_smtp.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index cb5c3f6..8b909ed 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -589,6 +589,17 @@ def test_rcpt_to_temporary_failure_recipient end end + def test_rcpt_to_nonexistent_recipient + port = fake_server_start + smtp = Net::SMTP.new('localhost', port) + smtp.start do |conn| + assert_raise Net::SMTPMailboxPermanentlyUnavailable do + conn.send_message "test", "me@example.org", ["nonexistent@example.net", "friend@example.net"] + end + end + assert_empty @recipients + end + private def accept(servers) @@ -668,6 +679,8 @@ def fake_server_start(helo: 'localhost', user: nil, password: nil, tls: false, s 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" From 30b37978b44f32478238352940aca027069cc017 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Mon, 27 Mar 2023 13:56:58 -0700 Subject: [PATCH 12/25] Add documentation for Net::SMTPMailboxPermanentlyUnavailable --- lib/net/smtp.rb | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 6042599..38e202d 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -66,11 +66,16 @@ class SMTPSyntaxError < ProtoSyntaxError include SMTPError end - # Represents a fatal SMTP error (error code 5xx, except for 500 and 53x) + # Represents a fatal SMTP error (error code 5xx, except for 500, 53x, and 55x) class SMTPFatalError < ProtoFatalError include SMTPError end + # Represents a fatal SMTP error pertaining to the mailbox (error code 55x) + class SMTPMailboxPermanentlyUnavailable < ProtoFatalError + include SMTPError + end + # Unexpected reply code returned from server. class SMTPUnknownError < ProtoUnknownError include SMTPError @@ -510,6 +515,7 @@ def debug_output=(arg) # * Net::SMTPServerBusy # * Net::SMTPSyntaxError # * Net::SMTPFatalError + # * Net::SMTPMailboxPermanentlyUnavailable # * Net::SMTPUnknownError # * Net::OpenTimeout # * Net::ReadTimeout @@ -583,6 +589,7 @@ def started? # * Net::SMTPServerBusy # * Net::SMTPSyntaxError # * Net::SMTPFatalError + # * Net::SMTPMailboxPermanentlyUnavailable # * Net::SMTPUnknownError # * Net::OpenTimeout # * Net::ReadTimeout @@ -752,9 +759,11 @@ def do_finish # # This method may raise: # + # * Net::SMTPAuthenticationError # * Net::SMTPServerBusy # * Net::SMTPSyntaxError # * Net::SMTPFatalError + # * Net::SMTPMailboxPermanentlyUnavailable # * Net::SMTPUnknownError # * Net::ReadTimeout # * IOError @@ -808,6 +817,7 @@ def send_message(msgstr, from_addr, *to_addrs) # * Net::SMTPServerBusy # * Net::SMTPSyntaxError # * Net::SMTPFatalError + # * Net::SMTPMailboxPermanentlyUnavailable # * Net::SMTPUnknownError # * Net::ReadTimeout # * IOError @@ -1165,6 +1175,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 From 8f5934d6da8cc96e2661ad30a24136dce01015a0 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Mon, 27 Mar 2023 14:07:55 -0700 Subject: [PATCH 13/25] make sure it's the RCPT TO that fails --- test/net/smtp/test_smtp.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index 8b909ed..898aaef 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -589,7 +589,7 @@ def test_rcpt_to_temporary_failure_recipient end end - def test_rcpt_to_nonexistent_recipient + def test_rcpt_to_nonexistent_recipient_send_message port = fake_server_start smtp = Net::SMTP.new('localhost', port) smtp.start do |conn| @@ -600,6 +600,16 @@ def test_rcpt_to_nonexistent_recipient assert_empty @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" + conn.rcptto_list ["friend@example.net", "nonexistent@example.net"] + end + assert_empty @recipients + end + private def accept(servers) From d3beb536cc046c006d747ac97a885af3452d6c3e Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Mon, 27 Mar 2023 14:08:54 -0700 Subject: [PATCH 14/25] make test pass --- test/net/smtp/test_smtp.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index 898aaef..e231128 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -605,9 +605,11 @@ def test_rcpt_to_nonexistent_recipient_rcptto smtp = Net::SMTP.new('localhost', port) smtp.start do |conn| conn.mailfrom "me@example.org" - conn.rcptto_list ["friend@example.net", "nonexistent@example.net"] + assert_raise Net::SMTPMailboxPermanentlyUnavailable do + conn.rcptto_list ["friend@example.net", "nonexistent@example.net"] + end end - assert_empty @recipients + assert_equal ["friend@example.net"], @recipients end private From e9733341df5045e50c4524e9e4728bb1adedc178 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Mon, 27 Mar 2023 14:41:41 -0700 Subject: [PATCH 15/25] add more rcptto_list tests --- test/net/smtp/test_smtp.rb | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index e231128..c83c493 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -612,6 +612,26 @@ def test_rcpt_to_nonexistent_recipient_rcptto 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" + conn.rcptto_list [] + end + assert_equal [], @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" + conn.rcptto_list ["nonexistent1@example.net", "nonexistent2@example.net"] + end + assert_equal [], @recipients + end + private def accept(servers) From 9871cafee2f1b9174c3a464e725a5011d089fee5 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Mon, 27 Mar 2023 15:14:46 -0700 Subject: [PATCH 16/25] rcppto_list takes a block upon success, actually --- test/net/smtp/test_smtp.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index c83c493..788b235 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -606,7 +606,7 @@ def test_rcpt_to_nonexistent_recipient_rcptto smtp.start do |conn| conn.mailfrom "me@example.org" assert_raise Net::SMTPMailboxPermanentlyUnavailable do - conn.rcptto_list ["friend@example.net", "nonexistent@example.net"] + conn.rcptto_list ["friend@example.net", "nonexistent@example.net"] do end end end assert_equal ["friend@example.net"], @recipients @@ -617,7 +617,7 @@ def test_rcptto_list_empty_list smtp = Net::SMTP.new('localhost', port) smtp.start do |conn| conn.mailfrom "me@example.org" - conn.rcptto_list [] + conn.rcptto_list [] do end end assert_equal [], @recipients end @@ -627,7 +627,7 @@ def test_rcptto_list_all_nonexistent_recipients smtp = Net::SMTP.new('localhost', port) smtp.start do |conn| conn.mailfrom "me@example.org" - conn.rcptto_list ["nonexistent1@example.net", "nonexistent2@example.net"] + conn.rcptto_list ["nonexistent1@example.net", "nonexistent2@example.net"] do end end assert_equal [], @recipients end From 788b88de36e7190d86f8c7065a0606818f82cdb1 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Mon, 27 Mar 2023 15:21:28 -0700 Subject: [PATCH 17/25] these are both ArgumentError cases --- test/net/smtp/test_smtp.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index 788b235..5eea5f3 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -617,7 +617,9 @@ def test_rcptto_list_empty_list smtp = Net::SMTP.new('localhost', port) smtp.start do |conn| conn.mailfrom "me@example.org" - conn.rcptto_list [] do end + assert_raise ArgumentError do + conn.rcptto_list [] do end + end end assert_equal [], @recipients end @@ -627,7 +629,9 @@ def test_rcptto_list_all_nonexistent_recipients smtp = Net::SMTP.new('localhost', port) smtp.start do |conn| conn.mailfrom "me@example.org" - conn.rcptto_list ["nonexistent1@example.net", "nonexistent2@example.net"] do end + assert_raise ArgumentError do + conn.rcptto_list ["nonexistent1@example.net", "nonexistent2@example.net"] do end + end end assert_equal [], @recipients end From 9f46846e1bf2c027d639112897501fcabbdd667e Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Tue, 28 Mar 2023 09:11:19 -0700 Subject: [PATCH 18/25] make tests pass --- lib/net/smtp.rb | 17 ++++++++++++----- test/net/smtp/test_smtp.rb | 8 ++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 38e202d..3d0e37c 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -955,20 +955,27 @@ def mailfrom(from_addr) def rcptto_list(to_addrs) raise ArgumentError, 'mail destination not given' if to_addrs.empty? ok_users = [] - unknown_users = [] + unauthenticated_users = [] + permanent_error_users = [] to_addrs.flatten.each do |addr| begin rcptto addr rescue SMTPAuthenticationError - unknown_users << addr.to_s.dump + unauthenticated_users << addr.to_s.dump + rescue SMTPMailboxPermanentlyUnavailable + permanent_error_users << addr.to_s.dump else ok_users << addr end end - raise ArgumentError, 'mail destination not given' if ok_users.empty? + raise ArgumentError, 'all recipients rejected by server' if ok_users.empty? ret = yield - unless unknown_users.empty? - raise SMTPAuthenticationError, "failed to deliver for #{unknown_users.join(', ')}" + unless unauthenticated_users.empty? + raise SMTPAuthenticationError, "failed to authenticate for #{unauthenticated_users.join(', ')}" + end + # in this case there are mixed permanently-unavailable and OK recipients + unless permanent_error_users.empty? + raise SMTPMailboxPermanentlyUnavailable, "Recipient addresses failed: #{permanent_error_users.join(', ')}" end ret end diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index 5eea5f3..aab30c1 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -589,7 +589,7 @@ def test_rcpt_to_temporary_failure_recipient end end - def test_rcpt_to_nonexistent_recipient_send_message + def test_rcpt_to_one_nonexistent_recipient_send_message port = fake_server_start smtp = Net::SMTP.new('localhost', port) smtp.start do |conn| @@ -597,7 +597,7 @@ def test_rcpt_to_nonexistent_recipient_send_message conn.send_message "test", "me@example.org", ["nonexistent@example.net", "friend@example.net"] end end - assert_empty @recipients + assert_equal ['friend@example.net'], @recipients end def test_rcpt_to_nonexistent_recipient_rcptto @@ -621,7 +621,7 @@ def test_rcptto_list_empty_list conn.rcptto_list [] do end end end - assert_equal [], @recipients + assert_empty @recipients end def test_rcptto_list_all_nonexistent_recipients @@ -633,7 +633,7 @@ def test_rcptto_list_all_nonexistent_recipients conn.rcptto_list ["nonexistent1@example.net", "nonexistent2@example.net"] do end end end - assert_equal [], @recipients + assert_empty @recipients end private From f82ba436eb8be0e1035f884990b310fe888807be Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Tue, 28 Mar 2023 09:15:10 -0700 Subject: [PATCH 19/25] test for mixed recipient status --- test/net/smtp/test_smtp.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index aab30c1..53459c5 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -636,6 +636,18 @@ def test_rcptto_list_all_nonexistent_recipients 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) From ee5d0ad0bb7a47d2f0c9851c896dc45b700a2144 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Tue, 28 Mar 2023 10:25:28 -0700 Subject: [PATCH 20/25] test to ensure one string and one-element list are the same --- test/net/smtp/test_smtp.rb | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index 53459c5..c2f2db2 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -560,7 +560,25 @@ def test_start_instance_invalid_number_of_arguments assert_equal('wrong number of arguments (given 5, expected 0..4)', err.message) end - def test_rcpt_to + 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| From ceccdaf7053db7bac127bb30316ce0ad273d5226 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Tue, 28 Mar 2023 10:27:29 -0700 Subject: [PATCH 21/25] in send_message and open_message_stream, flatten to_addrs list --- lib/net/smtp.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 3d0e37c..4e6719a 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -771,7 +771,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 @@ -825,7 +825,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 From 86dcfa851625eab66583ab9a63ba10dfc8876ab0 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Tue, 28 Mar 2023 10:43:21 -0700 Subject: [PATCH 22/25] rework rcptto_list, adding Net::SMTPMixedRecipientStatus exception This change comes with some behavior changes: * A new exception, SMTPMailboxPermanentlyUnavailable, can be thrown from any method that accepts a recipient address. It is a subclass of SMTPFatalError. * A new exception, SMTPMixedRecipientStatus, can be thrown from `rcptto_list`. It is a subclass of SMTPMailboxPermanentlyUnavailable. * In the event of all recipients being rejected by the server, previous versions of this gem would raise an `ArgumentError` that was indistinguishable from providing 0 recipient addresses. Now it will raise a more specific SMTP error. * These exceptions are thrown *before* the method yields to the block. This allows the caller to inspect the exception and determine whether or not to continue with the transaction, instead. --- lib/net/smtp.rb | 92 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 4e6719a..6704dd0 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -76,6 +76,21 @@ class SMTPMailboxPermanentlyUnavailable < ProtoFatalError include SMTPError end + # A synthetic status, raised from `rcptto_list`, when multiple recipients are given, + # and some have succeeded, but others encountered 501 (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 @@ -952,32 +967,69 @@ def mailfrom(from_addr) getok((["MAIL FROM:<#{addr.address}>"] + addr.parameters).join(' ')) end + # +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 = [] - unauthenticated_users = [] - permanent_error_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 - unauthenticated_users << addr.to_s.dump - rescue SMTPMailboxPermanentlyUnavailable - permanent_error_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, 'all recipients rejected by server' if ok_users.empty? - ret = yield - unless unauthenticated_users.empty? - raise SMTPAuthenticationError, "failed to authenticate for #{unauthenticated_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 - # in this case there are mixed permanently-unavailable and OK recipients - unless permanent_error_users.empty? - raise SMTPMailboxPermanentlyUnavailable, "Recipient addresses failed: #{permanent_error_users.join(', ')}" + + # 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+ From dccfdf651233c1810b47bffbf9f6fff772c4df63 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Tue, 28 Mar 2023 11:05:56 -0700 Subject: [PATCH 23/25] improve documentation --- lib/net/smtp.rb | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 6704dd0..7875332 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -61,12 +61,12 @@ 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, 53x, and 55x) + # Represents a fatal SMTP error (error code 5xx, except for 50x, 53x, and 55x) class SMTPFatalError < ProtoFatalError include SMTPError end @@ -77,7 +77,7 @@ class SMTPMailboxPermanentlyUnavailable < ProtoFatalError end # A synthetic status, raised from `rcptto_list`, when multiple recipients are given, - # and some have succeeded, but others encountered 501 (syntax error) or 55x (permanent + # and some have succeeded, but others encountered 50x (syntax error) or 55x (permanent # mailbox error). class SMTPMixedRecipientStatus < SMTPMailboxPermanentlyUnavailable include SMTPError @@ -967,7 +967,17 @@ def mailfrom(from_addr) getok((["MAIL FROM:<#{addr.address}>"] + addr.parameters).join(' ')) end - # +to_addrs+ is an +Enumerable+ of +String+ or +Net::SMTP::Address+ + # 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 From cbaedf31a5dec5e85de02a37a2e18372956005c4 Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Tue, 28 Mar 2023 11:07:58 -0700 Subject: [PATCH 24/25] make tests pass --- test/net/smtp/test_smtp.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index c2f2db2..ea8e03d 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -591,7 +591,7 @@ def test_rcpt_to_bad_recipient port = fake_server_start smtp = Net::SMTP.new('localhost', port) smtp.start do |conn| - assert_raise Net::SMTPSyntaxError do + assert_raise Net::SMTPMixedRecipientStatus do conn.send_message "test", "me@example.org", ["you@example.net", "-friend@example.net"] end end @@ -611,7 +611,7 @@ 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::SMTPMailboxPermanentlyUnavailable do + assert_raise Net::SMTPMixedRecipientStatus do conn.send_message "test", "me@example.org", ["nonexistent@example.net", "friend@example.net"] end end @@ -623,7 +623,7 @@ def test_rcpt_to_nonexistent_recipient_rcptto smtp = Net::SMTP.new('localhost', port) smtp.start do |conn| conn.mailfrom "me@example.org" - assert_raise Net::SMTPMailboxPermanentlyUnavailable do + assert_raise Net::SMTPMixedRecipientStatus do conn.rcptto_list ["friend@example.net", "nonexistent@example.net"] do end end end @@ -647,7 +647,7 @@ def test_rcptto_list_all_nonexistent_recipients smtp = Net::SMTP.new('localhost', port) smtp.start do |conn| conn.mailfrom "me@example.org" - assert_raise ArgumentError do + assert_raise Net::SMTPMailboxPermanentlyUnavailable do conn.rcptto_list ["nonexistent1@example.net", "nonexistent2@example.net"] do end end end From cd439ec4db8bceff714db949201ce0056288b8dd Mon Sep 17 00:00:00 2001 From: Alex Maestas Date: Tue, 28 Mar 2023 11:17:54 -0700 Subject: [PATCH 25/25] Make superclass of SMTPMailboxPermanentlyUnavailable as documented --- lib/net/smtp.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 7875332..779bcf1 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -72,7 +72,7 @@ class SMTPFatalError < ProtoFatalError end # Represents a fatal SMTP error pertaining to the mailbox (error code 55x) - class SMTPMailboxPermanentlyUnavailable < ProtoFatalError + class SMTPMailboxPermanentlyUnavailable < SMTPFatalError include SMTPError end