mailblastr 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bace5e9375b668304c40339acea2334895be7f7af3221f628ad4f7fa21762752
4
+ data.tar.gz: a68a14501aab6cf50309f0bb0d938dad58a5b4a96b31cb09cb9b5285f63979eb
5
+ SHA512:
6
+ metadata.gz: ac99877d9dc0970341b2c1b16e075dfb1f32832d6f10d6fbc220243ccd02a73597cbda5534f5918b5f28f577a2090b5becfa51f23d2e22e90f901446d76a3d1f
7
+ data.tar.gz: 7e0e7c62751f5042054e502964595e6ffbc6f732469dca2076bdb748031449efb0023bc5fbd1ae5d1901a4f2ce0aed999957dc82ea0b4b8d693c6416543d5225
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MailBlastr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,332 @@
1
+ # mailblastr
2
+
3
+ Official Ruby SDK for the [MailBlastr](https://www.mailblastr.com) email API — send transactional and marketing email from your own verified domain.
4
+
5
+ Zero runtime dependencies: the gem uses only Ruby's standard library (`Net::HTTP`, `JSON`, `OpenSSL`).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ gem install mailblastr
11
+ ```
12
+
13
+ Or in your Gemfile:
14
+
15
+ ```ruby
16
+ gem "mailblastr"
17
+ ```
18
+
19
+ ## Setup
20
+
21
+ ```ruby
22
+ require "mailblastr"
23
+
24
+ Mailblastr.api_key = "mb_xxxxxxxxx"
25
+
26
+ # or
27
+ Mailblastr.configure do |config|
28
+ config.api_key = ENV["MAILBLASTR_API_KEY"]
29
+ # config.base_url = "https://www.mailblastr.com/api" # override your API host
30
+ end
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```ruby
36
+ sent = Mailblastr::Emails.send({
37
+ from: "Acme <hello@yourdomain.com>",
38
+ to: ["user@example.com"],
39
+ subject: "Hello from MailBlastr",
40
+ html: "<p>Your first email 🎉</p>"
41
+ })
42
+ puts sent["id"]
43
+ ```
44
+
45
+ Params are plain hashes with snake_case keys, passed through as JSON. Successful calls return the parsed response (a Hash, or a raw String for binary downloads). Any non-2xx response raises `Mailblastr::Error`:
46
+
47
+ ```ruby
48
+ begin
49
+ Mailblastr::Emails.send(params)
50
+ rescue Mailblastr::Error => e
51
+ puts e.status_code # => 422
52
+ puts e.name # => "validation_error"
53
+ puts e.message # => human-readable explanation
54
+ end
55
+ ```
56
+
57
+ ## Domain-first model
58
+
59
+ MailBlastr is **domain-first**: each sending domain has its own pool of contacts. The same email address on two domains is two records with separate consent, so unsubscribes on one product never leak into another.
60
+
61
+ That means `domain` (the sending domain, e.g. `"yourdomain.com"` — one of your verified domains) is **required** on:
62
+
63
+ - `Contacts.create` / `Contacts.list` (the flat `/contacts` API — pass `audience_id:` to use the nested audience routes instead)
64
+ - `Segments.create` / `Segments.list`
65
+ - `Topics.create` / `Topics.list`
66
+ - `Campaigns.create` (picks the contact pool the campaign targets; `from` may be a different verified domain)
67
+ - `Automations.create` and `Events.send` (only automations belonging to that domain are triggered)
68
+
69
+ ## Emails
70
+
71
+ ```ruby
72
+ Mailblastr::Emails.send({ from: from, to: to, subject: subject, html: html })
73
+ Mailblastr::Emails.list({ limit: 20, after: cursor }) # cursor pagination
74
+ Mailblastr::Emails.get(email_id)
75
+ Mailblastr::Emails.list_attachments(email_id)
76
+ Mailblastr::Emails.get_attachment(email_id, attachment_id)
77
+ Mailblastr::Emails.update(email_id, { scheduled_at: "2026-08-01T09:00:00Z" }) # reschedule
78
+ Mailblastr::Emails.cancel(email_id)
79
+
80
+ # Batch send — up to 100 emails in one request
81
+ Mailblastr::Batch.send([
82
+ { from: from, to: ["a@example.com"], subject: "Hi A", html: "<p>A</p>" },
83
+ { from: from, to: ["b@example.com"], subject: "Hi B", html: "<p>B</p>" }
84
+ ])
85
+
86
+ # Attachments: hosted URL (path) or inline base64 (content)
87
+ Mailblastr::Emails.send({
88
+ from: from, to: to, subject: "Your invoice", html: "<p>Attached.</p>",
89
+ attachments: [
90
+ { filename: "invoice.pdf", path: "https://yourdomain.com/invoices/invoice.pdf" },
91
+ { filename: "report.csv", content: base64_content, content_type: "text/csv" }
92
+ ]
93
+ })
94
+ ```
95
+
96
+ ### Inbound email
97
+
98
+ ```ruby
99
+ Mailblastr::Emails::Receiving.list
100
+ Mailblastr::Emails::Receiving.get(id)
101
+ Mailblastr::Emails::Receiving.list_attachments(id)
102
+ Mailblastr::Emails::Receiving.get_attachment(id, attachment_id) # => raw bytes (String)
103
+ Mailblastr::Emails::Receiving.raw(id) # => original RFC822 message
104
+ Mailblastr::Emails::Receiving.forward(id, { from: "you@yourdomain.com", to: "team@you.com" })
105
+ Mailblastr::Emails::Receiving.reply(id, { from: "you@yourdomain.com", html: "<p>Thanks!</p>" })
106
+ Mailblastr::Emails::Receiving.delete(id)
107
+ ```
108
+
109
+ ## Domains
110
+
111
+ ```ruby
112
+ Mailblastr::Domains.create({ name: "yourdomain.com" })
113
+ Mailblastr::Domains.get(id)
114
+ Mailblastr::Domains.list
115
+ Mailblastr::Domains.update(id, { click_tracking: true })
116
+ Mailblastr::Domains.verify(id)
117
+ Mailblastr::Domains.delete(id)
118
+
119
+ # Claim a domain verified in another account
120
+ Mailblastr::Domains.claim({ name: "yourdomain.com" })
121
+ Mailblastr::Domains.get_claim(id)
122
+ Mailblastr::Domains.verify_claim(id)
123
+
124
+ # One-click DNS setup
125
+ Mailblastr::Domains.detect_dns(id)
126
+ Mailblastr::Domains.apply_cloudflare_dns(id, { token: cf_token })
127
+ Mailblastr::Domains.apply_godaddy_dns(id, { key: key, secret: secret })
128
+ Mailblastr::Domains.apply_namecheap_dns(id, { apiUser: user, apiKey: key })
129
+ ```
130
+
131
+ ## Contacts (domain-first)
132
+
133
+ ```ruby
134
+ Mailblastr::Contacts.create({ domain: "yourdomain.com", email: "user@example.com", first_name: "Ada" })
135
+ Mailblastr::Contacts.list({ domain: "yourdomain.com" })
136
+ Mailblastr::Contacts.get({ id: contact_id }) # by id (exact) …
137
+ Mailblastr::Contacts.get({ id: "user@example.com", domain: "yourdomain.com" }) # … or email + domain
138
+ Mailblastr::Contacts.update({ id: contact_id, unsubscribed: true })
139
+ Mailblastr::Contacts.delete({ id: contact_id })
140
+
141
+ # Nested audience variants
142
+ Mailblastr::Contacts.create({ audience_id: aud_id, email: "user@example.com" })
143
+ Mailblastr::Contacts.list({ audience_id: aud_id, segment_id: seg_id })
144
+
145
+ # Bulk import
146
+ Mailblastr::Contacts.batch({ audience_id: aud_id, contacts: [{ email: "a@b.com" }], on_conflict: "skip" })
147
+ Mailblastr::Contacts.import({ audience_id: aud_id, csv: "email,company\na@b.com,Acme" })
148
+
149
+ # Segments & topics per contact
150
+ Mailblastr::Contacts.add_to_segment(contact_id, segment_id)
151
+ Mailblastr::Contacts.remove_from_segment(contact_id, segment_id)
152
+ Mailblastr::Contacts.list_segments(contact_id)
153
+ Mailblastr::Contacts.get_topics(contact_id)
154
+ Mailblastr::Contacts.update_topics(contact_id, { topics: [{ id: topic_id, subscription: "opt_in" }] })
155
+
156
+ # Custom contact properties ({{merge_tags}})
157
+ Mailblastr::ContactProperties.create({ key: "plan", type: "string", fallback_value: "free" })
158
+ ```
159
+
160
+ ## Audiences
161
+
162
+ ```ruby
163
+ Mailblastr::Audiences.create({ name: "Newsletter" })
164
+ Mailblastr::Audiences.get(id)
165
+ Mailblastr::Audiences.list
166
+ Mailblastr::Audiences.update(id, { name: "Weekly newsletter" })
167
+ Mailblastr::Audiences.delete(id)
168
+
169
+ # Import from a link-shared Google Sheet
170
+ Mailblastr::Audiences.import_sheet(id, { url: sheet_url, segment_name: "June leads" })
171
+ ```
172
+
173
+ ## Segments & Topics (domain-first)
174
+
175
+ ```ruby
176
+ Mailblastr::Segments.create({ domain: "yourdomain.com", name: "VIP", filter: { status: "subscribed" } })
177
+ Mailblastr::Segments.list({ domain: "yourdomain.com" })
178
+ Mailblastr::Segments.get(id)
179
+ Mailblastr::Segments.contacts(id) # preview who matches
180
+ Mailblastr::Segments.update(id, { name: "VIP customers" })
181
+ Mailblastr::Segments.delete(id)
182
+
183
+ Mailblastr::Topics.create({ domain: "yourdomain.com", name: "Product updates", default_subscription: "opt_in" })
184
+ Mailblastr::Topics.list({ domain: "yourdomain.com" })
185
+ Mailblastr::Topics.update(id, { description: "New features" })
186
+ Mailblastr::Topics.delete(id)
187
+ ```
188
+
189
+ ## Campaigns (domain-first)
190
+
191
+ ```ruby
192
+ campaign = Mailblastr::Campaigns.create({
193
+ domain: "yourdomain.com", # REQUIRED — the contact pool this campaign targets
194
+ from: "Acme <hello@yourdomain.com>",
195
+ subject: "Big news",
196
+ html: "<p>Hello {{first_name}}</p>",
197
+ segment_id: seg_id # optional — subset instead of everyone
198
+ })
199
+
200
+ Mailblastr::Campaigns.send(campaign["id"]) # send now
201
+ Mailblastr::Campaigns.send(campaign["id"], { scheduled_at: "2026-08-01T09:00:00Z" }) # or schedule
202
+ Mailblastr::Campaigns.cancel(campaign["id"])
203
+ Mailblastr::Campaigns.stats(campaign["id"])
204
+ Mailblastr::Campaigns.ab(campaign["id"]) # A/B winner evaluation
205
+ Mailblastr::Campaigns.get(campaign["id"])
206
+ Mailblastr::Campaigns.list({ limit: 25 })
207
+ Mailblastr::Campaigns.update(campaign["id"], { subject: "Bigger news" })
208
+ Mailblastr::Campaigns.delete(campaign["id"])
209
+ ```
210
+
211
+ ## Templates
212
+
213
+ ```ruby
214
+ tmpl = Mailblastr::Templates.create({ name: "Welcome", subject: "Welcome!", html: "<p>Hi {{first_name}}</p>" })
215
+ Mailblastr::Templates.publish(tmpl["id"])
216
+ Mailblastr::Templates.duplicate(tmpl["id"], { name: "Welcome v2" })
217
+ Mailblastr::Templates.get(tmpl["id"])
218
+ Mailblastr::Templates.list
219
+ Mailblastr::Templates.update(tmpl["id"], { subject: "Welcome aboard!" })
220
+ Mailblastr::Templates.delete(tmpl["id"])
221
+
222
+ # Send with a template
223
+ Mailblastr::Emails.send({ from: from, to: to, template_id: tmpl["id"], variables: { first_name: "Ada" } })
224
+ ```
225
+
226
+ ## Automations & Events (domain-first)
227
+
228
+ ```ruby
229
+ automation = Mailblastr::Automations.create({
230
+ name: "Welcome series",
231
+ domain: "yourdomain.com", # REQUIRED
232
+ trigger: "contact.created"
233
+ })
234
+
235
+ Mailblastr::Automations.add_step(automation["id"], { type: "send_email", config: { template_id: tmpl_id } })
236
+ Mailblastr::Automations.update(automation["id"], { status: "enabled" })
237
+
238
+ # Fire a custom event — only yourdomain.com's automations are triggered
239
+ Mailblastr::Events.send({
240
+ event: "signup.completed",
241
+ domain: "yourdomain.com", # REQUIRED
242
+ email: "user@example.com",
243
+ payload: { plan: "pro" }
244
+ })
245
+
246
+ # Event definitions
247
+ Mailblastr::Events.create({ name: "signup.completed", schema: { plan: "string" } })
248
+ Mailblastr::Events.list
249
+ Mailblastr::Events.delete(event_id)
250
+
251
+ # Inspect execution
252
+ runs = Mailblastr::Automations.runs(automation["id"], { limit: 25 })
253
+ Mailblastr::Automations.get_run(automation["id"], runs["data"].first["id"])
254
+ Mailblastr::Automations.delete_step(automation["id"], step_id)
255
+ Mailblastr::Automations.stop(automation["id"])
256
+ Mailblastr::Automations.delete(automation["id"])
257
+ ```
258
+
259
+ ## Webhooks
260
+
261
+ ```ruby
262
+ hook = Mailblastr::Webhooks.create({
263
+ endpoint: "https://yourapp.com/hooks/mailblastr",
264
+ events: ["email.delivered", "email.bounced", "contact.unsubscribed"]
265
+ })
266
+ hook["signing_secret"] # shown ONCE — store it
267
+
268
+ Mailblastr::Webhooks.list
269
+ Mailblastr::Webhooks.update(hook["id"], { status: "disabled" })
270
+ Mailblastr::Webhooks.rotate(hook["id"]) # new secret returned once
271
+ Mailblastr::Webhooks.test(hook["id"])
272
+ Mailblastr::Webhooks.delete(hook["id"])
273
+ ```
274
+
275
+ ### Verifying deliveries
276
+
277
+ `verify_signature` checks the Svix-style HMAC-SHA256 signature locally (no HTTP request). Pass the **exact raw request body** — re-serializing parsed JSON breaks the signature.
278
+
279
+ ```ruby
280
+ result = Mailblastr::Webhooks.verify_signature(
281
+ request.raw_post, # raw body string
282
+ {
283
+ "svix-id" => request.headers["svix-id"],
284
+ "svix-timestamp" => request.headers["svix-timestamp"],
285
+ "svix-signature" => request.headers["svix-signature"]
286
+ },
287
+ signing_secret # the whsec_... secret from create/rotate
288
+ )
289
+
290
+ head :unauthorized unless result[:valid]
291
+ # result => { valid: true } or { valid: false, reason: "no_match" | "timestamp_out_of_tolerance" | ... }
292
+ ```
293
+
294
+ Pass `tolerance: 0` to skip the timestamp freshness check (default 300 seconds).
295
+
296
+ ## API keys, Logs & Polls
297
+
298
+ ```ruby
299
+ Mailblastr::ApiKeys.create({ name: "CI", permission: "sending_access" })
300
+ Mailblastr::ApiKeys.list
301
+ Mailblastr::ApiKeys.delete(key_id)
302
+
303
+ Mailblastr::Logs.list({ limit: 100, method: "POST", status: 429 })
304
+ Mailblastr::Logs.get(log_id)
305
+
306
+ Mailblastr::Polls.list
307
+ Mailblastr::Polls.get(email_id) # aggregated answer breakdown
308
+ ```
309
+
310
+ ## Pagination
311
+
312
+ `list` methods accept cursor pagination — `{ limit:, after:, before: }` — appended as a query string:
313
+
314
+ ```ruby
315
+ Mailblastr::Campaigns.list({ limit: 25, after: "cursor_abc" })
316
+ ```
317
+
318
+ ## Idempotency
319
+
320
+ Pass an idempotency key to safely retry a create (24h window):
321
+
322
+ ```ruby
323
+ Mailblastr::Emails.send(payload, { idempotency_key: "order-123" })
324
+ ```
325
+
326
+ ## Documentation
327
+
328
+ Full docs: <https://www.mailblastr.com/docs>
329
+
330
+ ## License
331
+
332
+ MIT
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailblastr
4
+ module ApiKeys
5
+ class << self
6
+ # Create an API key — the full token is returned only once, here.
7
+ # POST /api-keys — params: { name:, permission: "full_access"|"sending_access", domain_id: }
8
+ def create(params)
9
+ Client.request(:post, "/api-keys", body: params)
10
+ end
11
+
12
+ # GET /api-keys
13
+ def list
14
+ Client.request(:get, "/api-keys")
15
+ end
16
+
17
+ # DELETE /api-keys/:id
18
+ def delete(api_key_id)
19
+ Client.request(:delete, "/api-keys/#{Client.path_escape(api_key_id)}")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailblastr
4
+ module Audiences
5
+ class << self
6
+ # POST /audiences — params: { name: "..." }
7
+ def create(params)
8
+ Client.request(:post, "/audiences", body: params)
9
+ end
10
+
11
+ # GET /audiences/:id
12
+ def get(audience_id)
13
+ Client.request(:get, "/audiences/#{Client.path_escape(audience_id)}")
14
+ end
15
+
16
+ # GET /audiences
17
+ def list(params = {})
18
+ Client.request(:get, "/audiences", query: Client.pagination(params))
19
+ end
20
+
21
+ # Rename an audience. PATCH /audiences/:id
22
+ def update(audience_id, params)
23
+ Client.request(:patch, "/audiences/#{Client.path_escape(audience_id)}", body: params)
24
+ end
25
+
26
+ # DELETE /audiences/:id
27
+ def delete(audience_id)
28
+ Client.request(:delete, "/audiences/#{Client.path_escape(audience_id)}")
29
+ end
30
+
31
+ # Import contacts from a link-shared Google Sheet; header columns become
32
+ # contact properties and rows land in a fresh segment.
33
+ # POST /audiences/:id/contacts/import-sheet — params: { url:, segment_name: }
34
+ def import_sheet(audience_id, params)
35
+ Client.request(:post, "/audiences/#{Client.path_escape(audience_id)}/contacts/import-sheet", body: params)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailblastr
4
+ # Automations are DOMAIN-FIRST: `domain` is required on create, and only
5
+ # Events.send calls naming the same domain trigger them.
6
+ module Automations
7
+ class << self
8
+ # POST /automations
9
+ # Mailblastr::Automations.create({ name: "Welcome series", domain: "yourdomain.com",
10
+ # trigger: "contact.created" })
11
+ def create(params)
12
+ Client.require_domain!(params, "Automations.create")
13
+ Client.request(:post, "/automations", body: params)
14
+ end
15
+
16
+ # GET /automations/:id
17
+ def get(automation_id)
18
+ Client.request(:get, "/automations/#{Client.path_escape(automation_id)}")
19
+ end
20
+
21
+ # GET /automations
22
+ def list(params = {})
23
+ Client.request(:get, "/automations", query: Client.pagination(params))
24
+ end
25
+
26
+ # PATCH /automations/:id — params: { name:, status: "enabled"|"disabled", ... }
27
+ def update(automation_id, params)
28
+ Client.request(:patch, "/automations/#{Client.path_escape(automation_id)}", body: params)
29
+ end
30
+
31
+ # Append a step. POST /automations/:id/steps — params: { type:, config:, key: }
32
+ def add_step(automation_id, params)
33
+ Client.request(:post, "/automations/#{Client.path_escape(automation_id)}/steps", body: params)
34
+ end
35
+
36
+ # Delete a step. DELETE /automations/:id/steps/:step_id
37
+ def delete_step(automation_id, step_id)
38
+ Client.request(:delete, "/automations/#{Client.path_escape(automation_id)}/steps/#{Client.path_escape(step_id)}")
39
+ end
40
+
41
+ # List an automation's runs. GET /automations/:id/runs
42
+ def runs(automation_id, params = {})
43
+ Client.request(:get, "/automations/#{Client.path_escape(automation_id)}/runs", query: Client.pagination(params))
44
+ end
45
+
46
+ # Retrieve a single run with its step trace. GET /automations/:id/runs/:run_id
47
+ def get_run(automation_id, run_id)
48
+ Client.request(:get, "/automations/#{Client.path_escape(automation_id)}/runs/#{Client.path_escape(run_id)}")
49
+ end
50
+
51
+ # Stop an automation — no new runs; in-progress runs finish. POST /automations/:id/stop
52
+ def stop(automation_id)
53
+ Client.request(:post, "/automations/#{Client.path_escape(automation_id)}/stop")
54
+ end
55
+
56
+ # DELETE /automations/:id
57
+ def delete(automation_id)
58
+ Client.request(:delete, "/automations/#{Client.path_escape(automation_id)}")
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailblastr
4
+ # Campaigns are DOMAIN-FIRST: `domain` (required on create) picks the
5
+ # contact pool the campaign targets; `from` may be any verified domain.
6
+ module Campaigns
7
+ class << self
8
+ # POST /campaigns
9
+ # Mailblastr::Campaigns.create({ domain: "yourdomain.com", from: "you@yourdomain.com",
10
+ # subject: "Hello", html: "<p>Hi</p>", segment_id: "seg_1" })
11
+ def create(params)
12
+ Client.require_domain!(params, "Campaigns.create")
13
+ Client.request(:post, "/campaigns", body: params)
14
+ end
15
+
16
+ # GET /campaigns/:id
17
+ def get(campaign_id)
18
+ Client.request(:get, "/campaigns/#{Client.path_escape(campaign_id)}")
19
+ end
20
+
21
+ # GET /campaigns
22
+ def list(params = {})
23
+ Client.request(:get, "/campaigns", query: Client.pagination(params))
24
+ end
25
+
26
+ # PATCH /campaigns/:id
27
+ def update(campaign_id, params)
28
+ Client.request(:patch, "/campaigns/#{Client.path_escape(campaign_id)}", body: params)
29
+ end
30
+
31
+ # Send now, or schedule with { scheduled_at: "..." }. POST /campaigns/:id/send
32
+ def send(campaign_id, params = {})
33
+ Client.request(:post, "/campaigns/#{Client.path_escape(campaign_id)}/send", body: params)
34
+ end
35
+
36
+ # Cancel a scheduled campaign (returns it to draft). POST /campaigns/:id/cancel
37
+ def cancel(campaign_id)
38
+ Client.request(:post, "/campaigns/#{Client.path_escape(campaign_id)}/cancel")
39
+ end
40
+
41
+ # Per-campaign analytics (counts, engagement rates, top links). GET /campaigns/:id/stats
42
+ def stats(campaign_id)
43
+ Client.request(:get, "/campaigns/#{Client.path_escape(campaign_id)}/stats")
44
+ end
45
+
46
+ # A/B winner evaluation for an A/B campaign. GET /campaigns/:id/ab
47
+ def ab(campaign_id)
48
+ Client.request(:get, "/campaigns/#{Client.path_escape(campaign_id)}/ab")
49
+ end
50
+
51
+ # DELETE /campaigns/:id
52
+ def delete(campaign_id)
53
+ Client.request(:delete, "/campaigns/#{Client.path_escape(campaign_id)}")
54
+ end
55
+ end
56
+ end
57
+ end