ternwire 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/LICENSE +21 -0
- data/README.md +43 -0
- data/examples/send.rb +14 -0
- data/lib/ternwire/client.rb +167 -0
- data/lib/ternwire/version.rb +3 -0
- data/lib/ternwire/webhooks.rb +14 -0
- data/lib/ternwire.rb +3 -0
- metadata +48 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 10179adca67dec7ba097d6b2595055038f15fa27f65cf87ac5c37df34b3b13fe
|
|
4
|
+
data.tar.gz: e7b1c6feff097229fa8dfc5b3735ef82531fa75cb64a169930cbbfb23dadb258
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 478a9cb9bea050a2ecf89ee3009863c3ce36a7dcc2cf1b58a4ed72429bf7619c98d56f1e8174c22441cacaf7cac1cb9c02abe23ea6dfc1c63cb8fb3464c5810f
|
|
7
|
+
data.tar.gz: d3ed0c692a0df8003c172982bbe8f5fc7734296dddc2b15a97864999dbc4f418160552424863def77c07d00cc3db3607aee60dce83fa6f959f4745ba96d76bf3
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BNSM s. r. o.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Ternwire Ruby SDK
|
|
2
|
+
|
|
3
|
+
Ruby client for the Ternwire transactional email API, for Ruby 3.1+. Uses Net::HTTP,
|
|
4
|
+
JSON, and OpenSSL from the Ruby distribution, with no other dependencies.
|
|
5
|
+
|
|
6
|
+
Add `gem "ternwire"` to your Gemfile, or run `gem install ternwire`.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
require "ternwire"
|
|
10
|
+
|
|
11
|
+
client = Ternwire::Client.new(
|
|
12
|
+
api_key: ENV.fetch("TERNWIRE_API_KEY"),
|
|
13
|
+
base_url: ENV.fetch("TERNWIRE_API_ORIGIN"),
|
|
14
|
+
timeout: 30
|
|
15
|
+
)
|
|
16
|
+
message = client.submit_email({
|
|
17
|
+
"from" => "notifications@your-verified-domain.com",
|
|
18
|
+
"to" => ["recipient@example.com"],
|
|
19
|
+
"subject" => "Your receipt",
|
|
20
|
+
"text" => "Thanks for your order."
|
|
21
|
+
}, "order-123-receipt")
|
|
22
|
+
puts client.get_message(message.fetch("id"))
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
All 17 operations use snake_case names and accept or return hashes with camelCase
|
|
26
|
+
JSON keys. Template deletion returns `nil`. The client is synchronous.
|
|
27
|
+
`list_email_templates(cursor: nil, limit: nil)` returns one page of summaries;
|
|
28
|
+
pass the previous `page.nextCursor` as `cursor` to fetch the next page.
|
|
29
|
+
|
|
30
|
+
Rescue `Ternwire::APIError` to inspect `status`, `body`, `raw_body`, and `headers`.
|
|
31
|
+
Response headers are lowercase and map to arrays of values. Native network and
|
|
32
|
+
timeout exceptions propagate separately.
|
|
33
|
+
|
|
34
|
+
Use `Ternwire::Webhooks.verify(signature_header, raw_body, signing_secret)` before
|
|
35
|
+
parsing the request. The optional `tolerance_seconds:` keyword defaults to 300.
|
|
36
|
+
Use the exact body string without re-encoding or normalizing it.
|
|
37
|
+
|
|
38
|
+
Set `TERNWIRE_API_KEY`, `TERNWIRE_API_ORIGIN`, `TERNWIRE_FROM`, and `TERNWIRE_TO`
|
|
39
|
+
before deliberately running `ruby -Ilib examples/send.rb` from this directory.
|
|
40
|
+
|
|
41
|
+
Use `https://ternwire.com` as the API origin. Read the full documentation at
|
|
42
|
+
[ternwire.com/docs](https://ternwire.com/docs), or contact support@ternwire.com.
|
|
43
|
+
Released under the MIT License.
|
data/examples/send.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "ternwire"
|
|
2
|
+
|
|
3
|
+
client = Ternwire::Client.new(
|
|
4
|
+
api_key: ENV.fetch("TERNWIRE_API_KEY"),
|
|
5
|
+
base_url: ENV.fetch("TERNWIRE_API_ORIGIN")
|
|
6
|
+
)
|
|
7
|
+
accepted = client.submit_email({
|
|
8
|
+
"from" => ENV.fetch("TERNWIRE_FROM"),
|
|
9
|
+
"to" => [ENV.fetch("TERNWIRE_TO")],
|
|
10
|
+
"subject" => "Your receipt",
|
|
11
|
+
"text" => "Thanks for your order."
|
|
12
|
+
}, "order-123-receipt")
|
|
13
|
+
puts JSON.pretty_generate(accepted)
|
|
14
|
+
puts JSON.pretty_generate(client.get_message(accepted.fetch("id")))
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
require "ipaddr"
|
|
2
|
+
require "json"
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Ternwire
|
|
7
|
+
class APIError < StandardError
|
|
8
|
+
attr_reader :status, :raw_body, :body, :headers
|
|
9
|
+
|
|
10
|
+
def initialize(status, raw_body, headers)
|
|
11
|
+
super("Ternwire API returned HTTP #{status}")
|
|
12
|
+
@status, @raw_body, @headers = status, raw_body, headers
|
|
13
|
+
@body = begin
|
|
14
|
+
JSON.parse(raw_body)
|
|
15
|
+
rescue JSON::ParserError
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Client
|
|
22
|
+
def initialize(api_key:, base_url:, timeout: 30)
|
|
23
|
+
unless /\Aem_(live|test)_[A-Za-z0-9_-]+\z/.match?(api_key)
|
|
24
|
+
raise ArgumentError, "Use an em_live_ or em_test_ API key"
|
|
25
|
+
end
|
|
26
|
+
origin = URI.parse(base_url)
|
|
27
|
+
unless %w[http https].include?(origin.scheme) && origin.host && !origin.userinfo &&
|
|
28
|
+
["", "/"].include?(origin.path) && !origin.query && !origin.fragment
|
|
29
|
+
raise ArgumentError, "base_url must be an HTTP(S) origin without /v1"
|
|
30
|
+
end
|
|
31
|
+
if origin.scheme == "http" && !loopback_host?(origin.hostname)
|
|
32
|
+
raise ArgumentError, "base_url must use https unless it points at localhost or a loopback address"
|
|
33
|
+
end
|
|
34
|
+
unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
|
|
35
|
+
raise ArgumentError, "timeout must be positive and finite"
|
|
36
|
+
end
|
|
37
|
+
@api_key, @base_url, @timeout = api_key, base_url.sub(%r{/+\z}, ""), timeout
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# BEGIN GENERATED OPERATIONS
|
|
41
|
+
def submit_email(input, idempotency_key)
|
|
42
|
+
raise ArgumentError, "input must be a Hash" unless input.is_a?(Hash)
|
|
43
|
+
raise ArgumentError, "idempotency_key is required" unless idempotency_key.is_a?(String)
|
|
44
|
+
request("POST", "/v1/emails", 202, input, idempotency_key)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def get_message(message_id)
|
|
48
|
+
request("GET", "/v1/emails/" + resource_id(message_id), 200, nil, nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create_email_template(input)
|
|
52
|
+
raise ArgumentError, "input must be a Hash" unless input.is_a?(Hash)
|
|
53
|
+
request("POST", "/v1/templates", 201, input, nil)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def list_email_templates(cursor: nil, limit: nil)
|
|
57
|
+
request("GET", "/v1/templates" + query_string(cursor: cursor, limit: limit), 200, nil, nil)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def get_email_template(template_id)
|
|
61
|
+
request("GET", "/v1/templates/" + resource_id(template_id), 200, nil, nil)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def replace_email_template(template_id, input)
|
|
65
|
+
raise ArgumentError, "input must be a Hash" unless input.is_a?(Hash)
|
|
66
|
+
request("PUT", "/v1/templates/" + resource_id(template_id), 200, input, nil)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def delete_email_template(template_id)
|
|
70
|
+
request("DELETE", "/v1/templates/" + resource_id(template_id), 204, nil, nil)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def create_attachment_upload_slot(input)
|
|
74
|
+
raise ArgumentError, "input must be a Hash" unless input.is_a?(Hash)
|
|
75
|
+
request("POST", "/v1/attachments", 201, input, nil)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def get_attachment(attachment_id)
|
|
79
|
+
request("GET", "/v1/attachments/" + resource_id(attachment_id), 200, nil, nil)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def complete_attachment_upload(attachment_id)
|
|
83
|
+
request("POST", "/v1/attachments/" + resource_id(attachment_id) + "/complete", 202, nil, nil)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def create_sending_domain(input)
|
|
87
|
+
raise ArgumentError, "input must be a Hash" unless input.is_a?(Hash)
|
|
88
|
+
request("POST", "/v1/domains", 201, input, nil)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def get_sending_domain(domain_id)
|
|
92
|
+
request("GET", "/v1/domains/" + resource_id(domain_id), 200, nil, nil)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def delete_sending_domain(domain_id)
|
|
96
|
+
request("DELETE", "/v1/domains/" + resource_id(domain_id), 202, nil, nil)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def create_webhook_endpoint(input)
|
|
100
|
+
raise ArgumentError, "input must be a Hash" unless input.is_a?(Hash)
|
|
101
|
+
request("POST", "/v1/webhook-endpoints", 201, input, nil)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def rotate_webhook_endpoint_signing_secret(endpoint_id)
|
|
105
|
+
request("POST", "/v1/webhook-endpoints/" + resource_id(endpoint_id) + "/rotate-signing-secret", 200, nil, nil)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def get_webhook_attempt(attempt_id)
|
|
109
|
+
request("GET", "/v1/webhook-attempts/" + resource_id(attempt_id), 200, nil, nil)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def replay_webhook_attempt(attempt_id)
|
|
113
|
+
request("POST", "/v1/webhook-attempts/" + resource_id(attempt_id) + "/replay", 202, nil, nil)
|
|
114
|
+
end
|
|
115
|
+
# END GENERATED OPERATIONS
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def resource_id(value)
|
|
120
|
+
raise ArgumentError, "Invalid Ternwire resource ID" unless /\A[a-z0-9][a-z0-9_-]{7,127}\z/.match?(value)
|
|
121
|
+
value
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def query_string(values)
|
|
125
|
+
present = values.compact
|
|
126
|
+
present.empty? ? "" : "?" + URI.encode_www_form(present)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def loopback_host?(host)
|
|
130
|
+
host = host.downcase
|
|
131
|
+
return true if host == "localhost" || host.end_with?(".localhost")
|
|
132
|
+
IPAddr.new(host).loopback?
|
|
133
|
+
rescue IPAddr::Error
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def request(method, path, expected_status, body = nil, idempotency_key = nil)
|
|
138
|
+
headers = { "Authorization" => "Bearer #{@api_key}", "Accept" => "application/json" }
|
|
139
|
+
if idempotency_key
|
|
140
|
+
unless /\A[\x21-\x7e]{1,128}\z/.match?(idempotency_key)
|
|
141
|
+
raise ArgumentError, "idempotency_key must contain 1-128 visible ASCII characters"
|
|
142
|
+
end
|
|
143
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
144
|
+
end
|
|
145
|
+
uri = URI.parse(@base_url + path)
|
|
146
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
147
|
+
http.use_ssl = uri.scheme == "https"
|
|
148
|
+
http.open_timeout = @timeout
|
|
149
|
+
http.read_timeout = @timeout
|
|
150
|
+
http.write_timeout = @timeout
|
|
151
|
+
http.max_retries = 0
|
|
152
|
+
request = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri, headers)
|
|
153
|
+
unless body.nil?
|
|
154
|
+
raise ArgumentError, "body must be a Hash" unless body.is_a?(Hash)
|
|
155
|
+
request["Content-Type"] = "application/json"
|
|
156
|
+
request.body = JSON.generate(body)
|
|
157
|
+
end
|
|
158
|
+
response = http.request(request)
|
|
159
|
+
raw = response.body || ""
|
|
160
|
+
raise APIError.new(response.code.to_i, raw, response.to_hash) unless response.code.to_i == expected_status
|
|
161
|
+
return nil if expected_status == 204
|
|
162
|
+
result = JSON.parse(raw)
|
|
163
|
+
raise JSON::ParserError, "Ternwire API returned a non-object JSON response" unless result.is_a?(Hash)
|
|
164
|
+
result
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module Ternwire
|
|
4
|
+
module Webhooks
|
|
5
|
+
def self.verify(header, body, secret, tolerance_seconds: 300)
|
|
6
|
+
return false if secret.empty? || !tolerance_seconds.finite? || tolerance_seconds.negative?
|
|
7
|
+
match = /\A\s*t=([1-9][0-9]{0,11})\s*,\s*v1=([a-f0-9]{64})\s*\z/.match(header)
|
|
8
|
+
return false unless match && (Time.now.to_i - match[1].to_i).abs <= tolerance_seconds
|
|
9
|
+
payload = "#{match[1]}.".b + body.b
|
|
10
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
|
|
11
|
+
OpenSSL.fixed_length_secure_compare(expected, match[2])
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/ternwire.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ternwire
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ternwire
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
executables: []
|
|
13
|
+
extensions: []
|
|
14
|
+
extra_rdoc_files: []
|
|
15
|
+
files:
|
|
16
|
+
- LICENSE
|
|
17
|
+
- README.md
|
|
18
|
+
- examples/send.rb
|
|
19
|
+
- lib/ternwire.rb
|
|
20
|
+
- lib/ternwire/client.rb
|
|
21
|
+
- lib/ternwire/version.rb
|
|
22
|
+
- lib/ternwire/webhooks.rb
|
|
23
|
+
homepage: https://ternwire.com
|
|
24
|
+
licenses:
|
|
25
|
+
- MIT
|
|
26
|
+
metadata:
|
|
27
|
+
source_code_uri: https://github.com/ternwire/ternwire-ruby
|
|
28
|
+
documentation_uri: https://ternwire.com/docs
|
|
29
|
+
bug_tracker_uri: https://ternwire.com/contact
|
|
30
|
+
rubygems_mfa_required: 'true'
|
|
31
|
+
rdoc_options: []
|
|
32
|
+
require_paths:
|
|
33
|
+
- lib
|
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.1'
|
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '0'
|
|
44
|
+
requirements: []
|
|
45
|
+
rubygems_version: 3.6.9
|
|
46
|
+
specification_version: 4
|
|
47
|
+
summary: Ruby client for the Ternwire transactional email API
|
|
48
|
+
test_files: []
|