Skip to content

Commit 5cbf05f

Browse files
committed
✨ Parsing ESEARCH, with examples from RFC9051
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.
1 parent 832812a commit 5cbf05f

File tree

8 files changed

+676
-2
lines changed

8 files changed

+676
-2
lines changed

lib/net/imap/esearch_result.rb

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP
5+
# An "extended search" response (+ESEARCH+). ESearchResult should be
6+
# returned (instead of SearchResult) by IMAP#search, IMAP#uid_search,
7+
# IMAP#sort, and IMAP#uid_sort under any of the following conditions:
8+
#
9+
# * Return options were specified for IMAP#search or IMAP#uid_search.
10+
# The server must support a search extension which allows
11+
# RFC4466[https://www.rfc-editor.org/rfc/rfc4466.html] +return+ options,
12+
# such as +ESEARCH+, +PARTIAL+, or +IMAP4rev2+.
13+
# * Return options were specified for IMAP#sort or IMAP#uid_sort.
14+
# The server must support the +ESORT+ extension
15+
# {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html#section-3].
16+
#
17+
# *NOTE:* IMAP#search and IMAP#uid_search do not support +ESORT+ yet.
18+
# * The server supports +IMAP4rev2+ but _not_ +IMAP4rev1+, or +IMAP4rev2+
19+
# has been enabled. +IMAP4rev2+ requires +ESEARCH+ results.
20+
#
21+
# Note that some servers may claim to support a search extension which
22+
# requires an +ESEARCH+ result, such as +PARTIAL+, but still only return a
23+
# +SEARCH+ result when +return+ options are specified.
24+
#
25+
# Some search extensions may result in the server sending ESearchResult
26+
# responses after the initiating command has completed. Use
27+
# IMAP#add_response_handler to handle these responses.
28+
class ESearchResult < Data.define(:tag, :uid, :data)
29+
def initialize(tag: nil, uid: nil, data: nil)
30+
tag => String | nil; tag = -tag if tag
31+
uid => true | false | nil; uid = !!uid
32+
data => Array | nil; data ||= []; data.freeze
33+
super
34+
end
35+
36+
# :call-seq: to_a -> Array of integers
37+
#
38+
# When #all contains a SequenceSet of message sequence
39+
# numbers or UIDs, +to_a+ returns that set as an array of integers.
40+
#
41+
# When #all is +nil+, either because the server
42+
# returned no results or because +ALL+ was not included in
43+
# the IMAP#search +RETURN+ options, #to_a returns an empty array.
44+
#
45+
# Note that SearchResult also implements +to_a+, so it can be used without
46+
# checking if the server returned +SEARCH+ or +ESEARCH+ data.
47+
def to_a; all&.numbers || [] end
48+
49+
##
50+
# attr_reader: tag
51+
#
52+
# The tag string for the command that caused this response to be returned.
53+
#
54+
# When +nil+, this response was not caused by a particular command.
55+
56+
##
57+
# attr_reader: uid
58+
#
59+
# Indicates whether #data in this response refers to UIDs (when +true+) or
60+
# to message sequence numbers (when +false+).
61+
62+
##
63+
alias uid? uid
64+
65+
##
66+
# attr_reader: data
67+
#
68+
# Search return data, as an array of <tt>[name, value]</tt> pairs. Most
69+
# return data corresponds to a search +return+ option with the same name.
70+
#
71+
# Note that some return data names may be used more than once per result.
72+
#
73+
# This data can be more simply retrieved by #min, #max, #all, #count,
74+
# #modseq, and other methods.
75+
76+
# :call-seq: min -> integer or nil
77+
#
78+
# The lowest message number/UID that satisfies the SEARCH criteria.
79+
#
80+
# Returns +nil+ when the associated search command has no results, or when
81+
# the +MIN+ return option wasn't specified.
82+
#
83+
# Requires +ESEARCH+ {[RFC4731]}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1] or
84+
# +IMAP4rev2+ {[RFC9051]}[https://www.rfc-editor.org/rfc/rfc9051.html#section-7.3.4].
85+
def min; data.assoc("MIN")&.last end
86+
87+
# :call-seq: max -> integer or nil
88+
#
89+
# The highest message number/UID that satisfies the SEARCH criteria.
90+
#
91+
# Returns +nil+ when the associated search command has no results, or when
92+
# the +MAX+ return option wasn't specified.
93+
#
94+
# Requires +ESEARCH+ {[RFC4731]}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1] or
95+
# +IMAP4rev2+ {[RFC9051]}[https://www.rfc-editor.org/rfc/rfc9051.html#section-7.3.4].
96+
def max; data.assoc("MAX")&.last end
97+
98+
# :call-seq: all -> sequence set or nil
99+
#
100+
# A SequenceSet containing all message sequence numbers or UIDs that
101+
# satisfy the SEARCH criteria.
102+
#
103+
# Returns +nil+ when the associated search command has no results, or when
104+
# the +ALL+ return option was not specified but other return options were.
105+
#
106+
# Requires +ESEARCH+ {[RFC4731]}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1] or
107+
# +IMAP4rev2+ {[RFC9051]}[https://www.rfc-editor.org/rfc/rfc9051.html#section-7.3.4].
108+
#
109+
# See also: #to_a
110+
def all; data.assoc("ALL")&.last end
111+
112+
# :call-seq: count -> integer or nil
113+
#
114+
# Returns the number of messages that satisfy the SEARCH criteria.
115+
#
116+
# Returns +nil+ when the associated search command has no results, or when
117+
# the +COUNT+ return option wasn't specified.
118+
#
119+
# Requires +ESEARCH+ {[RFC4731]}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.1] or
120+
# +IMAP4rev2+ {[RFC9051]}[https://www.rfc-editor.org/rfc/rfc9051.html#section-7.3.4].
121+
def count; data.assoc("COUNT")&.last end
122+
123+
# :call-seq: modseq -> integer or nil
124+
#
125+
# The highest +mod-sequence+ of all messages being returned.
126+
#
127+
# Returns +nil+ when the associated search command has no results, or when
128+
# the +MODSEQ+ search criterion wasn't specified.
129+
#
130+
# Note that there is no search +return+ option for +MODSEQ+. It will be
131+
# returned whenever the +CONDSTORE+ extension has been enabled. Using the
132+
# +MODSEQ+ search criteria will implicitly enable +CONDSTORE+.
133+
#
134+
# Requires +CONDSTORE+ {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html]
135+
# and +ESEARCH+ {[RFC4731]}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.2].
136+
def modseq; data.assoc("MODSEQ")&.last end
137+
138+
end
139+
end
140+
end

lib/net/imap/response_data.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
module Net
44
class IMAP < Protocol
5+
autoload :ESearchResult, "#{__dir__}/esearch_result"
56
autoload :FetchData, "#{__dir__}/fetch_data"
67
autoload :SearchResult, "#{__dir__}/search_result"
78
autoload :SequenceSet, "#{__dir__}/sequence_set"

lib/net/imap/response_parser.rb

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,7 +769,6 @@ def remaining_unparsed
769769
def response_data__ignored; response_data__unhandled(IgnoredResponse) end
770770
alias response_data__noop response_data__ignored
771771

772-
alias esearch_response response_data__unhandled
773772
alias expunged_resp response_data__unhandled
774773
alias uidfetch_resp response_data__unhandled
775774
alias listrights_data response_data__unhandled
@@ -1468,6 +1467,78 @@ def mailbox_data__search
14681467
end
14691468
alias sort_data mailbox_data__search
14701469

1470+
# esearch-response = "ESEARCH" [search-correlator] [SP "UID"]
1471+
# *(SP search-return-data)
1472+
# ;; Note that SEARCH and ESEARCH responses
1473+
# ;; SHOULD be mutually exclusive,
1474+
# ;; i.e., only one of the response types
1475+
# ;; should be
1476+
# ;; returned as a result of a command.
1477+
# esearch-response = "ESEARCH" [search-correlator] [SP "UID"]
1478+
# *(SP search-return-data)
1479+
# ; ESEARCH response replaces SEARCH response
1480+
# ; from IMAP4rev1.
1481+
# search-correlator = SP "(" "TAG" SP tag-string ")"
1482+
def esearch_response
1483+
name = label("ESEARCH")
1484+
tag = search_correlator if peek_str?(" (")
1485+
uid = peek_re?(/\G UID\b/i) && (SP!; label("UID"); true)
1486+
data = []
1487+
data << search_return_data while SP?
1488+
esearch = ESearchResult.new(tag, uid, data)
1489+
UntaggedResponse.new(name, esearch, @str)
1490+
end
1491+
1492+
# From RFC4731 (ESEARCH):
1493+
# search-return-data = "MIN" SP nz-number /
1494+
# "MAX" SP nz-number /
1495+
# "ALL" SP sequence-set /
1496+
# "COUNT" SP number /
1497+
# search-ret-data-ext
1498+
# ; All return data items conform to
1499+
# ; search-ret-data-ext syntax.
1500+
# search-ret-data-ext = search-modifier-name SP search-return-value
1501+
# search-modifier-name = tagged-ext-label
1502+
# search-return-value = tagged-ext-val
1503+
#
1504+
# From RFC4731 (ESEARCH):
1505+
# search-return-data =/ "MODSEQ" SP mod-sequence-value
1506+
#
1507+
def search_return_data
1508+
label = search_modifier_name; SP!
1509+
value =
1510+
case label
1511+
when "MIN" then nz_number
1512+
when "MAX" then nz_number
1513+
when "ALL" then sequence_set
1514+
when "COUNT" then number
1515+
when "MODSEQ" then mod_sequence_value # RFC7162: CONDSTORE
1516+
else search_return_value
1517+
end
1518+
[label, value]
1519+
end
1520+
1521+
# search-modifier-name = tagged-ext-label
1522+
alias search_modifier_name tagged_ext_label
1523+
1524+
# search-return-value = tagged-ext-val
1525+
# ; Data for the returned search option.
1526+
# ; A single "nz-number"/"number"/"number64" value
1527+
# ; can be returned as an atom (i.e., without
1528+
# ; quoting). A sequence-set can be returned
1529+
# ; as an atom as well.
1530+
def search_return_value; ExtensionData.new(tagged_ext_val) end
1531+
1532+
# search-correlator = SP "(" "TAG" SP tag-string ")"
1533+
def search_correlator
1534+
SP!; lpar; label("TAG"); SP!; tag = tag_string; rpar
1535+
tag
1536+
end
1537+
1538+
# tag-string = astring
1539+
# ; <tag> represented as <astring>
1540+
alias tag_string astring
1541+
14711542
# RFC5256: THREAD
14721543
# thread-data = "THREAD" [SP 1*thread-list]
14731544
def thread_data

lib/net/imap/response_parser/parser_utils.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,11 @@ def peek_str?(str)
185185
@str[@pos, str.length] == str
186186
end
187187

188+
def peek_re?(re)
189+
assert_no_lookahead if Net::IMAP.debug
190+
re.match?(@str, @pos)
191+
end
192+
188193
def peek_re(re)
189194
assert_no_lookahead if config.debug?
190195
re.match(@str, @pos)

0 commit comments

Comments
 (0)