quolle 1.1.0 → 1.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: a0658c46b0cbf87fde8e453e3c79da2444565b8a97360807ae24d7865125e57b
4
- data.tar.gz: ca10637ae139bfe029c0b98608e0b3edde03a0f0f3b9a54cbd75ba2c7201a6a4
3
+ metadata.gz: d49a558cd0e8d55d4a043225faf03055ab2d71b645b0d1fb9480acb5931addc0
4
+ data.tar.gz: 57eaff3fe141479fe6f99e5a84dc226957efb2dc3145f65f0666e9e9b5c8d556
5
5
  SHA512:
6
- metadata.gz: 46130d471c82ef7e8e1fc6f4a940d8a6a8937f039309d4fb75e45422923c7be48edc61953ba6fba44aa0a8d00db4b427747d0f4c43fa2a5e93b092606e1f4ad9
7
- data.tar.gz: ee558f680e6311a1eb05b56a611ca493c0bd24cbe57828ae1119e11b51dbb30b6bd9c4bc1dbffb8e613ed7cc66a5bb0f69ca71a55e61201695e321058c68e332
6
+ metadata.gz: aa4abf51a8b949560722c0fb9e7f4ad55653a6a16fac85f56c7a427ab0fe34ce0a52bad869c48274c76b959287734f48aef984a7134727854e931b9e8f4c1f5f
7
+ data.tar.gz: f04d7e24d159145ef67a7dbcc5c4675637d562dcd62f7eb509428837b1715e5ec735d3f5673d01bed8a531e80fa26310c07895488e75d37c328591da5570d457
data/README.md CHANGED
@@ -171,6 +171,29 @@ end
171
171
 
172
172
  HMAC-SHA256 with a 5-minute timestamp window (replay protection).
173
173
 
174
+ ## Domains, API keys and webhooks
175
+
176
+ These need a **full-access** API key. A sending-only key gets `403` with code `restricted_api_key`, so a key you ship in an app can't create keys or change your domains.
177
+
178
+ ```ruby
179
+ # Domains
180
+ created = quolle.domains.create("mail.example.com") # created["dnsRecords"] to publish
181
+ quolle.domains.verify(created["domain"]["id"]) # check DNS now
182
+ quolle.domains.list.each { |d| puts "#{d["domain"]} #{d["status"]}" }
183
+
184
+ # API keys
185
+ new_key = quolle.api_keys.create(name: "web app", scope: "sending", domains: ["mail.example.com"])
186
+ puts new_key["key"] # shown once
187
+ quolle.api_keys.remove(new_key["id"])
188
+
189
+ # Webhook endpoints
190
+ hook = quolle.webhooks.create(url: "https://example.com/hooks/quolle", events: %w[email.delivered email.bounced])
191
+ # hook["secret"] (whsec_…) is shown once; store it for quolle.webhooks.verify(...)
192
+ quolle.webhooks.test(hook["id"])
193
+ quolle.webhooks.deliveries(hook["id"], status: "failed").each { |d| quolle.webhooks.redeliver(hook["id"], d["id"]) }
194
+ new_secret = quolle.webhooks.rotate_secret(hook["id"])["secret"]
195
+ ```
196
+
174
197
  ## Automatic retries
175
198
 
176
199
  Transient failures — HTTP 429 (rate limit) and 5xx, plus network errors — are retried automatically with exponential backoff, honoring the `Retry-After` header. To avoid double-sending, a POST is only retried on a 5xx/network error when you pass an idempotency key; a 429 is always safe to retry (the request was never processed). Quota limits are the exception: `daily_quota_exceeded` and `warmup_limit_exceeded` (and any `Retry-After` over 60 seconds) are raised immediately, because they reset at a fixed time, not in seconds; check `e.code` and queue the send for later. Tune with `max_retries:` on `Quolle.new` (default 3).
data/lib/quolle/client.rb CHANGED
@@ -8,6 +8,8 @@ require_relative "version"
8
8
  require_relative "error"
9
9
  require_relative "resources/emails"
10
10
  require_relative "resources/webhooks"
11
+ require_relative "resources/domains"
12
+ require_relative "resources/api_keys"
11
13
 
12
14
  module Quolle
13
15
  # The Quolle API client.
@@ -27,6 +29,10 @@ module Quolle
27
29
  attr_reader :emails
28
30
  # @return [Quolle::Resources::Webhooks]
29
31
  attr_reader :webhooks
32
+ # @return [Quolle::Resources::Domains] sending domains (full-access key)
33
+ attr_reader :domains
34
+ # @return [Quolle::Resources::ApiKeys] API keys (full-access key)
35
+ attr_reader :api_keys
30
36
 
31
37
  # 429s that retrying can't fix: the limit resets at a fixed time (midnight
32
38
  # UTC, the next billing period), not in seconds. Raised at once so the
@@ -49,6 +55,8 @@ module Quolle
49
55
  @max_retries = max_retries
50
56
  @emails = Resources::Emails.new(self)
51
57
  @webhooks = Resources::Webhooks.new(self)
58
+ @domains = Resources::Domains.new(self)
59
+ @api_keys = Resources::ApiKeys.new(self)
52
60
  end
53
61
 
54
62
  # Perform an authenticated request and return the parsed JSON body.
@@ -70,6 +78,7 @@ module Quolle
70
78
  klass = {
71
79
  "GET" => Net::HTTP::Get,
72
80
  "POST" => Net::HTTP::Post,
81
+ "PATCH" => Net::HTTP::Patch,
73
82
  "DELETE" => Net::HTTP::Delete
74
83
  }.fetch(method.upcase)
75
84
 
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path"
4
+
5
+ module Quolle
6
+ module Resources
7
+ # API keys (+client.api_keys+). Needs a full-access key; a sending-only key
8
+ # gets 403 +restricted_api_key+.
9
+ class ApiKeys
10
+ def initialize(client)
11
+ @client = client
12
+ end
13
+
14
+ # @return [Array<Hash>] active keys (only a +preview+ of each key)
15
+ def list
16
+ @client.request("GET", "/v1/keys")["keys"] || []
17
+ end
18
+
19
+ # Create a key. The returned +key+ (qle_…) is shown once; store it now.
20
+ #
21
+ # @param scope ["full", "sending", nil] "sending" keys can only send email and read its status
22
+ # @param domains [Array<String>, nil] limit the key to these sending domains
23
+ # @param expires_in_days [Integer, nil] days until the key stops working
24
+ def create(name:, scope: nil, domains: nil, expires_in_days: nil)
25
+ body = { name: name }
26
+ body[:scope] = scope if scope
27
+ body[:domains] = domains if domains
28
+ body[:expiresInDays] = expires_in_days if expires_in_days
29
+ @client.request("POST", "/v1/keys", body: body)
30
+ end
31
+
32
+ # Revoke a key. Requests made with it fail from now on.
33
+ def remove(id)
34
+ @client.request("DELETE", "/v1/keys/#{Path.escape(id)}")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path"
4
+
5
+ module Quolle
6
+ module Resources
7
+ # Sending domains (+client.domains+). Needs a full-access API key.
8
+ class Domains
9
+ def initialize(client)
10
+ @client = client
11
+ end
12
+
13
+ # @return [Array<Hash>] every sending domain, with verification and warm-up state
14
+ def list
15
+ @client.request("GET", "/v1/domains")["domains"] || []
16
+ end
17
+
18
+ # Add a domain (e.g. "mail.example.com").
19
+ # @return [Hash] +domain+ and the +dnsRecords+ to publish
20
+ def create(domain)
21
+ @client.request("POST", "/v1/domains", body: { domain: domain })
22
+ end
23
+
24
+ # @return [Hash] one domain
25
+ def get(id)
26
+ @client.request("GET", "/v1/domains/#{Path.escape(id)}")["domain"]
27
+ end
28
+
29
+ # @return [Hash] the DNS records to publish, and which are found
30
+ def records(id)
31
+ @client.request("GET", "/v1/domains/#{Path.escape(id)}/records")
32
+ end
33
+
34
+ # Check DNS again now instead of waiting for the next automatic check.
35
+ def verify(id)
36
+ @client.request("POST", "/v1/domains/#{Path.escape(id)}/recheck")
37
+ end
38
+
39
+ # Remove a domain. Mail can no longer be sent from it.
40
+ def remove(id)
41
+ @client.request("DELETE", "/v1/domains/#{Path.escape(id)}")
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quolle
4
+ module Resources
5
+ # Path-segment encoding shared by the resources.
6
+ module Path
7
+ module_function
8
+
9
+ def escape(id)
10
+ id.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { |ch| format("%%%02X", ch.ord) }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -2,14 +2,73 @@
2
2
 
3
3
  require "openssl"
4
4
  require "json"
5
+ require "uri"
6
+ require_relative "path"
5
7
 
6
8
  module Quolle
7
9
  module Resources
8
- # Verify that an incoming webhook really came from Quolle.
10
+ # Verify incoming webhooks, and manage webhook endpoints (+client.webhooks+).
11
+ # +verify+ makes no API call; the management methods need a full-access key.
9
12
  class Webhooks
10
13
  DEFAULT_TOLERANCE = 300 # seconds
11
14
 
12
- def initialize(_client = nil); end
15
+ def initialize(client = nil)
16
+ @client = client
17
+ end
18
+
19
+ # @return [Array<Hash>] the events a webhook can subscribe to
20
+ def events
21
+ req("GET", "/v1/webhooks/events")["events"] || []
22
+ end
23
+
24
+ # @return [Array<Hash>]
25
+ def list
26
+ req("GET", "/v1/webhooks")["webhooks"] || []
27
+ end
28
+
29
+ # Add an endpoint. The returned +secret+ (whsec_…) is shown once; store it.
30
+ def create(url:, events:)
31
+ req("POST", "/v1/webhooks", { url: url, events: events })
32
+ end
33
+
34
+ def get(id)
35
+ req("GET", "/v1/webhooks/#{Path.escape(id)}")
36
+ end
37
+
38
+ # Change the URL or events. The signing secret stays the same.
39
+ def update(id, url: nil, events: nil)
40
+ body = {}
41
+ body[:url] = url if url
42
+ body[:events] = events if events
43
+ req("PATCH", "/v1/webhooks/#{Path.escape(id)}", body)
44
+ end
45
+
46
+ def remove(id)
47
+ req("DELETE", "/v1/webhooks/#{Path.escape(id)}")
48
+ end
49
+
50
+ # Send a signed +webhook.test+ event now and report how the endpoint answered.
51
+ def test(id)
52
+ req("POST", "/v1/webhooks/#{Path.escape(id)}/test")
53
+ end
54
+
55
+ # Deliveries from the last 30 days, newest first.
56
+ # @param status ["succeeded", "pending", "failed", nil]
57
+ def deliveries(id, status: nil, limit: nil)
58
+ params = { status: status, limit: limit }.compact
59
+ qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
60
+ req("GET", "/v1/webhooks/#{Path.escape(id)}/deliveries#{qs}")["deliveries"] || []
61
+ end
62
+
63
+ # Send a delivery again, with the same body and +eventId+.
64
+ def redeliver(id, delivery_id)
65
+ req("POST", "/v1/webhooks/#{Path.escape(id)}/deliveries/#{Path.escape(delivery_id)}/redeliver")
66
+ end
67
+
68
+ # Replace the signing secret. Takes effect immediately; the new +secret+ is returned once.
69
+ def rotate_secret(id)
70
+ req("POST", "/v1/webhooks/#{Path.escape(id)}/rotate-secret")
71
+ end
13
72
 
14
73
  # Verify a webhook signature and return the parsed event.
15
74
  #
@@ -52,6 +111,12 @@ module Quolle
52
111
  b.each_byte { |byte| res |= byte ^ l.shift }
53
112
  res.zero?
54
113
  end
114
+
115
+ def req(method, path, body = nil)
116
+ raise Quolle::Error.new("Webhooks was created without a client; use client.webhooks") unless @client
117
+
118
+ body ? @client.request(method, path, body: body) : @client.request(method, path)
119
+ end
55
120
  end
56
121
  end
57
122
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quolle
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quolle
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Quolle
@@ -47,7 +47,10 @@ files:
47
47
  - lib/quolle.rb
48
48
  - lib/quolle/client.rb
49
49
  - lib/quolle/error.rb
50
+ - lib/quolle/resources/api_keys.rb
51
+ - lib/quolle/resources/domains.rb
50
52
  - lib/quolle/resources/emails.rb
53
+ - lib/quolle/resources/path.rb
51
54
  - lib/quolle/resources/webhooks.rb
52
55
  - lib/quolle/version.rb
53
56
  homepage: https://quolle.com