mailgazelle 1.0.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,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailGazelle
4
+ # Base error for SDK failures and API error responses.
5
+ #
6
+ # Catch this class when any Mail Gazelle failure should take the same path.
7
+ # Use a subclass when recovery depends on the cause.
8
+ class Error < StandardError
9
+ attr_reader :code, :http_status, :details
10
+
11
+ def initialize(message, code:, http_status: 0, details: {})
12
+ super(message)
13
+ @code = code
14
+ @http_status = http_status
15
+ @details = details
16
+ end
17
+ end
18
+
19
+ # Raised when the mailer or default client is used without a token.
20
+ class ApiTokenMissing < Error
21
+ def initialize(message = "A Mail Gazelle API token is required. Set MAILGAZELLE_API_TOKEN or " \
22
+ "config.action_mailer.mailgazelle_settings[:api_token].")
23
+ super(message, code: "api_token_missing", http_status: 0)
24
+ end
25
+ end
26
+
27
+ # DNS, TLS, timeout, and other failures before an API response arrives.
28
+ class TransportError < Error
29
+ def initialize(message, code: "transport_error", http_status: 0, details: {})
30
+ super
31
+ end
32
+ end
33
+
34
+ # An API error that does not map to a more specific class.
35
+ class ApiError < Error; end
36
+
37
+ # 401 unauthenticated, including a token for an archived product.
38
+ class AuthenticationError < Error; end
39
+
40
+ # 403 product_not_ready.
41
+ class ProductNotReadyError < Error; end
42
+
43
+ # 422 validation_error.
44
+ class ValidationError < Error; end
45
+
46
+ # 422 from_not_allowed.
47
+ class FromNotAllowedError < Error; end
48
+
49
+ # 422 recipient_suppressed. The address was stored as rejected and was not sent.
50
+ class RecipientSuppressedError < Error; end
51
+
52
+ # 422 attachment_invalid.
53
+ class AttachmentError < Error; end
54
+
55
+ # 422 attachment_too_large.
56
+ class AttachmentTooLargeError < Error; end
57
+
58
+ # 422 quota_exceeded.
59
+ class QuotaExceededError < Error; end
60
+
61
+ # 422 html_too_large.
62
+ class HtmlTooLargeError < Error; end
63
+
64
+ # 422 message_too_large.
65
+ class MessageTooLargeError < Error; end
66
+
67
+ # 429 rate_limited. The limit is 60 requests per minute per token.
68
+ class RateLimitError < Error; end
69
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mailgazelle/error"
5
+
6
+ module MailGazelle
7
+ # Maps a non-success HTTP response onto a typed {Error}.
8
+ module ErrorMapper
9
+ CLASSES = {
10
+ "unauthenticated" => AuthenticationError,
11
+ "product_not_ready" => ProductNotReadyError,
12
+ "validation_error" => ValidationError,
13
+ "from_not_allowed" => FromNotAllowedError,
14
+ "recipient_suppressed" => RecipientSuppressedError,
15
+ "attachment_invalid" => AttachmentError,
16
+ "attachment_too_large" => AttachmentTooLargeError,
17
+ "quota_exceeded" => QuotaExceededError,
18
+ "html_too_large" => HtmlTooLargeError,
19
+ "message_too_large" => MessageTooLargeError,
20
+ "rate_limited" => RateLimitError
21
+ }.freeze
22
+
23
+ module_function
24
+
25
+ def from_response(response)
26
+ payload = decode(response.body)
27
+ code = string_or_nil(payload["code"]) || code_from_status(response.status_code)
28
+ message = string_or_nil(payload["message"]) || "Unexpected Mail Gazelle API error."
29
+ details = payload["details"].is_a?(Hash) ? payload["details"] : {}
30
+ error_class = CLASSES.fetch(code, ApiError)
31
+ error_class.new(message, code: code, http_status: response.status_code, details: details)
32
+ end
33
+
34
+ def decode(body)
35
+ return {} if body.nil? || body.empty?
36
+
37
+ decoded = JSON.parse(body)
38
+ decoded.is_a?(Hash) ? decoded : {}
39
+ rescue JSON::ParserError
40
+ {}
41
+ end
42
+
43
+ def code_from_status(status)
44
+ case status
45
+ when 401 then "unauthenticated"
46
+ when 403 then "product_not_ready"
47
+ when 422 then "validation_error"
48
+ when 429 then "rate_limited"
49
+ else "unknown_error"
50
+ end
51
+ end
52
+
53
+ def string_or_nil(value)
54
+ value.is_a?(String) && !value.empty? ? value : nil
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mailgazelle/error"
5
+ require "mailgazelle/error_mapper"
6
+ require "mailgazelle/http/request"
7
+
8
+ module MailGazelle
9
+ module Http
10
+ # Authenticates requests, encodes JSON, and maps API errors to exceptions.
11
+ class HttpClient
12
+ def initialize(transport:, base_url:, default_headers:, timeout:, connect_timeout:)
13
+ @transport = transport
14
+ @base_url = base_url
15
+ @default_headers = default_headers
16
+ @timeout = timeout
17
+ @connect_timeout = connect_timeout
18
+ end
19
+
20
+ # Send an authenticated JSON request and return the decoded object or list.
21
+ def request(method, path, json: nil)
22
+ headers = @default_headers.dup
23
+ body = nil
24
+
25
+ unless json.nil?
26
+ body = encode(json)
27
+ headers["Content-Type"] = "application/json"
28
+ end
29
+
30
+ response = @transport.perform(
31
+ Request.new(
32
+ http_method: method.to_s.upcase,
33
+ url: url(path),
34
+ headers: headers,
35
+ body: body,
36
+ timeout: @timeout,
37
+ connect_timeout: @connect_timeout
38
+ )
39
+ )
40
+
41
+ raise ErrorMapper.from_response(response) unless response.status_code.between?(200, 299)
42
+
43
+ decode_success(response)
44
+ end
45
+
46
+ def url(path)
47
+ "#{@base_url}/#{path.to_s.sub(%r{\A/}, '')}"
48
+ end
49
+
50
+ private
51
+
52
+ def encode(json)
53
+ JSON.generate(json)
54
+ rescue JSON::GeneratorError => e
55
+ raise ApiError.new(
56
+ "Failed to encode the request body as JSON.",
57
+ code: "invalid_request",
58
+ http_status: 0
59
+ ), cause: e
60
+ end
61
+
62
+ def decode_success(response)
63
+ return {} if response.body.nil? || response.body.empty?
64
+
65
+ decoded = JSON.parse(response.body)
66
+ unless decoded.is_a?(Hash) || decoded.is_a?(Array)
67
+ raise ApiError.new(
68
+ "API returned a non-object JSON payload.",
69
+ code: "invalid_response",
70
+ http_status: response.status_code
71
+ )
72
+ end
73
+
74
+ decoded
75
+ rescue JSON::ParserError => e
76
+ raise ApiError.new(
77
+ "Failed to decode the API response.",
78
+ code: "invalid_response",
79
+ http_status: response.status_code
80
+ ), cause: e
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require "mailgazelle/error"
7
+ require "mailgazelle/http/request"
8
+ require "mailgazelle/http/response"
9
+
10
+ module MailGazelle
11
+ module Http
12
+ # Default transport backed by +Net::HTTP+. Inject another object with +perform+
13
+ # when an application wants a different HTTP stack or a test double.
14
+ class NetHttpTransport
15
+ METHODS = {
16
+ "GET" => Net::HTTP::Get,
17
+ "POST" => Net::HTTP::Post,
18
+ "PATCH" => Net::HTTP::Patch,
19
+ "PUT" => Net::HTTP::Put,
20
+ "DELETE" => Net::HTTP::Delete
21
+ }.freeze
22
+
23
+ def perform(request)
24
+ uri = URI.parse(request.url)
25
+ http = Net::HTTP.new(uri.host, uri.port)
26
+ http.use_ssl = uri.scheme == "https"
27
+ http.open_timeout = request.connect_timeout
28
+ http.read_timeout = request.timeout
29
+ http.write_timeout = request.timeout if http.respond_to?(:write_timeout=)
30
+
31
+ response = http.request(build_request(uri, request))
32
+ headers = {}
33
+ response.each_header { |key, value| headers[key.downcase] = value }
34
+
35
+ Response.new(status_code: response.code.to_i, body: response.body.to_s, headers: headers)
36
+ rescue Timeout::Error, IOError, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
37
+ message = e.message.to_s
38
+ message = "The HTTP request failed." if message.empty?
39
+ raise TransportError.new(message, details: { "error_class" => e.class.name }), cause: e
40
+ end
41
+
42
+ private
43
+
44
+ def build_request(uri, request)
45
+ klass = METHODS[request.http_method.to_s.upcase]
46
+ raise TransportError, "Unsupported HTTP method #{request.http_method.inspect}." if klass.nil?
47
+
48
+ http_request = klass.new(uri.request_uri)
49
+ request.headers.each { |name, value| http_request[name] = value }
50
+ http_request.body = request.body unless request.body.nil?
51
+ http_request
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailGazelle
4
+ module Http
5
+ # Outgoing HTTP request passed to a transport.
6
+ #
7
+ # A transport is any object that implements +perform+ and returns a {Response}.
8
+ Request = Data.define(:http_method, :url, :headers, :body, :timeout, :connect_timeout)
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailGazelle
4
+ module Http
5
+ # HTTP response returned by a transport. Header names are lower-case.
6
+ Response = Data.define(:status_code, :body, :headers)
7
+ end
8
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+ require "mailgazelle/attachment"
5
+ require "mailgazelle/client"
6
+ require "mailgazelle/email"
7
+ require "mailgazelle/error"
8
+
9
+ module MailGazelle
10
+ # ActionMailer delivery method. Registered as +:mailgazelle+ by {Railtie}.
11
+ class Mailer
12
+ SKIPPED_HEADERS = %w[
13
+ bcc
14
+ cc
15
+ content-transfer-encoding
16
+ content-type
17
+ date
18
+ from
19
+ idempotency-key
20
+ idempotency_key
21
+ mailgazelle-idempotency-key
22
+ mime-version
23
+ reply-to
24
+ return-path
25
+ sender
26
+ subject
27
+ tags
28
+ to
29
+ x-mailgazelle-message-id
30
+ ].freeze
31
+
32
+ IDEMPOTENCY_HEADERS = %w[
33
+ idempotency-key
34
+ idempotency_key
35
+ mailgazelle-idempotency-key
36
+ ].freeze
37
+
38
+ attr_reader :settings
39
+
40
+ def initialize(settings = nil)
41
+ @config = settings.is_a?(Hash) ? settings : {}
42
+ @settings = @config.merge(return_response: true)
43
+ end
44
+
45
+ def deliver!(mail)
46
+ queued = client.emails.send(build_email(mail))
47
+ remember_id(mail, queued.id)
48
+ queued
49
+ end
50
+
51
+ private
52
+
53
+ def client
54
+ @client ||= build_client
55
+ end
56
+
57
+ def build_client
58
+ token = first_present(setting(:api_token), MailGazelle.config.api_token)
59
+ raise ApiTokenMissing if token.nil? || token.to_s.strip.empty?
60
+
61
+ Client.new(
62
+ api_token: token,
63
+ base_url: first_present(setting(:api_base), setting(:base_url), MailGazelle.config.api_base),
64
+ transport: setting(:transport),
65
+ timeout: first_present(setting(:timeout), MailGazelle.config.timeout) || 30,
66
+ connect_timeout: first_present(setting(:connect_timeout), MailGazelle.config.connect_timeout) || 10,
67
+ user_agent_suffix: user_agent_suffix
68
+ )
69
+ end
70
+
71
+ def user_agent_suffix
72
+ extra = first_present(setting(:user_agent_suffix), MailGazelle.config.user_agent_suffix)
73
+ ["rails", extra].compact.map { |value| value.to_s.strip }.reject(&:empty?).join(" ")
74
+ end
75
+
76
+ def setting(key)
77
+ return @config[key] if @config.key?(key)
78
+
79
+ @config[key.to_s] if @config.key?(key.to_s)
80
+ end
81
+
82
+ def first_present(*values)
83
+ values.find { |value| !value.nil? && !(value.is_a?(String) && value.strip.empty?) }
84
+ end
85
+
86
+ def build_email(message)
87
+ recipients = address_pairs(message[:to])
88
+ if recipients.empty?
89
+ raise ValidationError.new(
90
+ "A Mail Gazelle message requires at least one To address.",
91
+ code: "validation_error",
92
+ http_status: 422
93
+ )
94
+ end
95
+
96
+ email = Email.to(recipients.first[0], recipients.first[1])
97
+ recipients.drop(1).each { |address, name| email = email.add_to(address, name) }
98
+ address_pairs(message[:cc]).each { |address, name| email = email.cc(address, name) }
99
+ address_pairs(message[:bcc]).each { |address, name| email = email.bcc(address, name) }
100
+ email = email.subject(message.subject.to_s)
101
+ email = apply_body(email, message)
102
+
103
+ from = address_pairs(message[:from]).first
104
+ email = email.from(from[0], from[1]) if from
105
+ address_pairs(message[:reply_to]).each { |address, name| email = email.reply_to(address, name) }
106
+ email = apply_headers(email, message)
107
+ apply_attachments(email, message)
108
+ end
109
+
110
+ def apply_body(email, message)
111
+ if message.multipart?
112
+ text = part_body(message.text_part)
113
+ html = part_body(message.html_part)
114
+ email = email.text(text) if text
115
+ email = email.html(html) if html
116
+ return email
117
+ end
118
+
119
+ body = message.body.decoded.to_s
120
+ return email if body.empty?
121
+
122
+ if message.mime_type == "text/html"
123
+ email.html(body)
124
+ else
125
+ email.text(body)
126
+ end
127
+ end
128
+
129
+ def part_body(part)
130
+ return nil if part.nil?
131
+
132
+ body = part.decoded.to_s
133
+ body.empty? ? nil : body
134
+ end
135
+
136
+ def apply_headers(email, message)
137
+ tags = nil
138
+ idempotency = nil
139
+ headers = {}
140
+
141
+ message.header_fields.each do |field|
142
+ name = field.name.to_s
143
+ lowered = name.downcase
144
+
145
+ if lowered == "tags"
146
+ tags = hash_value(field)
147
+ next
148
+ end
149
+
150
+ if IDEMPOTENCY_HEADERS.include?(lowered)
151
+ idempotency = field.value.to_s
152
+ next
153
+ end
154
+
155
+ next if SKIPPED_HEADERS.include?(lowered)
156
+
157
+ value = field.value
158
+ next if value.nil?
159
+
160
+ headers[name] = value.to_s
161
+ end
162
+
163
+ headers.each { |name, value| email = email.header(name, value) }
164
+ tags&.each { |key, value| email = email.tag(key.to_s, value.to_s) }
165
+ email = email.idempotency_key(idempotency.strip) if idempotency.is_a?(String) && !idempotency.strip.empty?
166
+ email
167
+ end
168
+
169
+ def hash_value(field)
170
+ unparsed = field.unparsed_value if field.respond_to?(:unparsed_value)
171
+ return unparsed if unparsed.is_a?(Hash)
172
+ return field.value if field.value.is_a?(Hash)
173
+
174
+ nil
175
+ end
176
+
177
+ def apply_attachments(email, message)
178
+ message.attachments.each do |part|
179
+ filename = part.filename.to_s
180
+ filename = "attachment" if filename.empty?
181
+ email = email.attach(
182
+ Attachment.from_contents(
183
+ filename,
184
+ part.body.decoded.to_s,
185
+ content_type: part.mime_type,
186
+ content_id: part.inline? ? part.cid : nil
187
+ )
188
+ )
189
+ end
190
+ email
191
+ end
192
+
193
+ def address_pairs(header)
194
+ return [] if header.nil? || !header.respond_to?(:addrs)
195
+
196
+ header.addrs.filter_map do |address|
197
+ email = address.address.to_s.strip
198
+ next if email.empty?
199
+
200
+ name = address.display_name.to_s.strip
201
+ [email, name.empty? ? nil : name]
202
+ end
203
+ end
204
+
205
+ def remember_id(message, id)
206
+ return if id.nil? || id.empty?
207
+
208
+ message.header["X-MailGazelle-Message-Id"] = id
209
+ message.message_id = id
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mailgazelle/mailer"
4
+
5
+ module MailGazelle
6
+ # Registers the +:mailgazelle+ ActionMailer delivery method when Rails boots.
7
+ class Railtie < Rails::Railtie
8
+ initializer "mailgazelle.action_mailer" do
9
+ ActiveSupport.on_load(:action_mailer) do
10
+ add_delivery_method :mailgazelle, MailGazelle::Mailer
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailGazelle
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mailgazelle/version"
4
+ require "mailgazelle/error"
5
+ require "mailgazelle/configuration"
6
+ require "mailgazelle/address"
7
+ require "mailgazelle/attachment"
8
+ require "mailgazelle/email"
9
+ require "mailgazelle/client"
10
+
11
+ module MailGazelle
12
+ class << self
13
+ def configure
14
+ yield config
15
+ @client = nil
16
+ config
17
+ end
18
+
19
+ def config
20
+ @config ||= Configuration.new
21
+ end
22
+
23
+ def client
24
+ @client ||= default_client
25
+ end
26
+
27
+ def reset!
28
+ @config = Configuration.new
29
+ @client = nil
30
+ end
31
+
32
+ private
33
+
34
+ def default_client
35
+ token = config.api_token
36
+ raise ApiTokenMissing if token.nil? || token.to_s.strip.empty?
37
+
38
+ Client.new(
39
+ api_token: token,
40
+ base_url: config.api_base,
41
+ timeout: config.timeout || 30,
42
+ connect_timeout: config.connect_timeout || 10,
43
+ user_agent_suffix: config.user_agent_suffix
44
+ )
45
+ end
46
+ end
47
+ end
48
+
49
+ require "mailgazelle/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mailgazelle
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Srdjan Marjanovic
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Send transactional email with the Mail Gazelle API from Ruby, and from
13
+ Rails via ActionMailer.
14
+ email:
15
+ - srdjan@foobar.rs
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE
22
+ - README.md
23
+ - docs/api.md
24
+ - lib/mailgazelle.rb
25
+ - lib/mailgazelle/address.rb
26
+ - lib/mailgazelle/attachment.rb
27
+ - lib/mailgazelle/client.rb
28
+ - lib/mailgazelle/configuration.rb
29
+ - lib/mailgazelle/domain.rb
30
+ - lib/mailgazelle/domains_resource.rb
31
+ - lib/mailgazelle/email.rb
32
+ - lib/mailgazelle/emails/attachment_meta.rb
33
+ - lib/mailgazelle/emails/email_record.rb
34
+ - lib/mailgazelle/emails/email_status.rb
35
+ - lib/mailgazelle/emails/queued_email.rb
36
+ - lib/mailgazelle/emails_resource.rb
37
+ - lib/mailgazelle/error.rb
38
+ - lib/mailgazelle/error_mapper.rb
39
+ - lib/mailgazelle/http/http_client.rb
40
+ - lib/mailgazelle/http/net_http_transport.rb
41
+ - lib/mailgazelle/http/request.rb
42
+ - lib/mailgazelle/http/response.rb
43
+ - lib/mailgazelle/mailer.rb
44
+ - lib/mailgazelle/railtie.rb
45
+ - lib/mailgazelle/version.rb
46
+ homepage: https://mailgazelle.com
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://mailgazelle.com
51
+ source_code_uri: https://github.com/mailgazelle/ruby-sdk
52
+ changelog_uri: https://github.com/mailgazelle/ruby-sdk/blob/main/CHANGELOG.md
53
+ allowed_push_host: https://rubygems.org
54
+ rubygems_mfa_required: 'true'
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.2'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 4.0.4
70
+ specification_version: 4
71
+ summary: Ruby and Rails SDK for sending transactional email through Mail Gazelle.
72
+ test_files: []