quolle 1.0.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: 37768ae3c543a6ebc5a59a65f4c208d1c2c1ccbb7fdb5f47eec6e01fe17ab9a4
4
- data.tar.gz: 289978e8755370fa7a339248c16ffdcd098b8ff4b5a7e3355f0e6312853ac1d0
3
+ metadata.gz: d49a558cd0e8d55d4a043225faf03055ab2d71b645b0d1fb9480acb5931addc0
4
+ data.tar.gz: 57eaff3fe141479fe6f99e5a84dc226957efb2dc3145f65f0666e9e9b5c8d556
5
5
  SHA512:
6
- metadata.gz: c2ab703c9aad2e30a20b45fca63657833b571a915681669222c61589bc928492b54517ee1e1758e71b565e4a54b426215f7a488be045be4f911581d34bed163e
7
- data.tar.gz: b2b5282fe08b21d98e4906416e7d4a4a17bb0a3f938f2566dab9427667c734d1c439a3097f90d65908ca5d7c81a98e2278b1a12794c0b4e7f1e4df779a4e937a
6
+ metadata.gz: aa4abf51a8b949560722c0fb9e7f4ad55653a6a16fac85f56c7a427ab0fe34ce0a52bad869c48274c76b959287734f48aef984a7134727854e931b9e8f4c1f5f
7
+ data.tar.gz: f04d7e24d159145ef67a7dbcc5c4675637d562dcd62f7eb509428837b1715e5ec735d3f5673d01bed8a531e80fa26310c07895488e75d37c328591da5570d457
data/README.md CHANGED
@@ -14,7 +14,7 @@ Or in your Gemfile:
14
14
  gem "quolle"
15
15
  ```
16
16
 
17
- Requires Ruby 2.7+. No third-party dependencies.
17
+ Requires Ruby 3.3+. No third-party dependencies.
18
18
 
19
19
  ## Quick start
20
20
 
@@ -33,6 +33,11 @@ result = quolle.emails.send(
33
33
  puts "Queued: #{result['id']}"
34
34
  ```
35
35
 
36
+ `emails.create(...)` does exactly the same thing and doesn't shadow Ruby's
37
+ `Object#send`, so prefer it if you use `send` for dynamic dispatch. Pass
38
+ `type: "marketing"` for marketing mail (adds unsubscribe headers); the default
39
+ is transactional.
40
+
36
41
  ## Sending
37
42
 
38
43
  ### Multiple recipients
@@ -135,6 +140,7 @@ begin
135
140
  )
136
141
  rescue Quolle::Error => e
137
142
  puts e.status_code # e.g. 402
143
+ puts e.code # e.g. "monthly_quota_exceeded" — branch on this
138
144
  puts e.message # e.g. "Monthly limit reached"
139
145
  puts e.data # extra fields, e.g. {"limit" => 3000}
140
146
  end
@@ -165,9 +171,32 @@ end
165
171
 
166
172
  HMAC-SHA256 with a 5-minute timestamp window (replay protection).
167
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
+
168
197
  ## Automatic retries
169
198
 
170
- 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). Tune with `max_retries:` on `Quolle.new` (default 3).
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).
171
200
 
172
201
  ## License
173
202
 
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,18 @@ 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
36
+
37
+ # 429s that retrying can't fix: the limit resets at a fixed time (midnight
38
+ # UTC, the next billing period), not in seconds. Raised at once so the
39
+ # caller can queue the send instead of blocking in a retry loop.
40
+ NO_RETRY_CODES = %w[daily_quota_exceeded warmup_limit_exceeded monthly_quota_exceeded].freeze
41
+ # A Retry-After longer than this (seconds) means "not soon": raise instead
42
+ # of sleeping through it.
43
+ MAX_RETRY_WAIT = 60
30
44
 
31
45
  # @param api_key [String, nil] your API key (falls back to QUOLLE_API_KEY)
32
46
  # @param base_url [String]
@@ -41,6 +55,8 @@ module Quolle
41
55
  @max_retries = max_retries
42
56
  @emails = Resources::Emails.new(self)
43
57
  @webhooks = Resources::Webhooks.new(self)
58
+ @domains = Resources::Domains.new(self)
59
+ @api_keys = Resources::ApiKeys.new(self)
44
60
  end
45
61
 
46
62
  # Perform an authenticated request and return the parsed JSON body.
@@ -62,6 +78,7 @@ module Quolle
62
78
  klass = {
63
79
  "GET" => Net::HTTP::Get,
64
80
  "POST" => Net::HTTP::Post,
81
+ "PATCH" => Net::HTTP::Patch,
65
82
  "DELETE" => Net::HTTP::Delete
66
83
  }.fetch(method.upcase)
67
84
 
@@ -89,18 +106,38 @@ module Quolle
89
106
  raise last_error
90
107
  end
91
108
 
92
- parsed = resp.body.to_s.empty? ? {} : (JSON.parse(resp.body) rescue {})
93
109
  status = resp.code.to_i
110
+ json_ok = true
111
+ parsed =
112
+ if resp.body.to_s.empty?
113
+ {}
114
+ else
115
+ begin
116
+ JSON.parse(resp.body)
117
+ rescue JSON::ParserError
118
+ json_ok = false
119
+ {}
120
+ end
121
+ end
94
122
 
95
123
  if (200..299).cover?(status)
124
+ # A 2xx that isn't JSON (a proxy page, a truncated body) is an error,
125
+ # not an empty success.
126
+ unless json_ok
127
+ raise Quolle::Error.new("Unexpected non-JSON response from the Quolle API",
128
+ status_code: status, data: { "body" => resp.body.to_s[0, 500] })
129
+ end
96
130
  return parsed
97
131
  end
98
132
 
99
133
  message = parsed.delete("error") || "Request failed with status #{status}"
100
- last_error = Quolle::Error.new(message, status_code: status, data: parsed)
101
- retryable = status == 429 || ((500..504).cover?(status) && (idempotent || has_idem))
102
- if attempt < @max_retries && retryable
103
- sleep(backoff(attempt, resp["Retry-After"]))
134
+ code = parsed.delete("code")
135
+ last_error = Quolle::Error.new(message, status_code: status, data: parsed, code: code)
136
+ retryable = (status == 429 && !NO_RETRY_CODES.include?(code)) ||
137
+ ((500..504).cover?(status) && (idempotent || has_idem))
138
+ wait = backoff(attempt, resp["Retry-After"])
139
+ if attempt < @max_retries && retryable && wait <= MAX_RETRY_WAIT
140
+ sleep(wait)
104
141
  next
105
142
  end
106
143
  raise last_error
data/lib/quolle/error.rb CHANGED
@@ -8,11 +8,16 @@ module Quolle
8
8
  attr_reader :status_code
9
9
  # @return [Hash] extra fields returned alongside +error+.
10
10
  attr_reader :data
11
+ # @return [String, nil] the API's stable, machine-readable error code
12
+ # (e.g. "validation_error", "rate_limit_exceeded"). Branch on this, not
13
+ # on the message. nil for transport failures.
14
+ attr_reader :code
11
15
 
12
- def initialize(message, status_code: nil, data: {})
16
+ def initialize(message, status_code: nil, data: {}, code: nil)
13
17
  super(message)
14
18
  @status_code = status_code
15
19
  @data = data || {}
20
+ @code = code
16
21
  end
17
22
  end
18
23
  end
@@ -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
@@ -12,15 +12,26 @@ module Quolle
12
12
  #
13
13
  # Provide either +:html+ (with +:subject+) or a +:template+ slug (with
14
14
  # optional +:variables+). Pass +idempotency_key:+ to make retries safe.
15
+ # +type:+ (or +message_type:+) is "transactional" (default) or
16
+ # "marketing"; marketing mail gets unsubscribe headers and honours
17
+ # unsubscribes.
15
18
  #
16
19
  # @param params [Hash] from:, to:, subject:, html:, text:, template:,
17
- # variables:, reply_to:/replyTo:, scheduled_at:/scheduledAt:, metadata:
20
+ # variables:, reply_to:/replyTo:, scheduled_at:/scheduledAt:, metadata:,
21
+ # attachments:, type:
18
22
  # @return [Hash] at least "id" and "message"
19
- def send(idempotency_key: nil, **params)
23
+ def create(idempotency_key: nil, **params)
20
24
  headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
21
25
  @client.request("POST", "/v1/emails/send", body: normalize(params), headers: headers)
22
26
  end
23
27
 
28
+ # Same as {#create}. Kept because it's the documented name; note that it
29
+ # shadows Object#send on this object, so prefer +create+ in new code
30
+ # (and +public_send+ / +__send__+ for dynamic dispatch).
31
+ def send(idempotency_key: nil, **params)
32
+ create(idempotency_key: idempotency_key, **params)
33
+ end
34
+
24
35
  # Send up to 100 emails in one all-or-nothing request.
25
36
  #
26
37
  # @param emails [Array<Hash>]
@@ -54,14 +65,16 @@ module Quolle
54
65
  k = key.to_s
55
66
  k = "replyTo" if k == "reply_to"
56
67
  k = "scheduledAt" if k == "scheduled_at"
68
+ k = "type" if k == "message_type"
57
69
  out[k] = value
58
70
  end
59
71
  out
60
72
  end
61
73
 
74
+ # Path-segment encoding. (encode_www_form_component turned a space into
75
+ # "+", which is a literal plus in a URL path.)
62
76
  def escape(id)
63
- require "uri"
64
- URI.encode_www_form_component(id.to_s)
77
+ id.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { |ch| format("%%%02X", ch.ord) }
65
78
  end
66
79
  end
67
80
  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.0.0"
4
+ VERSION = "1.2.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quolle
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Quolle
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-07-25 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: minitest
@@ -39,7 +38,6 @@ dependencies:
39
38
  - !ruby/object:Gem::Version
40
39
  version: '13.0'
41
40
  description: Send transactional email through Quolle from Ruby.
42
- email:
43
41
  executables: []
44
42
  extensions: []
45
43
  extra_rdoc_files: []
@@ -49,7 +47,10 @@ files:
49
47
  - lib/quolle.rb
50
48
  - lib/quolle/client.rb
51
49
  - lib/quolle/error.rb
50
+ - lib/quolle/resources/api_keys.rb
51
+ - lib/quolle/resources/domains.rb
52
52
  - lib/quolle/resources/emails.rb
53
+ - lib/quolle/resources/path.rb
53
54
  - lib/quolle/resources/webhooks.rb
54
55
  - lib/quolle/version.rb
55
56
  homepage: https://quolle.com
@@ -59,7 +60,6 @@ metadata:
59
60
  homepage_uri: https://quolle.com
60
61
  documentation_uri: https://docs.quolle.com
61
62
  source_code_uri: https://github.com/Quolle-main/quolle-ruby
62
- post_install_message:
63
63
  rdoc_options: []
64
64
  require_paths:
65
65
  - lib
@@ -67,15 +67,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
67
67
  requirements:
68
68
  - - ">="
69
69
  - !ruby/object:Gem::Version
70
- version: '2.7'
70
+ version: '3.3'
71
71
  required_rubygems_version: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - ">="
74
74
  - !ruby/object:Gem::Version
75
75
  version: '0'
76
76
  requirements: []
77
- rubygems_version: 3.5.22
78
- signing_key:
77
+ rubygems_version: 4.0.20
79
78
  specification_version: 4
80
79
  summary: Official Ruby SDK for the Quolle email API
81
80
  test_files: []