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 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