mailhive 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: be565bac6c57f6bc2292b802192122794dfc98d9ac4060423b0ed51faa08510c
4
+ data.tar.gz: fb4e61ecfa08f93e3f553673947f3d930cb8f61ad632155cbb4fa2e3a5466746
5
+ SHA512:
6
+ metadata.gz: abe61a7f497e628a1031476ccb99d1dbc76df57054341f742cb7905f268fabba0a2590204c46e8b4819d806899b4f37138da482c743ed1a69e4f938cd0ac846b
7
+ data.tar.gz: 19171faa17039b7778eb150fc09938191a830cfb4c53dde81533265e9c945c3f81235f0d1c43131d405c8c0b416c2bd96400c9eae037cb49d2ddf0118ae8df81
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - `Mailhive::Client`, with `emails.send`, `emails.send_batch` and `emails.get`.
6
+ - Idempotency keys reused across retries, and `Retry-After` honoured.
7
+ - Typed errors that carry the request id.
8
+ - `Mailhive::Webhook.verify`, with secret rotation.
9
+ - The ActionMailer delivery method (`:mailhive`), registered by a Railtie.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Naszat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # mailhive
2
+
3
+ The official Ruby SDK for [Mailhive Send](https://mailhive.africa/docs/send/overview). It supports Ruby 3.0+, has no runtime dependencies, and includes an **ActionMailer** delivery method for Rails.
4
+
5
+ ```sh
6
+ bundle add mailhive
7
+ # or: gem install mailhive
8
+ ```
9
+
10
+ ## Send an email
11
+
12
+ ```ruby
13
+ require "mailhive"
14
+
15
+ client = Mailhive::Client.new # reads MAILHIVE_API_KEY
16
+
17
+ email = client.emails.send(
18
+ from: "Acme <hello@acme.com>",
19
+ to: "ada@example.com",
20
+ subject: "Your receipt",
21
+ html: "<p>Thanks for your order.</p>"
22
+ )
23
+ puts email[:id]
24
+ ```
25
+
26
+ Pass the fields as keyword arguments or as a Hash (symbol or string keys). They match the [API reference](https://mailhive.africa/docs/api/send-email) exactly: `from`, `to`, `cc`, `bcc`, `subject`, `html`, `text`, `template_id`, `variables`, `reply_to`, `headers`, `tags` and `attachments`.
27
+
28
+ Responses are Hashes with **symbol keys**, parsed from the API's JSON: `email[:id]`, `email[:status]`.
29
+
30
+ An attachment's `content` is the raw file, which is encoded for you. Use `content_base64` if your data is already base64:
31
+
32
+ ```ruby
33
+ client.emails.send(
34
+ from: "hello@acme.com",
35
+ to: "ada@example.com",
36
+ subject: "Your invoice",
37
+ text: "Attached.",
38
+ attachments: [
39
+ { filename: "invoice.pdf", content: File.binread("invoice.pdf"), content_type: "application/pdf" }
40
+ ]
41
+ )
42
+ ```
43
+
44
+ ```ruby
45
+ client.emails.send_batch([email1, email2]) # up to 100; all accepted or none. Returns the accepted emails.
46
+ client.emails.get(email_id) # {status: "delivered", …}
47
+ ```
48
+
49
+ `emails.send` sends an email: on that object, use `__send__` or `public_send` for Ruby's dynamic dispatch.
50
+
51
+ ## Rails (ActionMailer)
52
+
53
+ The gem's Railtie registers a `:mailhive` delivery method:
54
+
55
+ ```ruby
56
+ # config/environments/production.rb
57
+ config.action_mailer.delivery_method = :mailhive
58
+ config.action_mailer.mailhive_settings = { api_key: ENV["MAILHIVE_API_KEY"] } # or leave the key in the environment
59
+ ```
60
+
61
+ Every mailer then sends through Mailhive: HTML and text parts, attachments, cc, bcc, Reply-To, and your own `X-` headers (plus `In-Reply-To`, `References` and `List-Unsubscribe`). Two headers control Mailhive features and aren't sent on:
62
+
63
+ ```ruby
64
+ class ReceiptMailer < ApplicationMailer
65
+ def receipt(order)
66
+ headers["X-Mailhive-Idempotency-Key"] = "order-#{order.id}-receipt" # safe to repeat
67
+ headers["X-Mailhive-Tags"] = { type: "receipt" }.to_json # tags, returned in webhook events
68
+ mail(to: order.email, subject: "Your receipt")
69
+ end
70
+ end
71
+ ```
72
+
73
+ After delivery, the message's `X-Mailhive-Email-Id` header holds the email's id. `mailhive_settings` also takes `base_url`, `timeout` and `max_retries`.
74
+
75
+ Outside Rails, use it with the [mail](https://github.com/mikel/mail) gem:
76
+
77
+ ```ruby
78
+ mail.delivery_method Mailhive::ActionMailer::DeliveryMethod, api_key: "mhs_…"
79
+ ```
80
+
81
+ ## Retries and idempotency
82
+
83
+ Every send carries an `Idempotency-Key`. Retries reuse it, so **a retry never sends twice**.
84
+
85
+ - **Retried** (up to 2 times, then configurable): network errors, timeouts, 5xx responses, and `429 rate_limited` after the `Retry-After` delay.
86
+ - **Not retried:** a used-up allowance (`monthly_quota_reached`, `daily_cap_reached`) and invalid requests.
87
+
88
+ To stay safe across restarts, pass your own key:
89
+
90
+ ```ruby
91
+ client.emails.send(email, idempotency_key: "order-#{order.id}-receipt")
92
+ ```
93
+
94
+ ## Errors
95
+
96
+ ```ruby
97
+ begin
98
+ client.emails.send(email)
99
+ rescue Mailhive::ValidationError => e
100
+ puts e.code, e.message, e.details, e.request_id
101
+ rescue Mailhive::RateLimitError => e
102
+ # e.code == "monthly_quota_reached", or wait e.retry_after seconds
103
+ end
104
+ ```
105
+
106
+ | Error | Status |
107
+ |---|---|
108
+ | `Mailhive::AuthenticationError` | 401 |
109
+ | `Mailhive::BillingError` | 402 |
110
+ | `Mailhive::PermissionError` | 403 |
111
+ | `Mailhive::NotFoundError` | 404 |
112
+ | `Mailhive::ConflictError` | 409 (e.g. `idempotency_conflict`) |
113
+ | `Mailhive::ValidationError` | 400, 422 |
114
+ | `Mailhive::RateLimitError` | 429 (`retry_after` in seconds) |
115
+ | `Mailhive::APIError` | other statuses, and the base class of all of the above |
116
+ | `Mailhive::ConnectionError` | no response |
117
+
118
+ API errors carry `status`, `code`, `message`, `details`, `request_id` and `headers`. Everything the SDK raises is a `Mailhive::Error`.
119
+
120
+ ## Webhooks
121
+
122
+ Pass the **raw** body, exactly as received. `verify` returns the event as a Hash with symbol keys:
123
+
124
+ ```ruby
125
+ class MailhiveWebhooksController < ActionController::API
126
+ def create
127
+ event = Mailhive::Webhook.verify(
128
+ request.raw_post,
129
+ request.headers["Mailhive-Signature"],
130
+ ENV.fetch("MAILHIVE_WEBHOOK_SECRET")
131
+ )
132
+ case event[:type]
133
+ when "email.bounced" then # …
134
+ end
135
+ head :ok
136
+ rescue Mailhive::WebhookVerificationError => e
137
+ head :bad_request # e.reason is "header", "timestamp" or "signature"
138
+ end
139
+ end
140
+ ```
141
+
142
+ Signatures older than 300 seconds are refused (`tolerance:` changes that). Several `v1=` values are accepted, so secrets can be rotated.
143
+
144
+ ## Options
145
+
146
+ ```ruby
147
+ Mailhive::Client.new(
148
+ api_key: "mhs_…", # default: MAILHIVE_API_KEY
149
+ base_url: "https://api-beta.mailhive.africa/v1", # default: MAILHIVE_BASE_URL, then production
150
+ timeout: 30, # seconds
151
+ max_retries: 2
152
+ )
153
+ ```
154
+
155
+ The key is never included in `inspect` or logs.
156
+
157
+ **Test keys** (`mhs_test_…`) work unchanged: delivery is simulated and nothing is billed. [More about test keys](https://mailhive.africa/docs/send/api-keys#test-keys).
158
+
159
+ ## Development
160
+
161
+ ```sh
162
+ bundle config set --local path vendor/bundle
163
+ bundle install
164
+ bundle exec rake test # needs node on PATH for the shared mock server
165
+ ```
166
+
167
+ This gem lives in the [mailhive-sdks](https://github.com/Naszat/mailhive-sdks) monorepo. Please open issues and pull requests there.
168
+
169
+ ## Releasing (maintainers)
170
+
171
+ 1. Bump `Mailhive::VERSION` and the changelog, then merge to `main`.
172
+ 2. Push the tag `ruby-vX.Y.Z`. The `release-ruby` workflow publishes the gem to RubyGems through trusted publishing.
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailhive
4
+ # Sending ActionMailer (and plain Mail gem) messages through Mailhive Send.
5
+ #
6
+ # # config/environments/production.rb
7
+ # config.action_mailer.delivery_method = :mailhive
8
+ # config.action_mailer.mailhive_settings = { api_key: ENV["MAILHIVE_API_KEY"] }
9
+ #
10
+ # The Railtie registers the :mailhive delivery method. Outside Rails, call
11
+ # Mailhive::ActionMailer.install, or use the class directly with the Mail
12
+ # gem: +mail.delivery_method Mailhive::ActionMailer::DeliveryMethod, api_key: "mhs_…"+.
13
+ #
14
+ # This file never loads the mail gem: it works on whatever Mail::Message
15
+ # it's handed.
16
+ module ActionMailer
17
+ IDEMPOTENCY_HEADER = "X-Mailhive-Idempotency-Key"
18
+ TAGS_HEADER = "X-Mailhive-Tags"
19
+ EMAIL_ID_HEADER = "X-Mailhive-Email-Id"
20
+
21
+ # Non-X- headers the API accepts.
22
+ PASSED_HEADERS = %w[in-reply-to references list-unsubscribe list-unsubscribe-post].freeze
23
+
24
+ # Registers :mailhive on ActionMailer::Base. Safe to call twice.
25
+ def self.install(base = ::ActionMailer::Base)
26
+ base.add_delivery_method(:mailhive, DeliveryMethod) unless base.delivery_methods.key?(:mailhive)
27
+ end
28
+
29
+ # The API request for one Mail::Message.
30
+ def self.params(mail)
31
+ params = {
32
+ "from" => addresses(mail, :from).first,
33
+ "to" => recipients(mail),
34
+ "subject" => mail.subject.to_s
35
+ }
36
+ { "cc" => :cc, "bcc" => :bcc, "reply_to" => :reply_to }.each do |key, field|
37
+ list = addresses(mail, field)
38
+ params[key] = list unless list.empty?
39
+ end
40
+ params.merge!(bodies(mail))
41
+
42
+ headers = {}
43
+ mail.header.fields.each do |field|
44
+ name = field.name.to_s
45
+ lower = name.downcase
46
+ next if lower.start_with?("x-mailhive-")
47
+ next unless lower.start_with?("x-") || PASSED_HEADERS.include?(lower)
48
+
49
+ headers[name] = field.decoded.to_s
50
+ end
51
+ params["headers"] = headers unless headers.empty?
52
+
53
+ tags = tags(mail)
54
+ params["tags"] = tags if tags
55
+
56
+ attachments = mail.attachments.map do |part|
57
+ {
58
+ "filename" => part.filename || "attachment",
59
+ "content" => part.decoded,
60
+ "content_type" => part.mime_type || "application/octet-stream"
61
+ }
62
+ end
63
+ params["attachments"] = attachments unless attachments.empty?
64
+ params
65
+ end
66
+
67
+ def self.header_value(mail, name)
68
+ field = mail.header[name]
69
+ field = field.last if field.is_a?(Array)
70
+ value = field&.decoded.to_s.strip
71
+ value.nil? || value.empty? ? nil : value
72
+ end
73
+
74
+ def self.addresses(mail, name)
75
+ field = mail[name]
76
+ return [] if field.nil?
77
+
78
+ Array(field.respond_to?(:formatted) ? field.formatted : field.decoded).compact.map(&:to_s)
79
+ rescue StandardError
80
+ Array(mail.public_send(name)).compact.map(&:to_s)
81
+ end
82
+
83
+ # To, or the envelope's recipients (less cc and bcc) when there's no To.
84
+ def self.recipients(mail)
85
+ to = addresses(mail, :to)
86
+ return to unless to.empty?
87
+
88
+ envelope = Array(mail.smtp_envelope_to)
89
+ rest = envelope - Array(mail.cc) - Array(mail.bcc)
90
+ rest.empty? ? envelope : rest
91
+ end
92
+
93
+ def self.bodies(mail)
94
+ if mail.multipart?
95
+ result = {}
96
+ result["html"] = mail.html_part.decoded if mail.html_part
97
+ result["text"] = mail.text_part.decoded if mail.text_part
98
+ result
99
+ elsif mail.body.to_s.empty?
100
+ {}
101
+ elsif mail.mime_type == "text/html"
102
+ { "html" => mail.decoded }
103
+ else
104
+ { "text" => mail.decoded }
105
+ end
106
+ end
107
+
108
+ def self.tags(mail)
109
+ raw = header_value(mail, TAGS_HEADER)
110
+ return nil if raw.nil?
111
+
112
+ tags = begin
113
+ JSON.parse(raw)
114
+ rescue JSON::ParserError
115
+ nil
116
+ end
117
+ raise Error, "#{TAGS_HEADER} must be a JSON object, like {\"type\":\"receipt\"}." unless tags.is_a?(Hash)
118
+
119
+ tags.to_h { |key, value| [key.to_s, value.to_s] }
120
+ end
121
+ private_class_method :addresses, :recipients, :bodies, :tags
122
+
123
+ # An ActionMailer / Mail delivery method. ActionMailer builds one per
124
+ # message, from config.action_mailer.mailhive_settings: api_key,
125
+ # base_url, timeout, max_retries (or client: a Mailhive::Client).
126
+ # Without an api_key, MAILHIVE_API_KEY is used.
127
+ class DeliveryMethod
128
+ attr_accessor :settings
129
+
130
+ def initialize(settings = {})
131
+ @settings = (settings || {}).to_h.transform_keys(&:to_sym)
132
+ end
133
+
134
+ # Sends the message, sets its X-Mailhive-Email-Id header to the
135
+ # email's id, and returns the accepted email.
136
+ def deliver!(mail)
137
+ accepted = client.emails.send(
138
+ ActionMailer.params(mail),
139
+ idempotency_key: ActionMailer.header_value(mail, IDEMPOTENCY_HEADER)
140
+ )
141
+ mail[EMAIL_ID_HEADER] = accepted[:id]
142
+ accepted
143
+ end
144
+
145
+ def client
146
+ @client ||= settings[:client] || Client.new(
147
+ **settings.slice(:api_key, :base_url, :timeout, :max_retries, :transport, :sleeper)
148
+ )
149
+ end
150
+
151
+ def inspect
152
+ "#<#{self.class.name}>"
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailhive
4
+ # The Mailhive Send API client.
5
+ #
6
+ # client = Mailhive::Client.new # reads MAILHIVE_API_KEY
7
+ # client.emails.send(from: "Acme <hello@acme.com>", to: "ada@example.com", subject: "Hi", text: "Hello")
8
+ #
9
+ # Responses are Hashes with symbol keys, parsed from the API's JSON.
10
+ class Client
11
+ DEFAULT_BASE_URL = "https://api.mailhive.africa/v1"
12
+ MAX_RETRY_AFTER = 60.0
13
+
14
+ # Errors that mean no response came back: retried, then raised as
15
+ # Mailhive::ConnectionError.
16
+ NETWORK_ERRORS = [
17
+ Timeout::Error, # includes Net::OpenTimeout and Net::ReadTimeout
18
+ Net::WriteTimeout,
19
+ IOError, # includes EOFError
20
+ SocketError,
21
+ SystemCallError, # Errno::ECONNREFUSED, ECONNRESET, EPIPE, ETIMEDOUT, …
22
+ OpenSSL::SSL::SSLError,
23
+ Net::HTTPBadResponse
24
+ ].freeze
25
+
26
+ TIMEOUT_ERRORS = [Timeout::Error, Net::WriteTimeout, Errno::ETIMEDOUT].freeze
27
+ private_constant :TIMEOUT_ERRORS
28
+
29
+ attr_reader :base_url, :timeout, :max_retries, :emails
30
+
31
+ # Options:
32
+ # - api_key: a secret key (mhs_…); default MAILHIVE_API_KEY
33
+ # - base_url: default MAILHIVE_BASE_URL, then production
34
+ # - timeout: seconds per request (connect, write and read)
35
+ # - max_retries: retries after the first attempt
36
+ # - transport, sleeper: for tests (see NetHTTPTransport; sleeper gets seconds)
37
+ def initialize(api_key: nil, base_url: nil, timeout: 30, max_retries: 2, transport: nil, sleeper: nil)
38
+ key = present(api_key) || present(ENV.fetch("MAILHIVE_API_KEY", nil))
39
+ if key.nil?
40
+ raise Error, "No API key. Pass one to Mailhive::Client.new(api_key: …) or set MAILHIVE_API_KEY. " \
41
+ "Create keys under Mailhive Send → API keys."
42
+ end
43
+ if key.start_with?("mhp_")
44
+ raise Error, "That's a form's publishable key (mhp_…). The server SDK needs a secret API key (mhs_…) " \
45
+ "from Mailhive Send → API keys."
46
+ end
47
+
48
+ @api_key = key
49
+ @base_url = (present(base_url) || present(ENV.fetch("MAILHIVE_BASE_URL", nil)) || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
50
+ @timeout = timeout
51
+ @max_retries = [max_retries.to_i, 0].max
52
+ @transport = transport || NetHTTPTransport.new
53
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
54
+ @emails = Emails.new(self)
55
+ end
56
+
57
+ # Never shows the key.
58
+ def inspect
59
+ "#<#{self.class.name} base_url=#{base_url.inspect}>"
60
+ end
61
+ alias to_s inspect
62
+
63
+ # @api private Used by the resources.
64
+ def request(method, path, body = nil, idempotency_key: nil)
65
+ headers = {
66
+ "Authorization" => "Bearer #{@api_key}",
67
+ "Accept" => "application/json",
68
+ "User-Agent" => "mailhive-ruby/#{VERSION} ruby/#{RUBY_VERSION}"
69
+ }
70
+ payload = nil
71
+ unless body.nil?
72
+ headers["Content-Type"] = "application/json"
73
+ payload = JSON.generate(body)
74
+ end
75
+ # One key per call, reused on every retry: a retry never sends twice.
76
+ headers["Idempotency-Key"] = present(idempotency_key&.to_s) || SecureRandom.uuid if method == "POST"
77
+ url = "#{base_url}#{path}"
78
+
79
+ attempt = 0
80
+ loop do
81
+ begin
82
+ response = @transport.call(method, url, headers, payload, timeout)
83
+ rescue *NETWORK_ERRORS => e
84
+ if attempt < max_retries
85
+ @sleeper.call(self.class.backoff(attempt))
86
+ attempt += 1
87
+ next
88
+ end
89
+ raise ConnectionError, connection_message(e)
90
+ end
91
+
92
+ return parse(response.body) if response.status.between?(200, 299)
93
+
94
+ error = build_error(response)
95
+ wait = wait_before_retry(error, attempt)
96
+ raise error if wait.nil?
97
+
98
+ @sleeper.call(wait)
99
+ attempt += 1
100
+ end
101
+ end
102
+
103
+ # About 0.5s, 1s, 2s … up to 8s, with jitter.
104
+ def self.backoff(attempt)
105
+ ceiling = [8.0, 0.5 * (2**attempt)].min
106
+ (ceiling / 2) + (rand * ceiling / 2)
107
+ end
108
+
109
+ private
110
+
111
+ def present(value)
112
+ value.nil? || value.empty? ? nil : value
113
+ end
114
+
115
+ def parse(body)
116
+ body.nil? || body.strip.empty? ? {} : JSON.parse(body, symbolize_names: true)
117
+ end
118
+
119
+ # Seconds to wait before retrying, or nil to give up. Only a rate limit
120
+ # and server errors are worth retrying: a used-up allowance or a bad
121
+ # request fails the same way again.
122
+ def wait_before_retry(error, attempt)
123
+ return nil if attempt >= max_retries
124
+ return nil unless error.status >= 500 || (error.status == 429 && error.code == "rate_limited")
125
+
126
+ wait = Mailhive.parse_seconds(error.headers["retry-after"])
127
+ return nil if wait && wait > MAX_RETRY_AFTER
128
+
129
+ wait || self.class.backoff(attempt)
130
+ end
131
+
132
+ def build_error(response)
133
+ headers = response.headers || {}
134
+ error = begin
135
+ decoded = JSON.parse(response.body.to_s, symbolize_names: true)
136
+ decoded.is_a?(Hash) && decoded[:error].is_a?(Hash) ? decoded[:error] : {}
137
+ rescue JSON::ParserError
138
+ {}
139
+ end
140
+ Mailhive.error_for(
141
+ response.status,
142
+ error[:message].is_a?(String) ? error[:message] : "HTTP #{response.status}",
143
+ code: error[:code].is_a?(String) ? error[:code] : "http_error",
144
+ details: error[:details],
145
+ request_id: error[:request_id] || headers["x-request-id"],
146
+ headers: headers
147
+ )
148
+ end
149
+
150
+ def connection_message(error)
151
+ if TIMEOUT_ERRORS.any? { |klass| error.is_a?(klass) }
152
+ seconds = timeout.to_f == timeout.to_i ? timeout.to_i : timeout.to_f
153
+ "The Mailhive API didn't answer within #{seconds} seconds."
154
+ else
155
+ "Couldn't reach the Mailhive API at #{base_url}."
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailhive
4
+ # client.emails. Note that #send here sends an email: use #__send__ or
5
+ # #public_send for Ruby's dynamic dispatch on this object.
6
+ class Emails
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Sends one email and returns the accepted email: {id:, status:, suppressed:, test:}.
12
+ #
13
+ # Keys match the API reference exactly (symbols or strings): from, to,
14
+ # cc, bcc, subject, html, text, template_id, variables, reply_to,
15
+ # headers, tags, attachments. An attachment's +content+ is the raw file
16
+ # (encoded for you); use +content_base64+ for data that's already base64.
17
+ #
18
+ # client.emails.send({ from: "hello@acme.com", to: "ada@example.com", subject: "Hi", text: "Hello" })
19
+ # client.emails.send(from: "hello@acme.com", to: "ada@example.com", subject: "Hi", text: "Hello")
20
+ def send(params = nil, idempotency_key: nil, **fields)
21
+ @client.request("POST", "/send/emails", self.class.encode((params || {}).merge(fields)), idempotency_key: idempotency_key)
22
+ end
23
+
24
+ # Up to 100 independent emails; all are accepted, or none. Returns the
25
+ # list of accepted emails.
26
+ def send_batch(emails, idempotency_key: nil)
27
+ body = { emails: emails.map { |email| self.class.encode(email) } }
28
+ @client.request("POST", "/send/emails/batch", body, idempotency_key: idempotency_key)[:data]
29
+ end
30
+
31
+ # One email, with its status ("delivered", "bounced", …).
32
+ def get(id)
33
+ @client.request("GET", "/send/emails/#{self.class.escape(id)}")
34
+ end
35
+
36
+ def inspect
37
+ "#<#{self.class.name}>"
38
+ end
39
+
40
+ # @api private The JSON body for one email.
41
+ def self.encode(params)
42
+ raise ArgumentError, "An email must be a Hash, got #{params.class}" unless params.respond_to?(:to_hash)
43
+
44
+ email = {}
45
+ params.to_hash.each { |key, value| email[key.to_s] = value unless value.nil? }
46
+ attachments = email["attachments"]
47
+ email["attachments"] = attachments.map { |attachment| encode_attachment(attachment) } if attachments
48
+ email
49
+ end
50
+
51
+ # @api private
52
+ def self.encode_attachment(attachment)
53
+ encoded = {}
54
+ attachment.to_hash.each { |key, value| encoded[key.to_s] = value }
55
+ if encoded.key?("content_base64")
56
+ encoded["content"] = encoded.delete("content_base64").to_s
57
+ else
58
+ encoded["content"] = [encoded["content"].to_s].pack("m0")
59
+ end
60
+ encoded
61
+ end
62
+
63
+ # @api private Percent-encodes a path segment.
64
+ def self.escape(value)
65
+ value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |char| format("%%%02X", char.ord) }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailhive
4
+ # Base class for everything this SDK raises.
5
+ class Error < StandardError; end
6
+
7
+ # An error response from the API. Check #code, not #message: codes are
8
+ # stable, messages may change.
9
+ class APIError < Error
10
+ attr_reader :status, :code, :details, :request_id, :headers
11
+
12
+ def initialize(message = nil, status: nil, code: nil, details: nil, request_id: nil, headers: nil)
13
+ super(message)
14
+ @status = status
15
+ @code = code
16
+ @details = details
17
+ @request_id = request_id
18
+ @headers = headers || {}
19
+ end
20
+
21
+ def inspect
22
+ "#<#{self.class.name} status=#{status.inspect} code=#{code.inspect} request_id=#{request_id.inspect}>"
23
+ end
24
+ end
25
+
26
+ # 401: missing, unknown or revoked API key.
27
+ class AuthenticationError < APIError; end
28
+
29
+ # 402: Mailhive Send is paused over an unpaid invoice.
30
+ class BillingError < APIError; end
31
+
32
+ # 403: Send isn't activated, or the stream is paused.
33
+ class PermissionError < APIError; end
34
+
35
+ # 404
36
+ class NotFoundError < APIError; end
37
+
38
+ # 409: e.g. an Idempotency-Key reused for a different request.
39
+ class ConflictError < APIError; end
40
+
41
+ # 400 or 422: the request is invalid; #details lists every problem.
42
+ class ValidationError < APIError; end
43
+
44
+ # 429: rate_limited (retried for you), or monthly_quota_reached /
45
+ # daily_cap_reached (not retried: waiting won't help).
46
+ class RateLimitError < APIError
47
+ # Seconds from the Retry-After header, or nil.
48
+ def retry_after
49
+ Mailhive.parse_seconds(headers["retry-after"])
50
+ end
51
+ end
52
+
53
+ # The API couldn't be reached, or didn't answer in time.
54
+ class ConnectionError < Error; end
55
+
56
+ # A webhook's signature didn't check out. #reason is "header", "timestamp"
57
+ # or "signature".
58
+ class WebhookVerificationError < Error
59
+ attr_reader :reason
60
+
61
+ def initialize(message, reason)
62
+ super(message)
63
+ @reason = reason
64
+ end
65
+ end
66
+
67
+ ERRORS_BY_STATUS = {
68
+ 400 => ValidationError,
69
+ 401 => AuthenticationError,
70
+ 402 => BillingError,
71
+ 403 => PermissionError,
72
+ 404 => NotFoundError,
73
+ 409 => ConflictError,
74
+ 422 => ValidationError,
75
+ 429 => RateLimitError
76
+ }.freeze
77
+ private_constant :ERRORS_BY_STATUS
78
+
79
+ # @api private
80
+ def self.error_for(status, message, **fields)
81
+ ERRORS_BY_STATUS.fetch(status, APIError).new(message, status: status, **fields)
82
+ end
83
+
84
+ # @api private A non-negative number of seconds, or nil.
85
+ def self.parse_seconds(value)
86
+ return nil if value.nil? || value.to_s.strip.empty?
87
+
88
+ seconds = Float(value.to_s.strip, exception: false)
89
+ seconds if seconds&.finite? && seconds >= 0
90
+ end
91
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailhive
4
+ # Registers the :mailhive ActionMailer delivery method. Settings come from
5
+ # config.action_mailer.mailhive_settings = { api_key: …, base_url: … },
6
+ # falling back to MAILHIVE_API_KEY and MAILHIVE_BASE_URL.
7
+ class Railtie < ::Rails::Railtie
8
+ # Before ActionMailer applies config.action_mailer.*, so that
9
+ # mailhive_settings= exists by then.
10
+ initializer "mailhive.action_mailer", before: "action_mailer.set_configs" do
11
+ ActiveSupport.on_load(:action_mailer) { Mailhive::ActionMailer.install(self) }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailhive
4
+ # What a transport returns. +headers+ has lowercase names.
5
+ Response = Struct.new(:status, :headers, :body)
6
+
7
+ # The default transport, on Net::HTTP. A transport is anything with
8
+ # +call(method, url, headers, body, timeout)+ returning a Mailhive::Response,
9
+ # and raising the usual Net::HTTP / socket errors when there's no response.
10
+ class NetHTTPTransport
11
+ def call(method, url, headers, body, timeout)
12
+ uri = URI.parse(url)
13
+ http = Net::HTTP.new(uri.host, uri.port)
14
+ http.use_ssl = uri.scheme == "https"
15
+ http.open_timeout = timeout
16
+ http.read_timeout = timeout
17
+ http.write_timeout = timeout
18
+ # The client does its own retries, reusing the idempotency key.
19
+ http.max_retries = 0
20
+
21
+ request = request_class(method).new(uri.request_uri, headers)
22
+ request.body = body if body
23
+ response = http.start { |connection| connection.request(request) }
24
+
25
+ response_headers = {}
26
+ response.each_header { |name, value| response_headers[name.downcase] = value }
27
+ Response.new(response.code.to_i, response_headers, response.body.to_s.dup.force_encoding(Encoding::UTF_8))
28
+ end
29
+
30
+ private
31
+
32
+ def request_class(method)
33
+ case method
34
+ when "GET" then Net::HTTP::Get
35
+ when "POST" then Net::HTTP::Post
36
+ when "DELETE" then Net::HTTP::Delete
37
+ when "PATCH" then Net::HTTP::Patch
38
+ when "PUT" then Net::HTTP::Put
39
+ else raise ArgumentError, "Unsupported HTTP method #{method}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailhive
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailhive
4
+ # Checking Mailhive-Signature on webhooks.
5
+ module Webhook
6
+ V1 = /\A[0-9a-fA-F]{64}\z/.freeze
7
+ private_constant :V1
8
+
9
+ # Checks a webhook's Mailhive-Signature header (HMAC-SHA256 of
10
+ # "<t>.<raw body>" with the endpoint's secret) and returns the event, a
11
+ # Hash with symbol keys. +payload+ must be the raw body exactly as
12
+ # received (in Rails, request.raw_post): parsing and re-serializing it
13
+ # changes the bytes. Several v1= values are accepted, so secrets can be
14
+ # rotated. Raises Mailhive::WebhookVerificationError.
15
+ def self.verify(payload, signature_header, secret, tolerance: 300, now: nil)
16
+ unless payload.is_a?(String)
17
+ raise TypeError, "Mailhive::Webhook.verify needs the raw request body (a String), not #{payload.class}: " \
18
+ "re-serializing changes the bytes, so the signature can't match."
19
+ end
20
+
21
+ timestamp, candidates = parse(signature_header)
22
+ raise WebhookVerificationError.new("Missing or malformed Mailhive-Signature header.", "header") if timestamp.nil?
23
+
24
+ current = now.nil? ? Time.now.to_f : now.to_f
25
+ if (current - timestamp).abs > tolerance
26
+ raise WebhookVerificationError.new(
27
+ "The webhook's timestamp is more than #{tolerance} seconds from now; it may be a replay.", "timestamp"
28
+ )
29
+ end
30
+
31
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, "#{timestamp}.".b + payload.b)
32
+ unless candidates.any? { |candidate| OpenSSL.fixed_length_secure_compare(expected, candidate) }
33
+ raise WebhookVerificationError.new(
34
+ "The webhook's signature doesn't match. Check the endpoint's signing secret.", "signature"
35
+ )
36
+ end
37
+
38
+ JSON.parse(payload, symbolize_names: true)
39
+ end
40
+
41
+ # [timestamp, [v1, …]], or nil when either is missing.
42
+ def self.parse(header)
43
+ return nil if header.nil?
44
+
45
+ timestamp = nil
46
+ candidates = []
47
+ header.to_s.split(",").each do |part|
48
+ key, separator, value = part.partition("=")
49
+ next if separator.empty?
50
+
51
+ key = key.strip
52
+ value = value.strip
53
+ if key == "t" && value.match?(/\A\d+\z/)
54
+ timestamp = value.to_i
55
+ elsif key == "v1" && value.match?(V1)
56
+ candidates << value.downcase
57
+ end
58
+ end
59
+ return nil if timestamp.nil? || candidates.empty?
60
+
61
+ [timestamp, candidates]
62
+ end
63
+ private_class_method :parse
64
+ end
65
+ end
data/lib/mailhive.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "uri"
8
+
9
+ require_relative "mailhive/version"
10
+ require_relative "mailhive/errors"
11
+ require_relative "mailhive/transport"
12
+ require_relative "mailhive/emails"
13
+ require_relative "mailhive/client"
14
+ require_relative "mailhive/webhook"
15
+ require_relative "mailhive/action_mailer"
16
+ require_relative "mailhive/railtie" if defined?(Rails::Railtie)
17
+
18
+ # The official Ruby SDK for Mailhive Send: Mailhive::Client, Mailhive::Webhook
19
+ # and an ActionMailer delivery method.
20
+ module Mailhive
21
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mailhive
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Naszat
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Send transactional email through the Mailhive Send API from Ruby and
13
+ Rails: safe retries with idempotency keys, typed errors, webhook verification and
14
+ an ActionMailer delivery method. No runtime dependencies.'
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE
21
+ - README.md
22
+ - lib/mailhive.rb
23
+ - lib/mailhive/action_mailer.rb
24
+ - lib/mailhive/client.rb
25
+ - lib/mailhive/emails.rb
26
+ - lib/mailhive/errors.rb
27
+ - lib/mailhive/railtie.rb
28
+ - lib/mailhive/transport.rb
29
+ - lib/mailhive/version.rb
30
+ - lib/mailhive/webhook.rb
31
+ homepage: https://mailhive.africa/docs/sdks/ruby
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ homepage_uri: https://mailhive.africa/docs/sdks/ruby
36
+ source_code_uri: https://github.com/Naszat/mailhive-sdks/tree/main/ruby
37
+ changelog_uri: https://github.com/Naszat/mailhive-sdks/blob/main/ruby/CHANGELOG.md
38
+ bug_tracker_uri: https://github.com/Naszat/mailhive-sdks/issues
39
+ rubygems_mfa_required: 'true'
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 3.6.9
55
+ specification_version: 4
56
+ summary: The official Ruby SDK for Mailhive Send, with an ActionMailer delivery method.
57
+ test_files: []