Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support UIDPLUS extension #65

Merged
merged 6 commits into from
Oct 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>@responses["EXISTS"][-1]</code>,
# and the number of recent messages from <code>@responses["RECENT"][-1]</code>.
# 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
Expand Down Expand Up @@ -752,14 +758,23 @@ 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
args.push(flags)
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
Expand All @@ -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 <em>sequence number</em>. These will be deleted from
# #responses and this method returns them as an array of
# <em>sequence number</em> 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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions lib/net/imap/response_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,12 @@ def resp_text
# "UIDNEXT" SP nz-number / "UIDVALIDITY" SP nz-number /
# "UNSEEN" SP nz-number /
# atom [SP 1*<any TEXT-CHAR except "]">]
#
# 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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions test/net/imap/test_imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down