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