millionsend 0.0.1 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7ba9ebe734ecd28255800fe99c06057c9eb51813801476000d730faf4a12791
4
- data.tar.gz: 80b148cd3a5c73af573710ef4ceeb24f16e722f12b2ebe749661954916cbbf95
3
+ metadata.gz: da6448e6cf8b24c1c2badb8bb4c4d4904d873882ede341fefdbcb6949561753e
4
+ data.tar.gz: e68c0e3c988f18fee505aa780fbfc1974593da463f1aba01162eff0e50d4a0b4
5
5
  SHA512:
6
- metadata.gz: 92f383b23f1c403b0cdb74d8325c7674e8e75ae82aa896cac1dded64a1eccc77e1e30d605096d3b186aa0c88c672c4cfd50ef947961c0dac98113d44acb701fc
7
- data.tar.gz: 2274331c2c7bae47e1b7e8d574037367eb098d7b4876463966e61124c0a4f0ce5a4b9156bab3e88b1e0f50ab5f84ea415f37ba7e5d93fc5f2b1e969b538b8e77
6
+ metadata.gz: dc6a03a18b27ff92b46203c37df82eed46687420b14ac010e7b671ca6506f4f4c008e9177728c4640bb8aff3019f67fd7dbf23b9636a64a75b48cbfc658e2007
7
+ data.tar.gz: f5988d2662081f5bb34323cf482c10fc3762051b0b44e307a391cb867214d6dd6306664b052ed5f59e6ea5e2ef0e9187f6dba582c1c1b9c5ca4341ecdc076c4d
data/README.md CHANGED
@@ -1,7 +1,182 @@
1
1
  # millionsend
2
2
 
3
- Official Ruby SDK for [MillionSend](https://github.com/MillionSend) — the open-source,
4
- self-hostable email platform.
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
- **Status: under active development.** This release reserves the gem name; the first working
7
- SDK will land here.
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`, `audience_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
+ ### Audiences & contacts
74
+
75
+ ```ruby
76
+ audience = Millionsend::Audiences.create(name: "Registered users")
77
+ Millionsend::Audiences.list(limit: 20, after: cursor)
78
+ Millionsend::Audiences.get(id)
79
+ Millionsend::Audiences.remove(id)
80
+
81
+ Millionsend::Contacts.create(audience_id: audience[:id], email: "ada@acme.dev",
82
+ first_name: "Ada", properties: { plan: "pro" })
83
+ Millionsend::Contacts.get("ada@acme.dev", audience_id: audience[:id]) # by id or email
84
+ Millionsend::Contacts.get(contact_id) # top-level, by id
85
+ Millionsend::Contacts.update(id: contact_id, unsubscribed: true, first_name: nil) # nil clears
86
+ Millionsend::Contacts.remove("ada@acme.dev", audience_id: audience[:id])
87
+ Millionsend::Contacts.list(audience_id: audience[:id], limit: 50)
88
+
89
+ # Topic subscriptions (granular unsubscribe) — mirrors resend's contacts.topics.update
90
+ Millionsend::Contacts.topics_update("ada@acme.dev", [{ id: topic_id, subscription: "opt_out" }])
91
+ ```
92
+
93
+ Contacts are addressable by id or email; when an `update` hash carries both, the email wins.
94
+ Omit `audience_id:` to use the top-level `/contacts` endpoints.
95
+
96
+ ### Topics
97
+
98
+ ```ruby
99
+ Millionsend::Topics.create(name: "Product updates", default_subscription: "opt_in")
100
+ Millionsend::Topics.get(id)
101
+ Millionsend::Topics.list # bare { data: [...] } — topics are unpaginated
102
+ Millionsend::Topics.remove(id)
103
+ ```
104
+
105
+ ### Broadcasts
106
+
107
+ ```ruby
108
+ broadcast = Millionsend::Broadcasts.create(
109
+ audience_id: audience[:id],
110
+ from: "Acme <news@acme.dev>",
111
+ subject: "Launch",
112
+ html: "<p>Hi {{{FIRST_NAME|there}}}</p>"
113
+ )
114
+ Millionsend::Broadcasts.list
115
+ Millionsend::Broadcasts.get(broadcast[:id])
116
+ Millionsend::Broadcasts.update(broadcast[:id], subject: "Launch 🚀") # draft only
117
+ Millionsend::Broadcasts.send(broadcast[:id], scheduled_at: "2026-09-01T09:00:00Z") # omit to send now
118
+ Millionsend::Broadcasts.cancel(broadcast[:id]) # scheduled only
119
+ Millionsend::Broadcasts.remove(broadcast[:id]) # draft only
120
+ ```
121
+
122
+ ### Segments (MillionSend extension)
123
+
124
+ Dynamic segments are a saved filter over an audience's contacts — a MillionSend superset with
125
+ no Resend equivalent, served under `/segments2`.
126
+
127
+ ```ruby
128
+ segment = Millionsend::Segments.create(
129
+ name: "Pro plan",
130
+ audience_id: audience[:id],
131
+ filter: { match: "all", conditions: [{ field: "property:plan", op: "equals", value: "pro" }] }
132
+ )
133
+ Millionsend::Segments.get(segment[:id]) # includes a live contact_count
134
+ Millionsend::Segments.list
135
+ Millionsend::Segments.update(segment[:id], name: "Pro tier")
136
+ Millionsend::Segments.remove(segment[:id])
137
+ ```
138
+
139
+ ## Error handling
140
+
141
+ No `{ data, error }` tuple — a non-2xx response raises. The base class is `Millionsend::Error`,
142
+ which carries `#status_code`, `#name` (the stable snake_case discriminant), and `#message`.
143
+ Subclasses are keyed on `name`, so you can rescue a specific failure:
144
+
145
+ ```ruby
146
+ begin
147
+ Millionsend::Emails.get(id)
148
+ rescue Millionsend::NotFoundError => e
149
+ warn "no such email: #{e.message}"
150
+ rescue Millionsend::Error => e
151
+ warn "#{e.name} (#{e.status_code || 'transport'}): #{e.message}"
152
+ end
153
+ ```
154
+
155
+ Subclasses: `ValidationError`, `NotFoundError`, `RestrictedApiKeyError`, `SendingPausedError`,
156
+ `InvalidIdempotentRequestError`, and `ApplicationError` (the fallback). Client-side and
157
+ transport failures that never reached the API raise with `#status_code == nil`.
158
+
159
+ ## Migrating from Resend
160
+
161
+ ```diff
162
+ - require "resend"
163
+ - Resend.api_key = "re_123"
164
+ - Resend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")
165
+ + require "millionsend"
166
+ + Millionsend.api_key = "ms_123"
167
+ + Millionsend.base_url = "https://mail.acme.dev"
168
+ + Millionsend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")
169
+ ```
170
+
171
+ Method names, nesting and payloads match. Notes:
172
+
173
+ - **Domains and API keys** are managed in the MillionSend dashboard, not via the API, so there
174
+ are no `Domains` / `ApiKeys` resources here.
175
+ - Resend's `Contacts.topics.update` becomes `Millionsend::Contacts.topics_update` (Ruby has no
176
+ nested-module method on a module function).
177
+ - `Millionsend::Segments` is the distinct dynamic-filter feature (`/segments2`), not Resend's
178
+ audiences alias. Use `Millionsend::Audiences` for a straight port.
179
+
180
+ ## License
181
+
182
+ MIT
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # Audiences — named contact lists (Resend-compatible).
5
+ module Audiences
6
+ class << self
7
+ # POST /audiences
8
+ def create(params)
9
+ Millionsend::Request.new(method: :post, path: "/audiences", body: { name: params[:name] }).perform
10
+ end
11
+
12
+ # GET /audiences/:id
13
+ def get(id)
14
+ Millionsend::Request.new(method: :get, path: "/audiences/#{Millionsend::Util.encode(id)}").perform
15
+ end
16
+
17
+ # GET /audiences — accepts limit:/after:/before:.
18
+ def list(options = {})
19
+ Millionsend::Request.new(
20
+ method: :get, path: "/audiences", query: Millionsend::Util.list_query(options)
21
+ ).perform
22
+ end
23
+
24
+ # DELETE /audiences/:id
25
+ def remove(id)
26
+ Millionsend::Request.new(method: :delete, path: "/audiences/#{Millionsend::Util.encode(id)}").perform
27
+ end
28
+ end
29
+ end
30
+ end
@@ -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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # Broadcasts — one email sent to a whole audience or segment.
5
+ module Broadcasts
6
+ class << self
7
+ # POST /broadcasts
8
+ def create(params)
9
+ Millionsend::Request.new(method: :post, path: "/broadcasts", body: params).perform
10
+ end
11
+
12
+ # GET /broadcasts/:id
13
+ def get(id)
14
+ Millionsend::Request.new(method: :get, path: "/broadcasts/#{Millionsend::Util.encode(id)}").perform
15
+ end
16
+
17
+ # GET /broadcasts — accepts limit:/after:/before:.
18
+ def list(options = {})
19
+ Millionsend::Request.new(
20
+ method: :get, path: "/broadcasts", query: Millionsend::Util.list_query(options)
21
+ ).perform
22
+ end
23
+
24
+ # PATCH /broadcasts/:id — draft only.
25
+ def update(id, params)
26
+ Millionsend::Request.new(method: :patch, path: "/broadcasts/#{Millionsend::Util.encode(id)}", body: params).perform
27
+ end
28
+
29
+ # DELETE /broadcasts/:id — draft only.
30
+ def remove(id)
31
+ Millionsend::Request.new(method: :delete, path: "/broadcasts/#{Millionsend::Util.encode(id)}").perform
32
+ end
33
+
34
+ # POST /broadcasts/:id/send — pass scheduled_at: to schedule, omit to send now.
35
+ def send(id, params = {})
36
+ Millionsend::Request.new(method: :post, path: "/broadcasts/#{Millionsend::Util.encode(id)}/send", body: params).perform
37
+ end
38
+
39
+ # POST /broadcasts/:id/cancel — scheduled only.
40
+ def cancel(id)
41
+ Millionsend::Request.new(method: :post, path: "/broadcasts/#{Millionsend::Util.encode(id)}/cancel").perform
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # Contacts live inside an audience or at the top level, and are addressable by
5
+ # id or by email (email wins when an update hash carries both).
6
+ module Contacts
7
+ class << self
8
+ # POST /audiences/:audience_id/contacts (or /contacts). audience_id is
9
+ # addressing, not a body field, so it is stripped from the payload.
10
+ def create(params)
11
+ body = params.reject { |k, _| k == :audience_id }
12
+ Millionsend::Request.new(method: :post, path: collection_path(params[:audience_id]), body: body).perform
13
+ end
14
+
15
+ # GET a single contact by id or email.
16
+ def get(id_or_email, audience_id: nil)
17
+ Millionsend::Request.new(method: :get, path: member_path(id_or_email, audience_id)).perform
18
+ end
19
+
20
+ # PATCH a contact. Addressing keys (:audience_id, :id, :email) are pulled
21
+ # out; everything else is the body. A nil value clears a field; omit a key
22
+ # to leave it unchanged.
23
+ def update(params)
24
+ key = params[:email] || params[:id]
25
+ body = params.reject { |k, _| [:audience_id, :id, :email].include?(k) }
26
+ Millionsend::Request.new(method: :patch, path: member_path(key, params[:audience_id]), body: body).perform
27
+ end
28
+
29
+ # DELETE a contact by id or email.
30
+ def remove(id_or_email, audience_id: nil)
31
+ Millionsend::Request.new(method: :delete, path: member_path(id_or_email, audience_id)).perform
32
+ end
33
+
34
+ # GET a list of contacts; pass audience_id: to scope it, plus limit:/after:/before:.
35
+ def list(options = {})
36
+ Millionsend::Request.new(
37
+ method: :get, path: collection_path(options[:audience_id]), query: Millionsend::Util.list_query(options)
38
+ ).perform
39
+ end
40
+
41
+ # PATCH /contacts/:id_or_email/topics with a bare array of
42
+ # { id:, subscription: }. Mirrors resend-ruby's contacts.topics.update.
43
+ def topics_update(id_or_email, topics)
44
+ path = "/contacts/#{Millionsend::Util.encode(id_or_email)}/topics"
45
+ Millionsend::Request.new(method: :patch, path: path, body: topics).perform
46
+ end
47
+
48
+ private
49
+
50
+ def collection_path(audience_id)
51
+ audience_id ? "/audiences/#{Millionsend::Util.encode(audience_id)}/contacts" : "/contacts"
52
+ end
53
+
54
+ def member_path(id_or_email, audience_id)
55
+ key = Millionsend::Util.encode(id_or_email)
56
+ audience_id ? "/audiences/#{Millionsend::Util.encode(audience_id)}/contacts/#{key}" : "/contacts/#{key}"
57
+ end
58
+ end
59
+ end
60
+ 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 an audience's contacts. A MillionSend
5
+ # extension with no Resend equivalent; served under /segments2.
6
+ module Segments
7
+ class << self
8
+ # POST /segments2
9
+ def create(params)
10
+ Millionsend::Request.new(method: :post, path: "/segments2", body: params).perform
11
+ end
12
+
13
+ # GET /segments2/:id — also returns a live contact_count.
14
+ def get(id)
15
+ Millionsend::Request.new(method: :get, path: "/segments2/#{Millionsend::Util.encode(id)}").perform
16
+ end
17
+
18
+ # GET /segments2 — accepts limit:/after:/before:.
19
+ def list(options = {})
20
+ Millionsend::Request.new(
21
+ method: :get, path: "/segments2", query: Millionsend::Util.list_query(options)
22
+ ).perform
23
+ end
24
+
25
+ # PATCH /segments2/:id
26
+ def update(id, params)
27
+ Millionsend::Request.new(method: :patch, path: "/segments2/#{Millionsend::Util.encode(id)}", body: params).perform
28
+ end
29
+
30
+ # DELETE /segments2/:id
31
+ def remove(id)
32
+ Millionsend::Request.new(method: :delete, path: "/segments2/#{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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ VERSION = "0.1.0"
5
+ end
data/lib/millionsend.rb CHANGED
@@ -1,6 +1,42 @@
1
- # MillionSend Ruby SDK — under active development.
2
- # This placeholder release reserves the gem name.
3
- # Follow https://github.com/MillionSend for the first working release.
4
- module MillionSend
5
- VERSION = "0.0.1"
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/audiences"
10
+ require "millionsend/contacts"
11
+ require "millionsend/topics"
12
+ require "millionsend/broadcasts"
13
+ require "millionsend/segments"
14
+
15
+ # Ruby client for the MillionSend HTTP API. Configure once, then call the
16
+ # resource modules:
17
+ #
18
+ # Millionsend.api_key = "ms_..."
19
+ # Millionsend.base_url = "https://mail.acme.dev"
20
+ # Millionsend::Emails.send(from: "onboarding@acme.dev", to: "you@example.com",
21
+ # subject: "Hi", html: "<strong>it works</strong>")
22
+ #
23
+ # api_key falls back to the MILLIONSEND_API_KEY env var; base_url to
24
+ # MILLIONSEND_BASE_URL and then http://localhost:3001 (MillionSend is
25
+ # self-hosted, so there is no cloud default). Every call returns a symbol-keyed
26
+ # Hash on success and raises a Millionsend::Error on any non-2xx response.
27
+ module Millionsend
28
+ DEFAULT_BASE_URL = "http://localhost:3001"
29
+ USER_AGENT = "millionsend-ruby/#{VERSION}"
30
+
31
+ class << self
32
+ attr_writer :api_key, :base_url
33
+
34
+ def api_key
35
+ @api_key || ENV["MILLIONSEND_API_KEY"]
36
+ end
37
+
38
+ def base_url
39
+ @base_url || ENV["MILLIONSEND_BASE_URL"] || DEFAULT_BASE_URL
40
+ end
41
+ end
6
42
  end
metadata CHANGED
@@ -1,19 +1,62 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: millionsend
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.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-13 00:00:00.000000000 Z
12
- dependencies: []
13
- description: Official Ruby SDK for MillionSend, the open-source, self-hostable email
14
- platform. This placeholder release reserves the gem name; follow https://github.com/MillionSend
15
- for the first working release.
16
- email:
11
+ date: 2026-08-16 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, audiences,
56
+ contacts, topics, broadcasts, and dynamic segments. Wire-compatible with Resend
57
+ and mirror-shaped after resend-ruby, so migrating is mostly an import swap plus
58
+ a base_url.'
59
+ email:
17
60
  executables: []
18
61
  extensions: []
19
62
  extra_rdoc_files: []
@@ -21,12 +64,24 @@ files:
21
64
  - LICENSE
22
65
  - README.md
23
66
  - lib/millionsend.rb
67
+ - lib/millionsend/audiences.rb
68
+ - lib/millionsend/batch.rb
69
+ - lib/millionsend/broadcasts.rb
70
+ - lib/millionsend/contacts.rb
71
+ - lib/millionsend/emails.rb
72
+ - lib/millionsend/error.rb
73
+ - lib/millionsend/request.rb
74
+ - lib/millionsend/segments.rb
75
+ - lib/millionsend/topics.rb
76
+ - lib/millionsend/util.rb
77
+ - lib/millionsend/version.rb
24
78
  homepage: https://github.com/MillionSend/millionsend-ruby
25
79
  licenses:
26
80
  - MIT
27
81
  metadata:
28
82
  source_code_uri: https://github.com/MillionSend/millionsend-ruby
29
- post_install_message:
83
+ rubygems_mfa_required: 'true'
84
+ post_install_message:
30
85
  rdoc_options: []
31
86
  require_paths:
32
87
  - lib
@@ -41,9 +96,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
41
96
  - !ruby/object:Gem::Version
42
97
  version: '0'
43
98
  requirements: []
44
- rubygems_version: 3.0.3.1
45
- signing_key:
99
+ rubygems_version: 3.5.22
100
+ signing_key:
46
101
  specification_version: 4
47
- summary: Official Ruby SDK for MillionSend — the open-source email platform. Under
48
- active development.
102
+ summary: Official Ruby SDK for MillionSend — a self-hostable, Resend-compatible email
103
+ API.
49
104
  test_files: []