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