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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendClipper
4
+ # Kept equal to the release tag by CI.
5
+ VERSION = "0.1.0"
6
+ 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
@@ -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: []