From a535b1f45ec9d50a4db9599bbc6e7aed4f10af30 Mon Sep 17 00:00:00 2001 From: nicholas evans Date: Tue, 7 May 2024 16:58:17 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Gather=20ESEARCH=20response=20to=20?= =?UTF-8?q?#search/#uid=5Fsearch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the server returns both `ESEARCH` and `SEARCH`, both are cleared from the responses hash, but only the `ESEARCH` is returned. When the server doesn't send any search responses: If return options are passed, return an empty ESearchResult. It will have the appropriate `tag` and `uid` values, but no `data`. Otherwise return an empty `SearchResult` (changed from empty array). --- lib/net/imap.rb | 36 ++++++++++++++++++++++++---- test/net/imap/test_imap.rb | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/lib/net/imap.rb b/lib/net/imap.rb index df9ed6a8..92275708 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -1930,7 +1930,7 @@ def uid_expunge(uid_set) end # :call-seq: - # search(criteria, charset = nil) -> result + # search(criteria, charset = nil, esearch: false) -> result # # Sends a {SEARCH command [IMAP4rev1 ยง6.4.4]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.4.4] # to search the mailbox for messages that match the given search +criteria+, @@ -1973,6 +1973,11 @@ def uid_expunge(uid_set) # Do not use the +charset+ argument when either return options or charset # are embedded in +criteria+. # + # +esearch+ controls the return type when the server does not return any + # search results. If +esearch+ is +true+ or +criteria+ begins with + # +RETURN+, an empty ESearchResult will be returned. When +esearch+ is + # +false+, an empty SearchResult will be returned. + # # Related: #uid_search # # ===== For example: @@ -3146,12 +3151,35 @@ def enforce_logindisabled? end end - def search_internal(cmd, keys, charset = nil) + HasSearchReturnOpts = ->keys { + keys in RawData[/\ARETURN /] | Array[/\ARETURN\z/i, *] + } + private_constant :HasSearchReturnOpts + + def search_internal(cmd, keys, charset = nil, esearch: nil) keys = normalize_searching_criteria(keys) args = charset ? ["CHARSET", charset, *keys] : keys + # TODO: check if certain extensions are enabled + esearch = (keys in HasSearchReturnOpts) if esearch.nil? synchronize do - send_command(cmd, *args) - clear_responses("SEARCH").last || [] + tagged = send_command(cmd, *args) + tag = tagged.tag + # Only the last ESEARCH or SEARCH is used. Excess results are ignored. + esearch_result = extract_responses("ESEARCH") {|response| + response in ESearchResult(tag: ^tag) + }.last + search_result = clear_responses("SEARCH").last + if esearch_result + esearch_result # silently ignore SEARCH results + elsif search_result + # TODO: warn if ESEARCH result was expected, i.e: buggy server? + # warn EXPECTED_ESEARCH_RESULT if esearch + search_result + elsif esearch + ESearchResult[tag:, uid: cmd == "UID SEARCH"] + else + SearchResult[] + end end end diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index c9fe5ae1..e468bc51 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -1257,6 +1257,54 @@ def seqset_coercible.to_sequence_set end end + test("#search/#uid_search with ESEARCH or IMAP4rev2") do + with_fake_server do |server, imap| + # Example from RFC9051, 6.4.4: + # C: A282 SEARCH RETURN (MIN COUNT) FLAGGED + # SINCE 1-Feb-1994 NOT FROM "Smith" + # S: * ESEARCH (TAG "A282") MIN 2 COUNT 3 + # S: A282 OK SEARCH completed + server.on "SEARCH" do |cmd| + cmd.untagged "ESEARCH", "(TAG \"unrelated1\") MIN 1 COUNT 2" + cmd.untagged "ESEARCH", "(TAG %p) MIN 2 COUNT 3" % [cmd.tag] + cmd.untagged "ESEARCH", "(TAG \"unrelated2\") MIN 222 COUNT 333" + cmd.done_ok + end + result = imap.search( + 'RETURN (MIN COUNT) FLAGGED SINCE 1-Feb-1994 NOT FROM "Smith"' + ) + cmd = server.commands.pop + assert_equal Net::IMAP::ESearchResult.new( + cmd.tag, false, [["MIN", 2], ["COUNT", 3]] + ), result + esearch_responses = imap.clear_responses("ESEARCH") + assert_equal 2, esearch_responses.count + refute esearch_responses.include?(result) + end + end + + test("missing server ESEARCH response") do + with_fake_server do |server, imap| + # Example from RFC9051, 6.4.4: + # C: A282 SEARCH RETURN (SAVE) FLAGGED SINCE 1-Feb-1994 NOT FROM "Smith" + # S: A282 OK SEARCH completed, result saved + server.on "SEARCH" do |cmd| cmd.done_ok "result saved" end + server.on "UID SEARCH" do |cmd| cmd.done_ok "result saved" end + result = imap.search( + 'RETURN (SAVE) FLAGGED SINCE 1-Feb-1994 NOT FROM "Smith"' + ) + assert_pattern do + result => Net::IMAP::ESearchResult[uid: false, tag: /^RUBY\d+/, data: []] + end + result = imap.uid_search( + 'RETURN (SAVE) FLAGGED SINCE 1-Feb-1994 NOT FROM "Smith"' + ) + assert_pattern do + result => Net::IMAP::ESearchResult[uid: true, tag: /^RUBY\d+/, data: []] + end + end + end + test("missing server SEARCH response") do with_fake_server do |server, imap| server.on "SEARCH", &:done_ok