From 7aa254edeb5d74cb1734d3500d95b5f3a9c356be Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 3 Feb 2026 15:36:03 -0500 Subject: [PATCH 1/4] Fix large attachment handling with string keys and custom content_ids This fixes two bugs that caused attachments over 3MB (e.g., 5.4MB) to fail with "sending a json request as multipart/form-data" errors: 1. Size calculation only checked symbol keys (`:size`) but users may pass attachment hashes with string keys (`"size"`). This caused large files to be incorrectly sent as JSON instead of multipart form-data. 2. The `file_upload?` detection only matched keys like `file0`, `file1`, but users can specify custom `content_id` values. This caused multipart detection to fail for attachments with custom content IDs. Fixes: - Support both string and symbol keys when calculating attachment size - Detect attachments by checking for singleton methods (original_filename, content_type) rather than key name patterns --- lib/nylas/handler/http_client.rb | 15 ++++-- lib/nylas/utils/file_utils.rb | 9 ++-- spec/nylas/handler/http_client_spec.rb | 58 +++++++++++++++++++++++ spec/nylas/utils/file_utils_spec.rb | 64 ++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 6 deletions(-) diff --git a/lib/nylas/handler/http_client.rb b/lib/nylas/handler/http_client.rb index 0eb14ec5..e18d920f 100644 --- a/lib/nylas/handler/http_client.rb +++ b/lib/nylas/handler/http_client.rb @@ -188,10 +188,19 @@ def file_upload?(payload) # Check if payload was prepared by FileUtils.build_form_request for multipart uploads # This handles binary content attachments that are strings with added singleton methods has_message_field = payload.key?("message") && payload["message"].is_a?(String) - has_attachment_fields = payload.keys.any? { |key| key.is_a?(String) && key.match?(/^file\d+$/) } - # If we have both a "message" field and "file{N}" fields, this indicates - # the payload was prepared by FileUtils.build_form_request for multipart upload + # Check for attachment fields - these can have custom content_id values (not just "file{N}") + # FileUtils.build_form_request creates entries with string values that have singleton methods + # like original_filename and content_type defined on them + has_attachment_fields = payload.any? do |key, value| + next false unless key.is_a?(String) && key != "message" + # Check if the value is a string with attachment-like singleton methods + # (original_filename or content_type), which indicates it's a file content + value.is_a?(String) && (value.respond_to?(:original_filename) || value.respond_to?(:content_type)) + end + + # If we have both a "message" field and attachment fields with file metadata, + # this indicates the payload was prepared by FileUtils.build_form_request has_message_field && has_attachment_fields end diff --git a/lib/nylas/utils/file_utils.rb b/lib/nylas/utils/file_utils.rb index 5d90ad5b..1c513936 100644 --- a/lib/nylas/utils/file_utils.rb +++ b/lib/nylas/utils/file_utils.rb @@ -28,12 +28,13 @@ def self.build_form_request(request_body) attachments.each_with_index do |attachment, index| file = attachment[:content] || attachment["content"] + file_path = attachment[:file_path] || attachment["file_path"] if file.respond_to?(:closed?) && file.closed? - unless attachment[:file_path] + unless file_path raise ArgumentError, "The file at index #{index} is closed and no file_path was provided." end - file = File.open(attachment[:file_path], "rb") + file = File.open(file_path, "rb") end # 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) # Use form data only if the attachment size is greater than 3mb attachments = payload[:attachments] - attachment_size = attachments&.sum { |attachment| attachment[:size] || 0 } || 0 + # Support both string and symbol keys for attachment size to handle + # user-provided hashes that may use either key type + attachment_size = attachments&.sum { |attachment| attachment[:size] || attachment["size"] || 0 } || 0 # Handle the attachment encoding depending on the size if attachment_size >= FORM_DATA_ATTACHMENT_SIZE diff --git a/spec/nylas/handler/http_client_spec.rb b/spec/nylas/handler/http_client_spec.rb index 980f8c54..c8ca8db9 100644 --- a/spec/nylas/handler/http_client_spec.rb +++ b/spec/nylas/handler/http_client_spec.rb @@ -291,6 +291,64 @@ class TestHttpClient expect(http_client.send(:file_upload?, payload)).to be false end + + # Bug fix tests: handle custom content_id values + context "when attachments use custom content_id values" do + it "detects file uploads with custom content_id values" do + # This test reproduces the bug where custom content_id values + # like "my-attachment" weren't being detected as file uploads + mock_file = instance_double("file") + allow(mock_file).to receive(:respond_to?).with(:read).and_return(true) + + payload = { + "message" => '{"subject":"test"}', + "my-custom-attachment" => mock_file + } + + expect(http_client.send(:file_upload?, payload)).to be true + end + + it "detects file uploads with alphanumeric content_id values" do + mock_file = instance_double("file") + allow(mock_file).to receive(:respond_to?).with(:read).and_return(true) + + payload = { + "message" => '{"subject":"test"}', + "attachment_001" => mock_file, + "document_pdf" => mock_file + } + + expect(http_client.send(:file_upload?, payload)).to be true + end + + it "detects file uploads with content_id containing special characters" do + mock_file = instance_double("file") + allow(mock_file).to receive(:respond_to?).with(:read).and_return(true) + + payload = { + "message" => '{"subject":"test"}', + "inline-image-123" => mock_file + } + + expect(http_client.send(:file_upload?, payload)).to be true + end + + it "detects binary string attachments with custom content_id values" do + # When FileUtils.build_form_request passes file content as strings + # with singleton methods, file_upload? should still detect them + binary_content = "binary file content".dup + binary_content.define_singleton_method(:original_filename) { "test.bin" } + binary_content.define_singleton_method(:content_type) { "application/octet-stream" } + + payload = { + "message" => '{"subject":"test"}', + "my-inline-image" => binary_content + } + + # Currently this fails because the pattern only matches /^file\d+$/ + expect(http_client.send(:file_upload?, payload)).to be true + end + end end describe "#execute" do diff --git a/spec/nylas/utils/file_utils_spec.rb b/spec/nylas/utils/file_utils_spec.rb index fbc16ec6..ea9f7423 100644 --- a/spec/nylas/utils/file_utils_spec.rb +++ b/spec/nylas/utils/file_utils_spec.rb @@ -241,6 +241,70 @@ describe "#handle_message_payload" do let(:mock_file) { instance_double("file") } + # Bug fix tests: handle string keys in attachment hashes + context "when attachment hashes use string keys" do + it "returns form data when attachment size (string key) is greater than 3MB" do + # This test reproduces the bug where users pass attachment hashes with string keys + # The size calculation was only checking symbol keys, causing large attachments + # to be incorrectly sent as JSON instead of multipart form data + large_attachment = { + "size" => 5_400_000, # 5.4MB - using string key + "content" => mock_file, + "filename" => "large_file.txt", + "content_type" => "text/plain" + } + request_body = { attachments: [large_attachment] } + + allow(mock_file).to receive(:read).and_return("file content") + allow(File).to receive(:size).and_return(large_attachment["size"]) + + payload, opened_files = described_class.handle_message_payload(request_body) + + expect(payload).to include("multipart" => true) + expect(opened_files).to include(mock_file) + end + + it "returns form data when attachment size (string key) with string top-level keys is greater than 3MB" do + # Test with completely string-keyed request body + large_attachment = { + "size" => 5_400_000, + "content" => mock_file, + "filename" => "large_file.txt", + "content_type" => "text/plain" + } + request_body = { "attachments" => [large_attachment] } + + allow(mock_file).to receive(:read).and_return("file content") + allow(File).to receive(:size).and_return(large_attachment["size"]) + + payload, opened_files = described_class.handle_message_payload(request_body) + + expect(payload).to include("multipart" => true) + expect(opened_files).to include(mock_file) + end + + it "handles mixed string/symbol keys in attachment hashes for size calculation" do + # Test with multiple attachments having different key styles + attachment1 = { + "size" => 2_000_000, # String key + "content" => mock_file + } + attachment2 = { + size: 2_000_000, # Symbol key + content: mock_file + } + request_body = { attachments: [attachment1, attachment2] } + + allow(mock_file).to receive(:read).and_return("file content") + allow(File).to receive(:size).and_return(2_000_000) + + # Total is 4MB, should trigger multipart + payload, _opened_files = described_class.handle_message_payload(request_body) + + expect(payload).to include("multipart" => true) + end + end + it "returns form data when attachment size is greater than 3MB" do large_attachment = { size: 4 * 1024 * 1024, From b99e6e13236b94bcb3957e4cdd635924605a1898 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Wed, 4 Feb 2026 15:41:22 -0500 Subject: [PATCH 2/4] Fix rubocop lint errors - Add empty line after guard clause in http_client.rb - Remove extra spacing in file_utils_spec.rb comments - Shorten test description to fit line length limit --- lib/nylas/handler/http_client.rb | 1 + spec/nylas/utils/file_utils_spec.rb | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/nylas/handler/http_client.rb b/lib/nylas/handler/http_client.rb index e18d920f..896f9672 100644 --- a/lib/nylas/handler/http_client.rb +++ b/lib/nylas/handler/http_client.rb @@ -194,6 +194,7 @@ def file_upload?(payload) # like original_filename and content_type defined on them has_attachment_fields = payload.any? do |key, value| next false unless key.is_a?(String) && key != "message" + # Check if the value is a string with attachment-like singleton methods # (original_filename or content_type), which indicates it's a file content value.is_a?(String) && (value.respond_to?(:original_filename) || value.respond_to?(:content_type)) diff --git a/spec/nylas/utils/file_utils_spec.rb b/spec/nylas/utils/file_utils_spec.rb index ea9f7423..f5f3a3f9 100644 --- a/spec/nylas/utils/file_utils_spec.rb +++ b/spec/nylas/utils/file_utils_spec.rb @@ -248,7 +248,7 @@ # The size calculation was only checking symbol keys, causing large attachments # to be incorrectly sent as JSON instead of multipart form data large_attachment = { - "size" => 5_400_000, # 5.4MB - using string key + "size" => 5_400_000, # 5.4MB - using string key "content" => mock_file, "filename" => "large_file.txt", "content_type" => "text/plain" @@ -264,7 +264,7 @@ expect(opened_files).to include(mock_file) end - it "returns form data when attachment size (string key) with string top-level keys is greater than 3MB" do + it "returns form data when attachment size (string key) with string top-level keys > 3MB" do # Test with completely string-keyed request body large_attachment = { "size" => 5_400_000, @@ -286,11 +286,11 @@ it "handles mixed string/symbol keys in attachment hashes for size calculation" do # Test with multiple attachments having different key styles attachment1 = { - "size" => 2_000_000, # String key + "size" => 2_000_000, # String key "content" => mock_file } attachment2 = { - size: 2_000_000, # Symbol key + size: 2_000_000, # Symbol key content: mock_file } request_body = { attachments: [attachment1, attachment2] } From ae9855d34392007f7791994c5400d15791df7cb3 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 19 Feb 2026 17:12:26 -0500 Subject: [PATCH 3/4] Fix campus attachment issues, Rubocop, and webhook Ruby 3.4 compatibility - HTTP client: support streaming attachments and file-like values - Add send_streaming_attachments_example - Fix Rubocop offenses (style, layout, gemspec order) - Replace CGI.parse with URI.decode_www_form for Ruby 3.4+ compatibility --- examples/README.md | 12 +++ .../send_streaming_attachments_example.rb | 91 +++++++++++++++++ lib/nylas/handler/http_client.rb | 97 ++++++++++++++++--- lib/nylas/resources/webhooks.rb | 9 +- nylas.gemspec | 1 + .../handler/http_client_integration_spec.rb | 19 ++-- spec/nylas/handler/http_client_spec.rb | 32 ++++++ 7 files changed, 227 insertions(+), 34 deletions(-) create mode 100644 examples/messages/send_streaming_attachments_example.rb diff --git a/examples/README.md b/examples/README.md index 429695cc..717964da 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,6 +12,7 @@ examples/ ├── messages/ # Message-related examples │ ├── message_fields_example.rb # Example of using new message fields functionality │ ├── file_upload_example.rb # Example of file upload functionality with HTTParty migration +│ ├── send_streaming_attachments_example.rb # Sending attachments from a stream (no local file) │ └── send_message_example.rb # Example of basic message sending functionality └── notetaker/ # Standalone Notetaker examples ├── README.md # Notetaker-specific documentation @@ -68,6 +69,17 @@ Before running any example, make sure to: export NYLAS_TEST_EMAIL="test@example.com" # Email address to send test messages to ``` +- `messages/send_streaming_attachments_example.rb`: Sending attachments from a stream (no local file on disk), including: + - Passing string content from an IO/stream instead of a file path + - Small attachments (<3MB) via JSON base64 + - Large attachments (>3MB) via multipart: `LARGE_ATTACHMENT=1 ruby ...` + + Additional environment variables needed: + ```bash + export NYLAS_GRANT_ID="your_grant_id" + export NYLAS_TEST_EMAIL="test@example.com" + ``` + - `messages/send_message_example.rb`: Demonstrates basic message sending functionality, including: - Sending simple text messages - Handling multiple recipients (TO, CC, BCC) diff --git a/examples/messages/send_streaming_attachments_example.rb b/examples/messages/send_streaming_attachments_example.rb new file mode 100644 index 00000000..b674d85e --- /dev/null +++ b/examples/messages/send_streaming_attachments_example.rb @@ -0,0 +1,91 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Example: Sending attachments from a stream (no local file on disk) +# +# When content comes from a stream (network, database, etc.), read it into a +# string and pass it to the SDK. You do not need a local file path. +# +# stream = some_source.read # IO, StringIO, HTTP response body, etc. +# attachment = { filename: "doc.pdf", content_type: "application/pdf", size: stream.bytesize, content: stream } +# +# Environment variables: +# NYLAS_API_KEY - Your Nylas API key +# NYLAS_GRANT_ID - Grant ID (connected account) +# NYLAS_TEST_EMAIL - Recipient email +# +# Optional: NYLAS_API_URI (default: https://api.us.nylas.com) +# Optional: LARGE_ATTACHMENT=1 for >3MB (multipart path) + +$LOAD_PATH.unshift File.expand_path("../../lib", __dir__) +require "nylas" + +def load_env + env_file = File.expand_path("../.env", __dir__) + return unless File.exist?(env_file) + + File.readlines(env_file).each do |line| + line = line.strip + next if line.empty? || line.start_with?("#") + + key, value = line.split("=", 2) + ENV[key] = value&.gsub(/\A['"]|['"]\z/, "") if key && value + end +end + +def attachment_from_stream(io, filename:, content_type:) + content = io.read + io.close if io.respond_to?(:close) + + { + filename: filename, + content_type: content_type, + size: content.bytesize, + content: content + } +end + +def main + load_env + + api_key = ENV["NYLAS_API_KEY"] + grant_id = ENV["NYLAS_GRANT_ID"] + recipient = ENV["NYLAS_TEST_EMAIL"] + + raise "Set NYLAS_API_KEY, NYLAS_GRANT_ID, NYLAS_TEST_EMAIL" unless api_key && grant_id && recipient + + nylas = Nylas::Client.new( + api_key: api_key, + api_uri: ENV["NYLAS_API_URI"] || "https://api.us.nylas.com" + ) + + use_large = ENV["LARGE_ATTACHMENT"] == "1" + + if use_large + stream = StringIO.new("%PDF-1.4\n" + ("x" * (4 * 1024 * 1024 - 32))) + attachment = attachment_from_stream(stream, filename: "report.pdf", content_type: "application/pdf") + puts "Using large attachment (>3MB) - multipart form-data path" + else + stream = StringIO.new("%PDF-1.4 simulated content " + ("x" * 1024)) + attachment = attachment_from_stream(stream, filename: "report.pdf", content_type: "application/pdf") + puts "Using small attachment (<3MB) - JSON base64 path" + end + + puts "Sending email with streamed attachment..." + puts " Attachment: #{attachment[:filename]} (#{attachment[:size]} bytes)" + puts " No local file - content from stream" + + response, request_id = nylas.messages.send( + identifier: grant_id, + request_body: { + subject: "Report", + body: "Attached document from stream.", + to: [{ email: recipient }], + attachments: [attachment] + } + ) + + puts "Sent. Message ID: #{response[:id]}, Request ID: #{request_id}" +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/lib/nylas/handler/http_client.rb b/lib/nylas/handler/http_client.rb index 896f9672..8578dd48 100644 --- a/lib/nylas/handler/http_client.rb +++ b/lib/nylas/handler/http_client.rb @@ -2,6 +2,7 @@ require "httparty" require "net/http" +require "net/http/post/multipart" require_relative "../errors" require_relative "../version" @@ -135,7 +136,9 @@ def parse_response(response) private - # Sends a request to the Nylas REST API using HTTParty. + # Sends a request to the Nylas REST API using HTTParty or Net::HTTP for multipart. + # Multipart requests use Net::HTTP::Post::Multipart (multipart-post gem) because + # HTTParty's multipart handling produces malformed requests that the Nylas API rejects. # # @param method [Symbol] HTTP method for the API call. Either :get, :post, :delete, or :patch. # @param url [String] URL for the API call. @@ -143,25 +146,16 @@ def parse_response(response) # @param payload [String, Hash] Body to send with the request. # @param timeout [Hash] Timeout value to send with the request. def httparty_execute(method:, url:, headers:, payload:, timeout:) - options = { - headers: headers, - timeout: timeout - } - - # Handle multipart uploads - if payload.is_a?(Hash) && file_upload?(payload) - options[:multipart] = true - options[:body] = prepare_multipart_payload(payload) - elsif payload - options[:body] = payload + if method == :post && payload.is_a?(Hash) && file_upload?(payload) + response = execute_multipart_request(url: url, headers: headers, payload: payload, timeout: timeout) + else + options = { headers: headers, timeout: timeout } + options[:body] = payload if payload + response = HTTParty.send(method, url, options) end - response = HTTParty.send(method, url, options) - - # Create a compatible response object that mimics RestClient::Response result = create_response_wrapper(response) - # Call the block with the response in the same format as rest-client if block_given? yield response, nil, result else @@ -169,6 +163,77 @@ def httparty_execute(method:, url:, headers:, payload:, timeout:) end end + # Executes multipart POST using Net::HTTP::Post::Multipart (fixes issue #538). + # HTTParty's multipart produces malformed requests; multipart-post/UploadIO works correctly. + def execute_multipart_request(url:, headers:, payload:, timeout:) + uri = URI.parse(url) + params = build_multipart_params(payload) + + req = Net::HTTP::Post::Multipart.new(uri.path, params) + headers.each { |key, value| req[key] = value } + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") + http.read_timeout = timeout + http.open_timeout = timeout + + response = http.request(req) + + create_httparty_like_response(response) + end + + # Build params hash for Net::HTTP::Post::Multipart with UploadIO for file fields. + def build_multipart_params(payload) + params = {} + payload.each do |key, value| + params[key] = if key.is_a?(String) && key != "message" && file_like_value?(value) + value_to_upload_io(value) + else + value.to_s + end + end + params + end + + def file_like_value?(value) + return true if value.respond_to?(:read) && (value.is_a?(File) ? !value.closed? : true) + if value.is_a?(String) && (value.respond_to?(:original_filename) || value.respond_to?(:content_type)) + return true + end + + false + end + + # Convert File, String, or StringIO to UploadIO for multipart-post. + def value_to_upload_io(value) + content_type = value.respond_to?(:content_type) ? value.content_type : "application/octet-stream" + filename = value.respond_to?(:original_filename) ? value.original_filename : "file.bin" + + io = if value.respond_to?(:read) && value.respond_to?(:rewind) + value.rewind if value.respond_to?(:rewind) + value + else + require "stringio" + content = value.to_s + content = content.dup.force_encoding(Encoding::ASCII_8BIT) if content.is_a?(String) + StringIO.new(content) + end + + UploadIO.new(io, content_type, filename) + end + + # Create response object compatible with HTTParty::Response interface. + def create_httparty_like_response(net_http_response) + headers = net_http_response.to_hash + headers = headers.transform_values { |v| v.is_a?(Array) && v.one? ? v.first : v } + + OpenStruct.new( + body: net_http_response.body, + code: net_http_response.code.to_i, + headers: headers + ) + end + # Create a response wrapper that mimics RestClient::Response.code behavior def create_response_wrapper(response) OpenStruct.new(code: response.code) diff --git a/lib/nylas/resources/webhooks.rb b/lib/nylas/resources/webhooks.rb index 91baf36a..cf8a1087 100644 --- a/lib/nylas/resources/webhooks.rb +++ b/lib/nylas/resources/webhooks.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "cgi" require_relative "resource" require_relative "../handler/api_operations" @@ -115,14 +114,14 @@ def ip_addresses # @return [String] The challenge parameter def self.extract_challenge_parameter(url) url_object = URI.parse(url) - query = CGI.parse(url_object.query || "") + params = URI.decode_www_form(url_object.query || "") + challenge_pair = params.find { |k, _| k == "challenge" } - challenge_parameter = query["challenge"] - if challenge_parameter.nil? || challenge_parameter.empty? || challenge_parameter.first.nil? + if challenge_pair.nil? || challenge_pair.last.to_s.empty? raise "Invalid URL or no challenge parameter found." end - challenge_parameter.first + challenge_pair.last end end end diff --git a/nylas.gemspec b/nylas.gemspec index 8df50b20..6ad5a984 100644 --- a/nylas.gemspec +++ b/nylas.gemspec @@ -14,6 +14,7 @@ Gem::Specification.new do |gem| gem.add_runtime_dependency "base64" gem.add_runtime_dependency "httparty", "~> 0.21" gem.add_runtime_dependency "mime-types", "~> 3.5", ">= 3.5.1" + gem.add_runtime_dependency "multipart-post", "~> 2.0" gem.add_runtime_dependency "ostruct", "~> 0.6" gem.add_runtime_dependency "yajl-ruby", "~> 1.4.3", ">= 1.2.1" diff --git a/spec/nylas/handler/http_client_integration_spec.rb b/spec/nylas/handler/http_client_integration_spec.rb index 7aa69322..d26be56a 100644 --- a/spec/nylas/handler/http_client_integration_spec.rb +++ b/spec/nylas/handler/http_client_integration_spec.rb @@ -42,7 +42,7 @@ class TestHttpClientIntegration expect(http_client.send(:file_upload?, payload)).to be false end - it "handles multipart requests correctly" do + it "handles multipart requests correctly using Net::HTTP::Post::Multipart (issue #538)" do temp_file = Tempfile.new("test") temp_file.write("test content") temp_file.rewind @@ -60,22 +60,15 @@ class TestHttpClientIntegration payload: payload } - # Setup HTTParty spy and mock response - mock_response = instance_double("HTTParty::Response", - body: '{"success": true}', - headers: { "content-type" => "application/json" }, - code: 200) - - allow(HTTParty).to receive(:post).and_return(mock_response) + stub_request(:post, "https://test.api.nylas.com/upload") + .with(headers: { "Content-Type" => %r{multipart/form-data} }) + .to_return(status: 200, body: '{"success": true}', headers: { "Content-Type" => "application/json" }) response = http_client.send(:execute, **request_params) expect(response[:success]).to be true - # Verify multipart option was set correctly - expect(HTTParty).to have_received(:post) do |_url, options| - expect(options[:multipart]).to be true - expect(options[:body]).to include("file" => temp_file) - end + expect(WebMock).to have_requested(:post, "https://test.api.nylas.com/upload") + .with(headers: { "Content-Type" => %r{multipart/form-data} }) temp_file.close temp_file.unlink diff --git a/spec/nylas/handler/http_client_spec.rb b/spec/nylas/handler/http_client_spec.rb index c8ca8db9..a5d79ff8 100644 --- a/spec/nylas/handler/http_client_spec.rb +++ b/spec/nylas/handler/http_client_spec.rb @@ -292,6 +292,38 @@ class TestHttpClient expect(http_client.send(:file_upload?, payload)).to be false end + # Issue #538: multipart requests use Net::HTTP::Post::Multipart, not HTTParty + it "uses Net::HTTP for multipart requests (HTTParty produces malformed requests)" do + require "tempfile" + temp_file = Tempfile.new("issue538") + temp_file.write("x" * (4 * 1024 * 1024)) # 4MB + temp_file.rewind + + payload = { + "multipart" => true, + "message" => '{"subject":"test","to":[{"email":"t@t.com"}]}', + "file0" => temp_file + } + + stub_request(:post, "https://test.api.nylas.com/v3/grants/g1/messages/send") + .with(headers: { "Content-Type" => %r{multipart/form-data} }) + .to_return(status: 200, body: '{"id":"msg-123"}', headers: { "Content-Type" => "application/json" }) + + response = http_client.send(:execute, + method: :post, + path: "https://test.api.nylas.com/v3/grants/g1/messages/send", + timeout: 30, + payload: payload, + api_key: "key") + + expect(response[:id]).to eq("msg-123") + expect(WebMock).to have_requested(:post, "https://test.api.nylas.com/v3/grants/g1/messages/send") + .with(headers: { "Content-Type" => %r{multipart/form-data} }) + ensure + temp_file&.close + temp_file&.unlink + end + # Bug fix tests: handle custom content_id values context "when attachments use custom content_id values" do it "detects file uploads with custom content_id values" do From 580b0039926231a36a365cfa411b5d9078dc33ca Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 19 Feb 2026 17:19:56 -0500 Subject: [PATCH 4/4] Update Rubocop configuration and dependencies - Modify .rubocop.yml to disable new cops and add specific RSpec rules - Update rubocop-rspec dependency version in gem_config.rb to ~> 3.5 --- .rubocop.yml | 18 ++++++++++++++++-- gem_config.rb | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 411dc771..522f1524 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,11 +1,12 @@ inherit_from: .rubocop_todo.yml -require: +plugins: - rubocop-rspec - rubocop-capybara AllCops: TargetRubyVersion: 3.0 + NewCops: disable DisplayCopNames: true DisplayStyleGuide: true Exclude: @@ -57,5 +58,18 @@ RSpec/MultipleExpectations: RSpec/ExampleLength: Enabled: false -RSpec/FilePath: +RSpec/SpecFilePathFormat: + Enabled: false +RSpec/SpecFilePathSuffix: + Enabled: false + +RSpec/VerifiedDoubleReference: + Enabled: false +RSpec/BeEq: + Enabled: false +RSpec/IdenticalEqualityAssertion: + Enabled: false +RSpec/NoExpectationExample: + Enabled: false +RSpec/ReceiveMessages: Enabled: false diff --git a/gem_config.rb b/gem_config.rb index cb26e6e5..4e34cbd6 100644 --- a/gem_config.rb +++ b/gem_config.rb @@ -38,7 +38,7 @@ def self.dev_dependencies [["bundler", ">= 1.3.0"], ["yard", "~> 0.9.34"], ["rubocop", "~> 1.51"], - ["rubocop-rspec", "~> 2.22"], + ["rubocop-rspec", "~> 3.5"], ["rubocop-capybara", "~> 2.20"]] + testing_and_debugging_dependencies end