misarmail 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE +21 -0
- data/README.md +95 -0
- data/lib/misar_mail/client.rb +855 -0
- data/lib/misar_mail/core/query.rb +21 -0
- data/lib/misar_mail/core/sse.rb +72 -0
- data/lib/misar_mail/core/transport.rb +107 -0
- data/lib/misar_mail/core/webhooks.rb +53 -0
- data/lib/misar_mail/errors.rb +59 -0
- data/lib/misar_mail.rb +11 -0
- metadata +101 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module MisarMail
|
|
6
|
+
module Core
|
|
7
|
+
# Query-string encoding shared by every generated GET/DELETE method.
|
|
8
|
+
module Query
|
|
9
|
+
# Returns "" for an empty bag so a generated call site can always append
|
|
10
|
+
# unconditionally. Nil values are dropped so optional filters stay out of
|
|
11
|
+
# the URL entirely rather than being sent as the string "".
|
|
12
|
+
def self.encode(params)
|
|
13
|
+
return "" if params.nil? || params.empty?
|
|
14
|
+
|
|
15
|
+
pairs = params.reject { |_, v| v.nil? }
|
|
16
|
+
.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }
|
|
17
|
+
pairs.empty? ? "" : "?#{pairs.join('&')}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module MisarMail
|
|
8
|
+
module Core
|
|
9
|
+
# Server-Sent Events client for the MisarMail streaming endpoints.
|
|
10
|
+
#
|
|
11
|
+
# Both streams frame events as "data: <json>" and close with the sentinel
|
|
12
|
+
# "data: [DONE]". One of the two is a POST, so this reads the response body
|
|
13
|
+
# incrementally rather than using an EventSource-style helper.
|
|
14
|
+
module SSE
|
|
15
|
+
DONE = "[DONE]"
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Yields one decoded payload per event until the stream terminates.
|
|
20
|
+
def stream(url, api_key, method: "GET", body: nil)
|
|
21
|
+
return enum_for(:stream, url, api_key, method: method, body: body) unless block_given?
|
|
22
|
+
|
|
23
|
+
uri = URI.parse(url)
|
|
24
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
25
|
+
http.use_ssl = uri.scheme == "https"
|
|
26
|
+
http.read_timeout = 300
|
|
27
|
+
|
|
28
|
+
headers = {
|
|
29
|
+
"Authorization" => "Bearer #{api_key}",
|
|
30
|
+
"Accept" => "text/event-stream"
|
|
31
|
+
}
|
|
32
|
+
headers["Content-Type"] = "application/json" unless body.nil?
|
|
33
|
+
|
|
34
|
+
request = Net::HTTP.const_get(method.capitalize).new(uri.request_uri, headers)
|
|
35
|
+
request.body = JSON.generate(body) unless body.nil?
|
|
36
|
+
|
|
37
|
+
http.request(request) do |response|
|
|
38
|
+
# Errors arrive as a normal JSON body, not as an SSE frame.
|
|
39
|
+
if response.code.to_i >= 400
|
|
40
|
+
data = begin
|
|
41
|
+
JSON.parse(response.read_body)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
{}
|
|
44
|
+
end
|
|
45
|
+
raise MisarMail::Error.new(response.code.to_i, data["error"].to_s, "api_error", data)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
buffer = +""
|
|
49
|
+
response.read_body do |chunk|
|
|
50
|
+
buffer << chunk
|
|
51
|
+
while (index = buffer.index("\n"))
|
|
52
|
+
line = buffer.slice!(0..index).chomp
|
|
53
|
+
next unless line.start_with?("data:")
|
|
54
|
+
|
|
55
|
+
payload = line[5..].strip
|
|
56
|
+
return if payload == DONE
|
|
57
|
+
next if payload.empty?
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
yield JSON.parse(payload)
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
# One malformed frame should not discard everything already
|
|
63
|
+
# streamed.
|
|
64
|
+
yield({ "raw" => payload })
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
|
|
9
|
+
module MisarMail
|
|
10
|
+
module Core
|
|
11
|
+
# HTTP transport shared by the generated resource layer.
|
|
12
|
+
#
|
|
13
|
+
# Everything the SDK does goes through one of three transports — HTTP for
|
|
14
|
+
# REST, SSE for streaming, WebSocket for push — and all three authenticate
|
|
15
|
+
# the same way: the account API key, sent as a bearer token. There is no
|
|
16
|
+
# second credential path. What a key may do, and how much of it, is decided
|
|
17
|
+
# server-side from the subscription behind that key.
|
|
18
|
+
class Transport
|
|
19
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :api_key, :base_url
|
|
22
|
+
|
|
23
|
+
def initialize(api_key, base_url: "https://api.misar.io/mail", max_retries: 3, timeout: 30)
|
|
24
|
+
raise ArgumentError, "A MisarMail API key is required" if api_key.nil? || api_key.empty?
|
|
25
|
+
|
|
26
|
+
@api_key = api_key
|
|
27
|
+
@base_url = base_url.chomp("/")
|
|
28
|
+
@max_retries = max_retries
|
|
29
|
+
@timeout = timeout
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def request(method, path, body = nil)
|
|
33
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
34
|
+
attempt = 0
|
|
35
|
+
|
|
36
|
+
loop do
|
|
37
|
+
response = perform(uri, method, body)
|
|
38
|
+
|
|
39
|
+
if RETRYABLE_STATUSES.include?(response.code.to_i) && attempt < @max_retries - 1
|
|
40
|
+
sleep(backoff(attempt, response))
|
|
41
|
+
attempt += 1
|
|
42
|
+
next
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
return decode(response)
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
raise if e.is_a?(MisarMail::Error)
|
|
48
|
+
|
|
49
|
+
if attempt < @max_retries - 1
|
|
50
|
+
sleep(backoff(attempt))
|
|
51
|
+
attempt += 1
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
raise MisarMail::NetworkError.new(e.message)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def headers
|
|
59
|
+
{
|
|
60
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
61
|
+
"Content-Type" => "application/json"
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def perform(uri, method, body)
|
|
68
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
69
|
+
http.use_ssl = uri.scheme == "https"
|
|
70
|
+
http.read_timeout = @timeout
|
|
71
|
+
http.open_timeout = @timeout
|
|
72
|
+
|
|
73
|
+
request_class = Net::HTTP.const_get(method.to_s.capitalize)
|
|
74
|
+
req = request_class.new(uri.request_uri, headers)
|
|
75
|
+
req.body = JSON.generate(body) unless body.nil?
|
|
76
|
+
http.request(req)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def decode(response)
|
|
80
|
+
status = response.code.to_i
|
|
81
|
+
data = begin
|
|
82
|
+
response.body.nil? || response.body.empty? ? {} : JSON.parse(response.body)
|
|
83
|
+
rescue JSON::ParserError
|
|
84
|
+
{}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if status >= 400
|
|
88
|
+
message = data["error"] || data["message"] || response.message
|
|
89
|
+
raise MisarMail::Error.new(status, message.to_s, data["error_type"] || "api_error", data)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
data
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Exponential backoff, but honour Retry-After when the server sends one:
|
|
96
|
+
# on a 429 the server knows when the window reopens, and guessing wastes
|
|
97
|
+
# the caller's remaining budget.
|
|
98
|
+
def backoff(attempt, response = nil)
|
|
99
|
+
if response
|
|
100
|
+
header = response["retry-after"]
|
|
101
|
+
return [header.to_f, 60].min if header && header.to_f.positive?
|
|
102
|
+
end
|
|
103
|
+
0.2 * (2**attempt)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module MisarMail
|
|
6
|
+
# Inbound webhook signature verification.
|
|
7
|
+
#
|
|
8
|
+
# MisarMail signs each webhook as HMAC-SHA256(timestamp + "." + raw_body) with
|
|
9
|
+
# the endpoint's signing secret, sending the digest in X-Misar-Signature and
|
|
10
|
+
# the Unix timestamp in X-Misar-Timestamp.
|
|
11
|
+
#
|
|
12
|
+
# Verify against the RAW body, not a re-serialized hash: key order and
|
|
13
|
+
# whitespace both change the digest. The comparison is constant-time so a
|
|
14
|
+
# timing oracle cannot recover the digest byte by byte.
|
|
15
|
+
module Webhooks
|
|
16
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Returns true when the signature is authentic and the timestamp is fresh.
|
|
21
|
+
# Never raises on malformed input — a bad signature is false, not an error.
|
|
22
|
+
def verify(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE_SECONDS)
|
|
23
|
+
return false if [payload, signature, timestamp, secret].any? { |v| v.nil? || v.to_s.empty? }
|
|
24
|
+
|
|
25
|
+
sent_at = Float(timestamp) rescue (return false)
|
|
26
|
+
|
|
27
|
+
# Rejecting stale timestamps is what stops a captured request from being
|
|
28
|
+
# replayed forever, so this is a real check rather than a formality.
|
|
29
|
+
return false if (Time.now.to_f - sent_at).abs > tolerance
|
|
30
|
+
|
|
31
|
+
secure_compare(sign(payload, timestamp, secret), signature.strip)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Constant-time comparison. OpenSSL.fixed_length_secure_compare exists only
|
|
35
|
+
# on newer Rubies and raises on length mismatch (which itself leaks length),
|
|
36
|
+
# so compare lengths first and then XOR every byte regardless of where the
|
|
37
|
+
# first difference is.
|
|
38
|
+
def secure_compare(expected, actual)
|
|
39
|
+
return false unless expected.bytesize == actual.bytesize
|
|
40
|
+
|
|
41
|
+
difference = 0
|
|
42
|
+
expected.bytes.zip(actual.bytes) { |a, b| difference |= a ^ b }
|
|
43
|
+
difference.zero?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Produces the digest MisarMail sends. Exported because verification is only
|
|
47
|
+
# half the job: testing a webhook consumer needs a valid signature, and the
|
|
48
|
+
# exact framing is where that usually goes wrong.
|
|
49
|
+
def sign(payload, timestamp, secret)
|
|
50
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module MisarMail
|
|
2
|
+
class ApiError < StandardError
|
|
3
|
+
attr_reader :status, :error_type
|
|
4
|
+
|
|
5
|
+
def initialize(status, message, error_type = "api_error")
|
|
6
|
+
@status = status
|
|
7
|
+
@error_type = error_type
|
|
8
|
+
super("misar-mail: API error #{status} (#{error_type}): #{message}")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.from_response(response)
|
|
12
|
+
body = begin
|
|
13
|
+
JSON.parse(response.body)
|
|
14
|
+
rescue StandardError
|
|
15
|
+
{}
|
|
16
|
+
end
|
|
17
|
+
new(response.status, body["error"] || response.reason_phrase || "unknown error")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised when the subscription attached to the API key blocks the call.
|
|
22
|
+
#
|
|
23
|
+
# MisarMail meters per-plan server-side: a spent allowance answers 429 and a
|
|
24
|
+
# feature not on the plan answers 402. Raised as its own class rather than a
|
|
25
|
+
# generic 429 because retrying cannot help until the allowance resets or the
|
|
26
|
+
# plan changes — the client stops retrying on sight.
|
|
27
|
+
class PlanLimitError < ApiError
|
|
28
|
+
# @return [String, nil] the account's current plan slug
|
|
29
|
+
attr_reader :plan
|
|
30
|
+
# @return [String, nil] pricing page to send the user to
|
|
31
|
+
attr_reader :upgrade_url
|
|
32
|
+
# @return [Integer, nil] seconds until the allowance resets
|
|
33
|
+
attr_reader :retry_after
|
|
34
|
+
# @return [String, nil] the allowance that was exhausted
|
|
35
|
+
attr_reader :feature
|
|
36
|
+
|
|
37
|
+
def initialize(status, message, body = nil, headers = {})
|
|
38
|
+
body ||= {}
|
|
39
|
+
headers = (headers || {}).transform_keys { |k| k.to_s.downcase }
|
|
40
|
+
offer = body["upgrade"].is_a?(Hash) ? body["upgrade"] : {}
|
|
41
|
+
# Headers are authoritative; the offer body is the fallback when a proxy
|
|
42
|
+
# has stripped them.
|
|
43
|
+
@plan = headers["x-misar-plan"] || offer["currentPlanSlug"] ||
|
|
44
|
+
offer.dig("current_plan", "slug")
|
|
45
|
+
@upgrade_url = headers["x-misar-upgrade-url"] || offer.dig("urls", "pricing")
|
|
46
|
+
ra = headers["retry-after"]
|
|
47
|
+
@retry_after = ra.to_s.match?(/\A\d+\z/) ? ra.to_i : nil
|
|
48
|
+
@feature = offer["feature"]
|
|
49
|
+
super(status, message, "plan_limit_exceeded")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class NetworkError < ApiError
|
|
54
|
+
def initialize(message, cause = nil)
|
|
55
|
+
super(0, message, "network_error")
|
|
56
|
+
@cause = cause
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/misar_mail.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: misarmail
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Misar AI
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-18 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rspec
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.13'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.13'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: webmock
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.23'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.23'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: simplecov
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0.22'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0.22'
|
|
55
|
+
description: Full-featured Ruby SDK for the MisarMail API (misarmail.com). Covers
|
|
56
|
+
all 24 resource groups and 101 methods.
|
|
57
|
+
email:
|
|
58
|
+
- hello@misar.io
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- CHANGELOG.md
|
|
64
|
+
- LICENSE
|
|
65
|
+
- README.md
|
|
66
|
+
- lib/misar_mail.rb
|
|
67
|
+
- lib/misar_mail/client.rb
|
|
68
|
+
- lib/misar_mail/core/query.rb
|
|
69
|
+
- lib/misar_mail/core/sse.rb
|
|
70
|
+
- lib/misar_mail/core/transport.rb
|
|
71
|
+
- lib/misar_mail/core/webhooks.rb
|
|
72
|
+
- lib/misar_mail/errors.rb
|
|
73
|
+
homepage: https://misarmail.com/docs/sdks/ruby
|
|
74
|
+
licenses:
|
|
75
|
+
- MIT
|
|
76
|
+
metadata:
|
|
77
|
+
homepage_uri: https://misarmail.com/docs/sdks/ruby
|
|
78
|
+
source_code_uri: https://github.com/Misar-AI/misarmail-sdks
|
|
79
|
+
changelog_uri: https://github.com/Misar-AI/misarmail-sdks/blob/main/ruby/CHANGELOG.md
|
|
80
|
+
bug_tracker_uri: https://github.com/Misar-AI/misarmail-sdks/issues
|
|
81
|
+
post_install_message:
|
|
82
|
+
rdoc_options: []
|
|
83
|
+
require_paths:
|
|
84
|
+
- lib
|
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '2.7'
|
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
requirements: []
|
|
96
|
+
rubygems_version: 3.5.22
|
|
97
|
+
signing_key:
|
|
98
|
+
specification_version: 4
|
|
99
|
+
summary: Official Ruby SDK for MisarMail — transactional email, campaigns, leads,
|
|
100
|
+
CRM
|
|
101
|
+
test_files: []
|