Skip to content

Commit 60e57eb

Browse files
committed
Fix some UIDPLUS bugs and other UIDPLUS tweaks
* 🐛 Bug fixed: `uid-range` should behave the same, regardless of order. i.e. "2:4" and "4:2" are equivalent. * 🐛 Reversed a breaking change: The return values of Net::IMAP#copy, #move, #uid_copy, #uid_move, and #append are incompatible with all previously released versions. That would break any apps that expect the tagged response. TaggedResponse is more broadly useful anyway, since it contains the response code with its data. * Forward compatibility with `MULTIAPPEND`: I converted "append-uid" to parse a "uid-set" instead of a "uniqueid". This also provides a consistent interface: assigned_uids will always be an array of ints. * Replaced arrays with a `UIDPlusData` struct. In my opinion, it's much nicer to use a Struct than a "tuple". This also provides a home for documentation and utility methods. E.g. I also added UIDPlusData#uid_mapping, which returns a hash of {src_uid => dst_uid}. * Number parsing was converted from `to_i` to `Integer`. We matched the `uid-set` with a single `T_ATOM` token, so we haven't validated that it follows the rest of the grammar. This will still parse some invalid strings (e.g. `",:1,,:::,,"`), but in practice it's much more likely that a bogus atom would have at least one non-numeric character besides ":" and ",". (Although I've seen some crazy server bugs...)
1 parent 7e95dc3 commit 60e57eb

File tree

5 files changed

+179
-45
lines changed

5 files changed

+179
-45
lines changed

lib/net/imap.rb

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -778,12 +778,7 @@ def append(mailbox, message, flags = nil, date_time = nil)
778778
end
779779
args.push(date_time) if date_time
780780
args.push(Literal.new(message))
781-
synchronize do
782-
resp = send_command("APPEND", mailbox, *args)
783-
if resp.data.code && resp.data.code.name == "APPENDUID"
784-
return resp.data.code.data
785-
end
786-
end
781+
send_command("APPEND", mailbox, *args)
787782
end
788783

789784
# Sends a CHECK command to request a checkpoint of the currently
@@ -1459,12 +1454,7 @@ def store_internal(cmd, set, attr, flags)
14591454
end
14601455

14611456
def copy_internal(cmd, set, mailbox)
1462-
synchronize do
1463-
resp = send_command(cmd, MessageSet.new(set), mailbox)
1464-
if resp.data.code && resp.data.code.name == "COPYUID"
1465-
return resp.data.code.data
1466-
end
1467-
end
1457+
send_command(cmd, MessageSet.new(set), mailbox)
14681458
end
14691459

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

lib/net/imap/response_data.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,65 @@ class ResponseText < Struct.new(:code, :text)
113113
class ResponseCode < Struct.new(:name, :data)
114114
end
115115

116+
# Net::IMAP::UIDPlusData represents the ResponseCode#data that accompanies
117+
# the +APPENDUID+ and +COPYUID+ response codes.
118+
#
119+
# ==== Capability requirement
120+
#
121+
# The +UIDPLUS+ capability[rdoc-ref:Net::IMAP#capability] must be supported.
122+
# A server that supports +UIDPLUS+ should send a UIDPlusData object inside
123+
# every TaggedResponse returned by the append[rdoc-ref:Net::IMAP#append],
124+
# copy[rdoc-ref:Net::IMAP#copy], move[rdoc-ref:Net::IMAP#move], {uid
125+
# copy}[rdoc-ref:Net::IMAP#uid_copy], and {uid
126+
# move}[rdoc-ref:Net::IMAP#uid_move] commands---unless the destination
127+
# mailbox reports +UIDNOTSTICKY+.
128+
#
129+
#--
130+
# TODO: support MULTIAPPEND
131+
#++
132+
#
133+
# ==== References
134+
#
135+
# [UIDPLUS[https://www.rfc-editor.org/rfc/rfc4315.html]]::
136+
# Crispin, M., "Internet Message Access Protocol (IMAP) - UIDPLUS
137+
# extension", RFC 4315, DOI 10.17487/RFC4315, December 2005,
138+
# <https://www.rfc-editor.org/info/rfc4315>.
139+
#
140+
class UIDPlusData < Struct.new(:uidvalidity, :source_uids, :assigned_uids)
141+
##
142+
# method: uidvalidity
143+
# :call-seq: uidvalidity -> nonzero uint32
144+
#
145+
# The UIDVALIDITY of the destination mailbox.
146+
147+
##
148+
# method: source_uids
149+
# :call-seq: source_uids -> nil or an array of nonzero uint32
150+
#
151+
# The UIDs of the copied or moved messages.
152+
#
153+
# Note:: Returns +nil+ for Net::IMAP#append.
154+
155+
##
156+
# method: assigned_uids
157+
# :call-seq: assigned_uids -> an array of nonzero uint32
158+
#
159+
# The newly assigned UIDs of the copied, moved, or appended messages.
160+
#
161+
# Note:: This always returns an array, even when it contains only one UID.
162+
163+
##
164+
# :call-seq: uid_mapping -> nil or a hash
165+
#
166+
# Returns a hash mapping each source UID to the newly assigned destination
167+
# UID.
168+
#
169+
# Note:: Returns +nil+ for Net::IMAP#append.
170+
def uid_mapping
171+
source_uids&.zip(assigned_uids)&.to_h
172+
end
173+
end
174+
116175
# Net::IMAP::MailboxList represents contents of the LIST response.
117176
#
118177
# mailbox_list ::= "(" #("\Marked" / "\Noinferiors" /

lib/net/imap/response_parser.rb

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,11 +1104,8 @@ def resp_text
11041104
# "UNSEEN" SP nz-number /
11051105
# atom [SP 1*<any TEXT-CHAR except "]">]
11061106
#
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"
1107+
# +UIDPLUS+ ABNF:: https://www.rfc-editor.org/rfc/rfc4315.html#section-4
1108+
# resp-text-code =/ resp-code-apnd / resp-code-copy / "UIDNOTSTICKY"
11121109
def resp_text_code
11131110
token = match(T_ATOM)
11141111
name = token.value.upcase
@@ -1126,19 +1123,9 @@ def resp_text_code
11261123
match(T_SPACE)
11271124
result = ResponseCode.new(name, number)
11281125
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])
1126+
result = ResponseCode.new(name, resp_code_apnd__data)
11341127
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])
1128+
result = ResponseCode.new(name, resp_code_copy__data)
11421129
else
11431130
token = lookahead
11441131
if token.symbol == T_SPACE
@@ -1165,6 +1152,34 @@ def charset_list
11651152
result
11661153
end
11671154

1155+
# already matched: "APPENDUID"
1156+
#
1157+
# +UIDPLUS+ ABNF:: https://www.rfc-editor.org/rfc/rfc4315.html#section-4
1158+
# resp-code-apnd = "APPENDUID" SP nz-number SP append-uid
1159+
# append-uid = uniqueid
1160+
# append-uid =/ uid-set
1161+
# ; only permitted if client uses [MULTIAPPEND]
1162+
# ; to append multiple messages.
1163+
#
1164+
# n.b, uniqueid ⊂ uid-set. To avoid inconsistent return types, we always
1165+
# match uid_set even if that returns a single-member array.
1166+
#
1167+
def resp_code_apnd__data
1168+
match(T_SPACE); validity = number
1169+
match(T_SPACE); dst_uids = uid_set # uniqueid ⊂ uid-set
1170+
UIDPlusData.new(validity, nil, dst_uids)
1171+
end
1172+
1173+
# already matched: "COPYUID"
1174+
#
1175+
# resp-code-copy = "COPYUID" SP nz-number SP uid-set SP uid-set
1176+
def resp_code_copy__data
1177+
match(T_SPACE); validity = number
1178+
match(T_SPACE); src_uids = uid_set
1179+
match(T_SPACE); dst_uids = uid_set
1180+
UIDPlusData.new(validity, src_uids, dst_uids)
1181+
end
1182+
11681183
def address_list
11691184
token = lookahead
11701185
if token.symbol == T_NIL
@@ -1350,19 +1365,14 @@ def number
13501365
# uniqueid = nz-number
13511366
# ; Strictly ascending
13521367
def uid_set
1353-
case lookahead.symbol
1354-
when T_NUMBER then [match(T_NUMBER).value.to_i]
1368+
token = match(T_NUMBER, T_ATOM)
1369+
case token.symbol
1370+
when T_NUMBER then [Integer(token.value)]
13551371
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
1372+
token.value.split(",").flat_map {|range|
1373+
range = range.split(":").map {|uniqueid| Integer(uniqueid) }
1374+
range.size == 1 ? range : Range.new(range.min, range.max).to_a
1375+
}
13661376
end
13671377
end
13681378

test/net/imap/test_imap.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -848,16 +848,16 @@ def test_uidplus_responses
848848
849849
hello world
850850
EOF
851-
assert_equal(resp, [38505, 3955])
851+
assert_equal([38505, nil, [3955]], resp.data.code.data.to_a)
852852
resp = imap.uid_copy([3955,3960..3962], 'trash')
853853
assert_equal(requests.pop, "RUBY0002 UID COPY 3955,3960:3962 trash\r\n")
854854
assert_equal(
855-
resp,
856-
[38505, [3955, 3960, 3961, 3962], [3963, 3964, 3965, 3966]]
855+
[38505, [3955, 3960, 3961, 3962], [3963, 3964, 3965, 3966]],
856+
resp.data.code.data.to_a
857857
)
858858
resp = imap.uid_copy(3955, 'trash')
859859
assert_equal(requests.pop, "RUBY0003 UID COPY 3955 trash\r\n")
860-
assert_equal(resp, [38505, [3955], [3967]])
860+
assert_equal([38505, [3955], [3967]], resp.data.code.data.to_a)
861861
imap.select('trash')
862862
assert_equal(
863863
imap.responses["NO"].last.code,

test/net/imap/test_imap_response_parser.rb

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,4 +363,79 @@ def test_namespace
363363
assert_equal("/", namespace.delim)
364364
assert_equal({"X-PARAM" => ["FLAG1", "FLAG2"]}, namespace.extensions)
365365
end
366+
367+
def test_uidplus_appenduid
368+
parser = Net::IMAP::ResponseParser.new
369+
# RFC4315 example
370+
response = parser.parse(
371+
"A003 OK [APPENDUID 38505 3955] APPEND completed\r\n"
372+
)
373+
code = response.data.code
374+
assert_equal "APPENDUID", code.name
375+
assert_kind_of Net::IMAP::UIDPlusData, code.data
376+
assert_equal Net::IMAP::UIDPlusData.new(38505, nil, [3955]), code.data
377+
assert_equal "APPENDUID", code.name
378+
assert_kind_of Net::IMAP::UIDPlusData, code.data
379+
assert_equal Net::IMAP::UIDPlusData.new(38505, nil, [3955]), code.data
380+
# MULTIAPPEND compatibility:
381+
response = parser.parse(
382+
"A003 OK [APPENDUID 2 4,6:7,9] APPEND completed\r\n"
383+
)
384+
code = response.data.code
385+
assert_equal "APPENDUID", code.name
386+
assert_kind_of Net::IMAP::UIDPlusData, code.data
387+
assert_equal Net::IMAP::UIDPlusData.new(2, nil, [4, 6, 7, 9]), code.data
388+
end
389+
390+
def test_uidplus_copyuid
391+
parser = Net::IMAP::ResponseParser.new
392+
# RFC4315 example, but using mixed case "copyUID".
393+
response = parser.parse(
394+
"A004 OK [copyUID 38505 304,319:320 3956:3958] Done\r\n"
395+
)
396+
code = response.data.code
397+
assert_equal "COPYUID", code.name
398+
assert_kind_of Net::IMAP::UIDPlusData, code.data
399+
assert_equal Net::IMAP::UIDPlusData.new(
400+
38505, [304, 319, 320], [3956, 3957, 3958]
401+
), code.data
402+
end
403+
404+
# From RFC4315 ABNF:
405+
# > and all values between these two *regardless of order*.
406+
# > Example: 2:4 and 4:2 are equivalent.
407+
def test_uidplus_copyuid__reversed_ranges
408+
parser = Net::IMAP::ResponseParser.new
409+
response = parser.parse(
410+
"A004 OK [copyUID 9999 20:19,500:495 92:97,101:100] Done\r\n"
411+
)
412+
code = response.data.code
413+
assert_equal Net::IMAP::UIDPlusData.new(
414+
9999,
415+
[19, 20, 495, 496, 497, 498, 499, 500],
416+
[92, 93, 94, 95, 96, 97, 100, 101]
417+
), code.data
418+
end
419+
420+
def test_uidplus_copyuid__uid_mapping
421+
parser = Net::IMAP::ResponseParser.new
422+
response = parser.parse(
423+
"A004 OK [copyUID 9999 20:19,500:495 92:97,101:100] Done\r\n"
424+
)
425+
code = response.data.code
426+
assert_equal(
427+
{
428+
19 => 92,
429+
20 => 93,
430+
495 => 94,
431+
496 => 95,
432+
497 => 96,
433+
498 => 97,
434+
499 => 100,
435+
500 => 101,
436+
},
437+
code.data.uid_mapping
438+
)
439+
end
440+
366441
end

0 commit comments

Comments
 (0)