emailfuse 0.1.4 → 0.3.0
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 +4 -4
- data/CHANGELOG.md +131 -0
- data/README.md +90 -9
- data/lib/email_fuse/batch.rb +39 -0
- data/lib/email_fuse/client.rb +19 -0
- data/lib/email_fuse/emails/attachments.rb +72 -0
- data/lib/email_fuse/emails/receiving/attachments.rb +74 -0
- data/lib/email_fuse/emails/receiving.rb +45 -0
- data/lib/email_fuse/emails.rb +84 -0
- data/lib/email_fuse/errors.rb +51 -0
- data/lib/email_fuse/mailer.rb +264 -0
- data/lib/email_fuse/pagination_helper.rb +30 -0
- data/lib/email_fuse/railtie.rb +14 -0
- data/lib/email_fuse/request.rb +125 -0
- data/lib/email_fuse/response.rb +141 -0
- data/lib/email_fuse/version.rb +5 -0
- data/lib/email_fuse/webhooks.rb +246 -0
- data/lib/email_fuse.rb +45 -0
- data/lib/emailfuse.rb +2 -31
- metadata +31 -45
- data/Rakefile +0 -10
- data/lib/emailfuse/client.rb +0 -69
- data/lib/emailfuse/collection.rb +0 -27
- data/lib/emailfuse/configuration.rb +0 -10
- data/lib/emailfuse/deliverer.rb +0 -72
- data/lib/emailfuse/error.rb +0 -4
- data/lib/emailfuse/models/email.rb +0 -21
- data/lib/emailfuse/object.rb +0 -19
- data/lib/emailfuse/railtie.rb +0 -12
- data/lib/emailfuse/version.rb +0 -5
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "email_fuse"
|
|
5
|
+
|
|
6
|
+
module EmailFuse
|
|
7
|
+
# Mailer class used by railtie
|
|
8
|
+
class Mailer
|
|
9
|
+
attr_accessor :config, :settings
|
|
10
|
+
|
|
11
|
+
# These are set as `headers` by the Rails API, but these will be filtered out
|
|
12
|
+
# when constructing the EmailFuse API payload, since they're are sent as post params.
|
|
13
|
+
# https://resend.com/docs/api-reference/emails/send-email
|
|
14
|
+
IGNORED_HEADERS = %w[
|
|
15
|
+
cc bcc
|
|
16
|
+
from reply-to to subject mime-version
|
|
17
|
+
html text
|
|
18
|
+
content-type tags scheduled_at
|
|
19
|
+
headers options
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
SUPPORTED_OPTIONS = %w[idempotency_key].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(config)
|
|
25
|
+
@config = config
|
|
26
|
+
@settings = config.is_a?(Hash) ? config : {}
|
|
27
|
+
|
|
28
|
+
# Check for API key in settings first, then fall back to global config
|
|
29
|
+
raise EmailFuse::Error.new("Make sure your API Key is set", @config) unless api_key
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the API key from settings or global config
|
|
33
|
+
def api_key
|
|
34
|
+
@settings[:api_key] || EmailFuse.api_key
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns the base URL from settings or global config
|
|
38
|
+
def base_url
|
|
39
|
+
@settings[:host] || @settings[:base_url] || EmailFuse.base_url
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
#
|
|
43
|
+
# Overwritten deliver! method
|
|
44
|
+
#
|
|
45
|
+
# @param Mail mail
|
|
46
|
+
#
|
|
47
|
+
# @return Object resend response
|
|
48
|
+
#
|
|
49
|
+
def deliver!(mail)
|
|
50
|
+
params = build_resend_params(mail)
|
|
51
|
+
options = get_options(mail) if mail[:options].present?
|
|
52
|
+
options ||= {}
|
|
53
|
+
|
|
54
|
+
# Pass api_key and base_url from settings to the request
|
|
55
|
+
options[:api_key] = api_key
|
|
56
|
+
options[:base_url] = base_url
|
|
57
|
+
|
|
58
|
+
resp = EmailFuse::Emails.send(params, options: options)
|
|
59
|
+
mail.message_id = resp[:id] if resp[:error].nil?
|
|
60
|
+
resp
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#
|
|
64
|
+
# Builds the payload for sending
|
|
65
|
+
#
|
|
66
|
+
# @param Mail mail rails mail object
|
|
67
|
+
#
|
|
68
|
+
# @return Hash hash with all EmailFuse params
|
|
69
|
+
#
|
|
70
|
+
def build_resend_params(mail)
|
|
71
|
+
params = {
|
|
72
|
+
from: get_from(mail),
|
|
73
|
+
to: mail.to,
|
|
74
|
+
subject: mail.subject
|
|
75
|
+
}
|
|
76
|
+
params.merge!(get_addons(mail))
|
|
77
|
+
params.merge!(get_headers(mail))
|
|
78
|
+
params.merge!(get_tags(mail))
|
|
79
|
+
params[:attachments] = get_attachments(mail) if mail.attachments.present?
|
|
80
|
+
params.merge!(get_contents(mail))
|
|
81
|
+
params
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
#
|
|
85
|
+
# Add custom headers fields.
|
|
86
|
+
#
|
|
87
|
+
# Both ways are supported:
|
|
88
|
+
#
|
|
89
|
+
# 1. Through the `#mail()` method ie:
|
|
90
|
+
# mail(headers: { "X-Custom-Header" => "value" })
|
|
91
|
+
#
|
|
92
|
+
# 2. Through the Rails `#headers` method ie:
|
|
93
|
+
# headers["X-Custom-Header"] = "value"
|
|
94
|
+
#
|
|
95
|
+
#
|
|
96
|
+
# setting the header values through the `#mail` method will overwrite values set
|
|
97
|
+
# through the `#headers` method using the same key.
|
|
98
|
+
#
|
|
99
|
+
# @param Mail mail Rails Mail object
|
|
100
|
+
#
|
|
101
|
+
# @return Hash hash with headers param
|
|
102
|
+
#
|
|
103
|
+
def get_headers(mail)
|
|
104
|
+
params = {}
|
|
105
|
+
|
|
106
|
+
if mail[:headers].present? || unignored_headers(mail).present?
|
|
107
|
+
params[:headers] = {}
|
|
108
|
+
params[:headers].merge!(headers_values(mail)) if unignored_headers(mail).present?
|
|
109
|
+
params[:headers].merge!(mail_headers_values(mail)) if mail[:headers].present?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
params
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
#
|
|
116
|
+
# Adds additional options fields.
|
|
117
|
+
# Currently supports only :idempotency_key
|
|
118
|
+
#
|
|
119
|
+
# @param Mail mail Rails Mail object
|
|
120
|
+
# @return Hash hash with headers param
|
|
121
|
+
#
|
|
122
|
+
def get_options(mail)
|
|
123
|
+
opts = {}
|
|
124
|
+
if mail[:options].present?
|
|
125
|
+
opts.merge!(mail[:options].unparsed_value)
|
|
126
|
+
opts.delete_if { |k, _v| !SUPPORTED_OPTIONS.include?(k.to_s) }
|
|
127
|
+
end
|
|
128
|
+
opts
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Remove nils from header values
|
|
132
|
+
def cleanup_headers(headers)
|
|
133
|
+
headers.delete_if { |_k, v| v.nil? }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Gets the values of the headers that are set through the `#mail` method
|
|
137
|
+
#
|
|
138
|
+
# @param Mail mail Rails Mail object
|
|
139
|
+
# @return Hash hash with mail headers values
|
|
140
|
+
def mail_headers_values(mail)
|
|
141
|
+
params = {}
|
|
142
|
+
mail[:headers].unparsed_value.each do |k, v|
|
|
143
|
+
params[k.to_s] = v
|
|
144
|
+
end
|
|
145
|
+
cleanup_headers(params)
|
|
146
|
+
params
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Gets the values of the headers that are set through the `#headers` method
|
|
150
|
+
#
|
|
151
|
+
# @param Mail mail Rails Mail object
|
|
152
|
+
# @return Hash hash with headers values
|
|
153
|
+
def headers_values(mail)
|
|
154
|
+
params = {}
|
|
155
|
+
unignored_headers(mail).each do |h|
|
|
156
|
+
params[h.name.to_s] = h.unparsed_value
|
|
157
|
+
end
|
|
158
|
+
cleanup_headers(params)
|
|
159
|
+
params
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
#
|
|
163
|
+
# Add tags fields
|
|
164
|
+
#
|
|
165
|
+
# @param Mail mail Rails Mail object
|
|
166
|
+
#
|
|
167
|
+
# @return Hash hash with tags param
|
|
168
|
+
#
|
|
169
|
+
def get_tags(mail)
|
|
170
|
+
params = {}
|
|
171
|
+
params[:tags] = mail[:tags].unparsed_value if mail[:tags].present?
|
|
172
|
+
params
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
#
|
|
176
|
+
# Add cc, bcc, reply_to fields
|
|
177
|
+
#
|
|
178
|
+
# @param Mail mail Rails Mail Object
|
|
179
|
+
#
|
|
180
|
+
# @return Hash hash containing cc/bcc/reply_to attrs
|
|
181
|
+
#
|
|
182
|
+
def get_addons(mail)
|
|
183
|
+
params = {}
|
|
184
|
+
params[:cc] = mail.cc if mail.cc.present?
|
|
185
|
+
params[:bcc] = mail.bcc if mail.bcc.present?
|
|
186
|
+
params[:reply_to] = mail.reply_to if mail.reply_to.present?
|
|
187
|
+
params
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
#
|
|
191
|
+
# Gets the body of the email
|
|
192
|
+
#
|
|
193
|
+
# @param Mail mail Rails Mail Object
|
|
194
|
+
#
|
|
195
|
+
# @return Hash hash containing html/text or both attrs
|
|
196
|
+
#
|
|
197
|
+
def get_contents(mail)
|
|
198
|
+
params = {}
|
|
199
|
+
case mail.mime_type
|
|
200
|
+
when "text/plain"
|
|
201
|
+
params[:text] = mail.body.decoded
|
|
202
|
+
when "text/html"
|
|
203
|
+
params[:html] = mail.body.decoded
|
|
204
|
+
when "multipart/alternative", "multipart/mixed", "multipart/related"
|
|
205
|
+
params[:text] = mail.text_part.decoded if mail.text_part
|
|
206
|
+
params[:html] = mail.html_part.decoded if mail.html_part
|
|
207
|
+
end
|
|
208
|
+
params
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
#
|
|
212
|
+
# Properly gets the `from` attr
|
|
213
|
+
#
|
|
214
|
+
# @param Mail input object
|
|
215
|
+
#
|
|
216
|
+
# @return String `from` string
|
|
217
|
+
#
|
|
218
|
+
def get_from(input)
|
|
219
|
+
return input.from.first if input[:from].nil?
|
|
220
|
+
|
|
221
|
+
from = input[:from].formatted
|
|
222
|
+
return from.first if from.is_a? Array
|
|
223
|
+
|
|
224
|
+
from.to_s
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
#
|
|
228
|
+
# Handle attachments when present
|
|
229
|
+
# Uses base64 encoding for better API compatibility
|
|
230
|
+
#
|
|
231
|
+
# @return Array attachments array
|
|
232
|
+
#
|
|
233
|
+
def get_attachments(mail)
|
|
234
|
+
attachments = []
|
|
235
|
+
mail.attachments.each do |part|
|
|
236
|
+
# Get decoded content and ensure binary encoding for consistent base64 output
|
|
237
|
+
content = part.body.decoded.dup
|
|
238
|
+
content = content.force_encoding(Encoding::BINARY)
|
|
239
|
+
|
|
240
|
+
attachment = {
|
|
241
|
+
filename: part.filename,
|
|
242
|
+
content: Base64.strict_encode64(content),
|
|
243
|
+
content_type: part.content_type
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# Rails uses the auto generated cid for inline attachments
|
|
247
|
+
attachment[:content_id] = part.cid if part.inline?
|
|
248
|
+
attachments.append(attachment)
|
|
249
|
+
end
|
|
250
|
+
attachments
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
#
|
|
254
|
+
# Get all headers that are not ignored
|
|
255
|
+
#
|
|
256
|
+
# @param Mail mail
|
|
257
|
+
#
|
|
258
|
+
# @return Array headers
|
|
259
|
+
#
|
|
260
|
+
def unignored_headers(mail)
|
|
261
|
+
@unignored_headers ||= mail.header_fields.reject { |h| IGNORED_HEADERS.include?(h.name.downcase) }
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EmailFuse
|
|
4
|
+
# Helper class for building paginated query strings
|
|
5
|
+
class PaginationHelper
|
|
6
|
+
class << self
|
|
7
|
+
# Builds a paginated path with query parameters
|
|
8
|
+
#
|
|
9
|
+
# @param base_path [String] the base API path
|
|
10
|
+
# @param query_params [Hash] optional pagination parameters
|
|
11
|
+
# @option query_params [Integer] :limit Number of items to retrieve (max 100)
|
|
12
|
+
# @option query_params [String] :after ID after which to retrieve more items
|
|
13
|
+
# @option query_params [String] :before ID before which to retrieve more items
|
|
14
|
+
# @return [String] the path with query parameters
|
|
15
|
+
def build_paginated_path(base_path, query_params = nil)
|
|
16
|
+
return base_path if query_params.nil? || query_params.empty?
|
|
17
|
+
|
|
18
|
+
# Filter out nil values and convert to string keys
|
|
19
|
+
filtered_params = query_params.compact.transform_keys(&:to_s)
|
|
20
|
+
|
|
21
|
+
# Build query string
|
|
22
|
+
query_string = filtered_params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
|
|
23
|
+
|
|
24
|
+
return base_path if query_string.empty?
|
|
25
|
+
|
|
26
|
+
"#{base_path}?#{query_string}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "email_fuse"
|
|
4
|
+
require "email_fuse/mailer"
|
|
5
|
+
|
|
6
|
+
module EmailFuse
|
|
7
|
+
# Main railtime class
|
|
8
|
+
class Railtie < ::Rails::Railtie
|
|
9
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
10
|
+
add_delivery_method :emailfuse, EmailFuse::Mailer
|
|
11
|
+
ActiveSupport.run_load_hooks(:emailfuse_mailer, EmailFuse::Mailer)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EmailFuse
|
|
4
|
+
# This class is responsible for making the appropriate HTTP calls
|
|
5
|
+
# and raising the specific errors based on the response.
|
|
6
|
+
class Request
|
|
7
|
+
attr_accessor :body, :verb, :options
|
|
8
|
+
|
|
9
|
+
def initialize(path = "", body = {}, verb = "POST", options: {})
|
|
10
|
+
# Allow api_key override via options, fall back to global config
|
|
11
|
+
api_key = options[:api_key] || EmailFuse.api_key
|
|
12
|
+
raise ArgumentError, "API key is required. Set via EmailFuse.api_key= or pass :api_key in options" if api_key.nil?
|
|
13
|
+
|
|
14
|
+
api_key = api_key.call if api_key.is_a?(Proc)
|
|
15
|
+
|
|
16
|
+
# Allow base_url override via options, fall back to global config
|
|
17
|
+
@base_url = options[:base_url] || EmailFuse.base_url
|
|
18
|
+
|
|
19
|
+
@path = path
|
|
20
|
+
@body = body
|
|
21
|
+
@verb = verb
|
|
22
|
+
@options = options
|
|
23
|
+
@headers = {
|
|
24
|
+
"Content-Type" => "application/json",
|
|
25
|
+
"Accept" => "application/json",
|
|
26
|
+
"User-Agent" => "emailfuse-ruby:#{EmailFuse::VERSION}",
|
|
27
|
+
"Authorization" => "Bearer #{api_key}"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
set_idempotency_key
|
|
31
|
+
set_batch_validation
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Performs the HTTP call
|
|
35
|
+
def perform
|
|
36
|
+
options = build_request_options
|
|
37
|
+
resp = HTTParty.send(@verb.to_sym, URI.join(@base_url, @path).to_s, options)
|
|
38
|
+
|
|
39
|
+
check_json!(resp)
|
|
40
|
+
data = process_response(resp)
|
|
41
|
+
headers = extract_headers(resp)
|
|
42
|
+
|
|
43
|
+
EmailFuse::Response.new(data, headers)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_error!(data, resp = nil)
|
|
47
|
+
code = data[:statusCode]
|
|
48
|
+
body = data[:message]
|
|
49
|
+
headers = resp.respond_to?(:headers) ? resp.headers : (data[:headers] || {})
|
|
50
|
+
|
|
51
|
+
# get error from the known list of errors
|
|
52
|
+
error_class = EmailFuse::Error::ERRORS[code] || EmailFuse::Error
|
|
53
|
+
raise error_class.new(body, code, headers)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def build_request_options
|
|
59
|
+
options = { headers: @headers }
|
|
60
|
+
|
|
61
|
+
# Set timeout from options, global config, or default
|
|
62
|
+
timeout = @options[:timeout] || EmailFuse.timeout || EmailFuse::DEFAULT_TIMEOUT
|
|
63
|
+
options[:timeout] = timeout
|
|
64
|
+
|
|
65
|
+
if get_request_with_query?
|
|
66
|
+
options[:query] = @body
|
|
67
|
+
elsif !@body.empty?
|
|
68
|
+
options[:body] = @body.to_json
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
options
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def get_request_with_query?
|
|
75
|
+
@verb.downcase == "get" && !@body.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def process_response(resp)
|
|
79
|
+
# Extract the parsed data from HTTParty response or use the hash directly (for tests/mocks)
|
|
80
|
+
data = resp.respond_to?(:parsed_response) ? resp.parsed_response : resp
|
|
81
|
+
data ||= {}
|
|
82
|
+
data.transform_keys!(&:to_sym) unless resp.body.empty?
|
|
83
|
+
handle_error!(data, resp) if error_response?(data)
|
|
84
|
+
data
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def error_response?(resp)
|
|
88
|
+
resp[:statusCode] && resp[:statusCode] != 200 && resp[:statusCode] != 201
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def set_idempotency_key
|
|
92
|
+
# Only set idempotency key if the verb is POST for now.
|
|
93
|
+
#
|
|
94
|
+
# Does not set it if the idempotency_key is nil or empty
|
|
95
|
+
if @verb.downcase == "post" && !@options[:idempotency_key].nil? && !@options[:idempotency_key].empty?
|
|
96
|
+
@headers["Idempotency-Key"] = @options[:idempotency_key]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def set_batch_validation
|
|
101
|
+
# Set x-batch-validation header for batch emails
|
|
102
|
+
# Supported values: 'strict' (default) or 'permissive'
|
|
103
|
+
if @path == "emails/batch" && @options[:batch_validation]
|
|
104
|
+
@headers["x-batch-validation"] = @options[:batch_validation]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def check_json!(resp)
|
|
109
|
+
if resp.body.is_a?(Hash)
|
|
110
|
+
JSON.parse(resp.body.to_json)
|
|
111
|
+
else
|
|
112
|
+
JSON.parse(resp.body)
|
|
113
|
+
end
|
|
114
|
+
rescue JSON::ParserError, TypeError
|
|
115
|
+
raise EmailFuse::Error::InternalServerError.new("EmailFuse API returned an unexpected response", nil)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Extract and normalize headers from the HTTParty response
|
|
119
|
+
def extract_headers(resp)
|
|
120
|
+
return {} unless resp.respond_to?(:headers)
|
|
121
|
+
|
|
122
|
+
resp.headers.to_h.transform_keys { |k| k.to_s.downcase }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EmailFuse
|
|
4
|
+
# Response wrapper that maintains backwards compatibility while exposing headers
|
|
5
|
+
#
|
|
6
|
+
# This class wraps API responses and behaves like a Hash for backwards compatibility,
|
|
7
|
+
# while also providing access to response headers via the #headers method.
|
|
8
|
+
#
|
|
9
|
+
# @example Backwards compatible hash access
|
|
10
|
+
# response = EmailFuse::Emails.send(params)
|
|
11
|
+
# response[:id] # => "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"
|
|
12
|
+
#
|
|
13
|
+
# @example Accessing response headers
|
|
14
|
+
# response = EmailFuse::Emails.send(params)
|
|
15
|
+
# response.headers # => {"content-type" => "application/json", ...}
|
|
16
|
+
# response.headers['x-ratelimit-remaining'] # => "50"
|
|
17
|
+
class Response
|
|
18
|
+
include Enumerable
|
|
19
|
+
|
|
20
|
+
# @param data [Hash] The response data
|
|
21
|
+
# @param headers [Hash, HTTParty::Response] The response headers
|
|
22
|
+
def initialize(data, headers)
|
|
23
|
+
@data = data.is_a?(Hash) ? data : {}
|
|
24
|
+
@headers = normalize_headers(headers)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Access response headers
|
|
28
|
+
# @return [Hash] Response headers as a hash with lowercase string keys
|
|
29
|
+
attr_reader :headers
|
|
30
|
+
|
|
31
|
+
# Hash-like access via []
|
|
32
|
+
# @param key [Symbol, String] The key to access
|
|
33
|
+
# @return [Object] The value at the key
|
|
34
|
+
def [](key)
|
|
35
|
+
@data[key]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Hash-like assignment via []=
|
|
39
|
+
# @param key [Symbol, String] The key to set
|
|
40
|
+
# @param value [Object] The value to set
|
|
41
|
+
def []=(key, value)
|
|
42
|
+
@data[key] = value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Dig into nested hash structure
|
|
46
|
+
# @param keys [Array<Symbol, String>] Keys to dig through
|
|
47
|
+
# @return [Object] The value at the nested key path
|
|
48
|
+
def dig(*keys)
|
|
49
|
+
@data.dig(*keys)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Convert to plain hash
|
|
53
|
+
# @return [Hash] The underlying data hash
|
|
54
|
+
def to_h
|
|
55
|
+
@data
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
alias to_hash to_h
|
|
59
|
+
|
|
60
|
+
# Get all keys from the data
|
|
61
|
+
# @return [Array] Array of keys
|
|
62
|
+
def keys
|
|
63
|
+
@data.keys
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get all values from the data
|
|
67
|
+
# @return [Array] Array of values
|
|
68
|
+
def values
|
|
69
|
+
@data.values
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Check if key exists
|
|
73
|
+
# @param key [Symbol, String] The key to check
|
|
74
|
+
# @return [Boolean] True if key exists
|
|
75
|
+
def key?(key)
|
|
76
|
+
@data.key?(key)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
alias has_key? key?
|
|
80
|
+
|
|
81
|
+
# Enable enumeration over the data
|
|
82
|
+
def each(&block)
|
|
83
|
+
@data.each(&block)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Transform keys in the underlying data
|
|
87
|
+
# @return [EmailFuse::Response] Self for chaining
|
|
88
|
+
def transform_keys!(&block)
|
|
89
|
+
@data.transform_keys!(&block)
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Check if response is empty
|
|
94
|
+
# @return [Boolean] True if data is empty
|
|
95
|
+
def empty?
|
|
96
|
+
@data.empty?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Respond to hash-like methods
|
|
100
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
101
|
+
@data.respond_to?(method_name) || super
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Delegate unknown methods to the underlying data hash
|
|
105
|
+
def method_missing(method_name, *args, &block)
|
|
106
|
+
if @data.respond_to?(method_name)
|
|
107
|
+
result = @data.send(method_name, *args, &block)
|
|
108
|
+
# If the method returns the hash itself, return self to maintain wrapper
|
|
109
|
+
result.equal?(@data) ? self : result
|
|
110
|
+
else
|
|
111
|
+
super
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# String representation for debugging
|
|
116
|
+
# @return [String] String representation of the response
|
|
117
|
+
def inspect
|
|
118
|
+
"#<EmailFuse::Response data=#{@data.inspect} headers=#{@headers.keys.inspect}>"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Normalize headers to a simple hash with lowercase string keys
|
|
124
|
+
# @param headers [Hash, HTTParty::Response, nil] The headers to normalize
|
|
125
|
+
# @return [Hash] Normalized headers hash
|
|
126
|
+
def normalize_headers(headers)
|
|
127
|
+
return {} if headers.nil?
|
|
128
|
+
|
|
129
|
+
# Handle HTTParty::Response object
|
|
130
|
+
headers = headers.headers if headers.respond_to?(:headers)
|
|
131
|
+
|
|
132
|
+
# Convert to hash and normalize keys to lowercase strings
|
|
133
|
+
case headers
|
|
134
|
+
when Hash
|
|
135
|
+
headers.to_h.transform_keys { |k| k.to_s.downcase }
|
|
136
|
+
else
|
|
137
|
+
{}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|