Skip to content

Commit fa4502d

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 63e9e49 commit fa4502d

File tree

6 files changed

+175
-4
lines changed

6 files changed

+175
-4
lines changed

lib/net/imap.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2122,6 +2122,18 @@ def uid_expunge(uid_set)
21222122
# {[RFC4731]}[https://rfc-editor.org/rfc/rfc4731]
21232123
# {[RFC9051]}[https://rfc-editor.org/rfc/rfc9051]
21242124
#
2125+
# [+PARTIAL+ _range_]
2126+
# Returns ESearchResult#partial with a SequenceSet of a subset of
2127+
# matching sequence numbers or UIDs, as selected by _range_. As with
2128+
# sequence numbers, the first result is +1+: <tt>1..500</tt> selects the
2129+
# first 500 search results (in mailbox order), <tt>501..1000</tt> the
2130+
# second 500, and so on. _range_ may also be negative: <tt>-500..-1</tt>
2131+
# selects the last 500 search results.
2132+
#
2133+
# <em>Requires either the <tt>CONTEXT=SEARCH</tt> or +PARTIAL+ capabability.</em>
2134+
# {[RFC5267]}[https://rfc-editor.org/rfc/rfc5267]
2135+
# {[RFC9394]}[https://rfc-editor.org/rfc/rfc9394]
2136+
#
21252137
# ===== +MODSEQ+ return data
21262138
#
21272139
# ESearchResult#modseq return data does not have a corresponding return

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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,6 +1535,9 @@ def esearch_response
15351535
# From RFC4731 (ESEARCH):
15361536
# search-return-data =/ "MODSEQ" SP mod-sequence-value
15371537
#
1538+
# From RFC9394 (PARTIAL):
1539+
# search-return-data =/ ret-data-partial
1540+
#
15381541
def search_return_data
15391542
label = search_modifier_name; SP!
15401543
value =
@@ -1544,11 +1547,41 @@ def search_return_data
15441547
when "ALL" then sequence_set
15451548
when "COUNT" then number
15461549
when "MODSEQ" then mod_sequence_value # RFC7162: CONDSTORE
1550+
when "PARTIAL" then ret_data_partial__value # RFC9394: PARTIAL
15471551
else search_return_value
15481552
end
15491553
[label, value]
15501554
end
15511555

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+
15521585
# search-modifier-name = tagged-ext-label
15531586
alias search_modifier_name tagged_ext_label
15541587

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)