Skip to content

Commit 1eabb75

Browse files
committed
✨ Add BINARY support to #append (RFC3516)
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.
1 parent de70825 commit 1eabb75

File tree

4 files changed

+81
-10
lines changed

4 files changed

+81
-10
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: 23 additions & 3 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
@@ -158,7 +161,8 @@ def bytesize = data.bytesize
158161

159162
def validate
160163
if data.include?("\0")
161-
raise DataFormatError, "NULL byte not allowed in #{self.class}."
164+
raise DataFormatError, "NULL byte not allowed in #{self.class}. " \
165+
"Use #{Literal8} or a null-safe encoding."
162166
end
163167
end
164168

@@ -167,6 +171,14 @@ def send_data(imap, tag)
167171
end
168172
end
169173

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+
170182
class PartialRange < CommandData # :nodoc:
171183
uint32_max = 2**32 - 1
172184
POS_RANGE = 1..uint32_max
@@ -236,6 +248,14 @@ module StringFormatter
236248

237249
module_function
238250

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+
239259
# Allows symbols in addition to strings
240260
def valid_string?(str)
241261
str.is_a?(Symbol) || str.respond_to?(:to_str)

test/net/imap/test_command_data.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ class CommandDataTest < Net::IMAP::TestCase
77
DataFormatError = Net::IMAP::DataFormatError
88

99
Literal = Net::IMAP::Literal
10+
Literal8 = Net::IMAP::Literal8
11+
1012
Output = Data.define(:name, :args)
1113
TAG = Module.new.freeze
1214

@@ -45,6 +47,7 @@ def send_data(*data, tag: TAG)
4547
def_printer :send_date_data
4648
def_printer :send_quoted_string
4749
def_printer :send_literal
50+
def_printer :send_binary_literal
4851
end
4952

5053
test "Literal" do
@@ -61,4 +64,24 @@ def send_data(*data, tag: TAG)
6164
assert_empty imap.output
6265
end
6366

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+
6487
end

test/net/imap/test_imap_append.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
require_relative "fake_server"
66

77
class IMAPAppendTest < Net::IMAP::TestCase
8+
TEST_FIXTURE_PATH = File.join(__dir__, "fixtures/response_parser")
9+
810
include Net::IMAP::FakeServer::TestHelper
911

1012
test "#append" do
@@ -94,6 +96,30 @@ class IMAPAppendTest < Net::IMAP::TestCase
9496
end
9597
end
9698

99+
test "#append with binary data" do
100+
png = File.binread(File.join(TEST_FIXTURE_PATH, "ruby.png"))
101+
mail = <<~EOF.b.gsub(/\n/, "\r\n")
102+
From: nick@example.com
103+
To: shugo@example.com
104+
Subject: Binary append support
105+
MIME-Version: 1.0
106+
Content-Transfer-Encoding: binary
107+
Content-Type: image/png
108+
109+
EOF
110+
mail << png
111+
mail.freeze
112+
with_fake_server do |server, imap|
113+
server.on "APPEND", &:done_ok
114+
115+
time = Time.new(2026, 2, 20, 10, 00, in: "-0500")
116+
imap.append "Drafts", mail, %i[Draft Seen], time
117+
assert_equal 'Drafts (\\Draft \\Seen) "20-Feb-2026 10:00:00 -0500" ' \
118+
"~{#{mail.bytesize}}\r\n#{mail}",
119+
server.commands.pop.args
120+
end
121+
end
122+
97123
private
98124

99125
def start_server

0 commit comments

Comments
 (0)