unitpost 0.0.0 → 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 +160 -3
- data/lib/unitpost/client.rb +53 -0
- data/lib/unitpost/errors.rb +29 -0
- data/lib/unitpost/generated/operations.rb +1121 -0
- data/lib/unitpost/http_client.rb +160 -0
- data/lib/unitpost/resources.rb +464 -0
- data/lib/unitpost/version.rb +3 -1
- data/lib/unitpost/webhooks.rb +76 -0
- data/lib/unitpost.rb +10 -2
- metadata +72 -14
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "generated/operations"
|
|
8
|
+
|
|
9
|
+
module Unitpost
|
|
10
|
+
# HTTP transport — the one place that talks to the Unitpost API.
|
|
11
|
+
#
|
|
12
|
+
# Hand-written (not generated): auth, the required User-Agent, idempotency,
|
|
13
|
+
# query building, JSON encode/decode, and turning a non-2xx response into a
|
|
14
|
+
# typed Unitpost::Error. Resource methods (resources.rb) call through here.
|
|
15
|
+
class HttpClient
|
|
16
|
+
SDK_VERSION = Unitpost::VERSION
|
|
17
|
+
# The live API host. Matches the OpenAPI servers[].url and the app's own
|
|
18
|
+
# brand.ts FALLBACK_APP_ORIGIN. SINGLE HOST: the API lives on the same origin
|
|
19
|
+
# as the app (www.unitpost.com) under /api/v1 — no api. subdomain. Override
|
|
20
|
+
# per-client with base_url / UNITPOST_BASE_URL.
|
|
21
|
+
DEFAULT_BASE_URL = "https://www.unitpost.com"
|
|
22
|
+
|
|
23
|
+
def initialize(api_key: nil, base_url: nil, timeout: 30, headers: {}, adapter: nil, stubs: nil,
|
|
24
|
+
max_retries: 2, retry_base_delay: 0.5, max_retry_delay: 20.0, sleeper: nil)
|
|
25
|
+
key = api_key || ENV.fetch("UNITPOST_API_KEY", nil)
|
|
26
|
+
if key.nil? || key.empty?
|
|
27
|
+
raise Unitpost::Error.new(
|
|
28
|
+
code: "missing_api_key",
|
|
29
|
+
message: "No API key provided. Pass Unitpost::Client.new(api_key:) or set the UNITPOST_API_KEY environment variable. Create a key at https://www.unitpost.com → Settings → API keys. Full reference: https://www.unitpost.com/docs",
|
|
30
|
+
status: 0
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
@api_key = key
|
|
34
|
+
base = (base_url || ENV["UNITPOST_BASE_URL"] || DEFAULT_BASE_URL).sub(%r{/\z}, "")
|
|
35
|
+
@prefix = "/api/#{Unitpost::Generated::API_VERSION}"
|
|
36
|
+
@extra_headers = headers
|
|
37
|
+
# Automatic retry policy for transient failures (429, 5xx, timeouts,
|
|
38
|
+
# network errors). max_retries: 0 disables it. See SECURITY.md §6.
|
|
39
|
+
@max_retries = [max_retries, 0].max
|
|
40
|
+
@retry_base_delay = retry_base_delay
|
|
41
|
+
@max_retry_delay = max_retry_delay
|
|
42
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
43
|
+
|
|
44
|
+
@conn = Faraday.new(url: base) do |f|
|
|
45
|
+
f.options.timeout = timeout
|
|
46
|
+
if stubs
|
|
47
|
+
f.adapter(:test, stubs)
|
|
48
|
+
else
|
|
49
|
+
f.adapter(adapter || Faraday.default_adapter)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Make a request, retrying transient failures (429, 5xx, timeouts, network
|
|
55
|
+
# errors) with backoff. A 429/503 Retry-After is honored (clamped to
|
|
56
|
+
# max_retry_delay); otherwise exponential backoff with full jitter. 4xx
|
|
57
|
+
# (other than 429) is never retried. A request is only retried when it's safe
|
|
58
|
+
# to replay: a GET, or a write carrying an Idempotency-Key (deduped
|
|
59
|
+
# server-side). A keyless write is never auto-retried, so a lost response
|
|
60
|
+
# can't double-send.
|
|
61
|
+
def request(http_method, path, query: nil, body: nil, idempotency_key: nil)
|
|
62
|
+
retry_safe = http_method == "GET" || !idempotency_key.nil?
|
|
63
|
+
attempt = 0
|
|
64
|
+
loop do
|
|
65
|
+
result, retry_after = attempt_request(
|
|
66
|
+
http_method, path, query: query, body: body, idempotency_key: idempotency_key
|
|
67
|
+
)
|
|
68
|
+
err = result.error
|
|
69
|
+
retryable = !err.nil? && (err.status.zero? || retryable_status?(err.status))
|
|
70
|
+
return result if err.nil? || !retryable || !retry_safe || attempt >= @max_retries
|
|
71
|
+
|
|
72
|
+
@sleeper.call(backoff_delay(attempt, retry_after))
|
|
73
|
+
attempt += 1
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def retryable_status?(status)
|
|
80
|
+
status == 429 || (status >= 500 && status <= 599)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def backoff_delay(attempt, retry_after)
|
|
84
|
+
return [retry_after, @max_retry_delay].min unless retry_after.nil?
|
|
85
|
+
|
|
86
|
+
ceiling = [@retry_base_delay * (2**attempt), @max_retry_delay].min
|
|
87
|
+
rand * ceiling
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Parse a Retry-After header (delta-seconds or HTTP-date) into seconds.
|
|
91
|
+
def parse_retry_after(value)
|
|
92
|
+
return nil if value.nil? || value.to_s.empty?
|
|
93
|
+
|
|
94
|
+
stripped = value.to_s.strip
|
|
95
|
+
return [stripped.to_f, 0.0].max if stripped.match?(/\A\d+(\.\d+)?\z/)
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
delta = Time.httpdate(stripped).to_f - Time.now.to_f
|
|
99
|
+
[delta, 0.0].max
|
|
100
|
+
rescue ArgumentError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def attempt_request(http_method, path, query: nil, body: nil, idempotency_key: nil)
|
|
106
|
+
headers = {
|
|
107
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
108
|
+
"User-Agent" => "unitpost-ruby/#{SDK_VERSION}",
|
|
109
|
+
"Accept" => "application/json"
|
|
110
|
+
}.merge(@extra_headers)
|
|
111
|
+
headers["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
112
|
+
|
|
113
|
+
params = (query || {}).reject { |_, v| v.nil? }
|
|
114
|
+
|
|
115
|
+
begin
|
|
116
|
+
response = @conn.run_request(http_method.downcase.to_sym, "#{@prefix}#{path}", nil, headers) do |req|
|
|
117
|
+
req.params.update(params) unless params.empty?
|
|
118
|
+
if body && http_method != "GET"
|
|
119
|
+
req.headers["Content-Type"] = "application/json"
|
|
120
|
+
req.body = JSON.generate(body)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
rescue Faraday::TimeoutError
|
|
124
|
+
return [Result.new(error: Unitpost::Error.new(code: "timeout", message: "Request timed out.", status: 0)), nil]
|
|
125
|
+
rescue Faraday::Error => e
|
|
126
|
+
return [Result.new(error: Unitpost::Error.new(code: "network_error", message: e.message, status: 0)), nil]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
request_id = response.headers["x-request-id"]
|
|
130
|
+
parsed = parse_body(response.body)
|
|
131
|
+
|
|
132
|
+
unless (200..299).cover?(response.status)
|
|
133
|
+
err = parsed.is_a?(Hash) ? (parsed["error"] || {}) : {}
|
|
134
|
+
details = err["details"].is_a?(Array) ? err["details"] : nil
|
|
135
|
+
return [
|
|
136
|
+
Result.new(
|
|
137
|
+
error: Unitpost::Error.new(
|
|
138
|
+
code: err["code"] || "http_error",
|
|
139
|
+
message: err["message"] || "Request failed with status #{response.status}.",
|
|
140
|
+
status: response.status,
|
|
141
|
+
request_id: request_id,
|
|
142
|
+
details: details
|
|
143
|
+
)
|
|
144
|
+
),
|
|
145
|
+
parse_retry_after(response.headers["retry-after"])
|
|
146
|
+
]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
[Result.new(data: parsed), nil]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def parse_body(raw)
|
|
153
|
+
return nil if raw.nil? || raw.to_s.empty?
|
|
154
|
+
|
|
155
|
+
JSON.parse(raw)
|
|
156
|
+
rescue JSON::ParserError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Unitpost
|
|
7
|
+
# Resource surface — the hand-written, ergonomic layer (our own style).
|
|
8
|
+
#
|
|
9
|
+
# Each resource is a small class with explicit methods mirroring the Node and
|
|
10
|
+
# Python SDKs (emails.send, contacts.list/create/get/update/delete, ...). The
|
|
11
|
+
# codegen "surface coverage" check fails CI if a spec operation has no method
|
|
12
|
+
# here. Every method returns a Unitpost::Result and never raises for an API
|
|
13
|
+
# error. Bodies/params are plain Hashes.
|
|
14
|
+
module Resources
|
|
15
|
+
class Base
|
|
16
|
+
def initialize(http)
|
|
17
|
+
@http = http
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def enc(segment)
|
|
23
|
+
CGI.escape(segment.to_s)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def list_params(limit:, after:, before:, **extra)
|
|
27
|
+
{ limit: limit, after: after, before: before }.merge(extra)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def paginate(path, params)
|
|
31
|
+
Enumerator.new do |yielder|
|
|
32
|
+
after = params[:after]
|
|
33
|
+
loop do
|
|
34
|
+
page_params = params.merge(after: after)
|
|
35
|
+
result = @http.request("GET", path, query: page_params)
|
|
36
|
+
raise result.error if result.error
|
|
37
|
+
|
|
38
|
+
page = result.data || {}
|
|
39
|
+
rows = page["data"] || []
|
|
40
|
+
rows.each { |row| yielder << row }
|
|
41
|
+
break if !page["has_more"] || rows.empty?
|
|
42
|
+
|
|
43
|
+
after = rows.last["id"]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Emails < Base
|
|
50
|
+
# Send or schedule a transactional email. When no idempotency_key is
|
|
51
|
+
# given the SDK generates one so a transient retry can't double-send; pass
|
|
52
|
+
# your own to dedupe across separate send calls.
|
|
53
|
+
def send(body, idempotency_key: nil)
|
|
54
|
+
@http.request("POST", "/emails", body: body, idempotency_key: idempotency_key || SecureRandom.uuid)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Send up to 100 emails in one request (all-or-nothing validation). Like
|
|
58
|
+
# +send+, when no idempotency_key is given the SDK generates one so a
|
|
59
|
+
# transient retry is safe: the server dedupes the batch on the key and
|
|
60
|
+
# replays the original result instead of re-sending. Pass your own to
|
|
61
|
+
# dedupe across separate batch calls.
|
|
62
|
+
def batch(body, idempotency_key: nil)
|
|
63
|
+
@http.request("POST", "/emails/batch", body: body, idempotency_key: idempotency_key || SecureRandom.uuid)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def get_batch(id)
|
|
67
|
+
@http.request("GET", "/emails/batches/#{enc(id)}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def cancel_batch(id)
|
|
71
|
+
@http.request("POST", "/emails/batches/#{enc(id)}/cancel")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def list(limit: nil, after: nil, before: nil, **filters)
|
|
75
|
+
@http.request("GET", "/emails", query: list_params(limit: limit, after: after, before: before, **filters))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def list_all(**params)
|
|
79
|
+
paginate("/emails", params)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def get(id)
|
|
83
|
+
@http.request("GET", "/emails/#{enc(id)}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def update(id, body)
|
|
87
|
+
@http.request("PATCH", "/emails/#{enc(id)}", body: body)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Deliverability/engagement aggregates over a rolling window (days: 1–365,
|
|
91
|
+
# defaults to 30 server-side).
|
|
92
|
+
def stats(days: nil)
|
|
93
|
+
@http.request("GET", "/emails/stats", query: { days: days })
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def received_list(limit: nil, after: nil, before: nil)
|
|
97
|
+
@http.request("GET", "/emails/received", query: list_params(limit: limit, after: after, before: before))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def received_get(id)
|
|
101
|
+
@http.request("GET", "/emails/received/#{enc(id)}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def received_attachment_url(id, attachment_id)
|
|
105
|
+
@http.request("GET", "/emails/received/#{enc(id)}/attachments/#{enc(attachment_id)}")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class Contacts < Base
|
|
110
|
+
def list(limit: nil, after: nil, before: nil)
|
|
111
|
+
@http.request("GET", "/contacts", query: list_params(limit: limit, after: after, before: before))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def list_all(**params)
|
|
115
|
+
paginate("/contacts", params)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def create(body)
|
|
119
|
+
@http.request("POST", "/contacts", body: body)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def get(id_or_email)
|
|
123
|
+
@http.request("GET", "/contacts/#{enc(id_or_email)}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def update(id_or_email, body)
|
|
127
|
+
@http.request("PATCH", "/contacts/#{enc(id_or_email)}", body: body)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def delete(id_or_email)
|
|
131
|
+
@http.request("DELETE", "/contacts/#{enc(id_or_email)}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def import(body)
|
|
135
|
+
@http.request("POST", "/contacts/imports", body: body)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def list_imports(limit: nil, after: nil, before: nil)
|
|
139
|
+
@http.request("GET", "/contacts/imports", query: list_params(limit: limit, after: after, before: before))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def get_import(id)
|
|
143
|
+
@http.request("GET", "/contacts/imports/#{enc(id)}")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class ContactFields < Base
|
|
148
|
+
def list(limit: nil, after: nil, before: nil)
|
|
149
|
+
@http.request("GET", "/contact-fields", query: list_params(limit: limit, after: after, before: before))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def list_all(**params)
|
|
153
|
+
paginate("/contact-fields", params)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def create(body)
|
|
157
|
+
@http.request("POST", "/contact-fields", body: body)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def get(id)
|
|
161
|
+
@http.request("GET", "/contact-fields/#{enc(id)}")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def update(id, body)
|
|
165
|
+
@http.request("PATCH", "/contact-fields/#{enc(id)}", body: body)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def delete(id)
|
|
169
|
+
@http.request("DELETE", "/contact-fields/#{enc(id)}")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def rename(id, body)
|
|
173
|
+
@http.request("POST", "/contact-fields/#{enc(id)}/rename", body: body)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
class Segments < Base
|
|
178
|
+
def list(limit: nil, after: nil, before: nil)
|
|
179
|
+
@http.request("GET", "/segments", query: list_params(limit: limit, after: after, before: before))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def list_all(**params)
|
|
183
|
+
paginate("/segments", params)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def create(body)
|
|
187
|
+
@http.request("POST", "/segments", body: body)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def get(id)
|
|
191
|
+
@http.request("GET", "/segments/#{enc(id)}")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def update(id, body)
|
|
195
|
+
@http.request("PATCH", "/segments/#{enc(id)}", body: body)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def delete(id)
|
|
199
|
+
@http.request("DELETE", "/segments/#{enc(id)}")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def list_members(id, limit: nil, after: nil, before: nil)
|
|
203
|
+
@http.request("GET", "/segments/#{enc(id)}/contacts", query: list_params(limit: limit, after: after, before: before))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def add_member(id, body)
|
|
207
|
+
@http.request("POST", "/segments/#{enc(id)}/contacts", body: body)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def remove_member(id, contact)
|
|
211
|
+
@http.request("DELETE", "/segments/#{enc(id)}/contacts/#{enc(contact)}")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
class Topics < Base
|
|
216
|
+
def list(limit: nil, after: nil, before: nil)
|
|
217
|
+
@http.request("GET", "/topics", query: list_params(limit: limit, after: after, before: before))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def list_all(**params)
|
|
221
|
+
paginate("/topics", params)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def create(body)
|
|
225
|
+
@http.request("POST", "/topics", body: body)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def get(id)
|
|
229
|
+
@http.request("GET", "/topics/#{enc(id)}")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def update(id, body)
|
|
233
|
+
@http.request("PATCH", "/topics/#{enc(id)}", body: body)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def delete(id)
|
|
237
|
+
@http.request("DELETE", "/topics/#{enc(id)}")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def list_topics(contact_id, limit: nil, after: nil, before: nil)
|
|
241
|
+
@http.request("GET", "/contacts/#{enc(contact_id)}/topics", query: list_params(limit: limit, after: after, before: before))
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def set_topic(contact_id, body)
|
|
245
|
+
@http.request("POST", "/contacts/#{enc(contact_id)}/topics", body: body)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
class Campaigns < Base
|
|
250
|
+
def list(limit: nil, after: nil, before: nil)
|
|
251
|
+
@http.request("GET", "/campaigns", query: list_params(limit: limit, after: after, before: before))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def list_all(**params)
|
|
255
|
+
paginate("/campaigns", params)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def create(body)
|
|
259
|
+
@http.request("POST", "/campaigns", body: body)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def get(id)
|
|
263
|
+
@http.request("GET", "/campaigns/#{enc(id)}")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def update(id, body)
|
|
267
|
+
@http.request("PATCH", "/campaigns/#{enc(id)}", body: body)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def delete(id)
|
|
271
|
+
@http.request("DELETE", "/campaigns/#{enc(id)}")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def send(id)
|
|
275
|
+
@http.request("POST", "/campaigns/#{enc(id)}/send")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def cancel(id)
|
|
279
|
+
@http.request("POST", "/campaigns/#{enc(id)}/cancel")
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def pause(id)
|
|
283
|
+
@http.request("POST", "/campaigns/#{enc(id)}/pause")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def resume(id)
|
|
287
|
+
@http.request("POST", "/campaigns/#{enc(id)}/resume")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def reschedule(id, body)
|
|
291
|
+
@http.request("POST", "/campaigns/#{enc(id)}/reschedule", body: body)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def validate(id)
|
|
295
|
+
@http.request("GET", "/campaigns/#{enc(id)}/validate")
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
class Templates < Base
|
|
300
|
+
def list(limit: nil, after: nil, before: nil)
|
|
301
|
+
@http.request("GET", "/templates", query: list_params(limit: limit, after: after, before: before))
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def list_all(**params)
|
|
305
|
+
paginate("/templates", params)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def create(body)
|
|
309
|
+
@http.request("POST", "/templates", body: body)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def get(id)
|
|
313
|
+
@http.request("GET", "/templates/#{enc(id)}")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def update(id, body)
|
|
317
|
+
@http.request("PATCH", "/templates/#{enc(id)}", body: body)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def delete(id)
|
|
321
|
+
@http.request("DELETE", "/templates/#{enc(id)}")
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Read-only Brand Kit profiles (voice, colors, pinned /img/{id} graphics).
|
|
326
|
+
class BrandKits < Base
|
|
327
|
+
def list(name: nil)
|
|
328
|
+
query = {}
|
|
329
|
+
query[:name] = name unless name.nil?
|
|
330
|
+
@http.request("GET", "/brand-kits", query: query.empty? ? nil : query)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def get(id)
|
|
334
|
+
@http.request("GET", "/brand-kits/#{enc(id)}")
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
class Domains < Base
|
|
339
|
+
def list(limit: nil, after: nil, before: nil)
|
|
340
|
+
@http.request("GET", "/domains", query: list_params(limit: limit, after: after, before: before))
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def list_all(**params)
|
|
344
|
+
paginate("/domains", params)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def create(body)
|
|
348
|
+
@http.request("POST", "/domains", body: body)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def get(id)
|
|
352
|
+
@http.request("GET", "/domains/#{enc(id)}")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def delete(id)
|
|
356
|
+
@http.request("DELETE", "/domains/#{enc(id)}")
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def update(id, body)
|
|
360
|
+
@http.request("PATCH", "/domains/#{enc(id)}", body: body)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def verify(id)
|
|
364
|
+
@http.request("POST", "/domains/#{enc(id)}/verify")
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
class Webhooks < Base
|
|
369
|
+
def list(limit: nil, after: nil, before: nil)
|
|
370
|
+
@http.request("GET", "/webhooks", query: list_params(limit: limit, after: after, before: before))
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def list_all(**params)
|
|
374
|
+
paginate("/webhooks", params)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def create(body)
|
|
378
|
+
@http.request("POST", "/webhooks", body: body)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def get(id)
|
|
382
|
+
@http.request("GET", "/webhooks/#{enc(id)}")
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def update(id, body)
|
|
386
|
+
@http.request("PATCH", "/webhooks/#{enc(id)}", body: body)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def delete(id)
|
|
390
|
+
@http.request("DELETE", "/webhooks/#{enc(id)}")
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def test(id)
|
|
394
|
+
@http.request("POST", "/webhooks/#{enc(id)}/test")
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Verify a delivery's signature and return the parsed event. Alias of
|
|
398
|
+
# Unitpost.verify_webhook / Unitpost::Webhooks.verify for a Resend-style
|
|
399
|
+
# client.webhooks.verify(...). Raises Unitpost::WebhookVerificationError on
|
|
400
|
+
# a missing/invalid/stale signature.
|
|
401
|
+
def verify(payload:, secret:, headers:, tolerance_seconds: 300)
|
|
402
|
+
Unitpost::Webhooks.verify(
|
|
403
|
+
payload: payload,
|
|
404
|
+
secret: secret,
|
|
405
|
+
headers: headers,
|
|
406
|
+
tolerance_seconds: tolerance_seconds,
|
|
407
|
+
)
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
class ApiKeys < Base
|
|
412
|
+
def list(limit: nil, after: nil, before: nil)
|
|
413
|
+
@http.request("GET", "/api-keys", query: list_params(limit: limit, after: after, before: before))
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def list_all(**params)
|
|
417
|
+
paginate("/api-keys", params)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def create(body)
|
|
421
|
+
@http.request("POST", "/api-keys", body: body)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def delete(id)
|
|
425
|
+
@http.request("DELETE", "/api-keys/#{enc(id)}")
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
class Suppressions < Base
|
|
430
|
+
def list(limit: nil, after: nil, before: nil, **filters)
|
|
431
|
+
@http.request("GET", "/suppressions", query: list_params(limit: limit, after: after, before: before, **filters))
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def list_all(**params)
|
|
435
|
+
paginate("/suppressions", params)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Single (`{ email: ... }`) or bulk (`{ emails: [...] }`). Idempotent.
|
|
439
|
+
def create(body)
|
|
440
|
+
@http.request("POST", "/suppressions", body: body)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def get(id_or_email)
|
|
444
|
+
@http.request("GET", "/suppressions/#{enc(id_or_email)}")
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def delete(id_or_email)
|
|
448
|
+
@http.request("DELETE", "/suppressions/#{enc(id_or_email)}")
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# NOTE (pre-launch): the Events resource (POST /v1/events) is intentionally
|
|
453
|
+
# absent while the automations/custom-events surface is behind the launch
|
|
454
|
+
# gate. Restore it (class Events + client wiring) when the surface ships.
|
|
455
|
+
|
|
456
|
+
class Usage < Base
|
|
457
|
+
# Current billing-period snapshot: plan, period window, emails sent, and
|
|
458
|
+
# (paid plans) the dollar sending wallet in integer USD cents.
|
|
459
|
+
def get
|
|
460
|
+
@http.request("GET", "/usage")
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
data/lib/unitpost/version.rb
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Unitpost
|
|
9
|
+
# Webhook signature verification (svix-compatible). Mirrors the engine signer:
|
|
10
|
+
# signed content: "<svix-id>.<svix-timestamp>.<raw-body>"
|
|
11
|
+
# signature: base64( HMAC_SHA256(secret_bytes, signed_content) )
|
|
12
|
+
# header: svix-signature = "v1,<base64>" (space-separated list allowed)
|
|
13
|
+
# secret: "whsec_<base64url>" — bytes after the prefix are the key.
|
|
14
|
+
#
|
|
15
|
+
# Verify the RAW request body string (not a re-serialized hash).
|
|
16
|
+
module Webhooks
|
|
17
|
+
SECRET_PREFIX = "whsec_"
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def verify(payload:, secret:, headers:, tolerance_seconds: 300)
|
|
22
|
+
msg_id = header(headers, "svix-id")
|
|
23
|
+
timestamp = header(headers, "svix-timestamp")
|
|
24
|
+
signature = header(headers, "svix-signature")
|
|
25
|
+
|
|
26
|
+
if msg_id.nil? || timestamp.nil? || signature.nil?
|
|
27
|
+
raise WebhookVerificationError, "Missing svix-id, svix-timestamp, or svix-signature header."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
ts = Integer(timestamp, exception: false)
|
|
31
|
+
raise WebhookVerificationError, "Invalid svix-timestamp header." if ts.nil?
|
|
32
|
+
|
|
33
|
+
now = Time.now.to_i
|
|
34
|
+
if (now - ts).abs > tolerance_seconds
|
|
35
|
+
raise WebhookVerificationError, "Webhook timestamp is outside the tolerance window (possible replay)."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
signed_content = "#{msg_id}.#{ts}.#{payload}"
|
|
39
|
+
expected = Base64.strict_encode64(
|
|
40
|
+
OpenSSL::HMAC.digest("SHA256", secret_key_bytes(secret), signed_content)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
matched = signature.split(" ").any? do |part|
|
|
44
|
+
_, sig = part.split(",", 2)
|
|
45
|
+
sig && secure_compare(sig, expected)
|
|
46
|
+
end
|
|
47
|
+
raise WebhookVerificationError, "Webhook signature verification failed." unless matched
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
JSON.parse(payload)
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
raise WebhookVerificationError, "Webhook payload is not valid JSON."
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def header(headers, name)
|
|
57
|
+
headers[name] || headers[name.downcase] || headers[name.split("-").map(&:capitalize).join("-")]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def secret_key_bytes(secret)
|
|
61
|
+
raw = secret.start_with?(SECRET_PREFIX) ? secret[SECRET_PREFIX.length..] : secret
|
|
62
|
+
Base64.urlsafe_decode64(raw + ("=" * ((4 - (raw.length % 4)) % 4)))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def secure_compare(a, b)
|
|
66
|
+
return false unless a.bytesize == b.bytesize
|
|
67
|
+
|
|
68
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
69
|
+
rescue StandardError
|
|
70
|
+
# Fallback for older OpenSSL without fixed_length_secure_compare.
|
|
71
|
+
res = 0
|
|
72
|
+
a.bytes.zip(b.bytes) { |x, y| res |= x ^ y }
|
|
73
|
+
res.zero?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|