misarmail 1.0.0 → 5.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 +4 -4
- data/README.md +392 -38
- data/lib/misar_mail/client.rb +6 -1
- metadata +13 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c36f75bbe102b5c13546df99d0caa263e59ce8b88fe46d723f7d02810d65b477
|
|
4
|
+
data.tar.gz: 278a9e3c983715766e847e8a56bbebbeec4c4648b41e0956a615b3bc24249760
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0b98b6228ed597e0c49c86827c16e71cfb4925f7efea1ca341f653efe7ca128992e0e775fc0f01ed687af0bd06ffc6d4c4e92c40767dd5694f1e7ae32038292d
|
|
7
|
+
data.tar.gz: a82c543e3faf7e792bff37b5233539d8c34643cf6ae29c9c97624e3d5775f73e827d0a1a1244c1eb2510cb5f9507852ad31ff218d5a99922c4801c1aea45aabe
|
data/README.md
CHANGED
|
@@ -1,65 +1,386 @@
|
|
|
1
1
|
# MisarMail Ruby SDK
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
send, campaigns, contacts, templates, automations, deliverability, warmup,
|
|
5
|
-
monetization and the two AI streams.
|
|
3
|
+
> Send transactional email and run marketing campaigns from Ruby — one client, no runtime dependencies.
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
[](https://rubygems.org/gems/misarmail)
|
|
6
|
+
[](https://rubygems.org/gems/misarmail)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
**33 resource groups · 90 methods · SSE streaming · webhook signature verification**
|
|
10
|
+
|
|
11
|
+
MisarMail is one API for both halves of your email: the receipts and password resets your product sends, and the campaigns, segments and automations your marketing team runs on the same contact list and the same verified domains.
|
|
12
|
+
|
|
13
|
+
Built on `net/http` with nothing outside the standard library, for Ruby 2.7+. Every method returns a plain `Hash` with string keys, so nothing breaks when the API adds a field.
|
|
14
|
+
|
|
15
|
+
---
|
|
8
16
|
|
|
9
17
|
## Install
|
|
10
18
|
|
|
19
|
+
### RubyGems
|
|
20
|
+
|
|
11
21
|
```bash
|
|
12
22
|
gem install misarmail
|
|
13
23
|
```
|
|
14
24
|
|
|
15
|
-
|
|
25
|
+
### Bundler
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem "misarmail", "~> 1.0"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
16
32
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
`
|
|
33
|
+
## Authentication
|
|
34
|
+
|
|
35
|
+
Create a developer key at https://mail.misar.io/developers. It starts with `msk_` and is
|
|
36
|
+
sent as `Authorization: Bearer msk_…`.
|
|
20
37
|
|
|
21
38
|
Every call is metered against the subscription attached to that key. There is no
|
|
22
|
-
client-side limit checking — the server decides, and the SDK surfaces its answer.
|
|
39
|
+
client-side limit checking — the server decides, and the SDK surfaces its answer. A plan
|
|
40
|
+
refusal answers **403** with `code: "plan_limit_exceeded"` and is never retried.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require "misar_mail"
|
|
44
|
+
|
|
45
|
+
mail = MisarMail::Client.new(api_key: ENV.fetch("MISARMAIL_API_KEY"))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Resources
|
|
51
|
+
|
|
52
|
+
Every group the client exposes, and every public method on it.
|
|
53
|
+
|
|
54
|
+
### Send
|
|
55
|
+
|
|
56
|
+
| Resource | Methods | What it covers |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| `mail.email` | `send` | Transactional send — cc/bcc/reply-to, tags, metadata, `idempotency_key`. |
|
|
59
|
+
| `mail.sandbox` | `send`, `list`, `delete` | Test sends captured instead of delivered. |
|
|
60
|
+
|
|
61
|
+
### Campaigns and tests
|
|
62
|
+
|
|
63
|
+
| Resource | Methods | What it covers |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| `mail.campaigns` | `list`, `create`, `get`, `update`, `send`, `delete` | Marketing campaigns: draft, edit, queue for send. |
|
|
66
|
+
| `mail.ab_tests` | `list`, `create`, `get`, `set_winner` | Subject, content, send-time, from-name and preheader splits, and winner selection. |
|
|
67
|
+
|
|
68
|
+
### Audience
|
|
69
|
+
|
|
70
|
+
| Resource | Methods | What it covers |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `mail.contacts` | `list`, `create`, `get`, `update`, `delete`, `import_contacts` | Subscribers, plus bulk import. |
|
|
73
|
+
| `mail.segments` | `members` | Dynamic audience segments and their membership. |
|
|
74
|
+
| `mail.landing_pages` | `create` | Hosted landing pages with an email capture form. |
|
|
75
|
+
|
|
76
|
+
### Content
|
|
77
|
+
|
|
78
|
+
| Resource | Methods | What it covers |
|
|
79
|
+
| --- | --- | --- |
|
|
80
|
+
| `mail.templates` | `list`, `create`, `get`, `update`, `delete`, `render` | Reusable templates and server-side variable rendering. |
|
|
81
|
+
| `mail.ai` | `subject_lines` | AI-generated subject lines. |
|
|
82
|
+
|
|
83
|
+
### Automations
|
|
84
|
+
|
|
85
|
+
| Resource | Methods | What it covers |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| `mail.automations` | `list`, `create`, `get`, `update`, `delete`, `activate` | Trigger-based workflows — welcome series, drips, re-engagement. |
|
|
88
|
+
|
|
89
|
+
### Deliverability and sending infrastructure
|
|
90
|
+
|
|
91
|
+
| Resource | Methods | What it covers |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `mail.domains` | `list`, `create`, `get`, `verify`, `delete` | Sending domains and their DNS verification. |
|
|
94
|
+
| `mail.dmarc` | `check`, `list_domains`, `add_domain`, `remove_domain` | Live SPF/DKIM/DMARC record checks and monitored domains. |
|
|
95
|
+
| `mail.deliverability` | `audit`, `score` | Deliverability score, audit and remediation guidance. |
|
|
96
|
+
| `mail.dedicated_ips` | `list`, `create`, `update`, `delete` | Dedicated sending IPs. |
|
|
97
|
+
| `mail.warmup` | `get` | IP/domain warm-up progress and today's remaining capacity. |
|
|
98
|
+
| `mail.inbound` | `list`, `create`, `get`, `delete` | Inbound routing domains, so replies land in the unified inbox. |
|
|
99
|
+
|
|
100
|
+
### Mailbox and inbox
|
|
101
|
+
|
|
102
|
+
| Resource | Methods | What it covers |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| `mail.emails` | `list`, `get`, `update` | Stored messages in the mailbox. |
|
|
105
|
+
| `mail.email_accounts` | `list` | Connected mailbox accounts. |
|
|
106
|
+
|
|
107
|
+
### Analytics and attribution
|
|
108
|
+
|
|
109
|
+
| Resource | Methods | What it covers |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| `mail.analytics` | `overview` | Delivery and engagement stats — aggregate, or one campaign. |
|
|
112
|
+
| `mail.track` | `event`, `purchase` | Custom events and ecommerce purchases. |
|
|
113
|
+
| `mail.revenue` | `attribution` | Revenue attributed back to email. |
|
|
114
|
+
| `mail.usage` | `get` | Metered usage for a period. |
|
|
115
|
+
|
|
116
|
+
### Validation
|
|
117
|
+
|
|
118
|
+
| Resource | Methods | What it covers |
|
|
119
|
+
| --- | --- | --- |
|
|
120
|
+
| `mail.validate` | `email` | Address validation, and the credit balance behind it. |
|
|
121
|
+
|
|
122
|
+
### Plan, billing and credits
|
|
123
|
+
|
|
124
|
+
| Resource | Methods | What it covers |
|
|
125
|
+
| --- | --- | --- |
|
|
126
|
+
| `mail.plan` | `get`, `monetization` | Current plan, quotas and monetization stats. |
|
|
127
|
+
| `mail.billing` | `subscription`, `checkout` | Subscription state and checkout. |
|
|
128
|
+
| `mail.subscription` | `get`, `upsert`, `cancel` | Subscription read/write and per-product plan limits. |
|
|
129
|
+
| `mail.wallet` | `get`, `credit`, `debit` | Credit balance, credit and debit. |
|
|
130
|
+
| `mail.credit_rates` | `list` | What each metered action costs in credits. |
|
|
131
|
+
| `mail.team_members` | `get` | Team members on the account. |
|
|
132
|
+
| `mail.monetization` | `tip` | Newsletter tips. |
|
|
133
|
+
|
|
134
|
+
### Developer
|
|
135
|
+
|
|
136
|
+
| Resource | Methods | What it covers |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| `mail.keys` | `list`, `create`, `get`, `revoke` | API keys — create, list, revoke. |
|
|
139
|
+
| `mail.webhooks` | `list`, `create`, `get`, `update`, `delete`, `test` | Webhook endpoints, plus a test delivery. |
|
|
140
|
+
| `mail.streaming` | `generate_email`, `campaign_send` | The two Server-Sent Events endpoints. |
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Client
|
|
145
|
+
|
|
146
|
+
| Thing | Detail |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| Entry point | `MisarMail::Client.new(api_key:, timeout: 30, max_retries: 3, base_url:)`. `MisarMail.new(**kwargs)` is a shorthand. |
|
|
149
|
+
| `base_url` | `https://api.misar.io/mail/v1` |
|
|
150
|
+
| Results | A `Hash` with **string** keys. `204`/empty comes back as `{}`; a top-level array is wrapped as `{"data" => [...]}`. |
|
|
151
|
+
| Transport | `net/http`, 10-second open timeout, your `timeout` for reads. |
|
|
152
|
+
| Retried | `429`, `500`, `502`, `503`, `504`, plus `Net::OpenTimeout`, `Net::ReadTimeout`, `Errno::ECONNREFUSED`, `Errno::ECONNRESET` and `SocketError` — 300 ms then 600 ms. |
|
|
153
|
+
| Never retried | Plan refusals, and streams. |
|
|
154
|
+
| Errors | `MisarMail::ApiError`, with `NetworkError` and `PlanLimitError` extending it. |
|
|
155
|
+
| Webhook verifier | `MisarMail::Webhooks.verify` / `.sign`. |
|
|
156
|
+
| Escape hatch | `mail.request(:get, path)` is public, for routes with no method yet. |
|
|
157
|
+
|
|
158
|
+
---
|
|
23
159
|
|
|
24
160
|
## Quick start
|
|
25
161
|
|
|
26
162
|
```ruby
|
|
27
163
|
require "misar_mail"
|
|
28
164
|
|
|
29
|
-
mail = MisarMail::Client.new(api_key: "
|
|
165
|
+
mail = MisarMail::Client.new(api_key: ENV.fetch("MISARMAIL_API_KEY"))
|
|
30
166
|
|
|
31
|
-
mail.email.send(
|
|
32
|
-
from: { email: "you@yourdomain.com" },
|
|
167
|
+
sent = mail.email.send(
|
|
168
|
+
from: { email: "you@yourdomain.com", name: "Your App" },
|
|
33
169
|
to: [{ email: "someone@example.com" }],
|
|
34
170
|
subject: "Hello",
|
|
35
171
|
html: "<p>Hi there</p>"
|
|
36
172
|
)
|
|
37
173
|
|
|
38
|
-
|
|
174
|
+
puts sent["message_id"]
|
|
39
175
|
```
|
|
40
176
|
|
|
41
|
-
##
|
|
177
|
+
## Primary functions
|
|
42
178
|
|
|
43
|
-
|
|
44
|
-
carrying `code: "plan_limit_exceeded"`. The SDK keys on that code rather than
|
|
45
|
-
the status, which is why a refusal is typed correctly even though 403 is
|
|
46
|
-
otherwise an authorization failure. The SDK raises
|
|
47
|
-
`PlanLimitError` for either, and **does not retry** it — retrying cannot
|
|
48
|
-
help until the allowance resets or the plan changes. Read ``upgrade_url`` to
|
|
49
|
-
send the user somewhere useful.
|
|
179
|
+
### Send a transactional email
|
|
50
180
|
|
|
51
|
-
`
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
A null `limit` means unlimited, and `remaining` is null alongside it rather than
|
|
55
|
-
0. Read it before an expensive call rather than discovering the ceiling through
|
|
56
|
-
a refusal.
|
|
181
|
+
`from` is a single address hash and `to` is an array of them. Pass an `idempotency_key`
|
|
182
|
+
and a retry can never send twice — the response comes back with `idempotent: true` the
|
|
183
|
+
second time.
|
|
57
184
|
|
|
58
|
-
|
|
185
|
+
```ruby
|
|
186
|
+
res = mail.email.send(
|
|
187
|
+
from: { email: "receipts@yourdomain.com", name: "Acme" },
|
|
188
|
+
to: [{ email: "customer@example.com" }],
|
|
189
|
+
reply_to: { email: "support@yourdomain.com" },
|
|
190
|
+
subject: "Your receipt",
|
|
191
|
+
html: "<p>Thanks for your order.</p>",
|
|
192
|
+
text: "Thanks for your order.",
|
|
193
|
+
tags: ["receipt"],
|
|
194
|
+
metadata: { order_id: "ord-1041" },
|
|
195
|
+
idempotency_key: "ord-1041-receipt"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
res["message_id"] # "msg-…"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### List and create contacts
|
|
202
|
+
|
|
203
|
+
Responses are enveloped. `list` returns `{"success", "data", "pagination"}` and takes
|
|
204
|
+
`page:`/`limit:` keywords rather than a params hash; `create` returns
|
|
205
|
+
`{"success", "data"}` with the contact under `data`.
|
|
59
206
|
|
|
60
207
|
```ruby
|
|
61
|
-
|
|
208
|
+
page = mail.contacts.list(page: 1, limit: 50)
|
|
209
|
+
puts "#{page['data'].length} of #{page['pagination']['total']}"
|
|
210
|
+
|
|
211
|
+
created = mail.contacts.create(
|
|
212
|
+
email: "new@example.com",
|
|
213
|
+
firstName: "Ada",
|
|
214
|
+
lastName: "Lovelace",
|
|
215
|
+
tags: ["beta"],
|
|
216
|
+
customFields: { plan: "pro" }
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
puts created["data"]["id"]
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`get` and `delete` take the contact id, which the route reads from the query string
|
|
223
|
+
rather than a path segment. `update` is different again: it identifies the contact by
|
|
224
|
+
**email address**, not by id.
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
mail.contacts.update("ada@example.com", status: "unsubscribed")
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Bulk import contacts
|
|
231
|
+
|
|
232
|
+
The method is `import_contacts` — `import` is not defined. Counts come back under
|
|
233
|
+
`summary`, and `errors` is a separate list of messages.
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
imported = mail.contacts.import_contacts(
|
|
237
|
+
contacts: [
|
|
238
|
+
{ email: "a@example.com", firstName: "A" },
|
|
239
|
+
{ email: "b@example.com", firstName: "B" }
|
|
240
|
+
],
|
|
241
|
+
updateExisting: true
|
|
242
|
+
)
|
|
62
243
|
|
|
244
|
+
imported["summary"] # {"imported"=>…, "updated"=>…, "skipped"=>…, "errors"=>…}
|
|
245
|
+
imported["errors"] # array of strings
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Create and send a campaign
|
|
249
|
+
|
|
250
|
+
Campaigns take `fromName` and `fromEmail` as separate fields — there is no `from` hash
|
|
251
|
+
here, unlike `email.send`. `campaigns.send(id)` queues the campaign and reports it as
|
|
252
|
+
`scheduled`.
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
campaign = mail.campaigns.create(
|
|
256
|
+
name: "March launch",
|
|
257
|
+
subject: "We just shipped",
|
|
258
|
+
fromName: "Ada at Acme",
|
|
259
|
+
fromEmail: "hello@yourdomain.com",
|
|
260
|
+
replyTo: "support@yourdomain.com",
|
|
261
|
+
bodyHtml: "<h1>It's live</h1>",
|
|
262
|
+
segmentId: "seg-123"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
queued = mail.campaigns.send(campaign["data"]["id"])
|
|
266
|
+
puts "#{queued['campaignId']} #{queued['status']}" # … scheduled
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Only `draft`, `scheduled` or `paused` campaigns can be updated, and only `draft`
|
|
270
|
+
campaigns can be deleted.
|
|
271
|
+
|
|
272
|
+
### Validate an address
|
|
273
|
+
|
|
274
|
+
Each call spends a credit, and the response tells you what is left.
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
check = mail.validate.email("someone@example.com")
|
|
278
|
+
|
|
279
|
+
check["data"]["is_valid"] # true / false
|
|
280
|
+
check["data"]["score"] # 0–1 confidence
|
|
281
|
+
check["data"]["checks"] # {"syntax"=>…, "mx"=>…, "smtp"=>…}
|
|
282
|
+
check["data"]["flags"]["disposable"]
|
|
283
|
+
check["credits"]["balance_after"] # credits remaining
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Render a template
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
rendered = mail.templates.render(
|
|
290
|
+
template_id: "tpl-123",
|
|
291
|
+
variables: { name: "Ada", plan: "Pro" }
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
rendered["data"]["subject"] # "Welcome, Ada"
|
|
295
|
+
rendered["data"]["html"]
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Track events and revenue
|
|
299
|
+
|
|
300
|
+
The event name field is `event_name`, and purchase totals are integer **cents** in
|
|
301
|
+
`total_cents`.
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
mail.track.event(
|
|
305
|
+
email: "customer@example.com",
|
|
306
|
+
event_name: "viewed_pricing",
|
|
307
|
+
event_data: { plan: "pro" }
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
purchase = mail.track.purchase(
|
|
311
|
+
email: "customer@example.com",
|
|
312
|
+
order_id: "ord-1041",
|
|
313
|
+
total_cents: 9900,
|
|
314
|
+
currency: "USD",
|
|
315
|
+
items: [{ name: "Pro annual", quantity: 1, price_cents: 9900 }]
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
purchase["attribution"] # which campaign or automation earned it
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Read analytics and manage keys
|
|
322
|
+
|
|
323
|
+
Without `campaignId` you get aggregate usage and totals for the period; with one you get
|
|
324
|
+
that campaign's stats and rates. `keys.list` returns the keys under **`keys`**, not
|
|
325
|
+
`data`, and `create` returns the raw key exactly once.
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
overall = mail.analytics.overview(startDate: "2026-04-01", endDate: "2026-04-30")
|
|
329
|
+
one = mail.analytics.overview(campaignId: campaign_id)
|
|
330
|
+
|
|
331
|
+
keys = mail.keys.list
|
|
332
|
+
keys["keys"].length
|
|
333
|
+
|
|
334
|
+
fresh = mail.keys.create(name: "CI", scopes: %w[send read])
|
|
335
|
+
puts fresh["key"] # shown once and never again
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Verify an inbound webhook
|
|
339
|
+
|
|
340
|
+
MisarMail signs each delivery as `HMAC-SHA256(timestamp + "." + raw_body)`, sending the
|
|
341
|
+
digest in `X-Misar-Signature` and the Unix timestamp in `X-Misar-Timestamp`. Verify
|
|
342
|
+
against the **raw** request body — re-serializing the parsed hash changes key order and
|
|
343
|
+
whitespace, and so changes the digest. `verify` compares in constant time, rejects
|
|
344
|
+
timestamps older than 300 seconds by default, and returns `false` rather than raising on
|
|
345
|
+
malformed input.
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
ok = MisarMail::Webhooks.verify(
|
|
349
|
+
payload: request.raw_post,
|
|
350
|
+
signature: request.headers["X-Misar-Signature"],
|
|
351
|
+
timestamp: request.headers["X-Misar-Timestamp"],
|
|
352
|
+
secret: ENV.fetch("MISARMAIL_WEBHOOK_SECRET"),
|
|
353
|
+
tolerance: 300
|
|
354
|
+
)
|
|
355
|
+
head :bad_request unless ok
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`MisarMail::Webhooks.sign(payload, timestamp, secret)` produces the same digest, which is
|
|
359
|
+
what you want when testing your own consumer.
|
|
360
|
+
|
|
361
|
+
## Errors
|
|
362
|
+
|
|
363
|
+
Three classes, all under `MisarMail`:
|
|
364
|
+
|
|
365
|
+
| Class | When |
|
|
366
|
+
| --- | --- |
|
|
367
|
+
| `ApiError` | Any non-2xx API response. Carries `status` and `error_type`. |
|
|
368
|
+
| `NetworkError` | The request never got an answer, or every retry was spent. `status` is `0`. |
|
|
369
|
+
| `PlanLimitError` | The subscription behind the key does not cover the call. |
|
|
370
|
+
|
|
371
|
+
`NetworkError` and `PlanLimitError` both subclass `ApiError`, so
|
|
372
|
+
`rescue MisarMail::ApiError` catches everything the SDK raises.
|
|
373
|
+
|
|
374
|
+
### Plan limits
|
|
375
|
+
|
|
376
|
+
Both a spent allowance and a feature that is not on the plan answer **`403`**, carrying
|
|
377
|
+
`code: "plan_limit_exceeded"`. The SDK keys on that code rather than the status, which is
|
|
378
|
+
why a refusal is typed correctly even though 403 is otherwise an authorization failure.
|
|
379
|
+
It raises `PlanLimitError` and **does not retry** — retrying cannot help until the
|
|
380
|
+
allowance resets or the plan changes. Read `upgrade_url` to send the user somewhere
|
|
381
|
+
useful.
|
|
382
|
+
|
|
383
|
+
```ruby
|
|
63
384
|
begin
|
|
64
385
|
mail.campaigns.create(
|
|
65
386
|
name: "Blast", subject: "We just shipped",
|
|
@@ -67,29 +388,62 @@ begin
|
|
|
67
388
|
)
|
|
68
389
|
rescue MisarMail::PlanLimitError => e
|
|
69
390
|
warn "#{e.feature} exhausted on #{e.plan}: #{e.upgrade_url}"
|
|
391
|
+
# e.retry_after is seconds until the allowance resets, when the API says so
|
|
70
392
|
end
|
|
71
393
|
```
|
|
72
394
|
|
|
395
|
+
`mail.plan.get` returns `plan`, `sending` (the per-day and per-month email caps), `usage`
|
|
396
|
+
— an array with one entry per metered feature, each carrying `used`, `limit` and
|
|
397
|
+
`remaining` — and `upgrade`, which is null until a quota is tight. A null `limit` means
|
|
398
|
+
unlimited, and `remaining` is null alongside it rather than 0. Read it before an
|
|
399
|
+
expensive call rather than discovering the ceiling through a refusal.
|
|
400
|
+
|
|
401
|
+
The key needs the `read` or `subscription` scope.
|
|
402
|
+
|
|
403
|
+
```ruby
|
|
404
|
+
plan = mail.plan.get
|
|
405
|
+
p plan["sending"]
|
|
406
|
+
p plan["usage"]
|
|
407
|
+
```
|
|
408
|
+
|
|
73
409
|
## Streaming
|
|
74
410
|
|
|
75
|
-
Two endpoints stream Server-Sent Events. Both sit **outside** `/v1`, which the
|
|
76
|
-
|
|
411
|
+
Two endpoints stream Server-Sent Events. Both sit **outside** `/v1`, which the SDK
|
|
412
|
+
handles for you:
|
|
77
413
|
|
|
78
414
|
| Method | Route |
|
|
79
415
|
| --- | --- |
|
|
80
|
-
| `streaming.
|
|
81
|
-
| `streaming.
|
|
416
|
+
| `streaming.generate_email` | `POST /api/ai/generate-email/stream` |
|
|
417
|
+
| `streaming.campaign_send` | `GET /api/campaigns/{id}/send-stream` |
|
|
418
|
+
|
|
419
|
+
Frames are unnamed (`data: {…}`, with no `event:` line) and the stream ends with
|
|
420
|
+
`data: [DONE]`, which the SDK consumes rather than handing on. Each `StreamEvent` carries
|
|
421
|
+
`event` (normally nil), `data` (the parsed hash, or nil when the payload was not JSON)
|
|
422
|
+
and `raw`. Without a block you get an `Enumerator` instead. A stream is never retried:
|
|
423
|
+
replaying one that failed mid-flight would duplicate whatever you had already read.
|
|
82
424
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
that failed mid-flight would duplicate whatever you had already read.
|
|
425
|
+
Note that streaming always talks to `https://api.misar.io/mail` — unlike the other
|
|
426
|
+
methods it does not follow the `base_url:` you passed the client.
|
|
86
427
|
|
|
87
428
|
```ruby
|
|
88
429
|
mail.streaming.generate_email(prompt: "a launch email") do |event|
|
|
89
430
|
print event.data["delta"]
|
|
90
431
|
end
|
|
432
|
+
|
|
433
|
+
mail.streaming.campaign_send(campaign_id) do |event|
|
|
434
|
+
puts event.raw
|
|
435
|
+
end
|
|
91
436
|
```
|
|
92
437
|
|
|
93
|
-
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## Links
|
|
441
|
+
|
|
442
|
+
- Website — https://www.misarmail.com
|
|
443
|
+
- App — https://mail.misar.io
|
|
444
|
+
- Parent — https://misar.io
|
|
445
|
+
- Documentation — https://docs.misar.io/mail
|
|
446
|
+
- Source — https://github.com/Misar-AI/misarmail-sdks
|
|
447
|
+
- RubyGems — https://rubygems.org/gems/misarmail
|
|
94
448
|
|
|
95
|
-
MIT
|
|
449
|
+
MIT © [Misar AI](https://misar.io)
|
data/lib/misar_mail/client.rb
CHANGED
|
@@ -836,7 +836,10 @@ module MisarMail
|
|
|
836
836
|
end
|
|
837
837
|
|
|
838
838
|
def parse_response(resp, status)
|
|
839
|
-
|
|
839
|
+
# Only a *successful* empty body means "no content". An error with an empty
|
|
840
|
+
# body — a bare 401, or anything a proxy stripped — must still raise, or the
|
|
841
|
+
# caller reads a failure as an empty result set.
|
|
842
|
+
return {} if status < 400 && (status == 204 || resp.body.nil? || resp.body.empty?)
|
|
840
843
|
|
|
841
844
|
decoded = begin
|
|
842
845
|
JSON.parse(resp.body)
|
|
@@ -846,6 +849,8 @@ module MisarMail
|
|
|
846
849
|
|
|
847
850
|
if status >= 400
|
|
848
851
|
msg = decoded.is_a?(Hash) ? (decoded["error"] || decoded["message"] || resp.body) : resp.body
|
|
852
|
+
# A stripped body leaves nothing to report; fall back to the reason phrase.
|
|
853
|
+
msg = resp.message.to_s if msg.nil? || msg.empty?
|
|
849
854
|
raise ApiError.new(status, msg)
|
|
850
855
|
end
|
|
851
856
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: misarmail
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 5.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Misar AI
|
|
@@ -52,8 +52,12 @@ dependencies:
|
|
|
52
52
|
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '0.22'
|
|
55
|
-
description:
|
|
56
|
-
|
|
55
|
+
description: 'Ruby client for MisarMail''s transactional and marketing email API (misarmail.com):
|
|
56
|
+
send mail, run campaigns and A/B tests, manage contacts, segments, templates and
|
|
57
|
+
automations, verify domains and DMARC, validate addresses, track revenue and read
|
|
58
|
+
analytics. 33 resource groups on one client, plus retries with backoff, typed plan-limit
|
|
59
|
+
errors, SSE streaming, and constant-time webhook signature verification. No runtime
|
|
60
|
+
dependencies beyond the stdlib.'
|
|
57
61
|
email:
|
|
58
62
|
- hello@misar.io
|
|
59
63
|
executables: []
|
|
@@ -70,12 +74,13 @@ files:
|
|
|
70
74
|
- lib/misar_mail/core/transport.rb
|
|
71
75
|
- lib/misar_mail/core/webhooks.rb
|
|
72
76
|
- lib/misar_mail/errors.rb
|
|
73
|
-
homepage: https://misarmail.com
|
|
77
|
+
homepage: https://www.misarmail.com
|
|
74
78
|
licenses:
|
|
75
79
|
- MIT
|
|
76
80
|
metadata:
|
|
77
|
-
homepage_uri: https://misarmail.com
|
|
78
|
-
|
|
81
|
+
homepage_uri: https://www.misarmail.com
|
|
82
|
+
documentation_uri: https://docs.misar.io/mail
|
|
83
|
+
source_code_uri: https://github.com/Misar-AI/misarmail-sdks/tree/main/ruby
|
|
79
84
|
changelog_uri: https://github.com/Misar-AI/misarmail-sdks/blob/main/ruby/CHANGELOG.md
|
|
80
85
|
bug_tracker_uri: https://github.com/Misar-AI/misarmail-sdks/issues
|
|
81
86
|
post_install_message:
|
|
@@ -96,6 +101,6 @@ requirements: []
|
|
|
96
101
|
rubygems_version: 3.5.22
|
|
97
102
|
signing_key:
|
|
98
103
|
specification_version: 4
|
|
99
|
-
summary:
|
|
100
|
-
|
|
104
|
+
summary: 'Ruby client for MisarMail: transactional send, campaigns, contacts, templates,
|
|
105
|
+
automations, deliverability and analytics in one API'
|
|
101
106
|
test_files: []
|