diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 75449fd0d..dbff4c409 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -435,14 +435,20 @@ def login(user, password) # in the +mailbox+ can be accessed. # # After you have selected a mailbox, you may retrieve the - # number of items in that mailbox from +@responses["EXISTS"][-1]+, - # and the number of recent messages from +@responses["RECENT"][-1]+. + # number of items in that mailbox from @responses["EXISTS"][-1], + # and the number of recent messages from @responses["RECENT"][-1]. # Note that these values can change if new messages arrive # during a session; see #add_response_handler for a way of # detecting this event. # # A Net::IMAP::NoResponseError is raised if the mailbox does not # exist or is for some reason non-selectable. + # + # If the server supports the [UIDPLUS[https://www.rfc-editor.org/rfc/rfc4315.html]] + # extension it may return an additional "NO" response with a "UIDNOTSTICKY" response code + # indicating that the mailstore does not support persistent UIDs + # [1[https://www.rfc-editor.org/rfc/rfc4315.html#page-4]]: + # @responses["NO"].last.code.name == "UIDNOTSTICKY" def select(mailbox) synchronize do @responses.clear @@ -752,6 +758,10 @@ def status(mailbox, attr) # A Net::IMAP::NoResponseError is raised if the mailbox does # not exist (it is not created automatically), or if the flags, # date_time, or message arguments contain errors. + # + # If the server supports the [UIDPLUS[https://www.rfc-editor.org/rfc/rfc4315.html]] + # extension it returns an array with the UIDVALIDITY and the assigned UID of the + # appended message. def append(mailbox, message, flags = nil, date_time = nil) args = [] if flags @@ -759,7 +769,12 @@ def append(mailbox, message, flags = nil, date_time = nil) end args.push(date_time) if date_time args.push(Literal.new(message)) - send_command("APPEND", mailbox, *args) + synchronize do + resp = send_command("APPEND", mailbox, *args) + if resp.data.code && resp.data.code.name == "APPENDUID" + return resp.data.code.data + end + end end # Sends a CHECK command to request a checkpoint of the currently @@ -786,6 +801,32 @@ def expunge end end + # Similar to #expunge, but takes a set of unique identifiers as + # argument. Sends a UID EXPUNGE command to permanently remove all + # messages that have both the \\Deleted flag set and a UID that is + # included in +uid_set+. + # + # 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 +uid_set+ 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. + # + # ==== Required capability + # +UIDPLUS+ - described in [UIDPLUS[https://www.rfc-editor.org/rfc/rfc4315.html]]. + def uid_expunge(uid_set) + synchronize do + send_command("UID EXPUNGE", MessageSet.new(uid_set)) + return @responses.delete("EXPUNGE") + end + end + # Sends a SEARCH command to search the mailbox for messages that # match the given searching criteria, and returns message sequence # numbers. +keys+ can either be a string holding the entire @@ -906,6 +947,10 @@ def uid_store(set, attr, flags) # of the specified destination +mailbox+. The +set+ parameter is # a number, an array of numbers, or a Range object. The number is # a message sequence number. + # + # If the server supports the [UIDPLUS[https://www.rfc-editor.org/rfc/rfc4315.html]] + # extension it returns an array with the UIDVALIDITY, the UID set of the source messages + # and the assigned UID set of the copied messages. def copy(set, mailbox) copy_internal("COPY", set, mailbox) end @@ -921,6 +966,10 @@ def uid_copy(set, mailbox) # a message sequence number. # # The MOVE extension is described in [EXT-MOVE[https://tools.ietf.org/html/rfc6851]]. + # + # If the server supports the [UIDPLUS[https://www.rfc-editor.org/rfc/rfc4315.html]] + # extension it returns an array with the UIDVALIDITY, the UID set of the source messages + # and the assigned UID set of the moved messages. def move(set, mailbox) copy_internal("MOVE", set, mailbox) end @@ -1383,7 +1432,12 @@ def store_internal(cmd, set, attr, flags) end def copy_internal(cmd, set, mailbox) - send_command(cmd, MessageSet.new(set), mailbox) + synchronize do + resp = send_command(cmd, MessageSet.new(set), mailbox) + if resp.data.code && resp.data.code.name == "COPYUID" + return resp.data.code.data + end + end end def sort_internal(cmd, sort_keys, search_keys, charset) diff --git a/lib/net/imap/response_parser.rb b/lib/net/imap/response_parser.rb index 61ed89fae..f9e84f7ad 100644 --- a/lib/net/imap/response_parser.rb +++ b/lib/net/imap/response_parser.rb @@ -1103,6 +1103,12 @@ def resp_text # "UIDNEXT" SP nz-number / "UIDVALIDITY" SP nz-number / # "UNSEEN" SP nz-number / # atom [SP 1*] + # + # See https://datatracker.ietf.org/doc/html/rfc4315#section-6.4 for UIDPLUS extension + # + # resp-code-apnd = "APPENDUID" SP nz-number SP append-uid + # resp-code-copy = "COPYUID" SP nz-number SP uid-set SP uid-set + # resp-text-code =/ resp-code-apnd / resp-code-copy / "UIDNOTSTICKY" def resp_text_code token = match(T_ATOM) name = token.value.upcase @@ -1119,6 +1125,20 @@ def resp_text_code when /\A(?:UIDVALIDITY|UIDNEXT|UNSEEN)\z/n match(T_SPACE) result = ResponseCode.new(name, number) + when /\A(?:APPENDUID)\z/n + match(T_SPACE) + uidvalidity = number + match(T_SPACE) + append_uid = number + result = ResponseCode.new(name, [uidvalidity, append_uid]) + when /\A(?:COPYUID)\z/n + match(T_SPACE) + uidvalidity = number + match(T_SPACE) + from_uid = uid_set + match(T_SPACE) + to_uid = uid_set + result = ResponseCode.new(name, [uidvalidity, from_uid, to_uid]) else token = lookahead if token.symbol == T_SPACE @@ -1321,6 +1341,31 @@ def number return token.value.to_i end + # RFC-4315 (UIDPLUS) or RFC9051 (IMAP4rev2): + # uid-set = (uniqueid / uid-range) *("," uid-set) + # uid-range = (uniqueid ":" uniqueid) + # ; two uniqueid values and all values + # ; between these two regardless of order. + # ; Example: 2:4 and 4:2 are equivalent. + # uniqueid = nz-number + # ; Strictly ascending + def uid_set + case lookahead.symbol + when T_NUMBER then [match(T_NUMBER).value.to_i] + when T_ATOM + match(T_ATOM).value.split(',').flat_map do |element| + if element.include?(':') + Range.new(*element.split(':').map(&:to_i)).to_a + else + element.to_i + end + end + else + shift_token + nil + end + end + def nil_atom match(T_NIL) return nil diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index 339b68c3a..49969a74f 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -775,6 +775,100 @@ def test_id end end + def test_uid_expunge + server = create_tcp_server + port = server.addr[1] + requests = [] + start_server do + sock = server.accept + begin + sock.print("* OK test server\r\n") + requests.push(sock.gets) + sock.print("* 1 EXPUNGE\r\n") + sock.print("* 1 EXPUNGE\r\n") + sock.print("* 1 EXPUNGE\r\n") + sock.print("RUBY0001 OK UID EXPUNGE completed\r\n") + sock.gets + sock.print("* BYE terminating connection\r\n") + sock.print("RUBY0002 OK LOGOUT completed\r\n") + ensure + sock.close + server.close + end + end + + begin + imap = Net::IMAP.new(server_addr, :port => port) + response = imap.uid_expunge(1000..1003) + assert_equal("RUBY0001 UID EXPUNGE 1000:1003\r\n", requests.pop) + assert_equal(response, [1, 1, 1]) + imap.logout + ensure + imap.disconnect if imap + end + end + + def test_uidplus_responses + server = create_tcp_server + port = server.addr[1] + requests = [] + start_server do + sock = server.accept + begin + sock.print("* OK test server\r\n") + line = sock.gets + size = line.slice(/{(\d+)}\r\n/, 1).to_i + sock.print("+ Ready for literal data\r\n") + sock.read(size) + sock.gets + sock.print("RUBY0001 OK [APPENDUID 38505 3955] APPEND completed\r\n") + requests.push(sock.gets) + sock.print("RUBY0002 OK [COPYUID 38505 3955,3960:3962 3963:3966] " \ + "COPY completed\r\n") + requests.push(sock.gets) + sock.print("RUBY0003 OK [COPYUID 38505 3955 3967] COPY completed\r\n") + sock.gets + sock.print("* NO [UIDNOTSTICKY] Non-persistent UIDs\r\n") + sock.print("RUBY0004 OK SELECT completed\r\n") + sock.gets + sock.print("* BYE terminating connection\r\n") + sock.print("RUBY0005 OK LOGOUT completed\r\n") + ensure + sock.close + server.close + end + end + + begin + imap = Net::IMAP.new(server_addr, :port => port) + resp = imap.append("inbox", <<~EOF.gsub(/\n/, "\r\n"), [:Seen], Time.now) + Subject: hello + From: shugo@ruby-lang.org + To: shugo@ruby-lang.org + + hello world + EOF + assert_equal(resp, [38505, 3955]) + resp = imap.uid_copy([3955,3960..3962], 'trash') + assert_equal(requests.pop, "RUBY0002 UID COPY 3955,3960:3962 trash\r\n") + assert_equal( + resp, + [38505, [3955, 3960, 3961, 3962], [3963, 3964, 3965, 3966]] + ) + resp = imap.uid_copy(3955, 'trash') + assert_equal(requests.pop, "RUBY0003 UID COPY 3955 trash\r\n") + assert_equal(resp, [38505, [3955], [3967]]) + imap.select('trash') + assert_equal( + imap.responses["NO"].last.code, + Net::IMAP::ResponseCode.new('UIDNOTSTICKY', nil) + ) + imap.logout + ensure + imap.disconnect if imap + end + end + private def imaps_test