misarmail 1.0.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 +35 -0
- data/LICENSE +21 -0
- data/README.md +95 -0
- data/lib/misar_mail/client.rb +855 -0
- data/lib/misar_mail/core/query.rb +21 -0
- data/lib/misar_mail/core/sse.rb +72 -0
- data/lib/misar_mail/core/transport.rb +107 -0
- data/lib/misar_mail/core/webhooks.rb +53 -0
- data/lib/misar_mail/errors.rb +59 -0
- data/lib/misar_mail.rb +11 -0
- metadata +101 -0
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "core/query"
|
|
7
|
+
|
|
8
|
+
module MisarMail
|
|
9
|
+
BASE_URL = "https://api.misar.io/mail/v1"
|
|
10
|
+
API_BASE = "https://api.misar.io/mail"
|
|
11
|
+
BILLING_BASE = "https://api.misar.io/mail"
|
|
12
|
+
RETRYABLE = [429, 500, 502, 503, 504].freeze
|
|
13
|
+
RETRY_BASE_S = 0.3
|
|
14
|
+
|
|
15
|
+
# ── Resource base ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
class Resource
|
|
18
|
+
def initialize(client)
|
|
19
|
+
@client = client
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Every generated method that takes optional filters builds its path as
|
|
25
|
+
# query(compact(...)), but neither helper had a definition anywhere in the
|
|
26
|
+
# gem — so DmarcResource#check, EmailsResource#list, RevenueResource
|
|
27
|
+
# #attribution, SegmentsResource#members, SubscriptionResource#get,
|
|
28
|
+
# TeamMembersResource#get and DmarcResource#remove_domain all raised
|
|
29
|
+
# NoMethodError the moment they were called. Core::Query.encode was written
|
|
30
|
+
# to be "shared by every generated GET/DELETE method" and already drops
|
|
31
|
+
# nils; it was simply never wired up.
|
|
32
|
+
def compact(params)
|
|
33
|
+
params.reject { |_, v| v.nil? }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def query(params)
|
|
37
|
+
Core::Query.encode(params)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ── Resource: Email ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
class EmailResource < Resource
|
|
44
|
+
def send(data)
|
|
45
|
+
@client.request(:post, "/send", data)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ── Resource: Contacts ───────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
class ContactsResource < Resource
|
|
52
|
+
def list(page: 1, limit: 20)
|
|
53
|
+
@client.request(:get, "/contacts?#{URI.encode_www_form(page: page, limit: limit)}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def create(data)
|
|
57
|
+
@client.request(:post, "/contacts", data)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def get(id)
|
|
61
|
+
@client.request(:get, "/contacts?id=#{ERB::Util.url_encode(id)}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def update(email, data)
|
|
65
|
+
@client.request(:patch, "/contacts", data.merge(email: email))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def delete(id)
|
|
69
|
+
@client.request(:delete, "/contacts?id=#{ERB::Util.url_encode(id)}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def import_contacts(data)
|
|
73
|
+
@client.request(:post, "/contacts/import", data)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# ── Resource: Campaigns ──────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
class CampaignsResource < Resource
|
|
80
|
+
def list(params = {})
|
|
81
|
+
qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
|
|
82
|
+
@client.request(:get, "/campaigns#{qs}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def create(data)
|
|
86
|
+
@client.request(:post, "/campaigns", data)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def get(id)
|
|
90
|
+
@client.request(:get, "/campaigns/#{id}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def update(id, data)
|
|
94
|
+
@client.request(:patch, "/campaigns/#{id}", data)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def send(id)
|
|
98
|
+
@client.request(:post, "/campaigns/#{id}/send")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def delete(id)
|
|
102
|
+
@client.request(:delete, "/campaigns/#{id}")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ── Resource: Templates ──────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
class TemplatesResource < Resource
|
|
109
|
+
def list
|
|
110
|
+
@client.request(:get, "/templates")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def create(data)
|
|
114
|
+
@client.request(:post, "/templates", data)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def get(id)
|
|
118
|
+
@client.request(:get, "/templates/#{id}")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def update(id, data)
|
|
122
|
+
@client.request(:patch, "/templates/#{id}", data)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def delete(id)
|
|
126
|
+
@client.request(:delete, "/templates/#{id}")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def render(data)
|
|
130
|
+
@client.request(:post, "/templates/render", data)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# ── Resource: Automations ────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
class AutomationsResource < Resource
|
|
137
|
+
def list(params = {})
|
|
138
|
+
qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
|
|
139
|
+
@client.request(:get, "/automations#{qs}")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def create(data)
|
|
143
|
+
@client.request(:post, "/automations", data)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def get(id)
|
|
147
|
+
@client.request(:get, "/automations/#{id}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def update(id, data)
|
|
151
|
+
@client.request(:patch, "/automations/#{id}", data)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def delete(id)
|
|
155
|
+
@client.request(:delete, "/automations/#{id}")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def activate(id, active:)
|
|
159
|
+
@client.request(:post, "/automations/#{id}/activate", { active: active })
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ── Resource: Domains ────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
class DomainsResource < Resource
|
|
166
|
+
def list
|
|
167
|
+
@client.request(:get, "/domains", {}, API_BASE)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def create(data)
|
|
171
|
+
@client.request(:post, "/domains", data, API_BASE)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def get(id)
|
|
175
|
+
@client.request(:get, "/domains/#{id}", {}, API_BASE)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def verify(id)
|
|
179
|
+
@client.request(:post, "/domains/#{id}/verify", {}, API_BASE)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def delete(id)
|
|
183
|
+
@client.request(:delete, "/domains/#{id}", {}, API_BASE)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ── Resource: Dedicated IPs ──────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
class DedicatedIpsResource < Resource
|
|
191
|
+
def list
|
|
192
|
+
@client.request(:get, "/dedicated-ips")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def create(data)
|
|
196
|
+
@client.request(:post, "/dedicated-ips", data)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def update(id, data)
|
|
200
|
+
@client.request(:patch, "/dedicated-ips/#{id}", data)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def delete(id)
|
|
204
|
+
@client.request(:delete, "/dedicated-ips/#{id}")
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ── Resource: A/B Tests ──────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
class AbTestsResource < Resource
|
|
212
|
+
def list
|
|
213
|
+
@client.request(:get, "/ab-tests")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def create(data)
|
|
217
|
+
@client.request(:post, "/ab-tests", data)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def get(id)
|
|
221
|
+
@client.request(:get, "/ab-tests/#{id}")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def set_winner(id, variant_id)
|
|
225
|
+
@client.request(:post, "/ab-tests/#{id}/winner", { variant_id: variant_id })
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# ── Resource: Sandbox ────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
class SandboxResource < Resource
|
|
232
|
+
def send(data)
|
|
233
|
+
@client.request(:post, "/sandbox/send", data)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def list(params = {})
|
|
237
|
+
qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
|
|
238
|
+
@client.request(:get, "/sandbox#{qs}")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def delete(id)
|
|
242
|
+
@client.request(:delete, "/sandbox/#{id}")
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# ── Resource: Inbound ────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
class InboundResource < Resource
|
|
249
|
+
def list(params = {})
|
|
250
|
+
qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
|
|
251
|
+
@client.request(:get, "/inbound#{qs}")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def create(data)
|
|
255
|
+
@client.request(:post, "/inbound", data)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def get(id)
|
|
259
|
+
@client.request(:get, "/inbound/#{id}")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def delete(id)
|
|
263
|
+
@client.request(:delete, "/inbound/#{id}")
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# ── Resource: Analytics ──────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
class AnalyticsResource < Resource
|
|
270
|
+
def overview(params = {})
|
|
271
|
+
qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
|
|
272
|
+
@client.request(:get, "/analytics#{qs}")
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# ── Resource: Track ──────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
class TrackResource < Resource
|
|
279
|
+
def event(data)
|
|
280
|
+
@client.request(:post, "/track/event", data)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def purchase(data)
|
|
284
|
+
@client.request(:post, "/track/purchase", data)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# ── Resource: API Keys ───────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
class KeysResource < Resource
|
|
291
|
+
def list
|
|
292
|
+
@client.request(:get, "/keys")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def create(data)
|
|
296
|
+
@client.request(:post, "/keys", data)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def get(id)
|
|
300
|
+
@client.request(:get, "/keys/#{id}")
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def revoke(id)
|
|
304
|
+
@client.request(:delete, "/keys/#{id}")
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# ── Resource: Validate ───────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
class ValidateResource < Resource
|
|
311
|
+
def email(address)
|
|
312
|
+
@client.request(:post, "/validate", { email: address })
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# ── Resource: Webhooks ───────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
class WebhooksResource < Resource
|
|
319
|
+
def list
|
|
320
|
+
@client.request(:get, "/webhooks")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def create(data)
|
|
324
|
+
@client.request(:post, "/webhooks", data)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def get(id)
|
|
328
|
+
@client.request(:get, "/webhooks/#{id}")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def update(id, data)
|
|
332
|
+
@client.request(:patch, "/webhooks/#{id}", data)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def delete(id)
|
|
336
|
+
@client.request(:delete, "/webhooks/#{id}")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def test(id)
|
|
340
|
+
@client.request(:post, "/webhooks/#{id}/test")
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# ── Resource: Usage ──────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
class UsageResource < Resource
|
|
347
|
+
def get(params = {})
|
|
348
|
+
qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
|
|
349
|
+
@client.request(:get, "/usage#{qs}")
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# ── Resource: Billing ────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
class BillingResource < Resource
|
|
356
|
+
def subscription
|
|
357
|
+
@client.request(:get, "/billing/subscription", {}, BILLING_BASE)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def checkout(data)
|
|
361
|
+
@client.request(:post, "/billing/checkout", data, BILLING_BASE)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ── Main Client ──────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
# Live subscription standing for the key's owner.
|
|
369
|
+
#
|
|
370
|
+
# Read this before an expensive call rather than discovering the ceiling
|
|
371
|
+
# through a PlanLimitError: +usage+ reports every metered feature and
|
|
372
|
+
# +upgrade+ is non-nil as soon as one of them is spent.
|
|
373
|
+
class PlanResource < Resource
|
|
374
|
+
# GET /plan — plan, sending allowances, per-feature usage, upgrade offer.
|
|
375
|
+
def get
|
|
376
|
+
@client.request(:get, "/plan")
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# GET /monetization/stats — revenue and monetization counters.
|
|
380
|
+
def monetization
|
|
381
|
+
@client.request(:get, "/monetization/stats")
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ── Generated from scripts/sdk-endpoint-spec.json ──────────────────────────
|
|
387
|
+
#
|
|
388
|
+
# These twenty endpoints were missing from every SDK except TypeScript.
|
|
389
|
+
|
|
390
|
+
class AiResource < Resource
|
|
391
|
+
# POST /ai/subject-lines
|
|
392
|
+
def subject_lines(data: {})
|
|
393
|
+
@client.request(:post, "/ai/subject-lines", data)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
class CreditRatesResource < Resource
|
|
399
|
+
# GET /credit-rates
|
|
400
|
+
def list
|
|
401
|
+
@client.request(:get, "/credit-rates")
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
class DeliverabilityResource < Resource
|
|
407
|
+
# GET /deliverability/audit
|
|
408
|
+
def audit
|
|
409
|
+
@client.request(:get, "/deliverability/audit")
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# GET /deliverability/score
|
|
413
|
+
def score
|
|
414
|
+
@client.request(:get, "/deliverability/score")
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
class DmarcResource < Resource
|
|
420
|
+
# GET /dmarc/check
|
|
421
|
+
def check(domain: nil, dkim_selector: nil)
|
|
422
|
+
@client.request(:get, "/dmarc/check#{query(compact(domain: domain, dkim_selector: dkim_selector))}")
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# GET /dmarc/domains
|
|
426
|
+
def list_domains
|
|
427
|
+
@client.request(:get, "/dmarc/domains")
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# POST /dmarc/domains
|
|
431
|
+
def add_domain(data: {})
|
|
432
|
+
@client.request(:post, "/dmarc/domains", data)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# DELETE /dmarc/domains
|
|
436
|
+
def remove_domain(domain_id: nil)
|
|
437
|
+
@client.request(:delete, "/dmarc/domains#{query(compact(domain_id: domain_id))}")
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
class EmailAccountsResource < Resource
|
|
443
|
+
# GET /email-accounts
|
|
444
|
+
def list
|
|
445
|
+
@client.request(:get, "/email-accounts")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
class EmailsResource < Resource
|
|
451
|
+
# GET /emails
|
|
452
|
+
def list(folder: nil, search: nil, limit: nil)
|
|
453
|
+
@client.request(:get, "/emails#{query(compact(folder: folder, search: search, limit: limit))}")
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# GET /emails/:id
|
|
457
|
+
def get(id)
|
|
458
|
+
@client.request(:get, "/emails/#{id}")
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# PATCH /emails/:id
|
|
462
|
+
def update(id, data: {})
|
|
463
|
+
@client.request(:patch, "/emails/#{id}", data)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
class LandingPagesResource < Resource
|
|
469
|
+
# POST /landing-pages
|
|
470
|
+
def create(data: {})
|
|
471
|
+
@client.request(:post, "/landing-pages", data)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
class MonetizationResource < Resource
|
|
477
|
+
# POST /monetization/tip
|
|
478
|
+
def tip(data: {})
|
|
479
|
+
@client.request(:post, "/monetization/tip", data)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
class RevenueResource < Resource
|
|
485
|
+
# GET /revenue/attribution
|
|
486
|
+
def attribution(campaign_id: nil, period: nil)
|
|
487
|
+
@client.request(:get, "/revenue/attribution#{query(compact(campaign_id: campaign_id, period: period))}")
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
class SegmentsResource < Resource
|
|
493
|
+
# GET /segments/:id/members
|
|
494
|
+
def members(id, page: nil, limit: nil)
|
|
495
|
+
@client.request(:get, "/segments/#{id}/members#{query(compact(page: page, limit: limit))}")
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
class SubscriptionResource < Resource
|
|
501
|
+
# GET /subscription
|
|
502
|
+
def get(product: nil)
|
|
503
|
+
@client.request(:get, "/subscription#{query(compact(product: product))}")
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# POST /subscription
|
|
507
|
+
def upsert(data: {})
|
|
508
|
+
@client.request(:post, "/subscription", data)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# DELETE /subscription
|
|
512
|
+
def cancel(data: {})
|
|
513
|
+
@client.request(:delete, "/subscription", data)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
class TeamMembersResource < Resource
|
|
519
|
+
# GET /team-members
|
|
520
|
+
def get(owner_id: nil)
|
|
521
|
+
@client.request(:get, "/team-members#{query(compact(owner_id: owner_id))}")
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
class WalletResource < Resource
|
|
527
|
+
# GET /wallet
|
|
528
|
+
def get
|
|
529
|
+
@client.request(:get, "/wallet")
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# POST /wallet/credit
|
|
533
|
+
def credit(data: {})
|
|
534
|
+
@client.request(:post, "/wallet/credit", data)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# POST /wallet/debit
|
|
538
|
+
def debit(data: {})
|
|
539
|
+
@client.request(:post, "/wallet/debit", data)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
class WarmupResource < Resource
|
|
545
|
+
# GET /warmup
|
|
546
|
+
def get
|
|
547
|
+
@client.request(:get, "/warmup")
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# One decoded SSE frame.
|
|
553
|
+
#
|
|
554
|
+
# MisarMail emits unnamed frames — +data: {...}+ with no +event:+ line — so
|
|
555
|
+
# +event+ is normally nil. +data+ is the decoded JSON; +raw+ is always the
|
|
556
|
+
# payload exactly as received.
|
|
557
|
+
StreamEvent = Struct.new(:event, :data, :raw)
|
|
558
|
+
|
|
559
|
+
# The two Server-Sent Events endpoints.
|
|
560
|
+
#
|
|
561
|
+
# Both live outside +/v1+, so they use API_BASE. Both are API-key
|
|
562
|
+
# authenticated and metered, so a plan refusal on open raises PlanLimitError
|
|
563
|
+
# exactly as a non-streaming call would.
|
|
564
|
+
class StreamingResource < Resource
|
|
565
|
+
# POST /api/ai/generate-email/stream — token-by-token generation.
|
|
566
|
+
#
|
|
567
|
+
# mail.streaming.generate_email(prompt: "...") do |frame|
|
|
568
|
+
# print frame.data["delta"]
|
|
569
|
+
# end
|
|
570
|
+
#
|
|
571
|
+
# Returns an Enumerator when no block is given.
|
|
572
|
+
def generate_email(**options, &block)
|
|
573
|
+
@client.sse_stream(:post, "/ai/generate-email/stream", options, &block)
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# GET /api/campaigns/{id}/send-stream — live send progress.
|
|
577
|
+
def campaign_send(campaign_id, &block)
|
|
578
|
+
path = "/campaigns/#{ERB::Util.url_encode(campaign_id)}/send-stream"
|
|
579
|
+
@client.sse_stream(:get, path, nil, &block)
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
class Client
|
|
584
|
+
attr_reader :plan, :streaming, :ai, :credit_rates, :deliverability, :dmarc, :email_accounts, :emails, :landing_pages, :monetization, :revenue, :segments, :subscription, :team_members, :wallet, :warmup
|
|
585
|
+
attr_reader :email, :contacts, :campaigns, :templates, :automations,
|
|
586
|
+
:domains, :dedicated_ips, :ab_tests,
|
|
587
|
+
:sandbox, :inbound, :analytics, :track, :keys, :validate,
|
|
588
|
+
:webhooks, :usage,
|
|
589
|
+
:billing
|
|
590
|
+
|
|
591
|
+
# @param base_url [String] override the API host — point it at a stub
|
|
592
|
+
# server in tests, or at a self-hosted deployment. Defaults to BASE_URL.
|
|
593
|
+
def initialize(api_key:, timeout: 30, max_retries: 3, base_url: BASE_URL)
|
|
594
|
+
@api_key = api_key
|
|
595
|
+
@timeout = timeout
|
|
596
|
+
@max_retries = max_retries
|
|
597
|
+
@base_url = base_url
|
|
598
|
+
|
|
599
|
+
@plan = PlanResource.new(self)
|
|
600
|
+
@streaming = StreamingResource.new(self)
|
|
601
|
+
@ai = AiResource.new(self)
|
|
602
|
+
@credit_rates = CreditRatesResource.new(self)
|
|
603
|
+
@deliverability = DeliverabilityResource.new(self)
|
|
604
|
+
@dmarc = DmarcResource.new(self)
|
|
605
|
+
@email_accounts = EmailAccountsResource.new(self)
|
|
606
|
+
@emails = EmailsResource.new(self)
|
|
607
|
+
@landing_pages = LandingPagesResource.new(self)
|
|
608
|
+
@monetization = MonetizationResource.new(self)
|
|
609
|
+
@revenue = RevenueResource.new(self)
|
|
610
|
+
@segments = SegmentsResource.new(self)
|
|
611
|
+
@subscription = SubscriptionResource.new(self)
|
|
612
|
+
@team_members = TeamMembersResource.new(self)
|
|
613
|
+
@wallet = WalletResource.new(self)
|
|
614
|
+
@warmup = WarmupResource.new(self)
|
|
615
|
+
@email = EmailResource.new(self)
|
|
616
|
+
@contacts = ContactsResource.new(self)
|
|
617
|
+
@campaigns = CampaignsResource.new(self)
|
|
618
|
+
@templates = TemplatesResource.new(self)
|
|
619
|
+
@automations = AutomationsResource.new(self)
|
|
620
|
+
@domains = DomainsResource.new(self)
|
|
621
|
+
@dedicated_ips = DedicatedIpsResource.new(self)
|
|
622
|
+
@ab_tests = AbTestsResource.new(self)
|
|
623
|
+
@sandbox = SandboxResource.new(self)
|
|
624
|
+
@inbound = InboundResource.new(self)
|
|
625
|
+
@analytics = AnalyticsResource.new(self)
|
|
626
|
+
@track = TrackResource.new(self)
|
|
627
|
+
@keys = KeysResource.new(self)
|
|
628
|
+
@validate = ValidateResource.new(self)
|
|
629
|
+
@webhooks = WebhooksResource.new(self)
|
|
630
|
+
@usage = UsageResource.new(self)
|
|
631
|
+
@billing = BillingResource.new(self)
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# @param method [Symbol] :get, :post, :patch, :put, :delete
|
|
635
|
+
# @param path [String] e.g. "/contacts"
|
|
636
|
+
# @param data [Hash] request body (post/put/patch only)
|
|
637
|
+
# @param base [String] override base URL (for billing/workspaces)
|
|
638
|
+
# @return [Hash]
|
|
639
|
+
# @raise [ApiError, NetworkError]
|
|
640
|
+
def request(method, path, data = {}, base = nil)
|
|
641
|
+
base ||= @base_url
|
|
642
|
+
url = URI.parse(base.chomp("/") + "/" + path.delete_prefix("/"))
|
|
643
|
+
has_body = !data.empty? && %i[post put patch].include?(method)
|
|
644
|
+
json_body = has_body ? JSON.generate(data) : nil
|
|
645
|
+
|
|
646
|
+
last_status = 0
|
|
647
|
+
|
|
648
|
+
@max_retries.times do |attempt|
|
|
649
|
+
begin
|
|
650
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
651
|
+
http.use_ssl = url.scheme == "https"
|
|
652
|
+
http.open_timeout = 10
|
|
653
|
+
http.read_timeout = @timeout
|
|
654
|
+
|
|
655
|
+
req = build_request(method, url, json_body)
|
|
656
|
+
resp = http.request(req)
|
|
657
|
+
last_status = resp.code.to_i
|
|
658
|
+
|
|
659
|
+
# A rate-limit 429 and a spent-allowance 429 are identical by
|
|
660
|
+
# status, so the body decides. Only the first is worth retrying.
|
|
661
|
+
if plan_limit?(resp)
|
|
662
|
+
raise PlanLimitError.new(
|
|
663
|
+
last_status,
|
|
664
|
+
plan_limit_message(resp),
|
|
665
|
+
parse_body(resp),
|
|
666
|
+
response_headers(resp),
|
|
667
|
+
)
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
if RETRYABLE.include?(last_status) && attempt < @max_retries - 1
|
|
671
|
+
sleep(RETRY_BASE_S * (2**attempt))
|
|
672
|
+
next
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
return parse_response(resp, last_status)
|
|
676
|
+
|
|
677
|
+
rescue Net::OpenTimeout, Net::ReadTimeout,
|
|
678
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
|
|
679
|
+
if attempt < @max_retries - 1
|
|
680
|
+
sleep(RETRY_BASE_S * (2**attempt))
|
|
681
|
+
next
|
|
682
|
+
end
|
|
683
|
+
raise NetworkError.new(e.message, e)
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
raise ApiError.new(last_status, "Max retries exceeded")
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
# Opens an SSE connection and yields each decoded frame.
|
|
692
|
+
#
|
|
693
|
+
# Deliberately not retried: replaying a stream that failed mid-flight would
|
|
694
|
+
# duplicate whatever the caller already consumed.
|
|
695
|
+
#
|
|
696
|
+
# @yieldparam [StreamEvent] frame
|
|
697
|
+
# @return [Enumerator] when no block is given
|
|
698
|
+
def sse_stream(method, path, data = nil, &block)
|
|
699
|
+
return enum_for(:sse_stream, method, path, data) unless block_given?
|
|
700
|
+
|
|
701
|
+
url = URI.parse(API_BASE + path)
|
|
702
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
703
|
+
http.use_ssl = url.scheme == "https"
|
|
704
|
+
http.read_timeout = @timeout
|
|
705
|
+
|
|
706
|
+
req = build_request(method, url, data.nil? ? nil : JSON.generate(data))
|
|
707
|
+
req["Accept"] = "text/event-stream"
|
|
708
|
+
|
|
709
|
+
http.request(req) do |resp|
|
|
710
|
+
status = resp.code.to_i
|
|
711
|
+
unless (200..299).cover?(status)
|
|
712
|
+
raw = resp.read_body.to_s
|
|
713
|
+
body = begin
|
|
714
|
+
JSON.parse(raw)
|
|
715
|
+
rescue JSON::ParserError
|
|
716
|
+
{}
|
|
717
|
+
end
|
|
718
|
+
if body["code"] == "plan_limit_exceeded" || body["upgrade"].is_a?(Hash)
|
|
719
|
+
raise PlanLimitError.new(status, body["error"] || "plan limit exceeded",
|
|
720
|
+
body, response_headers(resp))
|
|
721
|
+
end
|
|
722
|
+
raise ApiError.new(status, body["error"] || (raw.empty? ? "stream error" : raw))
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
buffer = +""
|
|
726
|
+
event_name = nil
|
|
727
|
+
data_lines = []
|
|
728
|
+
|
|
729
|
+
flush = lambda do
|
|
730
|
+
next nil if data_lines.empty?
|
|
731
|
+
|
|
732
|
+
raw = data_lines.join("\n")
|
|
733
|
+
name = event_name
|
|
734
|
+
data_lines = []
|
|
735
|
+
event_name = nil
|
|
736
|
+
next :done if raw == "[DONE]"
|
|
737
|
+
|
|
738
|
+
decoded = begin
|
|
739
|
+
JSON.parse(raw)
|
|
740
|
+
rescue JSON::ParserError
|
|
741
|
+
nil
|
|
742
|
+
end
|
|
743
|
+
StreamEvent.new(name, decoded, raw)
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
resp.read_body do |chunk|
|
|
747
|
+
buffer << chunk
|
|
748
|
+
# A frame ends at a blank line; split on it and keep the remainder.
|
|
749
|
+
while (idx = buffer.index("\n\n") || buffer.index("\r\n\r\n"))
|
|
750
|
+
frame = buffer.slice!(0, idx)
|
|
751
|
+
buffer.sub!(/\A(\r?\n){1,2}/, "")
|
|
752
|
+
|
|
753
|
+
frame.split(/\r?\n/).each do |line|
|
|
754
|
+
next if line.empty? || line.start_with?(":") # keepalive
|
|
755
|
+
|
|
756
|
+
if line.start_with?("event:")
|
|
757
|
+
event_name = line.delete_prefix("event:").strip
|
|
758
|
+
elsif line.start_with?("data:")
|
|
759
|
+
data_lines << line.delete_prefix("data:").delete_prefix(" ")
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
result = flush.call
|
|
764
|
+
return if result == :done
|
|
765
|
+
|
|
766
|
+
yield result if result
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
# A trailing frame with no closing blank line.
|
|
771
|
+
buffer.split(/\r?\n/).each do |line|
|
|
772
|
+
next if line.empty? || line.start_with?(":")
|
|
773
|
+
|
|
774
|
+
if line.start_with?("event:")
|
|
775
|
+
event_name = line.delete_prefix("event:").strip
|
|
776
|
+
elsif line.start_with?("data:")
|
|
777
|
+
data_lines << line.delete_prefix("data:").delete_prefix(" ")
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
tail = flush.call
|
|
781
|
+
yield tail if tail && tail != :done
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
private
|
|
786
|
+
|
|
787
|
+
def build_request(method, url, json_body)
|
|
788
|
+
klass = case method
|
|
789
|
+
when :get then Net::HTTP::Get
|
|
790
|
+
when :post then Net::HTTP::Post
|
|
791
|
+
when :patch then Net::HTTP::Patch
|
|
792
|
+
when :put then Net::HTTP::Put
|
|
793
|
+
when :delete then Net::HTTP::Delete
|
|
794
|
+
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
req = klass.new(url.request_uri)
|
|
798
|
+
req["Authorization"] = "Bearer #{@api_key}"
|
|
799
|
+
req["Content-Type"] = "application/json"
|
|
800
|
+
req["Accept"] = "application/json"
|
|
801
|
+
req.body = json_body if json_body
|
|
802
|
+
req
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# True when the body carries the API's plan-refusal marker.
|
|
806
|
+
def plan_limit?(resp)
|
|
807
|
+
body = parse_body(resp)
|
|
808
|
+
body["code"] == "plan_limit_exceeded" ||
|
|
809
|
+
body["error_type"] == "plan_limit_exceeded" ||
|
|
810
|
+
body["upgrade"].is_a?(Hash)
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def plan_limit_message(resp)
|
|
814
|
+
parse_body(resp)["error"] || "plan limit exceeded"
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
# Always a Hash. Both callers index it by string key, and a top-level JSON
|
|
818
|
+
# array is a shape this API really returns — parse_response has always had
|
|
819
|
+
# a branch for it. Returning the raw Array here made plan_limit? raise
|
|
820
|
+
# "TypeError: no implicit conversion of String into Integer" before
|
|
821
|
+
# parse_response ever got to use that branch, so an array response could
|
|
822
|
+
# not be delivered at all.
|
|
823
|
+
def parse_body(resp)
|
|
824
|
+
return {} if resp.body.nil? || resp.body.empty?
|
|
825
|
+
|
|
826
|
+
decoded = JSON.parse(resp.body)
|
|
827
|
+
decoded.is_a?(Hash) ? decoded : {}
|
|
828
|
+
rescue JSON::ParserError
|
|
829
|
+
{}
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
def response_headers(resp)
|
|
833
|
+
resp.each_header.to_h
|
|
834
|
+
rescue StandardError
|
|
835
|
+
{}
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def parse_response(resp, status)
|
|
839
|
+
return {} if status == 204 || resp.body.nil? || resp.body.empty?
|
|
840
|
+
|
|
841
|
+
decoded = begin
|
|
842
|
+
JSON.parse(resp.body)
|
|
843
|
+
rescue JSON::ParserError
|
|
844
|
+
nil
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
if status >= 400
|
|
848
|
+
msg = decoded.is_a?(Hash) ? (decoded["error"] || decoded["message"] || resp.body) : resp.body
|
|
849
|
+
raise ApiError.new(status, msg)
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
decoded.is_a?(Hash) ? decoded : { "data" => decoded }
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
end
|