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 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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quolle
4
+ VERSION = "1.0.0"
5
+ 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: []