Skip to content

Commit

Permalink
✨ Parsing ESEARCH, with examples from RFC9051
Browse files Browse the repository at this point in the history
Parses +ESEARCH+ into ESearchResult, with support for generic RFC4466
syntax and RFC4731 `ESEARCH` return data.

For compatibility, `ESearchResult#to_a` returns an array of integers
(sequence numbers or UIDs) whenever any `ALL` result is available.
  • Loading branch information
nevans committed Dec 15, 2024
1 parent 832812a commit 5cbf05f
Show file tree
Hide file tree
Showing 8 changed files with 676 additions and 2 deletions.
140 changes: 140 additions & 0 deletions lib/net/imap/esearch_result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# frozen_string_literal: true

module Net
class IMAP
# An "extended search" response (+ESEARCH+). ESearchResult should be
# returned (instead of SearchResult) by IMAP#search, IMAP#uid_search,
# IMAP#sort, and IMAP#uid_sort under any of the following conditions:
#
# * Return options were specified for IMAP#search or IMAP#uid_search.
# The server must support a search extension which allows
# RFC4466[https://www.rfc-editor.org/rfc/rfc4466.html] +return+ options,
# such as +ESEARCH+, +PARTIAL+, or +IMAP4rev2+.
# * Return options were specified for IMAP#sort or IMAP#uid_sort.
# The server must support the +ESORT+ extension
# {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html#section-3].
#
# *NOTE:* IMAP#search and IMAP#uid_search do not support +ESORT+ yet.
# * The server supports +IMAP4rev2+ but _not_ +IMAP4rev1+, or +IMAP4rev2+
# has been enabled. +IMAP4rev2+ requires +ESEARCH+ results.
#
# Note that some servers may claim to support a search extension which
# requires an +ESEARCH+ result, such as +PARTIAL+, but still only return a
# +SEARCH+ result when +return+ options are specified.
#
# Some search extensions may result in the server sending ESearchResult
# responses after the initiating command has completed. Use
# IMAP#add_response_handler to handle these responses.
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 #all contains a SequenceSet of message sequence
# numbers or UIDs, +to_a+ returns that set as an array of integers.
#
# When #all is +nil+, either because the server
# returned no results or because +ALL+ was not included in
# the IMAP#search +RETURN+ options, #to_a returns an empty array.
#
# Note that SearchResult also implements +to_a+, so it can be used without
# checking if the server returned +SEARCH+ or +ESEARCH+ data.
def to_a; all&.numbers || [] end

##
# attr_reader: tag
#
# The tag string for the command that caused this response to be returned.
#
# When +nil+, this response was not caused by a particular command.

##
# attr_reader: uid
#
# Indicates whether #data in this response refers to UIDs (when +true+) or
# to message sequence numbers (when +false+).

##
alias uid? uid

##
# attr_reader: data
#
# Search return data, as an array of <tt>[name, value]</tt> pairs. Most
# return data corresponds to a search +return+ option with the same name.
#
# Note that some return data names may be used more than once per result.
#
# This data can be more simply retrieved by #min, #max, #all, #count,
# #modseq, and other methods.

# :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.
#
# Requires +ESEARCH+ {[RFC4731]}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1] or
# +IMAP4rev2+ {[RFC9051]}[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.
#
# Requires +ESEARCH+ {[RFC4731]}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1] or
# +IMAP4rev2+ {[RFC9051]}[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 sequence numbers or UIDs that
# satisfy the SEARCH criteria.
#
# Returns +nil+ when the associated search command has no results, or when
# the +ALL+ return option was not specified but other return options were.
#
# Requires +ESEARCH+ {[RFC4731]}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1] or
# +IMAP4rev2+ {[RFC9051]}[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, or when
# the +COUNT+ return option wasn't specified.
#
# Requires +ESEARCH+ {[RFC4731]}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1] or
# +IMAP4rev2+ {[RFC9051]}[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 being returned.
#
# Returns +nil+ when the associated search command has no results, or when
# the +MODSEQ+ search criterion wasn't specified.
#
# Note that there is no search +return+ option for +MODSEQ+. It will be
# returned whenever the +CONDSTORE+ extension has been enabled. Using the
# +MODSEQ+ search criteria will implicitly enable +CONDSTORE+.
#
# Requires +CONDSTORE+ {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html]
# and +ESEARCH+ {[RFC4731]}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.2].
def modseq; data.assoc("MODSEQ")&.last end

end
end
end
1 change: 1 addition & 0 deletions lib/net/imap/response_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
73 changes: 72 additions & 1 deletion lib/net/imap/response_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,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
Expand Down Expand Up @@ -1468,6 +1467,78 @@ 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
#
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
else search_return_value
end
[label, value]
end

# 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

# search-correlator = SP "(" "TAG" SP tag-string ")"
def search_correlator
SP!; lpar; label("TAG"); SP!; tag = tag_string; rpar
tag
end

# tag-string = astring
# ; <tag> represented as <astring>
alias tag_string astring

# RFC5256: THREAD
# thread-data = "THREAD" [SP 1*thread-list]
def thread_data
Expand Down
5 changes: 5 additions & 0 deletions lib/net/imap/response_parser/parser_utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 5cbf05f

Please sign in to comment.