Skip to content

Commit 5f39345

Browse files
committed
✨ CONDSTORE: return SearchResult with #modseq
Previously, MODSEQ was parsed but ignored. SearchResult inherits from Array, to preserve backwards compatibility.
1 parent b370314 commit 5f39345

File tree

6 files changed

+294
-26
lines changed

6 files changed

+294
-26
lines changed

lib/net/imap.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,10 @@ module Net
505505
# ==== RFC7162: +CONDSTORE+
506506
#
507507
# - Updates #status with the +HIGHESTMODSEQ+ status attribute.
508+
# - Updates #search, #uid_search, #sort, and #uid_sort with the +MODSEQ+
509+
# search criterion, and adds SearchResult#modseq to the search response.
510+
# - Updates #thread and #uid_thread with the +MODSEQ+ search criterion
511+
# <em>(but thread responses are unchanged)</em>.
508512
#
509513
# ==== RFC8438: <tt>STATUS=SIZE</tt>
510514
# - Updates #status with the +SIZE+ status attribute.
@@ -1887,6 +1891,10 @@ def uid_expunge(uid_set)
18871891
# string holding the entire search string, or a single-dimension array of
18881892
# search keywords and arguments.
18891893
#
1894+
# Returns a SearchResult object. SearchResult inherits from Array (for
1895+
# backward compatibility) but adds SearchResult#modseq when the +CONDSTORE+
1896+
# capability has been enabled.
1897+
#
18901898
# Related: #uid_search
18911899
#
18921900
# ===== Search criteria
@@ -1935,6 +1943,15 @@ def uid_expunge(uid_set)
19351943
# p imap.search(["SUBJECT", "hello", "NOT", "NEW"])
19361944
# #=> [1, 6, 7, 8]
19371945
#
1946+
# ===== Capabilities
1947+
#
1948+
# If [CONDSTORE[https://www.rfc-editor.org/rfc/rfc7162.html]] is supported
1949+
# and enabled for the selected mailbox, a non-empty SearchResult will
1950+
# include a +MODSEQ+ value.
1951+
# imap.select("mbox", condstore: true)
1952+
# result = imap.search(["SUBJECT", "hi there", "not", "new")
1953+
# #=> Net::IMAP::SearchResult[1, 6, 7, 8, modseq: 5594]
1954+
# result.modseq # => 5594
19381955
def search(keys, charset = nil)
19391956
return search_internal("SEARCH", keys, charset)
19401957
end
@@ -1943,6 +1960,10 @@ def search(keys, charset = nil)
19431960
# to search the mailbox for messages that match the given searching
19441961
# criteria, and returns unique identifiers (<tt>UID</tt>s).
19451962
#
1963+
# Returns a SearchResult object. SearchResult inherits from Array (for
1964+
# backward compatibility) but adds SearchResult#modseq when the +CONDSTORE+
1965+
# capability has been enabled.
1966+
#
19461967
# See #search for documentation of search criteria.
19471968
def uid_search(keys, charset = nil)
19481969
return search_internal("UID SEARCH", keys, charset)

lib/net/imap/response_data.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
module Net
44
class IMAP < Protocol
55
autoload :FetchData, "#{__dir__}/fetch_data"
6+
autoload :SearchResult, "#{__dir__}/search_result"
67
autoload :SequenceSet, "#{__dir__}/sequence_set"
78

89
# Net::IMAP::ContinuationRequest represents command continuation requests.

lib/net/imap/response_parser.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1467,9 +1467,10 @@ def mailbox_data__search
14671467
while _ = SP? && nz_number? do data << _ end
14681468
if lpar?
14691469
label("MODSEQ"); SP!
1470-
mod_sequence_value
1470+
modseq = mod_sequence_value
14711471
rpar
14721472
end
1473+
data = SearchResult.new(data, modseq: modseq)
14731474
UntaggedResponse.new(name, data, @str)
14741475
end
14751476
alias sort_data mailbox_data__search

lib/net/imap/search_result.rb

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP
5+
6+
# An array of sequence numbers returned by Net::IMAP#search, or unique
7+
# identifiers returned by Net::IMAP#uid_search.
8+
#
9+
# For backward compatibility, SearchResult inherits from Array.
10+
class SearchResult < Array
11+
12+
# Returns a frozen SearchResult populated with the given +seq_nums+.
13+
#
14+
# Net::IMAP::SearchResult[1, 3, 5, modseq: 9]
15+
# # => Net::IMAP::SearchResult[1, 3, 5, modseq: 9]
16+
def self.[](*seq_nums, modseq: nil)
17+
new(seq_nums, modseq: modseq)
18+
end
19+
20+
# A modification sequence number, as described by the +CONDSTORE+
21+
# extension in {[RFC7162
22+
# §3.1.6]}[https://www.rfc-editor.org/rfc/rfc7162.html#section-3.1.6].
23+
attr_reader :modseq
24+
25+
# Returns a frozen SearchResult populated with the given +seq_nums+.
26+
#
27+
# Net::IMAP::SearchResult.new([1, 3, 5], modseq: 9)
28+
# # => Net::IMAP::SearchResult[1, 3, 5, modseq: 9]
29+
def initialize(seq_nums, modseq: nil)
30+
super(seq_nums.to_ary.map { Integer _1 })
31+
@modseq = Integer modseq if modseq
32+
freeze
33+
end
34+
35+
# Returns a frozen copy of +other+.
36+
def initialize_copy(other); super; freeze end
37+
38+
# Returns whether +other+ is a SearchResult with the same values and the
39+
# same #modseq. The order of numbers is irrelevant.
40+
#
41+
# Net::IMAP::SearchResult[123, 456, modseq: 789] ==
42+
# Net::IMAP::SearchResult[123, 456, modseq: 789]
43+
# # => true
44+
# Net::IMAP::SearchResult[123, 456, modseq: 789] ==
45+
# Net::IMAP::SearchResult[456, 123, modseq: 789]
46+
# # => true
47+
#
48+
# Net::IMAP::SearchResult[123, 456, modseq: 789] ==
49+
# Net::IMAP::SearchResult[987, 654, modseq: 789]
50+
# # => false
51+
# Net::IMAP::SearchResult[123, 456, modseq: 789] ==
52+
# Net::IMAP::SearchResult[1, 2, 3, modseq: 9999]
53+
# # => false
54+
#
55+
# SearchResult can be compared directly with Array, if #modseq is nil and
56+
# the array is sorted.
57+
#
58+
# Net::IMAP::SearchResult[9, 8, 6, 4, 1] == [1, 4, 6, 8, 9] # => true
59+
# Net::IMAP::SearchResult[3, 5, 7, modseq: 99] == [3, 5, 7] # => false
60+
#
61+
# Note that Array#== does require matching order and ignores #modseq.
62+
#
63+
# [9, 8, 6, 4, 1] == Net::IMAP::SearchResult[1, 4, 6, 8, 9] # => false
64+
# [3, 5, 7] == Net::IMAP::SearchResult[3, 5, 7, modseq: 99] # => true
65+
#
66+
def ==(other)
67+
(modseq ?
68+
other.is_a?(self.class) && modseq == other.modseq :
69+
other.is_a?(Array)) &&
70+
size == other.size &&
71+
sort == other.sort
72+
end
73+
74+
# Hash equality. Unlike #==, order will be taken into account.
75+
def hash
76+
return super if modseq.nil?
77+
[super, self.class, modseq].hash
78+
end
79+
80+
# Hash equality. Unlike #==, order will be taken into account.
81+
def eql?(other)
82+
return super if modseq.nil?
83+
self.class == other.class && hash == other.hash
84+
end
85+
86+
# Returns a string that represents the SearchResult.
87+
#
88+
# Net::IMAP::SearchResult[123, 456, 789].inspect
89+
# # => "[123, 456, 789]"
90+
#
91+
# Net::IMAP::SearchResult[543, 210, 678, modseq: 2048].inspect
92+
# # => "Net::IMAP::SearchResult[543, 210, 678, modseq: 2048]"
93+
#
94+
def inspect
95+
return super if modseq.nil?
96+
"%s[%s, modseq: %p]" % [self.class, join(", "), modseq]
97+
end
98+
99+
# Returns a string that follows the formal \IMAP syntax.
100+
#
101+
# data = Net::IMAP::SearchResult[2, 8, 32, 128, 256, 512]
102+
# data.to_s # => "* SEARCH 2 8 32 128 256 512"
103+
# data.to_s("SEARCH") # => "* SEARCH 2 8 32 128 256 512"
104+
# data.to_s("SORT") # => "* SORT 2 8 32 128 256 512"
105+
# data.to_s(nil) # => "2 8 32 128 256 512"
106+
#
107+
# data = Net::IMAP::SearchResult[1, 3, 16, 1024, modseq: 2048].to_s
108+
# data.to_s # => "* SEARCH 1 3 16 1024 (MODSEQ 2048)"
109+
# data.to_s("SORT") # => "* SORT 1 3 16 1024 (MODSEQ 2048)"
110+
# data.to_s # => "1 3 16 1024 (MODSEQ 2048)"
111+
#
112+
def to_s(type = "SEARCH")
113+
str = +""
114+
str << "* %s " % [type.to_str] unless type.nil?
115+
str << join(" ")
116+
str << " (MODSEQ %d)" % [modseq] if modseq
117+
-str
118+
end
119+
120+
# Converts the SearchResult into a SequenceSet.
121+
#
122+
# Net::IMAP::SearchResult[9, 1, 2, 4, 10, 12, 3, modseq: 123_456]
123+
# .to_sequence_set
124+
# # => Net::IMAP::SequenceSet["1:4,9:10,12"]
125+
def to_sequence_set; SequenceSet[*self] end
126+
127+
def pretty_print(pp)
128+
return super if modseq.nil?
129+
pp.text self.class.name + "["
130+
pp.group_sub do
131+
pp.nest(2) do
132+
pp.breakable ""
133+
each do |num|
134+
pp.pp num
135+
pp.text ","
136+
pp.fill_breakable
137+
end
138+
pp.breakable ""
139+
pp.text "modseq: "
140+
pp.pp modseq
141+
end
142+
pp.breakable ""
143+
pp.text "]"
144+
end
145+
end
146+
147+
end
148+
149+
end
150+
end

test/net/imap/fixtures/response_parser/search_responses.yml

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,61 +17,60 @@
1717
:response: "* SEARCH\r\n"
1818
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
1919
name: SEARCH
20-
data: []
21-
# data: !ruby/array:Net::IMAP::SearchResult
22-
# internal: []
23-
# ivars:
24-
# "@modseq":
20+
data: !ruby/array:Net::IMAP::SearchResult
21+
internal: []
22+
ivars:
23+
"@modseq":
2524
raw_data: "* SEARCH\r\n"
2625

2726
test_search_response_single_seq_nums_returned:
2827
:response: "* SEARCH 1\r\n"
2928
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
3029
name: SEARCH
31-
data: # !ruby/array:Net::IMAP::SearchResult
32-
# internal:
30+
data: !ruby/array:Net::IMAP::SearchResult
31+
internal:
3332
- 1
34-
# ivars:
35-
# "@modseq":
33+
ivars:
34+
"@modseq":
3635
raw_data: "* SEARCH 1\r\n"
3736

3837
test_search_response_multiple_seq_nums_returned:
3938
:response: "* SEARCH 1 2 3\r\n"
4039
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
4140
name: SEARCH
42-
data: # !ruby/array:Net::IMAP::SearchResult
43-
# internal:
41+
data: !ruby/array:Net::IMAP::SearchResult
42+
internal:
4443
- 1
4544
- 2
4645
- 3
47-
# ivars:
48-
# "@modseq":
46+
ivars:
47+
"@modseq":
4948
raw_data: "* SEARCH 1 2 3\r\n"
5049

5150
test_invalid_search_response_single_result_with_trailing_space:
5251
:quirky_servers: Yahoo
5352
:response: "* SEARCH 1 \r\n"
5453
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
5554
name: SEARCH
56-
data: # !ruby/array:Net::IMAP::SearchResult
57-
# internal:
55+
data: !ruby/array:Net::IMAP::SearchResult
56+
internal:
5857
- 1
59-
# ivars:
60-
# "@modseq":
58+
ivars:
59+
"@modseq":
6160
raw_data: "* SEARCH 1 \r\n"
6261

6362
test_invalid_search_response_multiple_result_with_trailing_space:
6463
:quirky_servers: Yahoo
6564
:response: "* SEARCH 1 2 3 \r\n"
6665
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
6766
name: SEARCH
68-
data: # !ruby/array:Net::IMAP::SearchResult
69-
# internal:
67+
data: !ruby/array:Net::IMAP::SearchResult
68+
internal:
7069
- 1
7170
- 2
7271
- 3
73-
# ivars:
74-
# "@modseq":
72+
ivars:
73+
"@modseq":
7574
raw_data: "* SEARCH 1 2 3 \r\n"
7675

7776
test_search_response_with_condstore_modseq:
@@ -80,10 +79,10 @@
8079
:response: "* SEARCH 87216 87221 (MODSEQ 7667567)\r\n"
8180
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
8281
name: SEARCH
83-
data: # !ruby/array:Net::IMAP::SearchResult
84-
# internal:
82+
data: !ruby/array:Net::IMAP::SearchResult
83+
internal:
8584
- 87216
8685
- 87221
87-
# ivars:
88-
# "@modseq": 7667567
86+
ivars:
87+
"@modseq": 7667567
8988
raw_data: "* SEARCH 87216 87221 (MODSEQ 7667567)\r\n"

0 commit comments

Comments
 (0)