postscale 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 +224 -0
- data/lib/postscale/attachments.rb +76 -0
- data/lib/postscale/client.rb +84 -0
- data/lib/postscale/configuration.rb +40 -0
- data/lib/postscale/errors.rb +26 -0
- data/lib/postscale/http_client.rb +280 -0
- data/lib/postscale/resources/aliases.rb +27 -0
- data/lib/postscale/resources/dkim.rb +28 -0
- data/lib/postscale/resources/domains.rb +35 -0
- data/lib/postscale/resources/emails.rb +36 -0
- data/lib/postscale/resources/inbound.rb +22 -0
- data/lib/postscale/resources/resource.rb +35 -0
- data/lib/postscale/resources/stats.rb +28 -0
- data/lib/postscale/resources/suppressions.rb +36 -0
- data/lib/postscale/resources/templates.rb +31 -0
- data/lib/postscale/resources/trust.rb +15 -0
- data/lib/postscale/resources/usage.rb +11 -0
- data/lib/postscale/resources/warming.rb +33 -0
- data/lib/postscale/resources/webhooks.rb +35 -0
- data/lib/postscale/response.rb +36 -0
- data/lib/postscale/version.rb +5 -0
- data/lib/postscale/webhook_verification.rb +107 -0
- data/lib/postscale.rb +71 -0
- data/postscale.gemspec +29 -0
- metadata +75 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postscale
|
|
4
|
+
class Headers
|
|
5
|
+
attr_reader :headers, :request_id, :retry_after
|
|
6
|
+
|
|
7
|
+
def initialize(headers = {})
|
|
8
|
+
@headers = {}
|
|
9
|
+
headers.each { |key, value| @headers[key.to_s] = value }
|
|
10
|
+
@request_id = self["X-Request-ID"]
|
|
11
|
+
@retry_after = self["Retry-After"]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def [](name)
|
|
15
|
+
lookup = name.to_s.downcase
|
|
16
|
+
pair = @headers.find { |key, _| key.downcase == lookup }
|
|
17
|
+
pair && pair[1]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Result
|
|
22
|
+
attr_reader :data, :error, :headers
|
|
23
|
+
|
|
24
|
+
def initialize(data:, error:, headers:)
|
|
25
|
+
@data = data
|
|
26
|
+
@error = error
|
|
27
|
+
@headers = headers
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def success?
|
|
31
|
+
@error.nil?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Response = Result
|
|
36
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postscale
|
|
4
|
+
class WebhookVerificationResult
|
|
5
|
+
attr_reader :code, :message, :timestamp
|
|
6
|
+
|
|
7
|
+
def initialize(valid:, code: nil, message: nil, timestamp: nil)
|
|
8
|
+
@valid = valid
|
|
9
|
+
@code = code
|
|
10
|
+
@message = message
|
|
11
|
+
@timestamp = timestamp
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def valid?
|
|
15
|
+
@valid
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
alias valid valid?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module Webhook
|
|
22
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def verify_signature(body, header, secrets, now: Time.now.to_i, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS)
|
|
27
|
+
candidates = Array(secrets).compact.reject { |secret| secret.to_s.empty? }
|
|
28
|
+
return invalid("missing_secret", "At least one webhook secret is required.") if candidates.empty?
|
|
29
|
+
|
|
30
|
+
parsed = parse_signature_header(header)
|
|
31
|
+
return parsed if parsed.is_a?(WebhookVerificationResult)
|
|
32
|
+
|
|
33
|
+
timestamp, signatures = parsed
|
|
34
|
+
if (now.to_i - timestamp).abs > tolerance_seconds
|
|
35
|
+
return invalid(
|
|
36
|
+
"timestamp_outside_tolerance",
|
|
37
|
+
"Webhook signature timestamp is outside the allowed tolerance.",
|
|
38
|
+
timestamp
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
signed_payload = "#{timestamp}.#{body}"
|
|
43
|
+
candidates.each do |secret|
|
|
44
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, signed_payload)
|
|
45
|
+
signatures.each do |signature|
|
|
46
|
+
return WebhookVerificationResult.new(valid: true, timestamp: timestamp) if secure_compare(expected, signature)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
invalid("signature_mismatch", "Webhook signature does not match.", timestamp)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def sign_body(body, secret, timestamp: Time.now.to_i)
|
|
54
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, "#{timestamp}.#{body}")
|
|
55
|
+
"t=#{timestamp},v1=#{signature}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_signature_header(header)
|
|
59
|
+
return invalid("malformed_header", "Webhook signature header is empty.") if header.nil? || header.strip.empty?
|
|
60
|
+
|
|
61
|
+
timestamp_raw = nil
|
|
62
|
+
signatures = []
|
|
63
|
+
|
|
64
|
+
header.split(",").each do |part|
|
|
65
|
+
key, value = part.strip.split("=", 2)
|
|
66
|
+
return invalid("malformed_header", "Webhook signature header is malformed.") if key.nil? || key.empty? || value.nil? || value.empty?
|
|
67
|
+
|
|
68
|
+
if key == "v1"
|
|
69
|
+
signatures << value
|
|
70
|
+
elsif key == "t"
|
|
71
|
+
return invalid("malformed_header", "Webhook signature header has multiple timestamps.") unless timestamp_raw.nil?
|
|
72
|
+
|
|
73
|
+
timestamp_raw = value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return invalid("missing_timestamp", "Webhook signature header is missing t=.") if timestamp_raw.nil?
|
|
78
|
+
return invalid("missing_signature", "Webhook signature header is missing v1=.") if signatures.empty?
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
[Integer(timestamp_raw), signatures]
|
|
82
|
+
rescue ArgumentError
|
|
83
|
+
invalid("malformed_header", "Webhook signature timestamp is malformed.")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
private_class_method :parse_signature_header
|
|
87
|
+
|
|
88
|
+
def secure_compare(expected, actual)
|
|
89
|
+
return false unless expected.bytesize == actual.to_s.bytesize
|
|
90
|
+
|
|
91
|
+
result = 0
|
|
92
|
+
expected.bytes.zip(actual.to_s.bytes) { |left, right| result |= left ^ right }
|
|
93
|
+
result.zero?
|
|
94
|
+
end
|
|
95
|
+
private_class_method :secure_compare
|
|
96
|
+
|
|
97
|
+
def invalid(code, message, timestamp = nil)
|
|
98
|
+
WebhookVerificationResult.new(
|
|
99
|
+
valid: false,
|
|
100
|
+
code: code,
|
|
101
|
+
message: message,
|
|
102
|
+
timestamp: timestamp
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
private_class_method :invalid
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/postscale.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "time"
|
|
9
|
+
|
|
10
|
+
require_relative "postscale/version"
|
|
11
|
+
require_relative "postscale/errors"
|
|
12
|
+
require_relative "postscale/response"
|
|
13
|
+
require_relative "postscale/configuration"
|
|
14
|
+
require_relative "postscale/http_client"
|
|
15
|
+
require_relative "postscale/attachments"
|
|
16
|
+
require_relative "postscale/webhook_verification"
|
|
17
|
+
require_relative "postscale/client"
|
|
18
|
+
|
|
19
|
+
require_relative "postscale/resources/resource"
|
|
20
|
+
require_relative "postscale/resources/emails"
|
|
21
|
+
require_relative "postscale/resources/domains"
|
|
22
|
+
require_relative "postscale/resources/dkim"
|
|
23
|
+
require_relative "postscale/resources/aliases"
|
|
24
|
+
require_relative "postscale/resources/inbound"
|
|
25
|
+
require_relative "postscale/resources/stats"
|
|
26
|
+
require_relative "postscale/resources/warming"
|
|
27
|
+
require_relative "postscale/resources/suppressions"
|
|
28
|
+
require_relative "postscale/resources/webhooks"
|
|
29
|
+
require_relative "postscale/resources/templates"
|
|
30
|
+
require_relative "postscale/resources/usage"
|
|
31
|
+
require_relative "postscale/resources/trust"
|
|
32
|
+
|
|
33
|
+
module Postscale
|
|
34
|
+
class << self
|
|
35
|
+
def client
|
|
36
|
+
@client ||= Client.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def configure(**options)
|
|
40
|
+
@client = Client.new(**options)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def attachment_from_file(path, content_type = "application/octet-stream")
|
|
44
|
+
Attachments.from_file(path, content_type)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def attachment_from_bytes(filename, data, content_type = "application/octet-stream")
|
|
48
|
+
Attachments.from_bytes(filename, data, content_type)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def verify_webhook_signature(body, header, secrets, **options)
|
|
52
|
+
Webhook.verify_signature(body, header, secrets, **options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sign_webhook_body(body, secret, timestamp: Time.now.to_i)
|
|
56
|
+
Webhook.sign_body(body, secret, timestamp: timestamp)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def method_missing(method, *args, **kwargs, &block)
|
|
60
|
+
if client.respond_to?(method)
|
|
61
|
+
client.public_send(method, *args, **kwargs, &block)
|
|
62
|
+
else
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def respond_to_missing?(method, include_private = false)
|
|
68
|
+
client.respond_to?(method, include_private) || super
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/postscale.gemspec
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/postscale/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "postscale"
|
|
7
|
+
spec.version = Postscale::VERSION
|
|
8
|
+
spec.authors = ["Postscale"]
|
|
9
|
+
spec.email = ["support@postscale.io"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Official Ruby SDK for the Postscale email API"
|
|
12
|
+
spec.description = "Send transactional emails, manage domains, DKIM, templates, webhooks, and more with the Postscale API."
|
|
13
|
+
spec.homepage = "https://github.com/postscale/postscale-ruby"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
|
|
16
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
17
|
+
|
|
18
|
+
spec.metadata = {
|
|
19
|
+
"homepage_uri" => spec.homepage,
|
|
20
|
+
"source_code_uri" => "https://github.com/postscale/postscale-ruby",
|
|
21
|
+
"documentation_uri" => "https://docs.postscale.io/sdks/ruby",
|
|
22
|
+
"changelog_uri" => "https://github.com/postscale/postscale-ruby/blob/main/CHANGELOG.md",
|
|
23
|
+
"bug_tracker_uri" => "https://github.com/postscale/postscale-ruby/issues",
|
|
24
|
+
"rubygems_mfa_required" => "true"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
spec.files = Dir.glob("lib/**/*") + %w[LICENSE README.md postscale.gemspec]
|
|
28
|
+
spec.require_paths = ["lib"]
|
|
29
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: postscale
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Postscale
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-18 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Send transactional emails, manage domains, DKIM, templates, webhooks,
|
|
14
|
+
and more with the Postscale API.
|
|
15
|
+
email:
|
|
16
|
+
- support@postscale.io
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/postscale.rb
|
|
24
|
+
- lib/postscale/attachments.rb
|
|
25
|
+
- lib/postscale/client.rb
|
|
26
|
+
- lib/postscale/configuration.rb
|
|
27
|
+
- lib/postscale/errors.rb
|
|
28
|
+
- lib/postscale/http_client.rb
|
|
29
|
+
- lib/postscale/resources/aliases.rb
|
|
30
|
+
- lib/postscale/resources/dkim.rb
|
|
31
|
+
- lib/postscale/resources/domains.rb
|
|
32
|
+
- lib/postscale/resources/emails.rb
|
|
33
|
+
- lib/postscale/resources/inbound.rb
|
|
34
|
+
- lib/postscale/resources/resource.rb
|
|
35
|
+
- lib/postscale/resources/stats.rb
|
|
36
|
+
- lib/postscale/resources/suppressions.rb
|
|
37
|
+
- lib/postscale/resources/templates.rb
|
|
38
|
+
- lib/postscale/resources/trust.rb
|
|
39
|
+
- lib/postscale/resources/usage.rb
|
|
40
|
+
- lib/postscale/resources/warming.rb
|
|
41
|
+
- lib/postscale/resources/webhooks.rb
|
|
42
|
+
- lib/postscale/response.rb
|
|
43
|
+
- lib/postscale/version.rb
|
|
44
|
+
- lib/postscale/webhook_verification.rb
|
|
45
|
+
- postscale.gemspec
|
|
46
|
+
homepage: https://github.com/postscale/postscale-ruby
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata:
|
|
50
|
+
homepage_uri: https://github.com/postscale/postscale-ruby
|
|
51
|
+
source_code_uri: https://github.com/postscale/postscale-ruby
|
|
52
|
+
documentation_uri: https://docs.postscale.io/sdks/ruby
|
|
53
|
+
changelog_uri: https://github.com/postscale/postscale-ruby/blob/main/CHANGELOG.md
|
|
54
|
+
bug_tracker_uri: https://github.com/postscale/postscale-ruby/issues
|
|
55
|
+
rubygems_mfa_required: 'true'
|
|
56
|
+
post_install_message:
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: 3.0.0
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 3.0.3.1
|
|
72
|
+
signing_key:
|
|
73
|
+
specification_version: 4
|
|
74
|
+
summary: Official Ruby SDK for the Postscale email API
|
|
75
|
+
test_files: []
|