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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +172 -0
- data/lib/mailhive/action_mailer.rb +156 -0
- data/lib/mailhive/client.rb +159 -0
- data/lib/mailhive/emails.rb +68 -0
- data/lib/mailhive/errors.rb +91 -0
- data/lib/mailhive/railtie.rb +14 -0
- data/lib/mailhive/transport.rb +43 -0
- data/lib/mailhive/version.rb +5 -0
- data/lib/mailhive/webhook.rb +65 -0
- data/lib/mailhive.rb +21 -0
- metadata +57 -0
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,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: []
|