mailcheckr 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/Gemfile +9 -0
- data/README.md +134 -0
- data/Rakefile +11 -0
- data/bin/console +10 -0
- data/bin/setup +8 -0
- data/lib/mailcheckr/client.rb +98 -0
- data/lib/mailcheckr/errors.rb +66 -0
- data/lib/mailcheckr/version.rb +5 -0
- data/lib/mailcheckr/webhook.rb +24 -0
- data/lib/mailcheckr.rb +6 -0
- data/spec/client_spec.rb +159 -0
- data/spec/webhook_spec.rb +71 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6961901669d98e1acf873bf68523f2a6701600ccac4cca46c69e4bc7870f5da3
|
|
4
|
+
data.tar.gz: 207b9219579311d9c782194964b63334845e49e6b5276a58c51fd1c41fac7a87
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 47d996133599bad00402124c4935bbc02eb5bef7f741418e8cccaefeda62f14576c4960b22700470595190c74799ed1024d3f5aadcab37e36b40062f4f688c89
|
|
7
|
+
data.tar.gz: 1eda2d93b87fee25cc456d4212918084eeab23e49ed62a172235342383e31c760a5660ecd3a37c97438f1e7340573b8c9869cc6c3afa66560011f150dcf8f3e2
|
data/Gemfile
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# MailCheckr Ruby SDK
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Eazybright/mailcheckr/actions/workflows/test.yml)
|
|
4
|
+
[](https://rubygems.org/gems/mailcheckr)
|
|
5
|
+
[](https://rubygems.org/gems/mailcheckr/)
|
|
6
|
+
|
|
7
|
+
A minimal Ruby client for the [MailCheckr API](https://mailcheckr.app/docs). It uses Ruby's
|
|
8
|
+
standard HTTP and JSON libraries.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
Ruby 3.2 or newer.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
You can install the client library via RubyGems:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem install mailcheckr
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
or add it to your Gemfile:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "mailcheckr"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then run `bundle install`.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
Create a client with an API key from your MailCheckr dashboard:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
require "mailcheckr"
|
|
36
|
+
|
|
37
|
+
client = Mailcheckr::Client.new(api_key: ENV.fetch("MAILCHECKR_API_KEY"))
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Create a verification. MailCheckr requires an `Idempotency-Key`; use a stable
|
|
41
|
+
unique value when retrying the same operation. The result remains hash-like,
|
|
42
|
+
and `http_status` distinguishes an immediately completed response from one to
|
|
43
|
+
poll:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
result = client.create_verification(
|
|
47
|
+
email: "person@example.com",
|
|
48
|
+
idempotency_key: "signup-123"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
case result.http_status
|
|
52
|
+
when 200
|
|
53
|
+
puts result["status"]
|
|
54
|
+
when 202
|
|
55
|
+
result = client.get_verification(id: result.fetch("id"))
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Creation returns the parsed `data` object as a hash-like
|
|
60
|
+
`Mailcheckr::VerificationResponse`. Both `200 OK` and `202 Accepted` responses
|
|
61
|
+
include `http_status`; poll the returned ID after `202` until the state is
|
|
62
|
+
`completed` or `failed`.
|
|
63
|
+
|
|
64
|
+
The client also accepts a custom base URL and HTTP timeouts:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
client = Mailcheckr::Client.new(
|
|
68
|
+
api_key: ENV.fetch("MAILCHECKR_API_KEY"),
|
|
69
|
+
base_url: "https://mailcheckr.app/api/v1",
|
|
70
|
+
open_timeout: 5,
|
|
71
|
+
read_timeout: 30
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Webhook signatures
|
|
76
|
+
|
|
77
|
+
Verify webhook deliveries using the exact raw request body, the
|
|
78
|
+
`X-MailCheckr-Timestamp` header, the `X-MailCheckr-Signature` header, and the
|
|
79
|
+
signing secret shown when configuring the webhook:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
valid = Mailcheckr::Webhook.verify_signature(
|
|
83
|
+
raw_body: request.raw_post,
|
|
84
|
+
timestamp: request.headers["X-MailCheckr-Timestamp"],
|
|
85
|
+
signature: request.headers["X-MailCheckr-Signature"],
|
|
86
|
+
secret: ENV.fetch("MAILCHECKR_WEBHOOK_SECRET")
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
halt 401 unless valid
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
MailCheckr signs `timestamp + "." + raw_body` with HMAC-SHA256 and sends the
|
|
93
|
+
hex digest as `v1=<digest>`. Verification uses a constant-time comparison and
|
|
94
|
+
rejects timestamps more than five minutes away from the current time. Set the
|
|
95
|
+
`tolerance:` option in seconds to change that window.
|
|
96
|
+
|
|
97
|
+
## Errors
|
|
98
|
+
|
|
99
|
+
Non-2xx responses raise `Mailcheckr::APIError` (or a more specific subclass).
|
|
100
|
+
Errors expose `status`/`status_code`, parsed `response`, raw `response_body`,
|
|
101
|
+
API `code`, and `request_id` when the server supplies an `X-Request-Id` header.
|
|
102
|
+
Their message includes the HTTP status and API message/code when present.
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
begin
|
|
106
|
+
client.create_verification(email: email, idempotency_key: key)
|
|
107
|
+
rescue Mailcheckr::InsufficientCreditsError => error
|
|
108
|
+
logger.error("MailCheckr #{error.status} #{error.code}: #{error.message}")
|
|
109
|
+
rescue Mailcheckr::APIError => error
|
|
110
|
+
logger.error("MailCheckr request failed: #{error.status} #{error.response_body}")
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Documented error statuses map to `AuthenticationError` (401),
|
|
115
|
+
`InsufficientCreditsError` (402), `EmailVerificationRequiredError` (403),
|
|
116
|
+
`IdempotencyConflictError` (409), and `ValidationError` (422). All inherit
|
|
117
|
+
from `APIError`. Invalid JSON or an unexpected response envelope on a
|
|
118
|
+
successful status raises `Mailcheckr::ResponseError`. Network and timeout
|
|
119
|
+
errors from `Net::HTTP` are propagated to the caller.
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
Run the test suite with:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
bin/setup
|
|
127
|
+
bundle exec rake test
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Start an interactive Ruby console with:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
bin/console
|
|
134
|
+
```
|
data/Rakefile
ADDED
data/bin/console
ADDED
data/bin/setup
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Mailcheckr
|
|
9
|
+
class VerificationResponse < Hash
|
|
10
|
+
attr_reader :http_status
|
|
11
|
+
|
|
12
|
+
def initialize(http_status:, data:)
|
|
13
|
+
super()
|
|
14
|
+
update(data)
|
|
15
|
+
@http_status = http_status
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Client
|
|
20
|
+
DEFAULT_BASE_URL = "https://mailcheckr.app/api/v1"
|
|
21
|
+
|
|
22
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, open_timeout: 5, read_timeout: 30)
|
|
23
|
+
raise ArgumentError, "api_key must not be empty" if api_key.nil? || api_key.empty?
|
|
24
|
+
|
|
25
|
+
@api_key = api_key
|
|
26
|
+
@base_url = normalize_base_url(base_url)
|
|
27
|
+
@open_timeout = open_timeout
|
|
28
|
+
@read_timeout = read_timeout
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create_verification(email:, idempotency_key:)
|
|
32
|
+
raise ArgumentError, "idempotency_key must not be empty" if idempotency_key.nil? || idempotency_key.empty?
|
|
33
|
+
|
|
34
|
+
request(
|
|
35
|
+
Net::HTTP::Post,
|
|
36
|
+
"verifications",
|
|
37
|
+
body: { email: email },
|
|
38
|
+
idempotency_key: idempotency_key
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def get_verification(id:)
|
|
43
|
+
raise ArgumentError, "id must not be empty" if id.nil? || id.to_s.empty?
|
|
44
|
+
|
|
45
|
+
encoded_id = URI.encode_www_form_component(id.to_s).gsub("+", "%20")
|
|
46
|
+
request(Net::HTTP::Get, "verifications/#{encoded_id}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def normalize_base_url(base_url)
|
|
52
|
+
uri = URI.parse(base_url)
|
|
53
|
+
unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
|
|
54
|
+
raise ArgumentError, "base_url must be an HTTP(S) URL without credentials, query, or fragment"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
uri.to_s.sub(%r{/+\z}, "")
|
|
58
|
+
rescue URI::InvalidURIError
|
|
59
|
+
raise ArgumentError, "base_url must be a valid HTTP(S) URL"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def request(http_method, path, body: nil, idempotency_key: nil)
|
|
63
|
+
uri = URI.join("#{@base_url}/", path)
|
|
64
|
+
request = http_method.new(uri)
|
|
65
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
66
|
+
request["Accept"] = "application/json"
|
|
67
|
+
|
|
68
|
+
unless body.nil?
|
|
69
|
+
request["Content-Type"] = "application/json"
|
|
70
|
+
request["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
71
|
+
request.body = JSON.generate(body)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
response = Net::HTTP.start(
|
|
75
|
+
uri.host,
|
|
76
|
+
uri.port,
|
|
77
|
+
use_ssl: uri.scheme == "https",
|
|
78
|
+
open_timeout: @open_timeout,
|
|
79
|
+
read_timeout: @read_timeout
|
|
80
|
+
) { |http| http.request(request) }
|
|
81
|
+
|
|
82
|
+
status = response.code.to_i
|
|
83
|
+
response_body = response.body.to_s
|
|
84
|
+
request_id = response["X-Request-Id"]
|
|
85
|
+
unless (200..299).cover?(status)
|
|
86
|
+
raise APIError.for_response(status: status, response_body: response_body, request_id: request_id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
parsed_body = JSON.parse(response_body)
|
|
90
|
+
data = parsed_body.is_a?(Hash) ? parsed_body["data"] : nil
|
|
91
|
+
raise ResponseError.new(status: status, response_body: response_body) unless data.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
VerificationResponse.new(http_status: status, data: data)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
raise ResponseError.new(status: status, response_body: response_body)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mailcheckr
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
class APIError < Error
|
|
9
|
+
attr_reader :status, :response_body, :response, :code, :request_id
|
|
10
|
+
alias status_code status
|
|
11
|
+
|
|
12
|
+
def self.for_response(status:, response_body:, request_id: nil)
|
|
13
|
+
error_class = {
|
|
14
|
+
401 => AuthenticationError,
|
|
15
|
+
402 => InsufficientCreditsError,
|
|
16
|
+
403 => EmailVerificationRequiredError,
|
|
17
|
+
409 => IdempotencyConflictError,
|
|
18
|
+
422 => ValidationError
|
|
19
|
+
}[status] || self
|
|
20
|
+
error_class.new(status: status, response_body: response_body, request_id: request_id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(status:, response_body:, request_id: nil)
|
|
24
|
+
@status = status
|
|
25
|
+
@response_body = response_body
|
|
26
|
+
@request_id = request_id
|
|
27
|
+
@response = parse_response(response_body)
|
|
28
|
+
error_details = response.is_a?(Hash) ? response["error"] : nil
|
|
29
|
+
@code = response.is_a?(Hash) ? response["code"] : nil
|
|
30
|
+
@code ||= error_details["code"] if error_details.is_a?(Hash)
|
|
31
|
+
@code ||= error_details if error_details.is_a?(String)
|
|
32
|
+
message_detail = response.is_a?(Hash) ? response["message"] : nil
|
|
33
|
+
message_detail ||= error_details["message"] if error_details.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
message = "MailCheckr API request failed with HTTP #{status}"
|
|
36
|
+
detail = [message_detail, code].find { |value| value.is_a?(String) && !value.empty? }
|
|
37
|
+
message = "#{message}: #{detail}" if detail
|
|
38
|
+
message = "#{message} (request ID: #{request_id})" if request_id && !request_id.empty?
|
|
39
|
+
super(message)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def parse_response(body)
|
|
45
|
+
JSON.parse(body)
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class AuthenticationError < APIError; end
|
|
52
|
+
class InsufficientCreditsError < APIError; end
|
|
53
|
+
class EmailVerificationRequiredError < APIError; end
|
|
54
|
+
class IdempotencyConflictError < APIError; end
|
|
55
|
+
class ValidationError < APIError; end
|
|
56
|
+
|
|
57
|
+
class ResponseError < Error
|
|
58
|
+
attr_reader :status, :response_body
|
|
59
|
+
|
|
60
|
+
def initialize(status:, response_body:)
|
|
61
|
+
@status = status
|
|
62
|
+
@response_body = response_body
|
|
63
|
+
super("MailCheckr API returned invalid JSON with HTTP #{status}")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Mailcheckr
|
|
6
|
+
module Webhook
|
|
7
|
+
DEFAULT_TOLERANCE = 300
|
|
8
|
+
|
|
9
|
+
def self.verify_signature(raw_body:, timestamp:, signature:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
|
|
10
|
+
tolerance = Integer(tolerance)
|
|
11
|
+
raise ArgumentError, "tolerance must not be negative" if tolerance.negative?
|
|
12
|
+
|
|
13
|
+
return false unless raw_body.is_a?(String) && secret.is_a?(String) && !secret.empty?
|
|
14
|
+
return false unless timestamp.is_a?(String) && timestamp.match?(/\A\d+\z/)
|
|
15
|
+
return false unless signature.is_a?(String) && signature.match?(/\Av1=[a-fA-F0-9]{64}\z/)
|
|
16
|
+
|
|
17
|
+
timestamp_value = Integer(timestamp, 10)
|
|
18
|
+
return false if (now.to_i - timestamp_value).abs > tolerance
|
|
19
|
+
|
|
20
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
|
|
21
|
+
OpenSSL.fixed_length_secure_compare(expected, signature.delete_prefix("v1=").downcase)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/mailcheckr.rb
ADDED
data/spec/client_spec.rb
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "minitest/mock"
|
|
5
|
+
require "mailcheckr"
|
|
6
|
+
|
|
7
|
+
class MailcheckrClientTest < Minitest::Test
|
|
8
|
+
Response = Struct.new(:code, :body, :headers) do
|
|
9
|
+
def [](name)
|
|
10
|
+
headers[name]
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class FakeHTTP
|
|
15
|
+
attr_reader :last_request
|
|
16
|
+
|
|
17
|
+
def initialize(response)
|
|
18
|
+
@response = response
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def request(request)
|
|
22
|
+
@last_request = request
|
|
23
|
+
@response
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def with_response(code: "200", body: '{"data":{"id":"verification-123","state":"completed"}}', request_id: nil)
|
|
28
|
+
headers = request_id ? { "X-Request-Id" => request_id } : {}
|
|
29
|
+
http = FakeHTTP.new(Response.new(code, body, headers))
|
|
30
|
+
start = lambda do |_host, _port, **_options, &block|
|
|
31
|
+
block.call(http)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Net::HTTP.stub(:start, start) { yield http }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def test_create_verification_sends_bearer_auth_idempotency_key_and_email
|
|
38
|
+
with_response do |http|
|
|
39
|
+
result = Mailcheckr::Client.new(api_key: "secret").create_verification(
|
|
40
|
+
email: "person@example.com",
|
|
41
|
+
idempotency_key: "verify-person-123"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
assert_equal "verification-123", result["id"]
|
|
45
|
+
assert_equal 200, result.http_status
|
|
46
|
+
assert_equal "Bearer secret", http.last_request["Authorization"]
|
|
47
|
+
assert_equal "verify-person-123", http.last_request["Idempotency-Key"]
|
|
48
|
+
assert_equal "application/json", http.last_request["Content-Type"]
|
|
49
|
+
assert_equal({ "email" => "person@example.com" }, JSON.parse(http.last_request.body))
|
|
50
|
+
assert_equal "/api/v1/verifications", http.last_request.path
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_get_verification_quotes_id_as_a_path_segment
|
|
55
|
+
with_response do |http|
|
|
56
|
+
Mailcheckr::Client.new(api_key: "secret").get_verification(id: "verify/123")
|
|
57
|
+
|
|
58
|
+
assert_equal "/api/v1/verifications/verify%2F123", http.last_request.path
|
|
59
|
+
assert_nil http.last_request["Idempotency-Key"]
|
|
60
|
+
assert_equal "Bearer secret", http.last_request["Authorization"]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def test_accepts_a_202_response_for_async_verification
|
|
65
|
+
with_response(code: "202", body: '{"data":{"id":"verification-123","state":"processing"}}') do
|
|
66
|
+
result = Mailcheckr::Client.new(api_key: "secret").create_verification(
|
|
67
|
+
email: "person@example.com",
|
|
68
|
+
idempotency_key: "verify-person-123"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
assert_equal "processing", result["state"]
|
|
72
|
+
assert_equal 202, result.http_status
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def test_raises_api_error_for_non_success_responses
|
|
77
|
+
with_response(
|
|
78
|
+
code: "409",
|
|
79
|
+
body: '{"error":"idempotency_conflict"}',
|
|
80
|
+
request_id: "req_123"
|
|
81
|
+
) do
|
|
82
|
+
error = assert_raises(Mailcheckr::APIError) do
|
|
83
|
+
Mailcheckr::Client.new(api_key: "secret").create_verification(
|
|
84
|
+
email: "person@example.com",
|
|
85
|
+
idempotency_key: "verify-person-123"
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
assert_equal 409, error.status
|
|
90
|
+
assert_equal 409, error.status_code
|
|
91
|
+
assert_equal "idempotency_conflict", error.code
|
|
92
|
+
assert_equal "req_123", error.request_id
|
|
93
|
+
assert_equal '{"error":"idempotency_conflict"}', error.response_body
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_maps_documented_error_statuses_to_specific_error_classes
|
|
98
|
+
cases = {
|
|
99
|
+
"401" => Mailcheckr::AuthenticationError,
|
|
100
|
+
"402" => Mailcheckr::InsufficientCreditsError,
|
|
101
|
+
"403" => Mailcheckr::EmailVerificationRequiredError,
|
|
102
|
+
"409" => Mailcheckr::IdempotencyConflictError,
|
|
103
|
+
"422" => Mailcheckr::ValidationError
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
cases.each do |status, error_class|
|
|
107
|
+
with_response(code: status, body: '{"code":"api_error","message":"Request rejected"}') do
|
|
108
|
+
error = assert_raises(error_class) do
|
|
109
|
+
Mailcheckr::Client.new(api_key: "secret").get_verification(id: "verification-123")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
assert_equal status.to_i, error.status
|
|
113
|
+
assert_equal "api_error", error.code
|
|
114
|
+
assert_equal "Request rejected", error.response["message"]
|
|
115
|
+
assert_includes error.message, "Request rejected"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def test_extracts_error_details_from_nested_error_object
|
|
121
|
+
with_response(code: "402", body: '{"error":{"code":"insufficient_credits","message":"No credits left"}}') do
|
|
122
|
+
error = assert_raises(Mailcheckr::InsufficientCreditsError) do
|
|
123
|
+
Mailcheckr::Client.new(api_key: "secret").get_verification(id: "verification-123")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
assert_equal "insufficient_credits", error.code
|
|
127
|
+
assert_includes error.message, "No credits left"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def test_raises_response_error_for_invalid_json
|
|
132
|
+
with_response(body: "not json") do
|
|
133
|
+
error = assert_raises(Mailcheckr::ResponseError) do
|
|
134
|
+
Mailcheckr::Client.new(api_key: "secret").get_verification(id: "verification-123")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
assert_equal 200, error.status
|
|
138
|
+
assert_equal "not json", error.response_body
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def test_raises_response_error_when_data_envelope_is_missing
|
|
143
|
+
with_response(body: '{"id":"verification-123","state":"completed"}') do
|
|
144
|
+
error = assert_raises(Mailcheckr::ResponseError) do
|
|
145
|
+
Mailcheckr::Client.new(api_key: "secret").get_verification(id: "verification-123")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
assert_equal 200, error.status
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def test_requires_an_api_key_and_idempotency_key
|
|
153
|
+
assert_raises(ArgumentError) { Mailcheckr::Client.new(api_key: "") }
|
|
154
|
+
client = Mailcheckr::Client.new(api_key: "secret")
|
|
155
|
+
assert_raises(ArgumentError) do
|
|
156
|
+
client.create_verification(email: "person@example.com", idempotency_key: "")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "mailcheckr/webhook"
|
|
6
|
+
|
|
7
|
+
class MailcheckrWebhookSpec < Minitest::Test
|
|
8
|
+
SECRET = "webhook-secret"
|
|
9
|
+
RAW_BODY = '{"id":"event_123","type":"verification.completed"}'
|
|
10
|
+
NOW = Time.at(1_800_000_000)
|
|
11
|
+
|
|
12
|
+
def signed_headers(timestamp: NOW.to_i.to_s, raw_body: RAW_BODY)
|
|
13
|
+
signed_payload = "#{timestamp}.#{raw_body}"
|
|
14
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", SECRET, signed_payload)
|
|
15
|
+
{ timestamp: timestamp, signature: "v1=#{signature}" }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def verify(raw_body: RAW_BODY, timestamp: NOW.to_i.to_s, signature: signed_headers[:signature], secret: SECRET,
|
|
19
|
+
**)
|
|
20
|
+
Mailcheckr::Webhook.verify_signature(
|
|
21
|
+
raw_body: raw_body,
|
|
22
|
+
timestamp: timestamp,
|
|
23
|
+
signature: signature,
|
|
24
|
+
secret: secret,
|
|
25
|
+
now: NOW,
|
|
26
|
+
**
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_accepts_a_valid_signature
|
|
31
|
+
headers = signed_headers
|
|
32
|
+
|
|
33
|
+
assert Mailcheckr::Webhook.verify_signature(
|
|
34
|
+
raw_body: RAW_BODY,
|
|
35
|
+
timestamp: headers[:timestamp],
|
|
36
|
+
signature: headers[:signature],
|
|
37
|
+
secret: SECRET,
|
|
38
|
+
now: NOW
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_rejects_a_modified_body_or_signature
|
|
43
|
+
refute verify(raw_body: "#{RAW_BODY} ")
|
|
44
|
+
refute verify(signature: "v1=#{"0" * 64}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_rejects_timestamps_outside_tolerance
|
|
48
|
+
stale = (NOW.to_i - Mailcheckr::Webhook::DEFAULT_TOLERANCE - 1).to_s
|
|
49
|
+
future = (NOW.to_i + Mailcheckr::Webhook::DEFAULT_TOLERANCE + 1).to_s
|
|
50
|
+
|
|
51
|
+
refute verify(timestamp: stale, signature: signed_headers(timestamp: stale)[:signature])
|
|
52
|
+
refute verify(timestamp: future, signature: signed_headers(timestamp: future)[:signature])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_accepts_timestamp_inside_custom_tolerance
|
|
56
|
+
timestamp = (NOW.to_i - 600).to_s
|
|
57
|
+
signature = signed_headers(timestamp: timestamp)[:signature]
|
|
58
|
+
|
|
59
|
+
assert verify(timestamp: timestamp, signature: signature, tolerance: 600)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_rejects_malformed_headers_and_empty_secret
|
|
63
|
+
refute verify(timestamp: "not-a-timestamp")
|
|
64
|
+
refute verify(signature: "sha256=invalid")
|
|
65
|
+
refute verify(secret: "")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def test_rejects_negative_tolerance
|
|
69
|
+
assert_raises(ArgumentError) { verify(tolerance: -1) }
|
|
70
|
+
end
|
|
71
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mailcheckr
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kolawole Ezekiel
|
|
8
|
+
- Ridwan Kasim
|
|
9
|
+
autorequire:
|
|
10
|
+
bindir: bin
|
|
11
|
+
cert_chain: []
|
|
12
|
+
date: 2026-10-31 00:00:00.000000000 Z
|
|
13
|
+
dependencies: []
|
|
14
|
+
description: A Ruby SDK for creating and retrieving MailCheckr verifications.
|
|
15
|
+
email:
|
|
16
|
+
- doneazy911@gmail.com
|
|
17
|
+
- kasimsheyi20@gmail.com
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- Gemfile
|
|
23
|
+
- README.md
|
|
24
|
+
- Rakefile
|
|
25
|
+
- bin/console
|
|
26
|
+
- bin/setup
|
|
27
|
+
- lib/mailcheckr.rb
|
|
28
|
+
- lib/mailcheckr/client.rb
|
|
29
|
+
- lib/mailcheckr/errors.rb
|
|
30
|
+
- lib/mailcheckr/version.rb
|
|
31
|
+
- lib/mailcheckr/webhook.rb
|
|
32
|
+
- spec/client_spec.rb
|
|
33
|
+
- spec/webhook_spec.rb
|
|
34
|
+
homepage: https://mailcheckr.app/docs
|
|
35
|
+
licenses:
|
|
36
|
+
- MIT
|
|
37
|
+
metadata:
|
|
38
|
+
homepage_uri: https://mailcheckr.app/docs
|
|
39
|
+
source_code_uri: https://github.com/Eazybright/mailcheckr
|
|
40
|
+
rubygems_mfa_required: 'true'
|
|
41
|
+
post_install_message:
|
|
42
|
+
rdoc_options: []
|
|
43
|
+
require_paths:
|
|
44
|
+
- lib
|
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '3.2'
|
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
requirements: []
|
|
56
|
+
rubygems_version: 3.4.1
|
|
57
|
+
signing_key:
|
|
58
|
+
specification_version: 4
|
|
59
|
+
summary: Ruby client for the MailCheckr API
|
|
60
|
+
test_files: []
|