millionsend 0.0.1 → 0.2.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 +176 -4
- data/lib/millionsend/batch.rb +17 -0
- data/lib/millionsend/broadcasts.rb +46 -0
- data/lib/millionsend/contacts.rb +52 -0
- data/lib/millionsend/emails.rb +29 -0
- data/lib/millionsend/error.rb +48 -0
- data/lib/millionsend/request.rb +103 -0
- data/lib/millionsend/segments.rb +36 -0
- data/lib/millionsend/topics.rb +28 -0
- data/lib/millionsend/util.rb +26 -0
- data/lib/millionsend/version.rb +5 -0
- data/lib/millionsend.rb +40 -5
- metadata +66 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e15bda7d83e77437273c93cad192bc1da266e48ffb560b29037c898e26cc825d
|
|
4
|
+
data.tar.gz: be0dbc70f2210ff128541664405e74278f03825a2ea3e5eaa7ef887ce8c73a3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2a851400dfdff77ecd956e54df92b6ce0bf8b201c471b5354734e43a27dae34d66dff76602d61ac347758da0ee027f3cd9d331653752097dd13d637f10a38a17
|
|
7
|
+
data.tar.gz: aded65d56245645bef51bf1aa9954caf9f212c7c73e88946d3e6bc39ec59849ef3c05fd522b5dc259548c2408b01c834b684cd3609874ff23df4c960837d7e6c
|
data/README.md
CHANGED
|
@@ -1,7 +1,179 @@
|
|
|
1
1
|
# millionsend
|
|
2
2
|
|
|
3
|
-
Official Ruby SDK for [MillionSend](https://github.com/MillionSend) —
|
|
4
|
-
self-hostable email
|
|
3
|
+
Official Ruby SDK for [MillionSend](https://github.com/MillionSend/millionsend) — a
|
|
4
|
+
self-hostable, Resend-compatible email API on AWS SES.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
The API is wire-compatible with Resend, and this gem deliberately mirrors the shape of
|
|
7
|
+
[`resend`](https://github.com/resend/resend-ruby), so migrating is mostly a find-and-replace:
|
|
8
|
+
swap the constant, set `base_url` to your instance.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
gem install millionsend
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or in a `Gemfile`:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "millionsend"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Ruby 3.0+. Only the standard library is used at runtime (`net/http`, `json`).
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
require "millionsend"
|
|
28
|
+
|
|
29
|
+
Millionsend.api_key = "ms_123"
|
|
30
|
+
Millionsend.base_url = "https://mail.acme.dev"
|
|
31
|
+
|
|
32
|
+
email = Millionsend::Emails.send(
|
|
33
|
+
from: "Acme <onboarding@acme.dev>",
|
|
34
|
+
to: "delivered@resend.dev",
|
|
35
|
+
subject: "Hello from MillionSend",
|
|
36
|
+
html: "<strong>It works!</strong>"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
puts email[:id]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Every call returns a symbol-keyed `Hash` on success and raises a `Millionsend::Error`
|
|
43
|
+
on any non-2xx response (see [Error handling](#error-handling)).
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
Millionsend.api_key = "ms_123" # falls back to ENV["MILLIONSEND_API_KEY"]
|
|
49
|
+
Millionsend.base_url = "https://mail.acme.dev" # falls back to ENV["MILLIONSEND_BASE_URL"],
|
|
50
|
+
# then http://localhost:3001
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
MillionSend is self-hosted, so there is no cloud default — **set `base_url` to your
|
|
54
|
+
deployment in production.** An explicitly assigned value always wins over the environment.
|
|
55
|
+
Params are symbol-keyed hashes and map straight to the wire (Ruby's snake_case is already
|
|
56
|
+
the wire's snake_case: `reply_to`, `scheduled_at`, `segment_id`).
|
|
57
|
+
|
|
58
|
+
## Resources
|
|
59
|
+
|
|
60
|
+
### Emails
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
Millionsend::Emails.send(payload, idempotency_key: "order-42") # POST /emails
|
|
64
|
+
Millionsend::Emails.get(id) # GET /emails/:id
|
|
65
|
+
Millionsend::Emails.cancel(id) # POST /emails/:id/cancel (scheduled only)
|
|
66
|
+
|
|
67
|
+
Millionsend::Batch.send([payload_a, payload_b], idempotency_key: "run-7") # up to 100
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`to`, `cc`, `bcc` and `reply_to` accept either a string or an array. `Emails.create` is
|
|
71
|
+
an alias of `Emails.send` (as is `Batch.create`), mirroring Resend.
|
|
72
|
+
|
|
73
|
+
### Contacts
|
|
74
|
+
|
|
75
|
+
Contacts are team-global — one list per team, no audiences.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
contact = Millionsend::Contacts.create(email: "ada@acme.dev", first_name: "Ada",
|
|
79
|
+
properties: { plan: "pro" })
|
|
80
|
+
Millionsend::Contacts.get("ada@acme.dev") # by id or email
|
|
81
|
+
Millionsend::Contacts.update(id: contact[:id], unsubscribed: true, first_name: nil) # nil clears
|
|
82
|
+
Millionsend::Contacts.remove("ada@acme.dev")
|
|
83
|
+
Millionsend::Contacts.list(limit: 50)
|
|
84
|
+
|
|
85
|
+
# Topic subscriptions (granular unsubscribe) — mirrors resend's contacts.topics.update
|
|
86
|
+
Millionsend::Contacts.topics_update("ada@acme.dev", [{ id: topic_id, subscription: "opt_out" }])
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Contacts are addressable by id or email; when an `update` hash carries both, the email wins.
|
|
90
|
+
Emails are unique per team (case-insensitive) — a duplicate `create` raises
|
|
91
|
+
`Millionsend::ValidationError`.
|
|
92
|
+
|
|
93
|
+
### Topics
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
Millionsend::Topics.create(name: "Product updates", default_subscription: "opt_in")
|
|
97
|
+
Millionsend::Topics.get(id)
|
|
98
|
+
Millionsend::Topics.list # bare { data: [...] } — topics are unpaginated
|
|
99
|
+
Millionsend::Topics.remove(id)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Broadcasts
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
broadcast = Millionsend::Broadcasts.create(
|
|
106
|
+
segment_id: segment[:id], # optional; omit segment_id and topic_id to send to all contacts
|
|
107
|
+
from: "Acme <news@acme.dev>",
|
|
108
|
+
subject: "Launch",
|
|
109
|
+
html: "<p>Hi {{{FIRST_NAME|there}}}</p>"
|
|
110
|
+
)
|
|
111
|
+
Millionsend::Broadcasts.list
|
|
112
|
+
Millionsend::Broadcasts.get(broadcast[:id])
|
|
113
|
+
Millionsend::Broadcasts.update(broadcast[:id], subject: "Launch 🚀") # draft only
|
|
114
|
+
Millionsend::Broadcasts.send(broadcast[:id], scheduled_at: "2026-09-01T09:00:00Z") # omit to send now
|
|
115
|
+
Millionsend::Broadcasts.cancel(broadcast[:id]) # scheduled only
|
|
116
|
+
Millionsend::Broadcasts.remove(broadcast[:id]) # draft only
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Segments (MillionSend extension)
|
|
120
|
+
|
|
121
|
+
Dynamic segments are a saved filter over the team's contacts — a MillionSend superset with
|
|
122
|
+
no Resend equivalent.
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
segment = Millionsend::Segments.create(
|
|
126
|
+
name: "Pro plan",
|
|
127
|
+
filter: { match: "all", conditions: [{ field: "property:plan", op: "equals", value: "pro" }] }
|
|
128
|
+
)
|
|
129
|
+
Millionsend::Segments.get(segment[:id]) # includes a live contact_count
|
|
130
|
+
Millionsend::Segments.list
|
|
131
|
+
Millionsend::Segments.update(segment[:id], name: "Pro tier")
|
|
132
|
+
Millionsend::Segments.remove(segment[:id])
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Error handling
|
|
136
|
+
|
|
137
|
+
No `{ data, error }` tuple — a non-2xx response raises. The base class is `Millionsend::Error`,
|
|
138
|
+
which carries `#status_code`, `#name` (the stable snake_case discriminant), and `#message`.
|
|
139
|
+
Subclasses are keyed on `name`, so you can rescue a specific failure:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
begin
|
|
143
|
+
Millionsend::Emails.get(id)
|
|
144
|
+
rescue Millionsend::NotFoundError => e
|
|
145
|
+
warn "no such email: #{e.message}"
|
|
146
|
+
rescue Millionsend::Error => e
|
|
147
|
+
warn "#{e.name} (#{e.status_code || 'transport'}): #{e.message}"
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Subclasses: `ValidationError`, `NotFoundError`, `RestrictedApiKeyError`, `SendingPausedError`,
|
|
152
|
+
`InvalidIdempotentRequestError`, and `ApplicationError` (the fallback). Client-side and
|
|
153
|
+
transport failures that never reached the API raise with `#status_code == nil`.
|
|
154
|
+
|
|
155
|
+
## Migrating from Resend
|
|
156
|
+
|
|
157
|
+
```diff
|
|
158
|
+
- require "resend"
|
|
159
|
+
- Resend.api_key = "re_123"
|
|
160
|
+
- Resend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")
|
|
161
|
+
+ require "millionsend"
|
|
162
|
+
+ Millionsend.api_key = "ms_123"
|
|
163
|
+
+ Millionsend.base_url = "https://mail.acme.dev"
|
|
164
|
+
+ Millionsend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Method names, nesting and payloads match. Notes:
|
|
168
|
+
|
|
169
|
+
- **Domains and API keys** are managed in the MillionSend dashboard, not via the API, so there
|
|
170
|
+
are no `Domains` / `ApiKeys` resources here.
|
|
171
|
+
- Resend's `Contacts.topics.update` becomes `Millionsend::Contacts.topics_update` (Ruby has no
|
|
172
|
+
nested-module method on a module function).
|
|
173
|
+
- **No audiences** — contacts are team-global, so there is no `Audiences` resource and no
|
|
174
|
+
`audience_id` params. `Millionsend::Segments` is the dynamic-filter feature (`/segments`),
|
|
175
|
+
not Resend's audiences alias.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Send up to 100 emails in a single call.
|
|
5
|
+
module Batch
|
|
6
|
+
class << self
|
|
7
|
+
# POST /emails/batch with a bare array body. A trailing options hash
|
|
8
|
+
# carries idempotency_key (no keyword params, matching Emails.send).
|
|
9
|
+
def send(list, options = {})
|
|
10
|
+
Millionsend::Request.new(
|
|
11
|
+
method: :post, path: "/emails/batch", body: list, idempotency_key: options[:idempotency_key]
|
|
12
|
+
).perform
|
|
13
|
+
end
|
|
14
|
+
alias_method :create, :send
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Broadcasts — one email sent to a segment, a topic's subscribers, or every
|
|
5
|
+
# contact (pass neither segment_id nor topic_id).
|
|
6
|
+
module Broadcasts
|
|
7
|
+
class << self
|
|
8
|
+
# POST /broadcasts
|
|
9
|
+
def create(params)
|
|
10
|
+
Millionsend::Request.new(method: :post, path: "/broadcasts", body: params).perform
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# GET /broadcasts/:id
|
|
14
|
+
def get(id)
|
|
15
|
+
Millionsend::Request.new(method: :get, path: "/broadcasts/#{Millionsend::Util.encode(id)}").perform
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# GET /broadcasts — accepts limit:/after:/before:.
|
|
19
|
+
def list(options = {})
|
|
20
|
+
Millionsend::Request.new(
|
|
21
|
+
method: :get, path: "/broadcasts", query: Millionsend::Util.list_query(options)
|
|
22
|
+
).perform
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# PATCH /broadcasts/:id — draft only.
|
|
26
|
+
def update(id, params)
|
|
27
|
+
Millionsend::Request.new(method: :patch, path: "/broadcasts/#{Millionsend::Util.encode(id)}", body: params).perform
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# DELETE /broadcasts/:id — draft only.
|
|
31
|
+
def remove(id)
|
|
32
|
+
Millionsend::Request.new(method: :delete, path: "/broadcasts/#{Millionsend::Util.encode(id)}").perform
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# POST /broadcasts/:id/send — pass scheduled_at: to schedule, omit to send now.
|
|
36
|
+
def send(id, params = {})
|
|
37
|
+
Millionsend::Request.new(method: :post, path: "/broadcasts/#{Millionsend::Util.encode(id)}/send", body: params).perform
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# POST /broadcasts/:id/cancel — scheduled only.
|
|
41
|
+
def cancel(id)
|
|
42
|
+
Millionsend::Request.new(method: :post, path: "/broadcasts/#{Millionsend::Util.encode(id)}/cancel").perform
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Contacts are team-global and addressable by id or by email (email wins when
|
|
5
|
+
# an update hash carries both).
|
|
6
|
+
module Contacts
|
|
7
|
+
class << self
|
|
8
|
+
# POST /contacts
|
|
9
|
+
def create(params)
|
|
10
|
+
Millionsend::Request.new(method: :post, path: "/contacts", body: params).perform
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# GET a single contact by id or email.
|
|
14
|
+
def get(id_or_email)
|
|
15
|
+
Millionsend::Request.new(method: :get, path: member_path(id_or_email)).perform
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# PATCH a contact. Addressing keys (:id, :email) are pulled out;
|
|
19
|
+
# everything else is the body. A nil value clears a field; omit a key to
|
|
20
|
+
# leave it unchanged.
|
|
21
|
+
def update(params)
|
|
22
|
+
key = params[:email] || params[:id]
|
|
23
|
+
body = params.reject { |k, _| [:id, :email].include?(k) }
|
|
24
|
+
Millionsend::Request.new(method: :patch, path: member_path(key), body: body).perform
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# DELETE a contact by id or email.
|
|
28
|
+
def remove(id_or_email)
|
|
29
|
+
Millionsend::Request.new(method: :delete, path: member_path(id_or_email)).perform
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# GET /contacts — accepts limit:/after:/before:.
|
|
33
|
+
def list(options = {})
|
|
34
|
+
Millionsend::Request.new(
|
|
35
|
+
method: :get, path: "/contacts", query: Millionsend::Util.list_query(options)
|
|
36
|
+
).perform
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# PATCH /contacts/:id_or_email/topics with a bare array of
|
|
40
|
+
# { id:, subscription: }. Mirrors resend-ruby's contacts.topics.update.
|
|
41
|
+
def topics_update(id_or_email, topics)
|
|
42
|
+
Millionsend::Request.new(method: :patch, path: "#{member_path(id_or_email)}/topics", body: topics).perform
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def member_path(id_or_email)
|
|
48
|
+
"/contacts/#{Millionsend::Util.encode(id_or_email)}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Transactional email: send one, look it up, cancel a scheduled one.
|
|
5
|
+
module Emails
|
|
6
|
+
class << self
|
|
7
|
+
# POST /emails. Accepts a params hash or bare keywords
|
|
8
|
+
# (Emails.send(from: ..., to: ...)); a trailing options hash carries
|
|
9
|
+
# idempotency_key. No keyword parameters are declared on purpose — Ruby 3
|
|
10
|
+
# keyword separation would otherwise reject the bare-keyword call shape.
|
|
11
|
+
def send(params = {}, options = {})
|
|
12
|
+
Millionsend::Request.new(
|
|
13
|
+
method: :post, path: "/emails", body: params, idempotency_key: options[:idempotency_key]
|
|
14
|
+
).perform
|
|
15
|
+
end
|
|
16
|
+
alias_method :create, :send
|
|
17
|
+
|
|
18
|
+
# GET /emails/:id
|
|
19
|
+
def get(id)
|
|
20
|
+
Millionsend::Request.new(method: :get, path: "/emails/#{Millionsend::Util.encode(id)}").perform
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# POST /emails/:id/cancel — scheduled, unsent emails only.
|
|
24
|
+
def cancel(id)
|
|
25
|
+
Millionsend::Request.new(method: :post, path: "/emails/#{Millionsend::Util.encode(id)}/cancel").perform
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Base class for everything this SDK raises. Mirrors the API's wire error body
|
|
5
|
+
# { statusCode, name, message }; #status_code is nil for client-side and
|
|
6
|
+
# transport failures that never reached the API.
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
attr_reader :status_code, :name
|
|
9
|
+
|
|
10
|
+
def initialize(message, status_code = nil, name = nil)
|
|
11
|
+
super(message)
|
|
12
|
+
@status_code = status_code
|
|
13
|
+
@name = name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Build the right subclass from a non-2xx response, keyed on the stable
|
|
17
|
+
# `name` discriminant. Falls back to ApplicationError for unknown names or a
|
|
18
|
+
# body that is not the canonical error shape.
|
|
19
|
+
def self.from_response(status, body)
|
|
20
|
+
if body.is_a?(Hash)
|
|
21
|
+
name = body[:name].is_a?(String) ? body[:name] : "application_error"
|
|
22
|
+
message = body[:message].is_a?(String) ? body[:message] : "Request failed with status #{status}"
|
|
23
|
+
code = body[:statusCode].is_a?(Integer) ? body[:statusCode] : status
|
|
24
|
+
else
|
|
25
|
+
name = "application_error"
|
|
26
|
+
message = "Request failed with status #{status}"
|
|
27
|
+
code = status
|
|
28
|
+
end
|
|
29
|
+
(ERROR_TYPES[name] || ApplicationError).new(message, code, name)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class ValidationError < Error; end
|
|
34
|
+
class NotFoundError < Error; end
|
|
35
|
+
class RestrictedApiKeyError < Error; end
|
|
36
|
+
class SendingPausedError < Error; end
|
|
37
|
+
class InvalidIdempotentRequestError < Error; end
|
|
38
|
+
class ApplicationError < Error; end
|
|
39
|
+
|
|
40
|
+
ERROR_TYPES = {
|
|
41
|
+
"validation_error" => ValidationError,
|
|
42
|
+
"not_found" => NotFoundError,
|
|
43
|
+
"restricted_api_key" => RestrictedApiKeyError,
|
|
44
|
+
"sending_paused" => SendingPausedError,
|
|
45
|
+
"invalid_idempotent_request" => InvalidIdempotentRequestError,
|
|
46
|
+
"application_error" => ApplicationError,
|
|
47
|
+
}.freeze
|
|
48
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "openssl"
|
|
8
|
+
|
|
9
|
+
module Millionsend
|
|
10
|
+
# Internal: builds and performs one HTTP call over net/http, then returns the
|
|
11
|
+
# parsed (symbol-keyed) body or raises a Millionsend::Error. The resource
|
|
12
|
+
# modules are thin wrappers over this.
|
|
13
|
+
class Request
|
|
14
|
+
VERBS = {
|
|
15
|
+
get: Net::HTTP::Get,
|
|
16
|
+
post: Net::HTTP::Post,
|
|
17
|
+
patch: Net::HTTP::Patch,
|
|
18
|
+
delete: Net::HTTP::Delete,
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Transport-level failures that never produced an HTTP response -> the
|
|
22
|
+
# raised error carries status_code nil. Kept narrow so a mis-stubbed test
|
|
23
|
+
# (WebMock::NetConnectNotAllowedError) is not swallowed as a transport error.
|
|
24
|
+
TRANSPORT_ERRORS = [
|
|
25
|
+
Timeout::Error, SocketError, SystemCallError, IOError,
|
|
26
|
+
Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
def initialize(method:, path:, body: nil, query: nil, idempotency_key: nil)
|
|
30
|
+
@method = method
|
|
31
|
+
@path = path
|
|
32
|
+
@body = body
|
|
33
|
+
@query = query
|
|
34
|
+
@idempotency_key = idempotency_key
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def perform
|
|
38
|
+
api_key = Millionsend.api_key
|
|
39
|
+
if api_key.nil? || api_key.to_s.empty?
|
|
40
|
+
# Client-side failure: same class and stable name as a transport error,
|
|
41
|
+
# so rescue Millionsend::ApplicationError / branching on e.name works.
|
|
42
|
+
raise Millionsend::ApplicationError.new(
|
|
43
|
+
"Missing API key. Set Millionsend.api_key or the MILLIONSEND_API_KEY environment variable.",
|
|
44
|
+
nil, "application_error"
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
uri = build_uri
|
|
49
|
+
request = build_request(uri, api_key)
|
|
50
|
+
|
|
51
|
+
response =
|
|
52
|
+
begin
|
|
53
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
54
|
+
http.request(request)
|
|
55
|
+
end
|
|
56
|
+
rescue *TRANSPORT_ERRORS => e
|
|
57
|
+
raise Millionsend::ApplicationError.new(e.message, nil, "application_error")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
handle(response)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def build_uri
|
|
66
|
+
base = Millionsend.base_url.to_s.sub(%r{/+\z}, "")
|
|
67
|
+
uri = URI.parse("#{base}#{@path}")
|
|
68
|
+
query = (@query || {}).reject { |_, v| v.nil? }
|
|
69
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
70
|
+
uri
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_request(uri, api_key)
|
|
74
|
+
request = VERBS.fetch(@method).new(uri.request_uri)
|
|
75
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
76
|
+
request["Accept"] = "application/json"
|
|
77
|
+
request["User-Agent"] = Millionsend::USER_AGENT
|
|
78
|
+
unless @body.nil?
|
|
79
|
+
request["Content-Type"] = "application/json"
|
|
80
|
+
request.body = JSON.generate(@body)
|
|
81
|
+
end
|
|
82
|
+
# Idempotency is POST-only on the wire; ignored on other verbs.
|
|
83
|
+
request["Idempotency-Key"] = @idempotency_key if @idempotency_key && @method == :post
|
|
84
|
+
request
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def handle(response)
|
|
88
|
+
status = response.code.to_i
|
|
89
|
+
body = parse(response.body)
|
|
90
|
+
raise Millionsend::Error.from_response(status, body) unless (200..299).cover?(status)
|
|
91
|
+
|
|
92
|
+
body
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def parse(text)
|
|
96
|
+
return nil if text.nil? || text.empty?
|
|
97
|
+
|
|
98
|
+
JSON.parse(text, symbolize_names: true)
|
|
99
|
+
rescue JSON::ParserError
|
|
100
|
+
text
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Dynamic segments — a saved filter over the team's contacts. A MillionSend
|
|
5
|
+
# extension with no Resend equivalent.
|
|
6
|
+
module Segments
|
|
7
|
+
class << self
|
|
8
|
+
# POST /segments
|
|
9
|
+
def create(params)
|
|
10
|
+
Millionsend::Request.new(method: :post, path: "/segments", body: params).perform
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# GET /segments/:id — also returns a live contact_count.
|
|
14
|
+
def get(id)
|
|
15
|
+
Millionsend::Request.new(method: :get, path: "/segments/#{Millionsend::Util.encode(id)}").perform
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# GET /segments — accepts limit:/after:/before:.
|
|
19
|
+
def list(options = {})
|
|
20
|
+
Millionsend::Request.new(
|
|
21
|
+
method: :get, path: "/segments", query: Millionsend::Util.list_query(options)
|
|
22
|
+
).perform
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# PATCH /segments/:id
|
|
26
|
+
def update(id, params)
|
|
27
|
+
Millionsend::Request.new(method: :patch, path: "/segments/#{Millionsend::Util.encode(id)}", body: params).perform
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# DELETE /segments/:id
|
|
31
|
+
def remove(id)
|
|
32
|
+
Millionsend::Request.new(method: :delete, path: "/segments/#{Millionsend::Util.encode(id)}").perform
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Millionsend
|
|
4
|
+
# Subscription topics — granular unsubscribe categories.
|
|
5
|
+
module Topics
|
|
6
|
+
class << self
|
|
7
|
+
# POST /topics
|
|
8
|
+
def create(params)
|
|
9
|
+
Millionsend::Request.new(method: :post, path: "/topics", body: params).perform
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# GET /topics/:id
|
|
13
|
+
def get(id)
|
|
14
|
+
Millionsend::Request.new(method: :get, path: "/topics/#{Millionsend::Util.encode(id)}").perform
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# GET /topics — a bare { data: [...] } (topics are unpaginated).
|
|
18
|
+
def list
|
|
19
|
+
Millionsend::Request.new(method: :get, path: "/topics").perform
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# DELETE /topics/:id
|
|
23
|
+
def remove(id)
|
|
24
|
+
Millionsend::Request.new(method: :delete, path: "/topics/#{Millionsend::Util.encode(id)}").perform
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Millionsend
|
|
6
|
+
# Small shared helpers used across the resource modules.
|
|
7
|
+
module Util
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Percent-encode a single URL path segment (contact ids and emails, which
|
|
11
|
+
# can contain "@" and "+"). encode_www_form_component is form encoding, so
|
|
12
|
+
# it maps space to "+" — wrong inside a path segment, where servers decode
|
|
13
|
+
# "+" literally. It already turned real plus signs into %2B, so every
|
|
14
|
+
# remaining "+" is a space and can safely become %20.
|
|
15
|
+
def encode(value)
|
|
16
|
+
URI.encode_www_form_component(value.to_s).gsub("+", "%20")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The keyset pagination params every list endpoint accepts. nil values are
|
|
20
|
+
# dropped when the query string is built.
|
|
21
|
+
def list_query(options)
|
|
22
|
+
options ||= {}
|
|
23
|
+
{ limit: options[:limit], after: options[:after], before: options[:before] }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/millionsend.rb
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "millionsend/version"
|
|
4
|
+
require "millionsend/util"
|
|
5
|
+
require "millionsend/error"
|
|
6
|
+
require "millionsend/request"
|
|
7
|
+
require "millionsend/emails"
|
|
8
|
+
require "millionsend/batch"
|
|
9
|
+
require "millionsend/contacts"
|
|
10
|
+
require "millionsend/topics"
|
|
11
|
+
require "millionsend/broadcasts"
|
|
12
|
+
require "millionsend/segments"
|
|
13
|
+
|
|
14
|
+
# Ruby client for the MillionSend HTTP API. Configure once, then call the
|
|
15
|
+
# resource modules:
|
|
16
|
+
#
|
|
17
|
+
# Millionsend.api_key = "ms_..."
|
|
18
|
+
# Millionsend.base_url = "https://mail.acme.dev"
|
|
19
|
+
# Millionsend::Emails.send(from: "onboarding@acme.dev", to: "you@example.com",
|
|
20
|
+
# subject: "Hi", html: "<strong>it works</strong>")
|
|
21
|
+
#
|
|
22
|
+
# api_key falls back to the MILLIONSEND_API_KEY env var; base_url to
|
|
23
|
+
# MILLIONSEND_BASE_URL and then http://localhost:3001 (MillionSend is
|
|
24
|
+
# self-hosted, so there is no cloud default). Every call returns a symbol-keyed
|
|
25
|
+
# Hash on success and raises a Millionsend::Error on any non-2xx response.
|
|
26
|
+
module Millionsend
|
|
27
|
+
DEFAULT_BASE_URL = "http://localhost:3001"
|
|
28
|
+
USER_AGENT = "millionsend-ruby/#{VERSION}"
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
attr_writer :api_key, :base_url
|
|
32
|
+
|
|
33
|
+
def api_key
|
|
34
|
+
@api_key || ENV["MILLIONSEND_API_KEY"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def base_url
|
|
38
|
+
@base_url || ENV["MILLIONSEND_BASE_URL"] || DEFAULT_BASE_URL
|
|
39
|
+
end
|
|
40
|
+
end
|
|
6
41
|
end
|
metadata
CHANGED
|
@@ -1,19 +1,61 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: millionsend
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- MillionSend
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
12
|
-
dependencies:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
date: 2026-08-17 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rake
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '13.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '13.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rspec
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.12'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.12'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: webmock
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.19'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.19'
|
|
55
|
+
description: 'Ruby client for the MillionSend HTTP API: emails, batch, contacts, topics,
|
|
56
|
+
broadcasts, and dynamic segments. Wire-compatible with Resend and mirror-shaped
|
|
57
|
+
after resend-ruby, so migrating is mostly an import swap plus a base_url.'
|
|
58
|
+
email:
|
|
17
59
|
executables: []
|
|
18
60
|
extensions: []
|
|
19
61
|
extra_rdoc_files: []
|
|
@@ -21,12 +63,23 @@ files:
|
|
|
21
63
|
- LICENSE
|
|
22
64
|
- README.md
|
|
23
65
|
- lib/millionsend.rb
|
|
66
|
+
- lib/millionsend/batch.rb
|
|
67
|
+
- lib/millionsend/broadcasts.rb
|
|
68
|
+
- lib/millionsend/contacts.rb
|
|
69
|
+
- lib/millionsend/emails.rb
|
|
70
|
+
- lib/millionsend/error.rb
|
|
71
|
+
- lib/millionsend/request.rb
|
|
72
|
+
- lib/millionsend/segments.rb
|
|
73
|
+
- lib/millionsend/topics.rb
|
|
74
|
+
- lib/millionsend/util.rb
|
|
75
|
+
- lib/millionsend/version.rb
|
|
24
76
|
homepage: https://github.com/MillionSend/millionsend-ruby
|
|
25
77
|
licenses:
|
|
26
78
|
- MIT
|
|
27
79
|
metadata:
|
|
28
80
|
source_code_uri: https://github.com/MillionSend/millionsend-ruby
|
|
29
|
-
|
|
81
|
+
rubygems_mfa_required: 'true'
|
|
82
|
+
post_install_message:
|
|
30
83
|
rdoc_options: []
|
|
31
84
|
require_paths:
|
|
32
85
|
- lib
|
|
@@ -41,9 +94,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
41
94
|
- !ruby/object:Gem::Version
|
|
42
95
|
version: '0'
|
|
43
96
|
requirements: []
|
|
44
|
-
rubygems_version: 3.
|
|
45
|
-
signing_key:
|
|
97
|
+
rubygems_version: 3.5.22
|
|
98
|
+
signing_key:
|
|
46
99
|
specification_version: 4
|
|
47
|
-
summary: Official Ruby SDK for MillionSend —
|
|
48
|
-
|
|
100
|
+
summary: Official Ruby SDK for MillionSend — a self-hostable, Resend-compatible email
|
|
101
|
+
API.
|
|
49
102
|
test_files: []
|