diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 0c37be05..39deb0a5 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -530,6 +530,12 @@ module Net # See FetchData#emailid and FetchData#emailid. # - Updates #status with support for the +MAILBOXID+ status attribute. # + # ==== RFC9586: +UIDONLY+ + # - Updates #enable with +UIDONLY+ parameter. + # - Updates #uid_fetch and #uid_store to return +UIDFETCH+ response. + # - Updates #expunge and #uid_expunge to return +VANISHED+ response. + # - Prohibits use of message sequence numbers in responses or requests. + # # == References # # [{IMAP4rev1}[https://www.rfc-editor.org/rfc/rfc3501.html]]:: @@ -697,6 +703,11 @@ module Net # Gondwana, B., Ed., "IMAP Extension for Object Identifiers", # RFC 8474, DOI 10.17487/RFC8474, September 2018, # . + # [UIDONLY[https://www.rfc-editor.org/rfc/rfc9586.pdf]]:: + # Melnikov, A., Achuthan, A., Nagulakonda, V., Singh, A., and L. Alves, + # "\IMAP Extension for Using and Returning Unique Identifiers (UIDs) Only", + # RFC 9586, DOI 10.17487/RFC9586, May 2024, + # . # # === IANA registries # * {IMAP Capabilities}[http://www.iana.org/assignments/imap4-capabilities] @@ -1885,18 +1896,39 @@ 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. # # 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. + # + # *NOTE:* Any unhandled +VANISHED+ #responses without the +EARLIER+ modifier + # will be merged into the VanishedData and deleted from #responses. This is + # consistent with how Net::IMAP handles +EXPUNGE+ responses. Unhandled + # VANISHED (EARLIER) responses will _not_ be merged or returned. + # + # *NOTE:* When no messages are expunged, Net::IMAP currently returns an + # empty array, regardless of which extensions have been enabled. In the + # future, an empty VanishedData will be returned instead. def expunge - synchronize do - send_command("EXPUNGE") - clear_responses("EXPUNGE") - end + expunge_internal("EXPUNGE") end + # call-seq: + # uid_expunge -> array of message sequence numbers + # uid_expunge -> 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 @@ -1920,13 +1952,13 @@ def 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: @@ -2205,6 +2237,10 @@ def uid_expunge(uid_set) # result = imap.search(["SUBJECT", "hi there", "not", "new"]) # #=> Net::IMAP::SearchResult[1, 6, 7, 8, modseq: 5594] # result.modseq # => 5594 + # + # The +SEARCH+ command is prohibited when + # UIDONLY[https://www.rfc-editor.org/rfc/rfc9586.html] has been enabled. + # Use #uid_search instead. def search(...) search_internal("SEARCH", ...) end @@ -2221,6 +2257,15 @@ def search(...) # capability has been enabled. # # See #search for documentation of parameters. + # + # ===== Capabilities + # + # When UIDONLY[https://www.rfc-editor.org/rfc/rfc9586.html] is enabled, the + # search criterion is prohibited. Use +ALL+ or + # UID sequence-set instead. + # + # Otherwise, #uid_search is updated by extensions in the same way as + # #search. def uid_search(...) search_internal("UID SEARCH", ...) end @@ -2277,12 +2322,15 @@ def uid_search(...) # {[RFC7162]}[https://tools.ietf.org/html/rfc7162] in order to use the # +changedsince+ argument. Using +changedsince+ implicitly enables the # +CONDSTORE+ extension. + # + # When UIDONLY[https://www.rfc-editor.org/rfc/rfc9586.html] is enabled, the + # +FETCH+ command is prohibited. Use #uid_fetch instead. def fetch(set, attr, mod = nil, changedsince: nil) fetch_internal("FETCH", set, attr, mod, changedsince: changedsince) end # :call-seq: - # uid_fetch(set, attr, changedsince: nil) -> array of FetchData + # uid_fetch(set, attr, changedsince: nil) -> array of FetchData (or UIDFetchData) # # Sends a {UID FETCH command [IMAP4rev1 §6.4.8]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.4.8] # to retrieve data associated with a message in the mailbox. @@ -2298,7 +2346,12 @@ def fetch(set, attr, mod = nil, changedsince: nil) # Related: #fetch, FetchData # # ===== Capabilities - # Same as #fetch. + # + # When UIDONLY[https://www.rfc-editor.org/rfc/rfc9586.html] has been + # enabled, #uid_fetch must be used instead of #fetch, and UIDFetchData will + # be returned instead of FetchData. + # + # Otherwise, #uid_store is updated by extensions in the same way as #store. def uid_fetch(set, attr, mod = nil, changedsince: nil) fetch_internal("UID FETCH", set, attr, mod, changedsince: changedsince) end @@ -2346,12 +2399,16 @@ def uid_fetch(set, attr, mod = nil, changedsince: nil) # {[RFC7162]}[https://tools.ietf.org/html/rfc7162] in order to use the # +unchangedsince+ argument. Using +unchangedsince+ implicitly enables the # +CONDSTORE+ extension. + # + # The +STORE+ command is prohibited when + # UIDONLY[https://www.rfc-editor.org/rfc/rfc9586.html] has been enabled. + # Use #uid_store instead. def store(set, attr, flags, unchangedsince: nil) store_internal("STORE", set, attr, flags, unchangedsince: unchangedsince) end # :call-seq: - # uid_store(set, attr, value, unchangedsince: nil) -> array of FetchData + # uid_store(set, attr, value, unchangedsince: nil) -> array of FetchData (or UIDFetchData) # # Sends a {UID STORE command [IMAP4rev1 §6.4.8]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.4.8] # to alter data associated with messages in the mailbox, in particular their @@ -2363,7 +2420,12 @@ def store(set, attr, flags, unchangedsince: nil) # Related: #store # # ===== Capabilities - # Same as #store. + # + # When UIDONLY[https://www.rfc-editor.org/rfc/rfc9586.html] has been + # enabled, #uid_store must be used instead of #store, and UIDFetchData will + # be returned instead of FetchData. + # + # Otherwise, #uid_store is updated by extensions in the same way as #store. def uid_store(set, attr, flags, unchangedsince: nil) store_internal("UID STORE", set, attr, flags, unchangedsince: unchangedsince) end @@ -2382,6 +2444,9 @@ def uid_store(set, attr, flags, unchangedsince: nil) # with UIDPlusData. This will report the UIDVALIDITY of the destination # mailbox, the UID set of the source messages, and the assigned UID set of # the moved messages. + # + # When UIDONLY[https://www.rfc-editor.org/rfc/rfc9586.html] is enabled, the + # +COPY+ command is prohibited. Use #uid_copy instead. def copy(set, mailbox) copy_internal("COPY", set, mailbox) end @@ -2394,7 +2459,10 @@ def copy(set, mailbox) # # ===== Capabilities # - # +UIDPLUS+ affects #uid_copy the same way it affects #copy. + # When UIDONLY[https://www.rfc-editor.org/rfc/rfc9586.html] has been + # enabled, #uid_copy must be used instead of #copy. + # + # Otherwise, #uid_copy is updated by extensions in the same way as #copy. def uid_copy(set, mailbox) copy_internal("UID COPY", set, mailbox) end @@ -2418,6 +2486,8 @@ def uid_copy(set, mailbox) # mailbox, the UID set of the source messages, and the assigned UID set of # the moved messages. # + # When UIDONLY[https://www.rfc-editor.org/rfc/rfc9586.html] is enabled, the + # +MOVE+ command is prohibited. Use #uid_move instead. def move(set, mailbox) copy_internal("MOVE", set, mailbox) end @@ -2433,9 +2503,13 @@ def move(set, mailbox) # # ===== Capabilities # - # Same as #move: The server's capabilities must include +MOVE+ - # [RFC6851[https://tools.ietf.org/html/rfc6851]]. +UIDPLUS+ also affects - # #uid_move the same way it affects #move. + # The server's capabilities must include either +IMAP4rev2+ or +MOVE+ + # [RFC6851[https://tools.ietf.org/html/rfc6851]]. + # + # When UIDONLY[https://www.rfc-editor.org/rfc/rfc9586.html] has been + # enabled, #uid_move must be used instead of #move. + # + # Otherwise, #uid_move is updated by extensions in the same way as #move. def uid_move(set, mailbox) copy_internal("UID MOVE", set, mailbox) end @@ -2580,6 +2654,16 @@ def uid_thread(algorithm, search_keys, charset) # selected. For convenience, enable("UTF8=ONLY") is aliased to # enable("UTF8=ACCEPT"). # + # [+UIDONLY+ {[RFC9586]}[https://www.rfc-editor.org/rfc/rfc9586.pdf]] + # + # When UIDONLY is enabled, the #fetch, #store, #search, #copy, and #move + # commands are prohibited and result in a tagged BAD response. Clients + # should instead use uid_fetch, uid_store, uid_search, uid_copy, or + # uid_move, respectively. All +FETCH+ responses that would be returned are + # replaced by +UIDFETCH+ responses. All +EXPUNGED+ responses that would be + # returned are replaced by +VANISHED+ responses. The "" + # uid_search criterion is prohibited. + # # ===== Unsupported capabilities # # *Note:* Some extensions that use ENABLE permit the server to send syntax @@ -3150,6 +3234,21 @@ def enforce_logindisabled? end end + def expunge_internal(...) + synchronize do + send_command(...) + vanished_array = extract_responses("VANISHED") { !_1.earlier? } + if vanished_array.empty? + clear_responses("EXPUNGE") + 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 + def search_internal(cmd, keys, charset = nil, esearch: false) keys = normalize_searching_criteria(keys) args = charset ? ["CHARSET", charset, *keys] : keys @@ -3185,15 +3284,9 @@ def fetch_internal(cmd, set, attr, mod = nil, changedsince: nil) } end - synchronize do - clear_responses("FETCH") - if mod - send_command(cmd, SequenceSet.new(set), attr, mod) - else - send_command(cmd, SequenceSet.new(set), attr) - end - clear_responses("FETCH") - end + args = [cmd, SequenceSet.new(set), attr] + args << mod if mod + send_command_returning_fetch_results(*args) end def store_internal(cmd, set, attr, flags, unchangedsince: nil) @@ -3201,10 +3294,17 @@ def store_internal(cmd, set, attr, flags, unchangedsince: nil) args = [SequenceSet.new(set)] args << ["UNCHANGEDSINCE", Integer(unchangedsince)] if unchangedsince args << attr << flags + send_command_returning_fetch_results(cmd, *args) + end + + def send_command_returning_fetch_results(...) synchronize do clear_responses("FETCH") - send_command(cmd, *args) - clear_responses("FETCH") + clear_responses("UIDFETCH") + send_command(...) + fetches = clear_responses("FETCH") + uidfetches = clear_responses("UIDFETCH") + uidfetches.any? ? uidfetches : fetches end end diff --git a/lib/net/imap/fetch_data.rb b/lib/net/imap/fetch_data.rb index e11da82a..c6c13f41 100644 --- a/lib/net/imap/fetch_data.rb +++ b/lib/net/imap/fetch_data.rb @@ -3,9 +3,9 @@ module Net class IMAP < Protocol - # Net::IMAP::FetchData represents the contents of a FETCH response. - # Net::IMAP#fetch and Net::IMAP#uid_fetch both return an array of - # FetchData objects. + # Net::IMAP::FetchStruct is the superclass for FetchData and UIDFetchData. + # Net::IMAP#fetch, Net::IMAP#uid_fetch, Net::IMAP#store, and + # Net::IMAP#uid_store all return arrays of FetchStruct objects. # # === Fetch attributes # @@ -63,8 +63,7 @@ class IMAP < Protocol # * "X-GM-MSGID" --- unique message ID. Access via #attr. # * "X-GM-THRID" --- Thread ID. Access via #attr. # - # [Note:] - # >>> + # [NOTE:] # Additional static fields are defined in other \IMAP extensions, but # Net::IMAP can't parse them yet. # @@ -87,8 +86,7 @@ class IMAP < Protocol # extension]}[https://developers.google.com/gmail/imap/imap-extensions] # * "X-GM-LABELS" --- Gmail labels. Access via #attr. # - # [Note:] - # >>> + # [NOTE:] # Additional dynamic fields are defined in other \IMAP extensions, but # Net::IMAP can't parse them yet. # @@ -100,33 +98,11 @@ class IMAP < Protocol # BODY.PEEK[#{section}] or BINARY.PEEK[#{section}] # instead. # - # Note that the data will always be _returned_ without ".PEEK", in - # BODY[#{specifier}] or BINARY[#{section}]. + # [NOTE:] + # The data will always be _returned_ without the ".PEEK" suffix, + # as BODY[#{specifier}] or BINARY[#{section}]. # - class FetchData < Struct.new(:seqno, :attr) - ## - # method: seqno - # :call-seq: seqno -> Integer - # - # The message sequence number. - # - # [Note] - # This is never the unique identifier (UID), not even for the - # Net::IMAP#uid_fetch result. The UID is available from #uid, if it was - # returned. - - ## - # method: attr - # :call-seq: attr -> hash - # - # Each key specifies a message attribute, and the value is the - # corresponding data item. Standard data items have corresponding - # accessor methods. The definitions of each attribute type is documented - # on its accessor. - # - # >>> - # *Note:* #seqno is not a message attribute. - + class FetchStruct < Struct # :call-seq: attr_upcase -> hash # # A transformation of #attr, with all the keys converted to upper case. @@ -142,7 +118,7 @@ def attr_upcase; attr.transform_keys(&:upcase) end # # This is the same as getting the value for "BODY" from #attr. # - # [Note] + # [NOTE:] # Use #message, #part, #header, #header_fields, #header_fields_not, # #text, or #mime to retrieve BODY[#{section_spec}] attributes. def body; attr["BODY"] end @@ -235,7 +211,7 @@ def header(*part_nums, fields: nil, except: nil, offset: nil) fields && except and raise ArgumentError, "conflicting 'fields' and 'except' arguments" if fields - text = "HEADER.FIELDS (%s)" % [fields.join(" ").upcase] + text = "HEADER.FIELDS (%s)" % [fields.join(" ").upcase] attr_upcase[body_section_attr(part_nums, text, offset: offset)] elsif except text = "HEADER.FIELDS.NOT (%s)" % [except.join(" ").upcase] @@ -308,6 +284,7 @@ def text(*part, offset: nil) # This is the same as getting the value for "BODYSTRUCTURE" from # #attr. def bodystructure; attr["BODYSTRUCTURE"] end + alias body_structure bodystructure # :call-seq: envelope -> Envelope or nil @@ -320,7 +297,7 @@ def bodystructure; attr["BODYSTRUCTURE"] end # #attr. def envelope; attr["ENVELOPE"] end - # :call-seq: flags -> array of Symbols and Strings + # :call-seq: flags -> array of Symbols and Strings, or nil # # A array of flags that are set for this message. System flags are # symbols that have been capitalized by String#capitalize. Keyword flags @@ -328,7 +305,7 @@ def envelope; attr["ENVELOPE"] end # # This is the same as getting the value for "FLAGS" from #attr. # - # [Note] + # [NOTE:] # The +FLAGS+ field is dynamic, and can change for a uniquely identified # message. def flags; attr["FLAGS"] end @@ -343,25 +320,26 @@ def flags; attr["FLAGS"] end # This is similar to getting the value for "INTERNALDATE" from # #attr. # - # [Note] + # [NOTE:] # attr["INTERNALDATE"] returns a string, and this method # returns a Time object. def internaldate attr["INTERNALDATE"]&.then { IMAP.decode_time _1 } end + alias internal_date internaldate - # :call-seq: rfc822 -> String + # :call-seq: rfc822 -> String or nil # # Semantically equivalent to #message with no arguments. # # This is the same as getting the value for "RFC822" from #attr. # - # [Note] + # [NOTE:] # +IMAP4rev2+ deprecates RFC822. def rfc822; attr["RFC822"] end - # :call-seq: rfc822_size -> Integer + # :call-seq: rfc822_size -> Integer or nil # # A number expressing the [RFC5322[https://tools.ietf.org/html/rfc5322]] # size of the message. @@ -369,7 +347,7 @@ def rfc822; attr["RFC822"] end # This is the same as getting the value for "RFC822.SIZE" from # #attr. # - # [Note] + # [NOTE:] # \IMAP was originally developed for the older # RFC822[https://www.rfc-editor.org/rfc/rfc822.html] standard, and as a # consequence several fetch items in \IMAP incorporate "RFC822" in their @@ -379,34 +357,45 @@ def rfc822; attr["RFC822"] end # interpreted as a reference to the updated # RFC5322[https://www.rfc-editor.org/rfc/rfc5322.html] standard. def rfc822_size; attr["RFC822.SIZE"] end - alias size rfc822_size - # :call-seq: rfc822_header -> String + # NOTE: a bug in rdoc 6.7 prevents us from adding a call-seq to + # rfc822_size _and_ aliasing size => rfc822_size. Is it because this + # class inherits from Struct? + + # Alias for: rfc822_size + def size; rfc822_size end + + + # :call-seq: rfc822_header -> String or nil # # Semantically equivalent to #header, with no arguments. # # This is the same as getting the value for "RFC822.HEADER" from #attr. # - # [Note] + # [NOTE:] # +IMAP4rev2+ deprecates RFC822.HEADER. def rfc822_header; attr["RFC822.HEADER"] end - # :call-seq: rfc822_text -> String + # :call-seq: rfc822_text -> String or nil # # Semantically equivalent to #text, with no arguments. # # This is the same as getting the value for "RFC822.TEXT" from # #attr. # - # [Note] + # [NOTE:] # +IMAP4rev2+ deprecates RFC822.TEXT. def rfc822_text; attr["RFC822.TEXT"] end - # :call-seq: uid -> Integer + # :call-seq: uid -> Integer or nil # # A number expressing the unique identifier of the message. # # This is the same as getting the value for "UID" from #attr. + # + # [NOTE:] + # For UIDFetchData, this returns the uniqueid at the beginning of the + # +UIDFETCH+ response, _not_ the value from #attr. def uid; attr["UID"] end # :call-seq: @@ -452,7 +441,7 @@ def binary_size(*part_nums) attr[section_attr("BINARY.SIZE", part_nums)] end - # :call-seq: modseq -> Integer + # :call-seq: modseq -> Integer or nil # # The modification sequence number associated with this IMAP message. # @@ -461,7 +450,7 @@ def binary_size(*part_nums) # The server must support the +CONDSTORE+ extension # {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html]. # - # [Note] + # [NOTE:] # The +MODSEQ+ field is dynamic, and can change for a uniquely # identified message. def modseq; attr["MODSEQ"] end @@ -508,11 +497,92 @@ def section_attr(attr, part = [], text = nil, offset: nil) spec = Array(part).flatten.map { Integer(_1) } spec << text if text spec = spec.join(".") - if offset then "%s[%s]<%d>" % [attr, spec, Integer(offset)] - else "%s[%s]" % [attr, spec] - end + if offset then "%s[%s]<%d>" % [attr, spec, Integer(offset)] else "%s[%s]" % [attr, spec] end end + end + + # Net::IMAP::FetchData represents the contents of a +FETCH+ response. + # Net::IMAP#fetch, Net::IMAP#uid_fetch, Net::IMAP#store, and + # Net::IMAP#uid_store all return arrays of FetchData objects, except when + # the +UIDONLY+ extension is enabled. + # + # See FetchStruct documentation for a list of standard message attributes. + class FetchData < FetchStruct.new(:seqno, :attr) + ## + # method: seqno + # :call-seq: seqno -> Integer + # + # The message sequence number. + # + # [NOTE:] + # This is not the same as the unique identifier (UID), not even for the + # Net::IMAP#uid_fetch result. The UID is available from #uid, if it was + # returned. + # + # [NOTE:] + # UIDFetchData will raise a NoMethodError. + + ## + # method: attr + # :call-seq: attr -> hash + # + # Each key specifies a message attribute, and the value is the + # corresponding data item. Standard data items have corresponding + # accessor methods. The definitions of each attribute type is documented + # on its accessor. + # + # See FetchStruct documentation for message attribute accessors. + # + # [NOTE:] + # #seqno is not a message attribute. + end + + # Net::IMAP::UIDFetchData represents the contents of a +UIDFETCH+ response, + # When the +UIDONLY+ extension has been enabled, Net::IMAP#uid_fetch and + # Net::IMAP#uid_store will both return an array of UIDFetchData objects. + # + # UIDFetchData contains the same message attributes as FetchData. However, + # +UIDFETCH+ responses return the UID at the beginning of the response, + # replacing FetchData#seqno. UIDFetchData never contains a message sequence + # number. + # + # See FetchStruct documentation for a list of standard message attributes. + class UIDFetchData < FetchStruct.new(:uid, :attr) + ## + # method: uid + # call-seq: uid -> Integer + # + # A number expressing the unique identifier of the message. + # + # [NOTE:] + # Although #attr may _also_ have a redundant +UID+ attribute, #uid + # returns the uniqueid at the beginning of the +UIDFETCH+ response. + ## + # method: attr + # call-seq: attr -> hash + # + # Each key specifies a message attribute, and the value is the + # corresponding data item. Standard data items have corresponding + # accessor methods. The definitions of each attribute type is documented + # on its accessor. + # + # See FetchStruct documentation for message attribute accessors. + # + # [NOTE:] + # #uid is not a message attribute. Although the server may return a + # +UID+ message attribute, it is not required to. #uid is taken from + # its corresponding +UIDFETCH+ field. + + # UIDFetchData will print a warning if #attr["UID"] is present + # but not identical to #uid. + def initialize(...) + super + attr and + attr_uid = attr["UID"] and + attr_uid != uid and + warn "#{self.class} UIDs do not match (#{attr_uid} != #{uid})" + end end end end diff --git a/lib/net/imap/response_data.rb b/lib/net/imap/response_data.rb index 6b50cf81..8a74e2b3 100644 --- a/lib/net/imap/response_data.rb +++ b/lib/net/imap/response_data.rb @@ -4,8 +4,10 @@ module Net class IMAP < Protocol autoload :ESearchResult, "#{__dir__}/esearch_result" autoload :FetchData, "#{__dir__}/fetch_data" + autoload :UIDFetchData, "#{__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 42354332..0fb89559 100644 --- a/lib/net/imap/response_parser.rb +++ b/lib/net/imap/response_parser.rb @@ -730,7 +730,7 @@ def response_data when "EXISTS" then mailbox_data__exists # RFC3501, RFC9051 when "ESEARCH" then esearch_response # RFC4731, RFC9051, etc when "VANISHED" then expunged_resp # RFC7162 - when "UIDFETCH" then uidfetch_resp # (draft) UIDONLY + when "UIDFETCH" then uidfetch_resp # RFC9586 when "SEARCH" then mailbox_data__search # RFC3501 (obsolete) when "CAPABILITY" then capability_data__untagged # RFC3501, RFC9051 when "FLAGS" then mailbox_data__flags # RFC3501, RFC9051 @@ -783,8 +783,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 alias metadata_resp response_data__unhandled @@ -845,6 +843,14 @@ def message_data__fetch UntaggedResponse.new(name, data, @str) end + # uidfetch-resp = uniqueid SP "UIDFETCH" SP msg-att + def uidfetch_resp + uid = uniqueid; SP! + name = label "UIDFETCH"; SP! + data = UIDFetchData.new(uid, msg_att(uid)) + UntaggedResponse.new(name, data, @str) + end + def response_data__simple_numeric data = nz_number; SP! name = tagged_ext_label @@ -855,6 +861,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)) ")" @@ -1975,6 +1995,9 @@ def resp_text # # RFC8474: OBJECTID # resp-text-code =/ "MAILBOXID" SP "(" objectid ")" + # + # RFC9586: UIDONLY + # resp-text-code =/ "UIDREQUIRED" def resp_text_code name = resp_text_code__name data = @@ -1997,6 +2020,7 @@ def resp_text_code when "HIGHESTMODSEQ" then SP!; mod_sequence_value # CONDSTORE when "MODIFIED" then SP!; sequence_set # CONDSTORE when "MAILBOXID" then SP!; parens__objectid # RFC8474: OBJECTID + when "UIDREQUIRED" then # RFC9586: UIDONLY else SP? and text_chars_except_rbra end 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/rakelib/rfcs.rake b/rakelib/rfcs.rake index 950c49cc..66665efd 100644 --- a/rakelib/rfcs.rake +++ b/rakelib/rfcs.rake @@ -146,6 +146,7 @@ RFCS = { 8970 => "IMAP PREVIEW", 9208 => "IMAP QUOTA, QUOTA=, QUOTASET", 9394 => "IMAP PARTIAL", + 9586 => "IMAP UIDONLY", # etc... 3629 => "UTF8", 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/fixtures/response_parser/rfc9586_uidonly_responses.yml b/test/net/imap/fixtures/response_parser/rfc9586_uidonly_responses.yml new file mode 100644 index 00000000..a7088598 --- /dev/null +++ b/test/net/imap/fixtures/response_parser/rfc9586_uidonly_responses.yml @@ -0,0 +1,26 @@ +--- +:tests: + "RFC9586 UIDONLY 3. UIDREQUIRED response code": + :response: "07 BAD [UIDREQUIRED] Message numbers are not allowed once UIDONLY is enabled\r\n" + :expected: !ruby/struct:Net::IMAP::TaggedResponse + tag: '07' + name: BAD + data: !ruby/struct:Net::IMAP::ResponseText + code: !ruby/struct:Net::IMAP::ResponseCode + name: UIDREQUIRED + data: + text: Message numbers are not allowed once UIDONLY is enabled + raw_data: "07 BAD [UIDREQUIRED] Message numbers are not allowed once UIDONLY + is enabled\r\n" + + "RFC9586 UIDONLY 3.3 UIDFETCH response": + :response: "* 25997 UIDFETCH (FLAGS (\\Flagged \\Answered))\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: UIDFETCH + data: !ruby/struct:Net::IMAP::UIDFetchData + uid: 25997 + attr: + FLAGS: + - :Flagged + - :Answered + raw_data: "* 25997 UIDFETCH (FLAGS (\\Flagged \\Answered))\r\n" \ No newline at end of file diff --git a/test/net/imap/test_fetch_data.rb b/test/net/imap/test_fetch_data.rb index a1dec148..66b29d34 100644 --- a/test/net/imap/test_fetch_data.rb +++ b/test/net/imap/test_fetch_data.rb @@ -4,195 +4,234 @@ require "test/unit" class FetchDataTest < Test::Unit::TestCase - BodyTypeMessage = Net::IMAP::BodyTypeMessage - Envelope = Net::IMAP::Envelope - FetchData = Net::IMAP::FetchData + def fetch_data_class + Net::IMAP::FetchData + end test "#seqno" do - data = FetchData.new(22222, "UID" => 54_321) + data = Net::IMAP::FetchData.new(22222, "UID" => 54_321) assert_equal 22222, data.seqno end + test "#uid" do + data = Net::IMAP::FetchData.new(22222, "UID" => 54_321) + assert_equal 54_321, data.uid + end +end + +class UIDFetchDataTest < Test::Unit::TestCase + def fetch_data_class + Net::IMAP::UIDFetchData + end + + test "#seqno does not exist" do + data = Net::IMAP::UIDFetchData.new(22222) + assert_raise NoMethodError do + data.seqno + end + end + + test "#uid replaces #seqno" do + data = Net::IMAP::UIDFetchData.new(22222) + assert_equal 22222, data.uid + end + + test "#uid warns when differs from UID attribute" do + data = nil + assert_warn(/UIDs do not match/i) do + data = Net::IMAP::UIDFetchData.new(22222, "UID" => 54_321) + end + assert_equal 22222, data.uid + end + + test "#deconstruct_keys" do + Net::IMAP::UIDFetchData.new(22222, "UID" => 54_321) => { + uid: 22222 + } + end +end + +DEFINE_FETCH_DATA_SHARED_TESTS = proc do # "simple" attrs merely return exactly what is in the attr of the same name test "simple RFC3501 and RFC9051 attrs accessors" do - data = FetchData.new( + data = fetch_data_class.new( 22222, { - "UID" => 54_321, - "FLAGS" => ["foo", :seen, :flagged], - "BODY" => BodyTypeMessage.new(:body, :no_exts), - "BODYSTRUCTURE" => BodyTypeMessage.new(:body, :with_exts), - "ENVELOPE" => Envelope.new(:foo, :bar, :baz), - "RFC822.SIZE" => 12_345, + "FLAGS" => ["foo", :seen, :flagged], + "BODY" => Net::IMAP::BodyTypeMessage.new(:body, :no_exts), + "BODYSTRUCTURE" => Net::IMAP::BodyTypeMessage.new(:body, :with_exts), + "ENVELOPE" => Net::IMAP::Envelope.new(:foo, :bar, :baz), + "RFC822.SIZE" => 12_345, } ) - assert_equal 54321, data.uid - assert_equal ["foo", :seen, :flagged], data.flags - assert_equal BodyTypeMessage.new(:body, :no_exts), data.body - assert_equal BodyTypeMessage.new(:body, :with_exts), data.bodystructure - assert_equal BodyTypeMessage.new(:body, :with_exts), data.body_structure - assert_equal Envelope.new(:foo, :bar, :baz), data.envelope - assert_equal 12_345, data.rfc822_size - assert_equal 12_345, data.size + assert_equal ["foo", :seen, :flagged], data.flags + assert_equal Net::IMAP::BodyTypeMessage.new(:body, :no_exts), data.body + assert_equal Net::IMAP::BodyTypeMessage.new(:body, :with_exts), data.bodystructure + assert_equal Net::IMAP::BodyTypeMessage.new(:body, :with_exts), data.body_structure + assert_equal Net::IMAP::Envelope.new(:foo, :bar, :baz), data.envelope + assert_equal 12_345, data.rfc822_size + assert_equal 12_345, data.size end test "#modseq returns MODSEQ value (RFC7162: CONDSTORE)" do - data = FetchData.new(22222, {"MODSEQ" => 123_456_789}) + data = fetch_data_class.new(22222, { "MODSEQ" => 123_456_789 }) assert_equal(123_456_789, data.modseq) end test "#emailid returns EMAILID value (RFC8474: OBJECTID)" do - data = FetchData.new(22222, {"EMAILID" => "THIS-IS-IT-01234"}) + data = fetch_data_class.new(22222, { "EMAILID" => "THIS-IS-IT-01234" }) assert_equal "THIS-IS-IT-01234", data.emailid end test "#threadid returns THREADID value (RFC8474: OBJECTID)" do - data = FetchData.new(22222, {"THREADID" => "THAT-IS-THAT-98765"}) + data = fetch_data_class.new(22222, { "THREADID" => "THAT-IS-THAT-98765" }) assert_equal "THAT-IS-THAT-98765", data.threadid end test "simple RFC822 attrs accessors (deprecated by RFC9051)" do - data = FetchData.new( + data = fetch_data_class.new( 22222, { - "RFC822" => "RFC822 formatted message", - "RFC822.TEXT" => "message text", + "RFC822" => "RFC822 formatted message", + "RFC822.TEXT" => "message text", "RFC822.HEADER" => "RFC822-headers: unparsed\r\n", } ) - assert_equal("RFC822 formatted message", data.rfc822) - assert_equal("message text", data.rfc822_text) - assert_equal("RFC822-headers: unparsed\r\n", data.rfc822_header) + assert_equal("RFC822 formatted message", data.rfc822) + assert_equal("message text", data.rfc822_text) + assert_equal("RFC822-headers: unparsed\r\n", data.rfc822_header) end test "#internaldate parses a datetime value" do - assert_nil FetchData.new(123, {"UID" => 456}).internaldate - data = FetchData.new(1, {"INTERNALDATE" => "17-Jul-1996 02:44:25 -0700"}) + assert_nil fetch_data_class.new(123, { "UID" => 456 }).internaldate + data = fetch_data_class.new(1, { "INTERNALDATE" => "17-Jul-1996 02:44:25 -0700" }) time = Time.parse("1996-07-17T02:44:25-0700") assert_equal time, data.internaldate assert_equal time, data.internal_date end test "#message returns the BODY[] attr" do - data = FetchData.new(1, {"BODY[]" => "RFC5322 formatted message"}) + data = fetch_data_class.new(1, { "BODY[]" => "RFC5322 formatted message" }) assert_equal("RFC5322 formatted message", data.message) end test "#message(offset:) returns the BODY[] attr" do - data = FetchData.new(1, {"BODY[]<12345>" => "partial message 1",}) + data = fetch_data_class.new(1, { "BODY[]<12345>" => "partial message 1" }) assert_equal "partial message 1", data.message(offset: 12_345) end test "#part(1, 2, 3) returns the BODY[1.2.3] attr" do - data = FetchData.new(1, {"BODY[1.2.3]" => "Part"}) + data = fetch_data_class.new(1, { "BODY[1.2.3]" => "Part" }) assert_equal "Part", data.part(1, 2, 3) end test "#part(1, 2, oFfset: 456) returns the BODY[1.2]<456> attr" do - data = FetchData.new(1, {"BODY[1.2]<456>" => "partial"}) + data = fetch_data_class.new(1, { "BODY[1.2]<456>" => "partial" }) assert_equal "partial", data.part(1, 2, offset: 456) end test "#text returns the BODY[TEXT] attr" do - data = FetchData.new(1, {"BODY[TEXT]" => "message text"}) + data = fetch_data_class.new(1, { "BODY[TEXT]" => "message text" }) assert_equal "message text", data.text end test "#text(1, 2, 3) returns the BODY[1.2.3.TEXT] attr" do - data = FetchData.new(1, {"BODY[1.2.3.TEXT]" => "part text"}) + data = fetch_data_class.new(1, { "BODY[1.2.3.TEXT]" => "part text" }) assert_equal "part text", data.text(1, 2, 3) end test "#text(1, 2, 3, oFfset: 456) returns the BODY[1.2.3.TEXT]<456> attr" do - data = FetchData.new(1, {"BODY[1.2.3.TEXT]<456>" => "partial text"}) + data = fetch_data_class.new(1, { "BODY[1.2.3.TEXT]<456>" => "partial text" }) assert_equal "partial text", data.text(1, 2, 3, offset: 456) end test "#header returns the BODY[HEADER] attr" do - data = FetchData.new(1, {"BODY[HEADER]" => "Message: header"}) + data = fetch_data_class.new(1, { "BODY[HEADER]" => "Message: header" }) assert_equal "Message: header", data.header end test "#header(1, 2, 3) returns the BODY[1.2.3.HEADER] attr" do - data = FetchData.new(1, {"BODY[1.2.3.HEADER]" => "Part: header"}) + data = fetch_data_class.new(1, { "BODY[1.2.3.HEADER]" => "Part: header" }) assert_equal "Part: header", data.header(1, 2, 3) end test "#header(1, 2, oFfset: 456) returns the BODY[1.2.HEADER]<456> attr" do - data = FetchData.new(1, {"BODY[1.2.HEADER]<456>" => "partial header"}) + data = fetch_data_class.new(1, { "BODY[1.2.HEADER]<456>" => "partial header" }) assert_equal "partial header", data.header(1, 2, offset: 456) end test "#header_fields(*) => BODY[HEADER.FIELDS (*)] attr" do - data = FetchData.new(1, {"BODY[HEADER.FIELDS (Foo Bar)]" => "foo bar"}) + data = fetch_data_class.new(1, { "BODY[HEADER.FIELDS (Foo Bar)]" => "foo bar" }) assert_equal "foo bar", data.header_fields("foo", "BAR") assert_equal "foo bar", data.header(fields: %w[foo BAR]) end test "#header_fields(*, part:) => BODY[part.HEADER.FIELDS (*)] attr" do - data = FetchData.new(1, {"BODY[1.HEADER.FIELDS (Foo Bar)]" => "foo bar"}) + data = fetch_data_class.new(1, { "BODY[1.HEADER.FIELDS (Foo Bar)]" => "foo bar" }) assert_equal "foo bar", data.header_fields("foo", "BAR", part: 1) assert_equal "foo bar", data.header(1, fields: %w[foo BAR]) - data = FetchData.new(1, {"BODY[1.2.HEADER.FIELDS (Foo Bar)]" => "foo bar"}) + data = fetch_data_class.new(1, { "BODY[1.2.HEADER.FIELDS (Foo Bar)]" => "foo bar" }) assert_equal "foo bar", data.header_fields("foo", "BAR", part: [1, 2]) assert_equal "foo bar", data.header(1, 2, fields: %w[foo BAR]) end test "#header_fields(*, offset:) => BODY[part.HEADER.FIELDS (*)]" do - data = FetchData.new(1, {"BODY[1.HEADER.FIELDS (List-ID)]<1>" => "foo bar"}) + data = fetch_data_class.new(1, { "BODY[1.HEADER.FIELDS (List-ID)]<1>" => "foo bar" }) assert_equal "foo bar", data.header_fields("List-Id", part: 1, offset: 1) assert_equal "foo bar", data.header(1, fields: %w[List-Id], offset: 1) end test "#header_fields_not(*) => BODY[HEADER.FIELDS.NOT (*)] attr" do - data = FetchData.new(1, {"BODY[HEADER.FIELDS.NOT (Foo Bar)]" => "foo bar"}) + data = fetch_data_class.new(1, { "BODY[HEADER.FIELDS.NOT (Foo Bar)]" => "foo bar" }) assert_equal "foo bar", data.header_fields_not("foo", "BAR") assert_equal "foo bar", data.header(except: %w[foo BAR]) end test "#header_fields_not(*, part:) => BODY[part.HEADER.FIELDS.NOT (*)] attr" do - data = FetchData.new(1, {"BODY[1.HEADER.FIELDS.NOT (Foo Bar)]" => "foo bar"}) + data = fetch_data_class.new(1, { "BODY[1.HEADER.FIELDS.NOT (Foo Bar)]" => "foo bar" }) assert_equal "foo bar", data.header_fields_not("foo", "BAR", part: 1) assert_equal "foo bar", data.header(1, except: %w[foo BAR]) - data = FetchData.new(1, {"BODY[1.2.HEADER.FIELDS.NOT (Foo Bar)]" => "foo bar"}) + data = fetch_data_class.new(1, { "BODY[1.2.HEADER.FIELDS.NOT (Foo Bar)]" => "foo bar" }) assert_equal "foo bar", data.header_fields_not("foo", "BAR", part: [1, 2]) assert_equal "foo bar", data.header(1, 2, except: %w[foo BAR]) end test "#header_fields_not(*, offset:) => BODY[part.HEADER.FIELDS.NOT (*)]" do - data = FetchData.new(1, {"BODY[1.HEADER.FIELDS.NOT (List-ID)]<1>" => "foo bar"}) + data = fetch_data_class.new(1, { "BODY[1.HEADER.FIELDS.NOT (List-ID)]<1>" => "foo bar" }) assert_equal "foo bar", data.header_fields_not("List-Id", part: 1, offset: 1) assert_equal "foo bar", data.header(1, except: %w[List-Id], offset: 1) end test "#mime(1, 2, 3) returns the BODY[1.2.3.MIME] attr" do - data = FetchData.new(1, {"BODY[1.2.3.MIME]" => "Part: mime"}) + data = fetch_data_class.new(1, { "BODY[1.2.3.MIME]" => "Part: mime" }) assert_equal "Part: mime", data.mime(1, 2, 3) end test "#mime(1, 2, oFfset: 456) returns the BODY[1.2.MIME]<456> attr" do - data = FetchData.new(1, {"BODY[1.2.MIME]<456>" => "partial mime"}) + data = fetch_data_class.new(1, { "BODY[1.2.MIME]<456>" => "partial mime" }) assert_equal "partial mime", data.mime(1, 2, offset: 456) end test "#binary(1, 2, 3, offset: 1) returns the BINARY[1.2.3]<1> attr" do - data = FetchData.new(1, { + data = fetch_data_class.new(1, { "BINARY[]" => "binary\0whole".b, "BINARY[1.2.3]" => "binary\0part".b, "BINARY[1.2.3]<1>" => "inary\0pa".b, }) assert_equal "binary\0whole".b, data.binary - assert_equal "binary\0part".b, data.binary(1, 2, 3) - assert_equal "inary\0pa".b, data.binary(1, 2, 3, offset: 1) + assert_equal "binary\0part".b, data.binary(1, 2, 3) + assert_equal "inary\0pa".b, data.binary(1, 2, 3, offset: 1) end test "#binary_size(1, 2, 3) returns the BINARY.SIZE[1.2.3] attr" do - data = FetchData.new(1, { - "BINARY.SIZE[]" => 987_654, + data = fetch_data_class.new(1, { + "BINARY.SIZE[]" => 987_654, "BINARY.SIZE[1.2.3]" => 123_456, }) assert_equal 987_654, data.binary_size assert_equal 123_456, data.binary_size(1, 2, 3) assert_equal 123_456, data.binary_size([1, 2, 3]) end - end + +FetchDataTest.class_exec(&DEFINE_FETCH_DATA_SHARED_TESTS) +UIDFetchDataTest.class_exec(&DEFINE_FETCH_DATA_SHARED_TESTS) diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index bd64d7e5..34cf6d4c 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -1014,7 +1014,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| @@ -1030,6 +1030,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| @@ -1126,10 +1144,62 @@ def test_enable end end + test "#fetch with FETCH responses" do + with_fake_server select: "inbox" do |server, imap| + server.on("FETCH") do |resp| + resp.untagged("123 FETCH (UID 1111 FLAGS (\\Seen $MDNSent))") + resp.untagged("456 FETCH (UID 4444 FLAGS (\\Seen \\Answered))") + resp.untagged("789 FETCH (UID 7777 FLAGS ())") + resp.done_ok + end + fetched = imap.fetch [123, 456, 789], %w[UID FLAGS] + assert_equal 3, fetched.size + assert_instance_of Net::IMAP::FetchData, fetched[0] + assert_instance_of Net::IMAP::FetchData, fetched[1] + assert_instance_of Net::IMAP::FetchData, fetched[2] + assert_equal 123, fetched[0].seqno + assert_equal 456, fetched[1].seqno + assert_equal 789, fetched[2].seqno + assert_equal 1111, fetched[0].uid + assert_equal 4444, fetched[1].uid + assert_equal 7777, fetched[2].uid + assert_equal [:Seen, "$MDNSent"], fetched[0].flags + assert_equal [:Seen, :Answered], fetched[1].flags + assert_equal [], fetched[2].flags + assert_equal("RUBY0002 FETCH 123,456,789 (UID FLAGS)", + server.commands.pop.raw.strip) + end + end + + test "#uid_fetch with UIDFETCH responses" do + with_fake_server select: "inbox" do |server, imap| + server.on("UID FETCH") do |resp| + resp.untagged("1111 UIDFETCH (FLAGS (\\Seen $MDNSent))") + resp.untagged("4444 UIDFETCH (FLAGS (\\Seen \\Answered))") + resp.untagged("7777 UIDFETCH (FLAGS ())") + resp.done_ok + end + fetched = imap.uid_fetch [123, 456, 789], %w[FLAGS] + assert_equal 3, fetched.size + assert_instance_of Net::IMAP::UIDFetchData, fetched[0] + assert_instance_of Net::IMAP::UIDFetchData, fetched[1] + assert_instance_of Net::IMAP::UIDFetchData, fetched[2] + assert_equal 1111, fetched[0].uid + assert_equal 4444, fetched[1].uid + assert_equal 7777, fetched[2].uid + assert_equal [:Seen, "$MDNSent"], fetched[0].flags + assert_equal [:Seen, :Answered], fetched[1].flags + assert_equal [], fetched[2].flags + assert_equal("RUBY0002 UID FETCH 123,456,789 (FLAGS)", + server.commands.pop.raw.strip) + end + end + test "#fetch with changedsince" do with_fake_server select: "inbox" do |server, imap| server.on("FETCH", &:done_ok) - imap.fetch 1..-1, %w[FLAGS], changedsince: 12345 + fetched = imap.fetch 1..-1, %w[FLAGS], changedsince: 12345 + assert_empty fetched assert_equal("RUBY0002 FETCH 1:* (FLAGS) (CHANGEDSINCE 12345)", server.commands.pop.raw.strip) end @@ -1138,12 +1208,40 @@ def test_enable test "#uid_fetch with changedsince" do with_fake_server select: "inbox" do |server, imap| server.on("UID FETCH", &:done_ok) - imap.uid_fetch 1..-1, %w[FLAGS], changedsince: 12345 + fetched = imap.uid_fetch 1..-1, %w[FLAGS], changedsince: 12345 + assert_empty fetched assert_equal("RUBY0002 UID FETCH 1:* (FLAGS) (CHANGEDSINCE 12345)", server.commands.pop.raw.strip) end end + test "#store with FETCH responses" do + with_fake_server select: "inbox" do |server, imap| + server.on("STORE") do |resp| + resp.untagged("123 FETCH (UID 1111 FLAGS (\\Seen $MDNSent))") + resp.untagged("456 FETCH (UID 4444 FLAGS (\\Seen \\Answered))") + resp.untagged("789 FETCH (UID 7777 FLAGS (\\Seen))") + resp.done_ok + end + changed = imap.store [123, 456, 789], "+FLAGS", %i[Seen] + assert_equal("RUBY0002 STORE 123,456,789 +FLAGS (\\Seen)", + server.commands.pop.raw.strip) + assert_equal 3, changed.size + assert_instance_of Net::IMAP::FetchData, changed[0] + assert_instance_of Net::IMAP::FetchData, changed[1] + assert_instance_of Net::IMAP::FetchData, changed[2] + assert_equal 123, changed[0].seqno + assert_equal 456, changed[1].seqno + assert_equal 789, changed[2].seqno + assert_equal 1111, changed[0].uid + assert_equal 4444, changed[1].uid + assert_equal 7777, changed[2].uid + assert_equal [:Seen, "$MDNSent"], changed[0].flags + assert_equal [:Seen, :Answered], changed[1].flags + assert_equal [:Seen], changed[2].flags + end + end + test "#store with unchangedsince" do with_fake_server select: "inbox" do |server, imap| server.on("STORE", &:done_ok) @@ -1155,6 +1253,30 @@ def test_enable end end + test "#uid_store with UIDFETCH responses" do + with_fake_server select: "inbox" do |server, imap| + server.on("UID STORE") do |resp| + resp.untagged("1111 UIDFETCH (FLAGS (\\Seen $MDNSent))") + resp.untagged("4444 UIDFETCH (FLAGS (\\Seen \\Answered))") + resp.untagged("7777 UIDFETCH (FLAGS (\\Seen))") + resp.done_ok + end + changed = imap.uid_store [123, 456, 789], "+FLAGS", %i[Seen] + assert_equal("RUBY0002 UID STORE 123,456,789 +FLAGS (\\Seen)", + server.commands.pop.raw.strip) + assert_equal 3, changed.size + assert_instance_of Net::IMAP::UIDFetchData, changed[0] + assert_instance_of Net::IMAP::UIDFetchData, changed[1] + assert_instance_of Net::IMAP::UIDFetchData, changed[2] + assert_equal 1111, changed[0].uid + assert_equal 4444, changed[1].uid + assert_equal 7777, changed[2].uid + assert_equal [:Seen, "$MDNSent"], changed[0].flags + assert_equal [:Seen, :Answered], changed[1].flags + assert_equal [:Seen], changed[2].flags + end + end + test "#uid_store with changedsince" do with_fake_server select: "inbox" do |server, imap| server.on("UID STORE", &:done_ok) @@ -1166,6 +1288,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_imap_response_parser.rb b/test/net/imap/test_imap_response_parser.rb index 365ff7e3..b49a498b 100644 --- a/test/net/imap/test_imap_response_parser.rb +++ b/test/net/imap/test_imap_response_parser.rb @@ -112,6 +112,9 @@ def teardown # RFC 9394: PARTIAL extension generate_tests_from fixture_file: "rfc9394_partial.yml" + # RFC 9586: UIDONLY extension + generate_tests_from fixture_file: "rfc9586_uidonly_responses.yml" + ############################################################################ # Workarounds or unspecified extensions: generate_tests_from fixture_file: "quirky_behaviors.yml" 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