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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +469 -0
- data/lib/sendping/api_keys.rb +19 -0
- data/lib/sendping/audiences.rb +39 -0
- data/lib/sendping/automations.rb +100 -0
- data/lib/sendping/campaigns.rb +81 -0
- data/lib/sendping/client.rb +265 -0
- data/lib/sendping/contact_properties.rb +33 -0
- data/lib/sendping/contacts.rb +188 -0
- data/lib/sendping/domains.rb +103 -0
- data/lib/sendping/emails.rb +165 -0
- data/lib/sendping/error.rb +109 -0
- data/lib/sendping/events.rb +53 -0
- data/lib/sendping/logs.rb +24 -0
- data/lib/sendping/polls.rb +18 -0
- data/lib/sendping/segments.rb +52 -0
- data/lib/sendping/templates.rb +42 -0
- data/lib/sendping/topics.rb +38 -0
- data/lib/sendping/version.rb +5 -0
- data/lib/sendping/webhooks.rb +265 -0
- data/lib/sendping.rb +81 -0
- metadata +83 -0
|
@@ -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
|