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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ VERSION = "1.0.0"
5
+ 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: []