duva-mail 0.0.1 → 0.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: e827ccf2e586d4395a447ea3c9d7056b1f260434d3d5db34c7332499f1ab583f
4
- data.tar.gz: 8ea23bf187bb8aee8e200268b99742c7ab26771c417114e2d64f39da1367f58d
3
+ metadata.gz: b62be0aef438ad137fa4db07aae85263042d924b2db6c50d70347301f62f2737
4
+ data.tar.gz: 231d12564797d902d3398972da4809b2e3c8b1488f9a6e7750efa2ad15bca89a
5
5
  SHA512:
6
- metadata.gz: 62d73492bde77e29ccdc73f184fc99c6d490690c2bf4f7dce36ea65e5c1a856c74dab6314991fbf42723eb01ce26f58972366405ddab4fb5bad2d3df6a183cb6
7
- data.tar.gz: 8c10eaa315d432fb61d9521bae50e070625a2d9747f74b038b7e8ef7386a3d67e49d1a8e9ad6986903b40903d46ba82fa1a42e6e3bc5f6951a8e38e42edb7924
6
+ metadata.gz: 4133719861683e4084116674223885e0e1a3cf1eede3352725f6e492e5ec9c29665806a82fa552a0096cb775e6d4018961551b132061125f947db14f7adb381f
7
+ data.tar.gz: dac5abbdcf145b0f4d25258d69c478bf083adfee8e19f2f9bf8549bd6630420599cfb29cc1b5ab8b40136461c9b4ddb30e83f212f4b8a29640bd1b2a5298c97b
data/README.md CHANGED
@@ -1,15 +1,144 @@
1
1
  # Duva for Ruby
2
2
 
3
- The official [Duva](https://duva.ca) client library for Ruby. **Not released yet: this package name is reserved.**
3
+ The official [Duva](https://duva.ca) client library for Ruby. Duva is a transactional email API
4
+ hosted in Canada.
4
5
 
5
- Duva is a transactional email API hosted in Canada. In the meantime, the HTTP API is documented at
6
- <https://duva.ca/en/docs> and described by an OpenAPI 3.1 specification at <https://duva.ca/openapi.json>.
6
+ ```bash
7
+ gem install duva-mail
8
+ # or, in a Gemfile: gem "duva-mail"
9
+ ```
7
10
 
8
- ---
11
+ Requires Ruby 3.3 or later. No runtime dependency beyond `base64` (bundled with Ruby; declared
12
+ explicitly since it left Ruby's default gems in 3.4).
9
13
 
10
- # Duva pour Ruby
14
+ ## Sending a message
11
15
 
12
- La bibliothèque cliente officielle de [Duva](https://duva.ca) pour Ruby. **Pas encore publiée : ce nom de paquet est réservé.**
16
+ ```ruby
17
+ require "duva"
13
18
 
14
- Duva est une API de courriels transactionnels hébergée au Canada. En attendant, l'API HTTP est documentée sur
15
- <https://duva.ca/docs> et décrite par une spécification OpenAPI 3.1 sur <https://duva.ca/openapi.json>.
19
+ duva = Duva::Client.new(api_key: "dv_...", domain: "example.com") # or DUVA_API_KEY / DUVA_DOMAIN
20
+
21
+ message = duva.messages.deliver(
22
+ from: "Example <notifications@example.com>",
23
+ to: ["client@example.org"],
24
+ subject: "Your order",
25
+ text: "Thank you for your order."
26
+ )
27
+ puts message.id, message.status # "queued": always asynchronous
28
+ ```
29
+
30
+ Named `deliver`, not `send`: `Object#send` is a core Ruby method and shadowing it would be
31
+ confusing in a `rescue`/`method_missing`-heavy codebase.
32
+
33
+ ## Reading events and pagination
34
+
35
+ ```ruby
36
+ duva.events.list_all(type: "bounced").each { |event| puts event.type, event.detail["recipient"] }
37
+ ```
38
+
39
+ `events.list` and `suppressions.list` return one page (`.data`, `.next_cursor`); `events.list_all`
40
+ and `suppressions.list_all` return a lazy `Enumerator` that follows `next_cursor` for you, without
41
+ ever loading every page into memory, optionally bounded with `max_items:`.
42
+
43
+ ## Verifying a webhook
44
+
45
+ ```ruby
46
+ begin
47
+ event = Duva::Webhooks.construct_event(ENV.fetch("DUVA_WEBHOOK_SECRET"), request.headers, raw_body)
48
+ puts event.type, event.data["message_id"]
49
+ rescue Duva::WebhookSignatureError
50
+ # respond 400
51
+ end
52
+ ```
53
+
54
+ `raw_body` must be the **exact bytes** Duva sent (`request.raw_post` in Rails, `request.body.read`
55
+ in Sinatra/Rack): re-encoding a parsed body changes it and invalidates the signature. Rotating
56
+ your webhook secret? Pass an array — `Duva::Webhooks.construct_event([old_secret, new_secret], ...)`
57
+ — while both are active.
58
+
59
+ ## Errors
60
+
61
+ Every error Duva answers with is a `Duva::Error` subclass; rely on `.code` (the contract), never on
62
+ the exception message (its wording can change):
63
+
64
+ ```ruby
65
+ begin
66
+ duva.messages.deliver(...)
67
+ rescue Duva::ValidationError => e
68
+ p e.fields # [{"field" => "to[0]", "message" => "..."}]
69
+ rescue Duva::QuotaExceededError => e
70
+ puts "retry in #{e.retry_after}s"
71
+ rescue Duva::NotFoundError
72
+ # the API key, domain or resource could not be found
73
+ end
74
+ ```
75
+
76
+ Network failures and timeouts raise `Duva::ConnectionError` / `Duva::TimeoutError` instead (no
77
+ HTTP response was ever received). Reads and `messages.deliver` (idempotency-key protected) are
78
+ retried automatically on a transient failure; `suppressions.add`/`remove` and
79
+ `webhooks.create`/`delete` are not, because the outcome of a timed-out first attempt is unknown. A
80
+ `429 quota_exceeded` is never retried automatically (its `retry_after` can be hours); a
81
+ `429 rate_limited` is, as long as the wait fits within `max_retry_wait_seconds` (30s by default).
82
+
83
+ ## Attachments
84
+
85
+ ```ruby
86
+ attachment = Duva::Attachments.from_file("./invoice.pdf")
87
+ duva.messages.deliver(..., attachments: [attachment])
88
+ ```
89
+
90
+ `Duva::Attachments.from_bytes(filename, content, content_type:, content_id:)` works from data
91
+ already in memory; `content_id:` turns the attachment into an inline image the HTML references
92
+ with `cid:`.
93
+
94
+ ## Testing your own application
95
+
96
+ ```ruby
97
+ transport = Duva::TestTransport.new do |_config, spec|
98
+ Duva::TestTransport.response({ "status" => "ok" })
99
+ end
100
+ duva = Duva::Client.new(api_key: "dv_test", domain: "example.com", transport: transport)
101
+ duva.health
102
+ transport.requests.last.path # => "/health"
103
+ ```
104
+
105
+ `Duva::TestTransport` records every `RequestSpec` sent and lets you script the responses: no
106
+ network call, no stubbing library required.
107
+
108
+ ## Configuration
109
+
110
+ | Argument | Default | |
111
+ |---|---|---|
112
+ | `api_key:` | `DUVA_API_KEY` | Required. |
113
+ | `domain:` | `DUVA_DOMAIN` | Required: the domain this key was created for. |
114
+ | `base_url:` | `https://api.duva.ca` | |
115
+ | `timeout:` | `10` (seconds) | |
116
+ | `max_retries:` | `2` | Network failures / `5xx` on a safe-to-retry call. |
117
+ | `max_retry_wait_seconds:` | `30` | A `429 rate_limited` with a longer wait is not retried. |
118
+ | `language:` | unset | `"en"` or `"fr"`: the language of `error.message`. |
119
+ | `transport:` | a new `Duva::NetHttpTransport` (`Net::HTTP`, no gem dependency) | Inject your own (proxying, Faraday, tests). |
120
+
121
+ ## Full reference
122
+
123
+ The complete API surface and the OpenAPI specification this library follows:
124
+ <https://duva.ca/en/docs> and <https://duva.ca/openapi.json>.
125
+
126
+ ## Development
127
+
128
+ ```bash
129
+ bundle install
130
+ bundle exec rake spec # unit tests
131
+ ruby script/fetch_conformance.rb && bundle exec rake conformance
132
+ bundle exec rake rubocop
133
+ gem build duva-mail.gemspec
134
+ ```
135
+
136
+ Response types are hand-written value objects (`lib/duva/models.rb`, `Data.define`): Ruby's typing
137
+ is optional (RBS/Sorbet are left to the application), so there is no separate code-generation step
138
+ for them, unlike the Node.js and Python libraries. The client itself (retries, pagination, errors,
139
+ webhooks) is checked against the shared fixtures published in
140
+ [`duva-mail/duva-conformance`](https://github.com/duva-mail/duva-conformance).
141
+
142
+ ## License
143
+
144
+ MIT, see [LICENSE](./LICENSE).
data/duva-mail.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/duva"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "duva-mail"
7
+ spec.version = Duva::VERSION
8
+ spec.summary = "Official Duva client library (transactional email API hosted in Canada)."
9
+ spec.description = "Official client library for Duva, a transactional email API hosted in Canada: " \
10
+ "sending, delivery events, suppressions, webhooks and statistics."
11
+ spec.authors = ["9573-4562 Québec inc."]
12
+ spec.license = "MIT"
13
+ spec.homepage = "https://duva.ca"
14
+ spec.metadata = {
15
+ "source_code_uri" => "https://github.com/duva-mail/duva-ruby",
16
+ "documentation_uri" => "https://duva.ca/en/docs",
17
+ "changelog_uri" => "https://duva.ca/en/docs#changelog",
18
+ "rubygems_mfa_required" => "true"
19
+ }
20
+ spec.required_ruby_version = ">= 3.3"
21
+ spec.files = Dir.glob("lib/**/*.rb") + ["README.md", "LICENSE", "duva-mail.gemspec"]
22
+ spec.require_paths = ["lib"]
23
+ # `base64` left Ruby's default gems in 3.4 (a bare `require` then warns, and would eventually
24
+ # break): declared explicitly so it installs regardless of the running Ruby's bundled set.
25
+ # Everything else used (json, net/http, openssl, securerandom, time, uri) remains a Ruby
26
+ # default gem; no other dependency, matching `docs/bibliotheques-clientes.md` section 4.
27
+ spec.add_dependency "base64", "~> 0.2"
28
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duva
4
+ # Small formatting helpers matched to the API's own parsing rules (`docs/api.md`).
5
+ module Address
6
+ NEEDS_QUOTING = /[",]/
7
+ private_constant :NEEDS_QUOTING
8
+
9
+ class << self
10
+ # `"Name <address>"` (with `Name` quoted if it contains a `"` or `,`), or just `address`
11
+ # without a name.
12
+ def format(email, name = nil)
13
+ return email if name.nil? || name.empty?
14
+
15
+ escaped = name.gsub('"', '\\"')
16
+ quoted = NEEDS_QUOTING.match?(name) ? "\"#{escaped}\"" : name
17
+ "#{quoted} <#{email}>"
18
+ end
19
+
20
+ # Builds `List-Unsubscribe` (and `List-Unsubscribe-Post` for one-click) exactly as the API
21
+ # validates them: at most 3 links, `https://` or `mailto:` only. Raises ArgumentError when
22
+ # neither `https_url:` nor `mailto:` is given.
23
+ #
24
+ # `one_click:` adds `List-Unsubscribe-Post` (RFC 8058). Defaults to `true` when
25
+ # `https_url:` is given, `false` otherwise.
26
+ def unsubscribe_headers(https_url: nil, mailto: nil, one_click: nil)
27
+ raise ArgumentError, "Duva: unsubscribe_headers needs https_url and/or mailto" if https_url.nil? && mailto.nil?
28
+ if https_url && !https_url.start_with?("https://")
29
+ raise ArgumentError,
30
+ "Duva: unsubscribe_headers.https_url must be an https:// link"
31
+ end
32
+
33
+ resolved_one_click = one_click.nil? ? !https_url.nil? : one_click
34
+
35
+ links = [https_url, mailto && "mailto:#{mailto}"].compact.map { |link| "<#{link}>" }
36
+ headers = { "List-Unsubscribe" => links.join(", ") }
37
+ if resolved_one_click
38
+ raise ArgumentError, "Duva: one-click unsubscribe (List-Unsubscribe-Post) needs https_url" unless https_url
39
+
40
+ headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
41
+ end
42
+ headers
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Duva
6
+ # Attachment helpers. The server remains the authority on the limits below (they can change):
7
+ # these are a courtesy, so a mistake fails locally instead of after an upload.
8
+ Attachment = Data.define(:filename, :content, :content_type, :content_id) do
9
+ def to_h
10
+ { "filename" => filename, "content" => content, "content_type" => content_type, "content_id" => content_id }
11
+ end
12
+ end
13
+
14
+ module Attachments
15
+ # Mirrors `services/messages.py` in the `duva` repository at the time of writing: 10
16
+ # attachments, 5 MB decoded in total, these extensions refused. Re-check against
17
+ # `docs/api.md` if this drifts.
18
+ MAX_ATTACHMENTS = 10
19
+ MAX_TOTAL_BYTES = 5 * 1024 * 1024
20
+ FORBIDDEN_EXTENSIONS = %w[.exe .bat .cmd .com .js .vbs .vbe .scr .msi .msp .ps1 .jar].freeze
21
+
22
+ class << self
23
+ # From raw bytes already in memory.
24
+ def from_bytes(filename, content, content_type: nil, content_id: nil)
25
+ assert_allowed_filename(filename)
26
+ Attachment.new(filename: filename, content: Base64.strict_encode64(content),
27
+ content_type: content_type, content_id: content_id)
28
+ end
29
+
30
+ # Reads a file from disk.
31
+ def from_file(path, content_type: nil, content_id: nil, filename: nil)
32
+ from_bytes(filename || File.basename(path), File.binread(path), content_type: content_type, content_id: content_id)
33
+ end
34
+
35
+ # Local, courtesy-only checks: at most MAX_ATTACHMENTS attachments, at most
36
+ # MAX_TOTAL_BYTES decoded in total. Raises ArgumentError when exceeded; the server
37
+ # re-checks regardless.
38
+ def assert_limits(attachments)
39
+ raise ArgumentError, "Duva: at most #{MAX_ATTACHMENTS} attachments per message" if attachments.size > MAX_ATTACHMENTS
40
+
41
+ total_bytes = attachments.sum { |a| decoded_length(a.content) }
42
+ return unless total_bytes > MAX_TOTAL_BYTES
43
+
44
+ raise ArgumentError, "Duva: attachments are #{total_bytes} bytes decoded, over the #{MAX_TOTAL_BYTES} limit"
45
+ end
46
+
47
+ private
48
+
49
+ def assert_allowed_filename(filename)
50
+ if filename.include?("/") || filename.include?("\\")
51
+ raise ArgumentError,
52
+ "Duva: attachment filename must not contain a path: #{filename}"
53
+ end
54
+
55
+ ext = File.extname(filename).downcase
56
+ raise ArgumentError, "Duva: executable attachments are refused: #{filename}" if FORBIDDEN_EXTENSIONS.include?(ext)
57
+ end
58
+
59
+ def decoded_length(base64)
60
+ padding = if base64.end_with?("==")
61
+ 2
62
+ elsif base64.end_with?("=")
63
+ 1
64
+ else
65
+ 0
66
+ end
67
+ (base64.length * 3 / 4) - padding
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Duva
6
+ # Decides whether a failed request may be retried, exactly per
7
+ # `docs/bibliotheques-clientes.md` section 3.5 in the `duva` repository.
8
+ #
9
+ # @api private
10
+ module RetryPolicy
11
+ module_function
12
+
13
+ # `nil` = do not retry (re-raise); a number = wait this many seconds, then retry.
14
+ def delay_for(error, spec, attempt, config)
15
+ if error.is_a?(RateLimitError)
16
+ return error.retry_after > config.max_retry_wait_seconds ? nil : error.retry_after.to_f
17
+ end
18
+ # A QuotaExceededError (has retry_after too) is never retried automatically; neither is
19
+ # any other DuvaError that isn't a ServerError (4xx: the outcome is already known).
20
+ return nil if error.is_a?(Error) && !error.is_a?(ServerError)
21
+
22
+ transient = error.is_a?(ConnectionError) || error.is_a?(TimeoutError) || error.is_a?(ServerError)
23
+ return nil unless transient && spec.safe_retry && attempt < config.max_retries
24
+
25
+ backoff(attempt)
26
+ end
27
+
28
+ # Exponential backoff with jitter, capped: never a fixed delay, never unbounded.
29
+ def backoff(attempt)
30
+ base = [0.5 * (2.0**attempt), 8.0].min
31
+ (base / 2) + (rand * (base / 2))
32
+ end
33
+ end
34
+ private_constant :RetryPolicy
35
+
36
+ # What `messages.deliver` returns: the accepted message plus the idempotency outcome.
37
+ SendMessageResult = Data.define(:id, :status, :replayed, :location)
38
+
39
+ # A synchronous Duva client, bound to one domain and its API key.
40
+ #
41
+ # duva = Duva::Client.new(api_key: "dv_...", domain: "example.com")
42
+ # message = duva.messages.deliver(
43
+ # from: "Example <notifications@example.com>",
44
+ # to: ["client@example.org"],
45
+ # subject: "Your order",
46
+ # text: "Thank you for your order.",
47
+ # )
48
+ class Client
49
+ # @api private
50
+ attr_reader :config
51
+ attr_reader :messages, :events, :suppressions, :webhooks, :stats
52
+
53
+ def initialize(api_key: nil, domain: nil, base_url: Duva::DEFAULT_BASE_URL, timeout: 10, max_retries: 2,
54
+ max_retry_wait_seconds: 30, language: nil, user_agent: nil, transport: nil)
55
+ @config = Config.resolve(api_key: api_key, domain: domain, base_url: base_url, timeout: timeout,
56
+ max_retries: max_retries, max_retry_wait_seconds: max_retry_wait_seconds,
57
+ language: language, user_agent: user_agent)
58
+ @transport = transport || NetHttpTransport.new
59
+ @messages = MessagesResource.new(self)
60
+ @events = EventsResource.new(self)
61
+ @suppressions = SuppressionsResource.new(self)
62
+ @webhooks = WebhooksResource.new(self)
63
+ @stats = StatsResource.new(self)
64
+ end
65
+
66
+ # `GET /health`, without authentication: `{"status" => "ok"}` when the service works. Raises
67
+ # ServerError on a `503` (its database is unreachable).
68
+ def health
69
+ request(RequestSpec.new(method: "GET", path: "/health", safe_retry: true)).data
70
+ end
71
+
72
+ # @api private
73
+ def request(spec)
74
+ attempt = 0
75
+ loop do
76
+ return @transport.call(@config, spec)
77
+ rescue Error, ConnectionError, TimeoutError => e
78
+ delay = RetryPolicy.delay_for(e, spec, attempt, @config)
79
+ raise e if delay.nil?
80
+
81
+ attempt += 1
82
+ sleep(delay)
83
+ end
84
+ end
85
+ end
86
+
87
+ # @api private
88
+ class MessagesResource
89
+ def initialize(client)
90
+ @client = client
91
+ end
92
+
93
+ # Accepts a message for delivery. Always asynchronous: `queued` never confirms a delivery,
94
+ # only that the message was validated. Read the outcome with `messages.get`, `events.list`,
95
+ # or a webhook. Named `deliver`, not `send`: `Object#send` is a core Ruby method.
96
+ #
97
+ # `idempotency_key:`: unique per domain. A UUID is generated when omitted (see
98
+ # `docs/bibliotheques-clientes.md` section 3.3): a network-level retry of the SAME call can
99
+ # then never create a duplicate message, but two separate calls each get their own random
100
+ # key, so they are NOT deduplicated against each other; pass your own stable key for that
101
+ # (e.g. an order id).
102
+ def deliver(from:, to:, subject:, idempotency_key: nil, **fields)
103
+ body = message_body(from: from, to: to, subject: subject, **fields)
104
+ key = idempotency_key || SecureRandom.uuid
105
+ result = @client.request(RequestSpec.new(method: "POST", path: "#{@client.config.domain_path}/messages",
106
+ body: body, idempotency_key: key, safe_retry: true))
107
+ accepted = MessageAccepted.from_json_hash(result.data)
108
+ SendMessageResult.new(id: accepted.id, status: accepted.status,
109
+ replayed: result.headers["idempotent-replayed"] == "true",
110
+ location: result.headers["location"])
111
+ end
112
+
113
+ # The message's status and each recipient's status.
114
+ def get(id)
115
+ result = @client.request(RequestSpec.new(method: "GET", path: "#{@client.config.domain_path}/messages/#{id}",
116
+ safe_retry: true))
117
+ Message.from_json_hash(result.data)
118
+ end
119
+
120
+ private
121
+
122
+ def message_body(from:, to:, subject:, html: nil, text: nil, tags: nil, tracking: nil, reply_to: nil,
123
+ headers: nil, metadata: nil, attachments: nil)
124
+ Attachments.assert_limits(attachments) if attachments && !attachments.empty?
125
+ body = { "from" => from, "to" => to, "subject" => subject }
126
+ body["html"] = html unless html.nil?
127
+ body["text"] = text unless text.nil?
128
+ body["tags"] = tags unless tags.nil?
129
+ unless tracking.nil?
130
+ body["tracking"] =
131
+ { "opens" => tracking.fetch(:opens, false), "clicks" => tracking.fetch(:clicks, false) }
132
+ end
133
+ body["reply_to"] = reply_to unless reply_to.nil?
134
+ body["headers"] = headers unless headers.nil?
135
+ body["metadata"] = metadata unless metadata.nil?
136
+ body["attachments"] = attachments.map(&:to_h) unless attachments.nil?
137
+ body
138
+ end
139
+ end
140
+
141
+ # @api private
142
+ class EventsResource
143
+ def initialize(client)
144
+ @client = client
145
+ end
146
+
147
+ # One page of delivery events, most recent first.
148
+ def list(message_id: nil, type: nil, recipient: nil, since: nil, limit: nil, cursor: nil)
149
+ query = { "message_id" => message_id, "type" => type, "recipient" => recipient,
150
+ "since" => since&.iso8601, "limit" => limit, "cursor" => cursor }
151
+ result = @client.request(RequestSpec.new(method: "GET", path: "#{@client.config.domain_path}/events", query: query,
152
+ safe_retry: true))
153
+ EventPage.from_json_hash(result.data)
154
+ end
155
+
156
+ # Every delivery event, most recent first, following `next_cursor` automatically. Returns a
157
+ # lazy Enumerator (nothing is fetched until you iterate).
158
+ def list_all(message_id: nil, type: nil, recipient: nil, since: nil, max_items: nil)
159
+ Pagination.paginate(max_items: max_items) do |cursor|
160
+ list(message_id: message_id, type: type, recipient: recipient, since: since, cursor: cursor)
161
+ end
162
+ end
163
+ end
164
+
165
+ # @api private
166
+ class SuppressionsResource
167
+ def initialize(client)
168
+ @client = client
169
+ end
170
+
171
+ # One page of suppressed addresses, most recent first.
172
+ def list(reason: nil, limit: nil, cursor: nil)
173
+ query = { "reason" => reason, "limit" => limit, "cursor" => cursor }
174
+ result = @client.request(RequestSpec.new(method: "GET", path: "#{@client.config.domain_path}/suppressions", query: query,
175
+ safe_retry: true))
176
+ SuppressionPage.from_json_hash(result.data)
177
+ end
178
+
179
+ # Every suppressed address, most recent first, following `next_cursor` automatically.
180
+ def list_all(reason: nil, max_items: nil)
181
+ Pagination.paginate(max_items: max_items) { |cursor| list(reason: reason, cursor: cursor) }
182
+ end
183
+
184
+ # Adds an address by hand (reason `manual`): it receives nothing more from this domain.
185
+ # Naturally idempotent: adding an already-suppressed address changes nothing.
186
+ def add(email)
187
+ result = @client.request(RequestSpec.new(method: "POST", path: "#{@client.config.domain_path}/suppressions",
188
+ body: { "email" => email }, safe_retry: false))
189
+ Suppression.from_json_hash(result.data)
190
+ end
191
+
192
+ # Removes an address from the list: it may receive mail again. Raises NotFoundError if it
193
+ # was not on the list.
194
+ def remove(email)
195
+ path = "#{@client.config.domain_path}/suppressions/#{Config.percent_encode(email)}"
196
+ @client.request(RequestSpec.new(method: "DELETE", path: path, safe_retry: false))
197
+ nil
198
+ end
199
+ end
200
+
201
+ # @api private
202
+ class WebhooksResource
203
+ def initialize(client)
204
+ @client = client
205
+ end
206
+
207
+ # Registers an endpoint. The response carries the signing `secret` (`whsec_...`): shown
208
+ # ONCE, store it to verify signatures. Each call creates a DISTINCT endpoint, never retried
209
+ # automatically.
210
+ def create(url, events: nil)
211
+ result = @client.request(RequestSpec.new(method: "POST", path: "#{@client.config.domain_path}/webhooks",
212
+ body: { "url" => url, "events" => events || [] }, safe_retry: false))
213
+ Webhook.from_json_hash(result.data)
214
+ end
215
+
216
+ def list
217
+ result = @client.request(RequestSpec.new(method: "GET", path: "#{@client.config.domain_path}/webhooks", safe_retry: true))
218
+ WebhookList.from_json_hash(result.data).data
219
+ end
220
+
221
+ def get(id)
222
+ result = @client.request(RequestSpec.new(method: "GET", path: "#{@client.config.domain_path}/webhooks/#{id}",
223
+ safe_retry: true))
224
+ Webhook.from_json_hash(result.data)
225
+ end
226
+
227
+ def delete(id)
228
+ @client.request(RequestSpec.new(method: "DELETE", path: "#{@client.config.domain_path}/webhooks/#{id}", safe_retry: false))
229
+ nil
230
+ end
231
+
232
+ # The latest deliveries of this endpoint (`limit`: 1 to 100, 50 by default).
233
+ def deliveries(id, limit: nil)
234
+ path = "#{@client.config.domain_path}/webhooks/#{id}/deliveries"
235
+ result = @client.request(RequestSpec.new(method: "GET", path: path, query: { "limit" => limit }, safe_retry: true))
236
+ WebhookDeliveryList.from_json_hash(result.data).data
237
+ end
238
+ end
239
+
240
+ # @api private
241
+ class StatsResource
242
+ def initialize(client)
243
+ @client = client
244
+ end
245
+
246
+ # Counters of the domain by period (UTC). Defaults to the last 30 days (or 24 hours, by
247
+ # hour). At most 366 days, or 7 days by hour.
248
+ def get(granularity: nil, since: nil, until_: nil)
249
+ query = { "granularity" => granularity, "since" => since&.iso8601, "until" => until_&.iso8601 }
250
+ result = @client.request(RequestSpec.new(method: "GET", path: "#{@client.config.domain_path}/stats", query: query,
251
+ safe_retry: true))
252
+ Stats.from_json_hash(result.data)
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duva
4
+ # Base of every error raised for a request Duva actually answered (as opposed to a network
5
+ # failure: see ConnectionError and TimeoutError). Mapped from `error.code` (the contract),
6
+ # never from the HTTP status alone or the wording of `error.message` (which can change between
7
+ # languages and over time). See `docs/bibliotheques-clientes.md` section 3.6 in the `duva`
8
+ # repository for the source table.
9
+ class Error < StandardError
10
+ # The HTTP status Duva answered with.
11
+ attr_reader :status
12
+ # `error.code`: the contract. Rely on this, never on the exception message.
13
+ attr_reader :code
14
+ # `error.fields`, when the error is a validation error (`code == "invalid_request"`).
15
+ attr_reader :fields
16
+ # The raw response body, bounded to 4 KB: never includes your API key.
17
+ attr_reader :raw_body
18
+
19
+ def initialize(status:, code:, message:, raw_body:, fields: nil)
20
+ super(message)
21
+ @status = status
22
+ @code = code
23
+ @fields = fields
24
+ @raw_body = raw_body.to_s[0, 4096]
25
+ end
26
+
27
+ # Never leak the raw body (which could, in principle, carry a stray fragment of a request)
28
+ # through the default `#inspect` a debugger or a crash report would print.
29
+ def inspect
30
+ "#<#{self.class}: status=#{status} code=#{code.inspect}>"
31
+ end
32
+ end
33
+
34
+ class AuthenticationError < Error; end
35
+ class NotFoundError < Error; end
36
+
37
+ # `domain_not_verified` or `sending_not_allowed`; `code` distinguishes the two.
38
+ class PermissionError < Error; end
39
+
40
+ # `idempotency_conflict` or `limit_reached`; `code` distinguishes the two.
41
+ class ConflictError < Error; end
42
+
43
+ class PayloadTooLargeError < Error; end
44
+
45
+ class ValidationError < Error
46
+ def initialize(status:, code:, message:, raw_body:, fields: nil)
47
+ super(status: status, code: code, message: message, raw_body: raw_body, fields: fields || [])
48
+ end
49
+ end
50
+
51
+ # A `429` on YOUR ACCOUNT quota (daily or monthly). `retry_after` can be hours: never retried
52
+ # automatically, by design (see `docs/bibliotheques-clientes.md` section 3.5).
53
+ class QuotaExceededError < Error
54
+ attr_reader :retry_after
55
+
56
+ def initialize(status:, code:, message:, raw_body:, fields:, retry_after:)
57
+ super(status: status, code: code, message: message, raw_body: raw_body, fields: fields)
58
+ @retry_after = retry_after
59
+ end
60
+ end
61
+
62
+ # A `429` from the per-key rate limit (unrelated to your sending quota). Retried automatically
63
+ # when `retry_after` fits within `max_retry_wait_seconds`.
64
+ class RateLimitError < Error
65
+ attr_reader :retry_after
66
+
67
+ def initialize(status:, code:, message:, raw_body:, fields:, retry_after:)
68
+ super(status: status, code: code, message: message, raw_body: raw_body, fields: fields)
69
+ @retry_after = retry_after
70
+ end
71
+ end
72
+
73
+ # A `5xx`, or a response whose body was not the documented error envelope.
74
+ class ServerError < Error; end
75
+
76
+ # No response was received at all (DNS, TLS, connection refused, connection reset...). The
77
+ # original exception is kept as `#cause` (Ruby's own chaining, via `raise ... from:`-less
78
+ # `raise` inside a `rescue` block).
79
+ class ConnectionError < StandardError; end
80
+
81
+ # The request exceeded `timeout` before any response arrived.
82
+ class TimeoutError < StandardError; end
83
+
84
+ # A webhook signature failed to verify: never carries the secret or the raw body.
85
+ class WebhookSignatureError < StandardError; end
86
+
87
+ # @api private
88
+ CODES = {
89
+ "unauthorized" => AuthenticationError,
90
+ "not_found" => NotFoundError,
91
+ "domain_not_verified" => PermissionError,
92
+ "sending_not_allowed" => PermissionError,
93
+ "idempotency_conflict" => ConflictError,
94
+ "limit_reached" => ConflictError,
95
+ "payload_too_large" => PayloadTooLargeError,
96
+ "invalid_request" => ValidationError,
97
+ "internal_error" => ServerError,
98
+ "method_not_allowed" => ServerError,
99
+ "http_error" => ServerError
100
+ }.freeze
101
+ private_constant :CODES
102
+
103
+ # Builds the right Error subclass from a parsed response body, or a generic ServerError when
104
+ # the body does not match the documented envelope (a proxy error page, for instance): never
105
+ # raises itself.
106
+ #
107
+ # @api private
108
+ def self.error_from_response(status, parsed_body, raw_body, retry_after_header)
109
+ code, message, fields = as_error_body(parsed_body)
110
+ retry_after = retry_after_header ? retry_after_header.to_i : 0
111
+ case code
112
+ when "quota_exceeded"
113
+ QuotaExceededError.new(status: status, code: code, message: message, raw_body: raw_body,
114
+ fields: fields, retry_after: retry_after)
115
+ when "rate_limited"
116
+ RateLimitError.new(status: status, code: code, message: message, raw_body: raw_body,
117
+ fields: fields, retry_after: retry_after)
118
+ else
119
+ klass = CODES.fetch(code, ServerError)
120
+ klass.new(status: status, code: code, message: message, raw_body: raw_body, fields: fields)
121
+ end
122
+ end
123
+
124
+ # @api private
125
+ def self.as_error_body(value)
126
+ error = value.is_a?(Hash) && value["error"].is_a?(Hash) ? value["error"] : nil
127
+ return ["http_error", "Duva answered with an unexpected body.", nil] unless error.is_a?(Hash) && error["code"].is_a?(String)
128
+
129
+ [error["code"], error["message"] || "", error["fields"]]
130
+ end
131
+ private_class_method :as_error_body
132
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Duva
6
+ # Hand-written response value objects (Ruby's typing is optional per
7
+ # `docs/bibliotheques-clientes.md` section 4 -- RBS/Sorbet are left to the application, unlike
8
+ # the generated models of the Node.js and Python libraries). Each `.from_json_hash` factory
9
+ # reads only the fields it knows and ignores the rest, and never restricts a string enum-like
10
+ # field (`status`, `type`, `reason`...) to a fixed set: a value the server adds later still
11
+ # comes through as plain text rather than raising (section 3.7, "tolerance").
12
+ #
13
+ # @api private
14
+ module Models
15
+ def self.time(value)
16
+ value && Time.iso8601(value)
17
+ end
18
+ end
19
+ private_constant :Models
20
+
21
+ Tracking = Data.define(:opens, :clicks) do
22
+ def self.from_json_hash(h)
23
+ new(opens: h["opens"] || false, clicks: h["clicks"] || false)
24
+ end
25
+ end
26
+
27
+ Recipient = Data.define(:email, :status, :updated_at) do
28
+ def self.from_json_hash(h)
29
+ new(email: h["email"], status: h["status"], updated_at: Models.time(h["updated_at"]))
30
+ end
31
+ end
32
+
33
+ Message = Data.define(:id, :status, :from, :subject, :tags, :metadata, :tracking, :created_at, :recipients) do
34
+ def self.from_json_hash(h)
35
+ new(
36
+ id: h["id"], status: h["status"], from: h["from"], subject: h["subject"],
37
+ tags: h["tags"] || [], metadata: h["metadata"] || {},
38
+ tracking: Tracking.from_json_hash(h["tracking"] || {}),
39
+ created_at: Models.time(h["created_at"]),
40
+ recipients: (h["recipients"] || []).map { |r| Recipient.from_json_hash(r) }
41
+ )
42
+ end
43
+ end
44
+
45
+ MessageAccepted = Data.define(:id, :status) do
46
+ def self.from_json_hash(h)
47
+ new(id: h["id"], status: h["status"])
48
+ end
49
+ end
50
+
51
+ Event = Data.define(:id, :type, :message_id, :recipient, :occurred_at, :detail, :metadata) do
52
+ def self.from_json_hash(h)
53
+ new(
54
+ id: h["id"], type: h["type"], message_id: h["message_id"], recipient: h["recipient"],
55
+ occurred_at: Models.time(h["occurred_at"]), detail: h["detail"] || {}, metadata: h["metadata"] || {}
56
+ )
57
+ end
58
+ end
59
+
60
+ EventPage = Data.define(:data, :next_cursor) do
61
+ def self.from_json_hash(h)
62
+ new(data: (h["data"] || []).map { |e| Event.from_json_hash(e) }, next_cursor: h["next_cursor"])
63
+ end
64
+ end
65
+
66
+ Suppression = Data.define(:email, :reason, :message_id, :created_at) do
67
+ def self.from_json_hash(h)
68
+ new(email: h["email"], reason: h["reason"], message_id: h["message_id"], created_at: Models.time(h["created_at"]))
69
+ end
70
+ end
71
+
72
+ SuppressionPage = Data.define(:data, :next_cursor) do
73
+ def self.from_json_hash(h)
74
+ new(data: (h["data"] || []).map { |s| Suppression.from_json_hash(s) }, next_cursor: h["next_cursor"])
75
+ end
76
+ end
77
+
78
+ Webhook = Data.define(:id, :url, :events, :status, :disabled_reason, :created_at, :secret) do
79
+ def self.from_json_hash(h)
80
+ new(
81
+ id: h["id"], url: h["url"], events: h["events"] || [], status: h["status"],
82
+ disabled_reason: h["disabled_reason"], created_at: Models.time(h["created_at"]), secret: h["secret"]
83
+ )
84
+ end
85
+ end
86
+
87
+ WebhookList = Data.define(:data) do
88
+ def self.from_json_hash(h)
89
+ new(data: (h["data"] || []).map { |w| Webhook.from_json_hash(w) })
90
+ end
91
+ end
92
+
93
+ WebhookDelivery = Data.define(:id, :event_id, :status, :attempts, :last_status_code, :last_error, :created_at, :delivered_at) do
94
+ def self.from_json_hash(h)
95
+ new(
96
+ id: h["id"], event_id: h["event_id"], status: h["status"], attempts: h["attempts"],
97
+ last_status_code: h["last_status_code"], last_error: h["last_error"],
98
+ created_at: Models.time(h["created_at"]), delivered_at: Models.time(h["delivered_at"])
99
+ )
100
+ end
101
+ end
102
+
103
+ WebhookDeliveryList = Data.define(:data) do
104
+ def self.from_json_hash(h)
105
+ new(data: (h["data"] || []).map { |d| WebhookDelivery.from_json_hash(d) })
106
+ end
107
+ end
108
+
109
+ StatsPeriod = Data.define(:period, :accepted, :suppressed, :delivered, :bounced, :deferred, :expired, :complained, :opened,
110
+ :clicked) do
111
+ def self.from_json_hash(h)
112
+ new(
113
+ period: Models.time(h["period"]), accepted: h["accepted"], suppressed: h["suppressed"],
114
+ delivered: h["delivered"], bounced: h["bounced"], deferred: h["deferred"], expired: h["expired"],
115
+ complained: h["complained"], opened: h["opened"], clicked: h["clicked"]
116
+ )
117
+ end
118
+ end
119
+
120
+ Stats = Data.define(:granularity, :since, :until, :data, :totals) do
121
+ def self.from_json_hash(h)
122
+ new(
123
+ granularity: h["granularity"], since: Models.time(h["since"]), until: Models.time(h["until"]),
124
+ data: (h["data"] || []).map { |p| StatsPeriod.from_json_hash(p) }, totals: h["totals"] || {}
125
+ )
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duva
4
+ # Cursor pagination, shared by `events.list_all` and `suppressions.list_all`: follows
5
+ # `next_cursor` until it is `nil`, without ever loading every page into memory at once.
6
+ #
7
+ # @api private
8
+ module Pagination
9
+ # `fetch_page` is called with the current cursor (`nil` for the first page) and must return
10
+ # an object responding to `#data` (an Array) and `#next_cursor`. Returns a lazy Enumerator:
11
+ # nothing is fetched until the caller actually iterates (`each`, `first`, `to_a`...).
12
+ def self.paginate(max_items: nil, &fetch_page)
13
+ Enumerator.new do |yielder|
14
+ cursor = nil
15
+ yielded = 0
16
+ catch(:duva_pagination_done) do
17
+ loop do
18
+ page = fetch_page.call(cursor)
19
+ page.data.each do |item|
20
+ yielder << item
21
+ yielded += 1
22
+ throw :duva_pagination_done if max_items && yielded >= max_items
23
+ end
24
+ break if page.next_cursor.nil?
25
+
26
+ cursor = page.next_cursor
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duva
4
+ # A scriptable fake Transport (`docs/bibliotheques-clientes.md` section 3.10: "a fake
5
+ # transport and a response builder, so you can test YOUR OWN application without a network
6
+ # call"). Inject it via `Client.new(transport: ...)`.
7
+ #
8
+ # transport = Duva::TestTransport.new { |_config, spec| Duva::TestTransport.response({"status" => "ok"}) }
9
+ # duva = Duva::Client.new(api_key: "dv_test", domain: "example.com", transport: transport)
10
+ # duva.health
11
+ # transport.requests.last.path # => "/health"
12
+ class TestTransport
13
+ # Every RequestSpec actually sent, in order: inspect it to assert what your code sent.
14
+ attr_reader :requests
15
+
16
+ # `handler` receives `(config, spec)` for each call and must return a RawResponse (see
17
+ # .response) or raise a Duva::Error subclass, Duva::ConnectionError or Duva::TimeoutError.
18
+ def initialize(&handler)
19
+ @handler = handler
20
+ @requests = []
21
+ end
22
+
23
+ # @api private
24
+ def call(config, spec)
25
+ @requests << spec
26
+ @handler.call(config, spec)
27
+ end
28
+
29
+ # Builds a RawResponse from a plain Hash (or nil, for a 204), as your handler returns it.
30
+ def self.response(data = nil, headers: {})
31
+ RawResponse.new(data: data, headers: headers.transform_keys { |k| k.to_s.downcase })
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Duva
8
+ DEFAULT_BASE_URL = "https://api.duva.ca"
9
+
10
+ # Resolved, validated client configuration.
11
+ Config = Data.define(:api_key, :domain, :base_url, :timeout, :max_retries, :max_retry_wait_seconds,
12
+ :language, :user_agent) do
13
+ class << self
14
+ def resolve(api_key: nil, domain: nil, base_url: DEFAULT_BASE_URL, timeout: 10, max_retries: 2,
15
+ max_retry_wait_seconds: 30, language: nil, user_agent: nil)
16
+ resolved_key = api_key || ENV.fetch("DUVA_API_KEY", nil)
17
+ resolved_domain = domain || ENV.fetch("DUVA_DOMAIN", nil)
18
+ raise ArgumentError, "Duva: an API key is required (api_key: or DUVA_API_KEY)" unless resolved_key
19
+ raise ArgumentError, "Duva: a domain is required (domain: or DUVA_DOMAIN)" unless resolved_domain
20
+
21
+ clean_base_url = base_url.chomp("/")
22
+ unless clean_base_url.start_with?("https://") || clean_base_url.include?("localhost")
23
+ raise ArgumentError, "Duva: base_url must be https:// (http://localhost is allowed for tests)"
24
+ end
25
+
26
+ new(api_key: resolved_key, domain: resolved_domain, base_url: clean_base_url, timeout: timeout,
27
+ max_retries: max_retries, max_retry_wait_seconds: max_retry_wait_seconds, language: language,
28
+ user_agent: user_agent)
29
+ end
30
+
31
+ # RFC 3986 percent-encoding with no "safe" characters (mirrors Python's
32
+ # `urllib.parse.quote(s, safe="")`): every octet outside unreserved (`A-Za-z0-9-_.~`) is
33
+ # escaped, including `/`.
34
+ def percent_encode(str)
35
+ str.b.gsub(/[^A-Za-z0-9\-_.~]/) { |char| format("%%%02X", char.ord) }
36
+ end
37
+ end
38
+
39
+ # The path prefix for this client's domain (`/v1/<domain>`).
40
+ def domain_path
41
+ "/v1/#{self.class.percent_encode(domain)}"
42
+ end
43
+
44
+ def user_agent_header
45
+ extra = user_agent ? " #{user_agent}" : ""
46
+ "duva-ruby/#{Duva::VERSION} Ruby/#{RUBY_VERSION}#{extra}"
47
+ end
48
+ end
49
+
50
+ # One HTTP call to make: built by Client, executed by a Transport.
51
+ RequestSpec = Data.define(:method, :path, :query, :body, :idempotency_key, :safe_retry) do
52
+ def initialize(method:, path:, query: nil, body: nil, idempotency_key: nil, safe_retry: false)
53
+ super
54
+ end
55
+ end
56
+
57
+ # A parsed response: `data` is the parsed JSON body (or nil for a 204 or an empty body),
58
+ # `headers` a Hash with lower-cased keys.
59
+ RawResponse = Data.define(:data, :headers)
60
+
61
+ # Builds the wire-level shape of a request (method, URI, headers, body) from a Config and a
62
+ # RequestSpec: a PURE function, with no I/O, so it can be checked directly (see
63
+ # `spec/conformance/requests_spec.rb`) without a network call or a fake transport.
64
+ #
65
+ # @api private
66
+ module HttpMessage
67
+ def self.build(config, spec)
68
+ uri = URI.parse(config.base_url + spec.path)
69
+ uri.query = URI.encode_www_form(spec.query.compact) if spec.query && !spec.query.compact.empty?
70
+ headers = {
71
+ "authorization" => "Bearer #{config.api_key}",
72
+ "user-agent" => config.user_agent_header
73
+ }
74
+ headers["accept-language"] = config.language if config.language
75
+ headers["idempotency-key"] = spec.idempotency_key if spec.idempotency_key
76
+ body = nil
77
+ if spec.body
78
+ headers["content-type"] = "application/json"
79
+ body = JSON.generate(spec.body)
80
+ end
81
+ { method: spec.method, uri: uri, headers: headers, body: body }
82
+ end
83
+ end
84
+
85
+ # Default transport: plain `Net::HTTP` (no gem dependency). Any object responding to
86
+ # `#call(config, spec) -> RawResponse` (raising ConnectionError/TimeoutError/a Duva::Error
87
+ # subclass as appropriate) can be injected instead -- for your own tests (see TestTransport) or
88
+ # to plug in Faraday or another HTTP client.
89
+ class NetHttpTransport
90
+ def call(config, spec)
91
+ built = HttpMessage.build(config, spec)
92
+ request = build_net_http_request(built)
93
+ response = perform(built[:uri], request, config.timeout)
94
+ parse(response)
95
+ rescue Net::OpenTimeout, Net::ReadTimeout
96
+ raise Duva::TimeoutError, "Duva: request timed out"
97
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, OpenSSL::SSL::SSLError, IOError => e
98
+ raise Duva::ConnectionError, "Duva: the request could not be sent: #{e.message}"
99
+ end
100
+
101
+ private
102
+
103
+ def build_net_http_request(built)
104
+ request_class = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post, "DELETE" => Net::HTTP::Delete }.fetch(built[:method])
105
+ request = request_class.new(built[:uri])
106
+ built[:headers].each { |name, value| request[name] = value }
107
+ request.body = built[:body] if built[:body]
108
+ request
109
+ end
110
+
111
+ def perform(uri, request, timeout)
112
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: timeout, read_timeout: timeout) do |http|
113
+ http.request(request)
114
+ end
115
+ end
116
+
117
+ def parse(response)
118
+ headers = {}
119
+ response.each_header { |name, value| headers[name] = value }
120
+ body = response.body
121
+ data = body && !body.empty? ? JSON.parse(body) : nil
122
+ code = response.code.to_i
123
+ return RawResponse.new(data: data, headers: headers) if code < 300
124
+
125
+ raise Duva.error_from_response(code, data, body.to_s, headers["retry-after"])
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module Duva
8
+ # The JSON body Duva sends to your webhook URL, already parsed.
9
+ WebhookEvent = Data.define(:id, :type, :domain, :data)
10
+
11
+ # Webhook signature verification, "Standard Webhooks" format (see `docs/api.md` "Webhooks" in
12
+ # the `duva` repository). Duva SENDS webhooks; this module is for VERIFYING them on your side.
13
+ module Webhooks
14
+ SECRET_PREFIX = "whsec_"
15
+ DEFAULT_TOLERANCE_SECONDS = 300
16
+ private_constant :SECRET_PREFIX, :DEFAULT_TOLERANCE_SECONDS
17
+
18
+ class << self
19
+ # Verifies a webhook request. `secrets` accepts one secret or an array (for key rotation:
20
+ # while both the old and the new secret are active, a webhook signed with either must
21
+ # verify).
22
+ #
23
+ # `headers` is looked up case-insensitively (whatever your framework hands you is not
24
+ # guaranteed to have lower-case keys). `raw_body` must be the EXACT bytes Duva sent: re-
25
+ # encoding a parsed-then-re-serialized JSON body changes its bytes and invalidates every
26
+ # signature.
27
+ #
28
+ # `now`: the current instant as a Unix timestamp in seconds. Defaults to `Time.now.to_i`;
29
+ # override only in your OWN tests (see .sign and the fixtures of `duva-mail/duva-conformance`,
30
+ # which document the exact instant each vector was signed at).
31
+ def verify_signature(secrets, headers, raw_body, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil)
32
+ event_id = header(headers, "webhook-id")
33
+ timestamp = header(headers, "webhook-timestamp")
34
+ signature_header = header(headers, "webhook-signature")
35
+ return false if event_id.nil? || timestamp.nil? || signature_header.nil? || signature_header.empty?
36
+
37
+ at = Integer(timestamp, exception: false)
38
+ return false if at.nil?
39
+
40
+ current = now || Time.now.to_i
41
+ return false if (current - at).abs > tolerance_seconds
42
+
43
+ secret_list = secrets.is_a?(Array) ? secrets : [secrets]
44
+ expected = expected_signatures(secret_list, event_id, timestamp, raw_body)
45
+ return false if expected.nil?
46
+
47
+ # `webhook-signature` may carry several space-separated `v1,<signature>` entries (Duva
48
+ # sends one; a sender that itself rotates its OWN signing key mid-flight could send
49
+ # more): any match against any of your active secrets is accepted.
50
+ signature_header.split.any? do |part|
51
+ version, _, signature = part.partition(",")
52
+ next false if version != "v1" || signature.empty?
53
+
54
+ expected.any? { |candidate| secure_compare(signature, candidate) }
55
+ end
56
+ end
57
+
58
+ # .verify_signature, then parses the body: raises WebhookSignatureError on a bad signature
59
+ # rather than returning a boolean, for call sites that want to raise on failure. Never
60
+ # includes the secret or the raw body in the error.
61
+ def construct_event(secrets, headers, raw_body, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil)
62
+ unless verify_signature(secrets, headers, raw_body, tolerance_seconds: tolerance_seconds, now: now)
63
+ raise WebhookSignatureError, "webhook signature verification failed"
64
+ end
65
+
66
+ parsed = JSON.parse(raw_body)
67
+ WebhookEvent.new(id: parsed["id"], type: parsed["type"], domain: parsed["domain"], data: parsed["data"])
68
+ end
69
+
70
+ # Builds a validly signed request FOR YOUR OWN TESTS: the headers a real Duva webhook
71
+ # delivery would carry for `body`, signed with `secret` as of `timestamp` (Unix seconds;
72
+ # defaults to now). Never used by the library itself to send anything: Duva is the only
73
+ # real sender.
74
+ def sign(secret, event_id, body, timestamp: nil)
75
+ at = timestamp || Time.now.to_i
76
+ ts = at.to_s
77
+ signature = expected_signature(secret, event_id, ts, body)
78
+ {
79
+ "content-type" => "application/json",
80
+ "webhook-id" => event_id,
81
+ "webhook-timestamp" => ts,
82
+ "webhook-signature" => "v1,#{signature}"
83
+ }
84
+ end
85
+
86
+ private
87
+
88
+ def header(headers, name)
89
+ match = headers.find { |key, _| key.to_s.downcase == name }
90
+ match && match[1]
91
+ end
92
+
93
+ def decode_secret(secret)
94
+ raise WebhookSignatureError, "a Duva webhook secret starts with whsec_" unless secret.start_with?(SECRET_PREFIX)
95
+
96
+ Base64.strict_decode64(secret.delete_prefix(SECRET_PREFIX))
97
+ rescue ArgumentError
98
+ raise WebhookSignatureError, "a Duva webhook secret starts with whsec_"
99
+ end
100
+
101
+ def expected_signature(secret, event_id, timestamp, raw_body)
102
+ key = decode_secret(secret)
103
+ digest = OpenSSL::HMAC.digest("SHA256", key, "#{event_id}.#{timestamp}.#{raw_body}")
104
+ Base64.strict_encode64(digest)
105
+ end
106
+
107
+ def expected_signatures(secret_list, event_id, timestamp, raw_body)
108
+ secret_list.map { |secret| expected_signature(secret, event_id, timestamp, raw_body) }
109
+ rescue WebhookSignatureError
110
+ nil
111
+ end
112
+
113
+ def secure_compare(left, right)
114
+ OpenSSL.secure_compare(left, right)
115
+ rescue TypeError, ArgumentError
116
+ false # secure_compare requires equal-length strings; a mismatched length is just "false"
117
+ end
118
+ end
119
+ end
120
+ end
data/lib/duva-mail.rb CHANGED
@@ -1,2 +1,4 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Convention RubyGems pour un nom a trait d union : `require "duva-mail"` charge la bibliotheque comme `require "duva"`.
2
4
  require_relative "duva"
data/lib/duva.rb CHANGED
@@ -1,4 +1,15 @@
1
- # Reserved name: the official Duva client library is not released yet. See https://duva.ca
1
+ # frozen_string_literal: true
2
+
2
3
  module Duva
3
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
4
5
  end
6
+
7
+ require_relative "duva/errors"
8
+ require_relative "duva/models"
9
+ require_relative "duva/pagination"
10
+ require_relative "duva/transport"
11
+ require_relative "duva/test_transport"
12
+ require_relative "duva/client"
13
+ require_relative "duva/webhooks"
14
+ require_relative "duva/attachments"
15
+ require_relative "duva/address"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: duva-mail
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - 9573-4562 Québec inc.
@@ -9,9 +9,23 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-09-27 00:00:00.000000000 Z
12
- dependencies: []
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
13
27
  description: 'Official client library for Duva, a transactional email API hosted in
14
- Canada. This name is reserved: the library is not released yet.'
28
+ Canada: sending, delivery events, suppressions, webhooks and statistics.'
15
29
  email:
16
30
  executables: []
17
31
  extensions: []
@@ -19,14 +33,25 @@ extra_rdoc_files: []
19
33
  files:
20
34
  - LICENSE
21
35
  - README.md
36
+ - duva-mail.gemspec
22
37
  - lib/duva-mail.rb
23
38
  - lib/duva.rb
39
+ - lib/duva/address.rb
40
+ - lib/duva/attachments.rb
41
+ - lib/duva/client.rb
42
+ - lib/duva/errors.rb
43
+ - lib/duva/models.rb
44
+ - lib/duva/pagination.rb
45
+ - lib/duva/test_transport.rb
46
+ - lib/duva/transport.rb
47
+ - lib/duva/webhooks.rb
24
48
  homepage: https://duva.ca
25
49
  licenses:
26
50
  - MIT
27
51
  metadata:
28
52
  source_code_uri: https://github.com/duva-mail/duva-ruby
29
53
  documentation_uri: https://duva.ca/en/docs
54
+ changelog_uri: https://duva.ca/en/docs#changelog
30
55
  rubygems_mfa_required: 'true'
31
56
  post_install_message:
32
57
  rdoc_options: []
@@ -43,8 +68,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
43
68
  - !ruby/object:Gem::Version
44
69
  version: '0'
45
70
  requirements: []
46
- rubygems_version: 3.3.15
71
+ rubygems_version: 3.5.22
47
72
  signing_key:
48
73
  specification_version: 4
49
- summary: Official Duva client library (name reserved, not released yet)
74
+ summary: Official Duva client library (transactional email API hosted in Canada).
50
75
  test_files: []