From 306e9ba5e5f37a3ae2d6d4cc70f19c08a0605fd7 Mon Sep 17 00:00:00 2001 From: "nicholas a. evans" Date: Wed, 5 Feb 2020 12:22:00 -0500 Subject: [PATCH 1/2] net/imap: support IMAP4 ID extension (RFC2971) IMAP servers often strongly recommend clients to report their ID, for usage statistics and bug reports. Clients may also find server IDs useful for the same reasons. Although the RFC requires otherwise, some servers do make it a soft requirement that the ID is reported (and will sometimes even disconnect clients they do not recognize). See https://tools.ietf.org/html/rfc2971 for more details. --- lib/net/imap.rb | 123 +++++++++++++++++++++ test/net/imap/test_imap.rb | 47 ++++++++ test/net/imap/test_imap_response_parser.rb | 12 ++ 3 files changed, 182 insertions(+) diff --git a/lib/net/imap.rb b/lib/net/imap.rb index ae23c0acf..57a1edb22 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -365,6 +365,30 @@ def capability end end + # Sends an ID command, and returns a hash of the server's + # response, or nil if the server does not identify itself. + # + # Note that the user should first check if the server supports the ID + # capability. For example: + # + # capabilities = imap.capability + # if capabilities.include?("ID") + # id = imap.id( + # name: "my IMAP client (ruby)", + # version: MyIMAP::VERSION, + # "support-url": "mailto:bugs@example.com", + # os: RbConfig::CONFIG["host_os"], + # ) + # end + # + # See RFC 2971, Section 3.3, for defined fields. + def id(client_id=nil) + synchronize do + send_command("ID", ClientID.new(client_id)) + return @responses.delete("ID")[-1] + end + end + # Sends a NOOP command to the server. It does nothing. def noop send_command("NOOP") @@ -1656,6 +1680,74 @@ def validate_internal(data) end end + class ClientID # :nodoc: + + def send_data(imap, tag) + imap.__send__(:send_data, format_internal(@data), tag) + end + + def validate + validate_internal(@data) + end + + private + + def initialize(data) + @data = data + end + + def validate_internal(client_id) + client_id.to_h.each do |k,v| + unless StringFormatter.valid_string?(k) + raise DataFormatError, client_id.inspect + end + end + rescue NoMethodError, TypeError # to_h failed + raise DataFormatError, client_id.inspect + end + + def format_internal(client_id) + return nil if client_id.nil? + client_id.to_h.flat_map {|k,v| + [StringFormatter.string(k), StringFormatter.nstring(v)] + } + end + + end + + module StringFormatter + + LITERAL_REGEX = /[\x80-\xff\r\n]/n + + module_function + + # Allows symbols in addition to strings + def valid_string?(str) + str.is_a?(Symbol) || str.respond_to?(:to_str) + end + + # Allows nil, symbols, and strings + def valid_nstring?(str) + str.nil? || valid_string?(str) + end + + # coerces using +to_s+ + def string(str) + str = str.to_s + if str =~ LITERAL_REGEX + Literal.new(str) + else + QuotedString.new(str) + end + end + + # coerces non-nil using +to_s+ + def nstring(str) + str.nil? ? nil : string(str) + end + + end + # Common validators of number and nz_number types module NumValidator # :nodoc class << self @@ -2291,6 +2383,8 @@ def response_untagged return response_cond when /\A(?:FLAGS)\z/ni return flags_response + when /\A(?:ID)\z/ni + return id_response when /\A(?:LIST|LSUB|XLIST)\z/ni return list_response when /\A(?:QUOTA)\z/ni @@ -3129,6 +3223,35 @@ def capability_response return UntaggedResponse.new(name, data, @str) end + def id_response + token = match(T_ATOM) + name = token.value.upcase + match(T_SPACE) + token = match(T_LPAR, T_NIL) + if token.symbol == T_NIL + return UntaggedResponse.new(name, nil, @str) + else + data = {} + while true + token = lookahead + case token.symbol + when T_RPAR + shift_token + break + when T_SPACE + shift_token + next + else + key = string + match(T_SPACE) + val = nstring + data[key] = val + end + end + return UntaggedResponse.new(name, data, @str) + end + end + def resp_text @lex_state = EXPR_RTEXT token = lookahead diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index 8b924b524..d2a210aae 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -753,6 +753,53 @@ def test_append_fail end end + def test_id + server = create_tcp_server + port = server.addr[1] + requests = Queue.new + server_id = {"name" => "test server", "version" => "v0.1.0"} + server_id_str = '("name" "test server" "version" "v0.1.0")' + @threads << Thread.start do + sock = server.accept + begin + sock.print("* OK test server\r\n") + requests.push(sock.gets) + sock.print("* ID #{server_id_str}\r\n") + sock.print("RUBY0001 OK ID completed\r\n") + requests.push(sock.gets) + sock.print("* ID #{server_id_str}\r\n") + sock.print("RUBY0002 OK ID completed\r\n") + requests.push(sock.gets) + sock.print("* ID #{server_id_str}\r\n") + sock.print("RUBY0003 OK ID completed\r\n") + requests.push(sock.gets) + sock.print("* BYE terminating connection\r\n") + sock.print("RUBY0004 OK LOGOUT completed\r\n") + ensure + sock.close + server.close + end + end + + begin + imap = Net::IMAP.new(server_addr, :port => port) + resp = imap.id + assert_equal(server_id, resp) + assert_equal("RUBY0001 ID NIL\r\n", requests.pop) + resp = imap.id({}) + assert_equal(server_id, resp) + assert_equal("RUBY0002 ID ()\r\n", requests.pop) + resp = imap.id("name" => "test client", "version" => "latest") + assert_equal(server_id, resp) + assert_equal("RUBY0003 ID (\"name\" \"test client\" \"version\" \"latest\")\r\n", + requests.pop) + imap.logout + assert_equal("RUBY0004 LOGOUT\r\n", requests.pop) + ensure + imap.disconnect if imap + end + end + private def imaps_test diff --git a/test/net/imap/test_imap_response_parser.rb b/test/net/imap/test_imap_response_parser.rb index 4e470459c..343e7eeba 100644 --- a/test/net/imap/test_imap_response_parser.rb +++ b/test/net/imap/test_imap_response_parser.rb @@ -236,6 +236,18 @@ def test_capability assert_equal("AUTH=PLAIN", response.data.last) end + def test_id + parser = Net::IMAP::ResponseParser.new + response = parser.parse("* ID NIL\r\n") + assert_equal("ID", response.name) + assert_equal(nil, response.data) + response = parser.parse("* ID (\"name\" \"GImap\" \"vendor\" \"Google, Inc.\" \"support-url\" NIL)\r\n") + assert_equal("ID", response.name) + assert_equal("GImap", response.data["name"]) + assert_equal("Google, Inc.", response.data["vendor"]) + assert_equal(nil, response.data.fetch("support-url")) + end + def test_mixed_boundary parser = Net::IMAP::ResponseParser.new response = parser.parse("* 2688 FETCH (UID 179161 BODYSTRUCTURE (" \ From 4be60970a5245916409336da83243c56196f4722 Mon Sep 17 00:00:00 2001 From: "nicholas a. evans" Date: Tue, 8 Dec 2020 02:20:20 -0500 Subject: [PATCH 2/2] Handle (buggy) missing ID response RFC 2971 very clearly states (in section 3.2): > a server MUST send a tagged ID response to an ID command. and yet... some servers don't. Co-Authored-By: Arkadiusz Holko --- lib/net/imap.rb | 2 +- test/net/imap/test_imap.rb | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 57a1edb22..42cc1bf6c 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -385,7 +385,7 @@ def capability def id(client_id=nil) synchronize do send_command("ID", ClientID.new(client_id)) - return @responses.delete("ID")[-1] + @responses.delete("ID")&.last end end diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index d2a210aae..20ea62c69 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -764,7 +764,9 @@ def test_id begin sock.print("* OK test server\r\n") requests.push(sock.gets) - sock.print("* ID #{server_id_str}\r\n") + # RFC 2971 very clearly states (in section 3.2): + # "a server MUST send a tagged ID response to an ID command." + # And yet... some servers report ID capability but won't the response. sock.print("RUBY0001 OK ID completed\r\n") requests.push(sock.gets) sock.print("* ID #{server_id_str}\r\n") @@ -784,7 +786,7 @@ def test_id begin imap = Net::IMAP.new(server_addr, :port => port) resp = imap.id - assert_equal(server_id, resp) + assert_equal(nil, resp) assert_equal("RUBY0001 ID NIL\r\n", requests.pop) resp = imap.id({}) assert_equal(server_id, resp)