diff --git a/.gitignore b/.gitignore index 4bec14ee..ed95ce23 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ **/*/.env # Example test files examples/messages/large_test_file.txt +examples/messages/small_pdf_test_file.pdf +examples/messages/large_pdf_test_file.pdf +examples/messages/large_jpg_test_file.jpg *.gem *.rbc .bundle diff --git a/CHANGELOG.md b/CHANGELOG.md index d29b9430..16a9b996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ### [Unreleased] * Added support for `single_level` query parameter in Folders API for Microsoft accounts * Added support for `include_hidden_folders` query parameter in folders list endpoint for Microsoft accounts to control whether hidden folders are included in the response +* Added support for passing in string contents for multipart and base64 (<3MB) attachments (#528) +* Fixed HTTParty encoding compatibility issue with multipart requests (#528) ### 6.5.0 / 2025-06-13 * Replaced `rest-client` dependency with `httparty` for improved maintainability and security diff --git a/examples/messages/file_upload_example.rb b/examples/messages/file_upload_example.rb index d7e3ccaa..a6d9b2e1 100644 --- a/examples/messages/file_upload_example.rb +++ b/examples/messages/file_upload_example.rb @@ -33,6 +33,28 @@ require "json" require "tempfile" +# Enhanced error logging helper +def log_detailed_error(error, context = "") + puts "\n❌ ERROR DETAILS #{context.empty? ? '' : "- #{context}"}" + puts "=" * 60 + puts "Error Class: #{error.class}" + puts "Error Message: #{error.message}" + + if error.respond_to?(:response) && error.response + puts "HTTP Response Code: #{error.response.code}" if error.response.respond_to?(:code) + puts "HTTP Response Body: #{error.response.body}" if error.response.respond_to?(:body) + puts "HTTP Response Headers: #{error.response.headers}" if error.response.respond_to?(:headers) + end + + if error.respond_to?(:request_id) && error.request_id + puts "Request ID: #{error.request_id}" + end + + puts "Full Stack Trace:" + puts error.backtrace.join("\n") + puts "=" * 60 +end + # Simple .env file loader def load_env_file env_file = File.expand_path('../.env', __dir__) @@ -50,52 +72,129 @@ def load_env_file value = value.gsub(/\A['"]|['"]\z/, '') ENV[key] = value end +rescue => e + log_detailed_error(e, "loading .env file") + raise 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 + begin + # 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 + rescue => e + log_detailed_error(e, "creating small test file") + raise + end +end + +def find_small_pdf_test_file + puts "\n=== Finding PDF Test File ===" - temp_file = Tempfile.new(['small_test', '.txt']) - temp_file.write(content) - temp_file.rewind + begin + pdf_file_path = File.expand_path("small_pdf_test_file.pdf", __dir__) + unless File.exist?(pdf_file_path) + raise "PDF test file not found at #{pdf_file_path}. Please create a PDF file for testing." + end + + puts "- Found PDF test file: #{pdf_file_path}" + puts "- File size: #{File.size(pdf_file_path)} bytes (#{File.size(pdf_file_path) / (1024.0 * 1024).round(2)} MB)" + puts "- This will be sent as multipart form data" + + pdf_file_path + rescue => e + log_detailed_error(e, "finding small PDF test file") + raise + end +end + +def find_large_pdf_test_file + puts "\n=== Finding Large PDF Test File ===" - 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" + begin + pdf_file_path = File.expand_path("large_pdf_test_file.pdf", __dir__) + unless File.exist?(pdf_file_path) + raise "PDF test file not found at #{pdf_file_path}. Please create a PDF file for testing." + end + + puts "- Found large PDF test file: #{pdf_file_path}" + puts "- File size: #{File.size(pdf_file_path)} bytes (#{File.size(pdf_file_path) / (1024.0 * 1024).round(2)} MB)" + puts "- This will be sent as multipart form data" + + pdf_file_path + rescue => e + log_detailed_error(e, "finding large PDF test file") + raise + end +end + +def find_large_jpg_test_file + puts "\n=== Finding Large JPG Test File ===" - temp_file + begin + jpg_file_path = File.expand_path("large_jpg_test_file.jpg", __dir__) + unless File.exist?(jpg_file_path) + raise "JPG test file not found at #{jpg_file_path}. Please create a JPG file for testing." + end + + puts "- Found large JPG test file: #{jpg_file_path}" + puts "- File size: #{File.size(jpg_file_path)} bytes (#{File.size(jpg_file_path) / (1024.0 * 1024).round(2)} MB)" + puts "- This will be sent as multipart form data" + + jpg_file_path + rescue => e + log_detailed_error(e, "finding large JPG test file") + raise + end 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}" + begin + # 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 + rescue => e + log_detailed_error(e, "finding or creating large test file") + raise 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 + puts "- Building file attachment for: #{test_file.path}" + # Build the file attachment file_attachment = Nylas::FileUtils.attach_file_request_builder(test_file.path) + puts "- File attachment built successfully" + puts "- Attachment keys: #{file_attachment.keys}" request_body = { subject: "Test Email with Small Attachment (<3MB) - HTTParty Migration Test", @@ -108,6 +207,7 @@ def send_message_with_small_attachment(nylas, grant_id, recipient_email, test_fi puts "- Recipient: #{recipient_email}" puts "- Attachment size: #{File.size(test_file.path)} bytes" puts "- Expected handling: JSON with base64 encoding" + puts "- Request body keys: #{request_body.keys}" response, request_id = nylas.messages.send( identifier: grant_id, @@ -118,11 +218,15 @@ def send_message_with_small_attachment(nylas, grant_id, recipient_email, test_fi puts "- Message ID: #{response[:id]}" puts "- Request ID: #{request_id}" puts "- Grant ID: #{response[:grant_id]}" + puts "- Response keys: #{response.keys}" response rescue => e - puts "❌ Failed to send message with small attachment: #{e.message}" - puts "- Error class: #{e.class}" + log_detailed_error(e, "sending message with small attachment") + puts "- Grant ID used: #{grant_id}" + puts "- Recipient: #{recipient_email}" + puts "- File path: #{test_file.path}" + puts "- File size: #{File.size(test_file.path)} bytes" if File.exist?(test_file.path) raise end end @@ -131,8 +235,12 @@ def send_message_with_large_attachment(nylas, grant_id, recipient_email, test_fi puts "\n=== Sending Message with Large Attachment ===" begin + puts "- Building file attachment for: #{test_file_path}" + # Build the file attachment file_attachment = Nylas::FileUtils.attach_file_request_builder(test_file_path) + puts "- File attachment built successfully" + puts "- Attachment keys: #{file_attachment.keys}" request_body = { subject: "Test Email with Large Attachment (>3MB) - HTTParty Migration Test", @@ -145,6 +253,7 @@ def send_message_with_large_attachment(nylas, grant_id, recipient_email, test_fi puts "- Recipient: #{recipient_email}" puts "- Attachment size: #{File.size(test_file_path)} bytes" puts "- Expected handling: Multipart form data" + puts "- Request body keys: #{request_body.keys}" response, request_id = nylas.messages.send( identifier: grant_id, @@ -155,11 +264,153 @@ def send_message_with_large_attachment(nylas, grant_id, recipient_email, test_fi puts "- Message ID: #{response[:id]}" puts "- Request ID: #{request_id}" puts "- Grant ID: #{response[:grant_id]}" + puts "- Response keys: #{response.keys}" + + response + rescue => e + log_detailed_error(e, "sending message with large attachment") + puts "- Grant ID used: #{grant_id}" + puts "- Recipient: #{recipient_email}" + puts "- File path: #{test_file_path}" + puts "- File size: #{File.size(test_file_path)} bytes" if File.exist?(test_file_path) + raise + end +end + +def send_message_with_small_pdf_attachment(nylas, grant_id, recipient_email, test_file_path) + puts "\n=== Sending Message with PDF Attachment ===" + + begin + puts "- Building PDF file attachment for: #{test_file_path}" + + # Build the file attachment + file_attachment = Nylas::FileUtils.attach_file_request_builder(test_file_path) + puts "- PDF file attachment built successfully" + puts "- Attachment keys: #{file_attachment.keys}" + + request_body = { + subject: "Test Email with PDF Attachment", + to: [{ email: recipient_email }], + body: "This is a test email with a PDF attachment 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 PDF attachment..." + puts "- Recipient: #{recipient_email}" + puts "- Attachment size: #{File.size(test_file_path)} bytes" + puts "- Expected handling: Multipart form data" + puts "- Request body keys: #{request_body.keys}" + + 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]}" + puts "- Response keys: #{response.keys}" + + response + rescue => e + log_detailed_error(e, "sending message with PDF attachment") + puts "- Grant ID used: #{grant_id}" + puts "- Recipient: #{recipient_email}" + puts "- File path: #{test_file_path}" + puts "- File size: #{File.size(test_file_path)} bytes" if File.exist?(test_file_path) + raise + end +end + +def send_message_with_large_pdf_attachment(nylas, grant_id, recipient_email, test_file_path) + puts "\n=== Sending Message with Large PDF Attachment ===" + + begin + puts "- Building large PDF file attachment for: #{test_file_path}" + + # Build the file attachment + file_attachment = Nylas::FileUtils.attach_file_request_builder(test_file_path) + puts "- Large PDF file attachment built successfully" + puts "- Attachment keys: #{file_attachment.keys}" + + request_body = { + subject: "Test Email with Large PDF Attachment", + to: [{ email: recipient_email }], + body: "This is a test email with a large PDF attachment 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 PDF attachment..." + puts "- Recipient: #{recipient_email}" + puts "- Attachment size: #{File.size(test_file_path)} bytes" + puts "- Expected handling: Multipart form data" + puts "- Request body keys: #{request_body.keys}" + + 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]}" + puts "- Response keys: #{response.keys}" + + response + rescue => e + log_detailed_error(e, "sending message with large PDF attachment") + puts "- Grant ID used: #{grant_id}" + puts "- Recipient: #{recipient_email}" + puts "- File path: #{test_file_path}" + puts "- File size: #{File.size(test_file_path)} bytes" if File.exist?(test_file_path) + raise + end +end + +def send_message_with_large_jpg_attachment(nylas, grant_id, recipient_email, test_file_path) + puts "\n=== Sending Message with Large JPG Attachment ===" + + begin + puts "- Building large JPG file attachment for: #{test_file_path}" + + # Build the file attachment + file_attachment = Nylas::FileUtils.attach_file_request_builder(test_file_path) + puts "- Large JPG file attachment built successfully" + puts "- Attachment keys: #{file_attachment.keys}" + + request_body = { + subject: "Test Email with Large JPG Attachment", + to: [{ email: recipient_email }], + body: "This is a test email with a large JPG attachment 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 JPG attachment..." + puts "- Recipient: #{recipient_email}" + puts "- Attachment size: #{File.size(test_file_path)} bytes" + puts "- Expected handling: Multipart form data" + puts "- Request body keys: #{request_body.keys}" + + 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]}" + puts "- Response keys: #{response.keys}" + response rescue => e - puts "❌ Failed to send message with large attachment: #{e.message}" - puts "- Error class: #{e.class}" + log_detailed_error(e, "sending message with large JPG attachment") + puts "- Grant ID used: #{grant_id}" + puts "- Recipient: #{recipient_email}" + puts "- File path: #{test_file_path}" + puts "- File size: #{File.size(test_file_path)} bytes" if File.exist?(test_file_path) raise end end @@ -167,75 +418,169 @@ def send_message_with_large_attachment(nylas, grant_id, recipient_email, test_fi 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 + # 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 + puts "- Testing file attachment builders..." + + # Test small file handling + small_attachment = Nylas::FileUtils.attach_file_request_builder(small_file.path) + puts "- Small file attachment structure: #{small_attachment.keys}" + puts "- Small file attachment content type: #{small_attachment[:content_type]}" if small_attachment[:content_type] + + # Test large file handling + large_attachment = Nylas::FileUtils.attach_file_request_builder(large_file_path) + puts "- Large file attachment structure: #{large_attachment.keys}" + puts "- Large file attachment content type: #{large_attachment[:content_type]}" if large_attachment[:content_type] + + # Demonstrate the SDK's file size handling + small_payload = { + subject: "test", + attachments: [small_attachment] + } + + large_payload = { + subject: "test", + attachments: [large_attachment] + } + + puts "- Testing payload handling methods..." + + # 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 "- Small files detected: #{small_files ? small_files.length : 0}" + puts "- Large file handling method: #{large_handling['multipart'] ? 'Form Data' : 'JSON'}" + puts "- Large files detected: #{large_files ? large_files.length : 0}" + + ensure + small_file.close + small_file.unlink + puts "- Cleaned up small test file" + # Note: We keep the large file on disk for future use + end + rescue => e + log_detailed_error(e, "demonstrating file utils handling") + raise + end +end + +def test_utf8_encoding_bug_with_large_attachment(nylas, grant_id, recipient_email, test_file_path) + puts "\n=== Testing UTF-8 Encoding Bug with Large Attachment ===" + puts "🐛 This test reproduces the HTTParty UTF-8 encoding bug" + puts " when sending multipart/form-data requests with non-ASCII characters" 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] - } + puts "- Building file attachment for: #{test_file_path}" - large_payload = { - subject: "test", - attachments: [large_attachment] + # Build the file attachment to force multipart handling + file_attachment = Nylas::FileUtils.attach_file_request_builder(test_file_path) + puts "- File attachment built successfully" + + # Create a request body with UTF-8 characters that should trigger the bug + request_body = { + subject: "UTF-8 Test: Ñylas 🚀 Tëst with Émojis and Spëcial Charactërs", + to: [{ email: recipient_email, name: "Tëst Récipient 👤" }], + body: "This message contains UTF-8 characters: ñ, é, ü, 中文, العربية, русский, 日本語, 한국어\n\n" + + "With emojis: 🚀 🌟 ✨ 💫 🎉 🎊 🎯 🔥 💯 ⚡\n\n" + + "Special characters: àáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ\n\n" + + "This should trigger the UTF-8 encoding bug in HTTParty when combined with large attachments.\n\n" + + "File size: #{File.size(test_file_path)} bytes\nSent at: #{Time.now}", + attachments: [file_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 "- Message contains UTF-8 characters in subject and body" + puts "- Subject: #{request_body[:subject]}" + puts "- Recipient name: #{request_body[:to][0][:name]}" + puts "- Attachment size: #{File.size(test_file_path)} bytes (forces multipart)" + puts "- Expected behavior: HTTParty should raise ArgumentError about invalid byte sequence" + + puts "- Attempting to send message (this should fail with UTF-8 encoding error)..." - puts "- Small file handling method: #{small_handling['multipart'] ? 'Form Data' : 'JSON'}" - puts "- Large file handling method: #{large_handling['multipart'] ? 'Form Data' : 'JSON'}" + response, request_id = nylas.messages.send( + identifier: grant_id, + request_body: request_body + ) - ensure - small_file.close - small_file.unlink - # Note: We keep the large file on disk for future use + # If we reach here, the bug might be fixed or the test conditions weren't met + puts "⚠️ UNEXPECTED: Message sent successfully!" + puts "- Message ID: #{response[:id]}" + puts "- Request ID: #{request_id}" + puts "- This suggests the UTF-8 encoding bug may have been fixed" + puts "- Or the test conditions didn't trigger multipart handling" + + response + rescue ArgumentError => e + if e.message.include?("invalid byte sequence") || e.message.include?("UTF-8") + puts "✅ BUG REPRODUCED: UTF-8 encoding error caught as expected!" + puts "- Error message: #{e.message}" + puts "- This confirms the bug exists in HTTParty multipart handling" + puts "- The issue is that UTF-8 encoded JSON message part is incompatible" + puts " with HTTParty's expectation that all multipart fields are ASCII-8BIT/BINARY" + + # Re-raise to show the full stack trace + raise e + else + puts "❓ Different ArgumentError occurred:" + puts "- Error message: #{e.message}" + raise e + end + rescue => e + log_detailed_error(e, "UTF-8 encoding bug test") + puts "- Grant ID used: #{grant_id}" + puts "- Recipient: #{recipient_email}" + puts "- File path: #{test_file_path}" + puts "- File size: #{File.size(test_file_path)} bytes" if File.exist?(test_file_path) + puts "- This error may or may not be the expected UTF-8 encoding bug" + 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 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 + # 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"] + + puts "- Checking environment variables..." + 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 "Using API key: #{api_key[0..4]}..." + puts "Using grant ID: #{grant_id[0..8]}..." + puts "Test email recipient: #{test_email}" + puts "API URI: #{ENV["NYLAS_API_URI"] || "https://api.us.nylas.com"}" + + # Initialize the Nylas client + puts "- Initializing Nylas client..." + nylas = Nylas::Client.new( + api_key: api_key, + api_uri: ENV["NYLAS_API_URI"] || "https://api.us.nylas.com" + ) + puts "- Nylas client initialized successfully" + # Demonstrate file handling logic demonstrate_file_utils_handling # Create test files + puts "- Creating and finding test files..." small_file = create_small_test_file large_file_path = find_or_create_large_test_file + small_pdf_file_path = find_small_pdf_test_file + large_pdf_file_path = find_large_pdf_test_file + large_jpg_file_path = find_large_jpg_test_file begin # Test 1: Send message with small attachment @@ -244,31 +589,72 @@ def main # Test 2: Send message with large attachment large_response = send_message_with_large_attachment(nylas, grant_id, test_email, large_file_path) + # Test 3: Send message with PDF attachment + pdf_response = send_message_with_small_pdf_attachment(nylas, grant_id, test_email, small_pdf_file_path) + + # Test 4: Send message with large PDF attachment + large_pdf_response = send_message_with_large_pdf_attachment(nylas, grant_id, test_email, large_pdf_file_path) + + # Test 5: Send message with large JPG attachment + large_jpg_response = send_message_with_large_jpg_attachment(nylas, grant_id, test_email, large_jpg_file_path) + + # Test 5: Test UTF-8 encoding bug with large attachment + puts "\n⚠️ WARNING: The next test is expected to FAIL and demonstrate a bug" + puts " If it succeeds, the bug may have been fixed!" + begin + utf8_bug_response = test_utf8_encoding_bug_with_large_attachment(nylas, grant_id, test_email, large_file_path) + puts "✅ UTF-8 test completed (unexpectedly successful)" + rescue ArgumentError => e + if e.message.include?("invalid byte sequence") || e.message.include?("UTF-8") + puts "✅ UTF-8 encoding bug successfully reproduced!" + puts " This confirms the reported bug exists." + else + puts "❌ Different ArgumentError occurred during UTF-8 test" + raise e + end + rescue => e + puts "❌ Unexpected error during UTF-8 test" + raise e + end + puts "\n=== Summary ===" - puts "✅ Both small and large file uploads completed successfully!" + puts "✅ File upload tests completed!" 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" + puts "- PDF message ID: #{pdf_response[:id]}" + puts "- Large PDF message ID: #{large_pdf_response[:id]}" + puts "- Large JPG message ID: #{large_jpg_response[:id]}" + puts "- UTF-8 encoding bug test: See results above" + puts "- HTTParty migration verified for supported 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)" + if small_file + small_file.close + small_file.unlink + puts "\n🧹 Cleaned up temporary small file (large file kept on disk for reuse)" + end end rescue => e - puts "\n❌ Example failed: #{e.message}" - puts "- #{e.backtrace.first}" + log_detailed_error(e, "main execution") + puts "\n💡 TROUBLESHOOTING HINTS:" + puts "- Check that all environment variables are set correctly" + puts "- Verify your API key has the correct permissions" + puts "- Ensure the grant ID is valid and active" + puts "- Check that the test email address is valid" + puts "- Verify network connectivity to the Nylas API" + puts "- Make sure test PDF files exist in the examples/messages directory" exit 1 end - puts "\n🎉 File upload example completed successfully!" - puts "This confirms that the HTTParty migration properly handles:" + puts "\n🎉 File upload example completed!" + puts "This example tests the HTTParty migration and includes:" 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" + puts "- UTF-8 encoding bug reproduction test (may fail as expected)" end if __FILE__ == $0 diff --git a/examples/messages/send_attachments.rb b/examples/messages/send_attachments.rb new file mode 100644 index 00000000..57b46856 --- /dev/null +++ b/examples/messages/send_attachments.rb @@ -0,0 +1,164 @@ +#!/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" + +# Enhanced error logging helper +def log_detailed_error(error, context = "") + puts "\n❌ ERROR DETAILS #{context.empty? ? '' : "- #{context}"}" + puts "=" * 60 + puts "Error Class: #{error.class}" + puts "Error Message: #{error.message}" + + if error.respond_to?(:response) && error.response + puts "HTTP Response Code: #{error.response.code}" if error.response.respond_to?(:code) + puts "HTTP Response Body: #{error.response.body}" if error.response.respond_to?(:body) + puts "HTTP Response Headers: #{error.response.headers}" if error.response.respond_to?(:headers) + end + + if error.respond_to?(:request_id) && error.request_id + puts "Request ID: #{error.request_id}" + end + + puts "Full Stack Trace:" + puts error.backtrace.join("\n") + puts "=" * 60 +end + +# 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 +rescue => e + log_detailed_error(e, "loading .env file") + raise +end + +# Def send message with small attachment +def send_message_with_attachment(nylas, grant_id, recipient_email, test_file_path, content_type) + # load the file and read it's contents + file_contents = File.read(test_file_path) + + # manually build file_attachment + file_attachment = { + filename: File.basename(test_file_path), + content_type: content_type, + content: file_contents, + size: File.size(test_file_path) + } + + request_body = { + subject: "Test Email with Attachment", + to: [{ email: recipient_email }], + body: "This is a test email with a attachment.\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" + puts "- Request body keys: #{request_body.keys}" + + response, request_id = nylas.messages.send( + identifier: grant_id, + request_body: request_body + ) + + puts "Response: #{response}" + puts "Request ID: #{request_id}" + puts "Grant ID: #{response[:grant_id]}" + puts "Message ID: #{response[:id]}" + puts "Message Subject: #{response[:subject]}" + puts "Message Body: #{response[:body]}" + +end + +def main + puts "=== Nylas File Upload Example - HTTParty Migration Test ===" + + begin + # 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"] + + puts "- Checking environment variables..." + 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 "Using API key: #{api_key[0..4]}..." + puts "Using grant ID: #{grant_id[0..8]}..." + puts "Test email recipient: #{test_email}" + puts "API URI: #{ENV["NYLAS_API_URI"] || "https://api.us.nylas.com"}" + + # Initialize the Nylas client + puts "- Initializing Nylas client..." + nylas = Nylas::Client.new( + api_key: api_key, + api_uri: ENV["NYLAS_API_URI"] || "https://api.us.nylas.com" + ) + puts "- Nylas client initialized successfully" + + # Demonstrate file handling logic + jpg_file_path = File.expand_path("large_jpg_test_file.jpg", __dir__) + unless File.exist?(jpg_file_path) + raise "JPG test file not found at #{jpg_file_path}. Please create a JPG file for testing." + end + send_message_with_attachment(nylas, grant_id, test_email, jpg_file_path, "image/jpeg") + + puts "\n=== File Upload Example Completed Successfully ===" + rescue => e + log_detailed_error(e, "main method") + raise + end +end + +main \ No newline at end of file diff --git a/lib/nylas/handler/http_client.rb b/lib/nylas/handler/http_client.rb index d637e272..d373140c 100644 --- a/lib/nylas/handler/http_client.rb +++ b/lib/nylas/handler/http_client.rb @@ -103,6 +103,7 @@ def build_request( is_multipart = !payload.nil? && (payload["multipart"] || payload[:multipart]) if !payload.nil? && !is_multipart + normalize_json_encodings!(payload) payload = payload&.to_json resulting_headers["Content-type"] = "application/json" elsif is_multipart @@ -149,7 +150,7 @@ def httparty_execute(method:, url:, headers:, payload:, timeout:) # Handle multipart uploads if payload.is_a?(Hash) && file_upload?(payload) options[:multipart] = true - options[:body] = payload + options[:body] = prepare_multipart_payload(payload) elsif payload options[:body] = payload end @@ -176,9 +177,113 @@ def create_response_wrapper(response) def file_upload?(payload) return false unless payload.is_a?(Hash) - payload.values.any? do |value| + # Check for traditional file uploads (File objects or objects that respond to :read) + has_file_objects = payload.values.any? do |value| value.respond_to?(:read) || (value.is_a?(File) && !value.closed?) end + + return true if has_file_objects + + # 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 + has_message_field && has_attachment_fields + end + + # Prepare multipart payload for HTTParty compatibility + # HTTParty requires all multipart fields to have compatible encodings + def prepare_multipart_payload(payload) + require "stringio" + + modified_payload = payload.dup + + # First, normalize all string encodings to prevent HTTParty encoding conflicts + normalize_multipart_encodings!(modified_payload) + + # Handle binary content attachments (file0, file1, etc.) by converting them to enhanced StringIO + # HTTParty expects file uploads to be objects with full file-like interface + modified_payload.each do |key, value| + next unless key.is_a?(String) && key.match?(/^file\d+$/) && value.is_a?(String) + + # Get the original value to check for singleton methods + original_value = payload[key] + + # Create an enhanced StringIO object for HTTParty compatibility + string_io = create_file_like_stringio(value) + + # Preserve filename and content_type if they exist as singleton methods + if original_value.respond_to?(:original_filename) + string_io.define_singleton_method(:original_filename) { original_value.original_filename } + end + + if original_value.respond_to?(:content_type) + string_io.define_singleton_method(:content_type) { original_value.content_type } + end + + modified_payload[key] = string_io + end + + modified_payload + end + + # Normalize string encodings in multipart payload to prevent HTTParty encoding conflicts + # This ensures all string fields use consistent ASCII-8BIT encoding for multipart compatibility + def normalize_multipart_encodings!(payload) + payload.each do |key, value| + next unless value.is_a?(String) + + # Force all string values to ASCII-8BIT encoding for multipart compatibility + # HTTParty/multipart-post expects binary encoding for consistent concatenation + payload[key] = value.dup.force_encoding(Encoding::ASCII_8BIT) + end + end + + # Normalize JSON encodings for attachment content to ensure binary data is base64 encoded. + # This handles cases where users pass raw binary content directly instead of file objects. + def normalize_json_encodings!(payload) + return unless payload.is_a?(Hash) + + # Handle attachment content encoding for JSON serialization + attachments = payload[:attachments] || payload["attachments"] + return unless attachments + + attachments.each do |attachment| + content = attachment[:content] || attachment["content"] + next unless content.is_a?(String) + + # If content appears to be binary (non-UTF-8), base64 encode it + next unless content.encoding == Encoding::ASCII_8BIT || !content.valid_encoding? + + encoded_content = Base64.strict_encode64(content) + if attachment.key?(:content) + attachment[:content] = encoded_content + else + attachment["content"] = encoded_content + end + end + end + + # Create a StringIO object that behaves more like a File for HTTParty compatibility + def create_file_like_stringio(content) + # Content is already normalized to ASCII-8BIT by normalize_multipart_encodings! + # Create StringIO with the normalized binary content + string_io = StringIO.new(content) + + # Add methods that HTTParty/multipart-post might expect + string_io.define_singleton_method(:path) { nil } + string_io.define_singleton_method(:local_path) { nil } + string_io.define_singleton_method(:respond_to_missing?) do |method_name, include_private = false| + File.instance_methods.include?(method_name) || super(method_name, include_private) + end + + # Set binary mode for file-like behavior + string_io.binmode if string_io.respond_to?(:binmode) + + string_io end def setup_http(path, timeout, headers, query, api_key) diff --git a/lib/nylas/resources/messages.rb b/lib/nylas/resources/messages.rb index 1d69325f..2a83c4ad 100644 --- a/lib/nylas/resources/messages.rb +++ b/lib/nylas/resources/messages.rb @@ -126,7 +126,7 @@ def send(identifier:, request_body:) request_body: payload ) - opened_files.each(&:close) + opened_files.each { |file| file.close if file.respond_to?(:close) } response end diff --git a/spec/nylas/handler/http_client_spec.rb b/spec/nylas/handler/http_client_spec.rb index 3024cdbe..34057eda 100644 --- a/spec/nylas/handler/http_client_spec.rb +++ b/spec/nylas/handler/http_client_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "webmock/rspec" +require "stringio" class TestHttpClient include Nylas::HttpClient @@ -247,6 +248,49 @@ class TestHttpClient ) end end + + # Test for binary content attachment detection + it "detects binary content attachments prepared by FileUtils for multipart" do + # Simulate what FileUtils.build_form_request creates for binary content attachments + binary_content = "some binary file content".dup # Create mutable string + binary_content.define_singleton_method(:original_filename) { "test.bin" } + binary_content.define_singleton_method(:content_type) { "application/octet-stream" } + + payload = { + "message" => '{"to":[{"email":"test@example.com"}],"subject":"Test"}', + "file0" => binary_content + } + + # Should be detected as multipart even though content is a string, not a File object + expect(http_client.send(:file_upload?, payload)).to be true + end + + it "does not detect regular payloads as file uploads" do + payload = { + "message" => "regular message", + "data" => "some data" + } + + expect(http_client.send(:file_upload?, payload)).to be false + end + + it "does not detect payloads with message field but no attachment fields" do + payload = { + "message" => '{"to":[{"email":"test@example.com"}]}', + "other_field" => "some value" + } + + expect(http_client.send(:file_upload?, payload)).to be false + end + + it "does not detect payloads with attachment fields but no message field" do + payload = { + "file0" => "some content", + "other_field" => "some value" + } + + expect(http_client.send(:file_upload?, payload)).to be false + end end describe "#execute" do @@ -608,4 +652,81 @@ class TestHttpClient end.to raise_error(Nylas::NylasSdkTimeoutError) end end + + describe "#prepare_multipart_payload" do + it "normalizes message payloads to ASCII-8BIT encoding for HTTParty compatibility" do + payload = { "message" => "Hello World" } + result = http_client.send(:prepare_multipart_payload, payload) + + expect(result["message"]).to eq("Hello World") + expect(result["message"].encoding).to eq(Encoding::ASCII_8BIT) + end + + it "leaves non-message fields unchanged" do + payload = { "other_field" => "value", "data" => "content" } + result = http_client.send(:prepare_multipart_payload, payload) + + expect(result).to eq(payload) + end + + it "handles payloads without message field" do + payload = { "data" => "content" } + result = http_client.send(:prepare_multipart_payload, payload) + + expect(result).to eq(payload) + end + + it "converts binary content attachments to StringIO objects" do + 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"}', + "file0" => binary_content, + "other_field" => "value" + } + + result = http_client.send(:prepare_multipart_payload, payload) + + # Message should be preserved + expect(result["message"]).to eq('{"subject":"test"}') + + # Binary content should be converted to StringIO + expect(result["file0"]).to be_a(StringIO) + expect(result["file0"].read).to eq("binary file content") + result["file0"].rewind # Reset for next read + + # Singleton methods should be preserved + expect(result["file0"].original_filename).to eq("test.bin") + expect(result["file0"].content_type).to eq("application/octet-stream") + + # Other fields should be unchanged + expect(result["other_field"]).to eq("value") + end + + it "handles multiple binary content attachments" do + content1 = "content 1".dup + content1.define_singleton_method(:original_filename) { "file1.txt" } + + content2 = "content 2".dup + content2.define_singleton_method(:original_filename) { "file2.txt" } + + payload = { + "message" => '{"subject":"test"}', + "file0" => content1, + "file1" => content2 + } + + result = http_client.send(:prepare_multipart_payload, payload) + + expect(result["file0"]).to be_a(StringIO) + expect(result["file0"].read).to eq("content 1") + expect(result["file0"].original_filename).to eq("file1.txt") + + expect(result["file1"]).to be_a(StringIO) + expect(result["file1"].read).to eq("content 2") + expect(result["file1"].original_filename).to eq("file2.txt") + end + end end diff --git a/spec/nylas/utils/file_utils_spec.rb b/spec/nylas/utils/file_utils_spec.rb index ce8d002b..fbc16ec6 100644 --- a/spec/nylas/utils/file_utils_spec.rb +++ b/spec/nylas/utils/file_utils_spec.rb @@ -108,6 +108,29 @@ expect(form_request).to eq([expected_response_form, [mock_file]]) end + # Test for the UTF-8 encoding compatibility - FileUtils should produce standard UTF-8 JSON + it "produces standard UTF-8 JSON that will be handled by HttpClient for HTTParty compatibility" do + utf8_request_body = { + to: [{ email: "test@example.com", name: "Tëst Récipient 👤" }], + subject: "UTF-8 Test: Ñylas 🚀 Tëst with Émojis", + body: "Message with UTF-8: ñ, é, ü, 中文, العربية, 🚀 ⚡ 💯" + } + + request_body_with_attachment = utf8_request_body.merge(attachments: [attachment]) + + form_data, _opened_files = described_class.build_form_request(request_body_with_attachment) + + # The message payload should remain UTF-8 encoded (HttpClient will handle HTTParty compatibility) + message_payload = form_data["message"] + expect(message_payload.encoding).to eq(Encoding::UTF_8) + + # JSON should contain the original UTF-8 characters + parsed_message = JSON.parse(message_payload) + expect(parsed_message["subject"]).to eq("UTF-8 Test: Ñylas 🚀 Tëst with Émojis") + expect(parsed_message["body"]).to eq("Message with UTF-8: ñ, é, ü, 中文, العربية, 🚀 ⚡ 💯") + expect(parsed_message["to"][0]["name"]).to eq("Tëst Récipient 👤") + end + it "returns the correct form request when there are no attachments" do form_request = described_class.build_form_request(request_body)