Skip to content

Commit e570d48

Browse files
authored
🔀 Merge pull request #333 from nevans/basic-esearch-support
✨ Add basic ESearch support
2 parents 8655aeb + e088eb1 commit e570d48

File tree

11 files changed

+968
-27
lines changed

11 files changed

+968
-27
lines changed

‎lib/net/imap.rb

Lines changed: 195 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ module Net
414414
# >>>
415415
# <em>The following are folded into +IMAP4rev2+ but are currently
416416
# unsupported or incompletely supported by</em> Net::IMAP<em>: RFC4466
417-
# extensions, +ESEARCH+, +SEARCHRES+, +LIST-EXTENDED+, +LIST-STATUS+,
417+
# extensions, +SEARCHRES+, +LIST-EXTENDED+, +LIST-STATUS+,
418418
# +LITERAL-+, and +SPECIAL-USE+.</em>
419419
#
420420
# ==== RFC2087: +QUOTA+
@@ -466,6 +466,10 @@ module Net
466466
# - Updates #append with the +APPENDUID+ ResponseCode
467467
# - Updates #copy, #move with the +COPYUID+ ResponseCode
468468
#
469+
# ==== RFC4731: +ESEARCH+
470+
# Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051].
471+
# - Updates #search, #uid_search with +return+ options and ESearchResult.
472+
#
469473
# ==== RFC4959: +SASL-IR+
470474
# Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051].
471475
# - Updates #authenticate with the option to send an initial response.
@@ -1931,48 +1935,103 @@ def uid_expunge(uid_set)
19311935

19321936
# :call-seq:
19331937
# search(criteria, charset = nil) -> result
1934-
# search(criteria, charset: nil) -> result
1938+
# search(criteria, charset: nil, return: nil) -> result
19351939
#
19361940
# Sends a {SEARCH command [IMAP4rev1 §6.4.4]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.4.4]
19371941
# to search the mailbox for messages that match the given search +criteria+,
1938-
# and returns a SearchResult. SearchResult inherits from Array (for
1939-
# backward compatibility) but adds SearchResult#modseq when the +CONDSTORE+
1940-
# capability has been enabled.
1942+
# and returns either a SearchResult or an ESearchResult. SearchResult
1943+
# inherits from Array (for backward compatibility) but adds
1944+
# SearchResult#modseq when the +CONDSTORE+ capability has been enabled.
1945+
# ESearchResult also implements to_a{rdoc-ref:ESearchResult#to_a}, for
1946+
# compatibility with SearchResult.
19411947
#
19421948
# +criteria+ is one or more search keys and their arguments, which may be
19431949
# provided as an array or a string.
19441950
# See {"Argument translation"}[rdoc-ref:#search@Argument+translation]
19451951
# and {"Search criteria"}[rdoc-ref:#search@Search+criteria], below.
19461952
#
1953+
# +return+ options control what kind of information is returned about
1954+
# messages matching the search +criteria+. Specifying +return+ should force
1955+
# the server to return an ESearchResult instead of a SearchResult, but some
1956+
# servers disobey this requirement. <em>Requires an extended search
1957+
# capability, such as +ESEARCH+ or +IMAP4rev2+.</em>
1958+
# See {"Argument translation"}[rdoc-ref:#search@Argument+translation]
1959+
# and {"Return options"}[rdoc-ref:#search@Return+options], below.
1960+
#
19471961
# +charset+ is the name of the {registered character
19481962
# set}[https://www.iana.org/assignments/character-sets/character-sets.xhtml]
19491963
# used by strings in the search +criteria+. When +charset+ isn't specified,
19501964
# either <tt>"US-ASCII"</tt> or <tt>"UTF-8"</tt> is assumed, depending on
1951-
# the server's capabilities. +charset+ may be sent inside +criteria+
1952-
# instead of as a separate argument.
1965+
# the server's capabilities.
1966+
#
1967+
# _NOTE:_ Return options and charset may be sent as part of +criteria+. Do
1968+
# not use the +return+ or +charset+ arguments when either return options or
1969+
# charset are embedded in +criteria+.
19531970
#
19541971
# Related: #uid_search
19551972
#
19561973
# ==== For example:
19571974
#
1958-
# p imap.search(["SUBJECT", "hello", "NOT", "SEEN"])
1975+
# imap.search(["SUBJECT", "hello", "NOT", "SEEN"])
19591976
# #=> [1, 6, 7, 8]
19601977
#
1961-
# The following searches send the exact same command to the server:
1962-
#
1963-
# # criteria array, charset arg
1964-
# imap.search(["OR", "UNSEEN", %w(FLAGGED SUBJECT foo)], "UTF-8")
1965-
# # criteria string, charset arg
1966-
# imap.search("OR UNSEEN (FLAGGED SUBJECT foo)", "UTF-8")
1967-
# # criteria array contains charset arg
1968-
# imap.search([*%w[CHARSET UTF-8], "OR", "UNSEEN", %w(FLAGGED SUBJECT foo)])
1969-
# # criteria string contains charset arg
1970-
# imap.search("CHARSET UTF-8 OR UNSEEN (FLAGGED SUBJECT foo)")
1978+
# The following assumes the server supports +ESEARCH+ and +CONDSTORE+:
1979+
#
1980+
# result = imap.uid_search(["UID", 12345.., "MODSEQ", 620_162_338],
1981+
# return: %w(all count min max))
1982+
# # => #<data Net::IMAP::ESearchResult tag="RUBY0123", uid=true,
1983+
# # data=[["ALL", Net::IMAP::SequenceSet["12346:12349,22222:22230"]],
1984+
# # ["COUNT", 13], ["MIN", 12346], ["MAX", 22230],
1985+
# # ["MODSEQ", 917162488]]>
1986+
# result.to_a # => [12346, 12347, 12348, 12349, 22222, 22223, 22224,
1987+
# # 22225, 22226, 22227, 22228, 22229, 22230]
1988+
# result.uid? # => true
1989+
# result.count # => 13
1990+
# result.min # => 12346
1991+
# result.max # => 22230
1992+
# result.modseq # => 917162488
1993+
#
1994+
# Using +return+ options to limit the result to only min, max, and count:
1995+
#
1996+
# result = imap.uid_search(["UID", 12345..,], return: %w(count min max))
1997+
# # => #<data Net::IMAP::ESearchResult tag="RUBY0124", uid=true,
1998+
# # data=[["COUNT", 13], ["MIN", 12346], ["MAX", 22230]]>
1999+
# result.to_a # => []
2000+
# result.count # => 13
2001+
# result.min # => 12346
2002+
# result.max # => 22230
2003+
#
2004+
# Return options and charset may be sent as keyword args or embedded in the
2005+
# +criteria+ arg, but they must be in the correct order: <tt>"RETURN (...)
2006+
# CHARSET ... criteria..."</tt>. The following searches
2007+
# send the exact same command to the server:
2008+
#
2009+
# # Return options and charset as keyword arguments (preferred)
2010+
# imap.search(%w(OR UNSEEN FLAGGED), return: %w(MIN MAX), charset: "UTF-8")
2011+
# # Embedding return and charset in the criteria array
2012+
# imap.search(["RETURN", %w(MIN MAX), "CHARSET", "UTF-8", *%w(OR UNSEEN FLAGGED)])
2013+
# # Embedding return and charset in the criteria string
2014+
# imap.search("RETURN (MIN MAX) CHARSET UTF-8 OR UNSEEN FLAGGED")
2015+
#
2016+
# Sending charset as the second positional argument is supported for
2017+
# backward compatibility. Future versions may print a deprecation warning:
2018+
# imap.search(%w(OR UNSEEN FLAGGED), "UTF-8", return: %w(MIN MAX))
19712019
#
19722020
# ==== Argument translation
19732021
#
2022+
# [+return+ options]
2023+
# Must be an Array. Return option names may be either strings or symbols.
2024+
# +Range+ elements which begin and end with negative integers are encoded
2025+
# for use with +PARTIAL+--any other ranges are converted to SequenceSet.
2026+
# Unlike +criteria+, other return option arguments are not automatically
2027+
# converted to SequenceSet.
2028+
#
19742029
# [When +criteria+ is an Array]
1975-
# Each member is a +SEARCH+ command argument:
2030+
# When the array begins with <tt>"RETURN"</tt> (case insensitive), the
2031+
# second array element is translated like the +return+ parameter (as
2032+
# described above).
2033+
#
2034+
# Every other member is a +SEARCH+ command argument:
19762035
# [SequenceSet]
19772036
# Encoded as an \IMAP +sequence-set+ with SequenceSet#valid_string.
19782037
# [Set, Range, <tt>-1</tt>, +:*+, responds to +#to_sequence_set+]
@@ -2007,6 +2066,49 @@ def uid_expunge(uid_set)
20072066
# <em>*WARNING:* This is vulnerable to injection attacks when external
20082067
# inputs are used.</em>
20092068
#
2069+
# ==== Return options
2070+
#
2071+
# For full definitions of the standard return options and return data, see
2072+
# the relevant RFCs.
2073+
#
2074+
# ===== +ESEARCH+ or +IMAP4rev2+
2075+
#
2076+
# The following return options require either +ESEARCH+ or +IMAP4rev2+.
2077+
# See [{RFC4731 §3.1}[https://rfc-editor.org/rfc/rfc4731#section-3.1]] or
2078+
# [{IMAP4rev2 §6.4.4}[https://www.rfc-editor.org/rfc/rfc9051.html#section-6.4.4]].
2079+
#
2080+
# [+ALL+]
2081+
# Returns ESearchResult#all with a SequenceSet of all matching sequence
2082+
# numbers or UIDs. This is the default, when return options are empty.
2083+
#
2084+
# For compatibility with SearchResult, ESearchResult#to_a returns an
2085+
# Array of message sequence numbers or UIDs.
2086+
# [+COUNT+]
2087+
# Returns ESearchResult#count with the number of matching messages.
2088+
# [+MAX+]
2089+
# Returns ESearchResult#max with the highest matching sequence number or
2090+
# UID.
2091+
# [+MIN+]
2092+
# Returns ESearchResult#min with the lowest matching sequence number or
2093+
# UID.
2094+
#
2095+
# ===== +CONDSTORE+
2096+
#
2097+
# ESearchResult#modseq return data does not have a corresponding return
2098+
# option. Instead, it is returned if the +MODSEQ+ search key is used or
2099+
# when the +CONDSTORE+ extension is enabled for the selected mailbox.
2100+
# See [{RFC4731 §3.2}[https://www.rfc-editor.org/rfc/rfc4731#section-3.2]]
2101+
# or [{RFC7162 §2.1.5}[https://www.rfc-editor.org/rfc/rfc7162#section-3.1.5]].
2102+
#
2103+
# ===== +RFC4466+ compatible extensions
2104+
#
2105+
# {RFC4466 §2.6}[https://www.rfc-editor.org/rfc/rfc4466.html#section-2.6]
2106+
# defines standard syntax for search extensions. Net::IMAP allows sending
2107+
# unknown search return options and will parse unknown search extensions'
2108+
# return values into ExtensionData. Please note that this is an
2109+
# intentionally _unstable_ API. Future releases may return different
2110+
# (incompatible) objects, <em>without deprecation or warning</em>.
2111+
#
20102112
# ==== Search keys
20112113
#
20122114
# For full definitions of the standard search +criteria+,
@@ -2198,6 +2300,13 @@ def uid_expunge(uid_set)
21982300
#
21992301
# ==== Capabilities
22002302
#
2303+
# Return options should only be specified when the server supports
2304+
# +IMAP4rev2+ or an extension that allows them, such as +ESEARCH+
2305+
# [RFC4731[https://rfc-editor.org/rfc/rfc4731#section-3.1]].
2306+
#
2307+
# When +IMAP4rev2+ is enabled, or when the server supports +IMAP4rev2+ but
2308+
# not +IMAP4rev1+, ESearchResult is always returned instead of SearchResult.
2309+
#
22012310
# If CONDSTORE[https://www.rfc-editor.org/rfc/rfc7162.html] is supported
22022311
# and enabled for the selected mailbox, a non-empty SearchResult will
22032312
# include a +MODSEQ+ value.
@@ -2211,7 +2320,7 @@ def search(...)
22112320

22122321
# :call-seq:
22132322
# uid_search(criteria, charset = nil) -> result
2214-
# uid_search(criteria, charset: nil) -> result
2323+
# uid_search(criteria, charset: nil, return: nil) -> result
22152324
#
22162325
# Sends a {UID SEARCH command [IMAP4rev1 §6.4.8]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.4.8]
22172326
# to search the mailbox for messages that match the given searching
@@ -3152,7 +3261,30 @@ def enforce_logindisabled?
31523261
end
31533262
end
31543263

3155-
def search_args(keys, charset_arg = nil, charset: nil)
3264+
RETURN_WHOLE = /\ARETURN\z/i
3265+
RETURN_START = /\ARETURN\b/i
3266+
private_constant :RETURN_WHOLE, :RETURN_START
3267+
3268+
def search_args(keys, charset_arg = nil, return: nil, charset: nil)
3269+
{return:} => {return: return_kw}
3270+
case [return_kw, keys]
3271+
in [nil, Array[RETURN_WHOLE, return_opts, *keys]]
3272+
return_opts = convert_return_opts(return_opts)
3273+
esearch = true
3274+
in [nil => return_opts, RETURN_START]
3275+
esearch = true
3276+
in [nil => return_opts, keys]
3277+
esearch = false
3278+
in [_, Array[RETURN_WHOLE, _, *] | RETURN_START]
3279+
raise ArgumentError, "conflicting return options"
3280+
in [_, Array[RETURN_WHOLE, _, *]] # workaround for https://bugs.ruby-lang.org/issues/20956
3281+
raise ArgumentError, "conflicting return options"
3282+
in [_, RETURN_START] # workaround for https://bugs.ruby-lang.org/issues/20956
3283+
raise ArgumentError, "conflicting return options"
3284+
in [return_opts, keys]
3285+
return_opts = convert_return_opts(return_opts)
3286+
esearch = true
3287+
end
31563288
if charset && charset_arg
31573289
raise ArgumentError, "multiple charset arguments"
31583290
end
@@ -3163,16 +3295,54 @@ def search_args(keys, charset_arg = nil, charset: nil)
31633295
end
31643296
args = normalize_searching_criteria(keys)
31653297
args.prepend("CHARSET", charset) if charset
3166-
args
3298+
args.prepend("RETURN", return_opts) if return_opts
3299+
return args, esearch
3300+
end
3301+
3302+
def convert_return_opts(unconverted)
3303+
return_opts = Array.try_convert(unconverted) or
3304+
raise TypeError, "expected return options to be Array, got %s" % [
3305+
unconverted.class
3306+
]
3307+
return_opts.map {|opt|
3308+
case opt
3309+
when Symbol then opt.to_s
3310+
when Range then partial_range_last_or_seqset(opt)
3311+
else opt
3312+
end
3313+
}
3314+
end
3315+
3316+
def partial_range_last_or_seqset(range)
3317+
case [range.begin, range.end]
3318+
in [Integer => first, Integer => last] if first.negative? && last.negative?
3319+
# partial-range-last [RFC9394]
3320+
first <= last or raise DataFormatError, "empty range: %p" % [range]
3321+
"#{first}:#{last}"
3322+
else
3323+
SequenceSet[range]
3324+
end
31673325
end
31683326

31693327
def search_internal(cmd, ...)
3170-
args = search_args(...)
3328+
args, esearch = search_args(...)
31713329
synchronize do
3172-
send_command(cmd, *args)
3330+
tagged = send_command(cmd, *args)
3331+
tag = tagged.tag
3332+
# Only the last ESEARCH or SEARCH is used. Excess results are ignored.
3333+
esearch_result = extract_responses("ESEARCH") {|response|
3334+
response in ESearchResult(tag: ^tag)
3335+
}.last
31733336
search_result = clear_responses("SEARCH").last
3174-
if search_result
3337+
if esearch_result
3338+
# silently ignore SEARCH results, if any
3339+
esearch_result
3340+
elsif search_result
3341+
# warn EXPECTED_ESEARCH_RESULT if esearch
31753342
search_result
3343+
elsif esearch
3344+
# warn NO_SEARCH_RESPONSE
3345+
ESearchResult[tag:, uid: cmd.start_with?("UID ")]
31763346
else
31773347
# warn NO_SEARCH_RESPONSE
31783348
SearchResult[]

0 commit comments

Comments
 (0)