postscale 1.0.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.
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ class HttpClient
5
+ IDEMPOTENT_METHODS = %w[GET HEAD OPTIONS].freeze
6
+ RETRYABLE_ERRORS = [
7
+ Net::OpenTimeout,
8
+ Net::ReadTimeout,
9
+ Errno::ECONNREFUSED,
10
+ Errno::ECONNRESET,
11
+ Errno::EHOSTUNREACH,
12
+ Errno::ETIMEDOUT,
13
+ SocketError,
14
+ EOFError
15
+ ].freeze
16
+
17
+ USER_AGENT = "postscale-ruby/#{Postscale::VERSION}"
18
+
19
+ def initialize(config)
20
+ @config = config
21
+ @base_uri = URI.parse(config.base_url)
22
+ end
23
+
24
+ # Perform a GET request.
25
+ def get(path, params: nil, headers: nil, parse_as: :json)
26
+ request("GET", path, params: params, headers: headers, parse_as: parse_as)
27
+ end
28
+
29
+ # Perform a POST request with a JSON body.
30
+ def post(path, json: nil, headers: nil, parse_as: :json)
31
+ request("POST", path, json: json, headers: headers, parse_as: parse_as)
32
+ end
33
+
34
+ # Perform a PUT request with a JSON body.
35
+ def put(path, json: nil, headers: nil, parse_as: :json)
36
+ request("PUT", path, json: json, headers: headers, parse_as: parse_as)
37
+ end
38
+
39
+ # Perform a PATCH request with a JSON body.
40
+ def patch(path, json: nil, headers: nil, parse_as: :json)
41
+ request("PATCH", path, json: json, headers: headers, parse_as: parse_as)
42
+ end
43
+
44
+ # Perform a DELETE request.
45
+ def delete(path, params: nil, headers: nil, parse_as: :json)
46
+ request("DELETE", path, params: params, headers: headers, parse_as: parse_as)
47
+ end
48
+
49
+ def request(method, path, json: nil, params: nil, headers: nil, parse_as: :json)
50
+ method = method.to_s.upcase
51
+ attempts = max_attempts(method)
52
+ last_error = nil
53
+
54
+ attempts.times do |attempt|
55
+ uri = build_uri(path, params)
56
+ request = build_request(method, uri, json, headers)
57
+
58
+ begin
59
+ response = perform_request(uri, request)
60
+ rescue *RETRYABLE_ERRORS => e
61
+ last_error = network_error(e)
62
+ if retry_network_error?(method, attempt, attempts)
63
+ sleep_for(backoff(attempt))
64
+ next
65
+ end
66
+ return failure(last_error)
67
+ end
68
+
69
+ response_headers = headers_from(response)
70
+ status = response.code.to_i
71
+
72
+ if status >= 200 && status < 300
73
+ return Result.new(data: parse_success(response, parse_as), error: nil, headers: response_headers)
74
+ end
75
+
76
+ body = parse_error_body(response)
77
+ error = api_error(status, body, response_headers.request_id)
78
+ last_error = error
79
+
80
+ if retry_response?(method, status, attempt, attempts)
81
+ sleep_for(retry_delay(response, body, attempt))
82
+ next
83
+ end
84
+
85
+ return Result.new(data: nil, error: error, headers: response_headers)
86
+ end
87
+
88
+ failure(last_error || APIError.new(message: "Postscale API request failed after all retry attempts.", code: "application_error"))
89
+ end
90
+
91
+ private
92
+
93
+ def build_uri(path, params = nil)
94
+ uri = @base_uri.dup
95
+ base_path = @base_uri.path.to_s.chomp("/")
96
+ request_path = path.to_s.start_with?("/") ? path.to_s : "/#{path}"
97
+ uri.path = "#{base_path}#{request_path}"
98
+ query = query_string(params)
99
+ uri.query = query unless query.nil? || query.empty?
100
+ uri
101
+ end
102
+
103
+ def build_request(method, uri, json, headers)
104
+ request_class = Net::HTTP.const_get(method.capitalize)
105
+ request = request_class.new(uri)
106
+ apply_headers(request, headers)
107
+
108
+ unless json.nil?
109
+ request.body = JSON.generate(json)
110
+ request.content_type = "application/json"
111
+ end
112
+
113
+ request
114
+ end
115
+
116
+ def apply_headers(request, headers)
117
+ @config.headers.each { |key, value| request[key.to_s] = value.to_s }
118
+ default_headers.each { |key, value| request[key] = value }
119
+ (headers || {}).each { |key, value| request[key.to_s] = value.to_s }
120
+ end
121
+
122
+ def default_headers
123
+ {
124
+ "Authorization" => "Bearer #{@config.api_key}",
125
+ "User-Agent" => USER_AGENT,
126
+ "Accept" => "application/json"
127
+ }
128
+ end
129
+
130
+ def query_string(params)
131
+ return nil if params.nil? || params.empty?
132
+
133
+ pairs = []
134
+ params.each do |key, value|
135
+ next if value.nil?
136
+
137
+ if value.is_a?(Array)
138
+ value.each { |item| pairs << [key, item] unless item.nil? }
139
+ else
140
+ pairs << [key, value]
141
+ end
142
+ end
143
+ URI.encode_www_form(pairs)
144
+ end
145
+
146
+ def perform_request(uri, request)
147
+ return @config.transport.call(uri, request) if @config.transport
148
+
149
+ http = Net::HTTP.new(uri.host, uri.port)
150
+ http.use_ssl = (uri.scheme == "https")
151
+ http.open_timeout = @config.timeout
152
+ http.read_timeout = @config.timeout
153
+ http.write_timeout = @config.timeout
154
+ http.request(request)
155
+ end
156
+
157
+ def max_attempts(method)
158
+ return 1 unless IDEMPOTENT_METHODS.include?(method)
159
+
160
+ @config.max_retries + 1
161
+ end
162
+
163
+ def retry_network_error?(method, attempt, attempts)
164
+ IDEMPOTENT_METHODS.include?(method) && attempt + 1 < attempts
165
+ end
166
+
167
+ def retry_response?(method, status, attempt, attempts)
168
+ attempt + 1 < attempts && IDEMPOTENT_METHODS.include?(method) && (status == 429 || status >= 500)
169
+ end
170
+
171
+ def retry_delay(response, body, attempt)
172
+ retry_after = response["Retry-After"]
173
+ parsed = parse_retry_after(retry_after) if retry_after
174
+ return parsed unless parsed.nil?
175
+
176
+ if body.is_a?(Hash)
177
+ seconds = body["retry_after_seconds"] || body[:retry_after_seconds]
178
+ return seconds.to_f if seconds.is_a?(Numeric) && seconds.positive?
179
+ end
180
+
181
+ backoff(attempt)
182
+ end
183
+
184
+ def parse_retry_after(value)
185
+ return nil if value.nil? || value.empty?
186
+ return value.to_f if value.match?(/\A\d+(\.\d+)?\z/) && value.to_f.positive?
187
+
188
+ parsed = Time.httpdate(value)
189
+ [parsed.to_f - Time.now.to_f, 0.0].max
190
+ rescue ArgumentError
191
+ nil
192
+ end
193
+
194
+ def backoff(attempt)
195
+ [0.25 * (2**attempt), 5.0].min
196
+ end
197
+
198
+ def sleep_for(seconds)
199
+ @config.sleeper.call(seconds)
200
+ end
201
+
202
+ def parse_success(response, parse_as)
203
+ body = response.body
204
+ return body.to_s.b if parse_as == :bytes
205
+ return body.to_s if parse_as == :text
206
+ return nil if parse_as == :void || response.code.to_i == 204
207
+ return nil if body.nil? || body.empty?
208
+
209
+ JSON.parse(body)
210
+ rescue JSON::ParserError
211
+ body
212
+ end
213
+
214
+ def parse_error_body(response)
215
+ body = response.body
216
+ return nil if body.nil? || body.empty?
217
+
218
+ JSON.parse(body)
219
+ rescue JSON::ParserError
220
+ body
221
+ end
222
+
223
+ def headers_from(response)
224
+ raw = {}
225
+ response.each_header { |key, value| raw[key] = value }
226
+ Headers.new(raw)
227
+ end
228
+
229
+ def api_error(status, body, request_id)
230
+ code, message = parse_error_envelope(body)
231
+ APIError.new(
232
+ message: message || "Postscale API request failed with status #{status}.",
233
+ code: code || default_error_code(status),
234
+ status_code: status,
235
+ request_id: request_id,
236
+ body: body
237
+ )
238
+ end
239
+
240
+ def parse_error_envelope(body)
241
+ return [nil, body] if body.is_a?(String)
242
+ return [nil, nil] unless body.is_a?(Hash)
243
+
244
+ error = body["error"] || body[:error]
245
+ if error.is_a?(Hash)
246
+ return [error["code"] || error[:code], error["message"] || error[:message]]
247
+ end
248
+ if error.is_a?(String)
249
+ message = body["message"] || body[:message] || error
250
+ return [error, message]
251
+ end
252
+
253
+ [body["code"] || body[:code], body["message"] || body[:message]]
254
+ end
255
+
256
+ def default_error_code(status)
257
+ return "unauthorized" if status == 401
258
+ return "forbidden" if status == 403
259
+ return "not_found" if status == 404
260
+ return "conflict" if status == 409
261
+ return "unprocessable_entity" if status == 422
262
+ return "rate_limit_exceeded" if status == 429
263
+ return "internal_error" if status >= 500
264
+
265
+ "api_error"
266
+ end
267
+
268
+ def network_error(error)
269
+ APIError.new(
270
+ message: error.message,
271
+ code: "application_error",
272
+ status_code: nil
273
+ )
274
+ end
275
+
276
+ def failure(error)
277
+ Result.new(data: nil, error: error, headers: Headers.new)
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Aliases < Resource
6
+ def create(request = nil, **kwargs)
7
+ @http.post("/v1/aliases", json: request_hash(request, kwargs))
8
+ end
9
+
10
+ def list(params = nil, **kwargs)
11
+ @http.get("/v1/aliases", params: params_hash(params, kwargs))
12
+ end
13
+
14
+ def get(id)
15
+ @http.get("/v1/aliases/#{encode(id)}")
16
+ end
17
+
18
+ def update(id, request = nil, **kwargs)
19
+ @http.patch("/v1/aliases/#{encode(id)}", json: request_hash(request, kwargs))
20
+ end
21
+
22
+ def delete(id)
23
+ @http.delete("/v1/aliases/#{encode(id)}")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class DKIM < Resource
6
+ def generate(domain_id = nil, request = nil, **kwargs)
7
+ domain_id = identifier(:domain_id, domain_id, kwargs)
8
+ @http.post("/v1/domains/#{encode(domain_id)}/dkim", json: request_hash(request, kwargs))
9
+ end
10
+
11
+ def list(domain_id = nil, **kwargs)
12
+ domain_id = identifier(:domain_id, domain_id, kwargs)
13
+ @http.get("/v1/domains/#{encode(domain_id)}/dkim")
14
+ end
15
+
16
+ def rotate(domain_id = nil, request = nil, **kwargs)
17
+ domain_id = identifier(:domain_id, domain_id, kwargs)
18
+ @http.post("/v1/domains/#{encode(domain_id)}/dkim/rotate", json: request_hash(request, kwargs))
19
+ end
20
+
21
+ def deactivate(domain_id = nil, selector = nil, **kwargs)
22
+ domain_id = identifier(:domain_id, domain_id, kwargs)
23
+ selector = identifier(:selector, selector, kwargs)
24
+ @http.delete("/v1/domains/#{encode(domain_id)}/dkim/#{encode(selector)}")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Domains < Resource
6
+ def create(request = nil, **kwargs)
7
+ @http.post("/v1/domains", json: request_hash(request, kwargs))
8
+ end
9
+
10
+ def list(params = nil, **kwargs)
11
+ @http.get("/v1/domains", params: params_hash(params, kwargs))
12
+ end
13
+
14
+ def get(id)
15
+ @http.get("/v1/domains/#{encode(id)}")
16
+ end
17
+
18
+ def update(id, request = nil, **kwargs)
19
+ @http.put("/v1/domains/#{encode(id)}", json: request_hash(request, kwargs))
20
+ end
21
+
22
+ def delete(id)
23
+ @http.delete("/v1/domains/#{encode(id)}")
24
+ end
25
+
26
+ def verify(id)
27
+ @http.post("/v1/domains/#{encode(id)}/verify")
28
+ end
29
+
30
+ def dns(id)
31
+ @http.get("/v1/domains/#{encode(id)}/dns")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Emails < Resource
6
+ def send(request = nil, **kwargs)
7
+ body = request_hash(request, kwargs)
8
+ Attachments.validate(body["attachments"] || body[:attachments])
9
+ @http.post("/v1/send", json: body)
10
+ end
11
+
12
+ def send_batch(request = nil, **kwargs)
13
+ body = request.is_a?(Array) ? { "emails" => request } : request_hash(request, kwargs)
14
+ Array(body["emails"] || body[:emails]).each do |email|
15
+ Attachments.validate(email["attachments"] || email[:attachments])
16
+ end
17
+ @http.post("/v1/send/batch", json: body)
18
+ end
19
+ alias batch send_batch
20
+
21
+ def list(params = nil, **kwargs)
22
+ params = params_hash(params, kwargs)
23
+ @http.get("/v1/emails", params: params)
24
+ end
25
+
26
+ def get(id)
27
+ @http.get("/v1/emails/#{encode(id)}")
28
+ end
29
+
30
+ def list_events(id)
31
+ @http.get("/v1/emails/#{encode(id)}/events")
32
+ end
33
+ alias events list_events
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Inbound < Resource
6
+ def list(params = nil, **kwargs)
7
+ @http.get("/v1/inbound-emails", params: params_hash(params, kwargs))
8
+ end
9
+
10
+ def get(id)
11
+ @http.get("/v1/inbound-emails/#{encode(id)}")
12
+ end
13
+
14
+ def download_attachment(email_id, attachment_id)
15
+ @http.get(
16
+ "/v1/inbound-emails/#{encode(email_id)}/attachments/#{encode(attachment_id)}/download",
17
+ parse_as: :bytes
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Resource
6
+ def initialize(http)
7
+ @http = http
8
+ end
9
+
10
+ private
11
+
12
+ def encode(value)
13
+ URI.encode_www_form_component(value.to_s)
14
+ end
15
+
16
+ def request_hash(request = nil, kwargs = {})
17
+ data = {}
18
+ data.merge!(request) if request
19
+ data.merge!(kwargs) unless kwargs.empty?
20
+ data
21
+ end
22
+
23
+ def params_hash(params = nil, kwargs = {})
24
+ request_hash(params, kwargs)
25
+ end
26
+
27
+ def identifier(name, value, kwargs = {})
28
+ found = value || kwargs.delete(name)
29
+ raise ArgumentError, "#{name} is required" if found.nil? || found.to_s.empty?
30
+
31
+ found
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Stats < Resource
6
+ def aggregate(domain_id = nil, params = nil, **kwargs)
7
+ domain_id = identifier(:domain_id, domain_id, kwargs)
8
+ @http.get("/v1/domains/#{encode(domain_id)}/stats", params: params_hash(params, kwargs))
9
+ end
10
+ alias get aggregate
11
+
12
+ def daily(domain_id = nil, params = nil, **kwargs)
13
+ domain_id = identifier(:domain_id, domain_id, kwargs)
14
+ @http.get("/v1/domains/#{encode(domain_id)}/stats/daily", params: params_hash(params, kwargs))
15
+ end
16
+
17
+ def hourly(domain_id = nil, params = nil, **kwargs)
18
+ domain_id = identifier(:domain_id, domain_id, kwargs)
19
+ @http.get("/v1/domains/#{encode(domain_id)}/stats/hourly", params: params_hash(params, kwargs))
20
+ end
21
+
22
+ def isp(domain_id = nil, params = nil, **kwargs)
23
+ domain_id = identifier(:domain_id, domain_id, kwargs)
24
+ @http.get("/v1/domains/#{encode(domain_id)}/stats/isp", params: params_hash(params, kwargs))
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Suppressions < Resource
6
+ def list(params = nil, **kwargs)
7
+ @http.get("/v1/suppressions", params: params_hash(params, kwargs))
8
+ end
9
+
10
+ def add(request = nil, **kwargs)
11
+ @http.post("/v1/suppressions", json: request_hash(request, kwargs))
12
+ end
13
+
14
+ def check(email = nil, **kwargs)
15
+ params = email.is_a?(Hash) ? params_hash(email, kwargs) : params_hash({ email: email }, kwargs)
16
+ @http.get("/v1/suppressions/check", params: params)
17
+ end
18
+
19
+ def remove(identifier)
20
+ @http.delete("/v1/suppressions/#{encode(identifier)}")
21
+ end
22
+
23
+ def import_preview(request = nil, **kwargs)
24
+ @http.post("/v1/imports/suppressions/preview", json: request_hash(request, kwargs))
25
+ end
26
+
27
+ def import_job(id)
28
+ @http.get("/v1/imports/suppressions/jobs/#{encode(id)}")
29
+ end
30
+
31
+ def commit_import(id)
32
+ @http.post("/v1/imports/suppressions/jobs/#{encode(id)}/commit")
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Templates < Resource
6
+ def create(request = nil, **kwargs)
7
+ @http.post("/v1/templates", json: request_hash(request, kwargs))
8
+ end
9
+
10
+ def list(params = nil, **kwargs)
11
+ @http.get("/v1/templates", params: params_hash(params, kwargs))
12
+ end
13
+
14
+ def get(id)
15
+ @http.get("/v1/templates/#{encode(id)}")
16
+ end
17
+
18
+ def update(id, request = nil, **kwargs)
19
+ @http.put("/v1/templates/#{encode(id)}", json: request_hash(request, kwargs))
20
+ end
21
+
22
+ def delete(id)
23
+ @http.delete("/v1/templates/#{encode(id)}")
24
+ end
25
+
26
+ def preview(id, request = nil, **kwargs)
27
+ @http.post("/v1/templates/#{encode(id)}/preview", json: request_hash(request, kwargs))
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Trust < Resource
6
+ def get_review_request
7
+ @http.get("/v1/trust/review-request")
8
+ end
9
+
10
+ def create_review_request(request = nil, **kwargs)
11
+ @http.post("/v1/trust/review-request", json: request_hash(request, kwargs))
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Usage < Resource
6
+ def summary
7
+ @http.get("/v1/usage/summary")
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Warming < Resource
6
+ def get_status(domain_id = nil, **kwargs)
7
+ domain_id = identifier(:domain_id, domain_id, kwargs)
8
+ @http.get("/v1/domains/#{encode(domain_id)}/warming")
9
+ end
10
+ alias status get_status
11
+
12
+ def history(domain_id = nil, params = nil, **kwargs)
13
+ domain_id = identifier(:domain_id, domain_id, kwargs)
14
+ @http.get("/v1/domains/#{encode(domain_id)}/warming/history", params: params_hash(params, kwargs))
15
+ end
16
+
17
+ def start(domain_id = nil, request = nil, **kwargs)
18
+ domain_id = identifier(:domain_id, domain_id, kwargs)
19
+ @http.post("/v1/domains/#{encode(domain_id)}/warming/start", json: request_hash(request, kwargs))
20
+ end
21
+
22
+ def pause(domain_id = nil, request = nil, **kwargs)
23
+ domain_id = identifier(:domain_id, domain_id, kwargs)
24
+ @http.post("/v1/domains/#{encode(domain_id)}/warming/pause", json: request_hash(request, kwargs))
25
+ end
26
+
27
+ def resume(domain_id = nil, **kwargs)
28
+ domain_id = identifier(:domain_id, domain_id, kwargs)
29
+ @http.post("/v1/domains/#{encode(domain_id)}/warming/resume")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postscale
4
+ module Resources
5
+ class Webhooks < Resource
6
+ def create(request = nil, **kwargs)
7
+ @http.post("/v1/webhooks", json: request_hash(request, kwargs))
8
+ end
9
+
10
+ def list(params = nil, **kwargs)
11
+ @http.get("/v1/webhooks", params: params_hash(params, kwargs))
12
+ end
13
+
14
+ def delete(id)
15
+ @http.delete("/v1/webhooks/#{encode(id)}")
16
+ end
17
+
18
+ def deliveries(params = nil, **kwargs)
19
+ @http.get("/v1/webhook-deliveries", params: params_hash(params, kwargs))
20
+ end
21
+
22
+ def endpoint_deliveries(id, params = nil, **kwargs)
23
+ @http.get("/v1/webhooks/#{encode(id)}/deliveries", params: params_hash(params, kwargs))
24
+ end
25
+
26
+ def rotate_secret(id, request = nil, **kwargs)
27
+ @http.post("/v1/webhooks/#{encode(id)}/rotate", json: request_hash(request, kwargs))
28
+ end
29
+
30
+ def verify_signature(body, signature_header, secrets, **options)
31
+ Webhook.verify_signature(body, signature_header, secrets, **options)
32
+ end
33
+ end
34
+ end
35
+ end