quolle 1.0.0 → 1.1.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: a0658c46b0cbf87fde8e453e3c79da2444565b8a97360807ae24d7865125e57b
4
+ data.tar.gz: ca10637ae139bfe029c0b98608e0b3edde03a0f0f3b9a54cbd75ba2c7201a6a4
5
5
  SHA512:
6
- metadata.gz: c2ab703c9aad2e30a20b45fca63657833b571a915681669222c61589bc928492b54517ee1e1758e71b565e4a54b426215f7a488be045be4f911581d34bed163e
7
- data.tar.gz: b2b5282fe08b21d98e4906416e7d4a4a17bb0a3f938f2566dab9427667c734d1c439a3097f90d65908ca5d7c81a98e2278b1a12794c0b4e7f1e4df779a4e937a
6
+ metadata.gz: 46130d471c82ef7e8e1fc6f4a940d8a6a8937f039309d4fb75e45422923c7be48edc61953ba6fba44aa0a8d00db4b427747d0f4c43fa2a5e93b092606e1f4ad9
7
+ data.tar.gz: ee558f680e6311a1eb05b56a611ca493c0bd24cbe57828ae1119e11b51dbb30b6bd9c4bc1dbffb8e613ed7cc66a5bb0f69ca71a55e61201695e321058c68e332
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
@@ -167,7 +173,7 @@ HMAC-SHA256 with a 5-minute timestamp window (replay protection).
167
173
 
168
174
  ## Automatic retries
169
175
 
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).
176
+ 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
177
 
172
178
  ## License
173
179
 
data/lib/quolle/client.rb CHANGED
@@ -28,6 +28,14 @@ module Quolle
28
28
  # @return [Quolle::Resources::Webhooks]
29
29
  attr_reader :webhooks
30
30
 
31
+ # 429s that retrying can't fix: the limit resets at a fixed time (midnight
32
+ # UTC, the next billing period), not in seconds. Raised at once so the
33
+ # caller can queue the send instead of blocking in a retry loop.
34
+ NO_RETRY_CODES = %w[daily_quota_exceeded warmup_limit_exceeded monthly_quota_exceeded].freeze
35
+ # A Retry-After longer than this (seconds) means "not soon": raise instead
36
+ # of sleeping through it.
37
+ MAX_RETRY_WAIT = 60
38
+
31
39
  # @param api_key [String, nil] your API key (falls back to QUOLLE_API_KEY)
32
40
  # @param base_url [String]
33
41
  # @param timeout [Integer] per-request timeout in seconds
@@ -89,18 +97,38 @@ module Quolle
89
97
  raise last_error
90
98
  end
91
99
 
92
- parsed = resp.body.to_s.empty? ? {} : (JSON.parse(resp.body) rescue {})
93
100
  status = resp.code.to_i
101
+ json_ok = true
102
+ parsed =
103
+ if resp.body.to_s.empty?
104
+ {}
105
+ else
106
+ begin
107
+ JSON.parse(resp.body)
108
+ rescue JSON::ParserError
109
+ json_ok = false
110
+ {}
111
+ end
112
+ end
94
113
 
95
114
  if (200..299).cover?(status)
115
+ # A 2xx that isn't JSON (a proxy page, a truncated body) is an error,
116
+ # not an empty success.
117
+ unless json_ok
118
+ raise Quolle::Error.new("Unexpected non-JSON response from the Quolle API",
119
+ status_code: status, data: { "body" => resp.body.to_s[0, 500] })
120
+ end
96
121
  return parsed
97
122
  end
98
123
 
99
124
  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"]))
125
+ code = parsed.delete("code")
126
+ last_error = Quolle::Error.new(message, status_code: status, data: parsed, code: code)
127
+ retryable = (status == 429 && !NO_RETRY_CODES.include?(code)) ||
128
+ ((500..504).cover?(status) && (idempotent || has_idem))
129
+ wait = backoff(attempt, resp["Retry-After"])
130
+ if attempt < @max_retries && retryable && wait <= MAX_RETRY_WAIT
131
+ sleep(wait)
104
132
  next
105
133
  end
106
134
  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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quolle
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.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.1.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: []
@@ -59,7 +57,6 @@ metadata:
59
57
  homepage_uri: https://quolle.com
60
58
  documentation_uri: https://docs.quolle.com
61
59
  source_code_uri: https://github.com/Quolle-main/quolle-ruby
62
- post_install_message:
63
60
  rdoc_options: []
64
61
  require_paths:
65
62
  - lib
@@ -67,15 +64,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
67
64
  requirements:
68
65
  - - ">="
69
66
  - !ruby/object:Gem::Version
70
- version: '2.7'
67
+ version: '3.3'
71
68
  required_rubygems_version: !ruby/object:Gem::Requirement
72
69
  requirements:
73
70
  - - ">="
74
71
  - !ruby/object:Gem::Version
75
72
  version: '0'
76
73
  requirements: []
77
- rubygems_version: 3.5.22
78
- signing_key:
74
+ rubygems_version: 4.0.20
79
75
  specification_version: 4
80
76
  summary: Official Ruby SDK for the Quolle email API
81
77
  test_files: []