millionsend 0.3.0 → 0.4.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 +200 -29
- data/lib/millionsend/api_keys.rb +24 -0
- data/lib/millionsend/batch.rb +6 -3
- data/lib/millionsend/contact_properties.rb +42 -0
- data/lib/millionsend/contacts.rb +61 -6
- data/lib/millionsend/domains.rb +46 -0
- data/lib/millionsend/emails.rb +39 -8
- data/lib/millionsend/error.rb +14 -0
- data/lib/millionsend/request.rb +3 -1
- data/lib/millionsend/segments.rb +9 -0
- data/lib/millionsend/suppressions.rb +54 -0
- data/lib/millionsend/templates.rb +51 -0
- data/lib/millionsend/topics.rb +6 -1
- data/lib/millionsend/usage.rb +13 -0
- data/lib/millionsend/util.rb +10 -0
- data/lib/millionsend/version.rb +1 -1
- data/lib/millionsend/webhooks.rb +40 -0
- data/lib/millionsend.rb +7 -0
- metadata +13 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f44b4a952ce5037c565e1ba8071a29ddf7d2b7d875fd99cad7719e91155d3054
|
|
4
|
+
data.tar.gz: a6b58d948cd026fe50ff598cc46129d6e9c164de71453dcdd77855237c0376fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c16c5f8ee728136b872cc3f6a83d78052c62649c7b69497973d72845fb318669073078eb54406863fb0d83cd5336868896e098da794677b50667eefb4f0ec7f
|
|
7
|
+
data.tar.gz: d492c8d93ddcfff8e5f887f50aa7200fa0e30856effd041530a19a62cd2c2ad07ed47520c9f5fb57a32e4078103d6edad6619cb45cbba888cb94e0f01ff0faf8
|
data/README.md
CHANGED
|
@@ -57,51 +57,115 @@ Plain `http://` is only accepted for loopback hosts (`localhost`, `127.0.0.1`, `
|
|
|
57
57
|
other `http://` URL raises `Millionsend::ApplicationError` on the first call, since the API
|
|
58
58
|
key is sent as a bearer header. Set `allow_insecure_http = true` to talk to a non-TLS
|
|
59
59
|
instance elsewhere (e.g. inside a private network).
|
|
60
|
-
|
|
61
|
-
the wire
|
|
60
|
+
|
|
61
|
+
Params are symbol-keyed hashes and go on the wire **exactly as given** — nothing is
|
|
62
|
+
filtered or renamed (Ruby's snake_case is already the wire's snake_case: `reply_to`,
|
|
63
|
+
`scheduled_at`, `segment_id`). A `nil` value is sent as JSON `null`, which is how the API
|
|
64
|
+
clears a nullable field (`topic_id`, `first_name`, a template `alias`, …); omit the key to
|
|
65
|
+
leave it unchanged.
|
|
66
|
+
|
|
67
|
+
## Request options
|
|
68
|
+
|
|
69
|
+
`Emails.send`, `Batch.send` and `Contacts::Batch.create` take a trailing options hash, in
|
|
70
|
+
either of two shapes:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
Millionsend::Emails.send(payload, idempotency_key: "order-42") # flat
|
|
74
|
+
Millionsend::Emails.send(payload, options: { idempotency_key: "order-42" }) # resend-ruby keyword shape
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| option | header / query | where |
|
|
78
|
+
| ------------------ | --------------------- | --------------------------------------- |
|
|
79
|
+
| `idempotency_key` | `Idempotency-Key` | any POST |
|
|
80
|
+
| `batch_validation` | `x-batch-validation` | `Batch.send`, `Contacts::Batch.create` |
|
|
81
|
+
| `on_conflict` | `?on_conflict=` | `Contacts::Batch.create` |
|
|
82
|
+
|
|
83
|
+
`batch_validation` is `"strict"` by default (one invalid item rejects the whole batch) or
|
|
84
|
+
`"permissive"` (the valid subset is processed and the failures come back under `errors:`
|
|
85
|
+
as `[{ index:, message: }]`).
|
|
62
86
|
|
|
63
87
|
## Resources
|
|
64
88
|
|
|
65
89
|
### Emails
|
|
66
90
|
|
|
67
91
|
```ruby
|
|
68
|
-
Millionsend::Emails.send(payload, idempotency_key: "order-42") # POST
|
|
69
|
-
Millionsend::Emails.get(id) # GET
|
|
70
|
-
Millionsend::Emails.
|
|
71
|
-
Millionsend::Emails.
|
|
72
|
-
|
|
73
|
-
Millionsend::
|
|
92
|
+
Millionsend::Emails.send(payload, idempotency_key: "order-42") # POST /emails
|
|
93
|
+
Millionsend::Emails.get(id) # GET /emails/:id (includes score: 0-10 or nil)
|
|
94
|
+
Millionsend::Emails.list(limit: 20, after: id) # GET /emails
|
|
95
|
+
Millionsend::Emails.update(id, scheduled_at: "in 2 hours") # PATCH /emails/:id (scheduled only)
|
|
96
|
+
Millionsend::Emails.cancel(id) # POST /emails/:id/cancel (scheduled only)
|
|
97
|
+
Millionsend::Emails.remove(id) # DELETE /emails/:id
|
|
98
|
+
Millionsend::Emails.get_insights(id) # GET /emails/:id/insights (404 until computed)
|
|
99
|
+
|
|
100
|
+
Millionsend::Batch.send([payload_a, payload_b], idempotency_key: "run-7", batch_validation: "permissive") # up to 100
|
|
74
101
|
```
|
|
75
102
|
|
|
76
|
-
`to`, `
|
|
77
|
-
|
|
103
|
+
The send payload accepts `from`, `to`, `subject`, `html`, `text`, `cc`, `bcc`, `reply_to`,
|
|
104
|
+
`scheduled_at`, `tags: [{ name:, value: }]`, `topic_id`, `headers: { "X-..." => "..." }`,
|
|
105
|
+
`attachments: [{ filename:, content: <base64>, content_type:, content_id:, path: }]` and
|
|
106
|
+
`template` (passed through; the server currently answers 422 for it). `to`, `cc`, `bcc` and
|
|
107
|
+
`reply_to` accept either a string or an array. `Emails.create` is an alias of `Emails.send`
|
|
108
|
+
(as is `Batch.create`), mirroring Resend. `Emails.update` also takes resend-ruby's single
|
|
109
|
+
hash: `Emails.update(email_id: id, scheduled_at: ...)`.
|
|
78
110
|
|
|
79
111
|
### Contacts
|
|
80
112
|
|
|
81
113
|
Contacts are team-global — one list per team, no audiences.
|
|
82
114
|
|
|
83
115
|
```ruby
|
|
84
|
-
contact = Millionsend::Contacts.create(
|
|
85
|
-
|
|
86
|
-
|
|
116
|
+
contact = Millionsend::Contacts.create(
|
|
117
|
+
email: "ada@acme.dev", first_name: "Ada", last_name: "Lovelace", unsubscribed: false,
|
|
118
|
+
properties: { plan: "pro", seats: 3 },
|
|
119
|
+
segments: [{ id: segment_id }],
|
|
120
|
+
topics: [{ id: topic_id, subscription: "opt_in" }]
|
|
121
|
+
)
|
|
122
|
+
Millionsend::Contacts.get("ada@acme.dev") # by id or email; also resend-ruby's get(id: ...) / get(email: ...)
|
|
87
123
|
Millionsend::Contacts.update(id: contact[:id], unsubscribed: true, first_name: nil) # nil clears
|
|
88
124
|
Millionsend::Contacts.remove("ada@acme.dev")
|
|
89
125
|
Millionsend::Contacts.list(limit: 50)
|
|
90
126
|
|
|
91
|
-
# Topic subscriptions (granular unsubscribe) —
|
|
92
|
-
Millionsend::Contacts.
|
|
127
|
+
# Topic subscriptions (granular unsubscribe) — PATCH /contacts/:id/topics
|
|
128
|
+
Millionsend::Contacts::Topics.update(email: "ada@acme.dev", topics: [{ id: topic_id, subscription: "opt_out" }]) # resend-ruby shape
|
|
129
|
+
Millionsend::Contacts.topics_update("ada@acme.dev", [{ id: topic_id, subscription: "opt_out" }]) # positional
|
|
130
|
+
|
|
131
|
+
# Segment membership — POST / DELETE /contacts/:id/segments/:segment_id
|
|
132
|
+
Millionsend::Contacts::Segments.add("ada@acme.dev", segment_id)
|
|
133
|
+
Millionsend::Contacts::Segments.remove(contact_id: contact[:id], segment_id: segment_id) # resend-ruby shape
|
|
134
|
+
|
|
135
|
+
# Bulk create (MillionSend extension) — up to 1000 per call
|
|
136
|
+
result = Millionsend::Contacts::Batch.create(
|
|
137
|
+
[{ email: "a@acme.dev" }, { email: "b@acme.dev", first_name: "B" }],
|
|
138
|
+
on_conflict: "upsert", # "error" (default) | "skip" | "upsert"
|
|
139
|
+
batch_validation: "permissive" # "strict" (default) | "permissive"
|
|
140
|
+
)
|
|
141
|
+
result[:data] # [{ index:, id:, status: "created" | "updated" | "skipped" }]
|
|
142
|
+
result[:counts] # { created:, updated:, skipped:, failed: }
|
|
143
|
+
result[:errors] # permissive mode only: [{ index:, message: }]
|
|
93
144
|
```
|
|
94
145
|
|
|
95
146
|
Contacts are addressable by id or email; when an `update` hash carries both, the email wins.
|
|
96
147
|
Emails are unique per team (case-insensitive) — a duplicate `create` raises
|
|
97
148
|
`Millionsend::ValidationError`.
|
|
98
149
|
|
|
150
|
+
### Contact properties
|
|
151
|
+
|
|
152
|
+
Typed custom fields for contacts. `key` and `type` are fixed at creation.
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
prop = Millionsend::ContactProperties.create(key: "plan", type: "string", fallback_value: "free")
|
|
156
|
+
Millionsend::ContactProperties.get(prop[:id])
|
|
157
|
+
Millionsend::ContactProperties.list(limit: 50)
|
|
158
|
+
Millionsend::ContactProperties.update(prop[:id], fallback_value: nil) # nil clears
|
|
159
|
+
Millionsend::ContactProperties.remove(prop[:id])
|
|
160
|
+
```
|
|
161
|
+
|
|
99
162
|
### Topics
|
|
100
163
|
|
|
101
164
|
```ruby
|
|
102
|
-
Millionsend::Topics.create(name: "Product updates", default_subscription: "opt_in")
|
|
165
|
+
Millionsend::Topics.create(name: "Product updates", default_subscription: "opt_in", visibility: "public")
|
|
103
166
|
Millionsend::Topics.get(id)
|
|
104
167
|
Millionsend::Topics.list # bare { data: [...] } — topics are unpaginated
|
|
168
|
+
Millionsend::Topics.update(id, name: "Product news")
|
|
105
169
|
Millionsend::Topics.remove(id)
|
|
106
170
|
```
|
|
107
171
|
|
|
@@ -109,19 +173,104 @@ Millionsend::Topics.remove(id)
|
|
|
109
173
|
|
|
110
174
|
```ruby
|
|
111
175
|
broadcast = Millionsend::Broadcasts.create(
|
|
176
|
+
name: "Launch", # internal name shown in the dashboard
|
|
112
177
|
segment_id: segment[:id], # optional; omit segment_id and topic_id to send to all contacts
|
|
178
|
+
topic_id: topic[:id], # optional; only contacts subscribed to it receive it
|
|
113
179
|
from: "Acme <news@acme.dev>",
|
|
114
180
|
subject: "Launch",
|
|
115
|
-
html: "<p>Hi {{{FIRST_NAME|there}}}</p>"
|
|
181
|
+
html: "<p>Hi {{{FIRST_NAME|there}}}</p>",
|
|
182
|
+
text: "Hi",
|
|
183
|
+
reply_to: "hello@acme.dev",
|
|
184
|
+
preview_text: "It's here",
|
|
185
|
+
send: false, # true sends (or, with scheduled_at, schedules) instead of saving a draft
|
|
186
|
+
scheduled_at: nil # "2026-09-01T09:00:00Z" or "in 1 hour" (requires send: true)
|
|
116
187
|
)
|
|
117
188
|
Millionsend::Broadcasts.list
|
|
118
189
|
Millionsend::Broadcasts.get(broadcast[:id])
|
|
119
|
-
Millionsend::Broadcasts.update(broadcast[:id], subject: "Launch 🚀") # draft only
|
|
190
|
+
Millionsend::Broadcasts.update(broadcast[:id], subject: "Launch 🚀", topic_id: nil) # draft only; nil clears
|
|
120
191
|
Millionsend::Broadcasts.send(broadcast[:id], scheduled_at: "2026-09-01T09:00:00Z") # omit to send now
|
|
121
192
|
Millionsend::Broadcasts.cancel(broadcast[:id]) # scheduled only
|
|
122
193
|
Millionsend::Broadcasts.remove(broadcast[:id]) # draft only
|
|
123
194
|
```
|
|
124
195
|
|
|
196
|
+
### Suppressions
|
|
197
|
+
|
|
198
|
+
Addresses the API refuses to send to. Entries are addressable by id or by email.
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
Millionsend::Suppressions.add(email: "bounced@example.com", origin: "manual") # origin: bounce | complaint | manual | unsubscribe
|
|
202
|
+
Millionsend::Suppressions.get("bounced@example.com")
|
|
203
|
+
Millionsend::Suppressions.list(limit: 50, origin: "bounce")
|
|
204
|
+
Millionsend::Suppressions.remove("bounced@example.com")
|
|
205
|
+
|
|
206
|
+
Millionsend::Suppressions::Batch.add(emails: ["a@example.com", "b@example.com"], origin: "unsubscribe") # up to 1000
|
|
207
|
+
Millionsend::Suppressions::Batch.remove(emails: ["a@example.com"]) # or ids: [...]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`Suppressions.create` is an alias of `Suppressions.add`.
|
|
211
|
+
|
|
212
|
+
### Domains
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
domain = Millionsend::Domains.create(
|
|
216
|
+
name: "acme.dev",
|
|
217
|
+
region: "us-east-1", # optional; a deployment serves one region
|
|
218
|
+
custom_return_path: "send", # optional
|
|
219
|
+
open_tracking: true, click_tracking: true, tracking_subdomain: "links"
|
|
220
|
+
)
|
|
221
|
+
domain[:records] # DNS records to publish, each with its own status
|
|
222
|
+
Millionsend::Domains.get(domain[:id])
|
|
223
|
+
Millionsend::Domains.list
|
|
224
|
+
Millionsend::Domains.verify(domain[:id]) # re-check DNS now
|
|
225
|
+
Millionsend::Domains.update(domain[:id], open_tracking: false, click_tracking: true, tracking_subdomain: nil) # nil clears
|
|
226
|
+
Millionsend::Domains.remove(domain[:id])
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Webhooks
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
hook = Millionsend::Webhooks.create(
|
|
233
|
+
endpoint: "https://acme.dev/hooks/millionsend",
|
|
234
|
+
events: ["email.delivered", "email.bounced", "email.complained"],
|
|
235
|
+
signing_secret: "whsec_..." # optional: reuse an existing secret so the receiver keeps verifying
|
|
236
|
+
)
|
|
237
|
+
hook[:signing_secret]
|
|
238
|
+
Millionsend::Webhooks.get(hook[:id]) # also returns signing_secret
|
|
239
|
+
Millionsend::Webhooks.list
|
|
240
|
+
Millionsend::Webhooks.update(hook[:id], events: ["email.opened"], status: "disabled")
|
|
241
|
+
Millionsend::Webhooks.remove(hook[:id])
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Events: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`,
|
|
245
|
+
`email.complained`, `email.opened`, `email.clicked`, `deliverability.warning`,
|
|
246
|
+
`deliverability.paused`, `quota.warning`, `quota.reached`.
|
|
247
|
+
|
|
248
|
+
### API keys
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
key = Millionsend::ApiKeys.create(name: "ci", permission: "sending_access", domain_id: domain[:id])
|
|
252
|
+
key[:token] # only returned here
|
|
253
|
+
Millionsend::ApiKeys.list
|
|
254
|
+
Millionsend::ApiKeys.remove(key[:id])
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Templates
|
|
258
|
+
|
|
259
|
+
Addressable by id or alias.
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
tpl = Millionsend::Templates.create(name: "Welcome", html: "<p>Hi</p>", subject: "Welcome", text: "Hi", alias: "welcome")
|
|
263
|
+
Millionsend::Templates.get("welcome")
|
|
264
|
+
Millionsend::Templates.list
|
|
265
|
+
Millionsend::Templates.update("welcome", subject: nil, alias: nil) # nil clears subject/text/alias
|
|
266
|
+
Millionsend::Templates.publish(tpl[:id]) # templates are always published; kept as a no-op for compatibility
|
|
267
|
+
Millionsend::Templates.duplicate(tpl[:id]) # returns the copy's id
|
|
268
|
+
Millionsend::Templates.remove(tpl[:id])
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Resend's `from`, `reply_to` and `variables` template fields are passed through untouched; the
|
|
272
|
+
server currently answers 422 for them.
|
|
273
|
+
|
|
125
274
|
### Segments (MillionSend extension)
|
|
126
275
|
|
|
127
276
|
Dynamic segments are a saved filter over the team's contacts — a MillionSend superset with
|
|
@@ -134,10 +283,20 @@ segment = Millionsend::Segments.create(
|
|
|
134
283
|
)
|
|
135
284
|
Millionsend::Segments.get(segment[:id]) # includes a live contact_count
|
|
136
285
|
Millionsend::Segments.list
|
|
286
|
+
Millionsend::Segments.contacts(segment[:id], limit: 50) # the contacts currently matching
|
|
137
287
|
Millionsend::Segments.update(segment[:id], name: "Pro tier")
|
|
138
288
|
Millionsend::Segments.remove(segment[:id])
|
|
139
289
|
```
|
|
140
290
|
|
|
291
|
+
### Usage (MillionSend extension)
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
usage = Millionsend::Usage.get # GET /usage
|
|
295
|
+
usage[:plan] # "free" | "pro" | "scale" | nil (self-hosted)
|
|
296
|
+
usage[:limits] # { emails_per_day:, domains: } — nil means unlimited
|
|
297
|
+
usage[:today] # { emails_sent:, resets_at: }
|
|
298
|
+
```
|
|
299
|
+
|
|
141
300
|
### Deliverability (MillionSend extension)
|
|
142
301
|
|
|
143
302
|
Per-email best-practice insights and the account-level deliverability score.
|
|
@@ -167,9 +326,11 @@ rescue Millionsend::Error => e
|
|
|
167
326
|
end
|
|
168
327
|
```
|
|
169
328
|
|
|
170
|
-
Subclasses: `ValidationError`, `NotFoundError`, `
|
|
171
|
-
`
|
|
172
|
-
|
|
329
|
+
Subclasses: `ValidationError`, `NotFoundError`, `MissingApiKeyError`, `InvalidApiKeyError`,
|
|
330
|
+
`RestrictedApiKeyError`, `SendingPausedError`, `BroadcastsPausedError`, `RateLimitExceededError`,
|
|
331
|
+
`DailyQuotaExceededError`, `InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`,
|
|
332
|
+
`InternalServerError`, and `ApplicationError` (the fallback for unknown names). Client-side and
|
|
333
|
+
transport failures that never reached the API raise `ApplicationError` with `#status_code == nil`.
|
|
173
334
|
|
|
174
335
|
## Migrating from Resend
|
|
175
336
|
|
|
@@ -183,15 +344,25 @@ transport failures that never reached the API raise with `#status_code == nil`.
|
|
|
183
344
|
+ Millionsend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")
|
|
184
345
|
```
|
|
185
346
|
|
|
186
|
-
Method names, nesting and payloads match
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
347
|
+
Method names, nesting and payloads match; `options: { idempotency_key:, batch_validation: }`
|
|
348
|
+
works as in resend-ruby. Notes:
|
|
349
|
+
|
|
350
|
+
- **`update` takes the id first.** On domains, webhooks, contact properties, topics, broadcasts
|
|
351
|
+
and segments it is `update(id, params)` where resend-ruby takes one hash carrying the id
|
|
352
|
+
(`:id`, `:topic_id`, `:broadcast_id`, `:segment_id`); templates already match resend-ruby's
|
|
353
|
+
`update(id, params)`, `Contacts.update` keeps resend's single-hash shape, and `Emails.update`
|
|
354
|
+
accepts both.
|
|
355
|
+
- `Contacts` member methods and `Contacts::Segments` / `Contacts::Topics` accept resend-ruby's
|
|
356
|
+
addressing hashes (`id:` / `email:` / `contact_id:`, `segment_id:`) as well as bare values.
|
|
357
|
+
`Suppressions::Batch` is nested as in resend-ruby.
|
|
192
358
|
- **No audiences** — contacts are team-global, so there is no `Audiences` resource and no
|
|
193
|
-
`audience_id` params.
|
|
194
|
-
|
|
359
|
+
`audience_id` params. The API's `/audiences/*` routes are a compatibility shim and are not
|
|
360
|
+
part of this SDK. `Millionsend::Segments` is the dynamic-filter feature (`/segments`), not
|
|
361
|
+
Resend's audiences alias.
|
|
362
|
+
- Not in the API (yet), so not here: broadcast recipients/clicked links, email sharing and
|
|
363
|
+
metrics, contact imports, receiving, automations, logs, OAuth grants, webhook event replay.
|
|
364
|
+
- MillionSend extensions with no Resend counterpart: `Segments`, `Contacts::Batch`, `Usage`,
|
|
365
|
+
`Deliverability`, `Emails.get_insights`, `Suppressions` `origin: "unsubscribe"`.
|
|
195
366
|
|
|
196
367
|
## License
|
|
197
368
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# API keys. The token is only ever returned by create.
|
|
5
|
+
module ApiKeys
|
|
6
|
+
class << self
|
|
7
|
+
# POST /api-keys — name, permission ("full_access" default or
|
|
8
|
+
# "sending_access"), domain_id. Returns { id:, token: }.
|
|
9
|
+
def create(params)
|
|
10
|
+
Millionsend::Request.new(method: :post, path: "/api-keys", body: params).perform
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# GET /api-keys — accepts limit:/after:/before:.
|
|
14
|
+
def list(options = {})
|
|
15
|
+
Millionsend::Request.new(method: :get, path: "/api-keys", query: Millionsend::Util.list_query(options)).perform
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# DELETE /api-keys/:id
|
|
19
|
+
def remove(id)
|
|
20
|
+
Millionsend::Request.new(method: :delete, path: "/api-keys/#{Millionsend::Util.encode(id)}").perform
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/millionsend/batch.rb
CHANGED
|
@@ -4,11 +4,14 @@ module Millionsend
|
|
|
4
4
|
# Send up to 100 emails in a single call.
|
|
5
5
|
module Batch
|
|
6
6
|
class << self
|
|
7
|
-
# POST /emails/batch with a bare array body.
|
|
8
|
-
#
|
|
7
|
+
# POST /emails/batch with a bare array body. The trailing hash carries
|
|
8
|
+
# idempotency_key and batch_validation ("strict", the default, rejects the
|
|
9
|
+
# whole batch on one invalid item; "permissive" sends the valid subset and
|
|
10
|
+
# lists the rest under errors[]) — either flat or under options:, like
|
|
11
|
+
# Emails.send.
|
|
9
12
|
def send(list, options = {})
|
|
10
13
|
Millionsend::Request.new(
|
|
11
|
-
method: :post, path: "/emails/batch", body: list,
|
|
14
|
+
method: :post, path: "/emails/batch", body: list, **Millionsend::Util.request_options(options)
|
|
12
15
|
).perform
|
|
13
16
|
end
|
|
14
17
|
alias_method :create, :send
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Typed custom properties for contacts. key and type are fixed at creation;
|
|
5
|
+
# only fallback_value can change.
|
|
6
|
+
module ContactProperties
|
|
7
|
+
class << self
|
|
8
|
+
# POST /contact-properties — key, type ("string"/"number"), fallback_value.
|
|
9
|
+
def create(params)
|
|
10
|
+
Millionsend::Request.new(method: :post, path: "/contact-properties", body: params).perform
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# GET /contact-properties/:id
|
|
14
|
+
def get(id)
|
|
15
|
+
Millionsend::Request.new(method: :get, path: member_path(id)).perform
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# GET /contact-properties — accepts limit:/after:/before:.
|
|
19
|
+
def list(options = {})
|
|
20
|
+
Millionsend::Request.new(
|
|
21
|
+
method: :get, path: "/contact-properties", query: Millionsend::Util.list_query(options)
|
|
22
|
+
).perform
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# PATCH /contact-properties/:id — fallback_value (nil clears it).
|
|
26
|
+
def update(id, params)
|
|
27
|
+
Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# DELETE /contact-properties/:id
|
|
31
|
+
def remove(id)
|
|
32
|
+
Millionsend::Request.new(method: :delete, path: member_path(id)).perform
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def member_path(id)
|
|
38
|
+
"/contact-properties/#{Millionsend::Util.encode(id)}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/millionsend/contacts.rb
CHANGED
|
@@ -5,7 +5,8 @@ module Millionsend
|
|
|
5
5
|
# an update hash carries both).
|
|
6
6
|
module Contacts
|
|
7
7
|
class << self
|
|
8
|
-
# POST /contacts
|
|
8
|
+
# POST /contacts — email, first_name, last_name, unsubscribed, properties,
|
|
9
|
+
# segments: [{ id: }], topics: [{ id:, subscription: }].
|
|
9
10
|
def create(params)
|
|
10
11
|
Millionsend::Request.new(method: :post, path: "/contacts", body: params).perform
|
|
11
12
|
end
|
|
@@ -19,9 +20,8 @@ module Millionsend
|
|
|
19
20
|
# everything else is the body. A nil value clears a field; omit a key to
|
|
20
21
|
# leave it unchanged.
|
|
21
22
|
def update(params)
|
|
22
|
-
key = params[:email] || params[:id]
|
|
23
23
|
body = params.reject { |k, _| [:id, :email].include?(k) }
|
|
24
|
-
Millionsend::Request.new(method: :patch, path: member_path(
|
|
24
|
+
Millionsend::Request.new(method: :patch, path: member_path(params), body: body).perform
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# DELETE a contact by id or email.
|
|
@@ -37,16 +37,71 @@ module Millionsend
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# PATCH /contacts/:id_or_email/topics with a bare array of
|
|
40
|
-
# { id:, subscription: }.
|
|
40
|
+
# { id:, subscription: }. Contacts::Topics.update is the resend-ruby shape.
|
|
41
41
|
def topics_update(id_or_email, topics)
|
|
42
42
|
Millionsend::Request.new(method: :patch, path: "#{member_path(id_or_email)}/topics", body: topics).perform
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
# Every member method also accepts resend-ruby's addressing hash
|
|
46
|
+
# ({ id: } / { email: } / { contact_id: }) in place of the bare value.
|
|
47
47
|
def member_path(id_or_email)
|
|
48
|
+
id_or_email = id_or_email[:email] || id_or_email[:id] || id_or_email[:contact_id] if id_or_email.is_a?(Hash)
|
|
48
49
|
"/contacts/#{Millionsend::Util.encode(id_or_email)}"
|
|
49
50
|
end
|
|
50
51
|
end
|
|
52
|
+
|
|
53
|
+
# Topic subscriptions of one contact, in resend-ruby's nested shape.
|
|
54
|
+
module Topics
|
|
55
|
+
class << self
|
|
56
|
+
# PATCH /contacts/:id_or_email/topics — { id: | email:, topics: [{ id:, subscription: }] }.
|
|
57
|
+
def update(params)
|
|
58
|
+
Millionsend::Contacts.topics_update(params, params[:topics])
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Bulk contact creation — a MillionSend extension (Resend imports via CSV).
|
|
64
|
+
module Batch
|
|
65
|
+
class << self
|
|
66
|
+
# POST /contacts/batch with a bare array of up to 1000 create payloads.
|
|
67
|
+
# Options (flat or under options:): on_conflict ("error" default,
|
|
68
|
+
# "skip", "upsert") for emails that already belong to a contact,
|
|
69
|
+
# batch_validation ("strict" default, "permissive" writes the valid
|
|
70
|
+
# subset and lists failures under errors[]), idempotency_key. Returns
|
|
71
|
+
# { data: [{ index:, id:, status: }], counts: {...}, errors: [...] }.
|
|
72
|
+
def create(list, options = {})
|
|
73
|
+
options = options[:options] if options.is_a?(Hash) && options.key?(:options)
|
|
74
|
+
Millionsend::Request.new(
|
|
75
|
+
method: :post, path: "/contacts/batch", body: list,
|
|
76
|
+
query: { on_conflict: options[:on_conflict] },
|
|
77
|
+
**Millionsend::Util.request_options(options)
|
|
78
|
+
).perform
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Segment membership of one contact. Segments are dynamic filters, so this
|
|
84
|
+
# only applies to segments that hold an explicit member list.
|
|
85
|
+
module Segments
|
|
86
|
+
class << self
|
|
87
|
+
# POST /contacts/:id_or_email/segments/:segment_id. Takes
|
|
88
|
+
# (id_or_email, segment_id) or resend-ruby's { contact_id: | email:, segment_id: }.
|
|
89
|
+
def add(id_or_email, segment_id = nil)
|
|
90
|
+
Millionsend::Request.new(method: :post, path: path(id_or_email, segment_id)).perform
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# DELETE /contacts/:id_or_email/segments/:segment_id — same shapes as add.
|
|
94
|
+
def remove(id_or_email, segment_id = nil)
|
|
95
|
+
Millionsend::Request.new(method: :delete, path: path(id_or_email, segment_id)).perform
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def path(id_or_email, segment_id)
|
|
101
|
+
segment_id ||= id_or_email[:segment_id] if id_or_email.is_a?(Hash)
|
|
102
|
+
"#{Millionsend::Contacts.member_path(id_or_email)}/segments/#{Millionsend::Util.encode(segment_id)}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
51
106
|
end
|
|
52
107
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Sending domains and their DNS records.
|
|
5
|
+
module Domains
|
|
6
|
+
class << self
|
|
7
|
+
# POST /domains — name, region, custom_return_path, open_tracking,
|
|
8
|
+
# click_tracking, tracking_subdomain. Returns the records to publish.
|
|
9
|
+
def create(params)
|
|
10
|
+
Millionsend::Request.new(method: :post, path: "/domains", body: params).perform
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# GET /domains/:id — includes records[] with per-record status.
|
|
14
|
+
def get(id)
|
|
15
|
+
Millionsend::Request.new(method: :get, path: member_path(id)).perform
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# GET /domains — accepts limit:/after:/before:.
|
|
19
|
+
def list(options = {})
|
|
20
|
+
Millionsend::Request.new(method: :get, path: "/domains", query: Millionsend::Util.list_query(options)).perform
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# PATCH /domains/:id — open_tracking, click_tracking, tracking_subdomain
|
|
24
|
+
# (nil or "" clears it).
|
|
25
|
+
def update(id, params)
|
|
26
|
+
Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# POST /domains/:id/verify — re-check DNS now.
|
|
30
|
+
def verify(id)
|
|
31
|
+
Millionsend::Request.new(method: :post, path: "#{member_path(id)}/verify").perform
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# DELETE /domains/:id
|
|
35
|
+
def remove(id)
|
|
36
|
+
Millionsend::Request.new(method: :delete, path: member_path(id)).perform
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def member_path(id)
|
|
42
|
+
"/domains/#{Millionsend::Util.encode(id)}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/millionsend/emails.rb
CHANGED
|
@@ -1,23 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Millionsend
|
|
4
|
-
# Transactional email: send one, look it up, cancel a scheduled
|
|
4
|
+
# Transactional email: send one, look it up, reschedule or cancel a scheduled
|
|
5
|
+
# one, list what was sent. Params hashes go on the wire as-is (from, to, cc,
|
|
6
|
+
# bcc, reply_to, subject, html, text, headers, tags, attachments,
|
|
7
|
+
# scheduled_at, topic_id, template), so nothing a caller passes is dropped.
|
|
5
8
|
module Emails
|
|
6
9
|
class << self
|
|
7
10
|
# POST /emails. Accepts a params hash or bare keywords
|
|
8
|
-
# (Emails.send(from: ..., to: ...)); a trailing
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
+
# (Emails.send(from: ..., to: ...)); a trailing hash carries the request
|
|
12
|
+
# options as either `idempotency_key: "k"` or resend-ruby's
|
|
13
|
+
# `options: { idempotency_key: "k" }`. No keyword parameters are declared
|
|
14
|
+
# on purpose — Ruby 3 keyword separation would otherwise reject the
|
|
15
|
+
# bare-keyword call shape.
|
|
11
16
|
def send(params = {}, options = {})
|
|
12
17
|
Millionsend::Request.new(
|
|
13
|
-
method: :post, path: "/emails", body: params,
|
|
18
|
+
method: :post, path: "/emails", body: params, **Millionsend::Util.request_options(options)
|
|
14
19
|
).perform
|
|
15
20
|
end
|
|
16
21
|
alias_method :create, :send
|
|
17
22
|
|
|
18
23
|
# GET /emails/:id
|
|
19
24
|
def get(id)
|
|
20
|
-
Millionsend::Request.new(method: :get, path:
|
|
25
|
+
Millionsend::Request.new(method: :get, path: member_path(id)).perform
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# GET /emails — accepts limit:/after:/before:.
|
|
29
|
+
def list(options = {})
|
|
30
|
+
Millionsend::Request.new(method: :get, path: "/emails", query: Millionsend::Util.list_query(options)).perform
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# PATCH /emails/:id — reschedule a scheduled, unsent email. Takes
|
|
34
|
+
# (id, { scheduled_at: }) or resend-ruby's single hash with :email_id.
|
|
35
|
+
def update(id, params = nil)
|
|
36
|
+
if id.is_a?(Hash)
|
|
37
|
+
params = id.reject { |k, _| k == :email_id }
|
|
38
|
+
id = id[:email_id]
|
|
39
|
+
end
|
|
40
|
+
Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
|
|
21
41
|
end
|
|
22
42
|
|
|
23
43
|
# GET /emails/:id/insights — the pre-send best-practice report computed
|
|
@@ -25,12 +45,23 @@ module Millionsend
|
|
|
25
45
|
# insights yet. Check ids and band/severity/status values are an open
|
|
26
46
|
# set that grows across score versions; they arrive as plain strings.
|
|
27
47
|
def get_insights(id)
|
|
28
|
-
Millionsend::Request.new(method: :get, path: "
|
|
48
|
+
Millionsend::Request.new(method: :get, path: "#{member_path(id)}/insights").perform
|
|
29
49
|
end
|
|
30
50
|
|
|
31
51
|
# POST /emails/:id/cancel — scheduled, unsent emails only.
|
|
32
52
|
def cancel(id)
|
|
33
|
-
Millionsend::Request.new(method: :post, path: "
|
|
53
|
+
Millionsend::Request.new(method: :post, path: "#{member_path(id)}/cancel").perform
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# DELETE /emails/:id
|
|
57
|
+
def remove(id)
|
|
58
|
+
Millionsend::Request.new(method: :delete, path: member_path(id)).perform
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def member_path(id)
|
|
64
|
+
"/emails/#{Millionsend::Util.encode(id)}"
|
|
34
65
|
end
|
|
35
66
|
end
|
|
36
67
|
end
|
data/lib/millionsend/error.rb
CHANGED
|
@@ -32,17 +32,31 @@ module Millionsend
|
|
|
32
32
|
|
|
33
33
|
class ValidationError < Error; end
|
|
34
34
|
class NotFoundError < Error; end
|
|
35
|
+
class MissingApiKeyError < Error; end
|
|
36
|
+
class InvalidApiKeyError < Error; end
|
|
35
37
|
class RestrictedApiKeyError < Error; end
|
|
36
38
|
class SendingPausedError < Error; end
|
|
39
|
+
class BroadcastsPausedError < Error; end
|
|
40
|
+
class RateLimitExceededError < Error; end
|
|
41
|
+
class DailyQuotaExceededError < Error; end
|
|
37
42
|
class InvalidIdempotentRequestError < Error; end
|
|
43
|
+
class ConcurrentIdempotentRequestsError < Error; end
|
|
44
|
+
class InternalServerError < Error; end
|
|
38
45
|
class ApplicationError < Error; end
|
|
39
46
|
|
|
40
47
|
ERROR_TYPES = {
|
|
41
48
|
"validation_error" => ValidationError,
|
|
42
49
|
"not_found" => NotFoundError,
|
|
50
|
+
"missing_api_key" => MissingApiKeyError,
|
|
51
|
+
"invalid_api_key" => InvalidApiKeyError,
|
|
43
52
|
"restricted_api_key" => RestrictedApiKeyError,
|
|
44
53
|
"sending_paused" => SendingPausedError,
|
|
54
|
+
"broadcasts_paused" => BroadcastsPausedError,
|
|
55
|
+
"rate_limit_exceeded" => RateLimitExceededError,
|
|
56
|
+
"daily_quota_exceeded" => DailyQuotaExceededError,
|
|
45
57
|
"invalid_idempotent_request" => InvalidIdempotentRequestError,
|
|
58
|
+
"concurrent_idempotent_requests" => ConcurrentIdempotentRequestsError,
|
|
59
|
+
"internal_server_error" => InternalServerError,
|
|
46
60
|
"application_error" => ApplicationError,
|
|
47
61
|
}.freeze
|
|
48
62
|
end
|
data/lib/millionsend/request.rb
CHANGED
|
@@ -26,12 +26,13 @@ module Millionsend
|
|
|
26
26
|
Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
|
|
27
27
|
].freeze
|
|
28
28
|
|
|
29
|
-
def initialize(method:, path:, body: nil, query: nil, idempotency_key: nil)
|
|
29
|
+
def initialize(method:, path:, body: nil, query: nil, idempotency_key: nil, batch_validation: nil)
|
|
30
30
|
@method = method
|
|
31
31
|
@path = path
|
|
32
32
|
@body = body
|
|
33
33
|
@query = query
|
|
34
34
|
@idempotency_key = idempotency_key
|
|
35
|
+
@batch_validation = batch_validation
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def perform
|
|
@@ -88,6 +89,7 @@ module Millionsend
|
|
|
88
89
|
end
|
|
89
90
|
# Idempotency is POST-only on the wire; ignored on other verbs.
|
|
90
91
|
request["Idempotency-Key"] = @idempotency_key if @idempotency_key && @method == :post
|
|
92
|
+
request["x-batch-validation"] = @batch_validation.to_s if @batch_validation
|
|
91
93
|
request
|
|
92
94
|
end
|
|
93
95
|
|
data/lib/millionsend/segments.rb
CHANGED
|
@@ -22,6 +22,15 @@ module Millionsend
|
|
|
22
22
|
).perform
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# GET /segments/:id/contacts — the contacts currently matching, paginated
|
|
26
|
+
# with limit:/after:/before:.
|
|
27
|
+
def contacts(id, options = {})
|
|
28
|
+
Millionsend::Request.new(
|
|
29
|
+
method: :get, path: "/segments/#{Millionsend::Util.encode(id)}/contacts",
|
|
30
|
+
query: Millionsend::Util.list_query(options)
|
|
31
|
+
).perform
|
|
32
|
+
end
|
|
33
|
+
|
|
25
34
|
# PATCH /segments/:id
|
|
26
35
|
def update(id, params)
|
|
27
36
|
Millionsend::Request.new(method: :patch, path: "/segments/#{Millionsend::Util.encode(id)}", body: params).perform
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Suppression list — addresses the API refuses to send to. Entries are
|
|
5
|
+
# addressable by id or by email.
|
|
6
|
+
module Suppressions
|
|
7
|
+
class << self
|
|
8
|
+
# POST /suppressions — email, origin ("manual" default, "bounce",
|
|
9
|
+
# "complaint", "unsubscribe"). Adding an already suppressed address
|
|
10
|
+
# returns the existing entry.
|
|
11
|
+
def add(params)
|
|
12
|
+
Millionsend::Request.new(method: :post, path: "/suppressions", body: params).perform
|
|
13
|
+
end
|
|
14
|
+
alias_method :create, :add
|
|
15
|
+
|
|
16
|
+
# GET /suppressions — accepts limit:/after:/before: and origin:.
|
|
17
|
+
def list(options = {})
|
|
18
|
+
query = Millionsend::Util.list_query(options).merge(origin: options[:origin])
|
|
19
|
+
Millionsend::Request.new(method: :get, path: "/suppressions", query: query).perform
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# GET /suppressions/:id_or_email
|
|
23
|
+
def get(id_or_email)
|
|
24
|
+
Millionsend::Request.new(method: :get, path: member_path(id_or_email)).perform
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# DELETE /suppressions/:id_or_email — allows sending to that address again.
|
|
28
|
+
def remove(id_or_email)
|
|
29
|
+
Millionsend::Request.new(method: :delete, path: member_path(id_or_email)).perform
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def member_path(id_or_email)
|
|
35
|
+
"/suppressions/#{Millionsend::Util.encode(id_or_email)}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Up to 1000 suppressions per call.
|
|
40
|
+
module Batch
|
|
41
|
+
class << self
|
|
42
|
+
# POST /suppressions/batch/add — { emails: [...], origin: }.
|
|
43
|
+
def add(params)
|
|
44
|
+
Millionsend::Request.new(method: :post, path: "/suppressions/batch/add", body: params).perform
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# POST /suppressions/batch/remove — { emails: [...] } or { ids: [...] }.
|
|
48
|
+
def remove(params)
|
|
49
|
+
Millionsend::Request.new(method: :post, path: "/suppressions/batch/remove", body: params).perform
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Email templates, addressable by id or alias. Templates are always
|
|
5
|
+
# published: publish is kept as a no-op for resend-ruby compatibility.
|
|
6
|
+
module Templates
|
|
7
|
+
class << self
|
|
8
|
+
# POST /templates — name, html, subject, text, alias.
|
|
9
|
+
def create(params)
|
|
10
|
+
Millionsend::Request.new(method: :post, path: "/templates", body: params).perform
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# GET /templates/:id_or_alias
|
|
14
|
+
def get(id_or_alias)
|
|
15
|
+
Millionsend::Request.new(method: :get, path: member_path(id_or_alias)).perform
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# GET /templates — accepts limit:/after:/before:.
|
|
19
|
+
def list(options = {})
|
|
20
|
+
Millionsend::Request.new(method: :get, path: "/templates", query: Millionsend::Util.list_query(options)).perform
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# PATCH /templates/:id_or_alias — name, html, subject, text, alias; nil
|
|
24
|
+
# clears subject/text/alias.
|
|
25
|
+
def update(id_or_alias, params)
|
|
26
|
+
Millionsend::Request.new(method: :patch, path: member_path(id_or_alias), body: params).perform
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# POST /templates/:id_or_alias/publish
|
|
30
|
+
def publish(id_or_alias)
|
|
31
|
+
Millionsend::Request.new(method: :post, path: "#{member_path(id_or_alias)}/publish").perform
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# POST /templates/:id_or_alias/duplicate — returns the copy's id.
|
|
35
|
+
def duplicate(id_or_alias)
|
|
36
|
+
Millionsend::Request.new(method: :post, path: "#{member_path(id_or_alias)}/duplicate").perform
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# DELETE /templates/:id_or_alias
|
|
40
|
+
def remove(id_or_alias)
|
|
41
|
+
Millionsend::Request.new(method: :delete, path: member_path(id_or_alias)).perform
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def member_path(id_or_alias)
|
|
47
|
+
"/templates/#{Millionsend::Util.encode(id_or_alias)}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/millionsend/topics.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Millionsend
|
|
|
4
4
|
# Subscription topics — granular unsubscribe categories.
|
|
5
5
|
module Topics
|
|
6
6
|
class << self
|
|
7
|
-
# POST /topics
|
|
7
|
+
# POST /topics — name, default_subscription, description, visibility.
|
|
8
8
|
def create(params)
|
|
9
9
|
Millionsend::Request.new(method: :post, path: "/topics", body: params).perform
|
|
10
10
|
end
|
|
@@ -19,6 +19,11 @@ module Millionsend
|
|
|
19
19
|
Millionsend::Request.new(method: :get, path: "/topics").perform
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# PATCH /topics/:id — name, description, visibility.
|
|
23
|
+
def update(id, params)
|
|
24
|
+
Millionsend::Request.new(method: :patch, path: "/topics/#{Millionsend::Util.encode(id)}", body: params).perform
|
|
25
|
+
end
|
|
26
|
+
|
|
22
27
|
# DELETE /topics/:id
|
|
23
28
|
def remove(id)
|
|
24
29
|
Millionsend::Request.new(method: :delete, path: "/topics/#{Millionsend::Util.encode(id)}").perform
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Plan limits and today's send count — a MillionSend extension.
|
|
5
|
+
module Usage
|
|
6
|
+
class << self
|
|
7
|
+
# GET /usage
|
|
8
|
+
def get
|
|
9
|
+
Millionsend::Request.new(method: :get, path: "/usage").perform
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/millionsend/util.rb
CHANGED
|
@@ -32,5 +32,15 @@ module Millionsend
|
|
|
32
32
|
options ||= {}
|
|
33
33
|
{ limit: options[:limit], after: options[:after], before: options[:before] }
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
# Per-request options for the Request constructor. Both call shapes land in
|
|
37
|
+
# the same trailing positional hash: the original `idempotency_key: "k"` and
|
|
38
|
+
# resend-ruby's keyword form `options: { idempotency_key: "k" }`, so the
|
|
39
|
+
# latter is unwrapped here.
|
|
40
|
+
def request_options(options)
|
|
41
|
+
options = options[:options] if options.is_a?(Hash) && options.key?(:options)
|
|
42
|
+
options ||= {}
|
|
43
|
+
{ idempotency_key: options[:idempotency_key], batch_validation: options[:batch_validation] }
|
|
44
|
+
end
|
|
35
45
|
end
|
|
36
46
|
end
|
data/lib/millionsend/version.rb
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Webhook endpoints. The signing secret is returned on create and on get.
|
|
5
|
+
module Webhooks
|
|
6
|
+
class << self
|
|
7
|
+
# POST /webhooks — endpoint, events[], signing_secret (optional; pass an
|
|
8
|
+
# existing whsec_ value to keep a receiver verifying unchanged).
|
|
9
|
+
def create(params)
|
|
10
|
+
Millionsend::Request.new(method: :post, path: "/webhooks", body: params).perform
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# GET /webhooks/:id — includes signing_secret.
|
|
14
|
+
def get(id)
|
|
15
|
+
Millionsend::Request.new(method: :get, path: member_path(id)).perform
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# GET /webhooks — accepts limit:/after:/before:.
|
|
19
|
+
def list(options = {})
|
|
20
|
+
Millionsend::Request.new(method: :get, path: "/webhooks", query: Millionsend::Util.list_query(options)).perform
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# PATCH /webhooks/:id — endpoint, events, status ("enabled"/"disabled").
|
|
24
|
+
def update(id, params)
|
|
25
|
+
Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# DELETE /webhooks/:id
|
|
29
|
+
def remove(id)
|
|
30
|
+
Millionsend::Request.new(method: :delete, path: member_path(id)).perform
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def member_path(id)
|
|
36
|
+
"/webhooks/#{Millionsend::Util.encode(id)}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/millionsend.rb
CHANGED
|
@@ -7,9 +7,16 @@ require "millionsend/request"
|
|
|
7
7
|
require "millionsend/emails"
|
|
8
8
|
require "millionsend/batch"
|
|
9
9
|
require "millionsend/contacts"
|
|
10
|
+
require "millionsend/contact_properties"
|
|
10
11
|
require "millionsend/topics"
|
|
11
12
|
require "millionsend/broadcasts"
|
|
12
13
|
require "millionsend/segments"
|
|
14
|
+
require "millionsend/suppressions"
|
|
15
|
+
require "millionsend/domains"
|
|
16
|
+
require "millionsend/webhooks"
|
|
17
|
+
require "millionsend/api_keys"
|
|
18
|
+
require "millionsend/templates"
|
|
19
|
+
require "millionsend/usage"
|
|
13
20
|
require "millionsend/deliverability"
|
|
14
21
|
|
|
15
22
|
# Ruby client for the MillionSend HTTP API. Configure once, then call the
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: millionsend
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- MillionSend
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rake
|
|
@@ -52,9 +52,10 @@ dependencies:
|
|
|
52
52
|
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '3.19'
|
|
55
|
-
description: 'Ruby client for the MillionSend HTTP API: emails, batch, contacts,
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
description: 'Ruby client for the MillionSend HTTP API: emails, batch, contacts, contact
|
|
56
|
+
properties, topics, broadcasts, segments, suppressions, domains, webhooks, API keys,
|
|
57
|
+
templates and usage. Wire-compatible with Resend and mirror-shaped after resend-ruby,
|
|
58
|
+
so migrating is mostly an import swap plus a base_url.'
|
|
58
59
|
email:
|
|
59
60
|
executables: []
|
|
60
61
|
extensions: []
|
|
@@ -63,17 +64,24 @@ files:
|
|
|
63
64
|
- LICENSE
|
|
64
65
|
- README.md
|
|
65
66
|
- lib/millionsend.rb
|
|
67
|
+
- lib/millionsend/api_keys.rb
|
|
66
68
|
- lib/millionsend/batch.rb
|
|
67
69
|
- lib/millionsend/broadcasts.rb
|
|
70
|
+
- lib/millionsend/contact_properties.rb
|
|
68
71
|
- lib/millionsend/contacts.rb
|
|
69
72
|
- lib/millionsend/deliverability.rb
|
|
73
|
+
- lib/millionsend/domains.rb
|
|
70
74
|
- lib/millionsend/emails.rb
|
|
71
75
|
- lib/millionsend/error.rb
|
|
72
76
|
- lib/millionsend/request.rb
|
|
73
77
|
- lib/millionsend/segments.rb
|
|
78
|
+
- lib/millionsend/suppressions.rb
|
|
79
|
+
- lib/millionsend/templates.rb
|
|
74
80
|
- lib/millionsend/topics.rb
|
|
81
|
+
- lib/millionsend/usage.rb
|
|
75
82
|
- lib/millionsend/util.rb
|
|
76
83
|
- lib/millionsend/version.rb
|
|
84
|
+
- lib/millionsend/webhooks.rb
|
|
77
85
|
homepage: https://github.com/MillionSend/millionsend-ruby
|
|
78
86
|
licenses:
|
|
79
87
|
- MIT
|