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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EmailFuse
4
+ VERSION = "0.3.0"
5
+ end