ravenrelay 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.
Files changed (4) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +29 -0
  3. data/lib/ravenrelay.rb +122 -0
  4. metadata +99 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9ed3b9550d082bbb323c241ce1e32cd48ee55b6e080f5b689b58aedf0297fe2c
4
+ data.tar.gz: d147f655e47e876dafdb462abee31ea34e7558a4a3a1388edb7c2826e68724b9
5
+ SHA512:
6
+ metadata.gz: afa9effda2080d0c7b9dabb211424161e3b03f542bf068d5e1cc798ab2116083d9a08a6a35dd02bb7898520d043df14976c8d4d5b45fcf5327cdfc2373924666
7
+ data.tar.gz: 07bc0a0412f6e5dcd98e28f3719bf359774278b1017ea68a3a99e452cc6046b53850a04c88fb659a3bc5276352dd15d8e7736345c9aec306d09ce196fc613d58
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # RavenRelay Ruby SDK
2
+
3
+ Ruby 3.1+. Source is implemented; RubyGems publication is separate. Build and install locally:
4
+
5
+ ```sh
6
+ cd sdks/ruby
7
+ gem build ravenrelay.gemspec
8
+ gem install ./ravenrelay-0.1.0.gem
9
+ ```
10
+
11
+ ```ruby
12
+ require 'ravenrelay'
13
+ client = RavenRelay::Client.new(api_key: ENV.fetch('RAVENRELAY_API_KEY'), base_url: ENV.fetch('RAVENRELAY_API_URL'))
14
+ result = client.emails.send({from: 'hello@example.com', to: 'user@example.com', subject: 'Welcome', text: 'Hello'}, idempotency_key: 'welcome-123')
15
+ email = client.emails.get(result['id'])
16
+ page = client.emails.list(status: 'sent', recipient: 'user@example.com')
17
+ ```
18
+
19
+ Saved templates use `template`/`variables` instead of subject/body. Responses are hashes with string keys, preserving API fields. `RavenRelay::APIError` exposes `status`/`code`. `TransportError` means acceptance may be uncertain; `ProtocolError` indicates an invalid successful response. No automatic retries; retry only with the same idempotency key and body. The ten-second default timeout covers the whole request. Redirects are refused. Default origin: `https://api.ravenrelay.com`; supply your deployed origin explicitly. Keep API keys server-side.
20
+
21
+ `RavenRelay.verify_webhook_signature(secret:, timestamp:, signature:, body:)` verifies original bytes with HMAC-SHA256, constant-time comparison, and a five-minute tolerance. Verify before parsing JSON and durably deduplicate event IDs. Optional `now` is Unix seconds for testing.
22
+
23
+ ```sh
24
+ python3 sdks/testing/contract_server.py ruby sdks/ruby/test/contract.rb
25
+ ```
26
+
27
+ Publish with `gem push ravenrelay-0.1.0.gem` after account/name and license review. No reuse license has been granted.
28
+
29
+ Security: remote origins must use HTTPS. Plain HTTP is accepted only for literal `localhost`, `127.0.0.1` and IPv6 loopback hosts. Credentials/query/fragment, whitespace and backslashes are rejected in API origins. Authentication/idempotency headers must be printable ASCII; `.` and `..` email IDs are rejected. Response bodies are limited to 16 MiB, valid UTF-8 and 64 JSON nesting levels. TLS certificate verification stays enabled. Use vendor-supported runtimes with current security patches; the compatibility floor does not promise that an old runtime is secure. See `docs/sdk-security-review.md` for dependency and release controls.
data/lib/ravenrelay.rb ADDED
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+ require 'net/http'
3
+ require 'json'
4
+ require 'openssl'
5
+ require 'uri'
6
+ require 'timeout'
7
+
8
+ module RavenRelay
9
+ VERSION = '0.1.0'
10
+ class APIError < StandardError
11
+ attr_reader :status, :code
12
+ def initialize(status, code, message)
13
+ super(message)
14
+ @status, @code = status, code
15
+ end
16
+ end
17
+ class TransportError < StandardError; end
18
+ class ProtocolError < StandardError; end
19
+
20
+ class Client
21
+ attr_reader :emails
22
+ def initialize(api_key:, base_url: 'https://api.ravenrelay.com', timeout: 10)
23
+ validate_header(api_key)
24
+ raise ArgumentError, 'Invalid base_url' if base_url.match?(/[\\\x00-\x20\x7f]/)
25
+ url = URI.parse(base_url)
26
+ unless %w[http https].include?(url.scheme) && url.host && !url.userinfo && !url.query && !url.fragment
27
+ raise ArgumentError, 'base_url must be an absolute HTTP(S) URL without credentials, query or fragment'
28
+ end
29
+ raise ArgumentError, 'Use HTTPS; HTTP is only allowed on literal loopback hosts' if url.scheme == 'http' && !%w[localhost 127.0.0.1 ::1].include?(url.hostname)
30
+ raise ArgumentError, 'timeout must be finite and positive' unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
31
+ @api_key, @base_url, @timeout = api_key, base_url.sub(%r{/+\z}, ''), timeout
32
+ @emails = Emails.new(self)
33
+ end
34
+
35
+ # Internal transport. No retries or redirect following, even on connection failures.
36
+ def request(method, path, body = nil, idempotency_key = nil)
37
+ url = URI.parse(@base_url + path)
38
+ request = (method == 'POST' ? Net::HTTP::Post : Net::HTTP::Get).new(url)
39
+ request['Authorization'] = "Bearer #{@api_key}"
40
+ request['Content-Type'] = 'application/json'
41
+ request['Accept-Encoding'] = 'identity'
42
+ unless idempotency_key.nil?
43
+ validate_header(idempotency_key)
44
+ request['Idempotency-Key'] = idempotency_key
45
+ end
46
+ request.body = JSON.generate(body) unless body.nil?
47
+ http = Net::HTTP.new(url.host, url.port, nil)
48
+ http.use_ssl = url.scheme == 'https'
49
+ http.open_timeout = http.read_timeout = http.write_timeout = @timeout
50
+ http.max_retries = 0
51
+ raw = +''.b
52
+ status = nil
53
+ expected_length = nil
54
+ begin
55
+ Timeout.timeout(@timeout) do
56
+ http.start do |connection|
57
+ connection.request(request) do |response|
58
+ status = response.code.to_i
59
+ length = response['Content-Length']
60
+ expected_length = length.to_i if length&.match?(/\A[0-9]+\z/)
61
+ response.read_body do |chunk|
62
+ raise ProtocolError, 'RavenRelay response exceeds 16 MiB' if raw.bytesize + chunk.bytesize > 16 * 1024 * 1024
63
+ raw << chunk
64
+ end
65
+ end
66
+ end
67
+ end
68
+ rescue Timeout::Error, IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, EOFError => error
69
+ raise TransportError, 'RavenRelay network request failed; acceptance may be uncertain', cause: error
70
+ end
71
+ raise TransportError, 'RavenRelay response ended before the declared body length' if expected_length && raw.bytesize < expected_length
72
+ raw.force_encoding(Encoding::UTF_8)
73
+ raise ProtocolError, 'Invalid UTF-8 response' unless raw.valid_encoding?
74
+ begin
75
+ parsed = JSON.parse(raw, create_additions: false, max_nesting: 64)
76
+ rescue JSON::ParserError, EncodingError
77
+ parsed = nil
78
+ end
79
+ unless (200...300).cover?(status)
80
+ error = parsed.is_a?(Hash) && parsed['error'].is_a?(Hash) ? parsed['error'] : {}
81
+ code = error['code'].is_a?(String) ? error['code'] : 'unknown_error'
82
+ message = error['message'].is_a?(String) ? error['message'] : 'Request failed'
83
+ raise APIError.new(status, code, message)
84
+ end
85
+ raise ProtocolError, 'RavenRelay response must be a JSON object' unless parsed.is_a?(Hash)
86
+ parsed
87
+ end
88
+
89
+ private
90
+ def validate_header(value)
91
+ raise ArgumentError, 'Header values must be nonempty without control characters' unless value.is_a?(String) && !value.empty? && !value.match?(/[^\x20-\x7e]/)
92
+ end
93
+ end
94
+
95
+ class Emails
96
+ def initialize(client)
97
+ @client = client
98
+ end
99
+ def send(email, idempotency_key: nil)
100
+ raise ArgumentError, 'email must be a Hash' unless email.is_a?(Hash)
101
+ @client.request('POST', '/v1/emails', email, idempotency_key)
102
+ end
103
+ def get(id)
104
+ raise ArgumentError, 'Email ID must be nonempty' unless id.is_a?(String) && !id.empty? && !%w[. ..].include?(id)
105
+ encoded = URI.encode_www_form_component(id).gsub('+', '%20')
106
+ @client.request('GET', '/v1/emails/' + encoded)
107
+ end
108
+ def list(status: nil, recipient: nil, cursor: nil)
109
+ query = URI.encode_www_form({ status: status, recipient: recipient, cursor: cursor }.reject { |_, v| v.nil? })
110
+ @client.request('GET', '/v1/emails' + (query.empty? ? '' : '?' + query))
111
+ end
112
+ end
113
+
114
+ # Use original request bytes before parsing JSON and durably deduplicate event IDs.
115
+ def self.verify_webhook_signature(secret:, timestamp:, signature:, body:, now: Time.now.to_f)
116
+ return false unless secret.is_a?(String) && !secret.empty? && timestamp.is_a?(String) && timestamp.match?(/\A[0-9]{1,12}\z/)
117
+ return false unless signature.is_a?(String) && signature.match?(/\Av1=[a-f0-9]{64}\z/)
118
+ return false unless now.is_a?(Numeric) && now.finite? && (now - timestamp.to_i).abs <= 300
119
+ expected = OpenSSL::HMAC.hexdigest('SHA256', secret, timestamp.b + '.'.b + body.b)
120
+ OpenSSL.fixed_length_secure_compare(expected, signature[3..])
121
+ end
122
+ end
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ravenrelay
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - RavenRelay
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: net-http
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.9.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.9.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: json
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: 2.19.9
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - '='
38
+ - !ruby/object:Gem::Version
39
+ version: 2.19.9
40
+ - !ruby/object:Gem::Dependency
41
+ name: openssl
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - '='
45
+ - !ruby/object:Gem::Version
46
+ version: 4.0.2
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - '='
52
+ - !ruby/object:Gem::Version
53
+ version: 4.0.2
54
+ - !ruby/object:Gem::Dependency
55
+ name: uri
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - '='
59
+ - !ruby/object:Gem::Version
60
+ version: 1.1.1
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - '='
66
+ - !ruby/object:Gem::Version
67
+ version: 1.1.1
68
+ description: Send and read RavenRelay email, use saved templates, and verify signed
69
+ webhooks.
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - README.md
75
+ - lib/ravenrelay.rb
76
+ homepage: https://ravenrelay.joshlab.site
77
+ licenses:
78
+ - Nonstandard
79
+ metadata:
80
+ allowed_push_host: https://rubygems.org
81
+ rubygems_mfa_required: 'true'
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '3.1'
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ requirements: []
96
+ rubygems_version: 3.6.9
97
+ specification_version: 4
98
+ summary: Server-side Ruby SDK for the RavenRelay email API
99
+ test_files: []