From 48476f68a01d331e83b44d31871c405b76bfdfa6 Mon Sep 17 00:00:00 2001 From: nick evans Date: Fri, 20 Feb 2026 09:39:29 -0500 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=85=F0=9F=9A=9A=20Move=20`#append`=20?= =?UTF-8?q?tests=20to=20their=20own=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/net/imap/test_imap.rb | 87 ---------------------- test/net/imap/test_imap_append.rb | 115 ++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 87 deletions(-) create mode 100644 test/net/imap/test_imap_append.rb diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index ad9b453f..9f6a1215 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -736,93 +736,6 @@ def test_disconnect end end - def test_append - server = create_tcp_server - port = server.addr[1] - mail = < port) - imap.append("INBOX", mail) - assert_equal(1, requests.length) - assert_equal("RUBY0001 APPEND INBOX {#{mail.size}}\r\n", requests[0]) - assert_equal(mail, received_mail) - imap.logout - assert_equal(2, requests.length) - assert_equal("RUBY0002 LOGOUT\r\n", requests[1]) - ensure - imap.disconnect if imap - end - end - - def test_append_fail - server = create_tcp_server - port = server.addr[1] - mail = < port) - assert_raise(Net::IMAP::NoResponseError) do - imap.append("INBOX", mail) - end - assert_equal(1, requests.length) - assert_equal("RUBY0001 APPEND INBOX {#{mail.size}}\r\n", requests[0]) - imap.logout - assert_equal(2, requests.length) - assert_equal("RUBY0002 LOGOUT\r\n", requests[1]) - ensure - imap.disconnect if imap - end - end - def test_id server = create_tcp_server port = server.addr[1] diff --git a/test/net/imap/test_imap_append.rb b/test/net/imap/test_imap_append.rb new file mode 100644 index 00000000..a2b09bc4 --- /dev/null +++ b/test/net/imap/test_imap_append.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" +require_relative "fake_server" + +class IMAPAppendTest < Net::IMAP::TestCase + include Net::IMAP::FakeServer::TestHelper + + def test_append + server = create_tcp_server + port = server.addr[1] + mail = < port) + imap.append("INBOX", mail) + assert_equal(1, requests.length) + assert_equal("RUBY0001 APPEND INBOX {#{mail.size}}\r\n", requests[0]) + assert_equal(mail, received_mail) + imap.logout + assert_equal(2, requests.length) + assert_equal("RUBY0002 LOGOUT\r\n", requests[1]) + ensure + imap.disconnect if imap + end + end + + def test_append_fail + server = create_tcp_server + port = server.addr[1] + mail = < port) + assert_raise(Net::IMAP::NoResponseError) do + imap.append("INBOX", mail) + end + assert_equal(1, requests.length) + assert_equal("RUBY0001 APPEND INBOX {#{mail.size}}\r\n", requests[0]) + imap.logout + assert_equal(2, requests.length) + assert_equal("RUBY0002 LOGOUT\r\n", requests[1]) + ensure + imap.disconnect if imap + end + end + + private + + def start_server + th = Thread.new do + yield + end + @threads << th + sleep 0.1 until th.stop? + end + + def create_tcp_server + return TCPServer.new(server_addr, 0) + end + + def server_addr + Addrinfo.tcp("localhost", 0).ip_address + end + +end From 77e74b10d0b49d18c5e46230a4ecabee2909ddfc Mon Sep 17 00:00:00 2001 From: nick evans Date: Fri, 20 Feb 2026 11:07:25 -0500 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=85=E2=99=BB=EF=B8=8F=20Update=20exis?= =?UTF-8?q?ting=20#append=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/net/imap/test_imap_append.rb | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/net/imap/test_imap_append.rb b/test/net/imap/test_imap_append.rb index a2b09bc4..21cdcdd5 100644 --- a/test/net/imap/test_imap_append.rb +++ b/test/net/imap/test_imap_append.rb @@ -7,16 +7,16 @@ class IMAPAppendTest < Net::IMAP::TestCase include Net::IMAP::FakeServer::TestHelper - def test_append + test "#append" do server = create_tcp_server port = server.addr[1] - mail = < Date: Thu, 19 Feb 2026 18:30:18 -0500 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=A5=85=20Validate=20`Literal`=20data?= =?UTF-8?q?=20doesn't=20contain=20NULL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Net::IMAP::Literal` is currently only used by `#append` and `#id`, but more code will be refactored to use it in the (near?) future. --- lib/net/imap/command_data.rb | 15 +++++++ test/net/imap/test_command_data.rb | 64 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 test/net/imap/test_command_data.rb diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 02b9a61b..ec3c0f10 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -147,6 +147,21 @@ def send_data(imap, tag) end class Literal < CommandData # :nodoc: + def initialize(data:) + data = -String(data.to_str).b or + raise DataFormatError, "#{self.class} expects string input" + super + validate + end + + def bytesize = data.bytesize + + def validate + if data.include?("\0") + raise DataFormatError, "NULL byte not allowed in #{self.class}." + end + end + def send_data(imap, tag) imap.__send__(:send_literal, data, tag) end diff --git a/test/net/imap/test_command_data.rb b/test/net/imap/test_command_data.rb new file mode 100644 index 00000000..a9cc68c4 --- /dev/null +++ b/test/net/imap/test_command_data.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" + +class CommandDataTest < Net::IMAP::TestCase + DataFormatError = Net::IMAP::DataFormatError + + Literal = Net::IMAP::Literal + Output = Data.define(:name, :args) + TAG = Module.new.freeze + + class FakeCommandWriter + def self.def_printer(name) + unless Net::IMAP.instance_methods.include?(name) || + Net::IMAP.private_instance_methods.include?(name) + raise NoMethodError, "#{name} is not a method on Net::IMAP" + end + define_method(name) do |*args| + output << Output[name:, args:] + end + Output.define_singleton_method(name) do |*args| + new(name:, args:) + end + end + + attr_reader :output + + def initialize + @output = [] + end + + def clear = @output.clear + def validate(*data) = data.each(&:validate) + def send_data(*data, tag: TAG) + validate(*data) + data.each do _1.send_data(self, tag) end + end + + def_printer :put_string + def_printer :send_string_data + def_printer :send_number_data + def_printer :send_list_data + def_printer :send_time_data + def_printer :send_date_data + def_printer :send_quoted_string + def_printer :send_literal + end + + test "Literal" do + imap = FakeCommandWriter.new + imap.send_data Literal["foo\r\nbar"] + assert_equal [ + Output.send_literal("foo\r\nbar", TAG), + ], imap.output + + imap.clear + assert_raise_with_message(Net::IMAP::DataFormatError, /\bNULL byte\b/i) do + imap.send_data Literal["contains NULL char: \0"] + end + assert_empty imap.output + end + +end From 1eabb757eff3e05d46a8c811f445a5c1f7b80542 Mon Sep 17 00:00:00 2001 From: nick evans Date: Thu, 19 Feb 2026 18:35:13 -0500 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8=20Add=20`BINARY`=20support=20to?= =?UTF-8?q?=20`#append`=20(RFC3516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds the ability to send binary `literal8`, which were already supported by the parser for `FETCH`, and automatically uses `literal8` when the `#append` message includes `NULL` bytes. --- lib/net/imap.rb | 16 +++++++++------- lib/net/imap/command_data.rb | 26 +++++++++++++++++++++++--- test/net/imap/test_command_data.rb | 23 +++++++++++++++++++++++ test/net/imap/test_imap_append.rb | 26 ++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 71930e05..ec4648be 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -486,9 +486,7 @@ module Net # IMAP4rev2[https://www.rfc-editor.org/rfc/rfc9051]. # - Updates #fetch and #uid_fetch with the +BINARY+, +BINARY.PEEK+, and # +BINARY.SIZE+ items. See FetchData#binary and FetchData#binary_size. - # - # >>> - # *NOTE:* The binary extension the #append command is _not_ supported yet. + # - Updates #append to allow binary messages containing +NULL+ bytes. # # ==== RFC3691: +UNSELECT+ # Folded into IMAP4rev2[https://www.rfc-editor.org/rfc/rfc9051] and also included @@ -2044,6 +2042,11 @@ def status(mailbox, attr) # # ==== Capabilities # + # If +BINARY+ [RFC3516[https://www.rfc-editor.org/rfc/rfc3516.html]] is + # supported by the server, +message+ may contain +NULL+ characters and + # be sent as a binary literal. Otherwise, binary message parts must be + # encoded appropriately (for example, +base64+). + # # If +UIDPLUS+ [RFC4315[https://www.rfc-editor.org/rfc/rfc4315.html]] is # supported and the destination supports persistent UIDs, the server's # response should include an +APPENDUID+ response code with AppendUIDData. @@ -2054,12 +2057,11 @@ def status(mailbox, attr) # TODO: add MULTIAPPEND support #++ def append(mailbox, message, flags = nil, date_time = nil) + message = StringFormatter.literal_or_literal8(message, name: "message") args = [] - if flags - args.push(flags) - end + args.push(flags) if flags args.push(date_time) if date_time - args.push(Literal.new(message)) + args.push(message) send_command("APPEND", mailbox, *args) end diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index ec3c0f10..cfa35e44 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -77,9 +77,12 @@ def send_quoted_string(str) put_string('"' + str.gsub(/["\\]/, "\\\\\\&") + '"') end - def send_literal(str, tag = nil) + def send_binary_literal(str, tag) = send_literal(str, tag, binary: true) + + def send_literal(str, tag = nil, binary: false) synchronize do - put_string("{" + str.bytesize.to_s + "}" + CRLF) + prefix = "~" if binary + put_string("#{prefix}{#{str.bytesize}}\r\n") @continued_command_tag = tag @continuation_request_exception = nil begin @@ -158,7 +161,8 @@ def bytesize = data.bytesize def validate if data.include?("\0") - raise DataFormatError, "NULL byte not allowed in #{self.class}." + raise DataFormatError, "NULL byte not allowed in #{self.class}. " \ + "Use #{Literal8} or a null-safe encoding." end end @@ -167,6 +171,14 @@ def send_data(imap, tag) end end + class Literal8 < Literal # :nodoc: + def validate = nil # all bytes are okay + + def send_data(imap, tag) + imap.__send__(:send_binary_literal, data, tag) + end + end + class PartialRange < CommandData # :nodoc: uint32_max = 2**32 - 1 POS_RANGE = 1..uint32_max @@ -236,6 +248,14 @@ module StringFormatter module_function + def literal_or_literal8(input, name: "argument") + return input if input in Literal | Literal8 + data = String.try_convert(input) \ + or raise TypeError, "expected #{name} to be String, got #{input.class}" + type = data.include?("\0") ? Literal8 : Literal + type.new(data:) + end + # Allows symbols in addition to strings def valid_string?(str) str.is_a?(Symbol) || str.respond_to?(:to_str) diff --git a/test/net/imap/test_command_data.rb b/test/net/imap/test_command_data.rb index a9cc68c4..f78b1966 100644 --- a/test/net/imap/test_command_data.rb +++ b/test/net/imap/test_command_data.rb @@ -7,6 +7,8 @@ class CommandDataTest < Net::IMAP::TestCase DataFormatError = Net::IMAP::DataFormatError Literal = Net::IMAP::Literal + Literal8 = Net::IMAP::Literal8 + Output = Data.define(:name, :args) TAG = Module.new.freeze @@ -45,6 +47,7 @@ def send_data(*data, tag: TAG) def_printer :send_date_data def_printer :send_quoted_string def_printer :send_literal + def_printer :send_binary_literal end test "Literal" do @@ -61,4 +64,24 @@ def send_data(*data, tag: TAG) assert_empty imap.output end + test "Literal8" do + imap = FakeCommandWriter.new + imap.send_data Literal8["foo\r\nbar"], Literal8["foo\0bar"] + assert_equal [ + Output.send_binary_literal("foo\r\nbar", TAG), + Output.send_binary_literal("foo\0bar", TAG), + ], imap.output + end + + class StringFormatterTest < Net::IMAP::TestCase + include Net::IMAP::StringFormatter + + test "literal_or_literal8" do + assert_kind_of Literal, literal_or_literal8("simple\r\n") + assert_kind_of Literal8, literal_or_literal8("has NULL \0") + assert_kind_of Literal, literal_or_literal8(Literal["foo"]) + assert_kind_of Literal8, literal_or_literal8(Literal8["foo"]) + end + end + end diff --git a/test/net/imap/test_imap_append.rb b/test/net/imap/test_imap_append.rb index 21cdcdd5..ee139233 100644 --- a/test/net/imap/test_imap_append.rb +++ b/test/net/imap/test_imap_append.rb @@ -5,6 +5,8 @@ require_relative "fake_server" class IMAPAppendTest < Net::IMAP::TestCase + TEST_FIXTURE_PATH = File.join(__dir__, "fixtures/response_parser") + include Net::IMAP::FakeServer::TestHelper test "#append" do @@ -94,6 +96,30 @@ class IMAPAppendTest < Net::IMAP::TestCase end end + test "#append with binary data" do + png = File.binread(File.join(TEST_FIXTURE_PATH, "ruby.png")) + mail = <<~EOF.b.gsub(/\n/, "\r\n") + From: nick@example.com + To: shugo@example.com + Subject: Binary append support + MIME-Version: 1.0 + Content-Transfer-Encoding: binary + Content-Type: image/png + + EOF + mail << png + mail.freeze + with_fake_server do |server, imap| + server.on "APPEND", &:done_ok + + time = Time.new(2026, 2, 20, 10, 00, in: "-0500") + imap.append "Drafts", mail, %i[Draft Seen], time + assert_equal 'Drafts (\\Draft \\Seen) "20-Feb-2026 10:00:00 -0500" ' \ + "~{#{mail.bytesize}}\r\n#{mail}", + server.commands.pop.args + end + end + private def start_server