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 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: []