anypost 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 +352 -0
- data/lib/anypost/client.rb +72 -0
- data/lib/anypost/errors.rb +203 -0
- data/lib/anypost/http_client.rb +149 -0
- data/lib/anypost/page.rb +55 -0
- data/lib/anypost/resources/api_keys.rb +38 -0
- data/lib/anypost/resources/base.rb +36 -0
- data/lib/anypost/resources/domains.rb +44 -0
- data/lib/anypost/resources/email.rb +48 -0
- data/lib/anypost/resources/events.rb +34 -0
- data/lib/anypost/resources/identity.rb +13 -0
- data/lib/anypost/resources/suppressions.rb +55 -0
- data/lib/anypost/resources/templates.rb +62 -0
- data/lib/anypost/resources/webhooks.rb +51 -0
- data/lib/anypost/response.rb +67 -0
- data/lib/anypost/version.rb +7 -0
- data/lib/anypost/webhook_signature.rb +114 -0
- data/lib/anypost.rb +22 -0
- metadata +81 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Anypost
|
|
7
|
+
# Raised when a webhook delivery's signature cannot be verified.
|
|
8
|
+
class WebhookVerificationError < StandardError
|
|
9
|
+
# The machine-readable reason. Branch on this rather than the message.
|
|
10
|
+
#
|
|
11
|
+
# One of: :malformed_header, :no_timestamp, :no_signatures,
|
|
12
|
+
# :timestamp_out_of_tolerance, :no_match.
|
|
13
|
+
#
|
|
14
|
+
# @return [Symbol]
|
|
15
|
+
attr_reader :reason
|
|
16
|
+
|
|
17
|
+
def initialize(message, reason)
|
|
18
|
+
super(message)
|
|
19
|
+
@reason = reason
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Verify the signature on an Anypost webhook delivery.
|
|
24
|
+
module WebhookSignature
|
|
25
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Verify an Anypost webhook signature.
|
|
30
|
+
#
|
|
31
|
+
# Pass the **raw** request body (the exact bytes received, before JSON
|
|
32
|
+
# parsing), the `Anypost-Signature` header value, and the webhook's signing
|
|
33
|
+
# secret. Returns nil on success; raises {WebhookVerificationError} otherwise.
|
|
34
|
+
#
|
|
35
|
+
# The header may carry more than one `v1=` component during a secret
|
|
36
|
+
# rotation; a match on any one passes, so deliveries keep verifying across a
|
|
37
|
+
# rotation. Set `tolerance_seconds:` to 0 to disable the freshness check.
|
|
38
|
+
def verify(payload, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil)
|
|
39
|
+
timestamp, signatures = parse_header(signature_header)
|
|
40
|
+
|
|
41
|
+
if tolerance_seconds.positive?
|
|
42
|
+
current = now || Time.now.to_i
|
|
43
|
+
if current - timestamp > tolerance_seconds
|
|
44
|
+
raise WebhookVerificationError.new(
|
|
45
|
+
"Timestamp #{timestamp} is older than the #{tolerance_seconds}s tolerance.",
|
|
46
|
+
:timestamp_out_of_tolerance
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
|
|
52
|
+
|
|
53
|
+
# Constant-time over every candidate: accumulate without early exit.
|
|
54
|
+
matched = false
|
|
55
|
+
signatures.each { |candidate| matched = true if secure_compare(candidate, expected) }
|
|
56
|
+
|
|
57
|
+
unless matched
|
|
58
|
+
raise WebhookVerificationError.new(
|
|
59
|
+
"No signature in the header matched the computed signature.",
|
|
60
|
+
:no_match
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Verify a delivery and return its parsed body as a {Response}.
|
|
68
|
+
#
|
|
69
|
+
# A thin wrapper over {.verify} that parses the JSON only after the signature
|
|
70
|
+
# checks out.
|
|
71
|
+
def unwrap(payload, signature_header, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil)
|
|
72
|
+
verify(payload, signature_header, secret, tolerance_seconds: tolerance_seconds, now: now)
|
|
73
|
+
decoded = JSON.parse(payload)
|
|
74
|
+
Response.new(decoded.is_a?(Hash) ? decoded : {})
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse_header(header)
|
|
78
|
+
if header.nil? || header.empty?
|
|
79
|
+
raise WebhookVerificationError.new("The Anypost-Signature header is empty.", :malformed_header)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
timestamp = nil
|
|
83
|
+
signatures = []
|
|
84
|
+
|
|
85
|
+
header.split(",").each do |part|
|
|
86
|
+
key, separator, value = part.partition("=")
|
|
87
|
+
next if separator.empty?
|
|
88
|
+
|
|
89
|
+
key = key.strip
|
|
90
|
+
value = value.strip
|
|
91
|
+
if key == "t"
|
|
92
|
+
timestamp = value.to_i if /\A\d+\z/.match?(value)
|
|
93
|
+
elsif key == "v1"
|
|
94
|
+
signatures << value
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if timestamp.nil?
|
|
99
|
+
raise WebhookVerificationError.new("The Anypost-Signature header has no timestamp (t=).", :no_timestamp)
|
|
100
|
+
end
|
|
101
|
+
if signatures.empty?
|
|
102
|
+
raise WebhookVerificationError.new("The Anypost-Signature header has no v1= signature.", :no_signatures)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
[timestamp, signatures]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def secure_compare(left, right)
|
|
109
|
+
return false unless left.bytesize == right.bytesize
|
|
110
|
+
|
|
111
|
+
OpenSSL.fixed_length_secure_compare(left, right)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/anypost.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "anypost/version"
|
|
4
|
+
require_relative "anypost/errors"
|
|
5
|
+
require_relative "anypost/response"
|
|
6
|
+
require_relative "anypost/page"
|
|
7
|
+
require_relative "anypost/webhook_signature"
|
|
8
|
+
require_relative "anypost/http_client"
|
|
9
|
+
require_relative "anypost/resources/base"
|
|
10
|
+
require_relative "anypost/resources/email"
|
|
11
|
+
require_relative "anypost/resources/domains"
|
|
12
|
+
require_relative "anypost/resources/api_keys"
|
|
13
|
+
require_relative "anypost/resources/templates"
|
|
14
|
+
require_relative "anypost/resources/suppressions"
|
|
15
|
+
require_relative "anypost/resources/webhooks"
|
|
16
|
+
require_relative "anypost/resources/events"
|
|
17
|
+
require_relative "anypost/resources/identity"
|
|
18
|
+
require_relative "anypost/client"
|
|
19
|
+
|
|
20
|
+
# Official Ruby SDK for the Anypost email API.
|
|
21
|
+
module Anypost
|
|
22
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: anypost
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Anypost
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-09 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: faraday
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.0'
|
|
27
|
+
description: Send email, manage domains, templates, webhooks, and suppressions, and
|
|
28
|
+
read the event stream through the Anypost HTTP API.
|
|
29
|
+
email:
|
|
30
|
+
- support@anypost.com
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- lib/anypost.rb
|
|
38
|
+
- lib/anypost/client.rb
|
|
39
|
+
- lib/anypost/errors.rb
|
|
40
|
+
- lib/anypost/http_client.rb
|
|
41
|
+
- lib/anypost/page.rb
|
|
42
|
+
- lib/anypost/resources/api_keys.rb
|
|
43
|
+
- lib/anypost/resources/base.rb
|
|
44
|
+
- lib/anypost/resources/domains.rb
|
|
45
|
+
- lib/anypost/resources/email.rb
|
|
46
|
+
- lib/anypost/resources/events.rb
|
|
47
|
+
- lib/anypost/resources/identity.rb
|
|
48
|
+
- lib/anypost/resources/suppressions.rb
|
|
49
|
+
- lib/anypost/resources/templates.rb
|
|
50
|
+
- lib/anypost/resources/webhooks.rb
|
|
51
|
+
- lib/anypost/response.rb
|
|
52
|
+
- lib/anypost/version.rb
|
|
53
|
+
- lib/anypost/webhook_signature.rb
|
|
54
|
+
homepage: https://anypost.com
|
|
55
|
+
licenses:
|
|
56
|
+
- MIT
|
|
57
|
+
metadata:
|
|
58
|
+
homepage_uri: https://anypost.com
|
|
59
|
+
source_code_uri: https://github.com/anypost/anypost-ruby
|
|
60
|
+
bug_tracker_uri: https://github.com/anypost/anypost-ruby/issues
|
|
61
|
+
rubygems_mfa_required: 'true'
|
|
62
|
+
post_install_message:
|
|
63
|
+
rdoc_options: []
|
|
64
|
+
require_paths:
|
|
65
|
+
- lib
|
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '3.2'
|
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
requirements: []
|
|
77
|
+
rubygems_version: 3.5.22
|
|
78
|
+
signing_key:
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: Official Ruby SDK for the Anypost email API.
|
|
81
|
+
test_files: []
|