unitpost 0.0.0 → 0.2.1

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.
@@ -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,474 @@
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 (email.send, email.templates.list, contacts.create, ...). 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 Email < Base
50
+ attr_reader :topics, :campaigns, :templates, :domains
51
+
52
+ def initialize(http)
53
+ super
54
+ @topics = Topics.new(http)
55
+ @campaigns = Campaigns.new(http)
56
+ @templates = Templates.new(http)
57
+ @domains = Domains.new(http)
58
+ end
59
+
60
+ # Send or schedule a transactional email. When no idempotency_key is
61
+ # given the SDK generates one so a transient retry can't double-send; pass
62
+ # your own to dedupe across separate send calls.
63
+ def send(body, idempotency_key: nil)
64
+ @http.request("POST", "/email", body: body, idempotency_key: idempotency_key || SecureRandom.uuid)
65
+ end
66
+
67
+ # Send up to 100 emails in one request (all-or-nothing validation). Like
68
+ # +send+, when no idempotency_key is given the SDK generates one so a
69
+ # transient retry is safe: the server dedupes the batch on the key and
70
+ # replays the original result instead of re-sending. Pass your own to
71
+ # dedupe across separate batch calls.
72
+ def batch(body, idempotency_key: nil)
73
+ @http.request("POST", "/email/batch", body: body, idempotency_key: idempotency_key || SecureRandom.uuid)
74
+ end
75
+
76
+ def get_batch(id)
77
+ @http.request("GET", "/email/batches/#{enc(id)}")
78
+ end
79
+
80
+ def cancel_batch(id)
81
+ @http.request("POST", "/email/batches/#{enc(id)}/cancel")
82
+ end
83
+
84
+ def list(limit: nil, after: nil, before: nil, **filters)
85
+ @http.request("GET", "/email", query: list_params(limit: limit, after: after, before: before, **filters))
86
+ end
87
+
88
+ def list_all(**params)
89
+ paginate("/email", params)
90
+ end
91
+
92
+ def get(id)
93
+ @http.request("GET", "/email/#{enc(id)}")
94
+ end
95
+
96
+ def update(id, body)
97
+ @http.request("PATCH", "/email/#{enc(id)}", body: body)
98
+ end
99
+
100
+ # Deliverability/engagement aggregates over a rolling window (days: 1–365,
101
+ # defaults to 30 server-side).
102
+ def stats(days: nil)
103
+ @http.request("GET", "/email/stats", query: { days: days })
104
+ end
105
+
106
+ def received_list(limit: nil, after: nil, before: nil)
107
+ @http.request("GET", "/email/received", query: list_params(limit: limit, after: after, before: before))
108
+ end
109
+
110
+ def received_get(id)
111
+ @http.request("GET", "/email/received/#{enc(id)}")
112
+ end
113
+
114
+ def received_attachment_url(id, attachment_id)
115
+ @http.request("GET", "/email/received/#{enc(id)}/attachments/#{enc(attachment_id)}")
116
+ end
117
+ end
118
+
119
+ class Contacts < Base
120
+ def list(limit: nil, after: nil, before: nil)
121
+ @http.request("GET", "/contacts", query: list_params(limit: limit, after: after, before: before))
122
+ end
123
+
124
+ def list_all(**params)
125
+ paginate("/contacts", params)
126
+ end
127
+
128
+ def create(body)
129
+ @http.request("POST", "/contacts", body: body)
130
+ end
131
+
132
+ def get(id_or_email)
133
+ @http.request("GET", "/contacts/#{enc(id_or_email)}")
134
+ end
135
+
136
+ def update(id_or_email, body)
137
+ @http.request("PATCH", "/contacts/#{enc(id_or_email)}", body: body)
138
+ end
139
+
140
+ def delete(id_or_email)
141
+ @http.request("DELETE", "/contacts/#{enc(id_or_email)}")
142
+ end
143
+
144
+ def import(body)
145
+ @http.request("POST", "/contacts/imports", body: body)
146
+ end
147
+
148
+ def list_imports(limit: nil, after: nil, before: nil)
149
+ @http.request("GET", "/contacts/imports", query: list_params(limit: limit, after: after, before: before))
150
+ end
151
+
152
+ def get_import(id)
153
+ @http.request("GET", "/contacts/imports/#{enc(id)}")
154
+ end
155
+ end
156
+
157
+ class ContactFields < Base
158
+ def list(limit: nil, after: nil, before: nil)
159
+ @http.request("GET", "/contact-fields", query: list_params(limit: limit, after: after, before: before))
160
+ end
161
+
162
+ def list_all(**params)
163
+ paginate("/contact-fields", params)
164
+ end
165
+
166
+ def create(body)
167
+ @http.request("POST", "/contact-fields", body: body)
168
+ end
169
+
170
+ def get(id)
171
+ @http.request("GET", "/contact-fields/#{enc(id)}")
172
+ end
173
+
174
+ def update(id, body)
175
+ @http.request("PATCH", "/contact-fields/#{enc(id)}", body: body)
176
+ end
177
+
178
+ def delete(id)
179
+ @http.request("DELETE", "/contact-fields/#{enc(id)}")
180
+ end
181
+
182
+ def rename(id, body)
183
+ @http.request("POST", "/contact-fields/#{enc(id)}/rename", body: body)
184
+ end
185
+ end
186
+
187
+ class Segments < Base
188
+ def list(limit: nil, after: nil, before: nil)
189
+ @http.request("GET", "/segments", query: list_params(limit: limit, after: after, before: before))
190
+ end
191
+
192
+ def list_all(**params)
193
+ paginate("/segments", params)
194
+ end
195
+
196
+ def create(body)
197
+ @http.request("POST", "/segments", body: body)
198
+ end
199
+
200
+ def get(id)
201
+ @http.request("GET", "/segments/#{enc(id)}")
202
+ end
203
+
204
+ def update(id, body)
205
+ @http.request("PATCH", "/segments/#{enc(id)}", body: body)
206
+ end
207
+
208
+ def delete(id)
209
+ @http.request("DELETE", "/segments/#{enc(id)}")
210
+ end
211
+
212
+ def list_members(id, limit: nil, after: nil, before: nil)
213
+ @http.request("GET", "/segments/#{enc(id)}/contacts", query: list_params(limit: limit, after: after, before: before))
214
+ end
215
+
216
+ def add_member(id, body)
217
+ @http.request("POST", "/segments/#{enc(id)}/contacts", body: body)
218
+ end
219
+
220
+ def remove_member(id, contact)
221
+ @http.request("DELETE", "/segments/#{enc(id)}/contacts/#{enc(contact)}")
222
+ end
223
+ end
224
+
225
+ class Topics < Base
226
+ def list(limit: nil, after: nil, before: nil)
227
+ @http.request("GET", "/email/topics", query: list_params(limit: limit, after: after, before: before))
228
+ end
229
+
230
+ def list_all(**params)
231
+ paginate("/email/topics", params)
232
+ end
233
+
234
+ def create(body)
235
+ @http.request("POST", "/email/topics", body: body)
236
+ end
237
+
238
+ def get(id)
239
+ @http.request("GET", "/email/topics/#{enc(id)}")
240
+ end
241
+
242
+ def update(id, body)
243
+ @http.request("PATCH", "/email/topics/#{enc(id)}", body: body)
244
+ end
245
+
246
+ def delete(id)
247
+ @http.request("DELETE", "/email/topics/#{enc(id)}")
248
+ end
249
+
250
+ def list_topics(contact_id, limit: nil, after: nil, before: nil)
251
+ @http.request("GET", "/contacts/#{enc(contact_id)}/topics", query: list_params(limit: limit, after: after, before: before))
252
+ end
253
+
254
+ def set_topic(contact_id, body)
255
+ @http.request("POST", "/contacts/#{enc(contact_id)}/topics", body: body)
256
+ end
257
+ end
258
+
259
+ class Campaigns < Base
260
+ def list(limit: nil, after: nil, before: nil)
261
+ @http.request("GET", "/email/campaigns", query: list_params(limit: limit, after: after, before: before))
262
+ end
263
+
264
+ def list_all(**params)
265
+ paginate("/email/campaigns", params)
266
+ end
267
+
268
+ def create(body)
269
+ @http.request("POST", "/email/campaigns", body: body)
270
+ end
271
+
272
+ def get(id)
273
+ @http.request("GET", "/email/campaigns/#{enc(id)}")
274
+ end
275
+
276
+ def update(id, body)
277
+ @http.request("PATCH", "/email/campaigns/#{enc(id)}", body: body)
278
+ end
279
+
280
+ def delete(id)
281
+ @http.request("DELETE", "/email/campaigns/#{enc(id)}")
282
+ end
283
+
284
+ def send(id)
285
+ @http.request("POST", "/email/campaigns/#{enc(id)}/send")
286
+ end
287
+
288
+ def cancel(id)
289
+ @http.request("POST", "/email/campaigns/#{enc(id)}/cancel")
290
+ end
291
+
292
+ def pause(id)
293
+ @http.request("POST", "/email/campaigns/#{enc(id)}/pause")
294
+ end
295
+
296
+ def resume(id)
297
+ @http.request("POST", "/email/campaigns/#{enc(id)}/resume")
298
+ end
299
+
300
+ def reschedule(id, body)
301
+ @http.request("POST", "/email/campaigns/#{enc(id)}/reschedule", body: body)
302
+ end
303
+
304
+ def validate(id)
305
+ @http.request("GET", "/email/campaigns/#{enc(id)}/validate")
306
+ end
307
+ end
308
+
309
+ class Templates < Base
310
+ def list(limit: nil, after: nil, before: nil)
311
+ @http.request("GET", "/email/templates", query: list_params(limit: limit, after: after, before: before))
312
+ end
313
+
314
+ def list_all(**params)
315
+ paginate("/email/templates", params)
316
+ end
317
+
318
+ def create(body)
319
+ @http.request("POST", "/email/templates", body: body)
320
+ end
321
+
322
+ def get(id)
323
+ @http.request("GET", "/email/templates/#{enc(id)}")
324
+ end
325
+
326
+ def update(id, body)
327
+ @http.request("PATCH", "/email/templates/#{enc(id)}", body: body)
328
+ end
329
+
330
+ def delete(id)
331
+ @http.request("DELETE", "/email/templates/#{enc(id)}")
332
+ end
333
+ end
334
+
335
+ # Read-only Brand Kit profiles (voice, colors, pinned /img/{id} graphics).
336
+ class BrandKits < Base
337
+ def list(name: nil)
338
+ query = {}
339
+ query[:name] = name unless name.nil?
340
+ @http.request("GET", "/brand-kits", query: query.empty? ? nil : query)
341
+ end
342
+
343
+ def get(id)
344
+ @http.request("GET", "/brand-kits/#{enc(id)}")
345
+ end
346
+ end
347
+
348
+ class Domains < Base
349
+ def list(limit: nil, after: nil, before: nil)
350
+ @http.request("GET", "/email/domains", query: list_params(limit: limit, after: after, before: before))
351
+ end
352
+
353
+ def list_all(**params)
354
+ paginate("/email/domains", params)
355
+ end
356
+
357
+ def create(body)
358
+ @http.request("POST", "/email/domains", body: body)
359
+ end
360
+
361
+ def get(id)
362
+ @http.request("GET", "/email/domains/#{enc(id)}")
363
+ end
364
+
365
+ def delete(id)
366
+ @http.request("DELETE", "/email/domains/#{enc(id)}")
367
+ end
368
+
369
+ def update(id, body)
370
+ @http.request("PATCH", "/email/domains/#{enc(id)}", body: body)
371
+ end
372
+
373
+ def verify(id)
374
+ @http.request("POST", "/email/domains/#{enc(id)}/verify")
375
+ end
376
+ end
377
+
378
+ class Webhooks < Base
379
+ def list(limit: nil, after: nil, before: nil)
380
+ @http.request("GET", "/webhooks", query: list_params(limit: limit, after: after, before: before))
381
+ end
382
+
383
+ def list_all(**params)
384
+ paginate("/webhooks", params)
385
+ end
386
+
387
+ def create(body)
388
+ @http.request("POST", "/webhooks", body: body)
389
+ end
390
+
391
+ def get(id)
392
+ @http.request("GET", "/webhooks/#{enc(id)}")
393
+ end
394
+
395
+ def update(id, body)
396
+ @http.request("PATCH", "/webhooks/#{enc(id)}", body: body)
397
+ end
398
+
399
+ def delete(id)
400
+ @http.request("DELETE", "/webhooks/#{enc(id)}")
401
+ end
402
+
403
+ def test(id)
404
+ @http.request("POST", "/webhooks/#{enc(id)}/test")
405
+ end
406
+
407
+ # Verify a delivery's signature and return the parsed event. Alias of
408
+ # Unitpost.verify_webhook / Unitpost::Webhooks.verify for a Resend-style
409
+ # client.webhooks.verify(...). Raises Unitpost::WebhookVerificationError on
410
+ # a missing/invalid/stale signature.
411
+ def verify(payload:, secret:, headers:, tolerance_seconds: 300)
412
+ Unitpost::Webhooks.verify(
413
+ payload: payload,
414
+ secret: secret,
415
+ headers: headers,
416
+ tolerance_seconds: tolerance_seconds,
417
+ )
418
+ end
419
+ end
420
+
421
+ class ApiKeys < Base
422
+ def list(limit: nil, after: nil, before: nil)
423
+ @http.request("GET", "/api-keys", query: list_params(limit: limit, after: after, before: before))
424
+ end
425
+
426
+ def list_all(**params)
427
+ paginate("/api-keys", params)
428
+ end
429
+
430
+ def create(body)
431
+ @http.request("POST", "/api-keys", body: body)
432
+ end
433
+
434
+ def delete(id)
435
+ @http.request("DELETE", "/api-keys/#{enc(id)}")
436
+ end
437
+ end
438
+
439
+ class Suppressions < Base
440
+ def list(limit: nil, after: nil, before: nil, **filters)
441
+ @http.request("GET", "/suppressions", query: list_params(limit: limit, after: after, before: before, **filters))
442
+ end
443
+
444
+ def list_all(**params)
445
+ paginate("/suppressions", params)
446
+ end
447
+
448
+ # Single (`{ email: ... }`) or bulk (`{ emails: [...] }`). Idempotent.
449
+ def create(body)
450
+ @http.request("POST", "/suppressions", body: body)
451
+ end
452
+
453
+ def get(id_or_email)
454
+ @http.request("GET", "/suppressions/#{enc(id_or_email)}")
455
+ end
456
+
457
+ def delete(id_or_email)
458
+ @http.request("DELETE", "/suppressions/#{enc(id_or_email)}")
459
+ end
460
+ end
461
+
462
+ # NOTE (pre-launch): the Events resource (POST /v1/events) is intentionally
463
+ # absent while the automations/custom-events surface is behind the launch
464
+ # gate. Restore it (class Events + client wiring) when the surface ships.
465
+
466
+ class Usage < Base
467
+ # Current billing-period snapshot: plan, period window, emails sent, and
468
+ # (paid plans) the dollar sending wallet in integer USD cents.
469
+ def get
470
+ @http.request("GET", "/usage")
471
+ end
472
+ end
473
+ end
474
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Unitpost
2
- VERSION = "0.0.0"
4
+ VERSION = "0.2.1"
3
5
  end