millionsend 0.2.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 +218 -28
- 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/deliverability.rb +14 -0
- data/lib/millionsend/domains.rb +46 -0
- data/lib/millionsend/emails.rb +46 -7
- data/lib/millionsend/error.rb +14 -0
- data/lib/millionsend/request.rb +10 -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 +20 -0
- data/lib/millionsend/version.rb +1 -1
- data/lib/millionsend/webhooks.rb +40 -0
- data/lib/millionsend.rb +13 -2
- metadata +14 -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
|
@@ -48,54 +48,124 @@ on any non-2xx response (see [Error handling](#error-handling)).
|
|
|
48
48
|
Millionsend.api_key = "ms_123" # falls back to ENV["MILLIONSEND_API_KEY"]
|
|
49
49
|
Millionsend.base_url = "https://mail.acme.dev" # falls back to ENV["MILLIONSEND_BASE_URL"],
|
|
50
50
|
# then http://localhost:3001
|
|
51
|
+
Millionsend.allow_insecure_http = false # accept a non-loopback http:// base_url
|
|
51
52
|
```
|
|
52
53
|
|
|
53
54
|
MillionSend is self-hosted, so there is no cloud default — **set `base_url` to your
|
|
54
55
|
deployment in production.** An explicitly assigned value always wins over the environment.
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
Plain `http://` is only accepted for loopback hosts (`localhost`, `127.0.0.1`, `::1`); any
|
|
57
|
+
other `http://` URL raises `Millionsend::ApplicationError` on the first call, since the API
|
|
58
|
+
key is sent as a bearer header. Set `allow_insecure_http = true` to talk to a non-TLS
|
|
59
|
+
instance elsewhere (e.g. inside a private network).
|
|
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: }]`).
|
|
57
86
|
|
|
58
87
|
## Resources
|
|
59
88
|
|
|
60
89
|
### Emails
|
|
61
90
|
|
|
62
91
|
```ruby
|
|
63
|
-
Millionsend::Emails.send(payload, idempotency_key: "order-42") # POST
|
|
64
|
-
Millionsend::Emails.get(id) # GET
|
|
65
|
-
Millionsend::Emails.
|
|
66
|
-
|
|
67
|
-
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
|
|
68
101
|
```
|
|
69
102
|
|
|
70
|
-
`to`, `
|
|
71
|
-
|
|
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: ...)`.
|
|
72
110
|
|
|
73
111
|
### Contacts
|
|
74
112
|
|
|
75
113
|
Contacts are team-global — one list per team, no audiences.
|
|
76
114
|
|
|
77
115
|
```ruby
|
|
78
|
-
contact = Millionsend::Contacts.create(
|
|
79
|
-
|
|
80
|
-
|
|
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: ...)
|
|
81
123
|
Millionsend::Contacts.update(id: contact[:id], unsubscribed: true, first_name: nil) # nil clears
|
|
82
124
|
Millionsend::Contacts.remove("ada@acme.dev")
|
|
83
125
|
Millionsend::Contacts.list(limit: 50)
|
|
84
126
|
|
|
85
|
-
# Topic subscriptions (granular unsubscribe) —
|
|
86
|
-
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: }]
|
|
87
144
|
```
|
|
88
145
|
|
|
89
146
|
Contacts are addressable by id or email; when an `update` hash carries both, the email wins.
|
|
90
147
|
Emails are unique per team (case-insensitive) — a duplicate `create` raises
|
|
91
148
|
`Millionsend::ValidationError`.
|
|
92
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
|
+
|
|
93
162
|
### Topics
|
|
94
163
|
|
|
95
164
|
```ruby
|
|
96
|
-
Millionsend::Topics.create(name: "Product updates", default_subscription: "opt_in")
|
|
165
|
+
Millionsend::Topics.create(name: "Product updates", default_subscription: "opt_in", visibility: "public")
|
|
97
166
|
Millionsend::Topics.get(id)
|
|
98
167
|
Millionsend::Topics.list # bare { data: [...] } — topics are unpaginated
|
|
168
|
+
Millionsend::Topics.update(id, name: "Product news")
|
|
99
169
|
Millionsend::Topics.remove(id)
|
|
100
170
|
```
|
|
101
171
|
|
|
@@ -103,19 +173,104 @@ Millionsend::Topics.remove(id)
|
|
|
103
173
|
|
|
104
174
|
```ruby
|
|
105
175
|
broadcast = Millionsend::Broadcasts.create(
|
|
176
|
+
name: "Launch", # internal name shown in the dashboard
|
|
106
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
|
|
107
179
|
from: "Acme <news@acme.dev>",
|
|
108
180
|
subject: "Launch",
|
|
109
|
-
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)
|
|
110
187
|
)
|
|
111
188
|
Millionsend::Broadcasts.list
|
|
112
189
|
Millionsend::Broadcasts.get(broadcast[:id])
|
|
113
|
-
Millionsend::Broadcasts.update(broadcast[:id], subject: "Launch 🚀") # draft only
|
|
190
|
+
Millionsend::Broadcasts.update(broadcast[:id], subject: "Launch 🚀", topic_id: nil) # draft only; nil clears
|
|
114
191
|
Millionsend::Broadcasts.send(broadcast[:id], scheduled_at: "2026-09-01T09:00:00Z") # omit to send now
|
|
115
192
|
Millionsend::Broadcasts.cancel(broadcast[:id]) # scheduled only
|
|
116
193
|
Millionsend::Broadcasts.remove(broadcast[:id]) # draft only
|
|
117
194
|
```
|
|
118
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
|
+
|
|
119
274
|
### Segments (MillionSend extension)
|
|
120
275
|
|
|
121
276
|
Dynamic segments are a saved filter over the team's contacts — a MillionSend superset with
|
|
@@ -128,10 +283,33 @@ segment = Millionsend::Segments.create(
|
|
|
128
283
|
)
|
|
129
284
|
Millionsend::Segments.get(segment[:id]) # includes a live contact_count
|
|
130
285
|
Millionsend::Segments.list
|
|
286
|
+
Millionsend::Segments.contacts(segment[:id], limit: 50) # the contacts currently matching
|
|
131
287
|
Millionsend::Segments.update(segment[:id], name: "Pro tier")
|
|
132
288
|
Millionsend::Segments.remove(segment[:id])
|
|
133
289
|
```
|
|
134
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
|
+
|
|
300
|
+
### Deliverability (MillionSend extension)
|
|
301
|
+
|
|
302
|
+
Per-email best-practice insights and the account-level deliverability score.
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
insights = Millionsend::Emails.get_insights(email[:id]) # score, band, checks: [{id:, severity:, status:, penalty:, detail:}]
|
|
306
|
+
account = Millionsend::Deliverability.get # GET /deliverability — trailing-30-day account score
|
|
307
|
+
puts account[:score] # 0-10 (one decimal) or nil when there is not enough data
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Check ids and the band/severity/status values are an open set that grows across score
|
|
311
|
+
versions — treat them as strings, not a closed enum.
|
|
312
|
+
|
|
135
313
|
## Error handling
|
|
136
314
|
|
|
137
315
|
No `{ data, error }` tuple — a non-2xx response raises. The base class is `Millionsend::Error`,
|
|
@@ -148,9 +326,11 @@ rescue Millionsend::Error => e
|
|
|
148
326
|
end
|
|
149
327
|
```
|
|
150
328
|
|
|
151
|
-
Subclasses: `ValidationError`, `NotFoundError`, `
|
|
152
|
-
`
|
|
153
|
-
|
|
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`.
|
|
154
334
|
|
|
155
335
|
## Migrating from Resend
|
|
156
336
|
|
|
@@ -164,15 +344,25 @@ transport failures that never reached the API raise with `#status_code == nil`.
|
|
|
164
344
|
+ Millionsend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")
|
|
165
345
|
```
|
|
166
346
|
|
|
167
|
-
Method names, nesting and payloads match
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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.
|
|
173
358
|
- **No audiences** — contacts are team-global, so there is no `Audiences` resource and no
|
|
174
|
-
`audience_id` params.
|
|
175
|
-
|
|
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"`.
|
|
176
366
|
|
|
177
367
|
## License
|
|
178
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,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Account-level deliverability score over the trailing window
|
|
5
|
+
# (scores are 0-10, one decimal; null means not enough data).
|
|
6
|
+
module Deliverability
|
|
7
|
+
class << self
|
|
8
|
+
# GET /deliverability
|
|
9
|
+
def get
|
|
10
|
+
Millionsend::Request.new(method: :get, path: "/deliverability").perform
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
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,28 +1,67 @@
|
|
|
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
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# GET /emails/:id/insights — the pre-send best-practice report computed
|
|
44
|
+
# when the email was sent. 404 when the email is unknown or has no
|
|
45
|
+
# insights yet. Check ids and band/severity/status values are an open
|
|
46
|
+
# set that grows across score versions; they arrive as plain strings.
|
|
47
|
+
def get_insights(id)
|
|
48
|
+
Millionsend::Request.new(method: :get, path: "#{member_path(id)}/insights").perform
|
|
21
49
|
end
|
|
22
50
|
|
|
23
51
|
# POST /emails/:id/cancel — scheduled, unsent emails only.
|
|
24
52
|
def cancel(id)
|
|
25
|
-
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)}"
|
|
26
65
|
end
|
|
27
66
|
end
|
|
28
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
|
|
@@ -65,6 +66,13 @@ module Millionsend
|
|
|
65
66
|
def build_uri
|
|
66
67
|
base = Millionsend.base_url.to_s.sub(%r{/+\z}, "")
|
|
67
68
|
uri = URI.parse("#{base}#{@path}")
|
|
69
|
+
if !Millionsend.allow_insecure_http && Millionsend::Util.insecure_http?(uri)
|
|
70
|
+
raise Millionsend::ApplicationError.new(
|
|
71
|
+
"Refusing to send the API key over plain http to #{base}. " \
|
|
72
|
+
"Use https, or set Millionsend.allow_insecure_http = true.",
|
|
73
|
+
nil, "application_error"
|
|
74
|
+
)
|
|
75
|
+
end
|
|
68
76
|
query = (@query || {}).reject { |_, v| v.nil? }
|
|
69
77
|
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
70
78
|
uri
|
|
@@ -81,6 +89,7 @@ module Millionsend
|
|
|
81
89
|
end
|
|
82
90
|
# Idempotency is POST-only on the wire; ignored on other verbs.
|
|
83
91
|
request["Idempotency-Key"] = @idempotency_key if @idempotency_key && @method == :post
|
|
92
|
+
request["x-batch-validation"] = @batch_validation.to_s if @batch_validation
|
|
84
93
|
request
|
|
85
94
|
end
|
|
86
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
|
@@ -16,11 +16,31 @@ module Millionsend
|
|
|
16
16
|
URI.encode_www_form_component(value.to_s).gsub("+", "%20")
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
20
|
+
|
|
21
|
+
# True for an http:// URI whose host is not loopback.
|
|
22
|
+
def insecure_http?(uri)
|
|
23
|
+
return false unless uri.scheme == "http"
|
|
24
|
+
|
|
25
|
+
host = uri.host.to_s.downcase
|
|
26
|
+
!LOOPBACK_HOSTS.include?(host) && !host.start_with?("127.")
|
|
27
|
+
end
|
|
28
|
+
|
|
19
29
|
# The keyset pagination params every list endpoint accepts. nil values are
|
|
20
30
|
# dropped when the query string is built.
|
|
21
31
|
def list_query(options)
|
|
22
32
|
options ||= {}
|
|
23
33
|
{ limit: options[:limit], after: options[:after], before: options[:before] }
|
|
24
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
|
|
25
45
|
end
|
|
26
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,17 @@ 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"
|
|
20
|
+
require "millionsend/deliverability"
|
|
13
21
|
|
|
14
22
|
# Ruby client for the MillionSend HTTP API. Configure once, then call the
|
|
15
23
|
# resource modules:
|
|
@@ -21,14 +29,17 @@ require "millionsend/segments"
|
|
|
21
29
|
#
|
|
22
30
|
# api_key falls back to the MILLIONSEND_API_KEY env var; base_url to
|
|
23
31
|
# MILLIONSEND_BASE_URL and then http://localhost:3001 (MillionSend is
|
|
24
|
-
# self-hosted, so there is no cloud default).
|
|
25
|
-
#
|
|
32
|
+
# self-hosted, so there is no cloud default). Plain http is only accepted for
|
|
33
|
+
# loopback hosts unless allow_insecure_http is set, since the API key travels
|
|
34
|
+
# as a bearer header. Every call returns a symbol-keyed Hash on success and
|
|
35
|
+
# raises a Millionsend::Error on any non-2xx response.
|
|
26
36
|
module Millionsend
|
|
27
37
|
DEFAULT_BASE_URL = "http://localhost:3001"
|
|
28
38
|
USER_AGENT = "millionsend-ruby/#{VERSION}"
|
|
29
39
|
|
|
30
40
|
class << self
|
|
31
41
|
attr_writer :api_key, :base_url
|
|
42
|
+
attr_accessor :allow_insecure_http
|
|
32
43
|
|
|
33
44
|
def api_key
|
|
34
45
|
@api_key || ENV["MILLIONSEND_API_KEY"]
|
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,16 +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
|
|
72
|
+
- lib/millionsend/deliverability.rb
|
|
73
|
+
- lib/millionsend/domains.rb
|
|
69
74
|
- lib/millionsend/emails.rb
|
|
70
75
|
- lib/millionsend/error.rb
|
|
71
76
|
- lib/millionsend/request.rb
|
|
72
77
|
- lib/millionsend/segments.rb
|
|
78
|
+
- lib/millionsend/suppressions.rb
|
|
79
|
+
- lib/millionsend/templates.rb
|
|
73
80
|
- lib/millionsend/topics.rb
|
|
81
|
+
- lib/millionsend/usage.rb
|
|
74
82
|
- lib/millionsend/util.rb
|
|
75
83
|
- lib/millionsend/version.rb
|
|
84
|
+
- lib/millionsend/webhooks.rb
|
|
76
85
|
homepage: https://github.com/MillionSend/millionsend-ruby
|
|
77
86
|
licenses:
|
|
78
87
|
- MIT
|