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.
@@ -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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "misar_mail/errors"
4
+ require_relative "misar_mail/core/webhooks"
5
+ require_relative "misar_mail/client"
6
+
7
+ module MisarMail
8
+ def self.new(**kwargs)
9
+ Client.new(**kwargs)
10
+ end
11
+ end
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: []