Skip to content

Commit a582798

Browse files
authored
Merge UIDPLUS support (PR #65) from adigi-ai/uidplus
Support `UIDPLUS` extension
2 parents 174e35c + 37fc4c9 commit a582798

File tree

3 files changed

+197
-4
lines changed

3 files changed

+197
-4
lines changed

lib/net/imap.rb

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -435,14 +435,20 @@ def login(user, password)
435435
# in the +mailbox+ can be accessed.
436436
#
437437
# After you have selected a mailbox, you may retrieve the
438-
# number of items in that mailbox from +@responses["EXISTS"][-1]+,
439-
# and the number of recent messages from +@responses["RECENT"][-1]+.
438+
# number of items in that mailbox from <code>@responses["EXISTS"][-1]</code>,
439+
# and the number of recent messages from <code>@responses["RECENT"][-1]</code>.
440440
# Note that these values can change if new messages arrive
441441
# during a session; see #add_response_handler for a way of
442442
# detecting this event.
443443
#
444444
# A Net::IMAP::NoResponseError is raised if the mailbox does not
445445
# exist or is for some reason non-selectable.
446+
#
447+
# If the server supports the [UIDPLUS[https://www.rfc-editor.org/rfc/rfc4315.html]]
448+
# extension it may return an additional "NO" response with a "UIDNOTSTICKY" response code
449+
# indicating that the mailstore does not support persistent UIDs
450+
# [1[https://www.rfc-editor.org/rfc/rfc4315.html#page-4]]:
451+
# @responses["NO"].last.code.name == "UIDNOTSTICKY"
446452
def select(mailbox)
447453
synchronize do
448454
@responses.clear
@@ -752,14 +758,23 @@ def status(mailbox, attr)
752758
# A Net::IMAP::NoResponseError is raised if the mailbox does
753759
# not exist (it is not created automatically), or if the flags,
754760
# date_time, or message arguments contain errors.
761+
#
762+
# If the server supports the [UIDPLUS[https://www.rfc-editor.org/rfc/rfc4315.html]]
763+
# extension it returns an array with the UIDVALIDITY and the assigned UID of the
764+
# appended message.
755765
def append(mailbox, message, flags = nil, date_time = nil)
756766
args = []
757767
if flags
758768
args.push(flags)
759769
end
760770
args.push(date_time) if date_time
761771
args.push(Literal.new(message))
762-
send_command("APPEND", mailbox, *args)
772+
synchronize do
773+
resp = send_command("APPEND", mailbox, *args)
774+
if resp.data.code && resp.data.code.name == "APPENDUID"
775+
return resp.data.code.data
776+
end
777+
end
763778
end
764779

765780
# Sends a CHECK command to request a checkpoint of the currently
@@ -786,6 +801,32 @@ def expunge
786801
end
787802
end
788803

804+
# Similar to #expunge, but takes a set of unique identifiers as
805+
# argument. Sends a UID EXPUNGE command to permanently remove all
806+
# messages that have both the \\Deleted flag set and a UID that is
807+
# included in +uid_set+.
808+
#
809+
# By using UID EXPUNGE instead of EXPUNGE when resynchronizing with
810+
# the server, the client can ensure that it does not inadvertantly
811+
# remove any messages that have been marked as \\Deleted by other
812+
# clients between the time that the client was last connected and
813+
# the time the client resynchronizes.
814+
#
815+
# Note:: Although the command takes a +uid_set+ for its argument, the
816+
# server still returns regular EXPUNGE responses, which contain
817+
# a <em>sequence number</em>. These will be deleted from
818+
# #responses and this method returns them as an array of
819+
# <em>sequence number</em> integers.
820+
#
821+
# ==== Required capability
822+
# +UIDPLUS+ - described in [UIDPLUS[https://www.rfc-editor.org/rfc/rfc4315.html]].
823+
def uid_expunge(uid_set)
824+
synchronize do
825+
send_command("UID EXPUNGE", MessageSet.new(uid_set))
826+
return @responses.delete("EXPUNGE")
827+
end
828+
end
829+
789830
# Sends a SEARCH command to search the mailbox for messages that
790831
# match the given searching criteria, and returns message sequence
791832
# numbers. +keys+ can either be a string holding the entire
@@ -906,6 +947,10 @@ def uid_store(set, attr, flags)
906947
# of the specified destination +mailbox+. The +set+ parameter is
907948
# a number, an array of numbers, or a Range object. The number is
908949
# a message sequence number.
950+
#
951+
# If the server supports the [UIDPLUS[https://www.rfc-editor.org/rfc/rfc4315.html]]
952+
# extension it returns an array with the UIDVALIDITY, the UID set of the source messages
953+
# and the assigned UID set of the copied messages.
909954
def copy(set, mailbox)
910955
copy_internal("COPY", set, mailbox)
911956
end
@@ -921,6 +966,10 @@ def uid_copy(set, mailbox)
921966
# a message sequence number.
922967
#
923968
# The MOVE extension is described in [EXT-MOVE[https://tools.ietf.org/html/rfc6851]].
969+
#
970+
# If the server supports the [UIDPLUS[https://www.rfc-editor.org/rfc/rfc4315.html]]
971+
# extension it returns an array with the UIDVALIDITY, the UID set of the source messages
972+
# and the assigned UID set of the moved messages.
924973
def move(set, mailbox)
925974
copy_internal("MOVE", set, mailbox)
926975
end
@@ -1383,7 +1432,12 @@ def store_internal(cmd, set, attr, flags)
13831432
end
13841433

13851434
def copy_internal(cmd, set, mailbox)
1386-
send_command(cmd, MessageSet.new(set), mailbox)
1435+
synchronize do
1436+
resp = send_command(cmd, MessageSet.new(set), mailbox)
1437+
if resp.data.code && resp.data.code.name == "COPYUID"
1438+
return resp.data.code.data
1439+
end
1440+
end
13871441
end
13881442

13891443
def sort_internal(cmd, sort_keys, search_keys, charset)

lib/net/imap/response_parser.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,12 @@ def resp_text
11031103
# "UIDNEXT" SP nz-number / "UIDVALIDITY" SP nz-number /
11041104
# "UNSEEN" SP nz-number /
11051105
# atom [SP 1*<any TEXT-CHAR except "]">]
1106+
#
1107+
# See https://datatracker.ietf.org/doc/html/rfc4315#section-6.4 for UIDPLUS extension
1108+
#
1109+
# resp-code-apnd = "APPENDUID" SP nz-number SP append-uid
1110+
# resp-code-copy = "COPYUID" SP nz-number SP uid-set SP uid-set
1111+
# resp-text-code =/ resp-code-apnd / resp-code-copy / "UIDNOTSTICKY"
11061112
def resp_text_code
11071113
token = match(T_ATOM)
11081114
name = token.value.upcase
@@ -1119,6 +1125,20 @@ def resp_text_code
11191125
when /\A(?:UIDVALIDITY|UIDNEXT|UNSEEN)\z/n
11201126
match(T_SPACE)
11211127
result = ResponseCode.new(name, number)
1128+
when /\A(?:APPENDUID)\z/n
1129+
match(T_SPACE)
1130+
uidvalidity = number
1131+
match(T_SPACE)
1132+
append_uid = number
1133+
result = ResponseCode.new(name, [uidvalidity, append_uid])
1134+
when /\A(?:COPYUID)\z/n
1135+
match(T_SPACE)
1136+
uidvalidity = number
1137+
match(T_SPACE)
1138+
from_uid = uid_set
1139+
match(T_SPACE)
1140+
to_uid = uid_set
1141+
result = ResponseCode.new(name, [uidvalidity, from_uid, to_uid])
11221142
else
11231143
token = lookahead
11241144
if token.symbol == T_SPACE
@@ -1321,6 +1341,31 @@ def number
13211341
return token.value.to_i
13221342
end
13231343

1344+
# RFC-4315 (UIDPLUS) or RFC9051 (IMAP4rev2):
1345+
# uid-set = (uniqueid / uid-range) *("," uid-set)
1346+
# uid-range = (uniqueid ":" uniqueid)
1347+
# ; two uniqueid values and all values
1348+
# ; between these two regardless of order.
1349+
# ; Example: 2:4 and 4:2 are equivalent.
1350+
# uniqueid = nz-number
1351+
# ; Strictly ascending
1352+
def uid_set
1353+
case lookahead.symbol
1354+
when T_NUMBER then [match(T_NUMBER).value.to_i]
1355+
when T_ATOM
1356+
match(T_ATOM).value.split(',').flat_map do |element|
1357+
if element.include?(':')
1358+
Range.new(*element.split(':').map(&:to_i)).to_a
1359+
else
1360+
element.to_i
1361+
end
1362+
end
1363+
else
1364+
shift_token
1365+
nil
1366+
end
1367+
end
1368+
13241369
def nil_atom
13251370
match(T_NIL)
13261371
return nil

test/net/imap/test_imap.rb

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,100 @@ def test_id
775775
end
776776
end
777777

778+
def test_uid_expunge
779+
server = create_tcp_server
780+
port = server.addr[1]
781+
requests = []
782+
start_server do
783+
sock = server.accept
784+
begin
785+
sock.print("* OK test server\r\n")
786+
requests.push(sock.gets)
787+
sock.print("* 1 EXPUNGE\r\n")
788+
sock.print("* 1 EXPUNGE\r\n")
789+
sock.print("* 1 EXPUNGE\r\n")
790+
sock.print("RUBY0001 OK UID EXPUNGE completed\r\n")
791+
sock.gets
792+
sock.print("* BYE terminating connection\r\n")
793+
sock.print("RUBY0002 OK LOGOUT completed\r\n")
794+
ensure
795+
sock.close
796+
server.close
797+
end
798+
end
799+
800+
begin
801+
imap = Net::IMAP.new(server_addr, :port => port)
802+
response = imap.uid_expunge(1000..1003)
803+
assert_equal("RUBY0001 UID EXPUNGE 1000:1003\r\n", requests.pop)
804+
assert_equal(response, [1, 1, 1])
805+
imap.logout
806+
ensure
807+
imap.disconnect if imap
808+
end
809+
end
810+
811+
def test_uidplus_responses
812+
server = create_tcp_server
813+
port = server.addr[1]
814+
requests = []
815+
start_server do
816+
sock = server.accept
817+
begin
818+
sock.print("* OK test server\r\n")
819+
line = sock.gets
820+
size = line.slice(/{(\d+)}\r\n/, 1).to_i
821+
sock.print("+ Ready for literal data\r\n")
822+
sock.read(size)
823+
sock.gets
824+
sock.print("RUBY0001 OK [APPENDUID 38505 3955] APPEND completed\r\n")
825+
requests.push(sock.gets)
826+
sock.print("RUBY0002 OK [COPYUID 38505 3955,3960:3962 3963:3966] " \
827+
"COPY completed\r\n")
828+
requests.push(sock.gets)
829+
sock.print("RUBY0003 OK [COPYUID 38505 3955 3967] COPY completed\r\n")
830+
sock.gets
831+
sock.print("* NO [UIDNOTSTICKY] Non-persistent UIDs\r\n")
832+
sock.print("RUBY0004 OK SELECT completed\r\n")
833+
sock.gets
834+
sock.print("* BYE terminating connection\r\n")
835+
sock.print("RUBY0005 OK LOGOUT completed\r\n")
836+
ensure
837+
sock.close
838+
server.close
839+
end
840+
end
841+
842+
begin
843+
imap = Net::IMAP.new(server_addr, :port => port)
844+
resp = imap.append("inbox", <<~EOF.gsub(/\n/, "\r\n"), [:Seen], Time.now)
845+
Subject: hello
846+
847+
848+
849+
hello world
850+
EOF
851+
assert_equal(resp, [38505, 3955])
852+
resp = imap.uid_copy([3955,3960..3962], 'trash')
853+
assert_equal(requests.pop, "RUBY0002 UID COPY 3955,3960:3962 trash\r\n")
854+
assert_equal(
855+
resp,
856+
[38505, [3955, 3960, 3961, 3962], [3963, 3964, 3965, 3966]]
857+
)
858+
resp = imap.uid_copy(3955, 'trash')
859+
assert_equal(requests.pop, "RUBY0003 UID COPY 3955 trash\r\n")
860+
assert_equal(resp, [38505, [3955], [3967]])
861+
imap.select('trash')
862+
assert_equal(
863+
imap.responses["NO"].last.code,
864+
Net::IMAP::ResponseCode.new('UIDNOTSTICKY', nil)
865+
)
866+
imap.logout
867+
ensure
868+
imap.disconnect if imap
869+
end
870+
end
871+
778872
private
779873

780874
def imaps_test

0 commit comments

Comments
 (0)