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.
- checksums.yaml +7 -0
- data/README.md +29 -0
- data/lib/ravenrelay.rb +122 -0
- 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: []
|