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 +4 -4
- data/README.md +137 -8
- data/duva-mail.gemspec +28 -0
- data/lib/duva/address.rb +46 -0
- data/lib/duva/attachments.rb +71 -0
- data/lib/duva/client.rb +255 -0
- data/lib/duva/errors.rb +132 -0
- data/lib/duva/models.rb +128 -0
- data/lib/duva/pagination.rb +32 -0
- data/lib/duva/test_transport.rb +34 -0
- data/lib/duva/transport.rb +128 -0
- data/lib/duva/webhooks.rb +120 -0
- data/lib/duva-mail.rb +2 -0
- data/lib/duva.rb +13 -2
- metadata +30 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b62be0aef438ad137fa4db07aae85263042d924b2db6c50d70347301f62f2737
|
|
4
|
+
data.tar.gz: 231d12564797d902d3398972da4809b2e3c8b1488f9a6e7750efa2ad15bca89a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
3
|
+
The official [Duva](https://duva.ca) client library for Ruby. Duva is a transactional email API
|
|
4
|
+
hosted in Canada.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
14
|
+
## Sending a message
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
```ruby
|
|
17
|
+
require "duva"
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
data/lib/duva/address.rb
ADDED
|
@@ -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
|
data/lib/duva/client.rb
ADDED
|
@@ -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
|
data/lib/duva/errors.rb
ADDED
|
@@ -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
|
data/lib/duva/models.rb
ADDED
|
@@ -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
data/lib/duva.rb
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Duva
|
|
3
|
-
VERSION = "0.0
|
|
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
|
|
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
|
|
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.
|
|
71
|
+
rubygems_version: 3.5.22
|
|
47
72
|
signing_key:
|
|
48
73
|
specification_version: 4
|
|
49
|
-
summary: Official Duva client library (
|
|
74
|
+
summary: Official Duva client library (transactional email API hosted in Canada).
|
|
50
75
|
test_files: []
|