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,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module SendPing
|
|
6
|
+
module Webhooks
|
|
7
|
+
class << self
|
|
8
|
+
# Create a webhook. The plaintext signing secret is returned ONCE, only here.
|
|
9
|
+
# POST /webhooks — params: { endpoint:, events: [...], secret: }
|
|
10
|
+
def create(params)
|
|
11
|
+
Client.request(:post, "/webhooks", body: params)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# GET /webhooks/:id
|
|
15
|
+
def get(webhook_id)
|
|
16
|
+
Client.request(:get, "/webhooks/#{Client.path_escape(webhook_id)}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# GET /webhooks
|
|
20
|
+
def list(params = {})
|
|
21
|
+
Client.request(:get, "/webhooks", query: Client.pagination(params))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# PATCH /webhooks/:id — params: { endpoint:, events:, status: }
|
|
25
|
+
def update(webhook_id, params)
|
|
26
|
+
Client.request(:patch, "/webhooks/#{Client.path_escape(webhook_id)}", body: params)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Rotate the signing secret; the new plaintext secret is returned once and
|
|
30
|
+
# the old one stops verifying immediately. POST /webhooks/:id/rotate
|
|
31
|
+
def rotate(webhook_id)
|
|
32
|
+
Client.request(:post, "/webhooks/#{Client.path_escape(webhook_id)}/rotate")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Send a synchronous test delivery and return the endpoint's live result.
|
|
36
|
+
# POST /webhooks/:id/test
|
|
37
|
+
#
|
|
38
|
+
# A FAILED delivery is still HTTP 200, so this does NOT raise when your
|
|
39
|
+
# endpoint rejects the test. The outcome is the "ok" key:
|
|
40
|
+
#
|
|
41
|
+
# { "object" => "webhook_test", "id" => "<id>",
|
|
42
|
+
# "ok" => true, "status" => 200 } # endpoint accepted it
|
|
43
|
+
# { "object" => "webhook_test", "id" => "<id>",
|
|
44
|
+
# "ok" => false, "error" => "lookup_failed" } # it did not
|
|
45
|
+
#
|
|
46
|
+
# result = SendPing::Webhooks.test(id)
|
|
47
|
+
# warn "test delivery failed: #{result['error']}" unless result["ok"]
|
|
48
|
+
#
|
|
49
|
+
# "status" is your endpoint's HTTP status when it responded at all;
|
|
50
|
+
# "error" says why the delivery failed (e.g. "lookup_failed",
|
|
51
|
+
# "webhook missing or disabled"). It is a single attempt — no retries
|
|
52
|
+
# are scheduled.
|
|
53
|
+
def test(webhook_id)
|
|
54
|
+
Client.request(:post, "/webhooks/#{Client.path_escape(webhook_id)}/test")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# DELETE /webhooks/:id
|
|
58
|
+
def delete(webhook_id)
|
|
59
|
+
Client.request(:delete, "/webhooks/#{Client.path_escape(webhook_id)}")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Verify a webhook delivery's Svix-style signature against your endpoint's
|
|
63
|
+
# signing secret. Pure local computation (OpenSSL HMAC-SHA256) — no HTTP.
|
|
64
|
+
#
|
|
65
|
+
# `payload` MUST be the exact raw request body string (do not re-serialize
|
|
66
|
+
# parsed JSON). `headers` may be a Hash OR any pair-yielding header
|
|
67
|
+
# container (e.g. Rails' `request.headers`), carrying svix-id /
|
|
68
|
+
# svix-timestamp / svix-signature (read case-insensitively, rack
|
|
69
|
+
# `HTTP_SVIX_ID` spellings included; array values use the first element).
|
|
70
|
+
# The signature header may carry multiple space-separated `v1,<base64>`
|
|
71
|
+
# entries — any one match makes the delivery valid.
|
|
72
|
+
#
|
|
73
|
+
# Returns { valid: true } or { valid: false, reason: "..." }.
|
|
74
|
+
#
|
|
75
|
+
# # Rails: pass request.headers straight through — NOT .to_h, which
|
|
76
|
+
# # hands you the rack env spellings instead of the header names.
|
|
77
|
+
# result = SendPing::Webhooks.verify(request.raw_post, request.headers, secret)
|
|
78
|
+
# head :unauthorized unless result[:valid]
|
|
79
|
+
def verify(payload, headers, secret, tolerance: 300)
|
|
80
|
+
id = read_header(headers, "svix-id")
|
|
81
|
+
timestamp = read_header(headers, "svix-timestamp")
|
|
82
|
+
sig_header = read_header(headers, "svix-signature")
|
|
83
|
+
return { valid: false, reason: "missing_headers" } if id.nil? || timestamp.nil? || sig_header.nil?
|
|
84
|
+
return { valid: false, reason: "missing_secret" } if secret.nil? || secret.to_s.empty?
|
|
85
|
+
|
|
86
|
+
# Optional timestamp freshness check (default 5 minutes; 0 disables).
|
|
87
|
+
if tolerance && tolerance.positive?
|
|
88
|
+
ts = begin
|
|
89
|
+
Integer(timestamp, 10)
|
|
90
|
+
rescue ArgumentError, TypeError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
return { valid: false, reason: "invalid_timestamp" } if ts.nil?
|
|
94
|
+
return { valid: false, reason: "timestamp_out_of_tolerance" } if (Time.now.to_i - ts).abs > tolerance
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
signed = "#{id}.#{timestamp}.#{payload}"
|
|
98
|
+
digest = OpenSSL::HMAC.digest("SHA256", secret_to_key(secret), signed)
|
|
99
|
+
expected = [digest].pack("m0") # strict base64, no newline
|
|
100
|
+
|
|
101
|
+
sig_header.split(" ").each do |part|
|
|
102
|
+
part = part.strip
|
|
103
|
+
next if part.empty?
|
|
104
|
+
|
|
105
|
+
sig = part.start_with?("v1,") ? part[3..] : part
|
|
106
|
+
return { valid: true } if secure_compare(sig, expected)
|
|
107
|
+
end
|
|
108
|
+
{ valid: false, reason: "no_match" }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# Case-insensitively read one header value (first element if an array).
|
|
114
|
+
#
|
|
115
|
+
# Accepts a Hash AND any pair-yielding container, because Rails'
|
|
116
|
+
# `request.headers` is an ActionDispatch::Http::Headers — Enumerable, not
|
|
117
|
+
# a Hash — and the old `is_a?(Hash)` guard rejected it before any lookup,
|
|
118
|
+
# so the most natural call answered `missing_headers` for every genuinely
|
|
119
|
+
# signed delivery. Rack/WSGI spellings (`HTTP_SVIX_ID`) are matched too,
|
|
120
|
+
# since ActionDispatch's #each delegates to the raw rack env: that is what
|
|
121
|
+
# `request.headers.to_h` and a bare rack `env` actually contain, and
|
|
122
|
+
# downcased `http_svix_id` never equals `svix-id`.
|
|
123
|
+
#
|
|
124
|
+
# Enumerating is guarded: an exotic container whose #each demands a block
|
|
125
|
+
# would otherwise raise LocalJumpError out of `verify`, turning a 401 into
|
|
126
|
+
# a 500 inside a webhook controller. Caller-supplied input must never
|
|
127
|
+
# raise here — an unreadable container is just `missing_headers`.
|
|
128
|
+
def read_header(headers, name)
|
|
129
|
+
return nil if headers.nil?
|
|
130
|
+
|
|
131
|
+
lower = name.downcase
|
|
132
|
+
rack = "http_#{lower.tr('-', '_')}"
|
|
133
|
+
pairs =
|
|
134
|
+
begin
|
|
135
|
+
if headers.respond_to?(:each_pair) then headers.each_pair.to_a
|
|
136
|
+
elsif headers.respond_to?(:each) then headers.each.to_a
|
|
137
|
+
else return nil
|
|
138
|
+
end
|
|
139
|
+
rescue StandardError
|
|
140
|
+
return nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
pairs.each do |k, v|
|
|
144
|
+
key = k.to_s.downcase
|
|
145
|
+
next unless key == lower || key == rack
|
|
146
|
+
|
|
147
|
+
return v.is_a?(Array) ? v.first : v
|
|
148
|
+
end
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Derive the HMAC key from a `whsec_`-prefixed secret the way the SIGNER
|
|
153
|
+
# does, byte for byte. The signer is Node: base64-decode the suffix with
|
|
154
|
+
# `Buffer.from(suffix, 'base64')` and, when that yields ZERO bytes, fall
|
|
155
|
+
# back to the UTF-8 bytes of the WHOLE secret — `whsec_` prefix INCLUDED
|
|
156
|
+
# (sendping_webapp/lib/crypto.ts secretToKey). A secret without the
|
|
157
|
+
# prefix is used as raw UTF-8 bytes.
|
|
158
|
+
#
|
|
159
|
+
# The zero-byte fallback is not a curiosity: POST /webhooks stores
|
|
160
|
+
# `secret` verbatim with no shape validation, so "whsec_", "whsec_=",
|
|
161
|
+
# "whsec_!!!!" and "whsec_=YWJj" are all secrets a customer can really
|
|
162
|
+
# create, and each one keys the HMAC with its own literal text. Keying
|
|
163
|
+
# with an empty string instead — the obvious reading of "decode, then
|
|
164
|
+
# use the result" — costs the whole endpoint silently, because a key
|
|
165
|
+
# that differs from the signer's does not fail loudly: verify answers
|
|
166
|
+
# `no_match` and a correctly configured endpoint treats every genuine
|
|
167
|
+
# delivery as forged.
|
|
168
|
+
def secret_to_key(secret)
|
|
169
|
+
s = secret.to_s
|
|
170
|
+
return s unless s.start_with?("whsec_")
|
|
171
|
+
|
|
172
|
+
decoded = node_base64_decode(s["whsec_".length..])
|
|
173
|
+
decoded.empty? ? s : decoded
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Node's `Buffer.from(str, 'base64')`, reproduced. Every rule here was
|
|
177
|
+
# read off Node itself, NOT off a base64 RFC — following the RFC is
|
|
178
|
+
# precisely how this shipped broken twice — and each one costs a real
|
|
179
|
+
# key when Ruby's own decoder is trusted instead:
|
|
180
|
+
#
|
|
181
|
+
# * "=" TERMINATES the input. Everything from the first one onward is
|
|
182
|
+
# DISCARDED; it is not "padding to be stripped". "YWJj====ZA" is
|
|
183
|
+
# "abc", NOT "abcd", and a leading "=" leaves nothing at all (so the
|
|
184
|
+
# caller's raw fallback takes over). `unpack1("m")` decodes straight
|
|
185
|
+
# past an interior "=", deriving a LONGER key than the signer's.
|
|
186
|
+
# * "-" and "_" are the URL-safe spellings of "+" and "/" and must be
|
|
187
|
+
# TRANSLATED. `unpack1("m")` silently DROPS them, shortening the key.
|
|
188
|
+
# * Any other out-of-alphabet byte is SKIPPED, never fatal: whitespace,
|
|
189
|
+
# punctuation and non-ASCII are ignored, so "YW!Jj" is "abc".
|
|
190
|
+
# * A trailing group of ONE character carries no whole byte, so it is
|
|
191
|
+
# dropped here rather than left to `unpack1`'s discretion (2 chars ->
|
|
192
|
+
# 1 byte, 3 -> 2, 4 -> 3).
|
|
193
|
+
# * The unit Node indexes the alphabet with is the LOW 8 BITS OF EACH
|
|
194
|
+
# UTF-16 CODE UNIT, applied FIRST — see `utf16_low_bytes` below.
|
|
195
|
+
#
|
|
196
|
+
# Bytes, not characters, from the mask onward: a caller's secret need not
|
|
197
|
+
# be valid UTF-8, and an Encoding::CompatibilityError escaping this method
|
|
198
|
+
# would turn a webhook controller's 401 into a 500.
|
|
199
|
+
def node_base64_decode(suffix)
|
|
200
|
+
# Rule 5 runs BEFORE the "=" split, not after: U+013D masks to 0x3D and
|
|
201
|
+
# must TERMINATE the input like a literal "=" — splitting on the
|
|
202
|
+
# unmasked text would decode straight past it and derive a longer key.
|
|
203
|
+
s = utf16_low_bytes(suffix)
|
|
204
|
+
terminator = s.index("=")
|
|
205
|
+
s = s[0, terminator] if terminator
|
|
206
|
+
|
|
207
|
+
chars = s.tr("-_", "+/").gsub(%r{[^A-Za-z0-9+/]}n, "")
|
|
208
|
+
chars = chars[0, chars.bytesize - 1] if (chars.bytesize % 4) == 1
|
|
209
|
+
return "" if chars.empty?
|
|
210
|
+
|
|
211
|
+
chars.unpack1("m") || ""
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Rule 5, the one no SDK had: Node masks every UTF-16 CODE UNIT with 0xFF
|
|
215
|
+
# before the base64 table lookup, so the alphabet is indexed by a code
|
|
216
|
+
# unit's low byte, NOT by the codepoint and NOT by the UTF-8 bytes.
|
|
217
|
+
#
|
|
218
|
+
# Ruby strings are UTF-8, so the code units have to be materialised first:
|
|
219
|
+
# "Ł" (U+0141) is two UTF-8 bytes but ONE code unit masking to 0x41 "A",
|
|
220
|
+
# and "𝑁" (U+1D441) is ASTRAL — its four UTF-8 bytes are Node's TWO
|
|
221
|
+
# surrogate halves 0xD835/0xDC41, masking to 0x35 "5" and 0x41 "A". Taking
|
|
222
|
+
# UTF-8 bytes instead feeds the decoder continuation bytes that are all
|
|
223
|
+
# out-of-alphabet, silently shortening the key.
|
|
224
|
+
#
|
|
225
|
+
# Every codepoint below 0x100 masks to itself and every one at or above it
|
|
226
|
+
# is a different character entirely, so nothing under 0x100 can expose
|
|
227
|
+
# this — which is exactly why it survived a 31-vector corpus and a
|
|
228
|
+
# 2000-case ASCII fuzz, and why the cost lands only on the customer whose
|
|
229
|
+
# secret happens to carry a "Ł", a fullwidth letter or an emoji: their
|
|
230
|
+
# endpoint answers `no_match` to every genuine delivery.
|
|
231
|
+
#
|
|
232
|
+
# Undecodable input never raises out of here: invalid bytes become U+FFFD,
|
|
233
|
+
# whose low byte 0xFD is out of alphabet and therefore skipped — the same
|
|
234
|
+
# answer the old byte-wise reader gave — and a total conversion failure
|
|
235
|
+
# falls back to the raw bytes rather than 500-ing a webhook controller.
|
|
236
|
+
def utf16_low_bytes(str)
|
|
237
|
+
s = str.to_s
|
|
238
|
+
s = s.dup.force_encoding(Encoding::UTF_8) if s.encoding == Encoding::BINARY
|
|
239
|
+
s.encode(Encoding::UTF_16LE, invalid: :replace, undef: :replace)
|
|
240
|
+
.b.unpack("v*").map { |unit| unit & 0xFF }.pack("C*")
|
|
241
|
+
rescue StandardError
|
|
242
|
+
# Unreachable in practice: invalid:/undef: :replace make the encode total
|
|
243
|
+
# for any String. Kept so a webhook controller cannot 500 on a pathological
|
|
244
|
+
# input -- but it must NOT return str.b. Those are the raw UTF-8 bytes,
|
|
245
|
+
# which is exactly the pre-5.0.1 behaviour this method exists to replace,
|
|
246
|
+
# and returning them would silently derive a WRONG key that verifies
|
|
247
|
+
# nothing. Empty routes to the documented whole-secret fallback instead:
|
|
248
|
+
# still a mismatch, but a defined one rather than a reinstated bug.
|
|
249
|
+
""
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Constant-time compare of two signature strings.
|
|
253
|
+
def secure_compare(a, b)
|
|
254
|
+
a = a.to_s
|
|
255
|
+
b = b.to_s
|
|
256
|
+
return false unless a.bytesize == b.bytesize
|
|
257
|
+
|
|
258
|
+
l = a.unpack("C*")
|
|
259
|
+
res = 0
|
|
260
|
+
b.each_byte.with_index { |byte, i| res |= byte ^ l[i] }
|
|
261
|
+
res.zero?
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
data/lib/sendping.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sendping/version"
|
|
4
|
+
require "sendping/error"
|
|
5
|
+
require "sendping/client"
|
|
6
|
+
require "sendping/emails"
|
|
7
|
+
require "sendping/domains"
|
|
8
|
+
require "sendping/audiences"
|
|
9
|
+
require "sendping/contacts"
|
|
10
|
+
require "sendping/contact_properties"
|
|
11
|
+
require "sendping/segments"
|
|
12
|
+
require "sendping/topics"
|
|
13
|
+
require "sendping/campaigns"
|
|
14
|
+
require "sendping/templates"
|
|
15
|
+
require "sendping/automations"
|
|
16
|
+
require "sendping/webhooks"
|
|
17
|
+
require "sendping/events"
|
|
18
|
+
require "sendping/api_keys"
|
|
19
|
+
require "sendping/logs"
|
|
20
|
+
require "sendping/polls"
|
|
21
|
+
|
|
22
|
+
# The SendPing API client.
|
|
23
|
+
#
|
|
24
|
+
# SendPing.api_key = "mb_xxxxxxxxx"
|
|
25
|
+
#
|
|
26
|
+
# sent = SendPing::Emails.send({
|
|
27
|
+
# from: "Acme <hello@yourdomain.com>",
|
|
28
|
+
# to: ["delivered@test.sendping.co"],
|
|
29
|
+
# subject: "Hello from SendPing",
|
|
30
|
+
# html: "<p>Your first email</p>"
|
|
31
|
+
# })
|
|
32
|
+
# sent["id"] # => "..."
|
|
33
|
+
#
|
|
34
|
+
# `delivered@test.sendping.co` is the mailbox simulator: the send is accepted and
|
|
35
|
+
# produces a real email object without reaching a provider. Swap in a real
|
|
36
|
+
# recipient when you go live — an address on a reserved documentation domain
|
|
37
|
+
# (example.com, .test, .invalid) is refused with 422 `reserved_recipient`.
|
|
38
|
+
module SendPing
|
|
39
|
+
DEFAULT_BASE_URL = "https://www.sendping.co/api"
|
|
40
|
+
|
|
41
|
+
# Per-request network timeout, in seconds, applied to both the connect
|
|
42
|
+
# (open) and read phases. 0 or nil means "no timeout".
|
|
43
|
+
DEFAULT_TIMEOUT = 30
|
|
44
|
+
|
|
45
|
+
# How many times a retryable response (HTTP 429/503) is retried before
|
|
46
|
+
# giving up. 0 disables retries (a single attempt).
|
|
47
|
+
DEFAULT_MAX_RETRIES = 2
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# Your API key, e.g. "mb_xxxxxxxxx". Required before any call.
|
|
51
|
+
attr_accessor :api_key
|
|
52
|
+
|
|
53
|
+
# Override the API host (defaults to https://www.sendping.co/api).
|
|
54
|
+
attr_writer :base_url
|
|
55
|
+
|
|
56
|
+
# Per-request timeout in seconds (default 30). Set 0 (or nil) for none.
|
|
57
|
+
attr_writer :timeout
|
|
58
|
+
|
|
59
|
+
# Max automatic retries on HTTP 429/503 (default 2, i.e. up to 3 tries).
|
|
60
|
+
attr_writer :max_retries
|
|
61
|
+
|
|
62
|
+
def base_url
|
|
63
|
+
@base_url || DEFAULT_BASE_URL
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def timeout
|
|
67
|
+
@timeout.nil? ? DEFAULT_TIMEOUT : @timeout
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def max_retries
|
|
71
|
+
@max_retries.nil? ? DEFAULT_MAX_RETRIES : @max_retries
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# SendPing.configure do |config|
|
|
75
|
+
# config.api_key = ENV["SENDPING_API_KEY"]
|
|
76
|
+
# end
|
|
77
|
+
def configure
|
|
78
|
+
yield self
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sendping
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- SendPing
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-28 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
description: 'Send transactional and marketing email from your own verified domain:
|
|
28
|
+
emails, domains, contacts, segments, campaigns, templates, automations, webhooks,
|
|
29
|
+
events and more. Zero runtime dependencies (Net::HTTP only).'
|
|
30
|
+
email:
|
|
31
|
+
- support@sendping.co
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- LICENSE
|
|
37
|
+
- README.md
|
|
38
|
+
- lib/sendping.rb
|
|
39
|
+
- lib/sendping/api_keys.rb
|
|
40
|
+
- lib/sendping/audiences.rb
|
|
41
|
+
- lib/sendping/automations.rb
|
|
42
|
+
- lib/sendping/campaigns.rb
|
|
43
|
+
- lib/sendping/client.rb
|
|
44
|
+
- lib/sendping/contact_properties.rb
|
|
45
|
+
- lib/sendping/contacts.rb
|
|
46
|
+
- lib/sendping/domains.rb
|
|
47
|
+
- lib/sendping/emails.rb
|
|
48
|
+
- lib/sendping/error.rb
|
|
49
|
+
- lib/sendping/events.rb
|
|
50
|
+
- lib/sendping/logs.rb
|
|
51
|
+
- lib/sendping/polls.rb
|
|
52
|
+
- lib/sendping/segments.rb
|
|
53
|
+
- lib/sendping/templates.rb
|
|
54
|
+
- lib/sendping/topics.rb
|
|
55
|
+
- lib/sendping/version.rb
|
|
56
|
+
- lib/sendping/webhooks.rb
|
|
57
|
+
homepage: https://www.sendping.co
|
|
58
|
+
licenses:
|
|
59
|
+
- MIT
|
|
60
|
+
metadata:
|
|
61
|
+
homepage_uri: https://www.sendping.co
|
|
62
|
+
documentation_uri: https://www.sendping.co/docs
|
|
63
|
+
rubygems_mfa_required: 'true'
|
|
64
|
+
post_install_message:
|
|
65
|
+
rdoc_options: []
|
|
66
|
+
require_paths:
|
|
67
|
+
- lib
|
|
68
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '2.7'
|
|
73
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '0'
|
|
78
|
+
requirements: []
|
|
79
|
+
rubygems_version: 3.5.22
|
|
80
|
+
signing_key:
|
|
81
|
+
specification_version: 4
|
|
82
|
+
summary: Official Ruby SDK for the SendPing email API
|
|
83
|
+
test_files: []
|