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.
@@ -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: []