mailfloss 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: ae5fcc08d152608c98088bd9860eaaceaf47591f7ecca3b23e0a40b9260b4147
4
+ data.tar.gz: 2392c5185dba55e21924bbcdd1ee5593008d8ebe2111189dd5287461f39cf431
5
+ SHA512:
6
+ metadata.gz: d22fc1c0323c1b97bca607b1caea7287cc29d674fbccf1cf7cd9c2233c60019b854ed01430601af1fb2219594848bdb1c5b672486e2b7acfb3ce4afffd44d9bf
7
+ data.tar.gz: 500f1b5415426ab0ef8036a7ef5539b694f4284787362f0db22bf527fff040b5c2e14634a5fe156b7c5b207ad47219f1ba9846a929a3403f1511e2285830ac22
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-07-06)
4
+
5
+ Initial release.
6
+
7
+ - Full coverage of the mailfloss v1 public API (all 26 endpoints):
8
+ verification (`/verify`, `/batch-verify` + status/results/cancel),
9
+ jobs, users, usage reports, `/check-key`, account (read + update),
10
+ organization, integrations (list/detail + connection
11
+ create/get/update/delete/sync/test + keyword rule list/add/delete),
12
+ and erasures.
13
+ - Zero runtime dependencies — stdlib `Net::HTTP`, `JSON`, `SecureRandom`.
14
+ - Automatic retries on 429/5xx (honoring `Retry-After`) and transient
15
+ connection errors, with exponential backoff and full jitter.
16
+ - Automatic `Idempotency-Key` on every POST (caller-overridable).
17
+ - Typed errors: `Mailfloss::APIError` with `status`, `code`, `type`,
18
+ `request_id`.
19
+ - RBS type signatures under `sig/`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mailfloss
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,203 @@
1
+ # mailfloss — official Ruby SDK
2
+
3
+ Ruby client for the [mailfloss](https://mailfloss.com) email verification API.
4
+ Zero runtime dependencies (stdlib `Net::HTTP`), Ruby 2.6+.
5
+
6
+ API reference: https://developers.mailfloss.com
7
+
8
+ ## Installation
9
+
10
+ ```sh
11
+ gem install mailfloss
12
+ ```
13
+
14
+ Or in your Gemfile:
15
+
16
+ ```ruby
17
+ gem "mailfloss", "~> 0.1"
18
+ ```
19
+
20
+ ## Authentication
21
+
22
+ Grab an API key from your mailfloss dashboard, then either pass it
23
+ directly:
24
+
25
+ ```ruby
26
+ require "mailfloss"
27
+
28
+ client = Mailfloss::Client.new(api_key: "mf_rk_your_key")
29
+ ```
30
+
31
+ ...or set the `MAILFLOSS_API_KEY` environment variable and omit the kwarg:
32
+
33
+ ```sh
34
+ export MAILFLOSS_API_KEY="mf_rk_your_key"
35
+ ```
36
+
37
+ ```ruby
38
+ client = Mailfloss::Client.new
39
+ ```
40
+
41
+ If no key is available in either place, `Mailfloss::ConfigurationError` is
42
+ raised. The key is sent as `Authorization: Bearer <key>` on every request.
43
+
44
+ ## Quickstart
45
+
46
+ ### Verify a single email — `GET /v1/verify`
47
+
48
+ ```ruby
49
+ result = client.verify(email: "jane@acme.com")
50
+
51
+ result[:status] # "passed" | "undeliverable" | "risky" | "unknown"
52
+ result[:passed] # true
53
+ result[:reason] # e.g. "valid"
54
+ result[:domain] # "acme.com"
55
+ result[:disposable] # false
56
+ result[:role] # false
57
+ result[:free] # false
58
+ result[:suggestion] # typo suggestion, when detected
59
+ ```
60
+
61
+ ### Batch verification — `POST /v1/batch-verify`
62
+
63
+ ```ruby
64
+ batch = client.batch_verify.create(
65
+ emails: ["jane@acme.com", "bob@invalid.example"],
66
+ webhook_url: "https://hooks.example.com/mailfloss-done" # optional callback
67
+ )
68
+ batch[:id] # => the batch job id
69
+
70
+ # Poll progress
71
+ status = client.batch_verify.status(batch[:id])
72
+ status[:status] # e.g. "processing"
73
+ status[:progress] # 0.0..1.0
74
+
75
+ # Page through results when done
76
+ page = client.batch_verify.results(batch[:id], per_page: 100)
77
+ page[:results]
78
+
79
+ # Or cancel
80
+ client.batch_verify.cancel(batch[:id]) # => { success: true }
81
+ ```
82
+
83
+ All responses are JSON parsed with **symbol keys**.
84
+
85
+ ## Full surface
86
+
87
+ | Resource | Methods |
88
+ |---|---|
89
+ | `client.verify(email:, timeout: nil)` | GET /verify |
90
+ | `client.batch_verify` | `.create(emails:, webhook_url: nil)` · `.status(id)` · `.results(id, per_page:, next_cursor:)` · `.cancel(id)` |
91
+ | `client.jobs` | `.list(per_page:, cursor:, source:, status:)` · `.get(id)` |
92
+ | `client.users` | `.list(per_page:, cursor:)` · `.get(user_id)` |
93
+ | `client.reports` | `.usage(period:, connection_id:)` — period: `this_week` / `this_month` / `this_year` / `lifetime` |
94
+ | `client.check_key(...)` | GET /check-key |
95
+ | `client.account` | `.get` · `.update(name:, organization:, phone:, vat_id:, country:, address:, notifications:)` |
96
+ | `client.organization` | `.get` — plan, credits, usage, trial, entitlements |
97
+ | `client.integrations` | `.list(per_page:, cursor:)` · `.get(type)` |
98
+ | `client.integrations.connections` | `.create(type, credentials:, name:)` · `.get(type, id)` · `.update(type, id, aggressiveness:, manual_mode:, autofloss:, decay_protection:, action:, checks:)` · `.delete(type, id)` · `.sync(type, id)` · `.test(type, id)` |
99
+ | `client.integrations.keywords` | `.list(type, id, list, per_page:, cursor:)` · `.add(type, id, list, rules:)` · `.delete(type, id, list, rule_id)` — list: `"blacklist"` / `"whitelist"` |
100
+ | `client.erasures` | `.create(emails:, webhook_url: nil)` — GDPR/CCPA data erasure |
101
+
102
+ List endpoints return a cursor-paginated envelope:
103
+
104
+ ```ruby
105
+ page = client.jobs.list(per_page: 50)
106
+ page[:data] # => [{ id:, status:, source:, ... }, ...]
107
+ page[:pagination][:has_more] # => true
108
+ page[:pagination][:next_cursor] # opaque — pass back as cursor:
109
+ ```
110
+
111
+ ### Example: connect an ESP and add keyword rules
112
+
113
+ ```ruby
114
+ conn = client.integrations.connections.create(
115
+ "klaviyo",
116
+ credentials: { api_key: "pk_..." },
117
+ name: "Main Klaviyo account"
118
+ )
119
+
120
+ client.integrations.connections.update("klaviyo", conn[:id], autofloss: true)
121
+
122
+ client.integrations.keywords.add(
123
+ "klaviyo", conn[:id], "whitelist",
124
+ rules: [{ keyword: "vip", match: "contains", applies_to: { domain: true } }]
125
+ )
126
+ ```
127
+
128
+ ## Configuration
129
+
130
+ ```ruby
131
+ client = Mailfloss::Client.new(
132
+ api_key: "mf_rk_...", # or ENV["MAILFLOSS_API_KEY"]
133
+ base_url: "https://api.mailfloss.com/v1", # default
134
+ max_retries: 3, # retries after the first attempt
135
+ timeout: 30, # seconds (open/read)
136
+ transport: nil # injectable, see below
137
+ )
138
+ ```
139
+
140
+ ### Retries
141
+
142
+ 429 and 5xx responses are retried automatically (as are transient
143
+ connection errors). A `Retry-After` header with integer seconds is
144
+ honored; otherwise exponential backoff with full jitter is used
145
+ (base 0.5s, factor 2, capped at 8s).
146
+
147
+ ### Idempotency
148
+
149
+ Every `POST` carries an `Idempotency-Key` header — a fresh
150
+ `SecureRandom.uuid` per call by default. Pass `idempotency_key:` on any
151
+ create/cancel/sync/test call to supply your own (useful when you retry at
152
+ the application level):
153
+
154
+ ```ruby
155
+ client.batch_verify.create(emails: emails, idempotency_key: "import-2026-07-06")
156
+ ```
157
+
158
+ ### Errors
159
+
160
+ Non-2xx responses raise `Mailfloss::APIError` (< `Mailfloss::Error`):
161
+
162
+ ```ruby
163
+ begin
164
+ client.jobs.get("nope")
165
+ rescue Mailfloss::APIError => e
166
+ e.status # 404
167
+ e.code # "not_found"
168
+ e.message # human-readable message
169
+ e.type # error type, when provided
170
+ e.request_id # for support requests
171
+ end
172
+ ```
173
+
174
+ ### Custom transport
175
+
176
+ The low-level transport is injectable — any object responding to
177
+ `call(method, url, headers, body)` and returning an object with `status`,
178
+ `headers`, and `body`. This is how the test suite mocks HTTP without a
179
+ network:
180
+
181
+ ```ruby
182
+ fake = ->(method, url, headers, body) {
183
+ Mailfloss::Transport::Response.new(200, {}, '{"passed":true,"email":"a@b.com","status":"passed","reason":"valid"}')
184
+ }
185
+ client = Mailfloss::Client.new(api_key: "test", transport: fake)
186
+ ```
187
+
188
+ ## Type signatures
189
+
190
+ RBS signatures for the client, resources, and all response shapes
191
+ (hand-derived from the OpenAPI spec) ship under `sig/`.
192
+
193
+ ## Development
194
+
195
+ ```sh
196
+ ruby test/run_tests.rb
197
+ # or
198
+ ruby -Ilib -Itest test/mailfloss_test.rb
199
+ ```
200
+
201
+ ## License
202
+
203
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "uri"
6
+
7
+ module Mailfloss
8
+ # Entry point for the mailfloss API.
9
+ #
10
+ # client = Mailfloss::Client.new(api_key: "mf_rk_...")
11
+ # client.verify(email: "jane@acme.com")
12
+ #
13
+ # The API key falls back to ENV["MAILFLOSS_API_KEY"] when the kwarg is
14
+ # omitted. All requests are retried automatically on 429 / 5xx (honoring
15
+ # Retry-After) and on transient connection errors.
16
+ class Client
17
+ DEFAULT_BASE_URL = "https://api.mailfloss.com/v1"
18
+ DEFAULT_MAX_RETRIES = 3
19
+ DEFAULT_TIMEOUT = 30
20
+ RETRY_BASE_DELAY = 0.5
21
+ RETRY_MAX_DELAY = 8.0
22
+ USER_AGENT = "mailfloss-ruby/#{VERSION}"
23
+
24
+ attr_reader :base_url, :max_retries, :timeout
25
+
26
+ # @param api_key [String, nil] falls back to ENV["MAILFLOSS_API_KEY"]
27
+ # @param base_url [String]
28
+ # @param max_retries [Integer] retries after the initial attempt
29
+ # @param timeout [Numeric] per-request open/read timeout in seconds
30
+ # @param transport [#call, nil] injectable transport:
31
+ # call(method, url, headers, body) -> #status/#headers/#body
32
+ # @param sleeper [#call, nil] injectable sleep proc (tests pass ->(_s){})
33
+ def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, max_retries: DEFAULT_MAX_RETRIES,
34
+ timeout: DEFAULT_TIMEOUT, transport: nil, sleeper: nil)
35
+ @api_key = api_key || ENV["MAILFLOSS_API_KEY"]
36
+ if @api_key.nil? || @api_key.to_s.strip.empty?
37
+ raise ConfigurationError,
38
+ "No API key. Pass api_key: to Mailfloss::Client.new or set the " \
39
+ "MAILFLOSS_API_KEY environment variable."
40
+ end
41
+ @base_url = base_url.sub(%r{/+\z}, "")
42
+ @max_retries = max_retries
43
+ @timeout = timeout
44
+ @transport = transport || Transport.new(timeout: timeout)
45
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
46
+ end
47
+
48
+ # -- Resource namespaces --------------------------------------------------
49
+
50
+ # GET /verify — single-email verification.
51
+ #
52
+ # @param email [String] the address to verify
53
+ # @param timeout [Numeric, String, nil] optional per-request timeout (seconds)
54
+ # @return [Hash] { email:, domain:, status:, reason:, passed:, role:,
55
+ # disposable:, free:, suggestion:, meta: }
56
+ def verify(email:, timeout: nil)
57
+ request(:get, "/verify", query: { email: email, timeout: timeout })
58
+ end
59
+
60
+ # GET /check-key — validate an API key and optionally return plan/credits.
61
+ #
62
+ # @return [Hash] { name:, organization:, planType:, freeMatches:, extraCredits: }
63
+ def check_key(api_key: nil, public_key: nil, check_plan: nil, get_token: nil, check_credits: nil)
64
+ request(:get, "/check-key", query: {
65
+ api_key: api_key, public_key: public_key, check_plan: check_plan,
66
+ get_token: get_token, check_credits: check_credits
67
+ })
68
+ end
69
+
70
+ def batch_verify
71
+ @batch_verify ||= Resources::BatchVerify.new(self)
72
+ end
73
+
74
+ def jobs
75
+ @jobs ||= Resources::Jobs.new(self)
76
+ end
77
+
78
+ def users
79
+ @users ||= Resources::Users.new(self)
80
+ end
81
+
82
+ def reports
83
+ @reports ||= Resources::Reports.new(self)
84
+ end
85
+
86
+ def account
87
+ @account ||= Resources::Account.new(self)
88
+ end
89
+
90
+ def organization
91
+ @organization ||= Resources::Organization.new(self)
92
+ end
93
+
94
+ def integrations
95
+ @integrations ||= Resources::Integrations.new(self)
96
+ end
97
+
98
+ def erasures
99
+ @erasures ||= Resources::Erasures.new(self)
100
+ end
101
+
102
+ # -- Request plumbing -----------------------------------------------------
103
+
104
+ # Perform an authenticated request against the API.
105
+ #
106
+ # @param method [Symbol] :get, :post, :patch, :delete
107
+ # @param path [String] resource path relative to base_url (e.g. "/verify")
108
+ # @param query [Hash, nil] query params; nil values are dropped
109
+ # @param body [Hash, nil] JSON-serialized when present
110
+ # @param idempotency_key [String, nil] POST only; auto-generated when nil
111
+ # @return [Object, nil] parsed JSON (symbolized keys), or nil on empty body
112
+ def request(method, path, query: nil, body: nil, idempotency_key: nil)
113
+ url = build_url(path, query)
114
+ headers = {
115
+ "Authorization" => "Bearer #{@api_key}",
116
+ "Accept" => "application/json",
117
+ "User-Agent" => USER_AGENT
118
+ }
119
+ headers["Content-Type"] = "application/json" if body
120
+ if method == :post
121
+ headers["Idempotency-Key"] = idempotency_key || SecureRandom.uuid
122
+ end
123
+ payload = body ? JSON.generate(body) : nil
124
+
125
+ response = perform_with_retries(method, url, headers, payload)
126
+ status = response_status(response)
127
+ raise APIError.new(status: status, body: response_body(response)) unless (200..299).cover?(status)
128
+
129
+ parse_body(response_body(response))
130
+ end
131
+
132
+ private
133
+
134
+ def build_url(path, query)
135
+ url = @base_url + path
136
+ if query
137
+ pairs = query.reject { |_k, v| v.nil? }
138
+ url += "?#{URI.encode_www_form(pairs)}" unless pairs.empty?
139
+ end
140
+ url
141
+ end
142
+
143
+ def perform_with_retries(method, url, headers, payload)
144
+ attempt = 0
145
+ begin
146
+ response = @transport.call(method, url, headers, payload)
147
+ status = response_status(response)
148
+ if retryable_status?(status) && attempt < @max_retries
149
+ delay = retry_after_seconds(response) || backoff_delay(attempt)
150
+ attempt += 1
151
+ @sleeper.call(delay)
152
+ raise RetrySignal
153
+ end
154
+ response
155
+ rescue RetrySignal
156
+ retry
157
+ rescue *Transport::CONNECTION_ERRORS => e
158
+ raise Error, "Connection error: #{e.class}: #{e.message}" unless attempt < @max_retries
159
+
160
+ @sleeper.call(backoff_delay(attempt))
161
+ attempt += 1
162
+ retry
163
+ end
164
+ end
165
+
166
+ RetrySignal = Class.new(StandardError)
167
+ private_constant :RetrySignal
168
+
169
+ def retryable_status?(status)
170
+ status == 429 || (500..599).cover?(status)
171
+ end
172
+
173
+ def retry_after_seconds(response)
174
+ headers = response_headers(response)
175
+ value = headers.find { |k, _v| k.to_s.downcase == "retry-after" }
176
+ return nil unless value
177
+
178
+ seconds = value[1].to_s
179
+ /\A\d+\z/.match?(seconds) ? seconds.to_i : nil
180
+ end
181
+
182
+ # Exponential backoff with full jitter: rand(0..min(cap, base * 2**attempt))
183
+ def backoff_delay(attempt)
184
+ cap = [RETRY_MAX_DELAY, RETRY_BASE_DELAY * (2**attempt)].min
185
+ rand * cap
186
+ end
187
+
188
+ def response_status(response)
189
+ response.respond_to?(:status) ? response.status : response.fetch(:status)
190
+ end
191
+
192
+ def response_headers(response)
193
+ value = response.respond_to?(:headers) ? response.headers : response[:headers]
194
+ value || {}
195
+ end
196
+
197
+ def response_body(response)
198
+ response.respond_to?(:body) ? response.body : response[:body]
199
+ end
200
+
201
+ def parse_body(body)
202
+ return nil if body.nil? || body.strip.empty?
203
+
204
+ JSON.parse(body, symbolize_names: true)
205
+ rescue JSON::ParserError
206
+ body
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailfloss
4
+ # Base error class for everything raised by this gem.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the client is misconfigured (e.g. no API key available).
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when the API responds with a non-2xx status.
11
+ #
12
+ # The mailfloss API returns errors in the envelope:
13
+ # { "error": { "code": "...", "message": "...", "type": "...", "request_id": "..." } }
14
+ #
15
+ # When the body is not JSON (or not in that envelope) the error degrades
16
+ # gracefully: +code+ becomes "unknown_error" and +message+ falls back to
17
+ # the raw body / a generic message.
18
+ class APIError < Error
19
+ attr_reader :status, :code, :type, :request_id
20
+
21
+ def initialize(status:, body: nil)
22
+ @status = status
23
+ parsed = parse_error(body)
24
+ @code = parsed[:code]
25
+ @type = parsed[:type]
26
+ @request_id = parsed[:request_id]
27
+ super(parsed[:message])
28
+ end
29
+
30
+ private
31
+
32
+ def parse_error(body)
33
+ fallback = {
34
+ code: "unknown_error",
35
+ message: default_message(body),
36
+ type: nil,
37
+ request_id: nil
38
+ }
39
+ return fallback if body.nil? || body.empty?
40
+
41
+ data = JSON.parse(body)
42
+ err = data.is_a?(Hash) ? data["error"] : nil
43
+ return fallback unless err.is_a?(Hash)
44
+
45
+ {
46
+ code: err["code"] || "unknown_error",
47
+ message: err["message"] || default_message(body),
48
+ type: err["type"],
49
+ request_id: err["request_id"]
50
+ }
51
+ rescue JSON::ParserError
52
+ fallback
53
+ end
54
+
55
+ def default_message(body)
56
+ snippet = body.to_s.strip
57
+ if snippet.empty?
58
+ "HTTP #{@status}"
59
+ else
60
+ "HTTP #{@status}: #{snippet[0, 200]}"
61
+ end
62
+ end
63
+ end
64
+ end