sendclipper 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 +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +206 -0
- data/lib/sendclipper/client.rb +41 -0
- data/lib/sendclipper/errors.rb +77 -0
- data/lib/sendclipper/http_client.rb +259 -0
- data/lib/sendclipper/resources/audience.rb +105 -0
- data/lib/sendclipper/resources/base.rb +85 -0
- data/lib/sendclipper/resources/domains.rb +35 -0
- data/lib/sendclipper/resources/emails.rb +62 -0
- data/lib/sendclipper/resources/mailboxes.rb +63 -0
- data/lib/sendclipper/resources/sandboxes.rb +99 -0
- data/lib/sendclipper/resources/templates.rb +92 -0
- data/lib/sendclipper/resources/webhooks.rb +70 -0
- data/lib/sendclipper/resources/workspace.rb +22 -0
- data/lib/sendclipper/result.rb +50 -0
- data/lib/sendclipper/version.rb +6 -0
- data/lib/sendclipper/webhook.rb +114 -0
- data/lib/sendclipper.rb +30 -0
- metadata +66 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a3f2ca79b7b603e79b2a71e81fd3bf8033e705b0c76171c8a69197db1e08cf45
|
|
4
|
+
data.tar.gz: f97e10cb672f61fb581b866d02fb884b62485bfbc1c63a2721d3e1c116d6223b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cef70da38935cb7581232e23fe51041f5b0a9f2d3cb845b956dfdb3608762b4165c78b7fd17af857f99ba2301102167a841139e37c0cdb00b3337f3ebe21d95e
|
|
7
|
+
data.tar.gz: fb85cd253f346b37a46907366bf8cfa8ce8459b3f731ce1d4d7089ff17bf0839094f2d1826950cf1f644e54c0d7f128140fbe0d1b465acd1bd1bf9ef60faed8e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-16
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `emails.send`, `batch.send`, `emails.get`, `emails.list`, `emails.each`, `emails.stats`.
|
|
8
|
+
- Templates (with tags and image assets), domains, sandboxes (including the JSON door and `wait_for`), webhooks and their dispatches, contacts, audiences, suppressions, mailboxes, automations, workspace and API keys.
|
|
9
|
+
- Every method returns a `SendClipper::Result` with `data`, `error` and `headers`; it destructures to `data, error` and pattern-matches.
|
|
10
|
+
- Fields can be keyword arguments in snake_case (`template_id:`), turned into the API's camelCase.
|
|
11
|
+
- Automatic `Idempotency-Key` on every JSON write; retries on network errors, 429 and 5xx.
|
|
12
|
+
- `SendClipper.verify_webhook` for `X-SendClipper-Signature-V2`, rotation-aware.
|
|
13
|
+
- Ruby 3.1 or newer; no runtime dependencies.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SendClipper
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# SendClipper Ruby SDK
|
|
2
|
+
|
|
3
|
+
The official Ruby library for the [SendClipper](https://sendclipper.com) email API.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
gem install sendclipper
|
|
7
|
+
# or, in a Gemfile
|
|
8
|
+
bundle add sendclipper
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Ruby 3.1 or newer. No runtime dependencies.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
Create an API key in the dashboard under **API keys**.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "sendclipper"
|
|
19
|
+
|
|
20
|
+
client = SendClipper::Client.new("sc_live_xxxxxxxxxxxxxxxx")
|
|
21
|
+
# or: SendClipper::Client.new reads SENDCLIPPER_API_KEY
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Send an email
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
data, error = client.emails.send(
|
|
28
|
+
from: "hello@send.acme.io",
|
|
29
|
+
to: "ada@lumen.io",
|
|
30
|
+
subject: "Your receipt",
|
|
31
|
+
html: "<p>Thanks for your order.</p>",
|
|
32
|
+
text: "Thanks for your order."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if error
|
|
36
|
+
puts "#{error.name}: #{error.message}" # e.g. domain_not_verified
|
|
37
|
+
else
|
|
38
|
+
puts "Email #{data["id"]} accepted"
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Fields are keyword arguments (or a Hash). Top-level names may be snake_case (`template_id:`, `send_class:`), which the SDK turns into the API's camelCase, or the API's own names exactly as the [API reference](https://sendclipper.com/docs) writes them. Values, such as the `variables` map, are sent exactly as given. Responses are Hashes with the API's string keys (`data["id"]`).
|
|
43
|
+
|
|
44
|
+
`from` must be a bare address on a domain you verified in SendClipper. Each message has one recipient in `to`; for several, use `batch.send`.
|
|
45
|
+
|
|
46
|
+
## Send with a template
|
|
47
|
+
|
|
48
|
+
Design and publish a template in the dashboard, then send it by id or by its send name:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
client.emails.send(
|
|
52
|
+
from: "hello@send.acme.io",
|
|
53
|
+
to: "ada@lumen.io",
|
|
54
|
+
template_id: "tpl_9f2b7c10ad42", # or template: "welcome"
|
|
55
|
+
variables: { first_name: "Ada" }
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A tag with no value and no `{{tag|fallback}}` is refused by name (`template_variables_missing`), never sent blank.
|
|
60
|
+
|
|
61
|
+
## Send a batch
|
|
62
|
+
|
|
63
|
+
Up to 500 messages in one request. Each item is accepted or refused on its own:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
data, = client.batch.send([
|
|
67
|
+
{ from: "hello@send.acme.io", to: "ada@lumen.io", subject: "Hi Ada", html: "<p>Hi</p>" },
|
|
68
|
+
{ from: "hello@send.acme.io", to: "alan@lumen.io", template_id: "tpl_9f2b7c10ad42", variables: { first_name: "Alan" } }
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
data["data"].each do |item|
|
|
72
|
+
puts "#{item["index"]} #{item["error"]["code"]}" if item["status"] == "failed"
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Errors
|
|
77
|
+
|
|
78
|
+
API methods never raise for an API refusal or a network failure. They return a `SendClipper::Result` with `data`, `error` and `headers`, and exactly one of `data` and `error` is set. A Result destructures to `data, error` and pattern-matches:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
result = client.domains.list
|
|
82
|
+
result.data # the parsed response, or nil
|
|
83
|
+
result.error # a SendClipper::ErrorResponse, or nil
|
|
84
|
+
result.headers # response headers (lower-case names), or nil when no answer arrived
|
|
85
|
+
result.ok? # error.nil?
|
|
86
|
+
|
|
87
|
+
domains = client.domains.list.unwrap # data, or raises SendClipper::ApiError
|
|
88
|
+
|
|
89
|
+
case client.emails.send(**params)
|
|
90
|
+
in { error: nil, data: } then puts data["id"]
|
|
91
|
+
in { error: { name: "rate_limited" } } then retry_later
|
|
92
|
+
in { error: } then warn error.message
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`ErrorResponse` has `name` (SendClipper's code: `validation_failed`, `rate_limited`, `network_error`, ...), `message` (a sentence for a person), `status_code` (`nil` when no answer arrived) and `detail` (code-specific facts).
|
|
97
|
+
|
|
98
|
+
The iterators (`emails.each` and friends) raise a `SendClipper::ApiError` instead, since a loop has no `error` to hand back. `e.code` is the API's code and `e.status` the HTTP status.
|
|
99
|
+
|
|
100
|
+
## Retries and idempotency
|
|
101
|
+
|
|
102
|
+
Network errors, `429` and `5xx` answers are retried twice with backoff, honouring `Retry-After`. Every JSON write carries an `Idempotency-Key`, and the API answers a repeated key with the first result for 24 hours. A retried send therefore never goes out twice.
|
|
103
|
+
|
|
104
|
+
To make your own retries safe as well (a job that runs again after a crash), pass a key that names the logical operation:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
client.emails.send(**params, idempotency_key: "order-#{order.id}-receipt")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Request options
|
|
111
|
+
|
|
112
|
+
Every method takes request options as keyword arguments alongside its fields:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
client.emails.list(status: "bounced", timeout: 5, headers: { "X-Trace-Id" => trace_id })
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
| Option | Meaning |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `idempotency_key:` | Your own key for a write |
|
|
121
|
+
| `timeout:` | Per-attempt timeout in seconds (default 30) |
|
|
122
|
+
| `max_retries:` | Retries for this call (default 2) |
|
|
123
|
+
| `headers:` | Extra request headers |
|
|
124
|
+
|
|
125
|
+
Client options: `SendClipper::Client.new(key, base_url:, timeout:, max_retries:, default_headers:, transport:)`. `base_url` also reads `SENDCLIPPER_BASE_URL`. `transport:` replaces the Net::HTTP sender: anything that responds to `call(SendClipper::TransportRequest)` and returns a `SendClipper::TransportResponse`.
|
|
126
|
+
|
|
127
|
+
## Iterate
|
|
128
|
+
|
|
129
|
+
Lists that span pages have an `each` method; without a block it returns a lazy Enumerator:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
client.emails.each(status: "bounced") { |email| puts email["to"] }
|
|
133
|
+
first_ten = client.suppressions.each.first(10)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Verify webhooks
|
|
137
|
+
|
|
138
|
+
SendClipper signs each delivery. Verify it against the **raw** body, before parsing:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
class SendClipperWebhooksController < ActionController::API
|
|
142
|
+
def create
|
|
143
|
+
event = SendClipper.verify_webhook(
|
|
144
|
+
request.raw_post, # the raw body, not params
|
|
145
|
+
request.headers,
|
|
146
|
+
ENV.fetch("SENDCLIPPER_WEBHOOK_SECRET")
|
|
147
|
+
)
|
|
148
|
+
# event["id"] is stable across retries: deduplicate on it
|
|
149
|
+
Rails.logger.info("#{event["event"]} #{event["data"]["to"]}")
|
|
150
|
+
head :ok
|
|
151
|
+
rescue SendClipper::WebhookVerificationError => e
|
|
152
|
+
render plain: e.reason, status: :bad_request
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
In Rack or Sinatra pass `request.body.read` and `request.env`. During a secret rotation a delivery carries two signatures and either one is accepted. A delivery signed more than five minutes away from now is refused. `client.webhooks.verify(...)` is the same function.
|
|
158
|
+
|
|
159
|
+
To test your handler locally, `SendClipper.webhook_signature_header(secret, raw_body)` builds a valid header.
|
|
160
|
+
|
|
161
|
+
## Sandboxes
|
|
162
|
+
|
|
163
|
+
A sandbox traps mail while you develop: nothing is delivered.
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
box = client.sandboxes.create(name: "my-app-dev").unwrap
|
|
167
|
+
creds = client.sandboxes.credentials(box["id"]).unwrap # SMTP host, port, username, password
|
|
168
|
+
|
|
169
|
+
# Or send through the sandbox's JSON door with its own token, using the same fields as emails.send:
|
|
170
|
+
token = client.sandboxes.reset_credentials(box["id"]).unwrap
|
|
171
|
+
client.sandboxes.send(token["apiToken"], from: "app@example.com", to: "test@example.com", subject: "Hi", html: "<p>Hi</p>")
|
|
172
|
+
|
|
173
|
+
# In a test: wait for the message to arrive
|
|
174
|
+
msg, error = client.sandboxes.messages.wait_for(box["id"]) { |m| m["subject"] == "Hi" }
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Everything else
|
|
178
|
+
|
|
179
|
+
| Resource | Methods |
|
|
180
|
+
|---|---|
|
|
181
|
+
| `emails` | `send`, `get`, `list`, `each`, `stats` |
|
|
182
|
+
| `batch` | `send` |
|
|
183
|
+
| `domains` | `list`, `create`, `records`, `verify`, `enable_receiving`, `disable_receiving`, `set_region`, `deletion_preview`, `remove` |
|
|
184
|
+
| `templates` | `list`, `get`, `html`, `create`, `update`, `remove`, `duplicate`, `publish`, `unpublish`, `preview`, `set_tags`, `projects` |
|
|
185
|
+
| `templates.tags` | `list`, `create`, `update`, `remove` |
|
|
186
|
+
| `templates.assets` | `list`, `upload` |
|
|
187
|
+
| `sandboxes` | `list`, `get`, `create`, `update`, `remove`, `credentials`, `reset_credentials`, `send` |
|
|
188
|
+
| `sandboxes.messages` | `list`, `get`, `raw`, `remove`, `clear`, `mark_all_read`, `wait_for` |
|
|
189
|
+
| `webhooks` | `list`, `create`, `update`, `remove`, `rotate_secret`, `deliveries`, `verify` |
|
|
190
|
+
| `webhooks.dispatches` | `list`, `each`, `get`, `replay`, `replay_all` |
|
|
191
|
+
| `contacts` | `list`, `each`, `create`, `import`, `import_preview`, `export` |
|
|
192
|
+
| `audiences` | `list` |
|
|
193
|
+
| `suppressions` | `list`, `each`, `create`, `remove`, `export` |
|
|
194
|
+
| `mailboxes` | `list`, `get`, `create`, `update`, `archive` |
|
|
195
|
+
| `mailboxes.messages` | `list`, `each`, `get`, `attachment` |
|
|
196
|
+
| `automations` | `enroll` |
|
|
197
|
+
| `workspace` | `get` |
|
|
198
|
+
| `api_keys` | `list` |
|
|
199
|
+
|
|
200
|
+
`contacts.import` takes the file's contents (`File.read("contacts.csv")`) and `templates.assets.upload` the image's bytes (`File.binread("logo.png")`). Management routes need a key with **Full access**. A sending-only key can call `emails.send` and `batch.send`.
|
|
201
|
+
|
|
202
|
+
Full API reference: <https://sendclipper.com/docs>.
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendClipper
|
|
4
|
+
# The SendClipper API client.
|
|
5
|
+
#
|
|
6
|
+
# client = SendClipper::Client.new("sc_live_...") # or Client.new: reads SENDCLIPPER_API_KEY
|
|
7
|
+
# data, error = client.emails.send(from: "hello@acme.io", to: "ada@lumen.io", subject: "Hi", html: "<p>Hi</p>")
|
|
8
|
+
class Client
|
|
9
|
+
attr_reader :emails, :batch, :domains, :templates, :sandboxes, :webhooks, :contacts,
|
|
10
|
+
:audiences, :suppressions, :mailboxes, :automations, :workspace, :api_keys
|
|
11
|
+
|
|
12
|
+
# +api_key+ defaults to SENDCLIPPER_API_KEY. +base_url:+ defaults to
|
|
13
|
+
# SENDCLIPPER_BASE_URL, then https://sendclipper.com (no /v1). +timeout:+ is
|
|
14
|
+
# per attempt, in seconds. +max_retries:+ is how often a network error, a
|
|
15
|
+
# 429 or a 5xx is retried; a write is only retried with an
|
|
16
|
+
# Idempotency-Key, which the SDK adds to every JSON write, so a retried
|
|
17
|
+
# send can never go out twice. +transport:+ replaces the Net::HTTP sender.
|
|
18
|
+
def initialize(api_key = nil, base_url: nil, timeout: 30, max_retries: 2, default_headers: {}, transport: nil, sleeper: nil)
|
|
19
|
+
@http = HttpClient.new(api_key: api_key, base_url: base_url, timeout: timeout, max_retries: max_retries,
|
|
20
|
+
default_headers: default_headers, transport: transport, sleeper: sleeper)
|
|
21
|
+
@emails = Resources::Emails.new(@http)
|
|
22
|
+
@batch = Resources::Batch.new(@http)
|
|
23
|
+
@domains = Resources::Domains.new(@http)
|
|
24
|
+
@templates = Resources::Templates.new(@http)
|
|
25
|
+
@sandboxes = Resources::Sandboxes.new(@http)
|
|
26
|
+
@webhooks = Resources::Webhooks.new(@http)
|
|
27
|
+
@contacts = Resources::Contacts.new(@http)
|
|
28
|
+
@audiences = Resources::Audiences.new(@http)
|
|
29
|
+
@suppressions = Resources::Suppressions.new(@http)
|
|
30
|
+
@mailboxes = Resources::Mailboxes.new(@http)
|
|
31
|
+
@automations = Resources::Automations.new(@http)
|
|
32
|
+
@workspace = Resources::Workspace.new(@http)
|
|
33
|
+
@api_keys = Resources::ApiKeys.new(@http)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The API origin requests go to, without /v1.
|
|
37
|
+
def base_url = @http.base_url
|
|
38
|
+
|
|
39
|
+
def inspect = "#<SendClipper::Client base_url=#{base_url.inspect}>"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SendClipper
|
|
6
|
+
# Base class for everything this SDK raises on purpose.
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# The API answered with a non-2xx status. +code+ is the API's own
|
|
10
|
+
# machine-readable reason (+domain_not_verified+, +template_variables_missing+,
|
|
11
|
+
# +rate_limited+, ...) and is the field to branch on; the message is a
|
|
12
|
+
# sentence meant for a person.
|
|
13
|
+
class ApiError < Error
|
|
14
|
+
attr_reader :status, :code, :detail, :headers
|
|
15
|
+
|
|
16
|
+
def initialize(status, code, message, detail = nil, headers = {})
|
|
17
|
+
super(message)
|
|
18
|
+
@status = status
|
|
19
|
+
@code = code
|
|
20
|
+
@detail = detail
|
|
21
|
+
@headers = headers || {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The API answers refusals in two shapes: the house envelope
|
|
25
|
+
# { error: { code, message, detail? } } and Nest's default
|
|
26
|
+
# { statusCode, message: string | string[], error } (request validation).
|
|
27
|
+
def self.from_response(status, text, headers = {})
|
|
28
|
+
parsed = begin
|
|
29
|
+
JSON.parse(text)
|
|
30
|
+
rescue JSON::ParserError, TypeError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
obj = parsed.is_a?(Hash) ? parsed : {}
|
|
34
|
+
|
|
35
|
+
env = obj["error"]
|
|
36
|
+
return from_envelope(status, env, headers) if env.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
msg = obj["message"]
|
|
39
|
+
return from_envelope(status, msg["error"], headers) if msg.is_a?(Hash) && msg["error"].is_a?(Hash)
|
|
40
|
+
return new(status, "validation_failed", msg.map(&:to_s).join("; "), msg, headers) if msg.is_a?(Array)
|
|
41
|
+
return new(status, code_for_status(status), msg, nil, headers) if msg.is_a?(String)
|
|
42
|
+
|
|
43
|
+
snippet = text.to_s.strip[0, 300]
|
|
44
|
+
new(status, code_for_status(status), snippet.empty? ? "HTTP #{status}" : snippet, nil, headers)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.from_envelope(status, env, headers)
|
|
48
|
+
code = env["code"].to_s
|
|
49
|
+
message = env["message"].to_s
|
|
50
|
+
detail = env.key?("detail") ? env["detail"] : env["details"]
|
|
51
|
+
new(status, code.empty? ? code_for_status(status) : code, message.empty? ? "HTTP #{status}" : message, detail, headers)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.code_for_status(status)
|
|
55
|
+
{
|
|
56
|
+
400 => "bad_request", 401 => "unauthorized", 403 => "forbidden", 404 => "not_found",
|
|
57
|
+
409 => "conflict", 413 => "payload_too_large", 429 => "rate_limited"
|
|
58
|
+
}.fetch(status, "http_#{status}")
|
|
59
|
+
end
|
|
60
|
+
private_class_method :from_envelope
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The request never got an answer: DNS, TLS, a reset socket or the timeout.
|
|
64
|
+
class ConnectionError < Error; end
|
|
65
|
+
|
|
66
|
+
# SendClipper.verify_webhook refused a delivery. +reason+ says which check
|
|
67
|
+
# failed: +missing_header+, +malformed_header+, +timestamp_out_of_tolerance+,
|
|
68
|
+
# +signature_mismatch+ or +invalid_payload+.
|
|
69
|
+
class WebhookVerificationError < Error
|
|
70
|
+
attr_reader :reason
|
|
71
|
+
|
|
72
|
+
def initialize(reason, message)
|
|
73
|
+
super(message)
|
|
74
|
+
@reason = reason
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module SendClipper
|
|
11
|
+
# One request as a transport receives it. +timeout+ is in seconds.
|
|
12
|
+
TransportRequest = Struct.new(:method, :url, :headers, :body, :timeout, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
# One response as a transport returns it. Header names are lower-case.
|
|
15
|
+
TransportResponse = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
# Raised by a transport when no answer arrived. +timed_out+ marks a timeout.
|
|
18
|
+
class TransportError < StandardError
|
|
19
|
+
attr_reader :timed_out
|
|
20
|
+
|
|
21
|
+
def initialize(message, timed_out: false)
|
|
22
|
+
super(message)
|
|
23
|
+
@timed_out = timed_out
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The default transport: Net::HTTP from the standard library. Redirects
|
|
28
|
+
# are not followed. Anything that responds to +call(TransportRequest)+ and
|
|
29
|
+
# returns a TransportResponse can replace it.
|
|
30
|
+
class NetHttpTransport
|
|
31
|
+
def call(req)
|
|
32
|
+
uri = URI(req.url)
|
|
33
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
34
|
+
http.use_ssl = uri.scheme == "https"
|
|
35
|
+
http.open_timeout = [req.timeout, 10].min
|
|
36
|
+
http.read_timeout = req.timeout
|
|
37
|
+
http.write_timeout = req.timeout
|
|
38
|
+
klass = Net::HTTP.const_get(req.method.capitalize)
|
|
39
|
+
request = klass.new(uri.request_uri)
|
|
40
|
+
req.headers.each { |k, v| request[k] = v }
|
|
41
|
+
request.body = req.body if req.body
|
|
42
|
+
response = http.request(request)
|
|
43
|
+
headers = {}
|
|
44
|
+
response.each_header { |k, v| headers[k.downcase] = v }
|
|
45
|
+
TransportResponse.new(status: response.code.to_i, headers: headers, body: response.body.to_s)
|
|
46
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
|
|
47
|
+
raise TransportError.new(e.message, timed_out: true)
|
|
48
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, EOFError, Net::ProtocolError => e
|
|
49
|
+
raise TransportError, "#{e.class}: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @api private Reach the API through SendClipper::Client's resources.
|
|
54
|
+
class HttpClient
|
|
55
|
+
DEFAULT_BASE_URL = "https://sendclipper.com"
|
|
56
|
+
RETRYABLE_STATUS = [408, 429, 500, 502, 503, 504].freeze
|
|
57
|
+
MAX_RETRY_AFTER = 60.0
|
|
58
|
+
OPTION_KEYS = %i[idempotency_key timeout max_retries headers].freeze
|
|
59
|
+
|
|
60
|
+
attr_reader :base_url
|
|
61
|
+
|
|
62
|
+
def initialize(api_key: nil, base_url: nil, timeout: 30, max_retries: 2, default_headers: {}, transport: nil, sleeper: nil)
|
|
63
|
+
@api_key = api_key || ENV["SENDCLIPPER_API_KEY"]
|
|
64
|
+
@api_key = nil if @api_key == ""
|
|
65
|
+
raw = base_url || ENV.fetch("SENDCLIPPER_BASE_URL", nil)
|
|
66
|
+
raw = DEFAULT_BASE_URL if raw.nil? || raw.empty?
|
|
67
|
+
@base_url = raw.sub(%r{/+\z}, "").sub(%r{/v1\z}, "")
|
|
68
|
+
@timeout = timeout.to_f
|
|
69
|
+
@max_retries = [max_retries.to_i, 0].max
|
|
70
|
+
@default_headers = default_headers.to_h.transform_keys(&:to_s)
|
|
71
|
+
@transport = transport || NetHttpTransport.new
|
|
72
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) if seconds.positive? }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The public face of every API method: never raises for an API refusal
|
|
76
|
+
# or a network failure, it answers a Result.
|
|
77
|
+
def call(method, path, query: nil, body: nil, form: nil, as: :json, bearer: nil, options: {})
|
|
78
|
+
data, headers = request(method, path, query: query, body: body, form: form, as: as, bearer: bearer, options: options)
|
|
79
|
+
Result.new(data, nil, headers)
|
|
80
|
+
rescue ApiError => e
|
|
81
|
+
Result.new(nil, ErrorResponse.new(name: e.code, message: e.message, status_code: e.status, detail: e.detail),
|
|
82
|
+
e.headers.empty? ? nil : e.headers)
|
|
83
|
+
rescue ConnectionError => e
|
|
84
|
+
Result.new(nil, ErrorResponse.new(name: "network_error", message: e.message, status_code: nil), nil)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Like #call, but raises SendClipper::ApiError / SendClipper::ConnectionError.
|
|
88
|
+
def request(method, path, query: nil, body: nil, form: nil, as: :json, bearer: nil, options: {})
|
|
89
|
+
unknown = options.keys - OPTION_KEYS
|
|
90
|
+
raise ArgumentError, "Unknown request option #{unknown.first.inspect}. Allowed: #{OPTION_KEYS.join(", ")}." if unknown.any?
|
|
91
|
+
|
|
92
|
+
token = bearer || @api_key
|
|
93
|
+
if token.nil? || token.empty?
|
|
94
|
+
raise ApiError.new(401, "api_key_missing",
|
|
95
|
+
'No SendClipper API key. Pass one to SendClipper::Client.new("sc_live_...") or set SENDCLIPPER_API_KEY.')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
url = "#{@base_url}/v1#{path.start_with?("/") ? path : "/#{path}"}"
|
|
99
|
+
pairs = (query || {}).filter_map do |k, v|
|
|
100
|
+
next if v.nil? || v == ""
|
|
101
|
+
|
|
102
|
+
# true/false are written the way the API reads them.
|
|
103
|
+
"#{escape(k.to_s)}=#{escape(v.to_s)}"
|
|
104
|
+
end
|
|
105
|
+
url += "?#{pairs.join("&")}" unless pairs.empty?
|
|
106
|
+
|
|
107
|
+
headers = {
|
|
108
|
+
"Authorization" => "Bearer #{token}",
|
|
109
|
+
"Accept" => as == :json ? "application/json" : "*/*",
|
|
110
|
+
"User-Agent" => "sendclipper-ruby/#{VERSION}"
|
|
111
|
+
}.merge(@default_headers).merge((options[:headers] || {}).transform_keys(&:to_s))
|
|
112
|
+
|
|
113
|
+
payload = nil
|
|
114
|
+
if form
|
|
115
|
+
payload, headers["Content-Type"] = form
|
|
116
|
+
elsif !body.nil?
|
|
117
|
+
headers["Content-Type"] = "application/json"
|
|
118
|
+
payload = JSON.generate(body.empty? ? {} : body)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
read = %w[GET HEAD].include?(method)
|
|
122
|
+
key = options[:idempotency_key]
|
|
123
|
+
# The API de-duplicates every JSON mutation on this header for 24 hours.
|
|
124
|
+
# A multipart body is not fingerprinted server-side, so it gets no key
|
|
125
|
+
# and is never retried.
|
|
126
|
+
if !read && form.nil?
|
|
127
|
+
headers["Idempotency-Key"] = key.to_s.empty? ? "sc-ruby-#{SecureRandom.uuid}" : key.to_s
|
|
128
|
+
elsif !key.to_s.empty?
|
|
129
|
+
headers["Idempotency-Key"] = key.to_s
|
|
130
|
+
end
|
|
131
|
+
retries = read || headers.key?("Idempotency-Key") ? [(options[:max_retries] || @max_retries).to_i, 0].max : 0
|
|
132
|
+
timeout = (options[:timeout] || @timeout).to_f
|
|
133
|
+
|
|
134
|
+
attempt = 0
|
|
135
|
+
loop do
|
|
136
|
+
begin
|
|
137
|
+
res = @transport.call(TransportRequest.new(method: method, url: url, headers: headers, body: payload, timeout: timeout))
|
|
138
|
+
rescue TransportError => e
|
|
139
|
+
if attempt < retries
|
|
140
|
+
@sleeper.call(self.class.backoff(attempt))
|
|
141
|
+
attempt += 1
|
|
142
|
+
next
|
|
143
|
+
end
|
|
144
|
+
message = if e.timed_out
|
|
145
|
+
"SendClipper did not answer within #{format_seconds(timeout)} s (#{method} #{URI(url).path})."
|
|
146
|
+
else
|
|
147
|
+
"Could not reach SendClipper at #{@base_url}: #{e.message}"
|
|
148
|
+
end
|
|
149
|
+
raise ConnectionError, message
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
res_headers = (res.headers || {}).transform_keys { |k| k.to_s.downcase }
|
|
153
|
+
ok = res.status >= 200 && res.status < 300
|
|
154
|
+
|
|
155
|
+
if !ok && RETRYABLE_STATUS.include?(res.status) && attempt < retries
|
|
156
|
+
@sleeper.call(retry_after(res_headers) || self.class.backoff(attempt))
|
|
157
|
+
attempt += 1
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
unless ok
|
|
162
|
+
err = ApiError.from_response(res.status, res.body.to_s, res_headers)
|
|
163
|
+
# Our own earlier attempt is still running server-side (a timeout
|
|
164
|
+
# that was not a failure). Its result is replayed once it finishes.
|
|
165
|
+
if err.code == "idempotency_key_in_flight" && attempt < retries
|
|
166
|
+
@sleeper.call(retry_after(res_headers) || self.class.backoff(attempt))
|
|
167
|
+
attempt += 1
|
|
168
|
+
next
|
|
169
|
+
end
|
|
170
|
+
raise err
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
return [parse(res, res_headers, as), res_headers]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.backoff(attempt)
|
|
178
|
+
base = [8.0, 0.5 * (2**attempt)].min
|
|
179
|
+
(base / 2) + (rand * (base / 2))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# +multipart/form-data+: fields are [name, value], files [name, filename, type, bytes].
|
|
183
|
+
def self.multipart(fields, files)
|
|
184
|
+
boundary = "----sendclipper#{SecureRandom.hex(12)}"
|
|
185
|
+
q = ->(s) { s.to_s.gsub("\\", "\\\\\\\\").gsub('"', '\\"').tr("\r\n", " ") }
|
|
186
|
+
out = +"".b
|
|
187
|
+
fields.each do |name, value|
|
|
188
|
+
out << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{q.call(name)}\"\r\n\r\n".b << value.to_s.b << "\r\n".b
|
|
189
|
+
end
|
|
190
|
+
files.each do |name, filename, type, bytes|
|
|
191
|
+
out << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{q.call(name)}\"; filename=\"#{q.call(filename)}\"\r\n".b
|
|
192
|
+
out << "Content-Type: #{type}\r\n\r\n".b << bytes.to_s.b << "\r\n".b
|
|
193
|
+
end
|
|
194
|
+
out << "--#{boundary}--\r\n".b
|
|
195
|
+
[out, "multipart/form-data; boundary=#{boundary}"]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Percent-encode one path segment.
|
|
199
|
+
def self.seg(value) = escape(value.to_s)
|
|
200
|
+
|
|
201
|
+
def self.escape(s) = s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |c| format("%%%02X", c.ord) }
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def escape(s) = self.class.escape(s)
|
|
206
|
+
|
|
207
|
+
def parse(res, headers, as)
|
|
208
|
+
body = res.body.to_s
|
|
209
|
+
case as
|
|
210
|
+
when :bytes
|
|
211
|
+
DownloadedFile.new(content: body.b, content_type: headers["content-type"], filename: filename_of(headers["content-disposition"]))
|
|
212
|
+
when :text
|
|
213
|
+
text(body, headers)
|
|
214
|
+
else
|
|
215
|
+
return nil if body.strip.empty?
|
|
216
|
+
|
|
217
|
+
begin
|
|
218
|
+
JSON.parse(text(body, headers))
|
|
219
|
+
rescue JSON::ParserError
|
|
220
|
+
text(body, headers)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def text(body, headers)
|
|
226
|
+
charset = headers["content-type"].to_s[/charset=([\w-]+)/i, 1] || "UTF-8"
|
|
227
|
+
body.dup.force_encoding(charset).encode("UTF-8", invalid: :replace, undef: :replace)
|
|
228
|
+
rescue ArgumentError, Encoding::ConverterNotFoundError
|
|
229
|
+
body.dup.force_encoding("UTF-8").scrub
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def retry_after(headers)
|
|
233
|
+
raw = headers["retry-after"]
|
|
234
|
+
return nil if raw.nil? || raw.empty?
|
|
235
|
+
|
|
236
|
+
if raw.match?(/\A\d+(\.\d+)?\z/)
|
|
237
|
+
[raw.to_f, MAX_RETRY_AFTER].min
|
|
238
|
+
else
|
|
239
|
+
at = Time.httpdate(raw)
|
|
240
|
+
[[at - Time.now, 0.0].max, MAX_RETRY_AFTER].min
|
|
241
|
+
end
|
|
242
|
+
rescue ArgumentError
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def filename_of(disposition)
|
|
247
|
+
return nil if disposition.nil? || disposition.empty?
|
|
248
|
+
|
|
249
|
+
if (m = disposition.match(/filename\*\s*=\s*(?:UTF-8'')?([^;]+)/i))
|
|
250
|
+
decoded = m[1].strip.delete('"').gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }.force_encoding("UTF-8")
|
|
251
|
+
return decoded if decoded.valid_encoding?
|
|
252
|
+
end
|
|
253
|
+
m = disposition.match(/filename\s*=\s*"?([^";]+)"?/i)
|
|
254
|
+
m && m[1].strip
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def format_seconds(s) = s == s.to_i ? s.to_i.to_s : s.to_s
|
|
258
|
+
end
|
|
259
|
+
end
|