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,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: []