diff --git a/.gitignore b/.gitignore index 956c01e3..4bec14ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .env **/*/.env +# Example test files +examples/messages/large_test_file.txt *.gem *.rbc .bundle diff --git a/CHANGELOG.md b/CHANGELOG.md index f8454df1..e7004b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ### Unreleased +* Replaced `rest-client` dependency with `httparty` for improved maintainability and security + - `rest-client` is no longer actively maintained and has known security vulnerabilities + - `httparty` is actively maintained and provides better performance + - All existing functionality remains backwards compatible - no customer code changes required + - Removed workaround for `rest-client` cookie jar threading bug * Added support for new `fields` query parameter values in Messages API: - `include_tracking_options`: Returns messages and their tracking settings - `raw_mime`: Returns the grant_id, object, id, and raw_mime fields only @@ -11,6 +16,11 @@ - `label`: String label describing the message tracking purpose * Added support for `raw_mime` field in message responses containing Base64url-encoded message data * Added `MessageFields` module with constants for all valid field values to improve developer experience +* Fixed multipart email sending bug where large attachments would fail due to multipart flag key mismatch (#525) + - `FileUtils.handle_message_payload` transforms keys to symbols (`:multipart`) + - `HttpClient.build_request` was only checking for string keys (`"multipart"`) + - Now checks for both string and symbol keys to maintain full backwards compatibility + - Prevents encoding errors when sending emails with attachments larger than 3MB ### 6.4.0 / 2025-04-30 * Added support for Notetaker APIs diff --git a/README.md b/README.md index 5fc295ea..3c584b54 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ If you have a question about the Nylas Communications Platform, [contact Nylas S ### Prerequisites - Ruby 3.0 or above. -- Ruby Frameworks: `rest-client` and `yajl-ruby`. +- Ruby Frameworks: `httparty` and `yajl-ruby`. ### Install diff --git a/examples/.env.example b/examples/.env.example index 9b015c6e..55c9415f 100644 --- a/examples/.env.example +++ b/examples/.env.example @@ -12,4 +12,8 @@ NYLAS_API_URI=https://api.us.nylas.com # Grant ID - Required for message and event examples # You can get this from your Nylas Dashboard after connecting an account -NYLAS_GRANT_ID=your_grant_id_here \ No newline at end of file +NYLAS_GRANT_ID=your_grant_id_here + +# Send email - Optionl +# Used for send examples +NYLAS_TEST_EMAIL=your@email.com \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index b77133b9..429695cc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,7 +10,9 @@ examples/ ├── events/ # Event-related examples │ └── event_notetaker_example.rb # Example of creating events with Notetaker ├── messages/ # Message-related examples -│ └── message_fields_example.rb # Example of using new message fields functionality +│ ├── message_fields_example.rb # Example of using new message fields functionality +│ ├── file_upload_example.rb # Example of file upload functionality with HTTParty migration +│ └── send_message_example.rb # Example of basic message sending functionality └── notetaker/ # Standalone Notetaker examples ├── README.md # Notetaker-specific documentation └── notetaker_example.rb # Basic Notetaker functionality example @@ -53,6 +55,31 @@ Before running any example, make sure to: export NYLAS_GRANT_ID="your_grant_id" ``` +- `messages/file_upload_example.rb`: Demonstrates file upload functionality with the HTTParty migration, including: + - Sending messages with small attachments (<3MB) - handled as JSON with base64 encoding + - Sending messages with large attachments (>3MB) - handled as multipart form data + - Creating test files of appropriate sizes for demonstration + - File handling logic and processing differences + - Verification that HTTParty migration works for both upload methods + + Additional environment variables needed: + ```bash + export NYLAS_GRANT_ID="your_grant_id" + export NYLAS_TEST_EMAIL="test@example.com" # Email address to send test messages to + ``` + +- `messages/send_message_example.rb`: Demonstrates basic message sending functionality, including: + - Sending simple text messages + - Handling multiple recipients (TO, CC, BCC) + - Sending rich HTML content + - Processing responses and error handling + + Additional environment variables needed: + ```bash + export NYLAS_GRANT_ID="your_grant_id" + export NYLAS_TEST_EMAIL="test@example.com" # Email address to send test messages to + ``` + ### Notetaker - `notetaker/notetaker_example.rb`: Shows basic Notetaker functionality, including: - Inviting a Notetaker to a meeting diff --git a/examples/messages/file_upload_example.rb b/examples/messages/file_upload_example.rb new file mode 100644 index 00000000..d7e3ccaa --- /dev/null +++ b/examples/messages/file_upload_example.rb @@ -0,0 +1,276 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Example demonstrating file upload functionality in the Nylas Ruby SDK +# Tests both small (<3MB) and large (>3MB) file handling with the new HTTParty implementation +# +# This example shows how to: +# 1. Send messages with small attachments (<3MB) - handled as JSON with base64 encoding +# 2. Send messages with large attachments (>3MB) - handled as multipart form data +# 3. Create test files of appropriate sizes for demonstration +# 4. Handle file upload errors and responses +# +# Prerequisites: +# - Ruby 3.0 or later +# - A Nylas API key +# - A grant ID (connected email account) +# - A test email address to send to +# +# Environment variables needed: +# export NYLAS_API_KEY="your_api_key" +# export NYLAS_GRANT_ID="your_grant_id" +# export NYLAS_TEST_EMAIL="test@example.com" # Email address to send test messages to +# export NYLAS_API_URI="https://api.us.nylas.com" # Optional +# +# Alternatively, create a .env file in the examples directory with: +# NYLAS_API_KEY=your_api_key +# NYLAS_GRANT_ID=your_grant_id +# NYLAS_TEST_EMAIL=test@example.com +# NYLAS_API_URI=https://api.us.nylas.com + +$LOAD_PATH.unshift File.expand_path('../../lib', __dir__) +require "nylas" +require "json" +require "tempfile" + +# Simple .env file loader +def load_env_file + env_file = File.expand_path('../.env', __dir__) + return unless File.exist?(env_file) + + puts "Loading environment variables from .env file..." + File.readlines(env_file).each do |line| + line = line.strip + next if line.empty? || line.start_with?('#') + + key, value = line.split('=', 2) + next unless key && value + + # Remove quotes if present + value = value.gsub(/\A['"]|['"]\z/, '') + ENV[key] = value + end +end + +def create_small_test_file + puts "\n=== Creating Small Test File (<3MB) ===" + + # Create a 1MB test file + content = "A" * (1024 * 1024) # 1MB of 'A' characters + + temp_file = Tempfile.new(['small_test', '.txt']) + temp_file.write(content) + temp_file.rewind + + puts "- Created test file: #{temp_file.path}" + puts "- File size: #{File.size(temp_file.path)} bytes (#{File.size(temp_file.path) / (1024.0 * 1024).round(2)} MB)" + puts "- This will be sent as JSON with base64 encoding" + + temp_file +end + +def find_or_create_large_test_file + puts "\n=== Finding Large Test File (>3MB) ===" + + # Look for an existing large file, or create one if needed + large_file_path = File.expand_path("large_test_file.txt", __dir__) + + unless File.exist?(large_file_path) && File.size(large_file_path) > 3 * 1024 * 1024 + puts "- Creating 5MB test file on disk..." + content = "B" * (5 * 1024 * 1024) # 5MB of 'B' characters + File.write(large_file_path, content) + puts "- Created permanent test file: #{large_file_path}" + else + puts "- Found existing test file: #{large_file_path}" + end + + puts "- File size: #{File.size(large_file_path)} bytes (#{File.size(large_file_path) / (1024.0 * 1024).round(2)} MB)" + puts "- This will be sent as multipart form data" + + large_file_path +end + +def send_message_with_small_attachment(nylas, grant_id, recipient_email, test_file) + puts "\n=== Sending Message with Small Attachment ===" + + begin + # Build the file attachment + file_attachment = Nylas::FileUtils.attach_file_request_builder(test_file.path) + + request_body = { + subject: "Test Email with Small Attachment (<3MB) - HTTParty Migration Test", + to: [{ email: recipient_email }], + body: "This is a test email with a small attachment (<3MB) to verify the HTTParty migration works correctly.\n\nFile size: #{File.size(test_file.path)} bytes\nSent at: #{Time.now}", + attachments: [file_attachment] + } + + puts "- Sending message with small attachment..." + puts "- Recipient: #{recipient_email}" + puts "- Attachment size: #{File.size(test_file.path)} bytes" + puts "- Expected handling: JSON with base64 encoding" + + response, request_id = nylas.messages.send( + identifier: grant_id, + request_body: request_body + ) + + puts "✅ Message sent successfully!" + puts "- Message ID: #{response[:id]}" + puts "- Request ID: #{request_id}" + puts "- Grant ID: #{response[:grant_id]}" + + response + rescue => e + puts "❌ Failed to send message with small attachment: #{e.message}" + puts "- Error class: #{e.class}" + raise + end +end + +def send_message_with_large_attachment(nylas, grant_id, recipient_email, test_file_path) + puts "\n=== Sending Message with Large Attachment ===" + + begin + # Build the file attachment + file_attachment = Nylas::FileUtils.attach_file_request_builder(test_file_path) + + request_body = { + subject: "Test Email with Large Attachment (>3MB) - HTTParty Migration Test", + to: [{ email: recipient_email }], + body: "This is a test email with a large attachment (>3MB) to verify the HTTParty migration works correctly.\n\nFile size: #{File.size(test_file_path)} bytes\nSent at: #{Time.now}", + attachments: [file_attachment] + } + + puts "- Sending message with large attachment..." + puts "- Recipient: #{recipient_email}" + puts "- Attachment size: #{File.size(test_file_path)} bytes" + puts "- Expected handling: Multipart form data" + + response, request_id = nylas.messages.send( + identifier: grant_id, + request_body: request_body + ) + + puts "✅ Message sent successfully!" + puts "- Message ID: #{response[:id]}" + puts "- Request ID: #{request_id}" + puts "- Grant ID: #{response[:grant_id]}" + + response + rescue => e + puts "❌ Failed to send message with large attachment: #{e.message}" + puts "- Error class: #{e.class}" + raise + end +end + +def demonstrate_file_utils_handling + puts "\n=== Demonstrating File Handling Logic ===" + + # Create temporary files to test the file handling logic + small_file = create_small_test_file + large_file_path = find_or_create_large_test_file + + begin + # Test small file handling + small_attachment = Nylas::FileUtils.attach_file_request_builder(small_file.path) + puts "- Small file attachment structure: #{small_attachment.keys}" + + # Test large file handling + large_attachment = Nylas::FileUtils.attach_file_request_builder(large_file_path) + puts "- Large file attachment structure: #{large_attachment.keys}" + + # Demonstrate the SDK's file size handling + small_payload = { + subject: "test", + attachments: [small_attachment] + } + + large_payload = { + subject: "test", + attachments: [large_attachment] + } + + # Show how the SDK determines handling method + small_handling, small_files = Nylas::FileUtils.handle_message_payload(small_payload) + large_handling, large_files = Nylas::FileUtils.handle_message_payload(large_payload) + + puts "- Small file handling method: #{small_handling['multipart'] ? 'Form Data' : 'JSON'}" + puts "- Large file handling method: #{large_handling['multipart'] ? 'Form Data' : 'JSON'}" + + ensure + small_file.close + small_file.unlink + # Note: We keep the large file on disk for future use + end +end + +def main + # Load .env file if it exists + load_env_file + + # Check for required environment variables + api_key = ENV["NYLAS_API_KEY"] + grant_id = ENV["NYLAS_GRANT_ID"] + test_email = ENV["NYLAS_TEST_EMAIL"] + + raise "NYLAS_API_KEY environment variable is not set" unless api_key + raise "NYLAS_GRANT_ID environment variable is not set" unless grant_id + raise "NYLAS_TEST_EMAIL environment variable is not set" unless test_email + + puts "=== Nylas File Upload Example - HTTParty Migration Test ===" + puts "Using API key: #{api_key[0..4]}..." + puts "Using grant ID: #{grant_id[0..8]}..." + puts "Test email recipient: #{test_email}" + + # Initialize the Nylas client + nylas = Nylas::Client.new( + api_key: api_key, + api_uri: ENV["NYLAS_API_URI"] || "https://api.us.nylas.com" + ) + + begin + # Demonstrate file handling logic + demonstrate_file_utils_handling + + # Create test files + small_file = create_small_test_file + large_file_path = find_or_create_large_test_file + + begin + # Test 1: Send message with small attachment + small_response = send_message_with_small_attachment(nylas, grant_id, test_email, small_file) + + # Test 2: Send message with large attachment + large_response = send_message_with_large_attachment(nylas, grant_id, test_email, large_file_path) + + puts "\n=== Summary ===" + puts "✅ Both small and large file uploads completed successfully!" + puts "- Small file message ID: #{small_response[:id]}" + puts "- Large file message ID: #{large_response[:id]}" + puts "- HTTParty migration verified for both file handling methods" + + ensure + # Clean up temporary small file only + small_file.close + small_file.unlink + puts "\n🧹 Cleaned up temporary small file (large file kept on disk for reuse)" + end + + rescue => e + puts "\n❌ Example failed: #{e.message}" + puts "- #{e.backtrace.first}" + exit 1 + end + + puts "\n🎉 File upload example completed successfully!" + puts "This confirms that the HTTParty migration properly handles:" + puts "- Small files (<3MB): JSON with base64 encoding" + puts "- Large files (>3MB): Multipart form data" + puts "- File attachment building and processing" + puts "- HTTP request execution with different payload types" +end + +if __FILE__ == $0 + main +end \ No newline at end of file diff --git a/examples/messages/send_message_example.rb b/examples/messages/send_message_example.rb new file mode 100644 index 00000000..76da9fd4 --- /dev/null +++ b/examples/messages/send_message_example.rb @@ -0,0 +1,224 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Example demonstrating basic message sending functionality in the Nylas Ruby SDK +# +# This example shows how to: +# 1. Send a simple text message +# 2. Send a message with CC and BCC recipients +# 3. Send a message with HTML content +# 4. Handle send responses and errors +# +# Prerequisites: +# - Ruby 3.0 or later +# - A Nylas API key +# - A grant ID (connected email account) +# - A test email address to send to +# +# Environment variables needed: +# export NYLAS_API_KEY="your_api_key" +# export NYLAS_GRANT_ID="your_grant_id" +# export NYLAS_TEST_EMAIL="test@example.com" # Email address to send test messages to +# export NYLAS_API_URI="https://api.us.nylas.com" # Optional +# +# Alternatively, create a .env file in the examples directory with: +# NYLAS_API_KEY=your_api_key +# NYLAS_GRANT_ID=your_grant_id +# NYLAS_TEST_EMAIL=test@example.com +# NYLAS_API_URI=https://api.us.nylas.com + +$LOAD_PATH.unshift File.expand_path('../../lib', __dir__) +require "nylas" +require "json" + +# Simple .env file loader +def load_env_file + env_file = File.expand_path('../.env', __dir__) + return unless File.exist?(env_file) + + puts "Loading environment variables from .env file..." + File.readlines(env_file).each do |line| + line = line.strip + next if line.empty? || line.start_with?('#') + + key, value = line.split('=', 2) + next unless key && value + + # Remove quotes if present + value = value.gsub(/\A['"]|['"]\z/, '') + ENV[key] = value + end +end + +def send_simple_message(nylas, grant_id, recipient_email) + puts "\n=== Sending Simple Text Message ===" + + begin + request_body = { + subject: "Simple Test Message - Nylas Ruby SDK", + to: [{ email: recipient_email }], + body: "Hello! This is a simple test message sent using the Nylas Ruby SDK.\n\nSent at: #{Time.now}" + } + + puts "- Sending simple message..." + puts "- Recipient: #{recipient_email}" + puts "- Subject: #{request_body[:subject]}" + + response, request_id = nylas.messages.send( + identifier: grant_id, + request_body: request_body + ) + + puts "✅ Message sent successfully!" + puts "- Message ID: #{response[:id]}" + puts "- Request ID: #{request_id}" + puts "- Grant ID: #{response[:grant_id]}" + + response + rescue => e + puts "❌ Failed to send simple message: #{e.message}" + puts "- Error class: #{e.class}" + raise + end +end + +def send_message_with_multiple_recipients(nylas, grant_id, recipient_email) + puts "\n=== Sending Message with CC and BCC ===" + + begin + request_body = { + subject: "Test Message with Multiple Recipients - Nylas Ruby SDK", + to: [{ email: recipient_email }], + cc: [{ email: recipient_email, name: "CC Recipient" }], + bcc: [{ email: recipient_email, name: "BCC Recipient" }], + body: "This message demonstrates sending to multiple recipients.\n\n- TO: Primary recipient\n- CC: Carbon copy\n- BCC: Blind carbon copy\n\nSent at: #{Time.now}" + } + + puts "- Sending message with multiple recipients..." + puts "- TO: #{recipient_email}" + puts "- CC: #{recipient_email} (CC Recipient)" + puts "- BCC: #{recipient_email} (BCC Recipient)" + + response, request_id = nylas.messages.send( + identifier: grant_id, + request_body: request_body + ) + + puts "✅ Message with multiple recipients sent successfully!" + puts "- Message ID: #{response[:id]}" + puts "- Request ID: #{request_id}" + + response + rescue => e + puts "❌ Failed to send message with multiple recipients: #{e.message}" + puts "- Error class: #{e.class}" + raise + end +end + +def send_html_message(nylas, grant_id, recipient_email) + puts "\n=== Sending HTML Message ===" + + begin + html_content = <<~HTML + +
+This is a rich HTML message sent using the Nylas Ruby SDK.
+Sent at: #{Time.now}
+ + + HTML + + request_body = { + subject: "HTML Test Message - Nylas Ruby SDK", + to: [{ email: recipient_email }], + body: html_content, + # Also include a plain text version + reply_to: [{ email: recipient_email }] + } + + puts "- Sending HTML message..." + puts "- Recipient: #{recipient_email}" + puts "- Content: Rich HTML with formatting" + + response, request_id = nylas.messages.send( + identifier: grant_id, + request_body: request_body + ) + + puts "✅ HTML message sent successfully!" + puts "- Message ID: #{response[:id]}" + puts "- Request ID: #{request_id}" + + response + rescue => e + puts "❌ Failed to send HTML message: #{e.message}" + puts "- Error class: #{e.class}" + raise + end +end + +def main + # Load .env file if it exists + load_env_file + + # Check for required environment variables + api_key = ENV["NYLAS_API_KEY"] + grant_id = ENV["NYLAS_GRANT_ID"] + test_email = ENV["NYLAS_TEST_EMAIL"] + + raise "NYLAS_API_KEY environment variable is not set" unless api_key + raise "NYLAS_GRANT_ID environment variable is not set" unless grant_id + raise "NYLAS_TEST_EMAIL environment variable is not set" unless test_email + + puts "=== Nylas Send Message Example ===" + puts "Using API key: #{api_key[0..4]}..." + puts "Using grant ID: #{grant_id[0..8]}..." + puts "Test email recipient: #{test_email}" + + # Initialize the Nylas client + nylas = Nylas::Client.new( + api_key: api_key, + api_uri: ENV["NYLAS_API_URI"] || "https://api.us.nylas.com" + ) + + begin + # Test 1: Send simple message + simple_response = send_simple_message(nylas, grant_id, test_email) + + # Test 2: Send message with multiple recipients + multi_response = send_message_with_multiple_recipients(nylas, grant_id, test_email) + + # Test 3: Send HTML message + html_response = send_html_message(nylas, grant_id, test_email) + + puts "\n=== Summary ===" + puts "✅ All message sending tests completed successfully!" + puts "- Simple message ID: #{simple_response[:id]}" + puts "- Multi-recipient message ID: #{multi_response[:id]}" + puts "- HTML message ID: #{html_response[:id]}" + puts "- All messages sent to: #{test_email}" + + rescue => e + puts "\n❌ Example failed: #{e.message}" + puts "- #{e.backtrace.first}" + exit 1 + end + + puts "\n🎉 Send message example completed successfully!" + puts "This confirms that the Nylas Ruby SDK can:" + puts "- Send simple text messages" + puts "- Handle multiple recipients (TO, CC, BCC)" + puts "- Send rich HTML content" + puts "- Process responses and handle errors" +end + +if __FILE__ == $0 + main +end \ No newline at end of file diff --git a/examples/messages/test_example.rb b/examples/messages/test_example.rb deleted file mode 100644 index 0519ecba..00000000 --- a/examples/messages/test_example.rb +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/lib/nylas.rb b/lib/nylas.rb index 81c526b1..8086c8a2 100644 --- a/lib/nylas.rb +++ b/lib/nylas.rb @@ -1,21 +1,7 @@ # frozen_string_literal: true require "json" -require "rest-client" - -# BUGFIX -# See https://github.com/sparklemotion/http-cookie/issues/27 -# and https://github.com/sparklemotion/http-cookie/issues/6 -# -# CookieJar uses unsafe class caching for dynamically loading cookie jars. -# If two rest-client instances are instantiated at the same time (in threads), non-deterministic -# behaviour can occur whereby the Hash cookie jar isn't properly loaded and cached. -# Forcing an instantiation of the jar onload will force the CookieJar to load before the system has -# a chance to spawn any threads. -# Note that this should technically be fixed in rest-client itself, however that library appears to -# be stagnant so we're forced to fix it here. -# This object should get GC'd as it's not referenced by anything. -HTTP::CookieJar.new +require "httparty" require "ostruct" require "forwardable" diff --git a/lib/nylas/handler/http_client.rb b/lib/nylas/handler/http_client.rb index 6e20330a..d637e272 100644 --- a/lib/nylas/handler/http_client.rb +++ b/lib/nylas/handler/http_client.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require "rest-client" +require "httparty" +require "net/http" require_relative "../errors" require_relative "../version" @@ -34,18 +35,18 @@ def execute(method:, path:, timeout:, headers: {}, query: {}, payload: nil, api_ request = build_request(method: method, path: path, headers: headers, query: query, payload: payload, api_key: api_key, timeout: timeout) begin - rest_client_execute(**request) do |response, _request, result| + httparty_execute(**request) do |response, _request, result| content_type = nil - if response.headers && response.headers[:content_type] - content_type = response.headers[:content_type].downcase + if response.headers && response.headers["content-type"] + content_type = response.headers["content-type"].downcase end - parsed_response = parse_json_evaluate_error(result.code.to_i, response, path, content_type) + parsed_response = parse_json_evaluate_error(result.code.to_i, response.body, path, content_type) # Include headers in the response parsed_response[:headers] = response.headers unless parsed_response.nil? parsed_response end - rescue RestClient::Exceptions::OpenTimeout, RestClient::Exceptions::ReadTimeout + rescue Net::OpenTimeout, Net::ReadTimeout raise Nylas::NylasSdkTimeoutError.new(request[:path], timeout) end end @@ -97,11 +98,17 @@ def build_request( ) url = build_url(path, query) resulting_headers = default_headers.merge(headers).merge(auth_header(api_key)) - if !payload.nil? && !payload["multipart"] + + # Check for multipart flag using both string and symbol keys for backwards compatibility + is_multipart = !payload.nil? && (payload["multipart"] || payload[:multipart]) + + if !payload.nil? && !is_multipart payload = payload&.to_json resulting_headers["Content-type"] = "application/json" - elsif !payload.nil? && payload["multipart"] + elsif is_multipart + # Remove multipart flag from both possible key types payload.delete("multipart") + payload.delete(:multipart) end { method: method, url: url, payload: payload, headers: resulting_headers, timeout: timeout } @@ -126,16 +133,52 @@ def parse_response(response) private - # Sends a request to the Nylas REST API. + # Sends a request to the Nylas REST API using HTTParty. # # @param method [Symbol] HTTP method for the API call. Either :get, :post, :delete, or :patch. # @param url [String] URL for the API call. # @param headers [Hash] HTTP headers to include in the payload. # @param payload [String, Hash] Body to send with the request. # @param timeout [Hash] Timeout value to send with the request. - def rest_client_execute(method:, url:, headers:, payload:, timeout:, &block) - ::RestClient::Request.execute(method: method, url: url, payload: payload, - headers: headers, timeout: timeout, &block) + 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] = payload + elsif payload + options[:body] = payload + 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 + response + end + end + + # Create a response wrapper that mimics RestClient::Response.code behavior + def create_response_wrapper(response) + OpenStruct.new(code: response.code) + end + + # Check if payload contains file uploads + def file_upload?(payload) + return false unless payload.is_a?(Hash) + + payload.values.any? do |value| + value.respond_to?(:read) || (value.is_a?(File) && !value.closed?) + end end def setup_http(path, timeout, headers, query, api_key) diff --git a/nylas.gemspec b/nylas.gemspec index 996c49f3..8df50b20 100644 --- a/nylas.gemspec +++ b/nylas.gemspec @@ -12,9 +12,9 @@ Gem::Specification.new do |gem| # Runtime dependencies 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 "ostruct", "~> 0.6" - gem.add_runtime_dependency "rest-client", ">= 2.0.0", "< 3.0" gem.add_runtime_dependency "yajl-ruby", "~> 1.4.3", ">= 1.2.1" # Add remaining gem details and dev dependencies diff --git a/spec/nylas/handler/http_client_integration_spec.rb b/spec/nylas/handler/http_client_integration_spec.rb new file mode 100644 index 00000000..7aa69322 --- /dev/null +++ b/spec/nylas/handler/http_client_integration_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "webmock/rspec" +require "tempfile" + +class TestHttpClientIntegration + include Nylas::HttpClient +end + +describe Nylas::HttpClient do + subject(:http_client) do + http_client = TestHttpClientIntegration.new + allow(http_client).to receive(:api_uri).and_return("https://test.api.nylas.com") + + http_client + end + + describe "Integration Tests - file upload functionality" do + it "correctly identifies file uploads in payload" do + # Create a temporary file for testing + temp_file = Tempfile.new("test") + temp_file.write("test content") + temp_file.rewind + + payload = { + "message" => "test message", + "file" => temp_file + } + + expect(http_client.send(:file_upload?, payload)).to be true + + temp_file.close + temp_file.unlink + end + + it "returns false for non-file payloads" do + payload = { + "message" => "test message", + "data" => "some data" + } + + expect(http_client.send(:file_upload?, payload)).to be false + end + + it "handles multipart requests correctly" do + temp_file = Tempfile.new("test") + temp_file.write("test content") + temp_file.rewind + + payload = { + "multipart" => true, + "message" => "test message", + "file" => temp_file + } + + request_params = { + method: :post, + path: "https://test.api.nylas.com/upload", + timeout: 30, + 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) + + 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 + + temp_file.close + temp_file.unlink + end + end + + describe "Integration Tests - backwards compatibility" do + it "maintains the same response format as rest-client" do + response_json = { "data" => { "id" => "123", "name" => "test" } } + + mock_response = instance_double("HTTParty::Response", + body: response_json.to_json, + headers: { "content-type" => "application/json" }, + code: 200) + + allow(HTTParty).to receive(:get).and_return(mock_response) + + request_params = { + method: :get, + path: "https://test.api.nylas.com/test", + timeout: 30 + } + + response = http_client.send(:execute, **request_params) + + # Verify response structure matches expected format + expect(response).to have_key(:data) + expect(response).to have_key(:headers) + expect(response[:data][:id]).to eq("123") + expect(response[:data][:name]).to eq("test") + expect(response[:headers]).to eq(mock_response.headers) + end + end +end diff --git a/spec/nylas/handler/http_client_spec.rb b/spec/nylas/handler/http_client_spec.rb index a3469608..3024cdbe 100644 --- a/spec/nylas/handler/http_client_spec.rb +++ b/spec/nylas/handler/http_client_spec.rb @@ -141,7 +141,7 @@ class TestHttpClient ) end - it "returns the correct request with a multipart flag" do + it "returns the correct request with a multipart flag (string key)" do payload = { "multipart" => true } request = http_client.send(:build_request, method: :post, path: "https://test.api.nylas.com/foo", payload: payload, api_key: "fake-key") @@ -155,6 +155,97 @@ class TestHttpClient "Authorization" => "Bearer fake-key" ) end + + it "returns the correct request with a multipart flag (symbol key)" do + payload = { multipart: true } + request = http_client.send(:build_request, method: :post, path: "https://test.api.nylas.com/foo", + payload: payload, api_key: "fake-key") + + expect(request[:method]).to eq(:post) + expect(request[:url]).to eq("https://test.api.nylas.com/foo") + expect(request[:payload]).to eq({}) + expect(request[:headers]).to eq( + "User-Agent" => "Nylas Ruby SDK 1.0.0 - 5.0.0", + "X-Nylas-API-Wrapper" => "ruby", + "Authorization" => "Bearer fake-key" + ) + end + + it "handles mixed payload with both multipart keys (backwards compatibility)" do + payload = { "multipart" => true, multipart: true, "data" => "test", other: "value" } + request = http_client.send(:build_request, method: :post, path: "https://test.api.nylas.com/foo", + payload: payload, api_key: "fake-key") + + expect(request[:method]).to eq(:post) + expect(request[:url]).to eq("https://test.api.nylas.com/foo") + expect(request[:payload]).to eq({ "data" => "test", other: "value" }) + expect(request[:headers]).to eq( + "User-Agent" => "Nylas Ruby SDK 1.0.0 - 5.0.0", + "X-Nylas-API-Wrapper" => "ruby", + "Authorization" => "Bearer fake-key" + ) + end + + it "properly handles multipart payload with file-like content (simulating FileUtils output)" do + mock_file = instance_double("file") + allow(mock_file).to receive(:respond_to?).with(:read).and_return(true) + + # This simulates what FileUtils.handle_message_payload returns for large attachments + payload = { + multipart: true, # Symbol key as set by FileUtils.handle_message_payload + "message" => '{"to":[{"email":"test@example.com"}],"subject":"Test"}', + "file0" => mock_file + } + + request = http_client.send(:build_request, method: :post, path: "https://test.api.nylas.com/foo", + payload: payload, api_key: "fake-key") + + expect(request[:method]).to eq(:post) + expect(request[:url]).to eq("https://test.api.nylas.com/foo") + # Multipart should be removed, leaving only the actual payload + expect(request[:payload]).to eq( + "message" => '{"to":[{"email":"test@example.com"}],"subject":"Test"}', + "file0" => mock_file + ) + # Should NOT have Content-type: application/json since it's multipart + expect(request[:headers]).to eq( + "User-Agent" => "Nylas Ruby SDK 1.0.0 - 5.0.0", + "X-Nylas-API-Wrapper" => "ruby", + "Authorization" => "Bearer fake-key" + ) + end + + it "treats payload as JSON when multipart flag is false (string key)" do + payload = { "multipart" => false, "data" => "test" } + request = http_client.send(:build_request, method: :post, path: "https://test.api.nylas.com/foo", + payload: payload, api_key: "fake-key") + + expect(request[:method]).to eq(:post) + expect(request[:url]).to eq("https://test.api.nylas.com/foo") + expect(request[:payload]).to eq('{"multipart":false,"data":"test"}') + expect(request[:headers]).to eq( + "User-Agent" => "Nylas Ruby SDK 1.0.0 - 5.0.0", + "X-Nylas-API-Wrapper" => "ruby", + "Authorization" => "Bearer fake-key", + "Content-type" => "application/json" + ) + end + + it "treats payload as JSON when multipart flag is false (symbol key)" do + payload = { multipart: false, "data" => "test" } + request = http_client.send(:build_request, method: :post, path: "https://test.api.nylas.com/foo", + payload: payload, api_key: "fake-key") + + expect(request[:method]).to eq(:post) + expect(request[:url]).to eq("https://test.api.nylas.com/foo") + expect(request[:payload]).to eq('{"multipart":false,"data":"test"}') + expect(request[:headers]).to eq( + "User-Agent" => "Nylas Ruby SDK 1.0.0 - 5.0.0", + "X-Nylas-API-Wrapper" => "ruby", + "Authorization" => "Bearer fake-key", + "Content-type" => "application/json" + ) + end end end @@ -167,15 +258,16 @@ class TestHttpClient } request_params = { method: :get, path: "https://test.api.nylas.com/foo", timeout: 30 } mock_headers = { - content_type: "application/json", - x_request_id: "123", - some_header: "value" + "content-type" => "application/json", + "x-request-id" => "123", + "some-header" => "value" } - mock_http_res = instance_double("response", to_hash: {}, code: 200, headers: mock_headers) - mock_response = RestClient::Response.create(response_json.to_json, mock_http_res, mock_request) - mock_response.headers.merge!(mock_headers) + mock_response = instance_double("HTTParty::Response", + body: response_json.to_json, + headers: mock_headers, + code: 200) - allow(RestClient::Request).to receive(:execute).and_yield(mock_response, mock_request, mock_http_res) + allow(HTTParty).to receive(:get).and_return(mock_response) response = http_client.send(:execute, **request_params) @@ -184,7 +276,7 @@ class TestHttpClient it "raises a timeout error" do request_params = { method: :get, path: "https://test.api.nylas.com/foo", timeout: 30 } - allow(RestClient::Request).to receive(:execute).and_raise(RestClient::Exceptions::OpenTimeout) + allow(HTTParty).to receive(:get).and_raise(Net::OpenTimeout) expect do http_client.send(:execute, **request_params) diff --git a/spec/nylas/utils/file_utils_spec.rb b/spec/nylas/utils/file_utils_spec.rb index aa79eb66..ce8d002b 100644 --- a/spec/nylas/utils/file_utils_spec.rb +++ b/spec/nylas/utils/file_utils_spec.rb @@ -281,5 +281,56 @@ expect(payload).to include("multipart" => true) expect(opened_files).to include(mock_file) end + + # Test for the bug fix: ensure FileUtils.handle_message_payload output works with HttpClient.build_request + it "produces payload compatible with HttpClient.build_request (fixes issue #525)" do + # Create a test HTTP client to test the integration + test_client = Class.new do + include Nylas::HttpClient + attr_accessor :api_server + + def api_uri + "https://api.nylas.com" + end + + def auth_header(api_key) + { "Authorization" => "Bearer #{api_key}" } + end + end.new + + large_attachment = { + size: 4 * 1024 * 1024, + content: mock_file, + filename: "large_file.txt", + content_type: "text/plain" + } + request_body = { + to: [{ email: "test@example.com" }], + subject: "Test email with large attachment", + body: "This is a test email", + attachments: [large_attachment] + } + + allow(mock_file).to receive(:read).and_return("file content") + allow(File).to receive(:size).and_return(large_attachment[:size]) + + # This should return a payload with symbol keys (including :multipart => true) from transform_keys + payload, _opened_files = described_class.handle_message_payload(request_body) + + # Before the fix, this would fail because build_request only checked for string "multipart" + # After the fix, it should properly handle the symbol :multipart key + expect do + request = test_client.send(:build_request, + method: :post, + path: "/v3/grants/test/messages/send", + payload: payload, + api_key: "test-key") + + # The request should be properly formatted for multipart + expect(request[:payload]).not_to include(:multipart) # Should be removed + expect(request[:payload]).not_to include("multipart") # Should be removed + expect(request[:headers]).not_to include("Content-type") # Should NOT be JSON + end.not_to raise_error + end end end