mailhive 0.1.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -1
- data/README.md +19 -0
- data/lib/mailhive/audiences.rb +29 -0
- data/lib/mailhive/client.rb +12 -3
- data/lib/mailhive/contacts.rb +96 -0
- data/lib/mailhive/version.rb +1 -1
- data/lib/mailhive.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: edcbe7300fa0125e510b97d0225cd3b04b134e3ffa31cb6b53c8964659600615
|
|
4
|
+
data.tar.gz: e7de31bd34f7cc7d0b2339bcdc63fdee039d02304c4b8d3554885aa159743f50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a971410f091c7727ba4e967457b781da96af928427ae27deed1e751ca58334d3c4dcd1d8fffdc412008d27be4378002fd5ff257c0eda1e40fbaafb5b1fe641c6
|
|
7
|
+
data.tar.gz: ce3e746f965c8ca30dba2eb2b7858279d78bba7d47c7e6d2a4951d2bf5ab9b686c20bcaa50054fc5768a9e65b397e9dd9f106d5aa5684b2432142710c892c61d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.2.0 (2026-10-26)
|
|
4
|
+
|
|
5
|
+
- Audiences and contacts, for API keys with Full access: `audiences.list`, `create` and `rename`; `contacts.list`, `add`, `upsert`, `upsert_batch`, `get`, `update`, `unsubscribe` and `delete`.
|
|
6
|
+
- `double_opt_in` on a contact emails the person a confirmation link; they're subscribed only if they confirm. Someone who unsubscribed or reported spam can't be re-subscribed by these calls (a `Mailhive::ConflictError` with code `contact_unsubscribed`).
|
|
7
|
+
- Requests can now carry query strings, and an empty response (`204`) is `nil` from `contacts.delete`.
|
|
8
|
+
|
|
9
|
+
## 0.1.0 (2026-09-30)
|
|
4
10
|
|
|
5
11
|
- `Mailhive::Client`, with `emails.send`, `emails.send_batch` and `emails.get`.
|
|
6
12
|
- Idempotency keys reused across retries, and `Retry-After` honoured.
|
data/README.md
CHANGED
|
@@ -78,6 +78,25 @@ Outside Rails, use it with the [mail](https://github.com/mikel/mail) gem:
|
|
|
78
78
|
mail.delivery_method Mailhive::ActionMailer::DeliveryMethod, api_key: "mhs_…"
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
## Audiences and contacts
|
|
82
|
+
|
|
83
|
+
With an API key that has **Full access**, keep an audience in step with your app or CRM:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
contact = client.contacts.upsert(audience_id, email: "ada@example.com", first_name: "Ada", fields: { plan: "pro" })
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`upsert` adds the person, or updates them if they're already there, so it's safe to repeat. Other calls: `audiences.list`, `audiences.create`, `audiences.rename`, `contacts.list` (pass `email:` to find one person), `contacts.add`, `contacts.upsert_batch` (up to 100; check each result's `ok`), `contacts.get`, `contacts.update`, `contacts.unsubscribe` and `contacts.delete`.
|
|
90
|
+
|
|
91
|
+
Someone who unsubscribed, or reported your email as spam, can't be subscribed again by your code: you get a `Mailhive::ConflictError` with code `contact_unsubscribed`. To bring them back, ask them to confirm:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
client.contacts.upsert(audience_id, email: "ada@example.com",
|
|
95
|
+
double_opt_in: { from: "Acme News <news@yourcompany.com>", subject: "Confirm your subscription" })
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
They're `pending`, and never mailed by a broadcast, until they click the link. See the [contacts API reference](https://mailhive.africa/docs/api/contacts).
|
|
99
|
+
|
|
81
100
|
## Retries and idempotency
|
|
82
101
|
|
|
83
102
|
Every send carries an `Idempotency-Key`. Retries reuse it, so **a retry never sends twice**.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailhive
|
|
4
|
+
# client.audiences: the lists your broadcasts go to. Needs an API key with
|
|
5
|
+
# Full access.
|
|
6
|
+
class Audiences
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# The organization's audiences: [{id:, name:, contacts:, mailable:, …}].
|
|
12
|
+
def list
|
|
13
|
+
@client.request("GET", "/send/audiences")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create(name, idempotency_key: nil)
|
|
17
|
+
@client.request("POST", "/send/audiences", { name: name }, idempotency_key: idempotency_key)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Changes an audience's name. Returns {id:, name:}.
|
|
21
|
+
def rename(id, name)
|
|
22
|
+
@client.request("PATCH", "/send/audiences/#{Emails.escape(id)}", { name: name })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def inspect
|
|
26
|
+
"#<#{self.class.name}>"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/mailhive/client.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Mailhive
|
|
|
26
26
|
TIMEOUT_ERRORS = [Timeout::Error, Net::WriteTimeout, Errno::ETIMEDOUT].freeze
|
|
27
27
|
private_constant :TIMEOUT_ERRORS
|
|
28
28
|
|
|
29
|
-
attr_reader :base_url, :timeout, :max_retries, :emails
|
|
29
|
+
attr_reader :base_url, :timeout, :max_retries, :emails, :audiences, :contacts
|
|
30
30
|
|
|
31
31
|
# Options:
|
|
32
32
|
# - api_key: a secret key (mhs_…); default MAILHIVE_API_KEY
|
|
@@ -52,6 +52,8 @@ module Mailhive
|
|
|
52
52
|
@transport = transport || NetHTTPTransport.new
|
|
53
53
|
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
54
54
|
@emails = Emails.new(self)
|
|
55
|
+
@audiences = Audiences.new(self)
|
|
56
|
+
@contacts = Contacts.new(self)
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
# Never shows the key.
|
|
@@ -61,7 +63,7 @@ module Mailhive
|
|
|
61
63
|
alias to_s inspect
|
|
62
64
|
|
|
63
65
|
# @api private Used by the resources.
|
|
64
|
-
def request(method, path, body = nil, idempotency_key: nil)
|
|
66
|
+
def request(method, path, body = nil, idempotency_key: nil, query: nil)
|
|
65
67
|
headers = {
|
|
66
68
|
"Authorization" => "Bearer #{@api_key}",
|
|
67
69
|
"Accept" => "application/json",
|
|
@@ -74,7 +76,7 @@ module Mailhive
|
|
|
74
76
|
end
|
|
75
77
|
# One key per call, reused on every retry: a retry never sends twice.
|
|
76
78
|
headers["Idempotency-Key"] = present(idempotency_key&.to_s) || SecureRandom.uuid if method == "POST"
|
|
77
|
-
url = "#{base_url}#{path}"
|
|
79
|
+
url = "#{base_url}#{path}#{query_string(query)}"
|
|
78
80
|
|
|
79
81
|
attempt = 0
|
|
80
82
|
loop do
|
|
@@ -112,6 +114,13 @@ module Mailhive
|
|
|
112
114
|
value.nil? || value.empty? ? nil : value
|
|
113
115
|
end
|
|
114
116
|
|
|
117
|
+
# "?email=a%2Bb%40example.com&page=2": percent-encoded, nil values left out.
|
|
118
|
+
def query_string(query)
|
|
119
|
+
pairs = (query || {}).reject { |_name, value| value.nil? }
|
|
120
|
+
.map { |name, value| "#{name}=#{Emails.escape(value)}" }
|
|
121
|
+
pairs.empty? ? "" : "?#{pairs.join("&")}"
|
|
122
|
+
end
|
|
123
|
+
|
|
115
124
|
def parse(body)
|
|
116
125
|
body.nil? || body.strip.empty? ? {} : JSON.parse(body, symbolize_names: true)
|
|
117
126
|
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailhive
|
|
4
|
+
# client.contacts: the people in an audience. Needs an API key with Full
|
|
5
|
+
# access. Keys match the API reference (symbols or strings): email,
|
|
6
|
+
# first_name, last_name, fields, status, double_opt_in.
|
|
7
|
+
#
|
|
8
|
+
# Someone who unsubscribed, or reported your email as spam, can't be made
|
|
9
|
+
# subscribed again by these calls (a Mailhive::ConflictError with code
|
|
10
|
+
# "contact_unsubscribed"). Pass double_opt_in: { from:, subject: } to email
|
|
11
|
+
# them a confirmation link instead: they're subscribed only if they click
|
|
12
|
+
# it, and until then they're "pending" and never mailed.
|
|
13
|
+
class Contacts
|
|
14
|
+
def initialize(client)
|
|
15
|
+
@client = client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A page of contacts: {items:, page:, page_size:, total:}. Pass +email+ to
|
|
19
|
+
# find one person by exact address.
|
|
20
|
+
def list(audience_id, email: nil, q: nil, status: nil, page: nil, page_size: nil)
|
|
21
|
+
query = { email: email, q: q, status: status, page: page, page_size: page_size }
|
|
22
|
+
@client.request("GET", audience_path(audience_id), nil, query: query)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Adds one person. A 409 "contact_exists" if they're already there: use #upsert.
|
|
26
|
+
#
|
|
27
|
+
# client.contacts.add(audience_id, email: "ada@example.com", first_name: "Ada")
|
|
28
|
+
def add(audience_id, contact = nil, idempotency_key: nil, **fields)
|
|
29
|
+
@client.request("POST", audience_path(audience_id), self.class.encode((contact || {}).merge(fields)),
|
|
30
|
+
idempotency_key: idempotency_key)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Adds the person, or updates them if they're already there. Safe to repeat.
|
|
34
|
+
def upsert(audience_id, contact = nil, **fields)
|
|
35
|
+
@client.request("PUT", audience_path(audience_id), self.class.encode((contact || {}).merge(fields)))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Adds or updates up to 100 people. A bad entry doesn't raise: check each
|
|
39
|
+
# result's +ok+. Returns {created:, updated:, failed:, results: […]}.
|
|
40
|
+
def upsert_batch(audience_id, contacts, idempotency_key: nil)
|
|
41
|
+
body = { contacts: contacts.map { |contact| self.class.encode(contact) } }
|
|
42
|
+
@client.request("POST", "#{audience_path(audience_id)}/batch", body, idempotency_key: idempotency_key)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A contact and the latest emails sent to them (+history+).
|
|
46
|
+
def get(id)
|
|
47
|
+
@client.request("GET", contact_path(id))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Edits names and fields (fields are replaced, not merged: use #upsert to
|
|
51
|
+
# merge), or unsubscribes with unsubscribe: true.
|
|
52
|
+
def update(id, changes = nil, **fields)
|
|
53
|
+
@client.request("PATCH", contact_path(id), self.class.encode((changes || {}).merge(fields)))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Unsubscribes them. Only they can undo it, by confirming a double opt-in email.
|
|
57
|
+
def unsubscribe(id)
|
|
58
|
+
update(id, unsubscribe: true)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Deleting someone who unsubscribed keeps their address suppressed. Returns nil.
|
|
62
|
+
def delete(id)
|
|
63
|
+
@client.request("DELETE", contact_path(id))
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def inspect
|
|
68
|
+
"#<#{self.class.name}>"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @api private The JSON body: string keys, with nil values left out (an
|
|
72
|
+
# empty string clears a name, and an empty Hash is sent as {}).
|
|
73
|
+
def self.encode(params)
|
|
74
|
+
raise ArgumentError, "A contact must be a Hash, got #{params.class}" unless params.respond_to?(:to_hash)
|
|
75
|
+
|
|
76
|
+
params.to_hash.each_with_object({}) do |(key, value), body|
|
|
77
|
+
body[key.to_s] = value.respond_to?(:to_hash) ? stringify(value.to_hash) : value unless value.nil?
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @api private
|
|
82
|
+
def self.stringify(hash)
|
|
83
|
+
hash.each_with_object({}) { |(key, value), out| out[key.to_s] = value.respond_to?(:to_hash) ? stringify(value.to_hash) : value }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def audience_path(id)
|
|
89
|
+
"/send/audiences/#{Emails.escape(id)}/contacts"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def contact_path(id)
|
|
93
|
+
"/send/contacts/#{Emails.escape(id)}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/mailhive/version.rb
CHANGED
data/lib/mailhive.rb
CHANGED
|
@@ -10,6 +10,8 @@ require_relative "mailhive/version"
|
|
|
10
10
|
require_relative "mailhive/errors"
|
|
11
11
|
require_relative "mailhive/transport"
|
|
12
12
|
require_relative "mailhive/emails"
|
|
13
|
+
require_relative "mailhive/audiences"
|
|
14
|
+
require_relative "mailhive/contacts"
|
|
13
15
|
require_relative "mailhive/client"
|
|
14
16
|
require_relative "mailhive/webhook"
|
|
15
17
|
require_relative "mailhive/action_mailer"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mailhive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Naszat
|
|
@@ -21,7 +21,9 @@ files:
|
|
|
21
21
|
- README.md
|
|
22
22
|
- lib/mailhive.rb
|
|
23
23
|
- lib/mailhive/action_mailer.rb
|
|
24
|
+
- lib/mailhive/audiences.rb
|
|
24
25
|
- lib/mailhive/client.rb
|
|
26
|
+
- lib/mailhive/contacts.rb
|
|
25
27
|
- lib/mailhive/emails.rb
|
|
26
28
|
- lib/mailhive/errors.rb
|
|
27
29
|
- lib/mailhive/railtie.rb
|