millionsend 0.1.0 → 0.3.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: da6448e6cf8b24c1c2badb8bb4c4d4904d873882ede341fefdbcb6949561753e
4
- data.tar.gz: e68c0e3c988f18fee505aa780fbfc1974593da463f1aba01162eff0e50d4a0b4
3
+ metadata.gz: 3a05f5a8a6f73d20210b65a538c84b985206374628dd5298793c01d37bd9138f
4
+ data.tar.gz: 7fe8ff20393c98a38072d70617e0df869aefebdb14a577db320f8514d17eca05
5
5
  SHA512:
6
- metadata.gz: dc6a03a18b27ff92b46203c37df82eed46687420b14ac010e7b671ca6506f4f4c008e9177728c4640bb8aff3019f67fd7dbf23b9636a64a75b48cbfc658e2007
7
- data.tar.gz: f5988d2662081f5bb34323cf482c10fc3762051b0b44e307a391cb867214d6dd6306664b052ed5f59e6ea5e2ef0e9187f6dba582c1c1b9c5ca4341ecdc076c4d
6
+ metadata.gz: ec4a59041ec45a8c620b9fa2ff618971273b0dc6ba43ba75e758cec70653978e9a428249acd52c060b79ab83cdc70b5f119b019b2407b2ce802f2407444cbee9
7
+ data.tar.gz: 2fc47253ed0511c373c8273f84457ad90f812db1d4d6d9df8c4d58019780e34e484681239679033f9b9d0ca435cebac24c19f23f5438c26cd92c2d388bf6be85
data/README.md CHANGED
@@ -48,12 +48,17 @@ 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.
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).
55
60
  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`).
61
+ the wire's snake_case: `reply_to`, `scheduled_at`, `segment_id`).
57
62
 
58
63
  ## Resources
59
64
 
@@ -61,7 +66,8 @@ the wire's snake_case: `reply_to`, `scheduled_at`, `audience_id`).
61
66
 
62
67
  ```ruby
63
68
  Millionsend::Emails.send(payload, idempotency_key: "order-42") # POST /emails
64
- Millionsend::Emails.get(id) # GET /emails/:id
69
+ Millionsend::Emails.get(id) # GET /emails/:id (includes score: 0-10 or nil)
70
+ Millionsend::Emails.get_insights(id) # GET /emails/:id/insights (404 until computed)
65
71
  Millionsend::Emails.cancel(id) # POST /emails/:id/cancel (scheduled only)
66
72
 
67
73
  Millionsend::Batch.send([payload_a, payload_b], idempotency_key: "run-7") # up to 100
@@ -70,28 +76,25 @@ Millionsend::Batch.send([payload_a, payload_b], idempotency_key: "run-7") # up t
70
76
  `to`, `cc`, `bcc` and `reply_to` accept either a string or an array. `Emails.create` is
71
77
  an alias of `Emails.send` (as is `Batch.create`), mirroring Resend.
72
78
 
73
- ### Audiences & contacts
79
+ ### Contacts
80
+
81
+ Contacts are team-global — one list per team, no audiences.
74
82
 
75
83
  ```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)
84
+ contact = Millionsend::Contacts.create(email: "ada@acme.dev", first_name: "Ada",
85
+ properties: { plan: "pro" })
86
+ Millionsend::Contacts.get("ada@acme.dev") # by id or email
87
+ Millionsend::Contacts.update(id: contact[:id], unsubscribed: true, first_name: nil) # nil clears
88
+ Millionsend::Contacts.remove("ada@acme.dev")
89
+ Millionsend::Contacts.list(limit: 50)
88
90
 
89
91
  # Topic subscriptions (granular unsubscribe) — mirrors resend's contacts.topics.update
90
92
  Millionsend::Contacts.topics_update("ada@acme.dev", [{ id: topic_id, subscription: "opt_out" }])
91
93
  ```
92
94
 
93
95
  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.
96
+ Emails are unique per team (case-insensitive) — a duplicate `create` raises
97
+ `Millionsend::ValidationError`.
95
98
 
96
99
  ### Topics
97
100
 
@@ -106,7 +109,7 @@ Millionsend::Topics.remove(id)
106
109
 
107
110
  ```ruby
108
111
  broadcast = Millionsend::Broadcasts.create(
109
- audience_id: audience[:id],
112
+ segment_id: segment[:id], # optional; omit segment_id and topic_id to send to all contacts
110
113
  from: "Acme <news@acme.dev>",
111
114
  subject: "Launch",
112
115
  html: "<p>Hi {{{FIRST_NAME|there}}}</p>"
@@ -121,13 +124,12 @@ Millionsend::Broadcasts.remove(broadcast[:id]) # draft only
121
124
 
122
125
  ### Segments (MillionSend extension)
123
126
 
124
- Dynamic segments are a saved filter over an audience's contacts — a MillionSend superset with
125
- no Resend equivalent, served under `/segments2`.
127
+ Dynamic segments are a saved filter over the team's contacts — a MillionSend superset with
128
+ no Resend equivalent.
126
129
 
127
130
  ```ruby
128
131
  segment = Millionsend::Segments.create(
129
132
  name: "Pro plan",
130
- audience_id: audience[:id],
131
133
  filter: { match: "all", conditions: [{ field: "property:plan", op: "equals", value: "pro" }] }
132
134
  )
133
135
  Millionsend::Segments.get(segment[:id]) # includes a live contact_count
@@ -136,6 +138,19 @@ Millionsend::Segments.update(segment[:id], name: "Pro tier")
136
138
  Millionsend::Segments.remove(segment[:id])
137
139
  ```
138
140
 
141
+ ### Deliverability (MillionSend extension)
142
+
143
+ Per-email best-practice insights and the account-level deliverability score.
144
+
145
+ ```ruby
146
+ insights = Millionsend::Emails.get_insights(email[:id]) # score, band, checks: [{id:, severity:, status:, penalty:, detail:}]
147
+ account = Millionsend::Deliverability.get # GET /deliverability — trailing-30-day account score
148
+ puts account[:score] # 0-10 (one decimal) or nil when there is not enough data
149
+ ```
150
+
151
+ Check ids and the band/severity/status values are an open set that grows across score
152
+ versions — treat them as strings, not a closed enum.
153
+
139
154
  ## Error handling
140
155
 
141
156
  No `{ data, error }` tuple — a non-2xx response raises. The base class is `Millionsend::Error`,
@@ -174,8 +189,9 @@ Method names, nesting and payloads match. Notes:
174
189
  are no `Domains` / `ApiKeys` resources here.
175
190
  - Resend's `Contacts.topics.update` becomes `Millionsend::Contacts.topics_update` (Ruby has no
176
191
  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.
192
+ - **No audiences** contacts are team-global, so there is no `Audiences` resource and no
193
+ `audience_id` params. `Millionsend::Segments` is the dynamic-filter feature (`/segments`),
194
+ not Resend's audiences alias.
179
195
 
180
196
  ## License
181
197
 
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Millionsend
4
- # Broadcasts — one email sent to a whole audience or segment.
4
+ # Broadcasts — one email sent to a segment, a topic's subscribers, or every
5
+ # contact (pass neither segment_id nor topic_id).
5
6
  module Broadcasts
6
7
  class << self
7
8
  # POST /broadcasts
@@ -1,59 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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).
4
+ # Contacts are team-global and addressable by id or by email (email wins when
5
+ # an update hash carries both).
6
6
  module Contacts
7
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.
8
+ # POST /contacts
10
9
  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
10
+ Millionsend::Request.new(method: :post, path: "/contacts", body: params).perform
13
11
  end
14
12
 
15
13
  # 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
14
+ def get(id_or_email)
15
+ Millionsend::Request.new(method: :get, path: member_path(id_or_email)).perform
18
16
  end
19
17
 
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.
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.
23
21
  def update(params)
24
22
  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
23
+ body = params.reject { |k, _| [:id, :email].include?(k) }
24
+ Millionsend::Request.new(method: :patch, path: member_path(key), body: body).perform
27
25
  end
28
26
 
29
27
  # 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
28
+ def remove(id_or_email)
29
+ Millionsend::Request.new(method: :delete, path: member_path(id_or_email)).perform
32
30
  end
33
31
 
34
- # GET a list of contacts; pass audience_id: to scope it, plus limit:/after:/before:.
32
+ # GET /contacts accepts limit:/after:/before:.
35
33
  def list(options = {})
36
34
  Millionsend::Request.new(
37
- method: :get, path: collection_path(options[:audience_id]), query: Millionsend::Util.list_query(options)
35
+ method: :get, path: "/contacts", query: Millionsend::Util.list_query(options)
38
36
  ).perform
39
37
  end
40
38
 
41
39
  # PATCH /contacts/:id_or_email/topics with a bare array of
42
40
  # { id:, subscription: }. Mirrors resend-ruby's contacts.topics.update.
43
41
  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
42
+ Millionsend::Request.new(method: :patch, path: "#{member_path(id_or_email)}/topics", body: topics).perform
46
43
  end
47
44
 
48
45
  private
49
46
 
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}"
47
+ def member_path(id_or_email)
48
+ "/contacts/#{Millionsend::Util.encode(id_or_email)}"
57
49
  end
58
50
  end
59
51
  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
@@ -20,6 +20,14 @@ module Millionsend
20
20
  Millionsend::Request.new(method: :get, path: "/emails/#{Millionsend::Util.encode(id)}").perform
21
21
  end
22
22
 
23
+ # GET /emails/:id/insights — the pre-send best-practice report computed
24
+ # when the email was sent. 404 when the email is unknown or has no
25
+ # insights yet. Check ids and band/severity/status values are an open
26
+ # set that grows across score versions; they arrive as plain strings.
27
+ def get_insights(id)
28
+ Millionsend::Request.new(method: :get, path: "/emails/#{Millionsend::Util.encode(id)}/insights").perform
29
+ end
30
+
23
31
  # POST /emails/:id/cancel — scheduled, unsent emails only.
24
32
  def cancel(id)
25
33
  Millionsend::Request.new(method: :post, path: "/emails/#{Millionsend::Util.encode(id)}/cancel").perform
@@ -65,6 +65,13 @@ module Millionsend
65
65
  def build_uri
66
66
  base = Millionsend.base_url.to_s.sub(%r{/+\z}, "")
67
67
  uri = URI.parse("#{base}#{@path}")
68
+ if !Millionsend.allow_insecure_http && Millionsend::Util.insecure_http?(uri)
69
+ raise Millionsend::ApplicationError.new(
70
+ "Refusing to send the API key over plain http to #{base}. " \
71
+ "Use https, or set Millionsend.allow_insecure_http = true.",
72
+ nil, "application_error"
73
+ )
74
+ end
68
75
  query = (@query || {}).reject { |_, v| v.nil? }
69
76
  uri.query = URI.encode_www_form(query) unless query.empty?
70
77
  uri
@@ -1,35 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # Dynamic segments — a saved filter over the team's contacts. A MillionSend
5
+ # extension with no Resend equivalent.
6
6
  module Segments
7
7
  class << self
8
- # POST /segments2
8
+ # POST /segments
9
9
  def create(params)
10
- Millionsend::Request.new(method: :post, path: "/segments2", body: params).perform
10
+ Millionsend::Request.new(method: :post, path: "/segments", body: params).perform
11
11
  end
12
12
 
13
- # GET /segments2/:id — also returns a live contact_count.
13
+ # GET /segments/:id — also returns a live contact_count.
14
14
  def get(id)
15
- Millionsend::Request.new(method: :get, path: "/segments2/#{Millionsend::Util.encode(id)}").perform
15
+ Millionsend::Request.new(method: :get, path: "/segments/#{Millionsend::Util.encode(id)}").perform
16
16
  end
17
17
 
18
- # GET /segments2 — accepts limit:/after:/before:.
18
+ # GET /segments — accepts limit:/after:/before:.
19
19
  def list(options = {})
20
20
  Millionsend::Request.new(
21
- method: :get, path: "/segments2", query: Millionsend::Util.list_query(options)
21
+ method: :get, path: "/segments", query: Millionsend::Util.list_query(options)
22
22
  ).perform
23
23
  end
24
24
 
25
- # PATCH /segments2/:id
25
+ # PATCH /segments/:id
26
26
  def update(id, params)
27
- Millionsend::Request.new(method: :patch, path: "/segments2/#{Millionsend::Util.encode(id)}", body: params).perform
27
+ Millionsend::Request.new(method: :patch, path: "/segments/#{Millionsend::Util.encode(id)}", body: params).perform
28
28
  end
29
29
 
30
- # DELETE /segments2/:id
30
+ # DELETE /segments/:id
31
31
  def remove(id)
32
- Millionsend::Request.new(method: :delete, path: "/segments2/#{Millionsend::Util.encode(id)}").perform
32
+ Millionsend::Request.new(method: :delete, path: "/segments/#{Millionsend::Util.encode(id)}").perform
33
33
  end
34
34
  end
35
35
  end
@@ -16,6 +16,16 @@ 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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Millionsend
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/millionsend.rb CHANGED
@@ -6,11 +6,11 @@ require "millionsend/error"
6
6
  require "millionsend/request"
7
7
  require "millionsend/emails"
8
8
  require "millionsend/batch"
9
- require "millionsend/audiences"
10
9
  require "millionsend/contacts"
11
10
  require "millionsend/topics"
12
11
  require "millionsend/broadcasts"
13
12
  require "millionsend/segments"
13
+ require "millionsend/deliverability"
14
14
 
15
15
  # Ruby client for the MillionSend HTTP API. Configure once, then call the
16
16
  # resource modules:
@@ -22,14 +22,17 @@ require "millionsend/segments"
22
22
  #
23
23
  # api_key falls back to the MILLIONSEND_API_KEY env var; base_url to
24
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.
25
+ # self-hosted, so there is no cloud default). Plain http is only accepted for
26
+ # loopback hosts unless allow_insecure_http is set, since the API key travels
27
+ # as a bearer header. Every call returns a symbol-keyed Hash on success and
28
+ # raises a Millionsend::Error on any non-2xx response.
27
29
  module Millionsend
28
30
  DEFAULT_BASE_URL = "http://localhost:3001"
29
31
  USER_AGENT = "millionsend-ruby/#{VERSION}"
30
32
 
31
33
  class << self
32
34
  attr_writer :api_key, :base_url
35
+ attr_accessor :allow_insecure_http
33
36
 
34
37
  def api_key
35
38
  @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.1.0
4
+ version: 0.3.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-08-16 00:00:00.000000000 Z
11
+ date: 2026-08-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -52,10 +52,9 @@ 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, 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.'
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.'
59
58
  email:
60
59
  executables: []
61
60
  extensions: []
@@ -64,10 +63,10 @@ files:
64
63
  - LICENSE
65
64
  - README.md
66
65
  - lib/millionsend.rb
67
- - lib/millionsend/audiences.rb
68
66
  - lib/millionsend/batch.rb
69
67
  - lib/millionsend/broadcasts.rb
70
68
  - lib/millionsend/contacts.rb
69
+ - lib/millionsend/deliverability.rb
71
70
  - lib/millionsend/emails.rb
72
71
  - lib/millionsend/error.rb
73
72
  - lib/millionsend/request.rb
@@ -1,30 +0,0 @@
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