Skip to content

Commit 26436f3

Browse files
authored
🔀 Merge pull request #616 from ruby/add-binary-append
✨ Support `BINARY` extention to `#append` (RFC3516)
2 parents 2e59317 + 1eabb75 commit 26436f3

File tree

5 files changed

+274
-96
lines changed

5 files changed

+274
-96
lines changed

‎lib/net/imap.rb‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -486,9 +486,7 @@ module Net
486486
# IMAP4rev2[https://www.rfc-editor.org/rfc/rfc9051].
487487
# - Updates #fetch and #uid_fetch with the +BINARY+, +BINARY.PEEK+, and
488488
# +BINARY.SIZE+ items. See FetchData#binary and FetchData#binary_size.
489-
#
490-
# >>>
491-
# *NOTE:* The binary extension the #append command is _not_ supported yet.
489+
# - Updates #append to allow binary messages containing +NULL+ bytes.
492490
#
493491
# ==== RFC3691: +UNSELECT+
494492
# Folded into IMAP4rev2[https://www.rfc-editor.org/rfc/rfc9051] and also included
@@ -2044,6 +2042,11 @@ def status(mailbox, attr)
20442042
#
20452043
# ==== Capabilities
20462044
#
2045+
# If +BINARY+ [RFC3516[https://www.rfc-editor.org/rfc/rfc3516.html]] is
2046+
# supported by the server, +message+ may contain +NULL+ characters and
2047+
# be sent as a binary literal. Otherwise, binary message parts must be
2048+
# encoded appropriately (for example, +base64+).
2049+
#
20472050
# If +UIDPLUS+ [RFC4315[https://www.rfc-editor.org/rfc/rfc4315.html]] is
20482051
# supported and the destination supports persistent UIDs, the server's
20492052
# response should include an +APPENDUID+ response code with AppendUIDData.
@@ -2054,12 +2057,11 @@ def status(mailbox, attr)
20542057
# TODO: add MULTIAPPEND support
20552058
#++
20562059
def append(mailbox, message, flags = nil, date_time = nil)
2060+
message = StringFormatter.literal_or_literal8(message, name: "message")
20572061
args = []
2058-
if flags
2059-
args.push(flags)
2060-
end
2062+
args.push(flags) if flags
20612063
args.push(date_time) if date_time
2062-
args.push(Literal.new(message))
2064+
args.push(message)
20632065
send_command("APPEND", mailbox, *args)
20642066
end
20652067

‎lib/net/imap/command_data.rb‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,12 @@ def send_quoted_string(str)
7777
put_string('"' + str.gsub(/["\\]/, "\\\\\\&") + '"')
7878
end
7979

80-
def send_literal(str, tag = nil)
80+
def send_binary_literal(str, tag) = send_literal(str, tag, binary: true)
81+
82+
def send_literal(str, tag = nil, binary: false)
8183
synchronize do
82-
put_string("{" + str.bytesize.to_s + "}" + CRLF)
84+
prefix = "~" if binary
85+
put_string("#{prefix}{#{str.bytesize}}\r\n")
8386
@continued_command_tag = tag
8487
@continuation_request_exception = nil
8588
begin
@@ -147,11 +150,35 @@ def send_data(imap, tag)
147150
end
148151

149152
class Literal < CommandData # :nodoc:
153+
def initialize(data:)
154+
data = -String(data.to_str).b or
155+
raise DataFormatError, "#{self.class} expects string input"
156+
super
157+
validate
158+
end
159+
160+
def bytesize = data.bytesize
161+
162+
def validate
163+
if data.include?("\0")
164+
raise DataFormatError, "NULL byte not allowed in #{self.class}. " \
165+
"Use #{Literal8} or a null-safe encoding."
166+
end
167+
end
168+
150169
def send_data(imap, tag)
151170
imap.__send__(:send_literal, data, tag)
152171
end
153172
end
154173

174+
class Literal8 < Literal # :nodoc:
175+
def validate = nil # all bytes are okay
176+
177+
def send_data(imap, tag)
178+
imap.__send__(:send_binary_literal, data, tag)
179+
end
180+
end
181+
155182
class PartialRange < CommandData # :nodoc:
156183
uint32_max = 2**32 - 1
157184
POS_RANGE = 1..uint32_max
@@ -221,6 +248,14 @@ module StringFormatter
221248

222249
module_function
223250

251+
def literal_or_literal8(input, name: "argument")
252+
return input if input in Literal | Literal8
253+
data = String.try_convert(input) \
254+
or raise TypeError, "expected #{name} to be String, got #{input.class}"
255+
type = data.include?("\0") ? Literal8 : Literal
256+
type.new(data:)
257+
end
258+
224259
# Allows symbols in addition to strings
225260
def valid_string?(str)
226261
str.is_a?(Symbol) || str.respond_to?(:to_str)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
require "test/unit"
5+
6+
class CommandDataTest < Net::IMAP::TestCase
7+
DataFormatError = Net::IMAP::DataFormatError
8+
9+
Literal = Net::IMAP::Literal
10+
Literal8 = Net::IMAP::Literal8
11+
12+
Output = Data.define(:name, :args)
13+
TAG = Module.new.freeze
14+
15+
class FakeCommandWriter
16+
def self.def_printer(name)
17+
unless Net::IMAP.instance_methods.include?(name) ||
18+
Net::IMAP.private_instance_methods.include?(name)
19+
raise NoMethodError, "#{name} is not a method on Net::IMAP"
20+
end
21+
define_method(name) do |*args|
22+
output << Output[name:, args:]
23+
end
24+
Output.define_singleton_method(name) do |*args|
25+
new(name:, args:)
26+
end
27+
end
28+
29+
attr_reader :output
30+
31+
def initialize
32+
@output = []
33+
end
34+
35+
def clear = @output.clear
36+
def validate(*data) = data.each(&:validate)
37+
def send_data(*data, tag: TAG)
38+
validate(*data)
39+
data.each do _1.send_data(self, tag) end
40+
end
41+
42+
def_printer :put_string
43+
def_printer :send_string_data
44+
def_printer :send_number_data
45+
def_printer :send_list_data
46+
def_printer :send_time_data
47+
def_printer :send_date_data
48+
def_printer :send_quoted_string
49+
def_printer :send_literal
50+
def_printer :send_binary_literal
51+
end
52+
53+
test "Literal" do
54+
imap = FakeCommandWriter.new
55+
imap.send_data Literal["foo\r\nbar"]
56+
assert_equal [
57+
Output.send_literal("foo\r\nbar", TAG),
58+
], imap.output
59+
60+
imap.clear
61+
assert_raise_with_message(Net::IMAP::DataFormatError, /\bNULL byte\b/i) do
62+
imap.send_data Literal["contains NULL char: \0"]
63+
end
64+
assert_empty imap.output
65+
end
66+
67+
test "Literal8" do
68+
imap = FakeCommandWriter.new
69+
imap.send_data Literal8["foo\r\nbar"], Literal8["foo\0bar"]
70+
assert_equal [
71+
Output.send_binary_literal("foo\r\nbar", TAG),
72+
Output.send_binary_literal("foo\0bar", TAG),
73+
], imap.output
74+
end
75+
76+
class StringFormatterTest < Net::IMAP::TestCase
77+
include Net::IMAP::StringFormatter
78+
79+
test "literal_or_literal8" do
80+
assert_kind_of Literal, literal_or_literal8("simple\r\n")
81+
assert_kind_of Literal8, literal_or_literal8("has NULL \0")
82+
assert_kind_of Literal, literal_or_literal8(Literal["foo"])
83+
assert_kind_of Literal8, literal_or_literal8(Literal8["foo"])
84+
end
85+
end
86+
87+
end

‎test/net/imap/test_imap.rb‎

Lines changed: 0 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -736,93 +736,6 @@ def test_disconnect
736736
end
737737
end
738738

739-
def test_append
740-
server = create_tcp_server
741-
port = server.addr[1]
742-
mail = <<EOF.gsub(/\n/, "\r\n")
743-
From: shugo@example.com
744-
To: matz@example.com
745-
Subject: hello
746-
747-
hello world
748-
EOF
749-
requests = []
750-
received_mail = nil
751-
start_server do
752-
sock = server.accept
753-
begin
754-
sock.print("* OK test server\r\n")
755-
line = sock.gets
756-
requests.push(line)
757-
size = line.slice(/{(\d+)}\r\n/, 1).to_i
758-
sock.print("+ Ready for literal data\r\n")
759-
received_mail = sock.read(size)
760-
sock.gets
761-
sock.print("RUBY0001 OK APPEND completed\r\n")
762-
requests.push(sock.gets)
763-
sock.print("* BYE terminating connection\r\n")
764-
sock.print("RUBY0002 OK LOGOUT completed\r\n")
765-
ensure
766-
sock.close
767-
server.close
768-
end
769-
end
770-
771-
begin
772-
imap = Net::IMAP.new(server_addr, :port => port)
773-
imap.append("INBOX", mail)
774-
assert_equal(1, requests.length)
775-
assert_equal("RUBY0001 APPEND INBOX {#{mail.size}}\r\n", requests[0])
776-
assert_equal(mail, received_mail)
777-
imap.logout
778-
assert_equal(2, requests.length)
779-
assert_equal("RUBY0002 LOGOUT\r\n", requests[1])
780-
ensure
781-
imap.disconnect if imap
782-
end
783-
end
784-
785-
def test_append_fail
786-
server = create_tcp_server
787-
port = server.addr[1]
788-
mail = <<EOF.gsub(/\n/, "\r\n")
789-
From: shugo@example.com
790-
To: matz@example.com
791-
Subject: hello
792-
793-
hello world
794-
EOF
795-
requests = []
796-
start_server do
797-
sock = server.accept
798-
begin
799-
sock.print("* OK test server\r\n")
800-
requests.push(sock.gets)
801-
sock.print("RUBY0001 NO Mailbox doesn't exist\r\n")
802-
requests.push(sock.gets)
803-
sock.print("* BYE terminating connection\r\n")
804-
sock.print("RUBY0002 OK LOGOUT completed\r\n")
805-
ensure
806-
sock.close
807-
server.close
808-
end
809-
end
810-
811-
begin
812-
imap = Net::IMAP.new(server_addr, :port => port)
813-
assert_raise(Net::IMAP::NoResponseError) do
814-
imap.append("INBOX", mail)
815-
end
816-
assert_equal(1, requests.length)
817-
assert_equal("RUBY0001 APPEND INBOX {#{mail.size}}\r\n", requests[0])
818-
imap.logout
819-
assert_equal(2, requests.length)
820-
assert_equal("RUBY0002 LOGOUT\r\n", requests[1])
821-
ensure
822-
imap.disconnect if imap
823-
end
824-
end
825-
826739
def test_id
827740
server = create_tcp_server
828741
port = server.addr[1]

0 commit comments

Comments
 (0)