diff --git a/lib/net/imap.rb b/lib/net/imap.rb index da028106..b3330b9a 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -1934,9 +1934,9 @@ def uid_expunge(uid_set) # # 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+, - # and returns a SearchResult. SearchResult inherits from Array (for - # backward compatibility) but adds SearchResult#modseq when the +CONDSTORE+ - # capability has been enabled. + # and returns either a SearchResult or an ESearchResult. SearchResult + # inherits from Array (for backward compatibility) but adds + # SearchResult#modseq when the +CONDSTORE+ capability has been enabled. # # +criteria+ is one or more search keys and their arguments, which may be # provided as an array or a string. @@ -1967,8 +1967,11 @@ def uid_expunge(uid_set) # set}[https://www.iana.org/assignments/character-sets/character-sets.xhtml] # used by strings in the search +criteria+. When +charset+ isn't specified, # either "US-ASCII" or "UTF-8" is assumed, depending on - # the server's capabilities. +charset+ may be sent inside +criteria+ - # instead of as a separate argument. + # the server's capabilities. + # + # _NOTE:_ Return options and +charset+ may be sent as part of +criteria+. + # Do not use the +charset+ argument when either return options or charset + # are embedded in +criteria+. # # Related: #uid_search # @@ -1988,6 +1991,12 @@ def uid_expunge(uid_set) # # criteria string contains charset arg # imap.search("CHARSET UTF-8 OR UNSEEN (FLAGGED SUBJECT foo)") # + # Sending return optionsand charset embedded in the +crriteria+ arg: + # imap.search("RETURN (MIN MAX) CHARSET UTF-8 (OR UNSEEN FLAGGED)") + # imap.search(["RETURN", %w(MIN MAX), + # "CHARSET", "UTF-8", + # %w(OR UNSEEN FLAGGED)]) + # # ===== Search keys # # For full definitions of the standard search +criteria+, @@ -2178,6 +2187,12 @@ def uid_expunge(uid_set) # # ===== Capabilities # + # Return options should only be specified when the server supports + # +IMAP4rev2+ or an extension that allows them, such as +ESEARCH+. + # + # When +IMAP4rev2+ is enabled, or when the server supports +IMAP4rev2+ but + # not +IMAP4rev1+, ESearchResult is always returned instead of SearchResult. + # # If CONDSTORE[https://www.rfc-editor.org/rfc/rfc7162.html] is supported # and enabled for the selected mailbox, a non-empty SearchResult will # include a +MODSEQ+ value. diff --git a/lib/net/imap/esearch_result.rb b/lib/net/imap/esearch_result.rb new file mode 100644 index 00000000..796b5e86 --- /dev/null +++ b/lib/net/imap/esearch_result.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +module Net + class IMAP + # An Extended search result which is returned by IMAP#search, + # IMAP#uid_search, IMAP#sort, and IMAP#uid_sort instead of SearchResult + # under the following conditions: + # + # * The server supports +ESEARCH+ and a +return+ option was specified. + # * The server supports +ESORT+ and a +return+ options was specified (for + # IMAP#sort and IMAP#uid_sort). + # * The server supports +IMAP4rev2+ but _not_ +IMAP4rev1+. + # * +IMAP4rev2+ has been enabled. + # + class ESearchResult < Data.define(:tag, :uid, :data) + def initialize(tag: nil, uid: nil, data: nil) + tag => String | nil; tag = -tag if tag + uid => true | false | nil; uid = !!uid + data => Array | nil; data ||= []; data.freeze + super + end + + # :call-seq: to_a -> Array of integers + # + # When either #all or #partial contains a SequenceSet of message sequence + # numbers or UIDs, +to_a+ returns that set as an array of integers. + # + # When both #all and #partial are +nil+, either because the server + # returned no results or because +ALL+ and +PARTIAL+ were not included in + # the IMAP#search +RETURN+ options, #to_a returns an empty array. + # + # Note that +to_a+ is also a valid method on SearchResult, so it can be + # used without checking if the server returned +SEARCH+ or +ESEARCH+ data. + def to_a; all&.numbers || partial&.to_a || [] end + + ## + # method: tag + # :call-seq: tag -> string or nil + # + # The tag of the command that caused the response to be returned. + # + # If it is missing, then the response was not caused by a particular IMAP + # command. + + ## + # method: uid + # :call-seq: uid -> boolean + # + # When true, all #data in the +ESEARCH+ response refers to UIDs; + # otherwise, all returned #data refers to message sequence numbers. + + alias uid? uid + + ## + # method: data + # :call-seq: data -> array of [name, value] pairs + # + # Search return data, which can also be retrieved by #min, #max, #all, + # #count, #modseq, and other methods. Most names correspond to an + # IMAP#search +return+ option of the same name. + # + # Stored as an array of (name, value) pairs rather than as a hash, because + # extensions may allow the same name to be used more than once per result. + + # :call-seq: min -> integer or nil + # + # The lowest message number/UID that satisfies the SEARCH criteria. + # Returns nil when the associated search command has no results, or when + # the +MIN+ return option wasn't specified. + # + # See +ESEARCH+ ({RFC4731 + # §3.1}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1]) or + # +IMAP4rev2+ ({RFC9051 + # §7.3.4}[https://www.rfc-editor.org/rfc/rfc9051.html#section-7.3.4]) + def min; data.assoc("MIN")&.last end + + # :call-seq: max -> integer or nil + # + # The highest message number/UID that satisfies the SEARCH criteria. + # Returns nil when the associated search command has no results, or when + # the +MAX+ return option wasn't specified. + # + # See +ESEARCH+ ({RFC4731 + # §3.1}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1]) or + # +IMAP4rev2+ ({RFC9051 + # §7.3.4}[https://www.rfc-editor.org/rfc/rfc9051.html#section-7.3.4]) + def max; data.assoc("MAX")&.last end + + # :call-seq: all -> sequence set or nil + # + # A SequenceSet containing all message numbers/UIDs that satisfy the + # SEARCH criteria. Returns +nil+ when the associated search command has + # no results, or when the +ALL+ return option wasn't specified. + # + # See +ESEARCH+ ({RFC4731 + # §3.1}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1]) or + # +IMAP4rev2+ ({RFC9051 + # §7.3.4}[https://www.rfc-editor.org/rfc/rfc9051.html#section-7.3.4]) + # + # See also: #to_a + def all; data.assoc("ALL")&.last end + + # :call-seq: count -> integer or nil + # + # Returns the number of messages that satisfy the SEARCH criteria. + # Returns +nil+ when the associated search command has no results. + # + # See +ESEARCH+ ({RFC4731 + # §3.1}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1]) or + # +IMAP4rev2+ ({RFC9051 + # §7.3.4}[https://www.rfc-editor.org/rfc/rfc9051.html#section-7.3.4]) + def count; data.assoc("COUNT")&.last end + + # :call-seq: modseq -> integer or nil + # + # The highest +mod-sequence+ of all messages in the set that satisfy the + # SEARCH criteria and result options. Returns +nil+ when the associated + # search command has no results. + # + # See +CONDSTORE+ + # {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html]. + def modseq; data.assoc("MODSEQ")&.last end + + class ContextUpdate < Data.define(:position, :set) + def initialize(position:, set:) + position = NumValidator.ensure_number(position) + set => SequenceSet + super + end + + ## + # method: position + + ## + # method: set + + end + + class AddToContext < ContextUpdate + end + + class RemoveFromContext < ContextUpdate + end + + # :call-seq: addto -> array of insertion updates, or nil + # + # Notification of updates, inserting messages into the result list for the + # command issued with #tag. + # + # See CONTEXT=SEARCH/CONTEXT=SORT + # {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html] + def addto + data.flat_map { _1 == "ADDTO" ? _2 : [] } + end + + # :call-seq: removefrom -> array of removal updates, or nil + # + # Notification of updates, removing messages into the result list for the + # command issued with #tag. + # + # See CONTEXT=SEARCH/CONTEXT=SORT + # {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html] + def removefrom + data.flat_map { _1 == "REMOVEFROM" ? _2 : [] } + end + + # :call-seq: updates -> array of context updates, or nil + # + # Notification of updates, inserting or removing messages to or from the + # result list for the command issued with #tag. + # + # See CONTEXT=SEARCH/CONTEXT=SORT + # {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html] + def updates + data.flat_map { %w[ADDTO REMOVEFROM].include?(_1) ? _2 : [] } + end + + # See +PARTIAL+ {[RFC9394]}[https://www.rfc-editor.org/rfc/rfc9394.html] + # or CONTEXT=SEARCH/CONTEXT=SORT + # {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html] + # + # See also: #to_a + class PartialResult < Data.define(:range, :results) + def initialize(range:, results:) + range => Range + results => SequenceSet | nil + super + end + + ## + # method: range + # :call-seq: range -> range + + ## + # method: results + # :call-seq: results -> sequence set or nil + + # Converts #results to an array of integers. + # + # See ESearchResult#to_a. + def to_a; results&.numbers || [] end + end + + # :call-seq: partial -> PartialResult or nil + # + # Return a PartialResult with a subset of the message numbers/UIDs that + # satisfy the SEARCH criteria. + # + # See +PARTIAL+ {[RFC9394]}[https://www.rfc-editor.org/rfc/rfc9394.html] + # or CONTEXT=SEARCH/CONTEXT=SORT + # {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html] + def partial; data.assoc("PARTIAL")&.last end + + # :call-seq: relevancy -> integer or nil + # + # Return a relevancy score for each message that satisfies the SEARCH + # criteria. + # + # See SEARCH=FUZZY + # {[RFC6203]}[https://www.rfc-editor.org/rfc/rfc6203.html] + def relevancy; data.assoc("RELEVANCY")&.last end + + end + end +end diff --git a/lib/net/imap/response_data.rb b/lib/net/imap/response_data.rb index 25c1d42c..6b50cf81 100644 --- a/lib/net/imap/response_data.rb +++ b/lib/net/imap/response_data.rb @@ -2,6 +2,7 @@ module Net class IMAP < Protocol + autoload :ESearchResult, "#{__dir__}/esearch_result" autoload :FetchData, "#{__dir__}/fetch_data" autoload :SearchResult, "#{__dir__}/search_result" autoload :SequenceSet, "#{__dir__}/sequence_set" diff --git a/lib/net/imap/response_parser.rb b/lib/net/imap/response_parser.rb index f120f9ba..42354332 100644 --- a/lib/net/imap/response_parser.rb +++ b/lib/net/imap/response_parser.rb @@ -321,6 +321,20 @@ module RFC3629 SEQUENCE_SET = /#{SEQUENCE_SET_ITEM}(?:,#{SEQUENCE_SET_ITEM})*/n SEQUENCE_SET_STR = /\A#{SEQUENCE_SET}\z/n + # partial-range-first = nz-number ":" nz-number + # ;; Request to search from oldest (lowest UIDs) to + # ;; more recent messages. + # ;; A range 500:400 is the same as 400:500. + # ;; This is similar to from [RFC3501] + # ;; but cannot contain "*". + PARTIAL_RANGE_FIRST = /\A(#{NZ_NUMBER}):(#{NZ_NUMBER})\z/n + + # partial-range-last = MINUS nz-number ":" MINUS nz-number + # ;; Request to search from newest (highest UIDs) to + # ;; oldest messages. + # ;; A range -500:-400 is the same as -400:-500. + PARTIAL_RANGE_LAST = /\A(-#{NZ_NUMBER}):(-#{NZ_NUMBER})\z/n + # RFC3501: # literal = "{" number "}" CRLF *CHAR8 # ; Number represents the number of CHAR8s @@ -769,7 +783,6 @@ def remaining_unparsed def response_data__ignored; response_data__unhandled(IgnoredResponse) end alias response_data__noop response_data__ignored - alias esearch_response response_data__unhandled alias expunged_resp response_data__unhandled alias uidfetch_resp response_data__unhandled alias listrights_data response_data__unhandled @@ -1468,6 +1481,168 @@ def mailbox_data__search end alias sort_data mailbox_data__search + # esearch-response = "ESEARCH" [search-correlator] [SP "UID"] + # *(SP search-return-data) + # ;; Note that SEARCH and ESEARCH responses + # ;; SHOULD be mutually exclusive, + # ;; i.e., only one of the response types + # ;; should be + # ;; returned as a result of a command. + # esearch-response = "ESEARCH" [search-correlator] [SP "UID"] + # *(SP search-return-data) + # ; ESEARCH response replaces SEARCH response + # ; from IMAP4rev1. + # search-correlator = SP "(" "TAG" SP tag-string ")" + def esearch_response + name = label("ESEARCH") + tag = search_correlator if peek_str?(" (") + uid = peek_re?(/\G UID\b/i) && (SP!; label("UID"); true) + data = [] + data << search_return_data while SP? + esearch = ESearchResult.new(tag, uid, data) + UntaggedResponse.new(name, esearch, @str) + end + + # From RFC4731 (ESEARCH): + # search-return-data = "MIN" SP nz-number / + # "MAX" SP nz-number / + # "ALL" SP sequence-set / + # "COUNT" SP number / + # search-ret-data-ext + # ; All return data items conform to + # ; search-ret-data-ext syntax. + # search-ret-data-ext = search-modifier-name SP search-return-value + # search-modifier-name = tagged-ext-label + # search-return-value = tagged-ext-val + # + # From RFC4731 (ESEARCH): + # search-return-data =/ "MODSEQ" SP mod-sequence-value + # + # From RFC5267 (CONTEXT=SEARCH, CONTEXT=SORT): + # search-return-data =/ ret-data-partial / ret-data-addto / + # ret-data-removefrom + # + # From RFC6203 (SEARCH=FUZZY): + # search-return-data =/ "RELEVANCY" SP score-list + # + # From RFC9394 (PARTIAL): + # search-return-data =/ ret-data-partial + # + def search_return_data + label = search_modifier_name; SP! + value = + case label + when "MIN" then nz_number + when "MAX" then nz_number + when "ALL" then sequence_set + when "COUNT" then number + when "MODSEQ" then mod_sequence_value # RFC7162: CONDSTORE + when "RELEVANCY" then score_list # RFC6203: SEARCH=FUZZY + when "PARTIAL" then ret_data_partial__value # RFC9394: PARTIAL + when "ADDTO" then ret_data_addto__value # RFC5267: CONTEXT=* + when "REMOVEFROM" then ret_data_removefrom__value # RFC5267: CONTEXT=* + else search_return_value + end + [label, value] + end + + # From RFC5267 (CONTEXT=SEARCH, CONTEXT=SORT) and RFC9394 (PARTIAL): + # ret-data-partial = "PARTIAL" + # SP "(" partial-range SP partial-results ")" + def ret_data_partial__value + lpar + range = partial_range; SP! + results = partial_results + rpar + ESearchResult::PartialResult.new(range, results) + end + + # partial-range = partial-range-first / partial-range-last + # tagged-ext-simple =/ partial-range-last + def partial_range + case (str = atom) + when Patterns::PARTIAL_RANGE_FIRST, Patterns::PARTIAL_RANGE_LAST + min, max = [Integer($1), Integer($2)].minmax + min..max + else + parse_error("unexpected atom %p, expected partial-range", str) + end + end + + # partial-results = sequence-set / "NIL" + # ;; from [RFC3501]. + # ;; NIL indicates that no results correspond to + # ;; the requested range. + def partial_results; NIL? ? nil : sequence_set end + + # ret-data-addto = "ADDTO" + # SP "(" context-position SP sequence-set + # *(SP context-position SP sequence-set) + # ")" + def ret_data_addto__value + lpar; list = [ret_data_addto__item] + (SP!; list << ret_data_addto__item) until rpar? + list + end + + def ret_data_addto__item + ESearchResult::AddToContext.new(context_position, (SP!; sequence_set)) + end + + # ret-data-removefrom = "REMOVEFROM" + # SP "(" context-position SP sequence-set + # *(SP context-position SP sequence-set) + # ")" + def ret_data_removefrom__value + lpar; list = [ret_data_removefrom__item] + (SP!; list << ret_data_removefrom__item) until rpar? + list + end + + def ret_data_removefrom__item + ESearchResult::RemoveFromContext.new(context_position, + (SP!; sequence_set)) + end + + # context-position = number + # ;; Context position may be 0 for SEARCH result additions. + # ;; from [IMAP] + alias context_position number + + # search-modifier-name = tagged-ext-label + alias search_modifier_name tagged_ext_label + + # search-return-value = tagged-ext-val + # ; Data for the returned search option. + # ; A single "nz-number"/"number"/"number64" value + # ; can be returned as an atom (i.e., without + # ; quoting). A sequence-set can be returned + # ; as an atom as well. + def search_return_value; ExtensionData.new(tagged_ext_val) end + + # From RFC6203 (SEARCH=FUZZY): + # score = 1*3DIGIT + # ;; (1 <= n <= 100) + alias score nz_number + + # From RFC6203 (SEARCH=FUZZY): + # score-list = "(" [score *(SP score)] ")" + def score_list + lpar; return [] if rpar? + list = [score]; (SP!; list << score) until rpar? + list + end + + # search-correlator = SP "(" "TAG" SP tag-string ")" + def search_correlator + SP!; lpar; label("TAG"); SP!; tag = tag_string; rpar + tag + end + + # tag-string = astring + # ; represented as + alias tag_string astring + # RFC5256: THREAD # thread-data = "THREAD" [SP 1*thread-list] def thread_data diff --git a/lib/net/imap/response_parser/parser_utils.rb b/lib/net/imap/response_parser/parser_utils.rb index bc84bed9..85fd2b8d 100644 --- a/lib/net/imap/response_parser/parser_utils.rb +++ b/lib/net/imap/response_parser/parser_utils.rb @@ -185,6 +185,11 @@ def peek_str?(str) @str[@pos, str.length] == str end + def peek_re?(re) + assert_no_lookahead if Net::IMAP.debug + re.match?(@str, @pos) + end + def peek_re(re) assert_no_lookahead if config.debug? re.match(@str, @pos) diff --git a/rakelib/rfcs.rake b/rakelib/rfcs.rake index d1b9bb7c..950c49cc 100644 --- a/rakelib/rfcs.rake +++ b/rakelib/rfcs.rake @@ -145,6 +145,7 @@ RFCS = { 8514 => "IMAP SAVEDATE", 8970 => "IMAP PREVIEW", 9208 => "IMAP QUOTA, QUOTA=, QUOTASET", + 9394 => "IMAP PARTIAL", # etc... 3629 => "UTF8", diff --git a/test/net/imap/fixtures/response_parser/esearch_responses.yml b/test/net/imap/fixtures/response_parser/esearch_responses.yml new file mode 100644 index 00000000..d070d433 --- /dev/null +++ b/test/net/imap/fixtures/response_parser/esearch_responses.yml @@ -0,0 +1,331 @@ +--- +:tests: + rfc9051_6.4.4_ESEARCH_example_1: + :response: "* ESEARCH (TAG \"A282\") MIN 2 COUNT 3\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: A282 + uid: false + data: + - - MIN + - 2 + - - COUNT + - 3 + raw_data: "* ESEARCH (TAG \"A282\") MIN 2 COUNT 3\r\n" + + rfc9051_6.4.4_ESEARCH_example_2: + :response: "* ESEARCH (TAG \"A283\") ALL 2,10:11\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: A283 + uid: false + data: + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: 2,10:11 + tuples: + - - 2 + - 2 + - - 10 + - 11 + raw_data: "* ESEARCH (TAG \"A283\") ALL 2,10:11\r\n" + + rfc9051_6.4.4_ESEARCH_example_3: + :response: "* ESEARCH (TAG \"A284\")\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: A284 + uid: false + data: [] + raw_data: "* ESEARCH (TAG \"A284\")\r\n" + + rfc9051_6.4.4_ESEARCH_example_4: + :response: "* ESEARCH (TAG \"A285\") ALL 43\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: A285 + uid: false + data: + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: '43' + tuples: + - - 43 + - 43 + raw_data: "* ESEARCH (TAG \"A285\") ALL 43\r\n" + + rfc9051_6.4.4_ESEARCH_example_5: + :response: "* ESEARCH (TAG \"A284\") MIN 4\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: A284 + uid: false + data: + - - MIN + - 4 + raw_data: "* ESEARCH (TAG \"A284\") MIN 4\r\n" + + rfc9051_6.4.4_ESEARCH_example_6: + :response: "* ESEARCH (TAG \"A285\") UID MIN 7 MAX 3800\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: A285 + uid: true + data: + - - MIN + - 7 + - - MAX + - 3800 + raw_data: "* ESEARCH (TAG \"A285\") UID MIN 7 MAX 3800\r\n" + + rfc9051_6.4.4_ESEARCH_example_7: + :response: "* ESEARCH (TAG \"A286\") COUNT 15\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: A286 + uid: false + data: + - - COUNT + - 15 + raw_data: "* ESEARCH (TAG \"A286\") COUNT 15\r\n" + + rfc9051_6.4.4.4_ESEARCH_example_1: + :response: "* ESEARCH (TAG \"A301\") UID ALL 17,900,901\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: A301 + uid: true + data: + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: '17,900,901' + tuples: + - - 17 + - 17 + - - 900 + - 901 + raw_data: "* ESEARCH (TAG \"A301\") UID ALL 17,900,901\r\n" + + rfc9051_6.4.4.4_ESEARCH_example_2: + :response: "* ESEARCH (TAG \"P283\") ALL 882,1102,3003,3005:3006\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: P283 + uid: false + data: + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: 882,1102,3003,3005:3006 + tuples: + - - 882 + - 882 + - - 1102 + - 1102 + - - 3003 + - 3003 + - - 3005 + - 3006 + raw_data: "* ESEARCH (TAG \"P283\") ALL 882,1102,3003,3005:3006\r\n" + + rfc9051_6.4.4.4_ESEARCH_example_3: + :response: "* ESEARCH (TAG \"G283\") ALL 3:15,27,29:103\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: G283 + uid: false + data: + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: 3:15,27,29:103 + tuples: + - - 3 + - 15 + - - 27 + - 27 + - - 29 + - 103 + raw_data: "* ESEARCH (TAG \"G283\") ALL 3:15,27,29:103\r\n" + + rfc9051_6.4.4.4_ESEARCH_example_4: + :response: "* ESEARCH (TAG \"C283\") ALL 2,10:15,21\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: C283 + uid: false + data: + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: 2,10:15,21 + tuples: + - - 2 + - 2 + - - 10 + - 15 + - - 21 + - 21 + raw_data: "* ESEARCH (TAG \"C283\") ALL 2,10:15,21\r\n" + + rfc9051_6.4.4.4_ESEARCH_example_5: + :response: "* ESEARCH (TAG \"C284\") MIN 2\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: C284 + uid: false + data: + - - MIN + - 2 + raw_data: "* ESEARCH (TAG \"C284\") MIN 2\r\n" + + rfc9051_6.4.4.4_ESEARCH_example_6: + :response: "* ESEARCH (TAG \"C285\") MIN 2 MAX 21\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: C285 + uid: false + data: + - - MIN + - 2 + - - MAX + - 21 + raw_data: "* ESEARCH (TAG \"C285\") MIN 2 MAX 21\r\n" + + rfc9051_6.4.4.4_ESEARCH_example_7: + :response: "* ESEARCH (TAG \"C286\") MIN 2 MAX 21 COUNT 8\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: C286 + uid: false + data: + - - MIN + - 2 + - - MAX + - 21 + - - COUNT + - 8 + raw_data: "* ESEARCH (TAG \"C286\") MIN 2 MAX 21 COUNT 8\r\n" + + rfc9051_6.4.4.4_ESEARCH_example_8: + :response: "* ESEARCH (TAG \"C286\") MIN 2 ALL 2,10:15,21\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: C286 + uid: false + data: + - - MIN + - 2 + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: 2,10:15,21 + tuples: + - - 2 + - 2 + - - 10 + - 15 + - - 21 + - 21 + raw_data: "* ESEARCH (TAG \"C286\") MIN 2 ALL 2,10:15,21\r\n" + + rfc9051_7.1_ESEARCH_example_1: + :response: "* ESEARCH (TAG \"h\") ALL 1:3,5,8,13,21,42\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: h + uid: false + data: + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: 1:3,5,8,13,21,42 + tuples: + - - 1 + - 3 + - - 5 + - 5 + - - 8 + - 8 + - - 13 + - 13 + - - 21 + - 21 + - - 42 + - 42 + raw_data: "* ESEARCH (TAG \"h\") ALL 1:3,5,8,13,21,42\r\n" + + rfc9051_7.3.4_ESEARCH_example_1: + :response: "* ESEARCH UID COUNT 17 ALL 4:18,21,28\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: + uid: true + data: + - - COUNT + - 17 + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: 4:18,21,28 + tuples: + - - 4 + - 18 + - - 21 + - 21 + - - 28 + - 28 + raw_data: "* ESEARCH UID COUNT 17 ALL 4:18,21,28\r\n" + + rfc9051_7.3.4_ESEARCH_example_2: + :response: "* ESEARCH (TAG \"a567\") UID COUNT 17 ALL 4:18,21,28\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: a567 + uid: true + data: + - - COUNT + - 17 + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: 4:18,21,28 + tuples: + - - 4 + - 18 + - - 21 + - 21 + - - 28 + - 28 + raw_data: "* ESEARCH (TAG \"a567\") UID COUNT 17 ALL 4:18,21,28\r\n" + + rfc9051_7.3.4_ESEARCH_example_3: + :response: "* ESEARCH COUNT 18 ALL 1:17,21\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: + uid: false + data: + - - COUNT + - 18 + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: 1:17,21 + tuples: + - - 1 + - 17 + - - 21 + - 21 + raw_data: "* ESEARCH COUNT 18 ALL 1:17,21\r\n" diff --git a/test/net/imap/fixtures/response_parser/rfc5267_context_updates.yml b/test/net/imap/fixtures/response_parser/rfc5267_context_updates.yml new file mode 100644 index 00000000..152fd754 --- /dev/null +++ b/test/net/imap/fixtures/response_parser/rfc5267_context_updates.yml @@ -0,0 +1,114 @@ +--- +:tests: + + "RFC5267 4.3.3. ADDTO Return Data Item example 1": + :response: "* ESEARCH (TAG \"B01\") UID ADDTO (0 32768:32769)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: B01 + uid: true + data: + - - ADDTO + - - !ruby/object:Net::IMAP::ESearchResult::AddToContext + position: 0 + set: !ruby/object:Net::IMAP::SequenceSet + string: 32768:32769 + tuples: + - - 32768 + - 32769 + raw_data: "* ESEARCH (TAG \"B01\") UID ADDTO (0 32768:32769)\r\n" + + "RFC5267 4.3.3. ADDTO Return Data Item example 2": + :response: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2733 1 2732 1 2731)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: C01 + uid: true + data: + - - ADDTO + - - !ruby/object:Net::IMAP::ESearchResult::AddToContext + position: 1 + set: !ruby/object:Net::IMAP::SequenceSet + string: '2733' + tuples: + - - 2733 + - 2733 + - !ruby/object:Net::IMAP::ESearchResult::AddToContext + position: 1 + set: !ruby/object:Net::IMAP::SequenceSet + string: '2732' + tuples: + - - 2732 + - 2732 + - !ruby/object:Net::IMAP::ESearchResult::AddToContext + position: 1 + set: !ruby/object:Net::IMAP::SequenceSet + string: '2731' + tuples: + - - 2731 + - 2731 + raw_data: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2733 1 2732 1 2731)\r\n" + + "RFC5267 4.3.3. ADDTO Return Data Item example 3": + :response: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2733) ADDTO (1 2731:2732)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: C01 + uid: true + data: + - - ADDTO + - - !ruby/object:Net::IMAP::ESearchResult::AddToContext + position: 1 + set: !ruby/object:Net::IMAP::SequenceSet + string: '2733' + tuples: + - - 2733 + - 2733 + - - ADDTO + - - !ruby/object:Net::IMAP::ESearchResult::AddToContext + position: 1 + set: !ruby/object:Net::IMAP::SequenceSet + string: 2731:2732 + tuples: + - - 2731 + - 2732 + raw_data: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2733) ADDTO (1 2731:2732)\r\n" + + "RFC5267 4.3.3. ADDTO Return Data Item example 4": + :response: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2731:2733)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: C01 + uid: true + data: + - - ADDTO + - - !ruby/object:Net::IMAP::ESearchResult::AddToContext + position: 1 + set: !ruby/object:Net::IMAP::SequenceSet + string: 2731:2733 + tuples: + - - 2731 + - 2733 + raw_data: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2731:2733)\r\n" + + "RFC5267 4.3.4. REMOVEFROM Return Data Item": + :response: "* ESEARCH (TAG \"B01\") UID REMOVEFROM (0 32768)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: B01 + uid: true + data: + - - REMOVEFROM + - - !ruby/object:Net::IMAP::ESearchResult::RemoveFromContext + position: 0 + set: !ruby/object:Net::IMAP::SequenceSet + string: '32768' + tuples: + - - 32768 + - 32768 + raw_data: "* ESEARCH (TAG \"B01\") UID REMOVEFROM (0 32768)\r\n" diff --git a/test/net/imap/fixtures/response_parser/rfc6203_fuzzy_search.yml b/test/net/imap/fixtures/response_parser/rfc6203_fuzzy_search.yml new file mode 100644 index 00000000..fe3960b7 --- /dev/null +++ b/test/net/imap/fixtures/response_parser/rfc6203_fuzzy_search.yml @@ -0,0 +1,54 @@ +--- +:tests: + + "RFC6203 SEARCH=FUZZY 4. Relevancy scores for search results": + :response: "* ESEARCH (TAG \"B1\") ALL 1,5,10 RELEVANCY (4 99 42)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: B1 + uid: false + data: + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: '1,5,10' + tuples: + - - 1 + - 1 + - - 5 + - 5 + - - 10 + - 10 + - - RELEVANCY + - - 4 + - 99 + - 42 + raw_data: "* ESEARCH (TAG \"B1\") ALL 1,5,10 RELEVANCY (4 99 42)\r\n" + + "RFC6203 SEARCH=FUZZY 6. Relevency score with ESORT results": + comment: | + This doesn't require anything new for the parser beyond the prior example. + But it important to note that the SequenceSet has *not* been normalized + (sorted and coalesced). + :response: "* ESEARCH (TAG \"C2\") ALL 5,10,1 RELEVANCY (99 42 4)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: C2 + uid: false + data: + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: '5,10,1' + tuples: + - - 1 + - 1 + - - 5 + - 5 + - - 10 + - 10 + - - RELEVANCY + - - 99 + - 42 + - 4 + raw_data: "* ESEARCH (TAG \"C2\") ALL 5,10,1 RELEVANCY (99 42 4)\r\n" diff --git a/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml b/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml index dc8fadcf..b571c990 100644 --- a/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml +++ b/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml @@ -98,6 +98,46 @@ raw_data: "* STATUS blurdybloop (MESSAGES 231 UIDNEXT 44292 HIGHESTMODSEQ 7011231777)\r\n" + "RFC7162 CONDSTORE 3.1.10. Example 19 (Interaction with ESEARCH)": + :response: "* ESEARCH (TAG \"a\") ALL 1:3,5 MODSEQ 1236\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: a + uid: false + data: + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: 1:3,5 + tuples: + - - 1 + - 3 + - - 5 + - 5 + - - MODSEQ + - 1236 + raw_data: "* ESEARCH (TAG \"a\") ALL 1:3,5 MODSEQ 1236\r\n" + + "RFC7162 CONDSTORE 3.1.10. Example 20 (Interaction with ESEARCH)": + :response: "* ESEARCH (TAG \"a\") ALL 5,3,2,1 MODSEQ 1236\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: a + uid: false + data: + - - ALL + - !ruby/object:Net::IMAP::SequenceSet + string: '5,3,2,1' + tuples: + - - 1 + - 3 + - - 5 + - 5 + - - MODSEQ + - 1236 + raw_data: "* ESEARCH (TAG \"a\") ALL 5,3,2,1 MODSEQ 1236\r\n" + "RFC7162 QRESYNC 3.2.5.1. Modification Sequence and UID Parameters": :response: "* VANISHED (EARLIER) 41,43:116,118,120:211,214:540\r\n" :expected: !ruby/struct:Net::IMAP::UntaggedResponse diff --git a/test/net/imap/fixtures/response_parser/rfc9394_partial.yml b/test/net/imap/fixtures/response_parser/rfc9394_partial.yml new file mode 100644 index 00000000..233a9096 --- /dev/null +++ b/test/net/imap/fixtures/response_parser/rfc9394_partial.yml @@ -0,0 +1,66 @@ +--- +:tests: + + "RFC9394 PARTIAL 3.1. example 1": + comment: | + Neither RFC9394 nor RFC5267 contain any examples of a normal unelided + sequence-set result. I've edited it to include a sequence-set here. + :response: "* ESEARCH (TAG \"A01\") UID PARTIAL (-1:-100 200:250,252:300)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: A01 + uid: true + data: + - - PARTIAL + - !ruby/object:Net::IMAP::ESearchResult::PartialResult + range: !ruby/range + begin: -100 + end: -1 + excl: false + results: !ruby/object:Net::IMAP::SequenceSet + string: 200:250,252:300 + tuples: + - - 200 + - 250 + - - 252 + - 300 + raw_data: "* ESEARCH (TAG \"A01\") UID PARTIAL (-1:-100 200:250,252:300)\r\n" + + "RFC9394 PARTIAL 3.1. example 2": + :response: "* ESEARCH (TAG \"A02\") UID PARTIAL (23500:24000 55500:56000)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: A02 + uid: true + data: + - - PARTIAL + - !ruby/object:Net::IMAP::ESearchResult::PartialResult + range: !ruby/range + begin: 23500 + end: 24000 + excl: false + results: !ruby/object:Net::IMAP::SequenceSet + string: 55500:56000 + tuples: + - - 55500 + - 56000 + raw_data: "* ESEARCH (TAG \"A02\") UID PARTIAL (23500:24000 55500:56000)\r\n" + + "RFC9394 PARTIAL 3.1. example 3": + :response: "* ESEARCH (TAG \"A04\") UID PARTIAL (24000:24500 NIL)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: ESEARCH + data: !ruby/object:Net::IMAP::ESearchResult + tag: A04 + uid: true + data: + - - PARTIAL + - !ruby/object:Net::IMAP::ESearchResult::PartialResult + range: !ruby/range + begin: 24000 + end: 24500 + excl: false + results: + raw_data: "* ESEARCH (TAG \"A04\") UID PARTIAL (24000:24500 NIL)\r\n" diff --git a/test/net/imap/test_esearch_result.rb b/test/net/imap/test_esearch_result.rb new file mode 100644 index 00000000..c40d763d --- /dev/null +++ b/test/net/imap/test_esearch_result.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" + +class ESearchResultTest < Test::Unit::TestCase + ESearchResult = Net::IMAP::ESearchResult + SequenceSet = Net::IMAP::SequenceSet + + test "#to_a" do + esearch = ESearchResult.new(nil, true, []) + assert_equal [], esearch.to_a + esearch = ESearchResult.new(nil, false, []) + assert_equal [], esearch.to_a + esearch = ESearchResult.new(nil, false, [["ALL", SequenceSet["1,5:8"]]]) + assert_equal [1, 5, 6, 7, 8], esearch.to_a + end + + test "#tag" do + esearch = ESearchResult.new("A0001", false, [["count", 0]]) + assert_equal "A0001", esearch.tag + esearch = ESearchResult.new("A0002", false, [["count", 0]]) + assert_equal "A0002", esearch.tag + end + + test "#uid" do + esearch = ESearchResult.new("A0003", true, [["count", 0]]) + assert_equal true, esearch.uid + assert_equal true, esearch.uid? + esearch = ESearchResult.new("A0004", false, [["count", 0]]) + assert_equal false, esearch.uid + assert_equal false, esearch.uid? + end + + # "simple" result da¨a return exactly what is in the data.assoc + test "simple RFC4731 and RFC9051 return data accessors" do + seqset = SequenceSet["5:9,101:105,151:152"] + esearch = ESearchResult.new( + "A0005", + true, + [ + ["MIN", 5], + ["MAX", 152], + ["COUNT", 12], + ["ALL", seqset], + ["MODSEQ", 12345], + ] + ) + assert_equal 5, esearch.min + assert_equal 152, esearch.max + assert_equal 12, esearch.count + assert_equal seqset, esearch.all + assert_equal 12345, esearch.modseq + end + + test "#partial returns PARTIAL value (RFC9394: PARTIAL)" do + result = Net::IMAP::ResponseParser.new.parse( + "* ESEARCH (TAG \"A0006\") UID PARTIAL (-1:-100 200:250,252:300)\r\n" + ).data + assert_equal(ESearchResult, result.class) + assert_equal( + ESearchResult::PartialResult.new( + -100..-1, SequenceSet[200..250, 252..300] + ), + result.partial + ) + end + + test "#relevancy returns RELEVANCY value (RFC6203: SEARCH=FUZZY)" do + esearch = ESearchResult.new("A0007", true, [["RELEVANCY", 1]]) + assert_equal 1, esearch.relevancy + esearch = ESearchResult.new("A0008", true, [["RELEVANCY", 99]]) + assert_equal 99, esearch.relevancy + end + + test "#addto returns ADDTO values (RFC5267: CONTEXT=SEARCH, CONTEXT=SORT)" do + parser = Net::IMAP::ResponseParser.new + expected = [ + ESearchResult::AddToContext.new(1, SequenceSet[2733]), + ESearchResult::AddToContext.new(1, SequenceSet[2732]), + ESearchResult::AddToContext.new(1, SequenceSet[2731]), + ] + assert_equal expected, parser.parse( + "* ESEARCH (TAG \"C01\") UID ADDTO (1 2733 1 2732 1 2731)\r\n" + ).data.addto + assert_equal expected, parser.parse( + "* ESEARCH (TAG \"C01\") UID ADDTO (1 2733) ADDTO (1 2732) ADDTO (1 2731)\r\n" + ).data.addto + end + + test "#removefrom returns REMOVEFROM values (RFC5267: CONTEXT=SEARCH...)" do + parser = Net::IMAP::ResponseParser.new + expected = [ + ESearchResult::RemoveFromContext.new(1, SequenceSet[2733]), + ESearchResult::RemoveFromContext.new(1, SequenceSet[2732]), + ESearchResult::RemoveFromContext.new(1, SequenceSet[2731]), + ] + assert_equal expected, parser.parse( + "* ESEARCH (TAG \"C01\") UID REMOVEFROM (1 2733 1 2732 1 2731)\r\n" + ).data.removefrom + assert_equal expected, parser.parse( + "* ESEARCH (TAG \"C01\") UID REMOVEFROM (1 2733) REMOVEFROM (1 2732) REMOVEFROM (1 2731)\r\n" + ).data.removefrom + end + + test "#updates returns both ADDTO and REMOVEFROM values (RFC5267: CONTEXT)" do + parser = Net::IMAP::ResponseParser.new + expected = [ + ESearchResult::AddToContext.new(1, SequenceSet[2733]), + ESearchResult::RemoveFromContext.new(1, SequenceSet[2732]), + ESearchResult::AddToContext.new(1, SequenceSet[2731]), + ] + assert_equal expected, parser.parse( + "* ESEARCH (TAG \"C01\") UID ADDTO (1 2733) REMOVEFROM (1 2732) ADDTO (1 2731)\r\n" + ).data.updates + end + +end diff --git a/test/net/imap/test_imap_response_parser.rb b/test/net/imap/test_imap_response_parser.rb index a4d70f92..365ff7e3 100644 --- a/test/net/imap/test_imap_response_parser.rb +++ b/test/net/imap/test_imap_response_parser.rb @@ -55,6 +55,9 @@ def teardown # §7.3.3: STATUS response generate_tests_from fixture_file: "status_responses.yml" + # §7.3.4: ESEARCH response + generate_tests_from fixture_file: "esearch_responses.yml" + # RFC3501 §7.2.5: SEARCH response (obsolete in IMAP4rev2): generate_tests_from fixture_file: "search_responses.yml" @@ -91,7 +94,13 @@ def teardown # RFC 5256: THREAD response generate_tests_from fixture_file: "thread_responses.yml" - # RFC 7164: CONDSTORE and QRESYNC responses + # RFC 5267: ADDTO and REMOVEFROM search return options + generate_tests_from fixture_file: "rfc5267_context_updates.yml" + + # RFC6203: SEARCH=FUZZY extension (RELEVANCY search return option) + generate_tests_from fixture_file: "rfc6203_fuzzy_search.yml" + + # RFC 7162: CONDSTORE and QRESYNC responses generate_tests_from fixture_file: "rfc7162_condstore_qresync_responses.yml" # RFC 8474: OBJECTID responses @@ -100,6 +109,9 @@ def teardown # RFC 9208: QUOTA extension generate_tests_from fixture_file: "rfc9208_quota_responses.yml" + # RFC 9394: PARTIAL extension + generate_tests_from fixture_file: "rfc9394_partial.yml" + ############################################################################ # Workarounds or unspecified extensions: generate_tests_from fixture_file: "quirky_behaviors.yml"