sendeez 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: 85eb442ceccc29ce2672d7f9667e622770bfbbd7fdc8cfbc627fab9b94587258
4
+ data.tar.gz: b5a13fa32a5c612f95d3f0277d687faf432dc3886b0ddd60073dc35f6cc304b1
5
+ SHA512:
6
+ metadata.gz: da4c9029648fd21898a1be35db3f44f8f71a28d62e5cc1728b09ee2b522912de0a3321dacc73e011d7d9840eba34ff75de296f4eeef9cd709d45f797443ba86b
7
+ data.tar.gz: 5009b9ebc827fb3770196f73062e0f3ffcb0f0a02d8c0d08f4f5b2809539a3dfed05311dc553b4604a44c3857cfe061a0e9eb5f84560a801bf2533e3f6ed9eaf
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sendeez
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,63 @@
1
+ # sendeez
2
+
3
+ Zero-dependency Ruby SDK for the Sendeez transactional email API.
4
+
5
+ ```ruby
6
+ require "sendeez"
7
+
8
+ sendeez = Sendeez::Client.new(ENV.fetch("SENDEEZ_API_KEY"))
9
+
10
+ email = sendeez.emails.send_email(
11
+ {
12
+ "from" => { "email" => "hello@example.com", "name" => "Example" },
13
+ "to" => [{ "email" => "developer@example.com" }],
14
+ "subject" => "Welcome",
15
+ "text" => "Your account is ready."
16
+ },
17
+ idempotency_key: "welcome-user-123" # Optional. The SDK generates one when omitted.
18
+ )
19
+
20
+ puts "#{email['id']} #{email['status']}"
21
+ ```
22
+
23
+ Note the input hash needs explicit `{ }` braces at the call site: `emails`
24
+ methods take keyword arguments too (`idempotency_key:`, `timeout:`), and
25
+ Ruby 3+ only treats a bare trailing hash as positional when it's wrapped in
26
+ braces — otherwise it tries (and fails) to parse it as keywords.
27
+
28
+ The send method is named `send_email`, not `send` — overriding `Kernel#send`
29
+ with a method that also declares keyword parameters hits a real Ruby VM bug
30
+ (argument count coming out as 0), not just a style concern.
31
+
32
+ ## Errors and agent actions
33
+
34
+ ```ruby
35
+ begin
36
+ sendeez.emails.send_email(message)
37
+ rescue Sendeez::Error => e
38
+ puts [e.code, e.param, e.request_id, e.action, e.retry_after].inspect
39
+ end
40
+ ```
41
+
42
+ The SDK retries GET requests and mutations carrying an idempotency key after
43
+ network errors, `429`, or `5xx`. It never automatically retries an unsafe
44
+ mutation.
45
+
46
+ Every method accepts a `timeout:` (seconds). This lets applications and AI
47
+ agents enforce their own execution deadline:
48
+
49
+ ```ruby
50
+ sendeez.emails.list(timeout: 5)
51
+ ```
52
+
53
+ ## Testing
54
+
55
+ Pass `transport:` to substitute a fake transport, the same role `fetch`/
56
+ `opener`/`transport` injection plays in the Node, Python, and PHP SDKs:
57
+
58
+ ```ruby
59
+ fake_transport = lambda do |method, url, headers, body, timeout|
60
+ { status: 200, headers: {}, body: "{}" }
61
+ end
62
+ client = Sendeez::Client.new("sendeez_example_secret", transport: fake_transport)
63
+ ```
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendeez
4
+ class Admin
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ def diagnostics(timeout: nil)
10
+ @client.request("GET", "/v1/admin/diagnostics", timeout: timeout)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendeez
4
+ class ApiKeys
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ def create(name, scopes, expires_at: nil, timeout: nil)
10
+ body = { "name" => name, "scopes" => scopes }
11
+ body["expires_at"] = expires_at if expires_at
12
+ @client.request("POST", "/v1/api-keys", body: body, timeout: timeout)
13
+ end
14
+
15
+ def list(timeout: nil)
16
+ @client.request("GET", "/v1/api-keys", timeout: timeout)
17
+ end
18
+
19
+ def rotate(id, timeout: nil)
20
+ @client.request("POST", "/v1/api-keys/#{URI.encode_www_form_component(id)}/rotate", timeout: timeout)
21
+ end
22
+
23
+ def remove(id, timeout: nil)
24
+ @client.request("DELETE", "/v1/api-keys/#{URI.encode_www_form_component(id)}", timeout: timeout)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendeez
4
+ # Client for the Sendeez transactional email API.
5
+ #
6
+ # Zero runtime dependencies: HTTP is done with Net::HTTP. Pass +transport+
7
+ # to substitute a fake transport in tests (same role as injecting +fetch+/
8
+ # +opener+ in the Node and Python SDKs).
9
+ class Client
10
+ DEFAULT_BASE_URL = "https://api.sendeez.com"
11
+
12
+ attr_reader :emails, :domains, :suppressions, :webhooks, :api_keys, :admin
13
+
14
+ def initialize(api_key, base_url: DEFAULT_BASE_URL, max_retries: 2, timeout: 30, transport: nil)
15
+ raise ArgumentError, "Sendeez api_key is required" if api_key.nil? || api_key.empty?
16
+
17
+ @api_key = api_key
18
+ @base_url = base_url.chomp("/")
19
+ @max_retries = max_retries
20
+ @timeout = timeout
21
+ @transport = transport || method(:default_transport)
22
+
23
+ @emails = Emails.new(self)
24
+ @domains = Domains.new(self)
25
+ @suppressions = Suppressions.new(self)
26
+ @webhooks = Webhooks.new(self)
27
+ @api_keys = ApiKeys.new(self)
28
+ @admin = Admin.new(self)
29
+ end
30
+
31
+ def self.new_idempotency_key
32
+ "sdk_#{SecureRandom.uuid}"
33
+ end
34
+
35
+ # @api private used by Sendeez::{Emails,Domains,...}; not part of the public API.
36
+ def request(method, path, body: nil, query: nil, idempotency_key: nil, timeout: nil)
37
+ url = "#{@base_url}#{path}"
38
+ if query
39
+ filtered = query.reject { |_, v| v.nil? }
40
+ url += "?#{URI.encode_www_form(filtered)}" unless filtered.empty?
41
+ end
42
+
43
+ headers = {
44
+ "Authorization" => "Bearer #{@api_key}",
45
+ "Accept" => "application/json"
46
+ }
47
+ payload = nil
48
+ unless body.nil?
49
+ payload = JSON.generate(body)
50
+ headers["Content-Type"] = "application/json"
51
+ end
52
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
53
+
54
+ effective_timeout = timeout || @timeout
55
+ attempt = 0
56
+
57
+ loop do
58
+ begin
59
+ response = @transport.call(method, url, headers, payload, effective_timeout)
60
+ rescue StandardError => e
61
+ raise e if attempt >= @max_retries || (method != "GET" && idempotency_key.nil?)
62
+
63
+ sleep(backoff(attempt))
64
+ attempt += 1
65
+ next
66
+ end
67
+
68
+ status = response[:status]
69
+ if status >= 200 && status < 300
70
+ body_str = response[:body]
71
+ return nil if status == 204 || body_str.nil? || body_str.empty?
72
+
73
+ return JSON.parse(body_str)
74
+ end
75
+
76
+ error_payload = parse_error_body(response[:body])
77
+ retry_after = parse_retry_after(response[:headers])
78
+
79
+ if attempt < @max_retries && retryable?(method, idempotency_key, status)
80
+ sleep(retry_after || backoff(attempt))
81
+ attempt += 1
82
+ next
83
+ end
84
+
85
+ raise Sendeez::Error.new(status, error_payload["error"] || {}, retry_after: retry_after)
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def retryable?(method, idempotency_key, status)
92
+ (method == "GET" || !idempotency_key.nil?) && (status == 429 || status >= 500)
93
+ end
94
+
95
+ def parse_error_body(body)
96
+ return {} if body.nil? || body.empty?
97
+
98
+ JSON.parse(body)
99
+ rescue JSON::ParserError
100
+ {}
101
+ end
102
+
103
+ def parse_retry_after(headers)
104
+ return nil unless headers
105
+
106
+ value = headers["retry-after"] || headers["Retry-After"]
107
+ return nil unless value
108
+
109
+ seconds = Float(value)
110
+ [seconds, 60].min
111
+ rescue ArgumentError, TypeError
112
+ nil
113
+ end
114
+
115
+ def backoff(attempt)
116
+ 2**attempt
117
+ end
118
+
119
+ def default_transport(method, url, headers, body, timeout)
120
+ uri = URI(url)
121
+ http = Net::HTTP.new(uri.host, uri.port)
122
+ http.use_ssl = uri.scheme == "https"
123
+ http.read_timeout = timeout
124
+ http.open_timeout = timeout
125
+
126
+ request_class = {
127
+ "GET" => Net::HTTP::Get,
128
+ "POST" => Net::HTTP::Post,
129
+ "DELETE" => Net::HTTP::Delete
130
+ }.fetch(method) { raise ArgumentError, "Unsupported method #{method}" }
131
+
132
+ req = request_class.new(uri)
133
+ headers.each { |key, value| req[key] = value }
134
+ req.body = body if body
135
+
136
+ response = http.request(req)
137
+ {
138
+ status: response.code.to_i,
139
+ headers: response.to_hash.transform_values(&:first),
140
+ body: response.body || ""
141
+ }
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendeez
4
+ class Domains
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ def create(name, timeout: nil)
10
+ @client.request("POST", "/v1/domains", body: { "name" => name }, timeout: timeout)
11
+ end
12
+
13
+ def list(timeout: nil)
14
+ @client.request("GET", "/v1/domains", timeout: timeout)
15
+ end
16
+
17
+ def retrieve(id, timeout: nil)
18
+ @client.request("GET", "/v1/domains/#{URI.encode_www_form_component(id)}", timeout: timeout)
19
+ end
20
+
21
+ def verify(id, timeout: nil)
22
+ @client.request("POST", "/v1/domains/#{URI.encode_www_form_component(id)}/verify", timeout: timeout)
23
+ end
24
+
25
+ def remove(id, timeout: nil)
26
+ @client.request("DELETE", "/v1/domains/#{URI.encode_www_form_component(id)}", timeout: timeout)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendeez
4
+ class Emails
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ def send_email(input, idempotency_key: nil, timeout: nil)
10
+ @client.request(
11
+ "POST",
12
+ "/v1/emails",
13
+ body: input,
14
+ idempotency_key: idempotency_key || Client.new_idempotency_key,
15
+ timeout: timeout
16
+ )
17
+ end
18
+
19
+ def retrieve(id, timeout: nil)
20
+ @client.request("GET", "/v1/emails/#{URI.encode_www_form_component(id)}", timeout: timeout)
21
+ end
22
+
23
+ def list(limit: nil, after: nil, timeout: nil)
24
+ @client.request("GET", "/v1/emails", query: { "limit" => limit, "after" => after }, timeout: timeout)
25
+ end
26
+
27
+ def simulate_event(email_id, input, idempotency_key: nil, timeout: nil)
28
+ @client.request(
29
+ "POST",
30
+ "/v1/emails/#{URI.encode_www_form_component(email_id)}/events",
31
+ body: input,
32
+ idempotency_key: idempotency_key || Client.new_idempotency_key,
33
+ timeout: timeout
34
+ )
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendeez
4
+ # Raised for any non-2xx response from the Sendeez API.
5
+ class Error < StandardError
6
+ attr_reader :status, :type, :code, :param, :request_id, :details, :action, :retry_after
7
+
8
+ def initialize(status, error, retry_after: nil)
9
+ @status = status
10
+ @type = error["type"] || "api_error"
11
+ @code = error["code"] || "unknown_error"
12
+ @param = error["param"]
13
+ @request_id = error["request_id"]
14
+ @details = error["details"]
15
+ @action = error["action"]
16
+ @retry_after = retry_after
17
+ super(error["message"] || "Sendeez request failed with HTTP #{status}")
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendeez
4
+ class Suppressions
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ def create(recipient, reason: nil, expires_at: nil, timeout: nil)
10
+ body = { "recipient" => recipient }
11
+ body["reason"] = reason if reason
12
+ body["expires_at"] = expires_at if expires_at
13
+ @client.request("POST", "/v1/suppressions", body: body, timeout: timeout)
14
+ end
15
+
16
+ def list(timeout: nil)
17
+ @client.request("GET", "/v1/suppressions", timeout: timeout)
18
+ end
19
+
20
+ def remove(id, timeout: nil)
21
+ @client.request("DELETE", "/v1/suppressions/#{URI.encode_www_form_component(id)}", timeout: timeout)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendeez
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendeez
4
+ class Webhooks
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ def create(url, description: nil, events: nil, timeout: nil)
10
+ body = { "url" => url }
11
+ body["description"] = description if description
12
+ body["events"] = events if events
13
+ @client.request("POST", "/v1/webhooks", body: body, timeout: timeout)
14
+ end
15
+
16
+ def list(timeout: nil)
17
+ @client.request("GET", "/v1/webhooks", timeout: timeout)
18
+ end
19
+
20
+ def retrieve(id, timeout: nil)
21
+ @client.request("GET", "/v1/webhooks/#{URI.encode_www_form_component(id)}", timeout: timeout)
22
+ end
23
+
24
+ def test(id, timeout: nil)
25
+ @client.request("POST", "/v1/webhooks/#{URI.encode_www_form_component(id)}/test", timeout: timeout)
26
+ end
27
+
28
+ def deliveries(id, timeout: nil)
29
+ @client.request("GET", "/v1/webhooks/#{URI.encode_www_form_component(id)}/deliveries", timeout: timeout)
30
+ end
31
+
32
+ def remove(id, timeout: nil)
33
+ @client.request("DELETE", "/v1/webhooks/#{URI.encode_www_form_component(id)}", timeout: timeout)
34
+ end
35
+ end
36
+ end
data/lib/sendeez.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "securerandom"
7
+
8
+ require_relative "sendeez/version"
9
+ require_relative "sendeez/errors"
10
+ require_relative "sendeez/client"
11
+ require_relative "sendeez/emails"
12
+ require_relative "sendeez/domains"
13
+ require_relative "sendeez/suppressions"
14
+ require_relative "sendeez/webhooks"
15
+ require_relative "sendeez/api_keys"
16
+ require_relative "sendeez/admin"
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sendeez
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sendeez
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Sendeez::Client wraps the Sendeez transactional email API with no gem
42
+ runtime dependencies, using Net::HTTP directly.
43
+ email:
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE
49
+ - README.md
50
+ - lib/sendeez.rb
51
+ - lib/sendeez/admin.rb
52
+ - lib/sendeez/api_keys.rb
53
+ - lib/sendeez/client.rb
54
+ - lib/sendeez/domains.rb
55
+ - lib/sendeez/emails.rb
56
+ - lib/sendeez/errors.rb
57
+ - lib/sendeez/suppressions.rb
58
+ - lib/sendeez/version.rb
59
+ - lib/sendeez/webhooks.rb
60
+ homepage: https://github.com/sendeezhq/sendeez-ruby
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ source_code_uri: https://github.com/sendeezhq/sendeez-ruby
65
+ bug_tracker_uri: https://github.com/sendeezhq/sendeez-ruby/issues
66
+ post_install_message:
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 3.5.22
82
+ signing_key:
83
+ specification_version: 4
84
+ summary: Zero-dependency Ruby SDK for the Sendeez transactional email API.
85
+ test_files: []