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 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,3 @@
1
+ module Ternwire
2
+ VERSION = "0.1.0"
3
+ 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
@@ -0,0 +1,3 @@
1
+ require_relative "ternwire/version"
2
+ require_relative "ternwire/client"
3
+ require_relative "ternwire/webhooks"
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: []