emailfuse 0.1.3 → 0.2.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,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "email_fuse"
4
+
5
+ module EmailFuse
6
+ # Mailer class used by railtie
7
+ class Mailer
8
+ attr_accessor :config, :settings
9
+
10
+ # These are set as `headers` by the Rails API, but these will be filtered out
11
+ # when constructing the EmailFuse API payload, since they're are sent as post params.
12
+ # https://resend.com/docs/api-reference/emails/send-email
13
+ IGNORED_HEADERS = %w[
14
+ cc bcc
15
+ from reply-to to subject mime-version
16
+ html text
17
+ content-type tags scheduled_at
18
+ headers options
19
+ ].freeze
20
+
21
+ SUPPORTED_OPTIONS = %w[idempotency_key].freeze
22
+
23
+ def initialize(config)
24
+ @config = config
25
+ @settings = config.is_a?(Hash) ? config : {}
26
+
27
+ # Check for API key in settings first, then fall back to global config
28
+ raise EmailFuse::Error.new("Make sure your API Key is set", @config) unless api_key
29
+ end
30
+
31
+ # Returns the API key from settings or global config
32
+ def api_key
33
+ @settings[:api_key] || EmailFuse.api_key
34
+ end
35
+
36
+ # Returns the base URL from settings or global config
37
+ def base_url
38
+ @settings[:host] || @settings[:base_url] || EmailFuse.base_url
39
+ end
40
+
41
+ #
42
+ # Overwritten deliver! method
43
+ #
44
+ # @param Mail mail
45
+ #
46
+ # @return Object resend response
47
+ #
48
+ def deliver!(mail)
49
+ params = build_resend_params(mail)
50
+ options = get_options(mail) if mail[:options].present?
51
+ options ||= {}
52
+
53
+ # Pass api_key and base_url from settings to the request
54
+ options[:api_key] = api_key
55
+ options[:base_url] = base_url
56
+
57
+ resp = EmailFuse::Emails.send(params, options: options)
58
+ mail.message_id = resp[:id] if resp[:error].nil?
59
+ resp
60
+ end
61
+
62
+ #
63
+ # Builds the payload for sending
64
+ #
65
+ # @param Mail mail rails mail object
66
+ #
67
+ # @return Hash hash with all EmailFuse params
68
+ #
69
+ def build_resend_params(mail)
70
+ params = {
71
+ from: get_from(mail),
72
+ to: mail.to,
73
+ subject: mail.subject
74
+ }
75
+ params.merge!(get_addons(mail))
76
+ params.merge!(get_headers(mail))
77
+ params.merge!(get_tags(mail))
78
+ params[:attachments] = get_attachments(mail) if mail.attachments.present?
79
+ params.merge!(get_contents(mail))
80
+ params
81
+ end
82
+
83
+ #
84
+ # Add custom headers fields.
85
+ #
86
+ # Both ways are supported:
87
+ #
88
+ # 1. Through the `#mail()` method ie:
89
+ # mail(headers: { "X-Custom-Header" => "value" })
90
+ #
91
+ # 2. Through the Rails `#headers` method ie:
92
+ # headers["X-Custom-Header"] = "value"
93
+ #
94
+ #
95
+ # setting the header values through the `#mail` method will overwrite values set
96
+ # through the `#headers` method using the same key.
97
+ #
98
+ # @param Mail mail Rails Mail object
99
+ #
100
+ # @return Hash hash with headers param
101
+ #
102
+ def get_headers(mail)
103
+ params = {}
104
+
105
+ if mail[:headers].present? || unignored_headers(mail).present?
106
+ params[:headers] = {}
107
+ params[:headers].merge!(headers_values(mail)) if unignored_headers(mail).present?
108
+ params[:headers].merge!(mail_headers_values(mail)) if mail[:headers].present?
109
+ end
110
+
111
+ params
112
+ end
113
+
114
+ #
115
+ # Adds additional options fields.
116
+ # Currently supports only :idempotency_key
117
+ #
118
+ # @param Mail mail Rails Mail object
119
+ # @return Hash hash with headers param
120
+ #
121
+ def get_options(mail)
122
+ opts = {}
123
+ if mail[:options].present?
124
+ opts.merge!(mail[:options].unparsed_value)
125
+ opts.delete_if { |k, _v| !SUPPORTED_OPTIONS.include?(k.to_s) }
126
+ end
127
+ opts
128
+ end
129
+
130
+ # Remove nils from header values
131
+ def cleanup_headers(headers)
132
+ headers.delete_if { |_k, v| v.nil? }
133
+ end
134
+
135
+ # Gets the values of the headers that are set through the `#mail` method
136
+ #
137
+ # @param Mail mail Rails Mail object
138
+ # @return Hash hash with mail headers values
139
+ def mail_headers_values(mail)
140
+ params = {}
141
+ mail[:headers].unparsed_value.each do |k, v|
142
+ params[k.to_s] = v
143
+ end
144
+ cleanup_headers(params)
145
+ params
146
+ end
147
+
148
+ # Gets the values of the headers that are set through the `#headers` method
149
+ #
150
+ # @param Mail mail Rails Mail object
151
+ # @return Hash hash with headers values
152
+ def headers_values(mail)
153
+ params = {}
154
+ unignored_headers(mail).each do |h|
155
+ params[h.name.to_s] = h.unparsed_value
156
+ end
157
+ cleanup_headers(params)
158
+ params
159
+ end
160
+
161
+ #
162
+ # Add tags fields
163
+ #
164
+ # @param Mail mail Rails Mail object
165
+ #
166
+ # @return Hash hash with tags param
167
+ #
168
+ def get_tags(mail)
169
+ params = {}
170
+ params[:tags] = mail[:tags].unparsed_value if mail[:tags].present?
171
+ params
172
+ end
173
+
174
+ #
175
+ # Add cc, bcc, reply_to fields
176
+ #
177
+ # @param Mail mail Rails Mail Object
178
+ #
179
+ # @return Hash hash containing cc/bcc/reply_to attrs
180
+ #
181
+ def get_addons(mail)
182
+ params = {}
183
+ params[:cc] = mail.cc if mail.cc.present?
184
+ params[:bcc] = mail.bcc if mail.bcc.present?
185
+ params[:reply_to] = mail.reply_to if mail.reply_to.present?
186
+ params
187
+ end
188
+
189
+ #
190
+ # Gets the body of the email
191
+ #
192
+ # @param Mail mail Rails Mail Object
193
+ #
194
+ # @return Hash hash containing html/text or both attrs
195
+ #
196
+ def get_contents(mail)
197
+ params = {}
198
+ case mail.mime_type
199
+ when "text/plain"
200
+ params[:text] = mail.body.decoded
201
+ when "text/html"
202
+ params[:html] = mail.body.decoded
203
+ when "multipart/alternative", "multipart/mixed", "multipart/related"
204
+ params[:text] = mail.text_part.decoded if mail.text_part
205
+ params[:html] = mail.html_part.decoded if mail.html_part
206
+ end
207
+ params
208
+ end
209
+
210
+ #
211
+ # Properly gets the `from` attr
212
+ #
213
+ # @param Mail input object
214
+ #
215
+ # @return String `from` string
216
+ #
217
+ def get_from(input)
218
+ return input.from.first if input[:from].nil?
219
+
220
+ from = input[:from].formatted
221
+ return from.first if from.is_a? Array
222
+
223
+ from.to_s
224
+ end
225
+
226
+ #
227
+ # Handle attachments when present
228
+ #
229
+ # @return Array attachments array
230
+ #
231
+ def get_attachments(mail)
232
+ attachments = []
233
+ mail.attachments.each do |part|
234
+ attachment = {
235
+ filename: part.filename,
236
+ content: part.body.decoded.bytes
237
+ }
238
+
239
+ # Rails uses the auto generated cid for inline attachments
240
+ attachment[:content_id] = part.cid if part.inline?
241
+ attachments.append(attachment)
242
+ end
243
+ attachments
244
+ end
245
+
246
+ #
247
+ # Get all headers that are not ignored
248
+ #
249
+ # @param Mail mail
250
+ #
251
+ # @return Array headers
252
+ #
253
+ def unignored_headers(mail)
254
+ @unignored_headers ||= mail.header_fields.reject { |h| IGNORED_HEADERS.include?(h.name.downcase) }
255
+ end
256
+ end
257
+ 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,121 @@
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 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
+ if get_request_with_query?
62
+ options[:query] = @body
63
+ elsif !@body.empty?
64
+ options[:body] = @body.to_json
65
+ end
66
+
67
+ options
68
+ end
69
+
70
+ def get_request_with_query?
71
+ @verb.downcase == "get" && !@body.empty?
72
+ end
73
+
74
+ def process_response(resp)
75
+ # Extract the parsed data from HTTParty response or use the hash directly (for tests/mocks)
76
+ data = resp.respond_to?(:parsed_response) ? resp.parsed_response : resp
77
+ data ||= {}
78
+ data.transform_keys!(&:to_sym) unless resp.body.empty?
79
+ handle_error!(data, resp) if error_response?(data)
80
+ data
81
+ end
82
+
83
+ def error_response?(resp)
84
+ resp[:statusCode] && resp[:statusCode] != 200 && resp[:statusCode] != 201
85
+ end
86
+
87
+ def set_idempotency_key
88
+ # Only set idempotency key if the verb is POST for now.
89
+ #
90
+ # Does not set it if the idempotency_key is nil or empty
91
+ if @verb.downcase == "post" && !@options[:idempotency_key].nil? && !@options[:idempotency_key].empty?
92
+ @headers["Idempotency-Key"] = @options[:idempotency_key]
93
+ end
94
+ end
95
+
96
+ def set_batch_validation
97
+ # Set x-batch-validation header for batch emails
98
+ # Supported values: 'strict' (default) or 'permissive'
99
+ if @path == "emails/batch" && @options[:batch_validation]
100
+ @headers["x-batch-validation"] = @options[:batch_validation]
101
+ end
102
+ end
103
+
104
+ def check_json!(resp)
105
+ if resp.body.is_a?(Hash)
106
+ JSON.parse(resp.body.to_json)
107
+ else
108
+ JSON.parse(resp.body)
109
+ end
110
+ rescue JSON::ParserError, TypeError
111
+ raise EmailFuse::Error::InternalServerError.new("EmailFuse API returned an unexpected response", nil)
112
+ end
113
+
114
+ # Extract and normalize headers from the HTTParty response
115
+ def extract_headers(resp)
116
+ return {} unless resp.respond_to?(:headers)
117
+
118
+ resp.headers.to_h.transform_keys { |k| k.to_s.downcase }
119
+ end
120
+ end
121
+ 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.2.0"
5
+ end