Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 93 additions & 21 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,11 @@ module Net
# See FetchData#emailid and FetchData#emailid.
# - Updates #status with support for the +MAILBOXID+ status attribute.
#
# ==== RFC9394: +PARTIAL+
# - Updates #search, #uid_search with the +PARTIAL+ return option which adds
# ESearchResult#partial return data.
# - Updates #uid_fetch with the +partial+ modifier.
#
# == References
#
# [{IMAP4rev1}[https://www.rfc-editor.org/rfc/rfc3501.html]]::
Expand Down Expand Up @@ -701,6 +706,11 @@ module Net
# Gondwana, B., Ed., "IMAP Extension for Object Identifiers",
# RFC 8474, DOI 10.17487/RFC8474, September 2018,
# <https://www.rfc-editor.org/info/rfc8474>.
# [PARTIAL[https://www.rfc-editor.org/info/rfc9394]]::
# Melnikov, A., Achuthan, A., Nagulakonda, V., and L. Alves,
# "IMAP PARTIAL Extension for Paged SEARCH and FETCH", RFC 9394,
# DOI 10.17487/RFC9394, June 2023,
# <https://www.rfc-editor.org/info/rfc9394>.
#
# === IANA registries
# * {IMAP Capabilities}[http://www.iana.org/assignments/imap4-capabilities]
Expand Down Expand Up @@ -1971,8 +1981,9 @@ def uid_expunge(uid_set)
# the server to return an ESearchResult instead of a SearchResult, but some
# servers disobey this requirement. <em>Requires an extended search
# capability, such as +ESEARCH+ or +IMAP4rev2+.</em>
# See {"Argument translation"}[rdoc-ref:#search@Argument+translation]
# and {"Return options"}[rdoc-ref:#search@Return+options], below.
# See {"Argument translation"}[rdoc-ref:#search@Argument+translation] and
# {"Supported return options"}[rdoc-ref:#search@Supported+return+options],
# below.
#
# +charset+ is the name of the {registered character
# set}[https://www.iana.org/assignments/character-sets/character-sets.xhtml]
Expand Down Expand Up @@ -2082,33 +2093,58 @@ def uid_expunge(uid_set)
# <em>*WARNING:* This is vulnerable to injection attacks when external
# inputs are used.</em>
#
# ==== Return options
# ==== Supported return options
#
# For full definitions of the standard return options and return data, see
# the relevant RFCs.
#
# ===== +ESEARCH+ or +IMAP4rev2+
#
# The following return options require either +ESEARCH+ or +IMAP4rev2+.
# See [{RFC4731 §3.1}[https://rfc-editor.org/rfc/rfc4731#section-3.1]] or
# [{IMAP4rev2 §6.4.4}[https://www.rfc-editor.org/rfc/rfc9051.html#section-6.4.4]].
#
# [+ALL+]
# Returns ESearchResult#all with a SequenceSet of all matching sequence
# numbers or UIDs. This is the default, when return options are empty.
#
# For compatibility with SearchResult, ESearchResult#to_a returns an
# Array of message sequence numbers or UIDs.
#
# <em>Requires either the +ESEARCH+ or +IMAP4rev2+ capabability.</em>
# {[RFC4731]}[https://rfc-editor.org/rfc/rfc4731]
# {[RFC9051]}[https://rfc-editor.org/rfc/rfc9051]
#
# [+COUNT+]
# Returns ESearchResult#count with the number of matching messages.
#
# <em>Requires either the +ESEARCH+ or +IMAP4rev2+ capabability.</em>
# {[RFC4731]}[https://rfc-editor.org/rfc/rfc4731]
# {[RFC9051]}[https://rfc-editor.org/rfc/rfc9051]
#
# [+MAX+]
# Returns ESearchResult#max with the highest matching sequence number or
# UID.
#
# <em>Requires either the +ESEARCH+ or +IMAP4rev2+ capabability.</em>
# {[RFC4731]}[https://rfc-editor.org/rfc/rfc4731]
# {[RFC9051]}[https://rfc-editor.org/rfc/rfc9051]
#
# [+MIN+]
# Returns ESearchResult#min with the lowest matching sequence number or
# UID.
#
# ===== +CONDSTORE+
# <em>Requires either the +ESEARCH+ or +IMAP4rev2+ capabability.</em>
# {[RFC4731]}[https://rfc-editor.org/rfc/rfc4731]
# {[RFC9051]}[https://rfc-editor.org/rfc/rfc9051]
#
# [+PARTIAL+ _range_]
# Returns ESearchResult#partial with a SequenceSet of a subset of
# matching sequence numbers or UIDs, as selected by _range_. As with
# sequence numbers, the first result is +1+: <tt>1..500</tt> selects the
# first 500 search results (in mailbox order), <tt>501..1000</tt> the
# second 500, and so on. _range_ may also be negative: <tt>-500..-1</tt>
# selects the last 500 search results.
#
# <em>Requires either the <tt>CONTEXT=SEARCH</tt> or +PARTIAL+ capabability.</em>
# {[RFC5267]}[https://rfc-editor.org/rfc/rfc5267]
# {[RFC9394]}[https://rfc-editor.org/rfc/rfc9394]
#
# ===== +MODSEQ+ return data
#
# ESearchResult#modseq return data does not have a corresponding return
# option. Instead, it is returned if the +MODSEQ+ search key is used or
Expand All @@ -2120,8 +2156,8 @@ def uid_expunge(uid_set)
#
# {RFC4466 §2.6}[https://www.rfc-editor.org/rfc/rfc4466.html#section-2.6]
# defines standard syntax for search extensions. Net::IMAP allows sending
# unknown search return options and will parse unknown search extensions'
# return values into ExtensionData. Please note that this is an
# unsupported search return options and will parse unsupported search
# extensions' return values into ExtensionData. Please note that this is an
# intentionally _unstable_ API. Future releases may return different
# (incompatible) objects, <em>without deprecation or warning</em>.
#
Expand Down Expand Up @@ -2398,12 +2434,12 @@ def uid_search(...)
# {[RFC7162]}[https://tools.ietf.org/html/rfc7162] in order to use the
# +changedsince+ argument. Using +changedsince+ implicitly enables the
# +CONDSTORE+ extension.
def fetch(set, attr, mod = nil, changedsince: nil)
fetch_internal("FETCH", set, attr, mod, changedsince: changedsince)
def fetch(...)
fetch_internal("FETCH", ...)
end

# :call-seq:
# uid_fetch(set, attr, changedsince: nil) -> array of FetchData
# uid_fetch(set, attr, changedsince: nil, partial: nil) -> array of FetchData
#
# Sends a {UID FETCH command [IMAP4rev1 §6.4.8]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.4.8]
# to retrieve data associated with a message in the mailbox.
Expand All @@ -2420,13 +2456,44 @@ def fetch(set, attr, mod = nil, changedsince: nil)
#
# +changedsince+ (optional) behaves the same as with #fetch.
#
# +partial+ is an optional range to limit the number of results returned.
# It's useful when +set+ contains an unknown number of messages.
# <tt>1..500</tt> returns the first 500 messages in +set+ (in mailbox
# order), <tt>501..1000</tt> the second 500, and so on. +partial+ may also
# be negative: <tt>-500..-1</tt> selects the last 500 messages in +set+.
# <em>Requires the +PARTIAL+ capabability.</em>
# {[RFC9394]}[https://rfc-editor.org/rfc/rfc9394]
#
# For example:
#
# # Without partial, the size of the results may be unknown beforehand:
# results = imap.uid_fetch(next_uid_to_fetch.., %w(UID FLAGS))
# # ... maybe wait for a long time ... and allocate a lot of memory ...
# results.size # => 0..2**32-1
# process results # may also take a long time and use a lot of memory...
#
# # Using partial, the results may be paginated:
# loop do
# results = imap.uid_fetch(next_uid_to_fetch.., %w(UID FLAGS),
# partial: 1..500)
# # fetch should return quickly and allocate little memory
# results.size # => 0..500
# break if results.empty?
# next_uid_to_fetch = results.last.uid + 1
# process results
# end
#
# Related: #fetch, FetchData
#
# ==== Capabilities
#
# Same as #fetch.
def uid_fetch(set, attr, mod = nil, changedsince: nil)
fetch_internal("UID FETCH", set, attr, mod, changedsince: changedsince)
# The server's capabilities must include +PARTIAL+
# {[RFC9394]}[https://rfc-editor.org/rfc/rfc9394] in order to use the
# +partial+ argument.
#
# Otherwise, the same as #fetch.
def uid_fetch(...)
fetch_internal("UID FETCH", ...)
end

# :call-seq:
Expand Down Expand Up @@ -3372,7 +3439,12 @@ def search_internal(cmd, ...)
end
end

def fetch_internal(cmd, set, attr, mod = nil, changedsince: nil)
def fetch_internal(cmd, set, attr, mod = nil, partial: nil, changedsince: nil)
set = SequenceSet[set]
if partial
mod ||= []
mod << "PARTIAL" << PartialRange[partial]
end
if changedsince
mod ||= []
mod << "CHANGEDSINCE" << Integer(changedsince)
Expand All @@ -3389,9 +3461,9 @@ def fetch_internal(cmd, set, attr, mod = nil, changedsince: nil)
synchronize do
clear_responses("FETCH")
if mod
send_command(cmd, SequenceSet.new(set), attr, mod)
send_command(cmd, set, attr, mod)
else
send_command(cmd, SequenceSet.new(set), attr)
send_command(cmd, set, attr)
end
clear_responses("FETCH")
end
Expand Down
48 changes: 44 additions & 4 deletions lib/net/imap/esearch_result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,16 @@ def initialize(tag: nil, uid: nil, data: nil)

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

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

# Returned by ESearchResult#partial.
#
# Requires +PARTIAL+ {[RFC9394]}[https://www.rfc-editor.org/rfc/rfc9394.html]
# or <tt>CONTEXT=SEARCH</tt>/<tt>CONTEXT=SORT</tt>
# {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html]
#
# See also: #to_a
class PartialResult < Data.define(:range, :results)
def initialize(range:, results:)
range => Range
results = SequenceSet[results] unless results.nil?
super
end

##
# method: range
# :call-seq: range -> range

##
# method: results
# :call-seq: results -> sequence set or nil

# Converts #results to an array of integers.
#
# See also: ESearchResult#to_a.
def to_a; results&.numbers || [] end
end

# :call-seq: partial -> PartialResult or nil
#
# A PartialResult containing a subset of the message sequence numbers or
# UIDs that satisfy the SEARCH criteria.
#
# Requires +PARTIAL+ {[RFC9394]}[https://www.rfc-editor.org/rfc/rfc9394.html]
# or <tt>CONTEXT=SEARCH</tt>/<tt>CONTEXT=SORT</tt>
# {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html]
#
# See also: #to_a
def partial; data.assoc("PARTIAL")&.last end

end
end
end
33 changes: 33 additions & 0 deletions lib/net/imap/response_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1535,6 +1535,9 @@ def esearch_response
# From RFC4731 (ESEARCH):
# search-return-data =/ "MODSEQ" SP mod-sequence-value
#
# From RFC9394 (PARTIAL):
# search-return-data =/ ret-data-partial
#
def search_return_data
label = search_modifier_name; SP!
value =
Expand All @@ -1544,11 +1547,41 @@ def search_return_data
when "ALL" then sequence_set
when "COUNT" then number
when "MODSEQ" then mod_sequence_value # RFC7162: CONDSTORE
when "PARTIAL" then ret_data_partial__value # RFC9394: PARTIAL
else search_return_value
end
[label, value]
end

# From RFC5267 (CONTEXT=SEARCH, CONTEXT=SORT) and RFC9394 (PARTIAL):
# ret-data-partial = "PARTIAL"
# SP "(" partial-range SP partial-results ")"
def ret_data_partial__value
lpar
range = partial_range; SP!
results = partial_results
rpar
ESearchResult::PartialResult.new(range, results)
end

# partial-range = partial-range-first / partial-range-last
# tagged-ext-simple =/ partial-range-last
def partial_range
case (str = atom)
when Patterns::PARTIAL_RANGE_FIRST, Patterns::PARTIAL_RANGE_LAST
min, max = [Integer($1), Integer($2)].minmax
min..max
else
parse_error("unexpected atom %p, expected partial-range", str)
end
end

# partial-results = sequence-set / "NIL"
# ;; <sequence-set> from [RFC3501].
# ;; NIL indicates that no results correspond to
# ;; the requested range.
def partial_results; NIL? ? nil : sequence_set end

# search-modifier-name = tagged-ext-label
alias search_modifier_name tagged_ext_label

Expand Down
1 change: 1 addition & 0 deletions rakelib/rfcs.rake
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ RFCS = {
8514 => "IMAP SAVEDATE",
8970 => "IMAP PREVIEW",
9208 => "IMAP QUOTA, QUOTA=, QUOTASET",
9394 => "IMAP PARTIAL",

# etc...
3629 => "UTF8",
Expand Down
66 changes: 66 additions & 0 deletions test/net/imap/fixtures/response_parser/rfc9394_partial.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
:tests:

"RFC9394 PARTIAL 3.1. example 1":
comment: |
Neither RFC9394 nor RFC5267 contain any examples of a normal unelided
sequence-set result. I've edited it to include a sequence-set here.
:response: "* ESEARCH (TAG \"A01\") UID PARTIAL (-1:-100 200:250,252:300)\r\n"
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
name: ESEARCH
data: !ruby/object:Net::IMAP::ESearchResult
tag: A01
uid: true
data:
- - PARTIAL
- !ruby/object:Net::IMAP::ESearchResult::PartialResult
range: !ruby/range
begin: -100
end: -1
excl: false
results: !ruby/object:Net::IMAP::SequenceSet
string: 200:250,252:300
tuples:
- - 200
- 250
- - 252
- 300
raw_data: "* ESEARCH (TAG \"A01\") UID PARTIAL (-1:-100 200:250,252:300)\r\n"

"RFC9394 PARTIAL 3.1. example 2":
:response: "* ESEARCH (TAG \"A02\") UID PARTIAL (23500:24000 55500:56000)\r\n"
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
name: ESEARCH
data: !ruby/object:Net::IMAP::ESearchResult
tag: A02
uid: true
data:
- - PARTIAL
- !ruby/object:Net::IMAP::ESearchResult::PartialResult
range: !ruby/range
begin: 23500
end: 24000
excl: false
results: !ruby/object:Net::IMAP::SequenceSet
string: 55500:56000
tuples:
- - 55500
- 56000
raw_data: "* ESEARCH (TAG \"A02\") UID PARTIAL (23500:24000 55500:56000)\r\n"

"RFC9394 PARTIAL 3.1. example 3":
:response: "* ESEARCH (TAG \"A04\") UID PARTIAL (24000:24500 NIL)\r\n"
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
name: ESEARCH
data: !ruby/object:Net::IMAP::ESearchResult
tag: A04
uid: true
data:
- - PARTIAL
- !ruby/object:Net::IMAP::ESearchResult::PartialResult
range: !ruby/range
begin: 24000
end: 24500
excl: false
results:
raw_data: "* ESEARCH (TAG \"A04\") UID PARTIAL (24000:24500 NIL)\r\n"
Loading
Loading