sendclipper 0.1.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/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +206 -0
- data/lib/sendclipper/client.rb +41 -0
- data/lib/sendclipper/errors.rb +77 -0
- data/lib/sendclipper/http_client.rb +259 -0
- data/lib/sendclipper/resources/audience.rb +105 -0
- data/lib/sendclipper/resources/base.rb +85 -0
- data/lib/sendclipper/resources/domains.rb +35 -0
- data/lib/sendclipper/resources/emails.rb +62 -0
- data/lib/sendclipper/resources/mailboxes.rb +63 -0
- data/lib/sendclipper/resources/sandboxes.rb +99 -0
- data/lib/sendclipper/resources/templates.rb +92 -0
- data/lib/sendclipper/resources/webhooks.rb +70 -0
- data/lib/sendclipper/resources/workspace.rb +22 -0
- data/lib/sendclipper/result.rb +50 -0
- data/lib/sendclipper/version.rb +6 -0
- data/lib/sendclipper/webhook.rb +114 -0
- data/lib/sendclipper.rb +30 -0
- metadata +66 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendClipper
|
|
4
|
+
# What an API refusal or a network failure looks like.
|
|
5
|
+
#
|
|
6
|
+
# +name+ is SendClipper's machine-readable code (+domain_not_verified+,
|
|
7
|
+
# +validation_failed+, +rate_limited+, +network_error+, ...), +message+ a
|
|
8
|
+
# sentence for a person, +status_code+ the HTTP status (+nil+ when no
|
|
9
|
+
# answer arrived) and +detail+ any code-specific facts.
|
|
10
|
+
ErrorResponse = Struct.new(:name, :message, :status_code, :detail, keyword_init: true) do
|
|
11
|
+
def to_s = "#{name}: #{message}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Every API method returns one. Exactly one of +data+ and +error+ is set.
|
|
15
|
+
# It destructures to +data, error+:
|
|
16
|
+
#
|
|
17
|
+
# data, error = client.emails.send(from: "...", to: "...", subject: "Hi", html: "<p>Hi</p>")
|
|
18
|
+
class Result
|
|
19
|
+
attr_reader :data, :error, :headers
|
|
20
|
+
|
|
21
|
+
def initialize(data, error, headers)
|
|
22
|
+
@data = data
|
|
23
|
+
@error = error
|
|
24
|
+
# Response headers with lower-case names, or nil when no answer arrived.
|
|
25
|
+
@headers = headers
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ok? = error.nil?
|
|
30
|
+
|
|
31
|
+
# +data+, or raise the error as a SendClipper::ApiError.
|
|
32
|
+
def unwrap
|
|
33
|
+
return data if error.nil?
|
|
34
|
+
|
|
35
|
+
raise ApiError.new(error.status_code || 0, error.name, error.message, error.detail, headers || {})
|
|
36
|
+
end
|
|
37
|
+
alias value! unwrap
|
|
38
|
+
|
|
39
|
+
def to_ary = [data, error]
|
|
40
|
+
|
|
41
|
+
def deconstruct = [data, error]
|
|
42
|
+
|
|
43
|
+
def deconstruct_keys(_keys) = { data: data, error: error, headers: headers }
|
|
44
|
+
|
|
45
|
+
def inspect = error ? "#<SendClipper::Result error=#{error.to_h}>" : "#<SendClipper::Result data=#{data.inspect}>"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# What a download returns.
|
|
49
|
+
DownloadedFile = Struct.new(:content, :content_type, :filename, keyword_init: true)
|
|
50
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module SendClipper
|
|
7
|
+
# SendClipper's documented tolerance: five minutes.
|
|
8
|
+
WEBHOOK_TOLERANCE_SECONDS = 300
|
|
9
|
+
SIGNATURE_HEADER = "x-sendclipper-signature-v2"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Verify a webhook delivery and return its parsed event (a Hash).
|
|
14
|
+
#
|
|
15
|
+
# +payload+ is the body EXACTLY as received (+request.raw_post+ in Rails,
|
|
16
|
+
# +request.body.read+ in Rack/Sinatra), never re-encoded JSON. +headers+ is
|
|
17
|
+
# the request's headers (Rails +request.headers+, a Rack env, a Hash) or the
|
|
18
|
+
# X-SendClipper-Signature-V2 value itself.
|
|
19
|
+
#
|
|
20
|
+
# SendClipper signs "#{timestamp}.#{raw_body}" with HMAC-SHA256 and sends
|
|
21
|
+
# X-SendClipper-Signature-V2: t=<seconds>,v1=<hex>[,v1=<hex>]. During a
|
|
22
|
+
# secret rotation there are two v1= values; any match is accepted.
|
|
23
|
+
#
|
|
24
|
+
# Raises SendClipper::WebhookVerificationError when any check fails.
|
|
25
|
+
def verify_webhook(payload, headers, secret, tolerance: WEBHOOK_TOLERANCE_SECONDS, now: nil)
|
|
26
|
+
raise WebhookVerificationError.new("missing_header", "No webhook signing secret was given.") if secret.to_s.empty?
|
|
27
|
+
unless payload.is_a?(String)
|
|
28
|
+
raise WebhookVerificationError.new("invalid_payload",
|
|
29
|
+
"payload must be the raw request body as a String, not parsed JSON. " \
|
|
30
|
+
"Use request.raw_post in Rails or request.body.read in Rack.")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
header = signature_header_of(headers)
|
|
34
|
+
raise WebhookVerificationError.new("missing_header", "The request carries no X-SendClipper-Signature-V2 header.") if header.to_s.empty?
|
|
35
|
+
|
|
36
|
+
timestamp, signatures = parse_signature_header(header)
|
|
37
|
+
if timestamp.nil? || signatures.empty?
|
|
38
|
+
raise WebhookVerificationError.new("malformed_header", "X-SendClipper-Signature-V2 has no t= or no v1= value.")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
drift = ((now || Time.now).to_i - timestamp).abs
|
|
42
|
+
if drift > tolerance
|
|
43
|
+
raise WebhookVerificationError.new("timestamp_out_of_tolerance",
|
|
44
|
+
"The delivery was signed #{drift} s from now, outside the #{tolerance} s tolerance.")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
expected = sign_payload(secret, timestamp, payload)
|
|
48
|
+
# ANY match, and every candidate is compared: a rotation sends two.
|
|
49
|
+
matched = signatures.map { |candidate| secure_compare(expected, candidate) }.any?
|
|
50
|
+
raise WebhookVerificationError.new("signature_mismatch", "The signature does not match this secret and body.") unless matched
|
|
51
|
+
|
|
52
|
+
event = begin
|
|
53
|
+
JSON.parse(payload.dup.force_encoding("UTF-8"))
|
|
54
|
+
rescue JSON::ParserError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
raise WebhookVerificationError.new("invalid_payload", "The signed body is not a JSON object.") unless event.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
event
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# hex(HMAC-SHA256(secret, "#{timestamp}.#{raw_body}")). Exported for tests of your own handler.
|
|
63
|
+
def sign_payload(secret, timestamp, raw_body)
|
|
64
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, "#{timestamp}.".b + raw_body.to_s.b)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Build a valid header for a body, e.g. to test your endpoint locally.
|
|
68
|
+
def webhook_signature_header(secret, raw_body, timestamp = Time.now.to_i)
|
|
69
|
+
"t=#{timestamp},v1=#{sign_payload(secret, timestamp, raw_body)}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# [timestamp, [signatures]] from t=<seconds>,v1=<hex>[,v1=<hex>].
|
|
73
|
+
def parse_signature_header(header)
|
|
74
|
+
timestamp = nil
|
|
75
|
+
signatures = []
|
|
76
|
+
header.to_s.split(",").each do |part|
|
|
77
|
+
key, eq, value = part.partition("=")
|
|
78
|
+
next if eq.empty?
|
|
79
|
+
|
|
80
|
+
key = key.strip
|
|
81
|
+
value = value.strip
|
|
82
|
+
if key == "t" && value.match?(/\A[0-9]+\z/)
|
|
83
|
+
timestamp = value.to_i
|
|
84
|
+
elsif key == "v1" && !value.empty?
|
|
85
|
+
signatures << value
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
[timestamp, signatures]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def signature_header_of(source)
|
|
92
|
+
return source if source.is_a?(String)
|
|
93
|
+
return nil if source.nil?
|
|
94
|
+
|
|
95
|
+
found = []
|
|
96
|
+
if source.respond_to?(:each_pair) || source.respond_to?(:each)
|
|
97
|
+
source.each do |name, value|
|
|
98
|
+
# Rack spells it HTTP_X_SENDCLIPPER_SIGNATURE_V2.
|
|
99
|
+
normalised = name.to_s.downcase.tr("_", "-")
|
|
100
|
+
next unless [SIGNATURE_HEADER, "http-#{SIGNATURE_HEADER}"].include?(normalised)
|
|
101
|
+
|
|
102
|
+
Array(value).each { |v| found << v.to_s }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
found.empty? ? nil : found.join(",")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def secure_compare(a, b)
|
|
109
|
+
return false unless a.bytesize == b.bytesize
|
|
110
|
+
|
|
111
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
112
|
+
end
|
|
113
|
+
private_class_method :signature_header_of, :secure_compare
|
|
114
|
+
end
|
data/lib/sendclipper.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The official Ruby SDK for the SendClipper email API.
|
|
4
|
+
#
|
|
5
|
+
# require "sendclipper"
|
|
6
|
+
#
|
|
7
|
+
# client = SendClipper::Client.new("sc_live_...")
|
|
8
|
+
# data, error = client.emails.send(
|
|
9
|
+
# from: "hello@send.acme.io",
|
|
10
|
+
# to: "ada@lumen.io",
|
|
11
|
+
# subject: "Your receipt",
|
|
12
|
+
# html: "<p>Thanks for your order.</p>"
|
|
13
|
+
# )
|
|
14
|
+
module SendClipper; end
|
|
15
|
+
|
|
16
|
+
require_relative "sendclipper/version"
|
|
17
|
+
require_relative "sendclipper/errors"
|
|
18
|
+
require_relative "sendclipper/result"
|
|
19
|
+
require_relative "sendclipper/http_client"
|
|
20
|
+
require_relative "sendclipper/webhook"
|
|
21
|
+
require_relative "sendclipper/resources/base"
|
|
22
|
+
require_relative "sendclipper/resources/emails"
|
|
23
|
+
require_relative "sendclipper/resources/domains"
|
|
24
|
+
require_relative "sendclipper/resources/templates"
|
|
25
|
+
require_relative "sendclipper/resources/sandboxes"
|
|
26
|
+
require_relative "sendclipper/resources/webhooks"
|
|
27
|
+
require_relative "sendclipper/resources/audience"
|
|
28
|
+
require_relative "sendclipper/resources/mailboxes"
|
|
29
|
+
require_relative "sendclipper/resources/workspace"
|
|
30
|
+
require_relative "sendclipper/client"
|
metadata
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sendclipper
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- SendClipper
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Send mail, and manage domains, templates, sandboxes, webhooks, contacts
|
|
13
|
+
and suppressions with the SendClipper email API. No runtime dependencies.
|
|
14
|
+
email:
|
|
15
|
+
- support@sendclipper.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- CHANGELOG.md
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/sendclipper.rb
|
|
24
|
+
- lib/sendclipper/client.rb
|
|
25
|
+
- lib/sendclipper/errors.rb
|
|
26
|
+
- lib/sendclipper/http_client.rb
|
|
27
|
+
- lib/sendclipper/resources/audience.rb
|
|
28
|
+
- lib/sendclipper/resources/base.rb
|
|
29
|
+
- lib/sendclipper/resources/domains.rb
|
|
30
|
+
- lib/sendclipper/resources/emails.rb
|
|
31
|
+
- lib/sendclipper/resources/mailboxes.rb
|
|
32
|
+
- lib/sendclipper/resources/sandboxes.rb
|
|
33
|
+
- lib/sendclipper/resources/templates.rb
|
|
34
|
+
- lib/sendclipper/resources/webhooks.rb
|
|
35
|
+
- lib/sendclipper/resources/workspace.rb
|
|
36
|
+
- lib/sendclipper/result.rb
|
|
37
|
+
- lib/sendclipper/version.rb
|
|
38
|
+
- lib/sendclipper/webhook.rb
|
|
39
|
+
homepage: https://sendclipper.com/docs#sdk-ruby
|
|
40
|
+
licenses:
|
|
41
|
+
- MIT
|
|
42
|
+
metadata:
|
|
43
|
+
homepage_uri: https://sendclipper.com/docs#sdk-ruby
|
|
44
|
+
source_code_uri: https://github.com/sendclipper/sendclipper-ruby
|
|
45
|
+
bug_tracker_uri: https://github.com/sendclipper/sendclipper-ruby/issues
|
|
46
|
+
changelog_uri: https://github.com/sendclipper/sendclipper-ruby/blob/main/CHANGELOG.md
|
|
47
|
+
documentation_uri: https://sendclipper.com/docs#sdk-ruby
|
|
48
|
+
rubygems_mfa_required: 'true'
|
|
49
|
+
rdoc_options: []
|
|
50
|
+
require_paths:
|
|
51
|
+
- lib
|
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '3.1'
|
|
57
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0'
|
|
62
|
+
requirements: []
|
|
63
|
+
rubygems_version: 3.6.9
|
|
64
|
+
specification_version: 4
|
|
65
|
+
summary: Official Ruby SDK for the SendClipper email API.
|
|
66
|
+
test_files: []
|