Skip to content

Commit 6cfdb02

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 10e19be commit 6cfdb02

File tree

7 files changed

+233
-23
lines changed

7 files changed

+233
-23
lines changed

lib/net/imap.rb

Lines changed: 55 additions & 19 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,39 +2093,56 @@ 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]
21122134
#
2113-
# ESearchResult#modseq return data does not have a corresponding return
2114-
# option. Instead, it is returned if the +MODSEQ+ search key is used or
2115-
# when the +CONDSTORE+ extension is enabled for the selected mailbox.
2116-
# See [{RFC4731 §3.2}[https://www.rfc-editor.org/rfc/rfc4731#section-3.2]]
2117-
# or [{RFC7162 §2.1.5}[https://www.rfc-editor.org/rfc/rfc7162#section-3.1.5]].
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]
21182146
#
21192147
# ===== +RFC4466+ compatible extensions
21202148
#
@@ -2125,6 +2153,14 @@ def uid_expunge(uid_set)
21252153
# intentionally _unstable_ API. Future releases may return different
21262154
# (incompatible) objects, <em>without deprecation or warning</em>.
21272155
#
2156+
# ===== +MODSEQ+ return data
2157+
#
2158+
# ESearchResult#modseq return data does not have a corresponding return
2159+
# option. Instead, it is returned if the +MODSEQ+ search key is used or
2160+
# when the +CONDSTORE+ extension is enabled for the selected mailbox.
2161+
# See [{RFC4731 §3.2}[https://www.rfc-editor.org/rfc/rfc4731#section-3.2]]
2162+
# or [{RFC7162 §2.1.5}[https://www.rfc-editor.org/rfc/rfc7162#section-3.1.5]].
2163+
#
21282164
# ==== Search keys
21292165
#
21302166
# For full definitions of the standard search +criteria+,
@@ -2396,8 +2432,8 @@ def uid_search(...)
23962432
# {[RFC7162]}[https://tools.ietf.org/html/rfc7162] in order to use the
23972433
# +changedsince+ argument. Using +changedsince+ implicitly enables the
23982434
# +CONDSTORE+ extension.
2399-
def fetch(set, attr, mod = nil, changedsince: nil)
2400-
fetch_internal("FETCH", set, attr, mod, changedsince: changedsince)
2435+
def fetch(...)
2436+
fetch_internal("FETCH", ...)
24012437
end
24022438

24032439
# :call-seq:
@@ -2419,8 +2455,8 @@ def fetch(set, attr, mod = nil, changedsince: nil)
24192455
# ==== Capabilities
24202456
#
24212457
# Same as #fetch.
2422-
def uid_fetch(set, attr, mod = nil, changedsince: nil)
2423-
fetch_internal("UID FETCH", set, attr, mod, changedsince: changedsince)
2458+
def uid_fetch(...)
2459+
fetch_internal("UID FETCH", ...)
24242460
end
24252461

24262462
# :call-seq:

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]
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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,20 @@ 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+
324338
# RFC3501:
325339
# literal = "{" number "}" CRLF *CHAR8
326340
# ; Number represents the number of CHAR8s
@@ -1517,6 +1531,9 @@ def esearch_response
15171531
# From RFC4731 (ESEARCH):
15181532
# search-return-data =/ "MODSEQ" SP mod-sequence-value
15191533
#
1534+
# From RFC9394 (PARTIAL):
1535+
# search-return-data =/ ret-data-partial
1536+
#
15201537
def search_return_data
15211538
label = search_modifier_name; SP!
15221539
value =
@@ -1526,11 +1543,41 @@ def search_return_data
15261543
when "ALL" then sequence_set
15271544
when "COUNT" then number
15281545
when "MODSEQ" then mod_sequence_value # RFC7162: CONDSTORE
1546+
when "PARTIAL" then ret_data_partial__value # RFC9394: PARTIAL
15291547
else search_return_value
15301548
end
15311549
[label, value]
15321550
end
15331551

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

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"

test/net/imap/test_esearch_result.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ class ESearchResultTest < Test::Unit::TestCase
1515
assert_equal [], esearch.to_a
1616
esearch = ESearchResult.new(nil, false, [["ALL", SequenceSet["1,5:8"]]])
1717
assert_equal [1, 5, 6, 7, 8], esearch.to_a
18+
esearch = ESearchResult.new(nil, false, [
19+
["PARTIAL", ESearchResult::PartialResult[1..5, "1,5:8"]]
20+
])
21+
assert_equal [1, 5, 6, 7, 8], esearch.to_a
1822
end
1923

2024
test "#tag" do
@@ -80,4 +84,17 @@ class ESearchResultTest < Test::Unit::TestCase
8084
assert_equal 12345, esearch.modseq
8185
end
8286

87+
test "#partial returns PARTIAL value (RFC9394: PARTIAL)" do
88+
result = Net::IMAP::ResponseParser.new.parse(
89+
"* ESEARCH (TAG \"A0006\") UID PARTIAL (-1:-100 200:250,252:300)\r\n"
90+
).data
91+
assert_equal(ESearchResult, result.class)
92+
assert_equal(
93+
ESearchResult::PartialResult.new(
94+
-100..-1, SequenceSet[200..250, 252..300]
95+
),
96+
result.partial
97+
)
98+
end
99+
83100
end

test/net/imap/test_imap_response_parser.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ def teardown
103103
# RFC 9208: QUOTA extension
104104
generate_tests_from fixture_file: "rfc9208_quota_responses.yml"
105105

106+
# RFC 9394: PARTIAL extension
107+
generate_tests_from fixture_file: "rfc9394_partial.yml"
108+
106109
############################################################################
107110
# Workarounds or unspecified extensions:
108111
generate_tests_from fixture_file: "quirky_behaviors.yml"

0 commit comments

Comments
 (0)