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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be565bac6c57f6bc2292b802192122794dfc98d9ac4060423b0ed51faa08510c
4
- data.tar.gz: fb4e61ecfa08f93e3f553673947f3d930cb8f61ad632155cbb4fa2e3a5466746
3
+ metadata.gz: edcbe7300fa0125e510b97d0225cd3b04b134e3ffa31cb6b53c8964659600615
4
+ data.tar.gz: e7de31bd34f7cc7d0b2339bcdc63fdee039d02304c4b8d3554885aa159743f50
5
5
  SHA512:
6
- metadata.gz: abe61a7f497e628a1031476ccb99d1dbc76df57054341f742cb7905f268fabba0a2590204c46e8b4819d806899b4f37138da482c743ed1a69e4f938cd0ac846b
7
- data.tar.gz: 19171faa17039b7778eb150fc09938191a830cfb4c53dde81533265e9c945c3f81235f0d1c43131d405c8c0b416c2bd96400c9eae037cb49d2ddf0118ae8df81
6
+ metadata.gz: a971410f091c7727ba4e967457b781da96af928427ae27deed1e751ca58334d3c4dcd1d8fffdc412008d27be4378002fd5ff257c0eda1e40fbaafb5b1fe641c6
7
+ data.tar.gz: ce3e746f965c8ca30dba2eb2b7858279d78bba7d47c7e6d2a4951d2bf5ab9b686c20bcaa50054fc5768a9e65b397e9dd9f106d5aa5684b2432142710c892c61d
data/CHANGELOG.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailhive
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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