diff --git a/lib/net/imap.rb b/lib/net/imap.rb
index 3534b573..37fe6c69 100644
--- a/lib/net/imap.rb
+++ b/lib/net/imap.rb
@@ -1889,48 +1889,64 @@ def unselect
send_command("UNSELECT")
end
+ # call-seq:
+ # expunge -> array of message sequence numbers
+ # expunge -> VanishedData of UIDs
+ #
# Sends an {EXPUNGE command [IMAP4rev1 §6.4.3]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.4.3]
- # Sends a EXPUNGE command to permanently remove from the currently
- # selected mailbox all messages that have the \Deleted flag set.
+ # to permanently remove all messages with the +\Deleted+ flag from the
+ # currently selected mailbox.
+ #
+ # Returns either an array of expunged message sequence numbers or
+ # (when the appropriate capability is enabled) VanishedData of expunged
+ # UIDs. Previously unhandled +EXPUNGE+ or +VANISHED+ responses are merged
+ # with the direct response to this command. VANISHED (EARLIER)
+ # responses will _not_ be merged.
+ #
+ # When no messages have been expunged, an empty array is returned,
+ # regardless of which extensions are enabled. In a future release, an empty
+ # VanishedData may be returned, based on the currently enabled extensions.
#
# Related: #uid_expunge
+ #
+ # ==== Capabilities
+ #
+ # When either QRESYNC[https://tools.ietf.org/html/rfc7162] or
+ # UIDONLY[https://tools.ietf.org/html/rfc9586] are enabled, #expunge
+ # returns VanishedData, which contains UIDs---not message sequence
+ # numbers.
def expunge
- synchronize do
- send_command("EXPUNGE")
- clear_responses("EXPUNGE")
- end
+ expunge_internal("EXPUNGE")
end
+ # call-seq:
+ # uid_expunge{uid_set) -> array of message sequence numbers
+ # uid_expunge{uid_set) -> VanishedData of UIDs
+ #
# Sends a {UID EXPUNGE command [RFC4315 §2.1]}[https://www.rfc-editor.org/rfc/rfc4315#section-2.1]
# {[IMAP4rev2 §6.4.9]}[https://www.rfc-editor.org/rfc/rfc9051#section-6.4.9]
# to permanently remove all messages that have both the \\Deleted
# flag set and a UID that is included in +uid_set+.
#
+ # Returns the same result type as #expunge.
+ #
# By using #uid_expunge instead of #expunge when resynchronizing with
# the server, the client can ensure that it does not inadvertantly
# remove any messages that have been marked as \\Deleted by other
# clients between the time that the client was last connected and
# the time the client resynchronizes.
#
- # *Note:*
- # >>>
- # Although the command takes a set of UIDs for its argument, the
- # server still returns regular EXPUNGE responses, which contain
- # a sequence number. These will be deleted from
- # #responses and this method returns them as an array of
- # sequence number integers.
- #
# Related: #expunge
#
# ==== Capabilities
#
- # The server's capabilities must include +UIDPLUS+
+ # The server's capabilities must include either +IMAP4rev2+ or +UIDPLUS+
# [RFC4315[https://www.rfc-editor.org/rfc/rfc4315.html]].
+ #
+ # Otherwise, #uid_expunge is updated by extensions in the same way as
+ # #expunge.
def uid_expunge(uid_set)
- synchronize do
- send_command("UID EXPUNGE", SequenceSet.new(uid_set))
- clear_responses("EXPUNGE")
- end
+ expunge_internal("UID EXPUNGE", SequenceSet.new(uid_set))
end
# :call-seq:
@@ -3261,6 +3277,22 @@ def enforce_logindisabled?
end
end
+ def expunge_internal(...)
+ synchronize do
+ send_command(...)
+ expunged_array = clear_responses("EXPUNGE")
+ vanished_array = extract_responses("VANISHED") { !_1.earlier? }
+ if vanished_array.empty?
+ expunged_array
+ elsif vanished_array.length == 1
+ vanished_array.first
+ else
+ merged_uids = SequenceSet[*vanished_array.map(&:uids)]
+ VanishedData[uids: merged_uids, earlier: false]
+ end
+ end
+ end
+
RETURN_WHOLE = /\ARETURN\z/i
RETURN_START = /\ARETURN\b/i
private_constant :RETURN_WHOLE, :RETURN_START
diff --git a/lib/net/imap/response_data.rb b/lib/net/imap/response_data.rb
index 6b50cf81..bc33afe3 100644
--- a/lib/net/imap/response_data.rb
+++ b/lib/net/imap/response_data.rb
@@ -6,6 +6,7 @@ class IMAP < Protocol
autoload :FetchData, "#{__dir__}/fetch_data"
autoload :SearchResult, "#{__dir__}/search_result"
autoload :SequenceSet, "#{__dir__}/sequence_set"
+ autoload :VanishedData, "#{__dir__}/vanished_data"
# Net::IMAP::ContinuationRequest represents command continuation requests.
#
diff --git a/lib/net/imap/response_parser.rb b/lib/net/imap/response_parser.rb
index b55b8d67..04c084a5 100644
--- a/lib/net/imap/response_parser.rb
+++ b/lib/net/imap/response_parser.rb
@@ -769,7 +769,6 @@ def remaining_unparsed
def response_data__ignored; response_data__unhandled(IgnoredResponse) end
alias response_data__noop response_data__ignored
- alias expunged_resp response_data__unhandled
alias uidfetch_resp response_data__unhandled
alias listrights_data response_data__unhandled
alias myrights_data response_data__unhandled
@@ -841,6 +840,20 @@ def response_data__simple_numeric
alias mailbox_data__exists response_data__simple_numeric
alias mailbox_data__recent response_data__simple_numeric
+ # The name for this is confusing, because it *replaces* EXPUNGE
+ # >>>
+ # expunged-resp = "VANISHED" [SP "(EARLIER)"] SP known-uids
+ def expunged_resp
+ name = label "VANISHED"; SP!
+ earlier = if lpar? then label("EARLIER"); rpar; SP!; true else false end
+ uids = known_uids
+ data = VanishedData[uids, earlier]
+ UntaggedResponse.new name, data, @str
+ end
+
+ # TODO: replace with uid_set
+ alias known_uids sequence_set
+
# RFC3501 & RFC9051:
# msg-att = "(" (msg-att-dynamic / msg-att-static)
# *(SP (msg-att-dynamic / msg-att-static)) ")"
diff --git a/lib/net/imap/vanished_data.rb b/lib/net/imap/vanished_data.rb
new file mode 100644
index 00000000..68690fa1
--- /dev/null
+++ b/lib/net/imap/vanished_data.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Net
+ class IMAP < Protocol
+
+ # Net::IMAP::VanishedData represents the contents of a +VANISHED+ response,
+ # which is described by the
+ # {QRESYNC}[https://www.rfc-editor.org/rfc/rfc7162.html] extension.
+ # [{RFC7162 §3.2.10}[https://www.rfc-editor.org/rfc/rfc7162.html#section-3.2.10]].
+ #
+ # +VANISHED+ responses replace +EXPUNGE+ responses when either the
+ # {QRESYNC}[https://www.rfc-editor.org/rfc/rfc7162.html] or the
+ # {UIDONLY}[https://www.rfc-editor.org/rfc/rfc9586.html] extension has been
+ # enabled.
+ class VanishedData < Data.define(:uids, :earlier)
+
+ # Returns a new VanishedData object.
+ #
+ # * +uids+ will be converted by SequenceSet.[].
+ # * +earlier+ will be converted to +true+ or +false+
+ def initialize(uids:, earlier:)
+ uids = SequenceSet[uids]
+ earlier = !!earlier
+ super
+ end
+
+ ##
+ # :attr_reader: uids
+ #
+ # SequenceSet of UIDs that have been permanently removed from the mailbox.
+
+ ##
+ # :attr_reader: earlier
+ #
+ # +true+ when the response was caused by Net::IMAP#uid_fetch with
+ # vanished: true or Net::IMAP#select/Net::IMAP#examine with
+ # qresync: true.
+ #
+ # +false+ when the response is used to announce message removals within an
+ # already selected mailbox.
+
+ # rdoc doesn't handle attr aliases nicely. :(
+ alias earlier? earlier # :nodoc:
+ ##
+ # :attr_reader: earlier?
+ #
+ # Alias for #earlier.
+
+ # Returns an Array of all of the UIDs in #uids.
+ #
+ # See SequenceSet#numbers.
+ def to_a; uids.numbers end
+
+ end
+ end
+end
diff --git a/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml b/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml
index b571c990..19fcd178 100644
--- a/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml
+++ b/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml
@@ -142,18 +142,38 @@
:response: "* VANISHED (EARLIER) 41,43:116,118,120:211,214:540\r\n"
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
name: VANISHED
- data: !ruby/struct:Net::IMAP::UnparsedData
- unparsed_data: "(EARLIER) 41,43:116,118,120:211,214:540"
+ data: !ruby/object:Net::IMAP::VanishedData
+ uids: !ruby/object:Net::IMAP::SequenceSet
+ string: 41,43:116,118,120:211,214:540
+ tuples:
+ - - 41
+ - 41
+ - - 43
+ - 116
+ - - 118
+ - 118
+ - - 120
+ - 211
+ - - 214
+ - 540
+ earlier: true
raw_data: "* VANISHED (EARLIER) 41,43:116,118,120:211,214:540\r\n"
- comment: |
- Note that QRESYNC isn't supported yet, so the data is unparsed.
"RFC7162 QRESYNC 3.2.7. EXPUNGE Command":
:response: "* VANISHED 405,407,410,425\r\n"
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
name: VANISHED
- data: !ruby/struct:Net::IMAP::UnparsedData
- unparsed_data: '405,407,410,425'
+ data: !ruby/object:Net::IMAP::VanishedData
+ uids: !ruby/object:Net::IMAP::SequenceSet
+ string: '405,407,410,425'
+ tuples:
+ - - 405
+ - 405
+ - - 407
+ - 407
+ - - 410
+ - 410
+ - - 425
+ - 425
+ earlier: false
raw_data: "* VANISHED 405,407,410,425\r\n"
- comment: |
- Note that QRESYNC isn't supported yet, so the data is unparsed.
diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb
index 73e630f2..25dfc013 100644
--- a/test/net/imap/test_imap.rb
+++ b/test/net/imap/test_imap.rb
@@ -1016,7 +1016,7 @@ def test_id
end
end
- def test_uidplus_uid_expunge
+ test "#uid_expunge with EXPUNGE responses" do
with_fake_server(select: "INBOX",
extensions: %i[UIDPLUS]) do |server, imap|
server.on "UID EXPUNGE" do |resp|
@@ -1032,6 +1032,24 @@ def test_uidplus_uid_expunge
end
end
+ test "#uid_expunge with VANISHED response" do
+ with_fake_server(select: "INBOX",
+ extensions: %i[UIDPLUS]) do |server, imap|
+ server.on "UID EXPUNGE" do |resp|
+ resp.untagged("VANISHED 1001,1003")
+ resp.done_ok
+ end
+ response = imap.uid_expunge(1000..1003)
+ cmd = server.commands.pop
+ assert_equal ["UID EXPUNGE", "1000:1003"], [cmd.name, cmd.args]
+ assert_equal(
+ Net::IMAP::VanishedData[uids: [1001, 1003], earlier: false],
+ response
+ )
+ assert_equal([], imap.clear_responses("VANISHED"))
+ end
+ end
+
def test_uidplus_appenduid
with_fake_server(select: "INBOX",
extensions: %i[UIDPLUS]) do |server, imap|
@@ -1168,6 +1186,65 @@ def test_enable
end
end
+ test "#expunge with EXPUNGE responses" do
+ with_fake_server(select: "INBOX") do |server, imap|
+ server.on "EXPUNGE" do |resp|
+ resp.untagged("1 EXPUNGE")
+ resp.untagged("1 EXPUNGE")
+ resp.untagged("99 EXPUNGE")
+ resp.done_ok
+ end
+ response = imap.expunge
+ cmd = server.commands.pop
+ assert_equal ["EXPUNGE", nil], [cmd.name, cmd.args]
+ assert_equal [1, 1, 99], response
+ assert_equal [], imap.clear_responses("EXPUNGED")
+ end
+ end
+
+ test "#expunge with a VANISHED response" do
+ with_fake_server(select: "INBOX") do |server, imap|
+ server.on "EXPUNGE" do |resp|
+ resp.untagged("VANISHED 15:456")
+ resp.done_ok
+ end
+ response = imap.expunge
+ cmd = server.commands.pop
+ assert_equal ["EXPUNGE", nil], [cmd.name, cmd.args]
+ assert_equal(
+ Net::IMAP::VanishedData[uids: [15..456], earlier: false],
+ response
+ )
+ assert_equal([], imap.clear_responses("VANISHED"))
+ end
+ end
+
+ test "#expunge with multiple VANISHED responses" do
+ with_fake_server(select: "INBOX") do |server, imap|
+ server.unsolicited("VANISHED 86")
+ server.on "EXPUNGE" do |resp|
+ resp.untagged("VANISHED (EARLIER) 1:5,99,123")
+ resp.untagged("VANISHED 15,456")
+ resp.untagged("VANISHED (EARLIER) 987,1001")
+ resp.done_ok
+ end
+ response = imap.expunge
+ cmd = server.commands.pop
+ assert_equal ["EXPUNGE", nil], [cmd.name, cmd.args]
+ assert_equal(
+ Net::IMAP::VanishedData[uids: [15, 86, 456], earlier: false],
+ response
+ )
+ assert_equal(
+ [
+ Net::IMAP::VanishedData[uids: [1..5, 99, 123], earlier: true],
+ Net::IMAP::VanishedData[uids: [987, 1001], earlier: true],
+ ],
+ imap.clear_responses("VANISHED")
+ )
+ end
+ end
+
def test_close
with_fake_server(select: "inbox") do |server, imap|
resp = imap.close
diff --git a/test/net/imap/test_vanished_data.rb b/test/net/imap/test_vanished_data.rb
new file mode 100644
index 00000000..4f8cac3e
--- /dev/null
+++ b/test/net/imap/test_vanished_data.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require "net/imap"
+require "test/unit"
+
+class VanishedDataTest < Test::Unit::TestCase
+ VanishedData = Net::IMAP::VanishedData
+ SequenceSet = Net::IMAP::SequenceSet
+ DataFormatError = Net::IMAP::DataFormatError
+
+ test ".new(uids: string, earlier: bool)" do
+ vanished = VanishedData.new(uids: "1,3:5,7", earlier: true)
+ assert_equal SequenceSet["1,3:5,7"], vanished.uids
+ assert vanished.earlier?
+ vanished = VanishedData.new(uids: "99,111", earlier: false)
+ assert_equal SequenceSet["99,111"], vanished.uids
+ refute vanished.earlier?
+ end
+
+ test ".new, missing args raises ArgumentError" do
+ assert_raise ArgumentError do VanishedData.new end
+ assert_raise ArgumentError do VanishedData.new "1234" end
+ assert_raise ArgumentError do VanishedData.new uids: "1234" end
+ assert_raise ArgumentError do VanishedData.new earlier: true end
+ end
+
+ test ".new, nil uids raises DataFormatError" do
+ assert_raise DataFormatError do VanishedData.new uids: nil, earlier: true end
+ assert_raise DataFormatError do VanishedData.new nil, true end
+ end
+
+ test ".[uids: string, earlier: bool]" do
+ vanished = VanishedData[uids: "1,3:5,7", earlier: true]
+ assert_equal SequenceSet["1,3:5,7"], vanished.uids
+ assert vanished.earlier?
+ vanished = VanishedData[uids: "99,111", earlier: false]
+ assert_equal SequenceSet["99,111"], vanished.uids
+ refute vanished.earlier?
+ end
+
+ test ".[uids, earlier]" do
+ vanished = VanishedData["1,3:5,7", true]
+ assert_equal SequenceSet["1,3:5,7"], vanished.uids
+ assert vanished.earlier?
+ vanished = VanishedData["99,111", false]
+ assert_equal SequenceSet["99,111"], vanished.uids
+ refute vanished.earlier?
+ end
+
+ test ".[], mixing args raises ArgumentError" do
+ assert_raise ArgumentError do
+ VanishedData[1, true, uids: "1", earlier: true]
+ end
+ assert_raise ArgumentError do VanishedData["1234", earlier: true] end
+ assert_raise ArgumentError do VanishedData[nil, true, uids: "1"] end
+ end
+
+ test ".[], missing args raises ArgumentError" do
+ assert_raise ArgumentError do VanishedData[] end
+ assert_raise ArgumentError do VanishedData["1234"] end
+ end
+
+ test ".[], nil uids raises DataFormatError" do
+ assert_raise DataFormatError do VanishedData[nil, true] end
+ assert_raise DataFormatError do VanishedData[nil, nil] end
+ end
+
+ test "#to_a delegates to uids (SequenceSet#to_a)" do
+ assert_equal [1, 2, 3, 4], VanishedData["1:4", true].to_a
+ end
+
+ test "#deconstruct_keys returns uids and earlier" do
+ assert_equal({uids: SequenceSet[1,9], earlier: true},
+ VanishedData["1,9", true].deconstruct_keys([:uids, :earlier]))
+ VanishedData["1:5", false] => VanishedData[uids: SequenceSet, earlier: false]
+ end
+
+ test "#==" do
+ assert_equal VanishedData[123, false], VanishedData["123", false]
+ assert_equal VanishedData["3:1", false], VanishedData["1:3", false]
+ end
+
+ test "#eql?" do
+ assert VanishedData["1:3", false].eql?(VanishedData[1..3, false])
+ refute VanishedData["3:1", false].eql?(VanishedData["1:3", false])
+ refute VanishedData["1:5", false].eql?(VanishedData["1:3", false])
+ refute VanishedData["1:3", true].eql?(VanishedData["1:3", false])
+ end
+
+end