Skip to content

Commit

Permalink
✨ Add support for CONTEXT=SEARCH (RFC5267) [🚧 WIP...]
Browse files Browse the repository at this point in the history
  • Loading branch information
nevans committed Dec 19, 2024
1 parent 818a353 commit fd7326e
Show file tree
Hide file tree
Showing 5 changed files with 275 additions and 0 deletions.
106 changes: 106 additions & 0 deletions lib/net/imap/esearch_result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,112 @@ 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

# Superclass for AddToContext and RemoveFromContext, returned by
# ESearchResult#addto, ESearchResult#removefrom, and
# ESearchResult#updates.
#
# Use the +UPDATE+ search +return+ option to request update notifications.
# Update notifications are sent after the searching command completes, as
# and when the search results change.
#
# Requires <tt>CONTEXT=SEARCH</tt>/<tt>CONTEXT=SORT</tt>
# {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html]
class ContextUpdate < Data.define(:position, :set)
def initialize(position:, set:)
position = NumValidator.ensure_number(position)
set = SequenceSet[set]
super
end

##
# attr_reader: position
#
# The position in the updated search context where results will be
# inserted or removed, where the first position is one.
#
# When +position+ is zero, the results may be inserted or removed into
# the result list in mailbox order.

##
# attr_reader: set
#
# A SequenceSet of updates to the search context.

##
# Returns #set
def to_sequence_set; set end

# Converts #set to an array of numbers (UIDs or sequence numbers).
def to_a; set.numbers end

# :call-seq: update(context) -> updated context
#
# Given a SequenceSet +context+, returns a new SequenceSet, updated by
# +self+.
def update(context) update! context.dup end

# :call-seq: update!(context) -> updated context
#
# Modifies a SequenceSet +context+ by the updates in +self+.
# Implemented by subclasses.
def update!(context) raise "implemented by subclasses" end
end

# Returned by ESearchResult#addto and ESearchResult#updates.
#
# Requires <tt>CONTEXT=SEARCH</tt>/<tt>CONTEXT=SORT</tt>
# {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html]
class AddToContext < ContextUpdate
alias additions set

# Updates context by adding #additions.
#
# *NOTE:* positions other than zero are not currently supported.
def update!(context)
if position.zero?
context.merge additions
else
raise "Positions other than zero are not currently supported."
# TODO: context.insert additions
end
end
end

# Returned by ESearchResult#removefrom and ESearchResult#updates.
#
# Requires <tt>CONTEXT=SEARCH</tt>/<tt>CONTEXT=SORT</tt>
# {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html]
class RemoveFromContext < ContextUpdate
alias removals set

# Updates context by subtracting #additions.
#
# *NOTE:* positions other than zero are not currently supported.
def update!(context)
if position.zero?
context.subtract removals
else
raise "Positions other than zero are not currently supported."
# TODO: context.remove removals
end
end
end

# :call-seq: updates -> array of context updates, or nil
#
# Returns an array of ContextUpdate updates, which indicate additions to
# or removals from the result list for the command issued with #tag.
#
# Use the +UPDATE+ search +return+ option to request update notifications.
# Update notifications are sent after the searching command completes, as
# and when the search results change.
#
# Requires <tt>CONTEXT=SEARCH</tt> or <tt>CONTEXT=SORT</tt>
# {[RFC5267]}[https://www.rfc-editor.org/rfc/rfc5267.html]
def updates
data.flat_map { %w[ADDTO REMOVEFROM].include?(_1) ? _2 : [] }
end

# Returned by ESearchResult#partial.
#
# Requires +PARTIAL+ {[RFC9394]}[https://www.rfc-editor.org/rfc/rfc9394.html]
Expand Down
40 changes: 40 additions & 0 deletions lib/net/imap/response_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1531,6 +1531,10 @@ def esearch_response
# From RFC4731 (ESEARCH):
# search-return-data =/ "MODSEQ" SP mod-sequence-value
#
# From RFC5267 (CONTEXT=SEARCH, CONTEXT=SORT):
# search-return-data =/ ret-data-partial / ret-data-addto /
# ret-data-removefrom
#
# From RFC6203 (SEARCH=FUZZY):
# search-return-data =/ "RELEVANCY" SP score-list
#
Expand All @@ -1548,6 +1552,8 @@ def search_return_data
when "MODSEQ" then mod_sequence_value # RFC7162: CONDSTORE
when "RELEVANCY" then score_list # RFC6203: SEARCH=FUZZY
when "PARTIAL" then ret_data_partial__value # RFC9394: PARTIAL
when "ADDTO" then ret_data_addto__value # RFC5267: CONTEXT=*
when "REMOVEFROM" then ret_data_removefrom__value # RFC5267: CONTEXT=*
else search_return_value
end
[label, value]
Expand Down Expand Up @@ -1582,6 +1588,40 @@ def partial_range
# ;; the requested range.
def partial_results; NIL? ? nil : sequence_set end

# ret-data-addto = "ADDTO"
# SP "(" context-position SP sequence-set
# *(SP context-position SP sequence-set)
# ")"
def ret_data_addto__value
lpar; list = [ret_data_addto__item]
(SP!; list << ret_data_addto__item) until rpar?
list
end

def ret_data_addto__item
ESearchResult::AddToContext.new(context_position, (SP!; sequence_set))
end

# ret-data-removefrom = "REMOVEFROM"
# SP "(" context-position SP sequence-set
# *(SP context-position SP sequence-set)
# ")"
def ret_data_removefrom__value
lpar; list = [ret_data_removefrom__item]
(SP!; list << ret_data_removefrom__item) until rpar?
list
end

def ret_data_removefrom__item
ESearchResult::RemoveFromContext.new(context_position,
(SP!; sequence_set))
end

# context-position = number
# ;; Context position may be 0 for SEARCH result additions.
# ;; <number> from [IMAP]
alias context_position number

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

Expand Down
114 changes: 114 additions & 0 deletions test/net/imap/fixtures/response_parser/rfc5267_context_updates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
:tests:

"RFC5267 4.3.3. ADDTO Return Data Item example 1":
:response: "* ESEARCH (TAG \"B01\") UID ADDTO (0 32768:32769)\r\n"
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
name: ESEARCH
data: !ruby/object:Net::IMAP::ESearchResult
tag: B01
uid: true
data:
- - ADDTO
- - !ruby/object:Net::IMAP::ESearchResult::AddToContext
position: 0
set: !ruby/object:Net::IMAP::SequenceSet
string: 32768:32769
tuples:
- - 32768
- 32769
raw_data: "* ESEARCH (TAG \"B01\") UID ADDTO (0 32768:32769)\r\n"

"RFC5267 4.3.3. ADDTO Return Data Item example 2":
:response: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2733 1 2732 1 2731)\r\n"
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
name: ESEARCH
data: !ruby/object:Net::IMAP::ESearchResult
tag: C01
uid: true
data:
- - ADDTO
- - !ruby/object:Net::IMAP::ESearchResult::AddToContext
position: 1
set: !ruby/object:Net::IMAP::SequenceSet
string: '2733'
tuples:
- - 2733
- 2733
- !ruby/object:Net::IMAP::ESearchResult::AddToContext
position: 1
set: !ruby/object:Net::IMAP::SequenceSet
string: '2732'
tuples:
- - 2732
- 2732
- !ruby/object:Net::IMAP::ESearchResult::AddToContext
position: 1
set: !ruby/object:Net::IMAP::SequenceSet
string: '2731'
tuples:
- - 2731
- 2731
raw_data: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2733 1 2732 1 2731)\r\n"

"RFC5267 4.3.3. ADDTO Return Data Item example 3":
:response: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2733) ADDTO (1 2731:2732)\r\n"
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
name: ESEARCH
data: !ruby/object:Net::IMAP::ESearchResult
tag: C01
uid: true
data:
- - ADDTO
- - !ruby/object:Net::IMAP::ESearchResult::AddToContext
position: 1
set: !ruby/object:Net::IMAP::SequenceSet
string: '2733'
tuples:
- - 2733
- 2733
- - ADDTO
- - !ruby/object:Net::IMAP::ESearchResult::AddToContext
position: 1
set: !ruby/object:Net::IMAP::SequenceSet
string: 2731:2732
tuples:
- - 2731
- 2732
raw_data: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2733) ADDTO (1 2731:2732)\r\n"

"RFC5267 4.3.3. ADDTO Return Data Item example 4":
:response: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2731:2733)\r\n"
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
name: ESEARCH
data: !ruby/object:Net::IMAP::ESearchResult
tag: C01
uid: true
data:
- - ADDTO
- - !ruby/object:Net::IMAP::ESearchResult::AddToContext
position: 1
set: !ruby/object:Net::IMAP::SequenceSet
string: 2731:2733
tuples:
- - 2731
- 2733
raw_data: "* ESEARCH (TAG \"C01\") UID ADDTO (1 2731:2733)\r\n"

"RFC5267 4.3.4. REMOVEFROM Return Data Item":
:response: "* ESEARCH (TAG \"B01\") UID REMOVEFROM (0 32768)\r\n"
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
name: ESEARCH
data: !ruby/object:Net::IMAP::ESearchResult
tag: B01
uid: true
data:
- - REMOVEFROM
- - !ruby/object:Net::IMAP::ESearchResult::RemoveFromContext
position: 0
set: !ruby/object:Net::IMAP::SequenceSet
string: '32768'
tuples:
- - 32768
- 32768
raw_data: "* ESEARCH (TAG \"B01\") UID REMOVEFROM (0 32768)\r\n"
12 changes: 12 additions & 0 deletions test/net/imap/test_esearch_result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,16 @@ class ESearchResultTest < Test::Unit::TestCase
assert_equal [3, 12, 23], esearch.relevancy
end

test "#updates returns both ADDTO and REMOVEFROM values (RFC5267: CONTEXT)" do
parser = Net::IMAP::ResponseParser.new
expected = [
ESearchResult::AddToContext.new(1, SequenceSet[2733]),
ESearchResult::RemoveFromContext.new(1, SequenceSet[2732]),
ESearchResult::AddToContext.new(1, SequenceSet[2731]),
]
assert_equal expected, parser.parse(
"* ESEARCH (TAG \"C01\") UID ADDTO (1 2733) REMOVEFROM (1 2732) ADDTO (1 2731)\r\n"
).data.updates
end

end
3 changes: 3 additions & 0 deletions test/net/imap/test_imap_response_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ def teardown
# RFC 5256: THREAD response
generate_tests_from fixture_file: "thread_responses.yml"

# RFC 5267: ADDTO and REMOVEFROM search return options
generate_tests_from fixture_file: "rfc5267_context_updates.yml"

# RFC6203: SEARCH=FUZZY extension (RELEVANCY search return option)
generate_tests_from fixture_file: "rfc6203_fuzzy_search.yml"

Expand Down

0 comments on commit fd7326e

Please sign in to comment.