quolle 1.0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +174 -0
- data/lib/quolle/client.rb +124 -0
- data/lib/quolle/error.rb +18 -0
- data/lib/quolle/resources/emails.rb +68 -0
- data/lib/quolle/resources/webhooks.rb +57 -0
- data/lib/quolle/version.rb +5 -0
- data/lib/quolle.rb +13 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 37768ae3c543a6ebc5a59a65f4c208d1c2c1ccbb7fdb5f47eec6e01fe17ab9a4
|
|
4
|
+
data.tar.gz: 289978e8755370fa7a339248c16ffdcd098b8ff4b5a7e3355f0e6312853ac1d0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c2ab703c9aad2e30a20b45fca63657833b571a915681669222c61589bc928492b54517ee1e1758e71b565e4a54b426215f7a488be045be4f911581d34bed163e
|
|
7
|
+
data.tar.gz: b2b5282fe08b21d98e4906416e7d4a4a17bb0a3f938f2566dab9427667c734d1c439a3097f90d65908ca5d7c81a98e2278b1a12794c0b4e7f1e4df779a4e937a
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quolle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Quolle Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby client for the [Quolle](https://quolle.com) email API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install quolle
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or in your Gemfile:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "quolle"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Ruby 2.7+. No third-party dependencies.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "quolle"
|
|
23
|
+
|
|
24
|
+
quolle = Quolle.new(api_key: "qle_...") # or set QUOLLE_API_KEY
|
|
25
|
+
|
|
26
|
+
result = quolle.emails.send(
|
|
27
|
+
from: "hello@mail.yourdomain.com",
|
|
28
|
+
to: "customer@example.com",
|
|
29
|
+
subject: "Welcome!",
|
|
30
|
+
html: "<h1>Thanks for signing up</h1>"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
puts "Queued: #{result['id']}"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Sending
|
|
37
|
+
|
|
38
|
+
### Multiple recipients
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
quolle.emails.send(
|
|
42
|
+
from: "hello@mail.yourdomain.com",
|
|
43
|
+
to: ["a@example.com", "b@example.com"],
|
|
44
|
+
subject: "Announcement",
|
|
45
|
+
html: "<p>Hello everyone</p>"
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Templates
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
quolle.emails.send(
|
|
53
|
+
from: "hello@mail.yourdomain.com",
|
|
54
|
+
to: "customer@example.com",
|
|
55
|
+
template: "welcome-email",
|
|
56
|
+
variables: { firstName: "Amaka", planName: "Starter" }
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Scheduled send
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
quolle.emails.send(
|
|
64
|
+
from: "hello@mail.yourdomain.com",
|
|
65
|
+
to: "customer@example.com",
|
|
66
|
+
subject: "Your weekly digest",
|
|
67
|
+
html: "<p>Here's what happened this week.</p>",
|
|
68
|
+
scheduled_at: "2026-12-25T09:00:00.000Z"
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Idempotency
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
quolle.emails.send(
|
|
76
|
+
from: "billing@mail.yourdomain.com",
|
|
77
|
+
to: "customer@example.com",
|
|
78
|
+
subject: "Invoice #1234",
|
|
79
|
+
html: "<p>Your invoice is attached.</p>",
|
|
80
|
+
idempotency_key: "order_invoice_12345"
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Batch
|
|
85
|
+
|
|
86
|
+
Up to 100 emails in one all-or-nothing request:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
result = quolle.emails.send_batch([
|
|
90
|
+
{ from: "hello@mail.yourdomain.com", to: "a@example.com", subject: "Hi Alice", html: "<p>Hi Alice</p>" },
|
|
91
|
+
{ from: "hello@mail.yourdomain.com", to: "b@example.com", subject: "Hi Bob", html: "<p>Hi Bob</p>" }
|
|
92
|
+
])
|
|
93
|
+
puts "Queued #{result['queued']}"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Attachments
|
|
97
|
+
|
|
98
|
+
Pass `attachments:` — an array of hashes with `filename`, base64 `content`, and an
|
|
99
|
+
optional `contentType`. Up to 20 files, 10 MB total.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
require "base64"
|
|
103
|
+
|
|
104
|
+
quolle.emails.send(
|
|
105
|
+
from: "billing@mail.yourdomain.com",
|
|
106
|
+
to: "customer@example.com",
|
|
107
|
+
subject: "Your invoice",
|
|
108
|
+
html: "<p>Invoice attached.</p>",
|
|
109
|
+
attachments: [
|
|
110
|
+
{
|
|
111
|
+
filename: "invoice.pdf",
|
|
112
|
+
content: Base64.strict_encode64(File.binread("invoice.pdf")),
|
|
113
|
+
contentType: "application/pdf"
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Retrieve & cancel
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
email = quolle.emails.get("a1b2c3d4-...")
|
|
123
|
+
puts email["status"] # queued | sending | sent | delivered | bounced | failed
|
|
124
|
+
|
|
125
|
+
quolle.emails.cancel("a1b2c3d4-...") # only while status == "scheduled"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Error handling
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
begin
|
|
132
|
+
quolle.emails.send(
|
|
133
|
+
from: "hello@mail.yourdomain.com", to: "customer@example.com",
|
|
134
|
+
subject: "Welcome!", html: "<h1>Welcome</h1>"
|
|
135
|
+
)
|
|
136
|
+
rescue Quolle::Error => e
|
|
137
|
+
puts e.status_code # e.g. 402
|
|
138
|
+
puts e.message # e.g. "Monthly limit reached"
|
|
139
|
+
puts e.data # extra fields, e.g. {"limit" => 3000}
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Testing your integration
|
|
144
|
+
|
|
145
|
+
Send to a reserved test address to simulate any outcome without touching your
|
|
146
|
+
sending reputation: `delivered@test.quolle.com`, `bounced@test.quolle.com`,
|
|
147
|
+
`complained@test.quolle.com`, `suppressed@test.quolle.com`.
|
|
148
|
+
|
|
149
|
+
## Verifying webhooks
|
|
150
|
+
|
|
151
|
+
Confirm an incoming webhook really came from Quolle. Pass the **raw** request body, the `Quolle-Signature` header, and your signing secret (`whsec_…`):
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
begin
|
|
155
|
+
event = quolle.webhooks.verify(
|
|
156
|
+
request.body.read, # raw body
|
|
157
|
+
request.env["HTTP_QUOLLE_SIGNATURE"],
|
|
158
|
+
"whsec_your_signing_secret"
|
|
159
|
+
)
|
|
160
|
+
# event["event"] == "email.delivered"
|
|
161
|
+
rescue Quolle::Error
|
|
162
|
+
halt 400
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
HMAC-SHA256 with a 5-minute timestamp window (replay protection).
|
|
167
|
+
|
|
168
|
+
## Automatic retries
|
|
169
|
+
|
|
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).
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "version"
|
|
8
|
+
require_relative "error"
|
|
9
|
+
require_relative "resources/emails"
|
|
10
|
+
require_relative "resources/webhooks"
|
|
11
|
+
|
|
12
|
+
module Quolle
|
|
13
|
+
# The Quolle API client.
|
|
14
|
+
#
|
|
15
|
+
# quolle = Quolle::Client.new(api_key: "qle_...")
|
|
16
|
+
# result = quolle.emails.send(
|
|
17
|
+
# from: "hello@mail.yourdomain.com",
|
|
18
|
+
# to: "customer@example.com",
|
|
19
|
+
# subject: "Welcome!",
|
|
20
|
+
# html: "<h1>Thanks for signing up</h1>"
|
|
21
|
+
# )
|
|
22
|
+
# puts result["id"]
|
|
23
|
+
class Client
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.quolle.com"
|
|
25
|
+
|
|
26
|
+
# @return [Quolle::Resources::Emails]
|
|
27
|
+
attr_reader :emails
|
|
28
|
+
# @return [Quolle::Resources::Webhooks]
|
|
29
|
+
attr_reader :webhooks
|
|
30
|
+
|
|
31
|
+
# @param api_key [String, nil] your API key (falls back to QUOLLE_API_KEY)
|
|
32
|
+
# @param base_url [String]
|
|
33
|
+
# @param timeout [Integer] per-request timeout in seconds
|
|
34
|
+
# @param max_retries [Integer] retries for transient failures (default 3)
|
|
35
|
+
def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, timeout: 30, max_retries: 3)
|
|
36
|
+
@api_key = api_key || ENV["QUOLLE_API_KEY"]
|
|
37
|
+
raise ArgumentError, "A Quolle API key is required (pass api_key: or set QUOLLE_API_KEY)" if @api_key.nil? || @api_key.empty?
|
|
38
|
+
|
|
39
|
+
@base_url = base_url.sub(%r{/+\z}, "")
|
|
40
|
+
@timeout = timeout
|
|
41
|
+
@max_retries = max_retries
|
|
42
|
+
@emails = Resources::Emails.new(self)
|
|
43
|
+
@webhooks = Resources::Webhooks.new(self)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Perform an authenticated request and return the parsed JSON body.
|
|
47
|
+
#
|
|
48
|
+
# Transient failures (HTTP 429 and 5xx, plus network errors) are retried with
|
|
49
|
+
# exponential backoff, honoring Retry-After on 429. To avoid double-sending, a
|
|
50
|
+
# POST is only retried on a 5xx/network error when an Idempotency-Key is
|
|
51
|
+
# present; a 429 (never processed) is always safe to retry.
|
|
52
|
+
#
|
|
53
|
+
# @raise [Quolle::Error] on a non-2xx response or transport failure after retries
|
|
54
|
+
# @return [Hash]
|
|
55
|
+
def request(method, path, body: nil, headers: {})
|
|
56
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
57
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
58
|
+
http.use_ssl = uri.scheme == "https"
|
|
59
|
+
http.open_timeout = @timeout
|
|
60
|
+
http.read_timeout = @timeout
|
|
61
|
+
|
|
62
|
+
klass = {
|
|
63
|
+
"GET" => Net::HTTP::Get,
|
|
64
|
+
"POST" => Net::HTTP::Post,
|
|
65
|
+
"DELETE" => Net::HTTP::Delete
|
|
66
|
+
}.fetch(method.upcase)
|
|
67
|
+
|
|
68
|
+
idempotent = %w[GET DELETE].include?(method.upcase)
|
|
69
|
+
has_idem = headers.keys.any? { |k| k.to_s.downcase == "idempotency-key" }
|
|
70
|
+
|
|
71
|
+
last_error = nil
|
|
72
|
+
(0..@max_retries).each do |attempt|
|
|
73
|
+
req = klass.new(uri.request_uri)
|
|
74
|
+
req["Authorization"] = "Bearer #{@api_key}"
|
|
75
|
+
req["Content-Type"] = "application/json"
|
|
76
|
+
req["Accept"] = "application/json"
|
|
77
|
+
req["User-Agent"] = "quolle-ruby/#{Quolle::VERSION}"
|
|
78
|
+
headers.each { |k, v| req[k] = v }
|
|
79
|
+
req.body = JSON.generate(body) unless body.nil?
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
resp = http.request(req)
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
last_error = Quolle::Error.new("Network error: #{e.message}")
|
|
85
|
+
if attempt < @max_retries && (idempotent || has_idem)
|
|
86
|
+
sleep(backoff(attempt, nil))
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
raise last_error
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
parsed = resp.body.to_s.empty? ? {} : (JSON.parse(resp.body) rescue {})
|
|
93
|
+
status = resp.code.to_i
|
|
94
|
+
|
|
95
|
+
if (200..299).cover?(status)
|
|
96
|
+
return parsed
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
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"]))
|
|
104
|
+
next
|
|
105
|
+
end
|
|
106
|
+
raise last_error
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
raise(last_error || Quolle::Error.new("Request failed"))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Seconds to wait before the next retry: Retry-After if given, else
|
|
115
|
+
# exponential backoff (0.5, 1, 2, …) capped at 8s, with jitter.
|
|
116
|
+
def backoff(attempt, retry_after)
|
|
117
|
+
if retry_after && retry_after.to_s =~ /\A\d+\z/
|
|
118
|
+
return retry_after.to_i
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
[0.5 * (2**attempt), 8.0].min + rand * 0.25
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/quolle/error.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quolle
|
|
4
|
+
# Raised when the Quolle API returns a non-2xx response, or a request could
|
|
5
|
+
# not be completed (network/timeout).
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
# @return [Integer, nil] HTTP status code, or nil for transport failures.
|
|
8
|
+
attr_reader :status_code
|
|
9
|
+
# @return [Hash] extra fields returned alongside +error+.
|
|
10
|
+
attr_reader :data
|
|
11
|
+
|
|
12
|
+
def initialize(message, status_code: nil, data: {})
|
|
13
|
+
super(message)
|
|
14
|
+
@status_code = status_code
|
|
15
|
+
@data = data || {}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quolle
|
|
4
|
+
module Resources
|
|
5
|
+
# Send and manage emails.
|
|
6
|
+
class Emails
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Queue an email for immediate or scheduled delivery.
|
|
12
|
+
#
|
|
13
|
+
# Provide either +:html+ (with +:subject+) or a +:template+ slug (with
|
|
14
|
+
# optional +:variables+). Pass +idempotency_key:+ to make retries safe.
|
|
15
|
+
#
|
|
16
|
+
# @param params [Hash] from:, to:, subject:, html:, text:, template:,
|
|
17
|
+
# variables:, reply_to:/replyTo:, scheduled_at:/scheduledAt:, metadata:
|
|
18
|
+
# @return [Hash] at least "id" and "message"
|
|
19
|
+
def send(idempotency_key: nil, **params)
|
|
20
|
+
headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
|
|
21
|
+
@client.request("POST", "/v1/emails/send", body: normalize(params), headers: headers)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Send up to 100 emails in one all-or-nothing request.
|
|
25
|
+
#
|
|
26
|
+
# @param emails [Array<Hash>]
|
|
27
|
+
# @return [Hash] { "queued" => Integer, "ids" => Array<String> }
|
|
28
|
+
def send_batch(emails, idempotency_key: nil)
|
|
29
|
+
headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
|
|
30
|
+
body = { emails: emails.map { |e| normalize(e) } }
|
|
31
|
+
@client.request("POST", "/v1/emails/batch", body: body, headers: headers)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Retrieve a single email by ID.
|
|
35
|
+
# @return [Hash]
|
|
36
|
+
def get(id)
|
|
37
|
+
resp = @client.request("GET", "/v1/emails/#{escape(id)}")
|
|
38
|
+
resp["email"] || resp
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Cancel a scheduled email that has not yet been sent.
|
|
42
|
+
# @return [Hash]
|
|
43
|
+
def cancel(id)
|
|
44
|
+
@client.request("DELETE", "/v1/emails/#{escape(id)}/cancel")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Accept Ruby-idiomatic snake_case keys and map them to the API's
|
|
50
|
+
# camelCase where they differ. Existing camelCase keys pass through.
|
|
51
|
+
def normalize(params)
|
|
52
|
+
out = {}
|
|
53
|
+
params.each do |key, value|
|
|
54
|
+
k = key.to_s
|
|
55
|
+
k = "replyTo" if k == "reply_to"
|
|
56
|
+
k = "scheduledAt" if k == "scheduled_at"
|
|
57
|
+
out[k] = value
|
|
58
|
+
end
|
|
59
|
+
out
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def escape(id)
|
|
63
|
+
require "uri"
|
|
64
|
+
URI.encode_www_form_component(id.to_s)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Quolle
|
|
7
|
+
module Resources
|
|
8
|
+
# Verify that an incoming webhook really came from Quolle.
|
|
9
|
+
class Webhooks
|
|
10
|
+
DEFAULT_TOLERANCE = 300 # seconds
|
|
11
|
+
|
|
12
|
+
def initialize(_client = nil); end
|
|
13
|
+
|
|
14
|
+
# Verify a webhook signature and return the parsed event.
|
|
15
|
+
#
|
|
16
|
+
# @param payload [String] the RAW request body
|
|
17
|
+
# @param signature_header [String] the value of the +Quolle-Signature+ header
|
|
18
|
+
# @param secret [String] your webhook signing secret (whsec_…)
|
|
19
|
+
# @param tolerance [Integer] max timestamp age in seconds (replay window)
|
|
20
|
+
# @raise [Quolle::Error] on a malformed header, stale timestamp, or bad signature
|
|
21
|
+
# @return [Hash] the parsed event
|
|
22
|
+
def verify(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE)
|
|
23
|
+
parts = signature_header.to_s.split(",").each_with_object({}) do |seg, acc|
|
|
24
|
+
k, v = seg.strip.split("=", 2)
|
|
25
|
+
acc[k] = v if k && v
|
|
26
|
+
end
|
|
27
|
+
t = parts["t"]
|
|
28
|
+
v1 = parts["v1"]
|
|
29
|
+
raise Quolle::Error.new("Invalid Quolle-Signature header") if t.nil? || v1.nil? || t.empty? || v1.empty?
|
|
30
|
+
|
|
31
|
+
ts = t.to_i
|
|
32
|
+
if (Time.now.to_i - ts).abs > tolerance
|
|
33
|
+
raise Quolle::Error.new("Webhook timestamp outside the tolerance window (possible replay)")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{payload}")
|
|
37
|
+
unless secure_compare(expected, v1)
|
|
38
|
+
raise Quolle::Error.new("Webhook signature verification failed")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
JSON.parse(payload)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Constant-time string comparison (no timing side channel).
|
|
47
|
+
def secure_compare(a, b)
|
|
48
|
+
return false unless a.bytesize == b.bytesize
|
|
49
|
+
|
|
50
|
+
l = a.unpack("C*")
|
|
51
|
+
res = 0
|
|
52
|
+
b.each_byte { |byte| res |= byte ^ l.shift }
|
|
53
|
+
res.zero?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/quolle.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "quolle/version"
|
|
4
|
+
require_relative "quolle/error"
|
|
5
|
+
require_relative "quolle/client"
|
|
6
|
+
|
|
7
|
+
# Official Ruby SDK for the Quolle email API.
|
|
8
|
+
module Quolle
|
|
9
|
+
# Convenience constructor: Quolle.new(api_key: "qle_...")
|
|
10
|
+
def self.new(**kwargs)
|
|
11
|
+
Client.new(**kwargs)
|
|
12
|
+
end
|
|
13
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: quolle
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Quolle
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-25 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
description: Send transactional email through Quolle from Ruby.
|
|
42
|
+
email:
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- LICENSE
|
|
48
|
+
- README.md
|
|
49
|
+
- lib/quolle.rb
|
|
50
|
+
- lib/quolle/client.rb
|
|
51
|
+
- lib/quolle/error.rb
|
|
52
|
+
- lib/quolle/resources/emails.rb
|
|
53
|
+
- lib/quolle/resources/webhooks.rb
|
|
54
|
+
- lib/quolle/version.rb
|
|
55
|
+
homepage: https://quolle.com
|
|
56
|
+
licenses:
|
|
57
|
+
- MIT
|
|
58
|
+
metadata:
|
|
59
|
+
homepage_uri: https://quolle.com
|
|
60
|
+
documentation_uri: https://docs.quolle.com
|
|
61
|
+
source_code_uri: https://github.com/Quolle-main/quolle-ruby
|
|
62
|
+
post_install_message:
|
|
63
|
+
rdoc_options: []
|
|
64
|
+
require_paths:
|
|
65
|
+
- lib
|
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '2.7'
|
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
requirements: []
|
|
77
|
+
rubygems_version: 3.5.22
|
|
78
|
+
signing_key:
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: Official Ruby SDK for the Quolle email API
|
|
81
|
+
test_files: []
|