nylas 6.2.3 → 6.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb37db614a1d3567334d0d7217d50e81a94a3d31bc758c916fa752953fc7369e
4
- data.tar.gz: 123be60d4794edb9ea702ae62c2768d39fda98afa7b53157d3e7b53def4b3b98
3
+ metadata.gz: 537c2669d5e30d03ad70d6acbd18c782865038fcff32779ffca36ece16234900
4
+ data.tar.gz: bf78777657d01f6907505b473d5304419d95a8f8984455604fe59c86e5a1279b
5
5
  SHA512:
6
- metadata.gz: 4d26327e107d0a911397a30a8f2f2d2fc7836dcf8d9c4f9448b4ec8ab58aa2cd37595fa7cdcea8c5ade7643d6d9ec8b33451dbf297237501ce0834494946f331
7
- data.tar.gz: 9340849392790c37b9c86334a97afe8b5b991c6d681313dee3158f26371953d698114bd38796e02f28c49e6de028594bfee4adae9b9e11d62d7e29cf10ccdb22
6
+ metadata.gz: bb7bca82b3c7dbb97b8365a4ef5882d86f41256952e17f854e03e1eff92d16a8cee53f57a971a8bc70209d26a7f5721915a6d458b8582adc24aaf9a055257469
7
+ data.tar.gz: 52196420bcaeb94ea0b31c2a513b57dee5a8dd7eed2f5689564cba0d84e2b63b91751a0616881e0813b63a02a29cd16a1064d78f0a57b6e1e6c4ce31fa0e395f
data/lib/nylas/client.rb CHANGED
@@ -8,6 +8,8 @@ require_relative "resources/auth"
8
8
  require_relative "resources/webhooks"
9
9
  require_relative "resources/applications"
10
10
  require_relative "resources/folders"
11
+ require_relative "resources/lists"
12
+ require_relative "resources/notetakers"
11
13
  require_relative "resources/scheduler"
12
14
 
13
15
  module Nylas
@@ -98,6 +100,13 @@ module Nylas
98
100
  Grants.new(self)
99
101
  end
100
102
 
103
+ # The list resources for your Nylas application.
104
+ #
105
+ # @return [Nylas::Lists] List resources for your Nylas application
106
+ def lists
107
+ Lists.new(self)
108
+ end
109
+
101
110
  # The message resources for your Nylas application.
102
111
  #
103
112
  # @return [Nylas::Messages] Message resources for your Nylas application
@@ -112,6 +121,34 @@ module Nylas
112
121
  Threads.new(self)
113
122
  end
114
123
 
124
+ # The policy resources for your Nylas application.
125
+ #
126
+ # @return [Nylas::Policies] Policy resources for your Nylas application.
127
+ def policies
128
+ Policies.new(self)
129
+ end
130
+
131
+ # The rule resources for your Nylas application.
132
+ #
133
+ # @return [Nylas::Rules] Rule resources for your Nylas application.
134
+ def rules
135
+ Rules.new(self)
136
+ end
137
+
138
+ # The workspace resources for your Nylas application.
139
+ #
140
+ # @return [Nylas::Workspaces] Workspace resources for your Nylas application.
141
+ def workspaces
142
+ Workspaces.new(self)
143
+ end
144
+
145
+ # The domain resources for your Nylas application.
146
+ #
147
+ # @return [Nylas::Domains] Domain resources for your Nylas application.
148
+ def domains
149
+ Domains.new(self)
150
+ end
151
+
115
152
  # The webhook resources for your Nylas application.
116
153
  #
117
154
  # @return [Nylas::Webhooks] Webhook resources for your Nylas application.
@@ -124,5 +161,11 @@ module Nylas
124
161
  def scheduler
125
162
  Scheduler.new(self)
126
163
  end
164
+
165
+ # The Notetaker resources for your Nylas application.
166
+ # @return [Nylas::Notetakers] Notetaker resources for your Nylas application.
167
+ def notetakers
168
+ Notetakers.new(self)
169
+ end
127
170
  end
128
171
  end
data/lib/nylas/errors.rb CHANGED
@@ -14,7 +14,7 @@ module Nylas
14
14
 
15
15
  # Error class representing a failed response from the Nylas API.
16
16
  class NylasApiError < AbstractNylasApiError
17
- attr_accessor :type, :request_id, :provider_error, :status_code
17
+ attr_accessor :type, :request_id, :provider_error, :status_code, :headers
18
18
 
19
19
  # Initializes an error and assigns the given attributes to it.
20
20
  #
@@ -23,24 +23,26 @@ module Nylas
23
23
  # @param status_code [Integer] Error status code.
24
24
  # @param provider_error [Hash, nil] The error from the provider.
25
25
  # @param request_id [Hash, nil] The ID of the request.
26
- def initialize(type, message, status_code, provider_error = nil, request_id = nil)
26
+ def initialize(type, message, status_code, provider_error = nil, request_id = nil, headers = nil)
27
27
  super(message)
28
28
  self.type = type
29
29
  self.status_code = status_code
30
30
  self.provider_error = provider_error
31
31
  self.request_id = request_id
32
+ self.headers = headers
32
33
  end
33
34
 
34
35
  # Parses the error response.
35
36
  #
36
37
  # @param response [Hash] Response from the Nylas API.
37
38
  # @param status_code [Integer] Error status code.
38
- def self.parse_error_response(response, status_code)
39
+ def self.parse_error_response(response, status_code, headers = nil)
39
40
  new(
40
41
  response["type"],
41
42
  response["message"],
42
43
  status_code,
43
- response["provider_error"]
44
+ response["provider_error"],
45
+ headers
44
46
  )
45
47
  end
46
48
  end
@@ -15,22 +15,25 @@ module Nylas
15
15
  #
16
16
  # @param path [String] Destination path for the call.
17
17
  # @param query_params [Hash, {}] Query params to pass to the call.
18
- # @return [Array([Hash, Array], String)] Nylas data object and API Request ID.
19
- def get(path:, query_params: {})
20
- response = get_raw(path: path, query_params: query_params)
18
+ # @param headers [Hash, {}] Additional HTTP headers to include in the payload.
19
+ # @return [Array([Hash, Array], String, Hash)] Nylas data object, API Request ID, and response headers.
20
+ def get(path:, query_params: {}, headers: {})
21
+ response = get_raw(path: path, query_params: query_params, headers: headers)
21
22
 
22
- [response[:data], response[:request_id]]
23
+ [response[:data], response[:request_id], response[:headers]]
23
24
  end
24
25
 
25
26
  # Performs a GET call to the Nylas API for a list response.
26
27
  #
27
28
  # @param path [String] Destination path for the call.
28
29
  # @param query_params [Hash, {}] Query params to pass to the call.
29
- # @return [Array(Array(Hash), String, String)] Nylas data array, API Request ID, and next cursor.
30
- def get_list(path:, query_params: {})
31
- response = get_raw(path: path, query_params: query_params)
30
+ # @param headers [Hash, {}] Additional HTTP headers to include in the payload.
31
+ # @return [Array<Array<Hash>, String, String, Hash>]
32
+ # Nylas data array, API Request ID, next cursor, and response headers.response headers.
33
+ def get_list(path:, query_params: {}, headers: {})
34
+ response = get_raw(path: path, query_params: query_params, headers: headers)
32
35
 
33
- [response[:data], response[:request_id], response[:next_cursor]]
36
+ [response[:data], response[:request_id], response[:next_cursor], response[:headers]]
34
37
  end
35
38
 
36
39
  private
@@ -39,16 +42,20 @@ module Nylas
39
42
  #
40
43
  # @param path [String] Destination path for the call.
41
44
  # @param query_params [Hash, {}] Query params to pass to the call.
45
+ # @param headers [Hash, {}] Additional HTTP headers to include in the payload.
42
46
  # @return [Hash] The JSON response from the Nylas API.
43
- def get_raw(path:, query_params: {})
44
- execute(
47
+ def get_raw(path:, query_params: {}, headers: {})
48
+ request = {
45
49
  method: :get,
46
50
  path: path,
47
51
  query: query_params,
48
52
  payload: nil,
49
53
  api_key: api_key,
50
54
  timeout: timeout
51
- )
55
+ }
56
+ request[:headers] = headers unless headers.empty?
57
+
58
+ execute(**request)
52
59
  end
53
60
  end
54
61
 
@@ -62,20 +69,23 @@ module Nylas
62
69
  # @param path [String] Destination path for the call.
63
70
  # @param query_params [Hash, {}] Query params to pass to the call.
64
71
  # @param request_body [Hash, nil] Request body to pass to the call.
72
+ # Defaults to {} when nil to ensure Content-Type: application/json is sent.
65
73
  # @param headers [Hash, {}] Additional HTTP headers to include in the payload.
66
- # @return Nylas data object and API Request ID.
67
- def post(path:, query_params: {}, request_body: nil, headers: {})
68
- response = execute(
74
+ # @return [Array(Hash, String, Hash)] Nylas data object, API Request ID, and response headers.
75
+ def post(path:, query_params: {}, request_body: nil, headers: {}, serialized_json_body: nil)
76
+ request = {
69
77
  method: :post,
70
78
  path: path,
71
79
  query: query_params,
72
- payload: request_body,
80
+ payload: request_body || {},
73
81
  headers: headers,
74
82
  api_key: api_key,
75
83
  timeout: timeout
76
- )
84
+ }
85
+ request[:serialized_json_body] = serialized_json_body unless serialized_json_body.nil?
86
+ response = execute(**request)
77
87
 
78
- [response[:data], response[:request_id]]
88
+ [response[:data], response[:request_id], response[:headers]]
79
89
  end
80
90
  end
81
91
 
@@ -89,18 +99,21 @@ module Nylas
89
99
  # @param path [String] Destination path for the call.
90
100
  # @param query_params [Hash, {}] Query params to pass to the call.
91
101
  # @param request_body [Hash, nil] Request body to pass to the call.
102
+ # Defaults to {} when nil to ensure Content-Type: application/json is sent.
92
103
  # @param headers [Hash, {}] Additional HTTP headers to include in the payload.
93
104
  # @return Nylas data object and API Request ID.
94
- def put(path:, query_params: {}, request_body: nil, headers: {})
95
- response = execute(
105
+ def put(path:, query_params: {}, request_body: nil, headers: {}, serialized_json_body: nil)
106
+ request = {
96
107
  method: :put,
97
108
  path: path,
98
109
  query: query_params,
99
- payload: request_body,
110
+ payload: request_body || {},
100
111
  headers: headers,
101
112
  api_key: api_key,
102
113
  timeout: timeout
103
- )
114
+ }
115
+ request[:serialized_json_body] = serialized_json_body unless serialized_json_body.nil?
116
+ response = execute(**request)
104
117
 
105
118
  [response[:data], response[:request_id]]
106
119
  end
@@ -116,18 +129,21 @@ module Nylas
116
129
  # @param path [String] Destination path for the call.
117
130
  # @param query_params [Hash, {}] Query params to pass to the call.
118
131
  # @param request_body [Hash, nil] Request body to pass to the call.
132
+ # Defaults to {} when nil to ensure Content-Type: application/json is sent.
119
133
  # @param headers [Hash, {}] Additional HTTP headers to include in the payload.
120
134
  # @return Nylas data object and API Request ID.
121
- def patch(path:, query_params: {}, request_body: nil, headers: {})
122
- response = execute(
135
+ def patch(path:, query_params: {}, request_body: nil, headers: {}, serialized_json_body: nil)
136
+ request = {
123
137
  method: :patch,
124
138
  path: path,
125
139
  query: query_params,
126
- payload: request_body,
140
+ payload: request_body || {},
127
141
  headers: headers,
128
142
  api_key: api_key,
129
143
  timeout: timeout
130
- )
144
+ }
145
+ request[:serialized_json_body] = serialized_json_body unless serialized_json_body.nil?
146
+ response = execute(**request)
131
147
 
132
148
  [response[:data], response[:request_id]]
133
149
  end
@@ -142,15 +158,17 @@ module Nylas
142
158
  #
143
159
  # @param path [String] Destination path for the call.
144
160
  # @param query_params [Hash, {}] Query params to pass to the call.
161
+ # @param request_body [Hash, nil] Optional request body (e.g. cancellation_reason for bookings).
162
+ # Defaults to {} to ensure Content-Type: application/json is sent.
145
163
  # @param headers [Hash, {}] Additional HTTP headers to include in the payload.
146
164
  # @return Nylas data object and API Request ID.
147
- def delete(path:, query_params: {}, headers: {})
165
+ def delete(path:, query_params: {}, request_body: nil, headers: {})
148
166
  response = execute(
149
167
  method: :delete,
150
168
  path: path,
151
169
  query: query_params,
152
170
  headers: headers,
153
- payload: nil,
171
+ payload: request_body || {},
154
172
  api_key: api_key,
155
173
  timeout: timeout
156
174
  )
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rest-client"
3
+ require "httparty"
4
+ require "net/http"
5
+ require "net/http/post/multipart"
4
6
 
5
7
  require_relative "../errors"
6
8
  require_relative "../version"
@@ -28,21 +30,28 @@ module Nylas
28
30
  # @param query [Hash, {}] Hash of names and values to include in the query section of the URI
29
31
  # fragment.
30
32
  # @param payload [Hash, nil] Body to send with the request.
33
+ # @param serialized_json_body [String, nil] Pre-serialized JSON body to send as-is.
31
34
  # @param api_key [Hash, nil] API key to send with the request.
32
35
  # @return [Object] Parsed JSON response from the API.
33
- def execute(method:, path:, timeout:, headers: {}, query: {}, payload: nil, api_key: nil)
36
+ def execute(method:, path:, timeout:, headers: {}, query: {}, payload: nil, api_key: nil,
37
+ serialized_json_body: nil)
34
38
  request = build_request(method: method, path: path, headers: headers,
35
- query: query, payload: payload, api_key: api_key, timeout: timeout)
39
+ query: query, payload: payload, api_key: api_key, timeout: timeout,
40
+ serialized_json_body: serialized_json_body)
36
41
  begin
37
- rest_client_execute(**request) do |response, _request, result|
42
+ httparty_execute(**request) do |response, _request, result|
38
43
  content_type = nil
39
- if response.headers && response.headers[:content_type]
40
- content_type = response.headers[:content_type].downcase
44
+ if response.headers && response.headers["content-type"]
45
+ content_type = response.headers["content-type"].downcase
41
46
  end
42
47
 
43
- parse_json_evaluate_error(result.code.to_i, response, path, content_type)
48
+ parsed_response = parse_json_evaluate_error(result.code.to_i, response.body, path, content_type,
49
+ response.headers)
50
+ # Include headers in the response
51
+ parsed_response[:headers] = response.headers unless parsed_response.nil?
52
+ parsed_response
44
53
  end
45
- rescue RestClient::Exceptions::OpenTimeout, RestClient::Exceptions::ReadTimeout
54
+ rescue Net::OpenTimeout, Net::ReadTimeout
46
55
  raise Nylas::NylasSdkTimeoutError.new(request[:path], timeout)
47
56
  end
48
57
  end
@@ -85,20 +94,32 @@ module Nylas
85
94
  # @param query [Hash, {}] Hash of names and values to include in the query section of the URI
86
95
  # fragment.
87
96
  # @param payload [Hash, nil] Body to send with the request.
97
+ # @param serialized_json_body [String, nil] Pre-serialized JSON body to send as-is.
88
98
  # @param timeout [Integer, nil] Timeout value to send with the request.
89
99
  # @param api_key [Hash, nil] API key to send with the request.
90
100
  # @return [Object] The request information after processing. This includes an updated payload
91
101
  # and headers.
92
102
  def build_request(
93
- method:, path: nil, headers: {}, query: {}, payload: nil, timeout: nil, api_key: nil
103
+ method:, path: nil, headers: {}, query: {}, payload: nil, timeout: nil, api_key: nil,
104
+ serialized_json_body: nil
94
105
  )
95
106
  url = build_url(path, query)
96
107
  resulting_headers = default_headers.merge(headers).merge(auth_header(api_key))
97
- if !payload.nil? && !payload["multipart"]
108
+
109
+ # Check for multipart flag using both string and symbol keys for backwards compatibility
110
+ is_multipart = !payload.nil? && (payload["multipart"] || payload[:multipart])
111
+
112
+ if !serialized_json_body.nil?
113
+ payload = serialized_json_body
114
+ resulting_headers["Content-type"] = "application/json"
115
+ elsif !payload.nil? && !is_multipart
116
+ normalize_json_encodings!(payload)
98
117
  payload = payload&.to_json
99
118
  resulting_headers["Content-type"] = "application/json"
100
- elsif !payload.nil? && payload["multipart"]
119
+ elsif is_multipart
120
+ # Remove multipart flag from both possible key types
101
121
  payload.delete("multipart")
122
+ payload.delete(:multipart)
102
123
  end
103
124
 
104
125
  { method: method, url: url, payload: payload, headers: resulting_headers, timeout: timeout }
@@ -116,23 +137,237 @@ module Nylas
116
137
  def parse_response(response)
117
138
  return response if response.is_a?(Enumerable)
118
139
 
119
- Yajl::Parser.new(symbolize_names: true).parse(response)
140
+ Yajl::Parser.new(symbolize_names: true).parse(response) || raise(Nylas::JsonParseError)
120
141
  rescue Yajl::ParseError
121
142
  raise Nylas::JsonParseError
122
143
  end
123
144
 
124
145
  private
125
146
 
126
- # Sends a request to the Nylas REST API.
147
+ # Sends a request to the Nylas REST API using HTTParty or Net::HTTP for multipart.
148
+ # Multipart requests use Net::HTTP::Post::Multipart (multipart-post gem) because
149
+ # HTTParty's multipart handling produces malformed requests that the Nylas API rejects.
127
150
  #
128
151
  # @param method [Symbol] HTTP method for the API call. Either :get, :post, :delete, or :patch.
129
152
  # @param url [String] URL for the API call.
130
153
  # @param headers [Hash] HTTP headers to include in the payload.
131
154
  # @param payload [String, Hash] Body to send with the request.
132
155
  # @param timeout [Hash] Timeout value to send with the request.
133
- def rest_client_execute(method:, url:, headers:, payload:, timeout:, &block)
134
- ::RestClient::Request.execute(method: method, url: url, payload: payload,
135
- headers: headers, timeout: timeout, &block)
156
+ def httparty_execute(method:, url:, headers:, payload:, timeout:)
157
+ if method == :post && payload.is_a?(Hash) && file_upload?(payload)
158
+ response = execute_multipart_request(url: url, headers: headers, payload: payload, timeout: timeout)
159
+ else
160
+ options = { headers: headers, timeout: timeout }
161
+ options[:body] = payload if payload
162
+ response = HTTParty.send(method, url, options)
163
+ end
164
+
165
+ result = create_response_wrapper(response)
166
+
167
+ if block_given?
168
+ yield response, nil, result
169
+ else
170
+ response
171
+ end
172
+ end
173
+
174
+ # Executes multipart POST using Net::HTTP::Post::Multipart (fixes issue #538).
175
+ # HTTParty's multipart produces malformed requests; multipart-post/UploadIO works correctly.
176
+ def execute_multipart_request(url:, headers:, payload:, timeout:)
177
+ uri = URI.parse(url)
178
+ params = build_multipart_params(payload)
179
+
180
+ req = Net::HTTP::Post::Multipart.new(uri.path, params)
181
+ headers.each { |key, value| req[key] = value }
182
+
183
+ http = Net::HTTP.new(uri.host, uri.port)
184
+ http.use_ssl = (uri.scheme == "https")
185
+ http.read_timeout = timeout
186
+ http.open_timeout = timeout
187
+
188
+ response = http.request(req)
189
+
190
+ create_httparty_like_response(response)
191
+ end
192
+
193
+ # Build params hash for Net::HTTP::Post::Multipart with UploadIO for file fields.
194
+ def build_multipart_params(payload)
195
+ params = {}
196
+ payload.each do |key, value|
197
+ params[key] = if key.is_a?(String) && key != "message" && file_like_value?(value)
198
+ value_to_upload_io(value)
199
+ else
200
+ value.to_s
201
+ end
202
+ end
203
+ params
204
+ end
205
+
206
+ def file_like_value?(value)
207
+ return true if value.respond_to?(:read) && (value.is_a?(File) ? !value.closed? : true)
208
+ if value.is_a?(String) && (value.respond_to?(:original_filename) || value.respond_to?(:content_type))
209
+ return true
210
+ end
211
+
212
+ false
213
+ end
214
+
215
+ # Convert File, String, or StringIO to UploadIO for multipart-post.
216
+ def value_to_upload_io(value)
217
+ content_type = value.respond_to?(:content_type) ? value.content_type : "application/octet-stream"
218
+ filename = value.respond_to?(:original_filename) ? value.original_filename : "file.bin"
219
+
220
+ io = if value.respond_to?(:read) && value.respond_to?(:rewind)
221
+ value.rewind if value.respond_to?(:rewind)
222
+ value
223
+ else
224
+ require "stringio"
225
+ content = value.to_s
226
+ content = content.dup.force_encoding(Encoding::ASCII_8BIT) if content.is_a?(String)
227
+ StringIO.new(content)
228
+ end
229
+
230
+ UploadIO.new(io, content_type, filename)
231
+ end
232
+
233
+ # Create response object compatible with HTTParty::Response interface.
234
+ def create_httparty_like_response(net_http_response)
235
+ headers = net_http_response.to_hash
236
+ headers = headers.transform_values { |v| v.is_a?(Array) && v.one? ? v.first : v }
237
+
238
+ OpenStruct.new(
239
+ body: net_http_response.body,
240
+ code: net_http_response.code.to_i,
241
+ headers: headers
242
+ )
243
+ end
244
+
245
+ # Create a response wrapper that mimics RestClient::Response.code behavior
246
+ def create_response_wrapper(response)
247
+ OpenStruct.new(code: response.code)
248
+ end
249
+
250
+ # Check if payload contains file uploads
251
+ def file_upload?(payload)
252
+ return false unless payload.is_a?(Hash)
253
+
254
+ # Check for traditional file uploads (File objects or objects that respond to :read)
255
+ has_file_objects = payload.values.any? do |value|
256
+ value.respond_to?(:read) || (value.is_a?(File) && !value.closed?)
257
+ end
258
+
259
+ return true if has_file_objects
260
+
261
+ # Check if payload was prepared by FileUtils.build_form_request for multipart uploads
262
+ # This handles binary content attachments that are strings with added singleton methods
263
+ has_message_field = payload.key?("message") && payload["message"].is_a?(String)
264
+
265
+ # Check for attachment fields - these can have custom content_id values (not just "file{N}")
266
+ # FileUtils.build_form_request creates entries with string values that have singleton methods
267
+ # like original_filename and content_type defined on them
268
+ has_attachment_fields = payload.any? do |key, value|
269
+ next false unless key.is_a?(String) && key != "message"
270
+
271
+ # Check if the value is a string with attachment-like singleton methods
272
+ # (original_filename or content_type), which indicates it's a file content
273
+ value.is_a?(String) && (value.respond_to?(:original_filename) || value.respond_to?(:content_type))
274
+ end
275
+
276
+ # If we have both a "message" field and attachment fields with file metadata,
277
+ # this indicates the payload was prepared by FileUtils.build_form_request
278
+ has_message_field && has_attachment_fields
279
+ end
280
+
281
+ # Prepare multipart payload for HTTParty compatibility
282
+ # HTTParty requires all multipart fields to have compatible encodings
283
+ def prepare_multipart_payload(payload)
284
+ require "stringio"
285
+
286
+ modified_payload = payload.dup
287
+
288
+ # First, normalize all string encodings to prevent HTTParty encoding conflicts
289
+ normalize_multipart_encodings!(modified_payload)
290
+
291
+ # Handle binary content attachments (file0, file1, etc.) by converting them to enhanced StringIO
292
+ # HTTParty expects file uploads to be objects with full file-like interface
293
+ modified_payload.each do |key, value|
294
+ next unless key.is_a?(String) && key.match?(/^file\d+$/) && value.is_a?(String)
295
+
296
+ # Get the original value to check for singleton methods
297
+ original_value = payload[key]
298
+
299
+ # Create an enhanced StringIO object for HTTParty compatibility
300
+ string_io = create_file_like_stringio(value)
301
+
302
+ # Preserve filename and content_type if they exist as singleton methods
303
+ if original_value.respond_to?(:original_filename)
304
+ string_io.define_singleton_method(:original_filename) { original_value.original_filename }
305
+ end
306
+
307
+ if original_value.respond_to?(:content_type)
308
+ string_io.define_singleton_method(:content_type) { original_value.content_type }
309
+ end
310
+
311
+ modified_payload[key] = string_io
312
+ end
313
+
314
+ modified_payload
315
+ end
316
+
317
+ # Normalize string encodings in multipart payload to prevent HTTParty encoding conflicts
318
+ # This ensures all string fields use consistent ASCII-8BIT encoding for multipart compatibility
319
+ def normalize_multipart_encodings!(payload)
320
+ payload.each do |key, value|
321
+ next unless value.is_a?(String)
322
+
323
+ # Force all string values to ASCII-8BIT encoding for multipart compatibility
324
+ # HTTParty/multipart-post expects binary encoding for consistent concatenation
325
+ payload[key] = value.dup.force_encoding(Encoding::ASCII_8BIT)
326
+ end
327
+ end
328
+
329
+ # Normalize JSON encodings for attachment content to ensure binary data is base64 encoded.
330
+ # This handles cases where users pass raw binary content directly instead of file objects.
331
+ def normalize_json_encodings!(payload)
332
+ return unless payload.is_a?(Hash)
333
+
334
+ # Handle attachment content encoding for JSON serialization
335
+ attachments = payload[:attachments] || payload["attachments"]
336
+ return unless attachments
337
+
338
+ attachments.each do |attachment|
339
+ content = attachment[:content] || attachment["content"]
340
+ next unless content.is_a?(String)
341
+
342
+ # If content appears to be binary (non-UTF-8), base64 encode it
343
+ next unless content.encoding == Encoding::ASCII_8BIT || !content.valid_encoding?
344
+
345
+ encoded_content = Base64.strict_encode64(content)
346
+ if attachment.key?(:content)
347
+ attachment[:content] = encoded_content
348
+ else
349
+ attachment["content"] = encoded_content
350
+ end
351
+ end
352
+ end
353
+
354
+ # Create a StringIO object that behaves more like a File for HTTParty compatibility
355
+ def create_file_like_stringio(content)
356
+ # Content is already normalized to ASCII-8BIT by normalize_multipart_encodings!
357
+ # Create StringIO with the normalized binary content
358
+ string_io = StringIO.new(content)
359
+
360
+ # Add methods that HTTParty/multipart-post might expect
361
+ string_io.define_singleton_method(:path) { nil }
362
+ string_io.define_singleton_method(:local_path) { nil }
363
+ string_io.define_singleton_method(:respond_to_missing?) do |method_name, include_private = false|
364
+ File.instance_methods.include?(method_name) || super(method_name, include_private)
365
+ end
366
+
367
+ # Set binary mode for file-like behavior
368
+ string_io.binmode if string_io.respond_to?(:binmode)
369
+
370
+ string_io
136
371
  end
137
372
 
138
373
  def setup_http(path, timeout, headers, query, api_key)
@@ -160,50 +395,51 @@ module Nylas
160
395
  end
161
396
 
162
397
  # Parses the response from the Nylas API and evaluates for errors.
163
- def parse_json_evaluate_error(http_code, response, path, content_type = nil)
398
+ def parse_json_evaluate_error(http_code, response, path, content_type = nil, headers = nil)
164
399
  begin
165
400
  response = parse_response(response) if content_type == "application/json"
166
401
  rescue Nylas::JsonParseError
167
- handle_failed_response(http_code, response, path)
402
+ handle_failed_response(http_code, response, path, headers)
168
403
  raise
169
404
  end
170
405
 
171
- handle_failed_response(http_code, response, path)
406
+ handle_failed_response(http_code, response, path, headers)
172
407
  response
173
408
  end
174
409
 
175
410
  # Handles failed responses from the Nylas API.
176
- def handle_failed_response(http_code, response, path)
411
+ def handle_failed_response(http_code, response, path, headers = nil)
177
412
  return if HTTP_SUCCESS_CODES.include?(http_code)
178
413
 
179
414
  case response
180
415
  when Hash
181
- raise error_hash_to_exception(response, http_code, path)
416
+ raise error_hash_to_exception(response, http_code, path, headers)
182
417
  else
183
418
  raise NylasApiError.parse_error_response(response, http_code)
184
419
  end
185
420
  end
186
421
 
187
422
  # Converts error hashes to exceptions.
188
- def error_hash_to_exception(response, status_code, path)
423
+ def error_hash_to_exception(response, status_code, path, headers = nil)
189
424
  return if !response || !response.key?(:error)
190
425
 
191
426
  if %W[#{api_uri}/v3/connect/token #{api_uri}/v3/connect/revoke].include?(path)
192
427
  NylasOAuthError.new(response[:error], response[:error_description], response[:error_uri],
193
428
  response[:error_code], status_code)
194
429
  else
195
- throw_error(response, status_code)
430
+ throw_error(response, status_code, headers)
196
431
  end
197
432
  end
198
433
 
199
- def throw_error(response, status_code)
434
+ def throw_error(response, status_code, headers = nil)
200
435
  error_obj = response[:error]
201
436
 
202
437
  # If `error_obj` is just a string, turn it into a hash with default keys.
203
438
  if error_obj.is_a?(String)
204
439
  error_obj = {
205
440
  type: "NylasApiError",
206
- message: error_obj
441
+ message: error_obj,
442
+ headers: headers
207
443
  }
208
444
  end
209
445
 
@@ -214,7 +450,8 @@ module Nylas
214
450
  error_obj[:message],
215
451
  status_code,
216
452
  provider_error,
217
- response[:request_id]
453
+ response[:request_id],
454
+ headers
218
455
  )
219
456
  end
220
457
 
@@ -260,6 +497,8 @@ module Nylas
260
497
 
261
498
  # Set the authorization header for an API query.
262
499
  def auth_header(api_key)
500
+ return {} if api_key.nil?
501
+
263
502
  { "Authorization" => "Bearer #{api_key}" }
264
503
  end
265
504
  end