sealpost 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 +30 -0
- data/lib/sealpost/client.rb +297 -0
- data/lib/sealpost/webhooks.rb +35 -0
- data/lib/sealpost.rb +18 -0
- metadata +49 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6e2cc83834cb454f75aae9fa1a26c044897c746edd16c20455a2f343d1f5879d
|
|
4
|
+
data.tar.gz: a0673046f81e3b45f487bcee469f9ee9fd697fecb07322400f99743d4c75315c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bef36104217ee183302be9ccf58d03158fa36e3388c96a8115c06c09b5cebae0c8ba544bc3540c05c4a8a0ddb6b3ea179a6ffddf71f18624e47e85ee58fa7e55
|
|
7
|
+
data.tar.gz: 566aaef5ceeaa94e3386dbad81c05962c537d0cff3d97483b3b6f036c086052c307dde47e8d266f0a5bcf58797c38c9ff6d0ee9bef3accf7971c66f59624d5b9
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sagar Pinninti
|
|
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,30 @@
|
|
|
1
|
+
# sealpost (Ruby)
|
|
2
|
+
|
|
3
|
+
Zero-dependency Ruby client for the self-hosted [Sealpost](https://github.com/isagarpinninti/sealpost) email API. Ruby 2.6+, standard library only.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
gem install sealpost
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require "sealpost"
|
|
11
|
+
|
|
12
|
+
mail = Sealpost::Client.new(api_key: ENV["SEALPOST_KEY"], base_url: "https://mail.yourdomain.com")
|
|
13
|
+
|
|
14
|
+
result = mail.emails.deliver(
|
|
15
|
+
template: "magic_link_otp_verification", to: "user@example.org",
|
|
16
|
+
variables: { userName: "Ada", otpCode: "482913", signInUrl: "https://app.example.com/in" },
|
|
17
|
+
idempotency_key: "signup-42", tags: [{ name: "campaign", value: "welcome" }],
|
|
18
|
+
)
|
|
19
|
+
if result.error&.code == "preflight_blocked"
|
|
20
|
+
puts result.error.preflight["checks"] # nothing was sent
|
|
21
|
+
elsif result.ok?
|
|
22
|
+
puts result.data["id"]
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Results are `Sealpost::Result` objects (`data` / `error`); nothing raises unless you call `.unwrap`. The method is `deliver` (alias `send_email`) because `send` is taken by Ruby's `Object`.
|
|
27
|
+
|
|
28
|
+
Also: `emails.batch/get/list/preview`, `inbound.list/get/raw/attachment/delete`, `audiences.*`, `broadcasts.*`, `scheduled.*`, `suppressions.*`, `domains.list/check`, `stats`, `unsubscribe_link`, and `Sealpost::Webhooks.verify(secret, raw_body, header)`.
|
|
29
|
+
|
|
30
|
+
Run the tests with `ruby test/sealpost_test.rb`. MIT.
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
module Sealpost
|
|
2
|
+
# One of data or error is set. Nothing raises unless you call #unwrap.
|
|
3
|
+
class Error
|
|
4
|
+
attr_reader :status, :code, :message, :preflight, :request_id
|
|
5
|
+
|
|
6
|
+
def initialize(status:, message:, code: nil, preflight: nil, request_id: nil)
|
|
7
|
+
@status = status # HTTP status, or 0 for a network failure or timeout
|
|
8
|
+
@message = message
|
|
9
|
+
@code = code
|
|
10
|
+
@preflight = preflight
|
|
11
|
+
@request_id = request_id
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_s
|
|
15
|
+
"#{message} (#{status})#{code ? " [#{code}]" : ""}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class SealpostException < StandardError
|
|
20
|
+
attr_reader :error
|
|
21
|
+
|
|
22
|
+
def initialize(error)
|
|
23
|
+
super(error.to_s)
|
|
24
|
+
@error = error
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class Result
|
|
29
|
+
attr_reader :data, :error, :headers
|
|
30
|
+
|
|
31
|
+
def initialize(data: nil, error: nil, headers: {})
|
|
32
|
+
@data = data
|
|
33
|
+
@error = error
|
|
34
|
+
@headers = headers
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ok?
|
|
38
|
+
@error.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# True when the server replayed an earlier response for the same idempotency_key.
|
|
42
|
+
def replayed?
|
|
43
|
+
@headers["idempotent-replayed"] == "true"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unwrap
|
|
47
|
+
raise SealpostException, @error if @error
|
|
48
|
+
@data
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class Resource
|
|
53
|
+
def initialize(client)
|
|
54
|
+
@c = client
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def q(value)
|
|
60
|
+
URI.encode_www_form_component(value.to_s).gsub("+", "%20")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def query(params)
|
|
64
|
+
pairs = params.reject { |_, v| v.nil? }
|
|
65
|
+
pairs.empty? ? "" : "?" + URI.encode_www_form(pairs)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class Emails < Resource
|
|
70
|
+
WIRE = { auto_fill: "autoFill", include_mime: "includeMime", in_reply_to: "inReplyTo", scheduled_at: "scheduledAt" }.freeze
|
|
71
|
+
|
|
72
|
+
# Send one email. result.error.code == "preflight_blocked" means nothing was sent.
|
|
73
|
+
# Options: variables, domain, cc, bcc, auto_fill, attachments, tags, metadata, in_reply_to, scheduled_at (ISO 8601 or Time),
|
|
74
|
+
# idempotency_key (same key + body within 24 h returns the original result; see result.replayed?).
|
|
75
|
+
def deliver(template:, to:, idempotency_key: nil, **opts)
|
|
76
|
+
body = { "templateId" => template, "to" => to }
|
|
77
|
+
opts.each do |k, v|
|
|
78
|
+
next if v.nil?
|
|
79
|
+
v = v.utc.iso8601 if v.respond_to?(:utc) && k == :scheduled_at
|
|
80
|
+
body[WIRE.fetch(k, k.to_s)] = v
|
|
81
|
+
end
|
|
82
|
+
@c.request(:post, "/api/send", body, nil, idempotency_key ? { "Idempotency-Key" => idempotency_key } : {})
|
|
83
|
+
end
|
|
84
|
+
alias send_email deliver
|
|
85
|
+
|
|
86
|
+
def batch(items)
|
|
87
|
+
emails = items.map do |i|
|
|
88
|
+
i = i.dup
|
|
89
|
+
b = { "templateId" => i.delete(:template), "to" => i.delete(:to) }
|
|
90
|
+
i.each { |k, v| b[WIRE.fetch(k, k.to_s)] = v unless v.nil? }
|
|
91
|
+
b
|
|
92
|
+
end
|
|
93
|
+
@c.request(:post, "/api/send/batch", { "emails" => emails })
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def preview(template:, to: [], **opts)
|
|
97
|
+
body = { "templateId" => template, "to" => to }
|
|
98
|
+
opts.each { |k, v| body[WIRE.fetch(k, k.to_s)] = v unless v.nil? }
|
|
99
|
+
@c.request(:post, "/api/compile", body)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def get(id)
|
|
103
|
+
@c.request(:get, "/api/emails/#{q(id)}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Newest first. Page with before: page["nextCursor"].
|
|
107
|
+
def list(status: nil, to: nil, template: nil, domain: nil, tag: nil, since: nil, until_: nil, limit: nil, before: nil)
|
|
108
|
+
@c.request(:get, "/api/emails" + query(status: status, to: to, template: template, domain: domain, tag: tag, since: since, until: until_, limit: limit, before: before))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class Inbound < Resource
|
|
113
|
+
# Received mail. html bodies are untrusted: show them only in a sandboxed frame with remote content blocked.
|
|
114
|
+
def list(from: nil, to: nil, q: nil, kind: nil, since: nil, until_: nil, limit: nil, before: nil)
|
|
115
|
+
@c.request(:get, "/api/inbound" + query(from: from, to: to, q: q, kind: kind, since: since, until: until_, limit: limit, before: before))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def get(id)
|
|
119
|
+
@c.request(:get, "/api/inbound/#{q(id)}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def raw(id)
|
|
123
|
+
@c.request_bytes("/api/inbound/#{q(id)}/raw")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def attachment(id, index)
|
|
127
|
+
@c.request_bytes("/api/inbound/#{q(id)}/attachments/#{Integer(index)}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def delete(id)
|
|
131
|
+
@c.request(:delete, "/api/inbound/#{q(id)}")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class Audiences < Resource
|
|
136
|
+
def create(name)
|
|
137
|
+
@c.request(:post, "/api/audiences", { "name" => name }, ->(j) { j["audience"] })
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def list
|
|
141
|
+
@c.request(:get, "/api/audiences", nil, ->(j) { j["audiences"] || [] })
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# contacts: [{ email:, name:, variables: {} }], up to 10,000 per call.
|
|
145
|
+
def add_contacts(id, contacts)
|
|
146
|
+
@c.request(:post, "/api/audiences/#{q(id)}/contacts", { "contacts" => contacts })
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def delete(id)
|
|
150
|
+
@c.request(:delete, "/api/audiences/#{q(id)}")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
class Broadcasts < Resource
|
|
155
|
+
def create(name:, audience_id:, template:, domain: nil, variables: nil, scheduled_at: nil, rate_per_minute: nil)
|
|
156
|
+
body = { "name" => name, "audienceId" => audience_id, "templateId" => template }
|
|
157
|
+
body["domain"] = domain if domain
|
|
158
|
+
body["variables"] = variables if variables
|
|
159
|
+
body["scheduledAtIso"] = scheduled_at.respond_to?(:utc) ? scheduled_at.utc.iso8601 : scheduled_at if scheduled_at
|
|
160
|
+
body["ratePerMinute"] = rate_per_minute if rate_per_minute
|
|
161
|
+
@c.request(:post, "/api/broadcasts", body, ->(j) { j["broadcast"] })
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def list
|
|
165
|
+
@c.request(:get, "/api/broadcasts", nil, ->(j) { j["broadcasts"] || [] })
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def get(id)
|
|
169
|
+
@c.request(:get, "/api/broadcasts/#{q(id)}")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def cancel(id)
|
|
173
|
+
@c.request(:post, "/api/broadcasts/#{q(id)}/cancel", {}, ->(j) { j["broadcast"] })
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
class Scheduled < Resource
|
|
178
|
+
def list
|
|
179
|
+
@c.request(:get, "/api/scheduled", nil, ->(j) { j["scheduled"] || [] })
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def cancel(id)
|
|
183
|
+
@c.request(:delete, "/api/scheduled/#{q(id)}", nil, ->(j) { j["scheduled"] })
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
class Suppressions < Resource
|
|
188
|
+
def list
|
|
189
|
+
@c.request(:get, "/api/suppressions", nil, ->(j) { j["suppressions"] || [] })
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def add(email, reason: "unsubscribed", detail: nil)
|
|
193
|
+
@c.request(:post, "/api/suppressions", { "email" => email, "reason" => reason, "detail" => detail }.compact, ->(j) { j["suppressions"] || [] })
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def remove(email)
|
|
197
|
+
@c.request(:delete, "/api/suppressions?email=#{q(email)}", nil, ->(j) { j["suppressions"] || [] })
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
class Domains < Resource
|
|
202
|
+
def list
|
|
203
|
+
@c.request(:get, "/api/domains", nil, ->(j) { j["domains"] || [] })
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Live SPF / DKIM / DMARC lookup.
|
|
207
|
+
def check(domain)
|
|
208
|
+
@c.request(:get, "/api/domains/#{q(domain)}/check")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
class Client
|
|
213
|
+
attr_reader :emails, :inbound, :audiences, :broadcasts, :scheduled, :suppressions, :domains
|
|
214
|
+
|
|
215
|
+
def initialize(api_key:, base_url: "http://127.0.0.1:4320", timeout: 30)
|
|
216
|
+
raise ArgumentError, "Sealpost: api_key is required" if api_key.nil? || api_key.empty?
|
|
217
|
+
@key = api_key
|
|
218
|
+
@base = base_url.sub(%r{/+\z}, "")
|
|
219
|
+
@timeout = timeout
|
|
220
|
+
@emails = Emails.new(self)
|
|
221
|
+
@inbound = Inbound.new(self)
|
|
222
|
+
@audiences = Audiences.new(self)
|
|
223
|
+
@broadcasts = Broadcasts.new(self)
|
|
224
|
+
@scheduled = Scheduled.new(self)
|
|
225
|
+
@suppressions = Suppressions.new(self)
|
|
226
|
+
@domains = Domains.new(self)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def stats
|
|
230
|
+
request(:get, "/api/stats")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# A hosted one-click unsubscribe URL (needs unsubscribe.hosted and a trackingBaseUrl on the server).
|
|
234
|
+
def unsubscribe_link(email, domain: nil)
|
|
235
|
+
request(:post, "/api/unsubscribe-link", { "email" => email, "domain" => domain }.compact, ->(j) { j["url"] })
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def verify_receipt(token, inbox_id: nil)
|
|
239
|
+
request(:post, "/api/verify", { "token" => token, "inboxId" => inbox_id }.compact)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# JSON request. pick: optional lambda that extracts the data from the parsed body.
|
|
243
|
+
def request(method, path, body = nil, pick = nil, extra_headers = {})
|
|
244
|
+
res = perform(method, path, body ? JSON.generate(body) : nil, extra_headers)
|
|
245
|
+
parsed = parse(res.body)
|
|
246
|
+
headers = {}
|
|
247
|
+
res.each_header { |k, v| headers[k.downcase] = v }
|
|
248
|
+
unless res.code.to_i.between?(200, 299)
|
|
249
|
+
return Result.new(error: Error.new(status: res.code.to_i, message: (parsed["error"] || "HTTP #{res.code}").to_s, code: parsed["code"], preflight: parsed["preflight"], request_id: parsed["requestId"] || headers["x-request-id"]), headers: headers)
|
|
250
|
+
end
|
|
251
|
+
Result.new(data: pick ? pick.call(parsed) : parsed, headers: headers)
|
|
252
|
+
rescue Timeout::Error, SystemCallError, SocketError, OpenSSL::SSL::SSLError, EOFError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
253
|
+
Result.new(error: Error.new(status: 0, message: e.message))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Binary download: { bytes:, content_type:, filename: }.
|
|
257
|
+
def request_bytes(path)
|
|
258
|
+
res = perform(:get, path, nil, {})
|
|
259
|
+
unless res.code.to_i.between?(200, 299)
|
|
260
|
+
parsed = parse(res.body)
|
|
261
|
+
return Result.new(error: Error.new(status: res.code.to_i, message: (parsed["error"] || "HTTP #{res.code}").to_s, code: parsed["code"], request_id: parsed["requestId"] || res["x-request-id"]))
|
|
262
|
+
end
|
|
263
|
+
encoded = res["content-disposition"].to_s[/filename\*=UTF-8''([^;]+)/i, 1]
|
|
264
|
+
Result.new(data: { bytes: res.body, content_type: res["content-type"], filename: encoded && URI.decode_www_form_component(encoded) })
|
|
265
|
+
rescue Timeout::Error, SystemCallError, SocketError, OpenSSL::SSL::SSLError, EOFError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
266
|
+
Result.new(error: Error.new(status: 0, message: e.message))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
private
|
|
270
|
+
|
|
271
|
+
def perform(method, path, json, extra_headers)
|
|
272
|
+
uri = URI.parse(@base + path)
|
|
273
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
274
|
+
http.use_ssl = uri.scheme == "https"
|
|
275
|
+
http.open_timeout = @timeout
|
|
276
|
+
http.read_timeout = @timeout
|
|
277
|
+
klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, delete: Net::HTTP::Delete }.fetch(method)
|
|
278
|
+
req = klass.new(uri.request_uri)
|
|
279
|
+
req["Authorization"] = "Bearer #{@key}"
|
|
280
|
+
req["Accept"] = "application/json"
|
|
281
|
+
req["User-Agent"] = "sealpost-ruby/#{Sealpost::VERSION}"
|
|
282
|
+
extra_headers.each { |k, v| req[k] = v }
|
|
283
|
+
if json
|
|
284
|
+
req["Content-Type"] = "application/json"
|
|
285
|
+
req.body = json
|
|
286
|
+
end
|
|
287
|
+
http.request(req)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def parse(raw)
|
|
291
|
+
v = JSON.parse(raw.to_s)
|
|
292
|
+
v.is_a?(Hash) ? v : { "_" => v }
|
|
293
|
+
rescue JSON::ParserError
|
|
294
|
+
{}
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Sealpost
|
|
2
|
+
# Verification of Sealpost outbound webhooks. The Sealpost-Signature header is "t=<unix seconds>,v1=<hex>" where
|
|
3
|
+
# v1 = HMAC-SHA256(secret, "<t>.<raw body>"). Always verify against the raw body.
|
|
4
|
+
module Webhooks
|
|
5
|
+
SIGNATURE_HEADER = "Sealpost-Signature".freeze
|
|
6
|
+
DEFAULT_TOLERANCE = 300
|
|
7
|
+
|
|
8
|
+
# Returns [ok, reason]; reason is :malformed, :stale or :mismatch when not ok.
|
|
9
|
+
def self.verify(secret, raw_body, header, tolerance: DEFAULT_TOLERANCE, now: Time.now.to_i)
|
|
10
|
+
parts = {}
|
|
11
|
+
header.to_s.split(",").each do |piece|
|
|
12
|
+
key, _, value = piece.partition("=")
|
|
13
|
+
parts[key.strip] = value.strip unless value.empty?
|
|
14
|
+
end
|
|
15
|
+
t = Integer(parts["t"], 10) rescue nil
|
|
16
|
+
v1 = parts["v1"].to_s
|
|
17
|
+
return [false, :malformed] if t.nil? || v1 !~ /\A[0-9a-f]{64}\z/
|
|
18
|
+
return [false, :stale] if (now - t).abs > tolerance
|
|
19
|
+
|
|
20
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
|
|
21
|
+
secure_compare(expected, v1) ? [true, nil] : [false, :mismatch]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.sign(secret, raw_body, timestamp = Time.now.to_i)
|
|
25
|
+
"t=#{timestamp},v1=#{OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.secure_compare(a, b)
|
|
29
|
+
return false unless a.bytesize == b.bytesize
|
|
30
|
+
result = 0
|
|
31
|
+
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
|
|
32
|
+
result.zero?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/sealpost.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "sealpost/webhooks"
|
|
6
|
+
require_relative "sealpost/client"
|
|
7
|
+
|
|
8
|
+
# Ruby client for the Sealpost API. Standard library only.
|
|
9
|
+
#
|
|
10
|
+
# mail = Sealpost::Client.new(api_key: ENV["SEALPOST_KEY"], base_url: "https://mail.example.com")
|
|
11
|
+
# result = mail.emails.deliver(template: "magic_link_otp_verification", to: "user@example.org",
|
|
12
|
+
# variables: { userName: "Ada", otpCode: "482913", signInUrl: "https://app.example.com/in" })
|
|
13
|
+
# if result.error&.code == "preflight_blocked"
|
|
14
|
+
# puts result.error.preflight["checks"] # nothing was sent
|
|
15
|
+
# end
|
|
16
|
+
module Sealpost
|
|
17
|
+
VERSION = "0.1.0"
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sealpost
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sagar Pinninti
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-22 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Send email, schedule it, read logs and received mail, manage audiences
|
|
14
|
+
and verify webhooks against a Sealpost server. Standard library only.
|
|
15
|
+
email:
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/sealpost.rb
|
|
23
|
+
- lib/sealpost/client.rb
|
|
24
|
+
- lib/sealpost/webhooks.rb
|
|
25
|
+
homepage: https://github.com/isagarpinninti/sealpost
|
|
26
|
+
licenses:
|
|
27
|
+
- MIT
|
|
28
|
+
metadata:
|
|
29
|
+
source_code_uri: https://github.com/isagarpinninti/sealpost/tree/main/sdks/ruby
|
|
30
|
+
post_install_message:
|
|
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: '2.6'
|
|
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.0.3.1
|
|
46
|
+
signing_key:
|
|
47
|
+
specification_version: 4
|
|
48
|
+
summary: Zero-dependency Ruby client for the self-hosted Sealpost email API
|
|
49
|
+
test_files: []
|