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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7ba9ebe734ecd28255800fe99c06057c9eb51813801476000d730faf4a12791
4
- data.tar.gz: 80b148cd3a5c73af573710ef4ceeb24f16e722f12b2ebe749661954916cbbf95
3
+ metadata.gz: e15bda7d83e77437273c93cad192bc1da266e48ffb560b29037c898e26cc825d
4
+ data.tar.gz: be0dbc70f2210ff128541664405e74278f03825a2ea3e5eaa7ef887ce8c73a3b
5
5
  SHA512:
6
- metadata.gz: 92f383b23f1c403b0cdb74d8325c7674e8e75ae82aa896cac1dded64a1eccc77e1e30d605096d3b186aa0c88c672c4cfd50ef947961c0dac98113d44acb701fc
7
- data.tar.gz: 2274331c2c7bae47e1b7e8d574037367eb098d7b4876463966e61124c0a4f0ce5a4b9156bab3e88b1e0f50ab5f84ea415f37ba7e5d93fc5f2b1e969b538b8e77
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) — 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`, `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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ VERSION = "0.2.0"
5
+ end
data/lib/millionsend.rb CHANGED
@@ -1,6 +1,41 @@
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/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.1
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-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-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
- post_install_message:
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.0.3.1
45
- signing_key:
97
+ rubygems_version: 3.5.22
98
+ signing_key:
46
99
  specification_version: 4
47
- summary: Official Ruby SDK for MillionSend — the open-source email platform. Under
48
- active development.
100
+ summary: Official Ruby SDK for MillionSend — a self-hostable, Resend-compatible email
101
+ API.
49
102
  test_files: []