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 +4 -4
- data/README.md +23 -0
- data/lib/quolle/client.rb +9 -0
- data/lib/quolle/resources/api_keys.rb +38 -0
- data/lib/quolle/resources/domains.rb +45 -0
- data/lib/quolle/resources/path.rb +14 -0
- data/lib/quolle/resources/webhooks.rb +67 -2
- data/lib/quolle/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d49a558cd0e8d55d4a043225faf03055ab2d71b645b0d1fb9480acb5931addc0
|
|
4
|
+
data.tar.gz: 57eaff3fe141479fe6f99e5a84dc226957efb2dc3145f65f0666e9e9b5c8d556
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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(
|
|
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
|
data/lib/quolle/version.rb
CHANGED
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.
|
|
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
|