Skip to content

Commit 77de434

Browse files
authored
Fix large attachment handling with string keys and custom content_ids (#537)
1 parent 7b6b868 commit 77de434

11 files changed

Lines changed: 385 additions & 43 deletions

File tree

.rubocop.yml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
inherit_from: .rubocop_todo.yml
22

3-
require:
3+
plugins:
44
- rubocop-rspec
55
- rubocop-capybara
66

77
AllCops:
88
TargetRubyVersion: 3.0
9+
NewCops: disable
910
DisplayCopNames: true
1011
DisplayStyleGuide: true
1112
Exclude:
@@ -57,5 +58,18 @@ RSpec/MultipleExpectations:
5758
RSpec/ExampleLength:
5859
Enabled: false
5960

60-
RSpec/FilePath:
61+
RSpec/SpecFilePathFormat:
62+
Enabled: false
63+
RSpec/SpecFilePathSuffix:
64+
Enabled: false
65+
66+
RSpec/VerifiedDoubleReference:
67+
Enabled: false
68+
RSpec/BeEq:
69+
Enabled: false
70+
RSpec/IdenticalEqualityAssertion:
71+
Enabled: false
72+
RSpec/NoExpectationExample:
73+
Enabled: false
74+
RSpec/ReceiveMessages:
6175
Enabled: false

examples/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ examples/
1212
├── messages/ # Message-related examples
1313
│ ├── message_fields_example.rb # Example of using new message fields functionality
1414
│ ├── file_upload_example.rb # Example of file upload functionality with HTTParty migration
15+
│ ├── send_streaming_attachments_example.rb # Sending attachments from a stream (no local file)
1516
│ └── send_message_example.rb # Example of basic message sending functionality
1617
└── notetaker/ # Standalone Notetaker examples
1718
├── README.md # Notetaker-specific documentation
@@ -68,6 +69,17 @@ Before running any example, make sure to:
6869
export NYLAS_TEST_EMAIL="test@example.com" # Email address to send test messages to
6970
```
7071

72+
- `messages/send_streaming_attachments_example.rb`: Sending attachments from a stream (no local file on disk), including:
73+
- Passing string content from an IO/stream instead of a file path
74+
- Small attachments (<3MB) via JSON base64
75+
- Large attachments (>3MB) via multipart: `LARGE_ATTACHMENT=1 ruby ...`
76+
77+
Additional environment variables needed:
78+
```bash
79+
export NYLAS_GRANT_ID="your_grant_id"
80+
export NYLAS_TEST_EMAIL="test@example.com"
81+
```
82+
7183
- `messages/send_message_example.rb`: Demonstrates basic message sending functionality, including:
7284
- Sending simple text messages
7385
- Handling multiple recipients (TO, CC, BCC)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
# Example: Sending attachments from a stream (no local file on disk)
5+
#
6+
# When content comes from a stream (network, database, etc.), read it into a
7+
# string and pass it to the SDK. You do not need a local file path.
8+
#
9+
# stream = some_source.read # IO, StringIO, HTTP response body, etc.
10+
# attachment = { filename: "doc.pdf", content_type: "application/pdf", size: stream.bytesize, content: stream }
11+
#
12+
# Environment variables:
13+
# NYLAS_API_KEY - Your Nylas API key
14+
# NYLAS_GRANT_ID - Grant ID (connected account)
15+
# NYLAS_TEST_EMAIL - Recipient email
16+
#
17+
# Optional: NYLAS_API_URI (default: https://api.us.nylas.com)
18+
# Optional: LARGE_ATTACHMENT=1 for >3MB (multipart path)
19+
20+
$LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
21+
require "nylas"
22+
23+
def load_env
24+
env_file = File.expand_path("../.env", __dir__)
25+
return unless File.exist?(env_file)
26+
27+
File.readlines(env_file).each do |line|
28+
line = line.strip
29+
next if line.empty? || line.start_with?("#")
30+
31+
key, value = line.split("=", 2)
32+
ENV[key] = value&.gsub(/\A['"]|['"]\z/, "") if key && value
33+
end
34+
end
35+
36+
def attachment_from_stream(io, filename:, content_type:)
37+
content = io.read
38+
io.close if io.respond_to?(:close)
39+
40+
{
41+
filename: filename,
42+
content_type: content_type,
43+
size: content.bytesize,
44+
content: content
45+
}
46+
end
47+
48+
def main
49+
load_env
50+
51+
api_key = ENV["NYLAS_API_KEY"]
52+
grant_id = ENV["NYLAS_GRANT_ID"]
53+
recipient = ENV["NYLAS_TEST_EMAIL"]
54+
55+
raise "Set NYLAS_API_KEY, NYLAS_GRANT_ID, NYLAS_TEST_EMAIL" unless api_key && grant_id && recipient
56+
57+
nylas = Nylas::Client.new(
58+
api_key: api_key,
59+
api_uri: ENV["NYLAS_API_URI"] || "https://api.us.nylas.com"
60+
)
61+
62+
use_large = ENV["LARGE_ATTACHMENT"] == "1"
63+
64+
if use_large
65+
stream = StringIO.new("%PDF-1.4\n" + ("x" * (4 * 1024 * 1024 - 32)))
66+
attachment = attachment_from_stream(stream, filename: "report.pdf", content_type: "application/pdf")
67+
puts "Using large attachment (>3MB) - multipart form-data path"
68+
else
69+
stream = StringIO.new("%PDF-1.4 simulated content " + ("x" * 1024))
70+
attachment = attachment_from_stream(stream, filename: "report.pdf", content_type: "application/pdf")
71+
puts "Using small attachment (<3MB) - JSON base64 path"
72+
end
73+
74+
puts "Sending email with streamed attachment..."
75+
puts " Attachment: #{attachment[:filename]} (#{attachment[:size]} bytes)"
76+
puts " No local file - content from stream"
77+
78+
response, request_id = nylas.messages.send(
79+
identifier: grant_id,
80+
request_body: {
81+
subject: "Report",
82+
body: "Attached document from stream.",
83+
to: [{ email: recipient }],
84+
attachments: [attachment]
85+
}
86+
)
87+
88+
puts "Sent. Message ID: #{response[:id]}, Request ID: #{request_id}"
89+
end
90+
91+
main if __FILE__ == $PROGRAM_NAME

gem_config.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def self.dev_dependencies
3838
[["bundler", ">= 1.3.0"],
3939
["yard", "~> 0.9.34"],
4040
["rubocop", "~> 1.51"],
41-
["rubocop-rspec", "~> 2.22"],
41+
["rubocop-rspec", "~> 3.5"],
4242
["rubocop-capybara", "~> 2.20"]] + testing_and_debugging_dependencies
4343
end
4444

lib/nylas/handler/http_client.rb

Lines changed: 94 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "httparty"
44
require "net/http"
5+
require "net/http/post/multipart"
56

67
require_relative "../errors"
78
require_relative "../version"
@@ -135,40 +136,104 @@ def parse_response(response)
135136

136137
private
137138

138-
# Sends a request to the Nylas REST API using HTTParty.
139+
# Sends a request to the Nylas REST API using HTTParty or Net::HTTP for multipart.
140+
# Multipart requests use Net::HTTP::Post::Multipart (multipart-post gem) because
141+
# HTTParty's multipart handling produces malformed requests that the Nylas API rejects.
139142
#
140143
# @param method [Symbol] HTTP method for the API call. Either :get, :post, :delete, or :patch.
141144
# @param url [String] URL for the API call.
142145
# @param headers [Hash] HTTP headers to include in the payload.
143146
# @param payload [String, Hash] Body to send with the request.
144147
# @param timeout [Hash] Timeout value to send with the request.
145148
def httparty_execute(method:, url:, headers:, payload:, timeout:)
146-
options = {
147-
headers: headers,
148-
timeout: timeout
149-
}
150-
151-
# Handle multipart uploads
152-
if payload.is_a?(Hash) && file_upload?(payload)
153-
options[:multipart] = true
154-
options[:body] = prepare_multipart_payload(payload)
155-
elsif payload
156-
options[:body] = payload
149+
if method == :post && payload.is_a?(Hash) && file_upload?(payload)
150+
response = execute_multipart_request(url: url, headers: headers, payload: payload, timeout: timeout)
151+
else
152+
options = { headers: headers, timeout: timeout }
153+
options[:body] = payload if payload
154+
response = HTTParty.send(method, url, options)
157155
end
158156

159-
response = HTTParty.send(method, url, options)
160-
161-
# Create a compatible response object that mimics RestClient::Response
162157
result = create_response_wrapper(response)
163158

164-
# Call the block with the response in the same format as rest-client
165159
if block_given?
166160
yield response, nil, result
167161
else
168162
response
169163
end
170164
end
171165

166+
# Executes multipart POST using Net::HTTP::Post::Multipart (fixes issue #538).
167+
# HTTParty's multipart produces malformed requests; multipart-post/UploadIO works correctly.
168+
def execute_multipart_request(url:, headers:, payload:, timeout:)
169+
uri = URI.parse(url)
170+
params = build_multipart_params(payload)
171+
172+
req = Net::HTTP::Post::Multipart.new(uri.path, params)
173+
headers.each { |key, value| req[key] = value }
174+
175+
http = Net::HTTP.new(uri.host, uri.port)
176+
http.use_ssl = (uri.scheme == "https")
177+
http.read_timeout = timeout
178+
http.open_timeout = timeout
179+
180+
response = http.request(req)
181+
182+
create_httparty_like_response(response)
183+
end
184+
185+
# Build params hash for Net::HTTP::Post::Multipart with UploadIO for file fields.
186+
def build_multipart_params(payload)
187+
params = {}
188+
payload.each do |key, value|
189+
params[key] = if key.is_a?(String) && key != "message" && file_like_value?(value)
190+
value_to_upload_io(value)
191+
else
192+
value.to_s
193+
end
194+
end
195+
params
196+
end
197+
198+
def file_like_value?(value)
199+
return true if value.respond_to?(:read) && (value.is_a?(File) ? !value.closed? : true)
200+
if value.is_a?(String) && (value.respond_to?(:original_filename) || value.respond_to?(:content_type))
201+
return true
202+
end
203+
204+
false
205+
end
206+
207+
# Convert File, String, or StringIO to UploadIO for multipart-post.
208+
def value_to_upload_io(value)
209+
content_type = value.respond_to?(:content_type) ? value.content_type : "application/octet-stream"
210+
filename = value.respond_to?(:original_filename) ? value.original_filename : "file.bin"
211+
212+
io = if value.respond_to?(:read) && value.respond_to?(:rewind)
213+
value.rewind if value.respond_to?(:rewind)
214+
value
215+
else
216+
require "stringio"
217+
content = value.to_s
218+
content = content.dup.force_encoding(Encoding::ASCII_8BIT) if content.is_a?(String)
219+
StringIO.new(content)
220+
end
221+
222+
UploadIO.new(io, content_type, filename)
223+
end
224+
225+
# Create response object compatible with HTTParty::Response interface.
226+
def create_httparty_like_response(net_http_response)
227+
headers = net_http_response.to_hash
228+
headers = headers.transform_values { |v| v.is_a?(Array) && v.one? ? v.first : v }
229+
230+
OpenStruct.new(
231+
body: net_http_response.body,
232+
code: net_http_response.code.to_i,
233+
headers: headers
234+
)
235+
end
236+
172237
# Create a response wrapper that mimics RestClient::Response.code behavior
173238
def create_response_wrapper(response)
174239
OpenStruct.new(code: response.code)
@@ -188,10 +253,20 @@ def file_upload?(payload)
188253
# Check if payload was prepared by FileUtils.build_form_request for multipart uploads
189254
# This handles binary content attachments that are strings with added singleton methods
190255
has_message_field = payload.key?("message") && payload["message"].is_a?(String)
191-
has_attachment_fields = payload.keys.any? { |key| key.is_a?(String) && key.match?(/^file\d+$/) }
192256

193-
# If we have both a "message" field and "file{N}" fields, this indicates
194-
# the payload was prepared by FileUtils.build_form_request for multipart upload
257+
# Check for attachment fields - these can have custom content_id values (not just "file{N}")
258+
# FileUtils.build_form_request creates entries with string values that have singleton methods
259+
# like original_filename and content_type defined on them
260+
has_attachment_fields = payload.any? do |key, value|
261+
next false unless key.is_a?(String) && key != "message"
262+
263+
# Check if the value is a string with attachment-like singleton methods
264+
# (original_filename or content_type), which indicates it's a file content
265+
value.is_a?(String) && (value.respond_to?(:original_filename) || value.respond_to?(:content_type))
266+
end
267+
268+
# If we have both a "message" field and attachment fields with file metadata,
269+
# this indicates the payload was prepared by FileUtils.build_form_request
195270
has_message_field && has_attachment_fields
196271
end
197272

lib/nylas/resources/webhooks.rb

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# frozen_string_literal: true
22

3-
require "cgi"
43
require_relative "resource"
54
require_relative "../handler/api_operations"
65

@@ -115,14 +114,14 @@ def ip_addresses
115114
# @return [String] The challenge parameter
116115
def self.extract_challenge_parameter(url)
117116
url_object = URI.parse(url)
118-
query = CGI.parse(url_object.query || "")
117+
params = URI.decode_www_form(url_object.query || "")
118+
challenge_pair = params.find { |k, _| k == "challenge" }
119119

120-
challenge_parameter = query["challenge"]
121-
if challenge_parameter.nil? || challenge_parameter.empty? || challenge_parameter.first.nil?
120+
if challenge_pair.nil? || challenge_pair.last.to_s.empty?
122121
raise "Invalid URL or no challenge parameter found."
123122
end
124123

125-
challenge_parameter.first
124+
challenge_pair.last
126125
end
127126
end
128127
end

lib/nylas/utils/file_utils.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,13 @@ def self.build_form_request(request_body)
2828

2929
attachments.each_with_index do |attachment, index|
3030
file = attachment[:content] || attachment["content"]
31+
file_path = attachment[:file_path] || attachment["file_path"]
3132
if file.respond_to?(:closed?) && file.closed?
32-
unless attachment[:file_path]
33+
unless file_path
3334
raise ArgumentError, "The file at index #{index} is closed and no file_path was provided."
3435
end
3536

36-
file = File.open(attachment[:file_path], "rb")
37+
file = File.open(file_path, "rb")
3738
end
3839

3940
# Setting original filename and content type if available. See rest-client#lib/restclient/payload.rb
@@ -87,7 +88,9 @@ def self.handle_message_payload(request_body)
8788

8889
# Use form data only if the attachment size is greater than 3mb
8990
attachments = payload[:attachments]
90-
attachment_size = attachments&.sum { |attachment| attachment[:size] || 0 } || 0
91+
# Support both string and symbol keys for attachment size to handle
92+
# user-provided hashes that may use either key type
93+
attachment_size = attachments&.sum { |attachment| attachment[:size] || attachment["size"] || 0 } || 0
9194

9295
# Handle the attachment encoding depending on the size
9396
if attachment_size >= FORM_DATA_ATTACHMENT_SIZE

nylas.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Gem::Specification.new do |gem|
1414
gem.add_runtime_dependency "base64"
1515
gem.add_runtime_dependency "httparty", "~> 0.21"
1616
gem.add_runtime_dependency "mime-types", "~> 3.5", ">= 3.5.1"
17+
gem.add_runtime_dependency "multipart-post", "~> 2.0"
1718
gem.add_runtime_dependency "ostruct", "~> 0.6"
1819
gem.add_runtime_dependency "yajl-ruby", "~> 1.4.3", ">= 1.2.1"
1920

0 commit comments

Comments
 (0)