Skip to content

Commit 65a731d

Browse files
committed
Fix UTF-8 encoding issue with binary attachment content in JSON serialization
- Add normalize_json_encodings! method to handle binary attachment content - Automatically base64 encode binary strings before JSON serialization - Fixes 'source sequence is illegal/malformed utf-8' error when sending attachments with raw binary content via JSON (non-multipart) requests - Preserves backward compatibility with both symbol and string keys - Resolves issue #528
1 parent fc3b309 commit 65a731d

File tree

2 files changed

+47
-46
lines changed

2 files changed

+47
-46
lines changed

lib/nylas/handler/http_client.rb

Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def build_request(
103103
is_multipart = !payload.nil? && (payload["multipart"] || payload[:multipart])
104104

105105
if !payload.nil? && !is_multipart
106+
normalize_json_encodings!(payload)
106107
payload = payload&.to_json
107108
resulting_headers["Content-type"] = "application/json"
108109
elsif is_multipart
@@ -146,39 +147,24 @@ def httparty_execute(method:, url:, headers:, payload:, timeout:)
146147
timeout: timeout
147148
}
148149

149-
temp_files_to_cleanup = []
150-
151-
begin
152-
# Handle multipart uploads
153-
if payload.is_a?(Hash) && file_upload?(payload)
154-
options[:multipart] = true
155-
options[:body], temp_files_to_cleanup = prepare_multipart_payload(payload)
156-
elsif payload
157-
options[:body] = payload
158-
end
150+
# Handle multipart uploads
151+
if payload.is_a?(Hash) && file_upload?(payload)
152+
options[:multipart] = true
153+
options[:body] = prepare_multipart_payload(payload)
154+
elsif payload
155+
options[:body] = payload
156+
end
159157

160-
response = HTTParty.send(method, url, options)
158+
response = HTTParty.send(method, url, options)
161159

162-
# Create a compatible response object that mimics RestClient::Response
163-
result = create_response_wrapper(response)
160+
# Create a compatible response object that mimics RestClient::Response
161+
result = create_response_wrapper(response)
164162

165-
# Call the block with the response in the same format as rest-client
166-
if block_given?
167-
yield response, nil, result
168-
else
169-
response
170-
end
171-
ensure
172-
# Clean up any temporary files we created
173-
temp_files_to_cleanup.each do |tempfile|
174-
tempfile.close unless tempfile.closed?
175-
begin
176-
tempfile.unlink
177-
rescue StandardError
178-
nil
179-
end
180-
# Don't fail if file is already deleted
181-
end
163+
# Call the block with the response in the same format as rest-client
164+
if block_given?
165+
yield response, nil, result
166+
else
167+
response
182168
end
183169
end
184170

@@ -241,8 +227,7 @@ def prepare_multipart_payload(payload)
241227
modified_payload[key] = string_io
242228
end
243229

244-
# Return modified payload and empty array (no temp files to cleanup)
245-
[modified_payload, []]
230+
modified_payload
246231
end
247232

248233
# Normalize string encodings in multipart payload to prevent HTTParty encoding conflicts
@@ -257,6 +242,31 @@ def normalize_multipart_encodings!(payload)
257242
end
258243
end
259244

245+
# Normalize JSON encodings for attachment content to ensure binary data is base64 encoded.
246+
# This handles cases where users pass raw binary content directly instead of file objects.
247+
def normalize_json_encodings!(payload)
248+
return unless payload.is_a?(Hash)
249+
250+
# Handle attachment content encoding for JSON serialization
251+
attachments = payload[:attachments] || payload["attachments"]
252+
return unless attachments
253+
254+
attachments.each do |attachment|
255+
content = attachment[:content] || attachment["content"]
256+
next unless content.is_a?(String)
257+
258+
# If content appears to be binary (non-UTF-8), base64 encode it
259+
next unless content.encoding == Encoding::ASCII_8BIT || !content.valid_encoding?
260+
261+
encoded_content = Base64.strict_encode64(content)
262+
if attachment.key?(:content)
263+
attachment[:content] = encoded_content
264+
else
265+
attachment["content"] = encoded_content
266+
end
267+
end
268+
end
269+
260270
# Create a StringIO object that behaves more like a File for HTTParty compatibility
261271
def create_file_like_stringio(content)
262272
# Content is already normalized to ASCII-8BIT by normalize_multipart_encodings!

spec/nylas/handler/http_client_spec.rb

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -656,27 +656,24 @@ class TestHttpClient
656656
describe "#prepare_multipart_payload" do
657657
it "normalizes message payloads to ASCII-8BIT encoding for HTTParty compatibility" do
658658
payload = { "message" => "Hello World" }
659-
result, temp_files = http_client.send(:prepare_multipart_payload, payload)
659+
result = http_client.send(:prepare_multipart_payload, payload)
660660

661661
expect(result["message"]).to eq("Hello World")
662662
expect(result["message"].encoding).to eq(Encoding::ASCII_8BIT)
663-
expect(temp_files).to be_empty
664663
end
665664

666665
it "leaves non-message fields unchanged" do
667666
payload = { "other_field" => "value", "data" => "content" }
668-
result, temp_files = http_client.send(:prepare_multipart_payload, payload)
667+
result = http_client.send(:prepare_multipart_payload, payload)
669668

670669
expect(result).to eq(payload)
671-
expect(temp_files).to be_empty
672670
end
673671

674672
it "handles payloads without message field" do
675673
payload = { "data" => "content" }
676-
result, temp_files = http_client.send(:prepare_multipart_payload, payload)
674+
result = http_client.send(:prepare_multipart_payload, payload)
677675

678676
expect(result).to eq(payload)
679-
expect(temp_files).to be_empty
680677
end
681678

682679
it "converts binary content attachments to StringIO objects" do
@@ -690,7 +687,7 @@ class TestHttpClient
690687
"other_field" => "value"
691688
}
692689

693-
result, temp_files = http_client.send(:prepare_multipart_payload, payload)
690+
result = http_client.send(:prepare_multipart_payload, payload)
694691

695692
# Message should be preserved
696693
expect(result["message"]).to eq('{"subject":"test"}')
@@ -706,9 +703,6 @@ class TestHttpClient
706703

707704
# Other fields should be unchanged
708705
expect(result["other_field"]).to eq("value")
709-
710-
# No temp files need cleanup with StringIO
711-
expect(temp_files).to be_empty
712706
end
713707

714708
it "handles multiple binary content attachments" do
@@ -724,7 +718,7 @@ class TestHttpClient
724718
"file1" => content2
725719
}
726720

727-
result, temp_files = http_client.send(:prepare_multipart_payload, payload)
721+
result = http_client.send(:prepare_multipart_payload, payload)
728722

729723
expect(result["file0"]).to be_a(StringIO)
730724
expect(result["file0"].read).to eq("content 1")
@@ -733,9 +727,6 @@ class TestHttpClient
733727
expect(result["file1"]).to be_a(StringIO)
734728
expect(result["file1"].read).to eq("content 2")
735729
expect(result["file1"].original_filename).to eq("file2.txt")
736-
737-
# No temp files need cleanup with StringIO
738-
expect(temp_files).to be_empty
739730
end
740731
end
741732
end

0 commit comments

Comments
 (0)