Skip to content

Commit 4648a06

Browse files
authored
🔀 Merge pull request #367 from ruby/RFC9394-PARTIAL
✨ Add support for `PARTIAL` extension (RFC9394)
2 parents 43f531e + 39a646d commit 4648a06

File tree

8 files changed

+278
-25
lines changed

8 files changed

+278
-25
lines changed

‎lib/net/imap.rb

Lines changed: 93 additions & 21 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+
# - 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,12 +2434,12 @@ 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:
2406-
# uid_fetch(set, attr, changedsince: nil) -> array of FetchData
2442+
# uid_fetch(set, attr, changedsince: nil, partial: nil) -> array of FetchData
24072443
#
24082444
# Sends a {UID FETCH command [IMAP4rev1 §6.4.8]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.4.8]
24092445
# to retrieve data associated with a message in the mailbox.
@@ -2420,13 +2456,44 @@ def fetch(set, attr, mod = nil, changedsince: nil)
24202456
#
24212457
# +changedsince+ (optional) behaves the same as with #fetch.
24222458
#
2459+
# +partial+ is an optional range to limit the number of results returned.
2460+
# It's useful when +set+ contains an unknown number of messages.
2461+
# <tt>1..500</tt> returns the first 500 messages in +set+ (in mailbox
2462+
# order), <tt>501..1000</tt> the second 500, and so on. +partial+ may also
2463+
# be negative: <tt>-500..-1</tt> selects the last 500 messages in +set+.
2464+
# <em>Requires the +PARTIAL+ capabability.</em>
2465+
# {[RFC9394]}[https://rfc-editor.org/rfc/rfc9394]
2466+
#
2467+
# For example:
2468+
#
2469+
# # Without partial, the size of the results may be unknown beforehand:
2470+
# results = imap.uid_fetch(next_uid_to_fetch.., %w(UID FLAGS))
2471+
# # ... maybe wait for a long time ... and allocate a lot of memory ...
2472+
# results.size # => 0..2**32-1
2473+
# process results # may also take a long time and use a lot of memory...
2474+
#
2475+
# # Using partial, the results may be paginated:
2476+
# loop do
2477+
# results = imap.uid_fetch(next_uid_to_fetch.., %w(UID FLAGS),
2478+
# partial: 1..500)
2479+
# # fetch should return quickly and allocate little memory
2480+
# results.size # => 0..500
2481+
# break if results.empty?
2482+
# next_uid_to_fetch = results.last.uid + 1
2483+
# process results
2484+
# end
2485+
#
24232486
# Related: #fetch, FetchData
24242487
#
24252488
# ==== Capabilities
24262489
#
2427-
# Same as #fetch.
2428-
def uid_fetch(set, attr, mod = nil, changedsince: nil)
2429-
fetch_internal("UID FETCH", set, attr, mod, changedsince: changedsince)
2490+
# The server's capabilities must include +PARTIAL+
2491+
# {[RFC9394]}[https://rfc-editor.org/rfc/rfc9394] in order to use the
2492+
# +partial+ argument.
2493+
#
2494+
# Otherwise, the same as #fetch.
2495+
def uid_fetch(...)
2496+
fetch_internal("UID FETCH", ...)
24302497
end
24312498

24322499
# :call-seq:
@@ -3372,7 +3439,12 @@ def search_internal(cmd, ...)
33723439
end
33733440
end
33743441

3375-
def fetch_internal(cmd, set, attr, mod = nil, changedsince: nil)
3442+
def fetch_internal(cmd, set, attr, mod = nil, partial: nil, changedsince: nil)
3443+
set = SequenceSet[set]
3444+
if partial
3445+
mod ||= []
3446+
mod << "PARTIAL" << PartialRange[partial]
3447+
end
33763448
if changedsince
33773449
mod ||= []
33783450
mod << "CHANGEDSINCE" << Integer(changedsince)
@@ -3389,9 +3461,9 @@ def fetch_internal(cmd, set, attr, mod = nil, changedsince: nil)
33893461
synchronize do
33903462
clear_responses("FETCH")
33913463
if mod
3392-
send_command(cmd, SequenceSet.new(set), attr, mod)
3464+
send_command(cmd, set, attr, mod)
33933465
else
3394-
send_command(cmd, SequenceSet.new(set), attr)
3466+
send_command(cmd, set, attr)
33953467
end
33963468
clear_responses("FETCH")
33973469
end

‎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

‎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)