Skip to content

Commit 68286f8

Browse files
committed
✨ Add support for PARTIAL esearch result
For convenience and compatibility, `ESearchResult#to_a` returns an array of integers (sequence numbers or UIDs) whenever either `ALL` or `PARTIAL` return data is available.
1 parent fe96a75 commit 68286f8

File tree

7 files changed

+255
-24
lines changed

7 files changed

+255
-24
lines changed

lib/net/imap.rb

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,11 @@ module Net
534534
# See FetchData#emailid and FetchData#emailid.
535535
# - Updates #status with support for the +MAILBOXID+ status attribute.
536536
#
537+
# ==== RFC9394: +PARTIAL+
538+
# - Updates #search, #uid_search with the +PARTIAL+ return option which adds
539+
# ESearchResult#partial return data.
540+
# - TODO: Updates #uid_fetch with the +partial+ modifier.
541+
#
537542
# == References
538543
#
539544
# [{IMAP4rev1}[https://www.rfc-editor.org/rfc/rfc3501.html]]::
@@ -701,6 +706,11 @@ module Net
701706
# Gondwana, B., Ed., "IMAP Extension for Object Identifiers",
702707
# RFC 8474, DOI 10.17487/RFC8474, September 2018,
703708
# <https://www.rfc-editor.org/info/rfc8474>.
709+
# [PARTIAL[https://www.rfc-editor.org/info/rfc9394]]::
710+
# Melnikov, A., Achuthan, A., Nagulakonda, V., and L. Alves,
711+
# "IMAP PARTIAL Extension for Paged SEARCH and FETCH", RFC 9394,
712+
# DOI 10.17487/RFC9394, June 2023,
713+
# <https://www.rfc-editor.org/info/rfc9394>.
704714
#
705715
# === IANA registries
706716
# * {IMAP Capabilities}[http://www.iana.org/assignments/imap4-capabilities]
@@ -1971,8 +1981,9 @@ def uid_expunge(uid_set)
19711981
# the server to return an ESearchResult instead of a SearchResult, but some
19721982
# servers disobey this requirement. <em>Requires an extended search
19731983
# capability, such as +ESEARCH+ or +IMAP4rev2+.</em>
1974-
# See {"Argument translation"}[rdoc-ref:#search@Argument+translation]
1975-
# and {"Return options"}[rdoc-ref:#search@Return+options], below.
1984+
# See {"Argument translation"}[rdoc-ref:#search@Argument+translation] and
1985+
# {"Supported return options"}[rdoc-ref:#search@Supported+return+options],
1986+
# below.
19761987
#
19771988
# +charset+ is the name of the {registered character
19781989
# set}[https://www.iana.org/assignments/character-sets/character-sets.xhtml]
@@ -2082,33 +2093,58 @@ def uid_expunge(uid_set)
20822093
# <em>*WARNING:* This is vulnerable to injection attacks when external
20832094
# inputs are used.</em>
20842095
#
2085-
# ==== Return options
2096+
# ==== Supported return options
20862097
#
20872098
# For full definitions of the standard return options and return data, see
20882099
# the relevant RFCs.
20892100
#
2090-
# ===== +ESEARCH+ or +IMAP4rev2+
2091-
#
2092-
# The following return options require either +ESEARCH+ or +IMAP4rev2+.
2093-
# See [{RFC4731 §3.1}[https://rfc-editor.org/rfc/rfc4731#section-3.1]] or
2094-
# [{IMAP4rev2 §6.4.4}[https://www.rfc-editor.org/rfc/rfc9051.html#section-6.4.4]].
2095-
#
20962101
# [+ALL+]
20972102
# Returns ESearchResult#all with a SequenceSet of all matching sequence
20982103
# numbers or UIDs. This is the default, when return options are empty.
20992104
#
21002105
# For compatibility with SearchResult, ESearchResult#to_a returns an
21012106
# Array of message sequence numbers or UIDs.
2107+
#
2108+
# <em>Requires either the +ESEARCH+ or +IMAP4rev2+ capabability.</em>
2109+
# {[RFC4731]}[https://rfc-editor.org/rfc/rfc4731]
2110+
# {[RFC9051]}[https://rfc-editor.org/rfc/rfc9051]
2111+
#
21022112
# [+COUNT+]
21032113
# Returns ESearchResult#count with the number of matching messages.
2114+
#
2115+
# <em>Requires either the +ESEARCH+ or +IMAP4rev2+ capabability.</em>
2116+
# {[RFC4731]}[https://rfc-editor.org/rfc/rfc4731]
2117+
# {[RFC9051]}[https://rfc-editor.org/rfc/rfc9051]
2118+
#
21042119
# [+MAX+]
21052120
# Returns ESearchResult#max with the highest matching sequence number or
21062121
# UID.
2122+
#
2123+
# <em>Requires either the +ESEARCH+ or +IMAP4rev2+ capabability.</em>
2124+
# {[RFC4731]}[https://rfc-editor.org/rfc/rfc4731]
2125+
# {[RFC9051]}[https://rfc-editor.org/rfc/rfc9051]
2126+
#
21072127
# [+MIN+]
21082128
# Returns ESearchResult#min with the lowest matching sequence number or
21092129
# UID.
21102130
#
2111-
# ===== +CONDSTORE+
2131+
# <em>Requires either the +ESEARCH+ or +IMAP4rev2+ capabability.</em>
2132+
# {[RFC4731]}[https://rfc-editor.org/rfc/rfc4731]
2133+
# {[RFC9051]}[https://rfc-editor.org/rfc/rfc9051]
2134+
#
2135+
# [+PARTIAL+ _range_]
2136+
# Returns ESearchResult#partial with a SequenceSet of a subset of
2137+
# matching sequence numbers or UIDs, as selected by _range_. As with
2138+
# sequence numbers, the first result is +1+: <tt>1..500</tt> selects the
2139+
# first 500 search results (in mailbox order), <tt>501..1000</tt> the
2140+
# second 500, and so on. _range_ may also be negative: <tt>-500..-1</tt>
2141+
# selects the last 500 search results.
2142+
#
2143+
# <em>Requires either the <tt>CONTEXT=SEARCH</tt> or +PARTIAL+ capabability.</em>
2144+
# {[RFC5267]}[https://rfc-editor.org/rfc/rfc5267]
2145+
# {[RFC9394]}[https://rfc-editor.org/rfc/rfc9394]
2146+
#
2147+
# ===== +MODSEQ+ return data
21122148
#
21132149
# ESearchResult#modseq return data does not have a corresponding return
21142150
# option. Instead, it is returned if the +MODSEQ+ search key is used or
@@ -2120,8 +2156,8 @@ def uid_expunge(uid_set)
21202156
#
21212157
# {RFC4466 §2.6}[https://www.rfc-editor.org/rfc/rfc4466.html#section-2.6]
21222158
# defines standard syntax for search extensions. Net::IMAP allows sending
2123-
# unknown search return options and will parse unknown search extensions'
2124-
# return values into ExtensionData. Please note that this is an
2159+
# unsupported search return options and will parse unsupported search
2160+
# extensions' return values into ExtensionData. Please note that this is an
21252161
# intentionally _unstable_ API. Future releases may return different
21262162
# (incompatible) objects, <em>without deprecation or warning</em>.
21272163
#
@@ -2398,8 +2434,8 @@ def uid_search(...)
23982434
# {[RFC7162]}[https://tools.ietf.org/html/rfc7162] in order to use the
23992435
# +changedsince+ argument. Using +changedsince+ implicitly enables the
24002436
# +CONDSTORE+ extension.
2401-
def fetch(set, attr, mod = nil, changedsince: nil)
2402-
fetch_internal("FETCH", set, attr, mod, changedsince: changedsince)
2437+
def fetch(...)
2438+
fetch_internal("FETCH", ...)
24032439
end
24042440

24052441
# :call-seq:
@@ -2425,8 +2461,8 @@ def fetch(set, attr, mod = nil, changedsince: nil)
24252461
# ==== Capabilities
24262462
#
24272463
# Same as #fetch.
2428-
def uid_fetch(set, attr, mod = nil, changedsince: nil)
2429-
fetch_internal("UID FETCH", set, attr, mod, changedsince: changedsince)
2464+
def uid_fetch(...)
2465+
fetch_internal("UID FETCH", ...)
24302466
end
24312467

24322468
# :call-seq:
@@ -3347,15 +3383,32 @@ def convert_return_opts(unconverted)
33473383

33483384
def partial_range_last_or_seqset(range)
33493385
case [range.begin, range.end]
3350-
in [Integer => first, Integer => last] if first.negative? && last.negative?
3351-
# partial-range-last [RFC9394]
3352-
first <= last or raise DataFormatError, "empty range: %p" % [range]
3353-
"#{first}:#{last}"
3386+
in [Integer => first, Integer => last] if first&.negative? || last&.negative?
3387+
partial_range(range)
33543388
else
33553389
SequenceSet[range]
33563390
end
33573391
end
33583392

3393+
NZ_NUMBER_RANGE = 1..2**32-1
3394+
NEG_NZ_NUMBER_RANGE = -(2**32-1)..-1
3395+
private_constant :NZ_NUMBER_RANGE, :NEG_NZ_NUMBER_RANGE
3396+
3397+
def partial_range(range)
3398+
case range
3399+
in Range
3400+
minmax = range.minmax.map { Integer _1 }
3401+
range = minmax.join(":")
3402+
in ResponseParser::Patterns::PARTIAL_RANGE
3403+
minmax = range.split(":").minmax
3404+
end
3405+
if minmax.all?(NZ_NUMBER_RANGE) || minmax.all?(NEG_NZ_NUMBER_RANGE)
3406+
range
3407+
else
3408+
raise ArgumentError, "invalid partial-range"
3409+
end
3410+
end
3411+
33593412
def search_internal(cmd, ...)
33603413
args, esearch = search_args(...)
33613414
synchronize do

lib/net/imap/esearch_result.rb

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,16 @@ def initialize(tag: nil, uid: nil, data: nil)
3535

3636
# :call-seq: to_a -> Array of integers
3737
#
38-
# When #all contains a SequenceSet of message sequence
38+
# When either #all or #partial contains a SequenceSet of message sequence
3939
# numbers or UIDs, +to_a+ returns that set as an array of integers.
4040
#
41-
# When #all is +nil+, either because the server
42-
# returned no results or because +ALL+ was not included in
41+
# When both #all and #partial are +nil+, either because the server
42+
# returned no results or because +ALL+ and +PARTIAL+ were not included in
4343
# the IMAP#search +RETURN+ options, #to_a returns an empty array.
4444
#
4545
# Note that SearchResult also implements +to_a+, so it can be used without
4646
# checking if the server returned +SEARCH+ or +ESEARCH+ data.
47-
def to_a; all&.numbers || [] end
47+
def to_a; all&.numbers || partial&.to_a || [] end
4848

4949
##
5050
# attr_reader: tag
@@ -135,6 +135,46 @@ def count; data.assoc("COUNT")&.last end
135135
# and +ESEARCH+ {[RFC4731]}[https://www.rfc-editor.org/rfc/rfc4731.html#section-3.2].
136136
def modseq; data.assoc("MODSEQ")&.last end
137137

138+
# Returned by ESearchResult#partial.
139+
#
140+
# Requires +PARTIAL+ {[RFC9394]}[https://www.rfc-editor.org/rfc/rfc9394.html]
141+
# or <tt>CONTEXT=SEARCH</tt>/<tt>CONTEXT=SORT</tt>
142+
# {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html]
143+
#
144+
# See also: #to_a
145+
class PartialResult < Data.define(:range, :results)
146+
def initialize(range:, results:)
147+
range => Range
148+
results = SequenceSet[results] unless results.nil?
149+
super
150+
end
151+
152+
##
153+
# method: range
154+
# :call-seq: range -> range
155+
156+
##
157+
# method: results
158+
# :call-seq: results -> sequence set or nil
159+
160+
# Converts #results to an array of integers.
161+
#
162+
# See also: ESearchResult#to_a.
163+
def to_a; results&.numbers || [] end
164+
end
165+
166+
# :call-seq: partial -> PartialResult or nil
167+
#
168+
# A PartialResult containing a subset of the message sequence numbers or
169+
# UIDs that satisfy the SEARCH criteria.
170+
#
171+
# Requires +PARTIAL+ {[RFC9394]}[https://www.rfc-editor.org/rfc/rfc9394.html]
172+
# or <tt>CONTEXT=SEARCH</tt>/<tt>CONTEXT=SORT</tt>
173+
# {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html]
174+
#
175+
# See also: #to_a
176+
def partial; data.assoc("PARTIAL")&.last end
177+
138178
end
139179
end
140180
end

lib/net/imap/response_parser.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,24 @@ module RFC3629
321321
SEQUENCE_SET = /#{SEQUENCE_SET_ITEM}(?:,#{SEQUENCE_SET_ITEM})*/n
322322
SEQUENCE_SET_STR = /\A#{SEQUENCE_SET}\z/n
323323

324+
# partial-range-first = nz-number ":" nz-number
325+
# ;; Request to search from oldest (lowest UIDs) to
326+
# ;; more recent messages.
327+
# ;; A range 500:400 is the same as 400:500.
328+
# ;; This is similar to <seq-range> from [RFC3501]
329+
# ;; but cannot contain "*".
330+
PARTIAL_RANGE_FIRST = /\A(#{NZ_NUMBER}):(#{NZ_NUMBER})\z/n
331+
332+
# partial-range-last = MINUS nz-number ":" MINUS nz-number
333+
# ;; Request to search from newest (highest UIDs) to
334+
# ;; oldest messages.
335+
# ;; A range -500:-400 is the same as -400:-500.
336+
PARTIAL_RANGE_LAST = /\A(-#{NZ_NUMBER}):(-#{NZ_NUMBER})\z/n
337+
338+
# partial-range = partial-range-first / partial-range-last
339+
PARTIAL_RANGE = Regexp.union(PARTIAL_RANGE_FIRST,
340+
PARTIAL_RANGE_LAST)
341+
324342
# RFC3501:
325343
# literal = "{" number "}" CRLF *CHAR8
326344
# ; Number represents the number of CHAR8s
@@ -1517,6 +1535,9 @@ def esearch_response
15171535
# From RFC4731 (ESEARCH):
15181536
# search-return-data =/ "MODSEQ" SP mod-sequence-value
15191537
#
1538+
# From RFC9394 (PARTIAL):
1539+
# search-return-data =/ ret-data-partial
1540+
#
15201541
def search_return_data
15211542
label = search_modifier_name; SP!
15221543
value =
@@ -1526,11 +1547,41 @@ def search_return_data
15261547
when "ALL" then sequence_set
15271548
when "COUNT" then number
15281549
when "MODSEQ" then mod_sequence_value # RFC7162: CONDSTORE
1550+
when "PARTIAL" then ret_data_partial__value # RFC9394: PARTIAL
15291551
else search_return_value
15301552
end
15311553
[label, value]
15321554
end
15331555

1556+
# From RFC5267 (CONTEXT=SEARCH, CONTEXT=SORT) and RFC9394 (PARTIAL):
1557+
# ret-data-partial = "PARTIAL"
1558+
# SP "(" partial-range SP partial-results ")"
1559+
def ret_data_partial__value
1560+
lpar
1561+
range = partial_range; SP!
1562+
results = partial_results
1563+
rpar
1564+
ESearchResult::PartialResult.new(range, results)
1565+
end
1566+
1567+
# partial-range = partial-range-first / partial-range-last
1568+
# tagged-ext-simple =/ partial-range-last
1569+
def partial_range
1570+
case (str = atom)
1571+
when Patterns::PARTIAL_RANGE_FIRST, Patterns::PARTIAL_RANGE_LAST
1572+
min, max = [Integer($1), Integer($2)].minmax
1573+
min..max
1574+
else
1575+
parse_error("unexpected atom %p, expected partial-range", str)
1576+
end
1577+
end
1578+
1579+
# partial-results = sequence-set / "NIL"
1580+
# ;; <sequence-set> from [RFC3501].
1581+
# ;; NIL indicates that no results correspond to
1582+
# ;; the requested range.
1583+
def partial_results; NIL? ? nil : sequence_set end
1584+
15341585
# search-modifier-name = tagged-ext-label
15351586
alias search_modifier_name tagged_ext_label
15361587

rakelib/rfcs.rake

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ RFCS = {
145145
8514 => "IMAP SAVEDATE",
146146
8970 => "IMAP PREVIEW",
147147
9208 => "IMAP QUOTA, QUOTA=, QUOTASET",
148+
9394 => "IMAP PARTIAL",
148149

149150
# etc...
150151
3629 => "UTF8",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
:tests:
3+
4+
"RFC9394 PARTIAL 3.1. example 1":
5+
comment: |
6+
Neither RFC9394 nor RFC5267 contain any examples of a normal unelided
7+
sequence-set result. I've edited it to include a sequence-set here.
8+
:response: "* ESEARCH (TAG \"A01\") UID PARTIAL (-1:-100 200:250,252:300)\r\n"
9+
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
10+
name: ESEARCH
11+
data: !ruby/object:Net::IMAP::ESearchResult
12+
tag: A01
13+
uid: true
14+
data:
15+
- - PARTIAL
16+
- !ruby/object:Net::IMAP::ESearchResult::PartialResult
17+
range: !ruby/range
18+
begin: -100
19+
end: -1
20+
excl: false
21+
results: !ruby/object:Net::IMAP::SequenceSet
22+
string: 200:250,252:300
23+
tuples:
24+
- - 200
25+
- 250
26+
- - 252
27+
- 300
28+
raw_data: "* ESEARCH (TAG \"A01\") UID PARTIAL (-1:-100 200:250,252:300)\r\n"
29+
30+
"RFC9394 PARTIAL 3.1. example 2":
31+
:response: "* ESEARCH (TAG \"A02\") UID PARTIAL (23500:24000 55500:56000)\r\n"
32+
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
33+
name: ESEARCH
34+
data: !ruby/object:Net::IMAP::ESearchResult
35+
tag: A02
36+
uid: true
37+
data:
38+
- - PARTIAL
39+
- !ruby/object:Net::IMAP::ESearchResult::PartialResult
40+
range: !ruby/range
41+
begin: 23500
42+
end: 24000
43+
excl: false
44+
results: !ruby/object:Net::IMAP::SequenceSet
45+
string: 55500:56000
46+
tuples:
47+
- - 55500
48+
- 56000
49+
raw_data: "* ESEARCH (TAG \"A02\") UID PARTIAL (23500:24000 55500:56000)\r\n"
50+
51+
"RFC9394 PARTIAL 3.1. example 3":
52+
:response: "* ESEARCH (TAG \"A04\") UID PARTIAL (24000:24500 NIL)\r\n"
53+
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
54+
name: ESEARCH
55+
data: !ruby/object:Net::IMAP::ESearchResult
56+
tag: A04
57+
uid: true
58+
data:
59+
- - PARTIAL
60+
- !ruby/object:Net::IMAP::ESearchResult::PartialResult
61+
range: !ruby/range
62+
begin: 24000
63+
end: 24500
64+
excl: false
65+
results:
66+
raw_data: "* ESEARCH (TAG \"A04\") UID PARTIAL (24000:24500 NIL)\r\n"

0 commit comments

Comments
 (0)