sendping 1.0.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.
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ # Campaigns are DOMAIN-FIRST: `domain` (required on create) picks the
5
+ # contact pool the campaign targets; `from` may be any verified domain.
6
+ module Campaigns
7
+ class << self
8
+ # POST /campaigns
9
+ # SendPing::Campaigns.create({ domain: "yourdomain.com", from: "you@yourdomain.com",
10
+ # subject: "Hello", html: "<p>Hi</p>", segment_id: "seg_1" })
11
+ # Optional scheduling params: `schedule_timezone` (IANA zone the schedule +
12
+ # daily batching are evaluated in, e.g. "America/New_York") and
13
+ # `daily_batch_size` (max recipients per batch-day, 1-100000).
14
+ def create(params)
15
+ Client.require_domain!(params, "Campaigns.create")
16
+ Client.request(:post, "/campaigns", body: params)
17
+ end
18
+
19
+ # GET /campaigns/:id
20
+ def get(campaign_id)
21
+ Client.request(:get, "/campaigns/#{Client.path_escape(campaign_id)}")
22
+ end
23
+
24
+ # GET /campaigns
25
+ def list(params = {})
26
+ Client.request(:get, "/campaigns", query: Client.pagination(params))
27
+ end
28
+
29
+ # PATCH /campaigns/:id (draft campaigns only). Accepts the create-side
30
+ # fields plus `followups` (replace pending follow-ups, [] clears),
31
+ # `list_to` (true/false), `unsubscribe_policy` ("account" | "domain" |
32
+ # "ignore"), `schedule_timezone` (IANA zone; nil clears), and
33
+ # `daily_batch_size` (1-100000; nil clears).
34
+ def update(campaign_id, params)
35
+ Client.request(:patch, "/campaigns/#{Client.path_escape(campaign_id)}", body: params)
36
+ end
37
+
38
+ # Send now, or schedule with { scheduled_at: "..." }. An optional
39
+ # `schedule_timezone` (IANA name) is persisted onto the campaign so daily
40
+ # batching evaluates batch-days in that zone. POST /campaigns/:id/send
41
+ def send(campaign_id, params = {})
42
+ Client.request(:post, "/campaigns/#{Client.path_escape(campaign_id)}/send", body: params)
43
+ end
44
+
45
+ # Stop a campaign's remaining work. Accepted on `scheduled`, `recurring`,
46
+ # `paused` and `queued`; anything else is a validation_error.
47
+ # `scheduled`/`recurring`/`paused` return to `draft` (editable, re-sendable);
48
+ # a `queued` campaign already fanning out becomes the TERMINAL `canceled`,
49
+ # which can never be edited, re-sent or deleted. Copies already handed to
50
+ # the mail service cannot be recalled; what stops is every remaining
51
+ # recipient (for a staggered campaign, every future batch-day).
52
+ # Read the returned `status` to see which happened.
53
+ # POST /campaigns/:id/cancel
54
+ def cancel(campaign_id)
55
+ Client.request(:post, "/campaigns/#{Client.path_escape(campaign_id)}/cancel")
56
+ end
57
+
58
+ # Per-campaign analytics (counts, engagement rates, top links). GET /campaigns/:id/stats
59
+ def stats(campaign_id)
60
+ Client.request(:get, "/campaigns/#{Client.path_escape(campaign_id)}/stats")
61
+ end
62
+
63
+ # Who opened, clicked and replied, contact by contact. Each list is
64
+ # capped at 500 rows and there is no pagination.
65
+ # GET /campaigns/:id/engagement
66
+ def engagement(campaign_id)
67
+ Client.request(:get, "/campaigns/#{Client.path_escape(campaign_id)}/engagement")
68
+ end
69
+
70
+ # A/B winner evaluation for an A/B campaign. GET /campaigns/:id/ab
71
+ def ab(campaign_id)
72
+ Client.request(:get, "/campaigns/#{Client.path_escape(campaign_id)}/ab")
73
+ end
74
+
75
+ # DELETE /campaigns/:id
76
+ def delete(campaign_id)
77
+ Client.request(:delete, "/campaigns/#{Client.path_escape(campaign_id)}")
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "cgi"
7
+ require "time"
8
+
9
+ module SendPing
10
+ # Internal HTTP layer. Every resource funnels through Client.request;
11
+ # tests stub Client.deliver to avoid the network.
12
+ module Client
13
+ module_function
14
+
15
+ VERBS = {
16
+ get: Net::HTTP::Get,
17
+ post: Net::HTTP::Post,
18
+ patch: Net::HTTP::Patch,
19
+ delete: Net::HTTP::Delete
20
+ }.freeze
21
+
22
+ # Only 429/503 are candidates; retry_allowed? also checks progress and
23
+ # whether a write was rejected before processing or protected by a key.
24
+ RETRYABLE_STATUSES = [429, 503].freeze
25
+
26
+ # Upper bound (seconds) on any single backoff wait.
27
+ MAX_BACKOFF_SECONDS = 30.0
28
+
29
+ # `Idempotency-Key` is stored in a VARCHAR(255) column, so the API accepts
30
+ # 1-255 characters measured after it trims the value — 255, not 256 — and
31
+ # answers anything else with 400 invalid_idempotency_key. Only
32
+ # POST /emails, POST /emails/batch, and received-email reply/forward read the header; every other endpoint
33
+ # ignores it, so a retry there creates a second resource.
34
+ #
35
+ # Exposed for discoverability only: the SDK sends the key as given and lets
36
+ # the server be the authority.
37
+ IDEMPOTENCY_KEY_MAX_LENGTH = 255
38
+
39
+ # Perform an API request and return the parsed JSON body (a Hash/Array),
40
+ # or the raw body String when `raw: true` (binary download endpoints).
41
+ # Raises SendPing::Error on any non-2xx response.
42
+ def request(verb, path, body: nil, query: nil, options: {}, raw: false)
43
+ key = SendPing.api_key
44
+ if key.nil? || key.to_s.strip.empty?
45
+ raise SendPing::Error.new(
46
+ 'SendPing.api_key is not set. Configure it first: SendPing.api_key = "mb_xxxxxxxxx"',
47
+ error_name: "missing_api_key"
48
+ )
49
+ end
50
+
51
+ uri = URI.parse("#{SendPing.base_url.to_s.sub(%r{/+\z}, '')}#{path}")
52
+ if query && !query.empty?
53
+ uri.query = [uri.query, URI.encode_www_form(query)].compact.reject(&:empty?).join("&")
54
+ end
55
+
56
+ req = build_request(verb, uri, body, options, key)
57
+ handle_response(deliver_with_retries(req, uri), raw: raw)
58
+ end
59
+
60
+ # The single request chokepoint: both the JSON and raw/binary paths funnel
61
+ # through here, so both get the timeout (applied inside deliver) and the
62
+ # bounded 429/503 retry loop. Non-retryable responses return immediately;
63
+ # network/timeout errors are NOT caught here (they propagate as before).
64
+ def deliver_with_retries(req, uri)
65
+ max = SendPing.max_retries.to_i
66
+ attempt = 0
67
+ loop do
68
+ resp = deliver(req, uri)
69
+ code = resp.code.to_i
70
+ return resp unless RETRYABLE_STATUSES.include?(code) && attempt < max && retry_allowed?(req, uri, code, resp.body)
71
+
72
+ wait = retry_after_seconds(response_header(resp, "Retry-After"))
73
+ wait ||= [MAX_BACKOFF_SECONDS, 0.5 * (2**attempt)].min
74
+ backoff_sleep(wait) if wait.positive?
75
+ attempt += 1
76
+ end
77
+ end
78
+
79
+ def retry_allowed?(req, uri, status, raw)
80
+ body = begin
81
+ JSON.parse(raw.to_s)
82
+ rescue JSON::ParserError
83
+ {}
84
+ end
85
+ body = {} unless body.is_a?(Hash)
86
+ return false if (body["id"].is_a?(String) && !body["id"].empty?) ||
87
+ (body["sent_count"].is_a?(Numeric) && body["sent_count"].positive?) ||
88
+ (body["sent"].is_a?(Array) && !body["sent"].empty?) ||
89
+ (body["reserved"].is_a?(Array) && !body["reserved"].empty?) ||
90
+ body["name"] == "batch_incomplete"
91
+ return true if status != 503 || %w[GET HEAD].include?(req.method)
92
+ return true if %w[service_unavailable sending_service_unavailable sending_configuration_unavailable contacts_busy contacts_timeout].include?(body["name"])
93
+
94
+ req.method == "POST" && !req["Idempotency-Key"].to_s.strip.empty? &&
95
+ %r{/emails(?:/batch|/receiving/[^/]+/(?:reply|forward))?\z}.match?(uri.path)
96
+ end
97
+
98
+ # Wraps Kernel#sleep so tests can stub out the wait. Extracted so the
99
+ # retry loop stays deterministic under test.
100
+ def backoff_sleep(seconds)
101
+ sleep(seconds)
102
+ end
103
+
104
+ # Read a response header without assuming the response object shape
105
+ # (Net::HTTPResponse supports #[]; test doubles may not).
106
+ def response_header(resp, name)
107
+ return nil unless resp.respond_to?(:[])
108
+
109
+ resp[name]
110
+ rescue StandardError
111
+ nil
112
+ end
113
+
114
+ # Parse a Retry-After header into a number of seconds to wait:
115
+ # a numeric delta-seconds value, or an HTTP-date to wait until.
116
+ # Negative is treated as 0, and the wait is capped at 30 seconds.
117
+ # Returns nil when the header is absent or unparseable.
118
+ def retry_after_seconds(value)
119
+ return nil if value.nil?
120
+
121
+ str = value.to_s.strip
122
+ return nil if str.empty?
123
+
124
+ seconds =
125
+ begin
126
+ Float(str)
127
+ rescue ArgumentError, TypeError
128
+ begin
129
+ Time.httpdate(str) - Time.now
130
+ rescue ArgumentError
131
+ return nil
132
+ end
133
+ end
134
+
135
+ return nil unless seconds.finite?
136
+ seconds = 0.0 if seconds.negative?
137
+ [seconds.to_f, MAX_BACKOFF_SECONDS].min
138
+ end
139
+
140
+ def build_request(verb, uri, body, options, key)
141
+ klass = VERBS.fetch(verb) { raise ArgumentError, "unsupported HTTP verb: #{verb.inspect}" }
142
+ req = klass.new(uri)
143
+ req["Authorization"] = "Bearer #{key}"
144
+ req["User-Agent"] = "sendping-ruby/#{SendPing::VERSION}"
145
+ req["Accept"] = "application/json"
146
+ idem = idempotency_key(opt(options, :idempotency_key))
147
+ req["Idempotency-Key"] = idem if idem
148
+ unless body.nil?
149
+ req["Content-Type"] = "application/json"
150
+ req.body = JSON.generate(body)
151
+ end
152
+ req
153
+ end
154
+
155
+ # The single seam that touches the network (stub me in tests).
156
+ # Applies the configured open/read timeout; 0 or nil means "no timeout".
157
+ def deliver(req, uri)
158
+ opts = { use_ssl: uri.scheme == "https" }
159
+ t = SendPing.timeout
160
+ if !t.nil? && t.to_f > 0
161
+ opts[:open_timeout] = t
162
+ opts[:read_timeout] = t
163
+ end
164
+ Net::HTTP.start(uri.host, uri.port, **opts) do |http|
165
+ http.request(req)
166
+ end
167
+ end
168
+
169
+ def handle_response(resp, raw: false)
170
+ code = resp.code.to_i
171
+ body = resp.body
172
+
173
+ if code >= 200 && code < 300
174
+ return body if raw
175
+ return nil if body.nil? || body.empty?
176
+
177
+ begin
178
+ JSON.parse(body)
179
+ rescue JSON::ParserError
180
+ body
181
+ end
182
+ else
183
+ parsed = begin
184
+ JSON.parse(body.to_s)
185
+ rescue JSON::ParserError, TypeError
186
+ nil
187
+ end
188
+ parsed = {} unless parsed.is_a?(Hash)
189
+ # The whole body rides along: plan/quota errors add `limit`, reputation
190
+ # gates add `reputation`, and a partial batch failure adds
191
+ # `sent`/`sent_count` (see SendPing::Error).
192
+ raise SendPing::Error.new(
193
+ parsed["message"] || "Request failed with status #{code}",
194
+ status_code: code,
195
+ error_name: parsed["name"] || "application_error",
196
+ body: parsed
197
+ )
198
+ end
199
+ end
200
+
201
+ # Percent-encode one path segment so an id like "../api-keys" cannot
202
+ # traverse the URL path (spaces become %20, "/" becomes %2F).
203
+ def path_escape(value)
204
+ CGI.escape(value.to_s).gsub("+", "%20")
205
+ end
206
+
207
+ # Normalize an `idempotency_key` option into the header value. nil/absent or
208
+ # an empty string means "no header"; anything else is sent VERBATIM.
209
+ #
210
+ # The 1-255 bound (IDEMPOTENCY_KEY_MAX_LENGTH) is the server's to enforce —
211
+ # it trims the value and answers an out-of-range key with
212
+ # 400 invalid_idempotency_key. Checking here would only risk drifting from
213
+ # the server, and would disagree with the other SendPing SDKs.
214
+ def idempotency_key(value)
215
+ return nil if value.nil?
216
+
217
+ key = value.to_s
218
+ key.empty? ? nil : key
219
+ end
220
+
221
+ # Read a hash param by symbol or string key.
222
+ def opt(params, key)
223
+ return nil unless params.is_a?(Hash)
224
+
225
+ params.key?(key) ? params[key] : params[key.to_s]
226
+ end
227
+
228
+ # A copy of `params` without the given keys (symbol or string forms).
229
+ def without(params, *keys)
230
+ strs = keys.map(&:to_s)
231
+ params.reject { |k, _| strs.include?(k.to_s) }
232
+ end
233
+
234
+ # Extract the cursor-pagination params ({ limit, after, before }).
235
+ def pagination(params)
236
+ q = {}
237
+ %i[limit after before].each do |k|
238
+ v = opt(params, k)
239
+ q[k] = v unless v.nil?
240
+ end
241
+ q
242
+ end
243
+
244
+ # Copy the given keys out of `params` into a query hash, skipping the
245
+ # ones the caller left out. Used to expose an endpoint's server-side
246
+ # filters without forwarding unrelated params.
247
+ def filters(params, *keys)
248
+ keys.each_with_object({}) do |k, q|
249
+ v = opt(params, k)
250
+ q[k] = v unless v.nil?
251
+ end
252
+ end
253
+
254
+ # Domain-first guard: several resources require the sending domain.
255
+ def require_domain!(params, context)
256
+ v = opt(params, :domain)
257
+ if v.nil? || v.to_s.strip.empty?
258
+ raise ArgumentError,
259
+ "#{context} requires `domain` — the sending domain whose contact pool it targets, " \
260
+ 'e.g. { domain: "yourdomain.com", ... }'
261
+ end
262
+ v
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ # Custom contact fields, usable as {{merge_tags}}.
5
+ module ContactProperties
6
+ class << self
7
+ # POST /contact-properties — params: { key:, type: "string"|"number", fallback_value: }
8
+ def create(params)
9
+ Client.request(:post, "/contact-properties", body: params)
10
+ end
11
+
12
+ # GET /contact-properties/:id
13
+ def get(property_id)
14
+ Client.request(:get, "/contact-properties/#{Client.path_escape(property_id)}")
15
+ end
16
+
17
+ # GET /contact-properties
18
+ def list(params = {})
19
+ Client.request(:get, "/contact-properties", query: Client.pagination(params))
20
+ end
21
+
22
+ # PATCH /contact-properties/:id — only fallback_value is mutable.
23
+ def update(property_id, params)
24
+ Client.request(:patch, "/contact-properties/#{Client.path_escape(property_id)}", body: params)
25
+ end
26
+
27
+ # DELETE /contact-properties/:id
28
+ def delete(property_id)
29
+ Client.request(:delete, "/contact-properties/#{Client.path_escape(property_id)}")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ # Contacts are DOMAIN-FIRST: each sending domain has its own contact pool,
5
+ # so the flat /contacts API takes `domain` (required on create/list). The
6
+ # nested audience variants (`audience_id:`) derive the pool from the path.
7
+ module Contacts
8
+ class << self
9
+ # Create a contact. POST /contacts (flat, `domain` required) or
10
+ # POST /audiences/:id/contacts when `audience_id` is given.
11
+ # SendPing::Contacts.create({ domain: "yourdomain.com", email: "a@b.com" })
12
+ # SendPing::Contacts.create({ audience_id: "aud_1", email: "a@b.com" })
13
+ def create(params)
14
+ audience_id = Client.opt(params, :audience_id)
15
+ if audience_id
16
+ body = Client.without(params, :audience_id, :domain)
17
+ Client.request(:post, "/audiences/#{Client.path_escape(audience_id)}/contacts", body: body)
18
+ else
19
+ Client.require_domain!(params, "Contacts.create (flat /contacts API)")
20
+ Client.request(:post, "/contacts", body: Client.without(params, :audience_id))
21
+ end
22
+ end
23
+
24
+ # Retrieve a contact by id (exact) or by email. An email can exist in
25
+ # several domains' pools, so pass `domain` to pick the pool.
26
+ # Contacts.get({ id: "cont_1" })
27
+ # Contacts.get({ id: "a@b.com", domain: "yourdomain.com" })
28
+ # Contacts.get({ id: "cont_1", audience_id: "aud_1" })
29
+ def get(params)
30
+ id = Client.path_escape(Client.opt(params, :id))
31
+ audience_id = Client.opt(params, :audience_id)
32
+ return Client.request(:get, "/audiences/#{Client.path_escape(audience_id)}/contacts/#{id}") if audience_id
33
+
34
+ domain = Client.opt(params, :domain)
35
+ query = domain ? { domain: domain } : nil
36
+ Client.request(:get, "/contacts/#{id}", query: query)
37
+ end
38
+
39
+ # List contacts. Flat /contacts requires `domain` (names the pool);
40
+ # pass `audience_id` instead to use the nested API. `segment_id`
41
+ # filters either variant; limit/after/before paginate.
42
+ def list(params = {})
43
+ audience_id = Client.opt(params, :audience_id)
44
+ query = Client.pagination(params)
45
+ segment_id = Client.opt(params, :segment_id)
46
+ query[:segment_id] = segment_id if segment_id
47
+
48
+ if audience_id
49
+ Client.request(:get, "/audiences/#{Client.path_escape(audience_id)}/contacts", query: query)
50
+ else
51
+ query = { domain: Client.require_domain!(params, "Contacts.list (flat /contacts API)") }.merge(query)
52
+ Client.request(:get, "/contacts", query: query)
53
+ end
54
+ end
55
+
56
+ # Update a contact (id or email). PATCH /contacts/:id, or the nested
57
+ # route when `audience_id` is given. On the flat API pass `domain` when
58
+ # `id` is an email (disambiguates across pools).
59
+ # Contacts.update({ id: "cont_1", unsubscribed: true })
60
+ def update(params)
61
+ id = Client.path_escape(Client.opt(params, :id))
62
+ audience_id = Client.opt(params, :audience_id)
63
+ if audience_id
64
+ body = Client.without(params, :audience_id, :domain, :id)
65
+ Client.request(:patch, "/audiences/#{Client.path_escape(audience_id)}/contacts/#{id}", body: body)
66
+ else
67
+ Client.request(:patch, "/contacts/#{id}", body: Client.without(params, :audience_id, :id))
68
+ end
69
+ end
70
+
71
+ # Delete a contact. DELETE /contacts/:id (pass `domain` when `id` is an
72
+ # email), or the nested route when `audience_id` is given.
73
+ def delete(params)
74
+ id = Client.path_escape(Client.opt(params, :id))
75
+ audience_id = Client.opt(params, :audience_id)
76
+ return Client.request(:delete, "/audiences/#{Client.path_escape(audience_id)}/contacts/#{id}") if audience_id
77
+
78
+ domain = Client.opt(params, :domain)
79
+ query = domain ? { domain: domain } : nil
80
+ Client.request(:delete, "/contacts/#{id}", query: query)
81
+ end
82
+
83
+ # Bulk-import contacts from an array (upsert by email; max 10,000).
84
+ #
85
+ # Domain-first, like .create: pass :domain for the flat
86
+ # POST /contacts/batch, or :audience_id for POST /audiences/:id/contacts/batch.
87
+ # Prefer this over a .create loop for many contacts — one batch takes the
88
+ # account's contact-limit lock once, a loop takes it per contact.
89
+ # Contacts.batch({ domain: "yourdomain.com", contacts: [{ email: "a@b.com" }] })
90
+ # Contacts.batch({ audience_id: "aud_1", contacts: [{ email: "a@b.com" }], on_conflict: "skip" })
91
+ def batch(params)
92
+ audience_id = Client.opt(params, :audience_id)
93
+ # "" is truthy in Ruby but names no audience — treat it as absent and
94
+ # take the flat route, matching the other SendPing SDKs.
95
+ audience_id = nil if audience_id == ""
96
+ query = {}
97
+ on_conflict = Client.opt(params, :on_conflict)
98
+ query[:on_conflict] = on_conflict if on_conflict
99
+ body = { contacts: Client.opt(params, :contacts) }
100
+ return Client.request(
101
+ :post,
102
+ "/audiences/#{Client.path_escape(audience_id)}/contacts/batch",
103
+ body: body,
104
+ query: query
105
+ ) if audience_id
106
+
107
+ # The nested route derives its pool from the path; only the flat route
108
+ # takes :domain (in the body, same as POST /contacts).
109
+ domain = Client.opt(params, :domain)
110
+ body[:domain] = domain unless domain.nil?
111
+ Client.request(:post, "/contacts/batch", body: body, query: query)
112
+ end
113
+
114
+ # Bulk-import contacts from CSV (header row optional; upsert by email).
115
+ # Non-builtin columns auto-register as custom properties unless
116
+ # `create_properties: false`. Pass `segment_id` to also add every
117
+ # imported email to one of this audience's segments.
118
+ # POST /audiences/:id/contacts/import
119
+ #
120
+ # Inline CSV text (capped at 5 MB and 10,000 rows):
121
+ # Contacts.import({ audience_id: "aud_1", csv: "email\na@b.com" })
122
+ # Or a file already uploaded via create_import_upload (no row cap — the
123
+ # overflow past your contact limit comes back as `limit_skipped`):
124
+ # Contacts.import({ audience_id: "aud_1", storage_key: key })
125
+ def import(params)
126
+ audience_id = Client.opt(params, :audience_id)
127
+ query = Client.filters(params, :on_conflict, :segment_id)
128
+ query[:create_properties] = "false" if Client.opt(params, :create_properties) == false
129
+ body = Client.filters(params, :csv, :file_name, :storage_key)
130
+ Client.request(
131
+ :post,
132
+ "/audiences/#{Client.path_escape(audience_id)}/contacts/import",
133
+ body: body,
134
+ query: query
135
+ )
136
+ end
137
+
138
+ # Mint a presigned direct-upload URL for a CSV too large to inline
139
+ # (up to 256 MB). Upload the file to `upload_url`, then pass the returned
140
+ # `storage_key` to Contacts.import.
141
+ # POST /audiences/:id/contacts/import/upload — params: { filename:, size: }
142
+ # The `upload_url` is a bearer credential — do not log it.
143
+ def create_import_upload(params)
144
+ audience_id = Client.opt(params, :audience_id)
145
+ Client.request(
146
+ :post,
147
+ "/audiences/#{Client.path_escape(audience_id)}/contacts/import/upload",
148
+ body: Client.without(params, :audience_id)
149
+ )
150
+ end
151
+
152
+ # Add a contact to a segment. POST /contacts/:id/segments/:segment_id
153
+ def add_to_segment(contact_id, segment_id)
154
+ Client.request(:post, "/contacts/#{Client.path_escape(contact_id)}/segments/#{Client.path_escape(segment_id)}")
155
+ end
156
+
157
+ # Remove a contact from a segment. DELETE /contacts/:id/segments/:segment_id
158
+ def remove_from_segment(contact_id, segment_id)
159
+ Client.request(:delete, "/contacts/#{Client.path_escape(contact_id)}/segments/#{Client.path_escape(segment_id)}")
160
+ end
161
+
162
+ # List the segments a contact belongs to — items carry id/name/created_at
163
+ # only, not the full segment object. GET /contacts/:id/segments
164
+ def list_segments(contact_id, params = {})
165
+ Client.request(
166
+ :get,
167
+ "/contacts/#{Client.path_escape(contact_id)}/segments",
168
+ query: Client.pagination(params)
169
+ )
170
+ end
171
+
172
+ # Get a contact's topic subscriptions. GET /contacts/:id/topics
173
+ def get_topics(contact_id, params = {})
174
+ Client.request(
175
+ :get,
176
+ "/contacts/#{Client.path_escape(contact_id)}/topics",
177
+ query: Client.pagination(params)
178
+ )
179
+ end
180
+
181
+ # Update a contact's topic subscriptions. PATCH /contacts/:id/topics
182
+ # Contacts.update_topics("cont_1", { topics: [{ id: "top_1", subscription: "opt_in" }] })
183
+ def update_topics(contact_id, params)
184
+ Client.request(:patch, "/contacts/#{Client.path_escape(contact_id)}/topics", body: params)
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ module Domains
5
+ class << self
6
+ # HTTPS readiness; an unavailable host schedules server-side repair.
7
+ def tracking_health(domain_id)
8
+ Client.request(:get, "/domains/#{Client.path_escape(domain_id)}/tracking-health")
9
+ end
10
+
11
+ # Register a sending domain. POST /domains
12
+ def create(params)
13
+ Client.request(:post, "/domains", body: params)
14
+ end
15
+
16
+ # GET /domains/:id
17
+ def get(domain_id)
18
+ Client.request(:get, "/domains/#{Client.path_escape(domain_id)}")
19
+ end
20
+
21
+ # GET /domains — with no pagination params one page carries up to 1,000
22
+ # domains and `has_more` reports any truncation; pass `limit` and walk
23
+ # `after` to read past that. Rows still pending a DNS-TXT ownership claim
24
+ # are excluded; read those through get_claim instead.
25
+ def list(params = {})
26
+ Client.request(:get, "/domains", query: Client.pagination(params))
27
+ end
28
+
29
+ # Check a domain's live MX records before adding receiving.
30
+ # `ours` is true only when every MX host is SendPing's. A DNS failure
31
+ # answers { has_mx: false, ours: false, records: [] }, not an error.
32
+ # GET /domains/mx-check?name=
33
+ def mx_check(name)
34
+ Client.request(:get, "/domains/mx-check", query: { name: name })
35
+ end
36
+
37
+ # Download the domain's DNS records as CSV text (a String, not JSON).
38
+ # GET /domains/:id/records.csv
39
+ def records_csv(domain_id)
40
+ Client.request(:get, "/domains/#{Client.path_escape(domain_id)}/records.csv", raw: true)
41
+ end
42
+
43
+ # PATCH /domains/:id (returns the slim ack { object: "domain", id }).
44
+ def update(domain_id, params)
45
+ Client.request(:patch, "/domains/#{Client.path_escape(domain_id)}", body: params)
46
+ end
47
+
48
+ # Trigger DNS verification. POST /domains/:id/verify
49
+ def verify(domain_id)
50
+ Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/verify")
51
+ end
52
+
53
+ # Claim a domain already verified in another account. POST /domains/claim
54
+ def claim(params)
55
+ Client.request(:post, "/domains/claim", body: params)
56
+ end
57
+
58
+ # Retrieve a domain's claim record. GET /domains/:id/claim
59
+ def get_claim(domain_id)
60
+ Client.request(:get, "/domains/#{Client.path_escape(domain_id)}/claim")
61
+ end
62
+
63
+ # Verify a domain claim's TXT record. POST /domains/:id/claim/verify
64
+ def verify_claim(domain_id)
65
+ Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/claim/verify")
66
+ end
67
+
68
+ # Detect the DNS provider and available one-click apply methods.
69
+ # GET /domains/:id/dns/detect
70
+ def detect_dns(domain_id)
71
+ Client.request(:get, "/domains/#{Client.path_escape(domain_id)}/dns/detect")
72
+ end
73
+
74
+ # Apply DNS records via the Cloudflare API, then auto-verify.
75
+ # POST /domains/:id/dns/cloudflare — params: { token: "..." }
76
+ def apply_cloudflare_dns(domain_id, params)
77
+ Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/dns/cloudflare", body: params)
78
+ end
79
+
80
+ # Apply DNS records via the GoDaddy API, then auto-verify.
81
+ # POST /domains/:id/dns/godaddy — params: { key: "...", secret: "..." }
82
+ def apply_godaddy_dns(domain_id, params)
83
+ Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/dns/godaddy", body: params)
84
+ end
85
+
86
+ # Apply DNS records via the Namecheap API (existing records preserved),
87
+ # then auto-verify. POST /domains/:id/dns/namecheap
88
+ # params: { apiUser: "...", apiKey: "...", userName: "..." } — camelCase,
89
+ # the same spelling every SendPing SDK uses. `api_user` and `api_key`
90
+ # are accepted aliases, and the optional username may also be spelled
91
+ # `username`, but there is NO `user_name` alias: the API ignores that key,
92
+ # so a username sent under it is silently dropped.
93
+ def apply_namecheap_dns(domain_id, params)
94
+ Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/dns/namecheap", body: params)
95
+ end
96
+
97
+ # DELETE /domains/:id
98
+ def delete(domain_id)
99
+ Client.request(:delete, "/domains/#{Client.path_escape(domain_id)}")
100
+ end
101
+ end
102
+ end
103
+ end