zerodrop 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: dce1e4e518096ffccfd4fdc04f743750b64230b58cd11e631b49ee116b1ad143
4
+ data.tar.gz: a847df3318ff736eb8500a1407b1e2d602c637f54e18dfb0959db2d1d786fc0c
5
+ SHA512:
6
+ metadata.gz: a8bfc595cd95a7b75ed9c652c27ccea75353e25b7c4355d6512c514a000773eedc09749e107738d1820c6e71cef87f619a2f41b06ebeb9f253f42798ea555eae
7
+ data.tar.gz: 0fbf9f95d8772a227f9a2b46c2b9092238b9dbdb75f0ddbc8a835df83bd77f28cde57c6b9b966a66b2917da534b7b5d7382217e755eaff4ba7227edc02c2c8b3
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZeroDrop
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,189 @@
1
+ # zerodrop
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/zerodrop.svg)](https://rubygems.org/gems/zerodrop)
4
+ [![CI](https://github.com/zerodrop-dev/zerodrop-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/zerodrop-dev/zerodrop-ruby/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/github/license/zerodrop-dev/zerodrop-ruby.svg)](LICENSE)
6
+
7
+ Email verification infrastructure for CI pipelines.
8
+
9
+ Send a verification email. Catch it at the edge. Get `email.otp` and `email.magic_link` back — auto-extracted, no regex, no Docker, no signup.
10
+
11
+ ```ruby
12
+ email = mail.wait_for_latest(inbox, timeout: 15)
13
+
14
+ email.otp # => "123456" — auto-extracted
15
+ email.magic_link # => "https://..." — no regex needed
16
+ ```
17
+
18
+ **[Documentation](https://docs.zerodrop.dev)** · [GitHub](https://github.com/zerodrop-dev) · [Status](https://zerodrop.instatus.com)
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ gem install zerodrop
24
+ ```
25
+
26
+ Or in your Gemfile:
27
+
28
+ ```ruby
29
+ gem "zerodrop", group: :test
30
+ ```
31
+
32
+ Zero runtime dependencies. Ruby 3.0+.
33
+
34
+ ## Quick start
35
+
36
+ ```ruby
37
+ require "zerodrop"
38
+
39
+ mail = ZeroDrop::Client.new
40
+ inbox = mail.generate_inbox
41
+ # => "swift-x7k29ab@zerodrop-sandbox.online"
42
+
43
+ # Trigger your app's email flow with the inbox address...
44
+
45
+ email = mail.wait_for_latest(inbox, timeout: 15)
46
+
47
+ email.subject # => "Verify your email"
48
+ email.otp # => "123456" — auto-extracted, no regex
49
+ email.magic_link # => "https://..." — auto-extracted
50
+ ```
51
+
52
+ ## RSpec + Capybara
53
+
54
+ ```ruby
55
+ RSpec.describe "Signup email verification", type: :system do
56
+ let(:mail) { ZeroDrop::Client.new }
57
+
58
+ it "verifies the account via emailed OTP" do
59
+ inbox = mail.generate_inbox
60
+
61
+ visit "/signup"
62
+ fill_in "Email", with: inbox
63
+ fill_in "Password", with: "TestPassword123!"
64
+ click_button "Create account"
65
+
66
+ email = mail.wait_for_latest(inbox, timeout: 15)
67
+
68
+ expect(email.otp).not_to be_nil
69
+ fill_in "Code", with: email.otp
70
+ click_button "Verify"
71
+
72
+ expect(page).to have_current_path("/dashboard")
73
+ end
74
+ end
75
+ ```
76
+
77
+ ## Minitest
78
+
79
+ ```ruby
80
+ require "minitest/autorun"
81
+ require "zerodrop"
82
+
83
+ class SignupTest < Minitest::Test
84
+ def test_email_verification
85
+ mail = ZeroDrop::Client.new
86
+ inbox = mail.generate_inbox
87
+
88
+ # Trigger signup with inbox...
89
+
90
+ email = mail.wait_for_latest(inbox, timeout: 15)
91
+
92
+ refute_nil email.otp
93
+ assert_match(/\A\d{6}\z/, email.otp)
94
+ end
95
+ end
96
+ ```
97
+
98
+ ## Email filtering
99
+
100
+ Filter by sender, subject, body, or extracted fields:
101
+
102
+ ```ruby
103
+ email = mail.wait_for_latest(
104
+ inbox,
105
+ timeout: 15,
106
+ filter: ZeroDrop::Filter.new(
107
+ from: "noreply@yourapp.com",
108
+ subject: "Verify",
109
+ has_otp: true
110
+ )
111
+ )
112
+ ```
113
+
114
+ All string filters are case-insensitive partial matches.
115
+
116
+ ## Magic link flows
117
+
118
+ ```ruby
119
+ email = mail.wait_for_latest(
120
+ inbox,
121
+ timeout: 15,
122
+ filter: ZeroDrop::Filter.new(has_magic_link: true)
123
+ )
124
+
125
+ visit email.magic_link
126
+ expect(page).to have_current_path("/dashboard")
127
+ ```
128
+
129
+ ## Parallel test runs
130
+
131
+ `generate_inbox` runs locally — no network request, no collisions:
132
+
133
+ ```ruby
134
+ # parallel_tests, flatware, turbo_tests — all safe
135
+ # Each process gets isolated inboxes automatically
136
+ inbox = mail.generate_inbox # unique every call
137
+ ```
138
+
139
+ ## Error handling
140
+
141
+ ```ruby
142
+ begin
143
+ email = mail.wait_for_latest(inbox, timeout: 15)
144
+ rescue ZeroDrop::TimeoutError
145
+ # No email arrived — check your app is sending correctly
146
+ rescue ZeroDrop::AuthError
147
+ # Invalid API key
148
+ rescue ZeroDrop::NetworkError => e
149
+ # Transport failure — e.message includes status page link
150
+ end
151
+ ```
152
+
153
+ ## Workspaces
154
+
155
+ ```ruby
156
+ mail = ZeroDrop::Client.new(api_key: ENV["ZERODROP_API_KEY"])
157
+ ```
158
+
159
+ ## Self-hosted
160
+
161
+ ```ruby
162
+ mail = ZeroDrop::Client.new(base_url: "https://your-instance.yourdomain.com")
163
+ ```
164
+
165
+ ## API
166
+
167
+ | Method | Description |
168
+ |---|---|
169
+ | `Client.new(api_key: nil, base_url: ...)` | Create a client. No args = free sandbox mode. |
170
+ | `#generate_inbox` | Instant inbox address. No network request. |
171
+ | `#fetch_latest(inbox, filter: nil)` | Latest matching email or nil. |
172
+ | `#wait_for_latest(inbox, timeout: 10, poll_interval: 2, filter: nil)` | Block until email arrives. |
173
+
174
+ ## Free vs Workspace
175
+
176
+ | | Free | Workspace |
177
+ |---|---|---|
178
+ | Inbox generation | ✓ | ✓ |
179
+ | OTP auto-extraction | ✓ | ✓ |
180
+ | Magic link extraction | ✓ | ✓ |
181
+ | Email filtering | ✓ | ✓ |
182
+ | Email retention | 30 min | Extended |
183
+ | Custom domains | ✗ | ✓ |
184
+
185
+ Get a Workspace at [zerodrop.dev](https://zerodrop.dev)
186
+
187
+ ## License
188
+
189
+ MIT
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroDrop
4
+ VERSION = "0.1.0"
5
+ end
data/lib/zerodrop.rb ADDED
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "securerandom"
7
+ require "time"
8
+
9
+ require_relative "zerodrop/version"
10
+
11
+ # ZeroDrop — disposable email inboxes for testing auth flows in CI.
12
+ # OTPs and magic links auto-extracted at Cloudflare's edge.
13
+ # No regex, no Docker, no signup.
14
+ #
15
+ # mail = ZeroDrop::Client.new
16
+ # inbox = mail.generate_inbox
17
+ # email = mail.wait_for_latest(inbox, timeout: 15)
18
+ # email.otp # => "847291"
19
+ # email.magic_link # => "https://..."
20
+ module ZeroDrop
21
+ DEFAULT_BASE_URL = "https://zerodrop.dev"
22
+ FREE_DOMAIN = "zerodrop-sandbox.online"
23
+ ADJECTIVES = %w[swift dark cold null void zero dead raw base core].freeze
24
+
25
+ # Raised when no email arrives within the timeout.
26
+ class TimeoutError < StandardError
27
+ def initialize(inbox, timeout)
28
+ super("no email received at #{inbox.inspect} within #{timeout}s — " \
29
+ "check that your app is sending to the correct address")
30
+ end
31
+ end
32
+
33
+ # Raised when an invalid API key is provided.
34
+ class AuthError < StandardError
35
+ def initialize
36
+ super("invalid or missing API key")
37
+ end
38
+ end
39
+
40
+ # Raised on transport-level failures.
41
+ class NetworkError < StandardError; end
42
+
43
+ # A caught email with auto-extracted fields.
44
+ Email = Struct.new(
45
+ :id, :from, :to, :subject, :body, :raw_body, :received_at,
46
+ :otp, :magic_link,
47
+ keyword_init: true
48
+ )
49
+
50
+ # Filter for narrowing which email is returned.
51
+ # All string matches are case-insensitive partial matches.
52
+ Filter = Struct.new(
53
+ :from, :subject, :body, :has_otp, :has_magic_link,
54
+ keyword_init: true
55
+ ) do
56
+ def matches?(email)
57
+ return false if from && !email.from.to_s.downcase.include?(from.downcase)
58
+ return false if subject && !email.subject.to_s.downcase.include?(subject.downcase)
59
+ return false if body && !email.body.to_s.downcase.include?(body.downcase)
60
+
61
+ unless has_otp.nil?
62
+ return false if has_otp && email.otp.to_s.empty?
63
+ return false if !has_otp && !email.otp.to_s.empty?
64
+ end
65
+
66
+ unless has_magic_link.nil?
67
+ return false if has_magic_link && email.magic_link.to_s.empty?
68
+ return false if !has_magic_link && !email.magic_link.to_s.empty?
69
+ end
70
+
71
+ true
72
+ end
73
+ end
74
+
75
+ # The ZeroDrop API client.
76
+ class Client
77
+ # @param api_key [String, nil] Workspace API key. Omit for free sandbox mode.
78
+ # @param base_url [String] Override for self-hosted instances.
79
+ def initialize(api_key: nil, base_url: DEFAULT_BASE_URL)
80
+ @api_key = api_key
81
+ @base_url = base_url.chomp("/")
82
+ end
83
+
84
+ # Returns a ready-to-use email address instantly.
85
+ # No network request is made.
86
+ #
87
+ # @return [String] e.g. "swift-x7k29ab@zerodrop-sandbox.online"
88
+ def generate_inbox
89
+ adjective = ADJECTIVES.sample
90
+ suffix = SecureRandom.alphanumeric(7).downcase
91
+ "#{adjective}-#{suffix}@#{FREE_DOMAIN}"
92
+ end
93
+
94
+ # Returns the newest email matching the filter, or nil.
95
+ #
96
+ # @param inbox [String] inbox address or name
97
+ # @param filter [ZeroDrop::Filter, nil]
98
+ # @return [ZeroDrop::Email, nil]
99
+ def fetch_latest(inbox, filter: nil)
100
+ name = inbox_name(inbox)
101
+ uri = URI("#{@base_url}/api/inbox/#{name}?source=ruby-sdk")
102
+
103
+ res = http_get(uri)
104
+ raise AuthError if res.code == "401"
105
+ raise NetworkError, "API returned #{res.code}" unless res.code == "200"
106
+
107
+ payload = JSON.parse(res.body)
108
+ emails = payload.fetch("emails", [])
109
+
110
+ emails.each do |raw|
111
+ email = build_email(raw)
112
+ return email if filter.nil? || filter.matches?(email)
113
+ end
114
+ nil
115
+ rescue JSON::ParserError => e
116
+ raise NetworkError, "invalid response: #{e.message}"
117
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
118
+ raise NetworkError, "#{e.message} (check https://zerodrop.instatus.com)"
119
+ end
120
+
121
+ # Blocks until an email matching the filter arrives.
122
+ # Polls every `poll_interval` seconds up to `timeout` seconds.
123
+ #
124
+ # @param inbox [String] inbox address or name
125
+ # @param timeout [Numeric] seconds to wait (default 10)
126
+ # @param poll_interval [Numeric] seconds between polls (default 2)
127
+ # @param filter [ZeroDrop::Filter, nil]
128
+ # @return [ZeroDrop::Email]
129
+ # @raise [ZeroDrop::TimeoutError] if nothing arrives in time
130
+ def wait_for_latest(inbox, timeout: 10, poll_interval: 2, filter: nil)
131
+ deadline = Time.now + timeout
132
+
133
+ loop do
134
+ email = fetch_latest(inbox, filter: filter)
135
+ return email if email
136
+
137
+ raise TimeoutError.new(inbox, timeout) if Time.now + poll_interval > deadline
138
+
139
+ sleep poll_interval
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def http_get(uri)
146
+ http = Net::HTTP.new(uri.host, uri.port)
147
+ http.use_ssl = uri.scheme == "https"
148
+ http.open_timeout = 10
149
+ http.read_timeout = 30
150
+
151
+ req = Net::HTTP::Get.new(uri)
152
+ req["Authorization"] = "Bearer #{@api_key}" if @api_key
153
+ req["User-Agent"] = "zerodrop-ruby/#{VERSION}"
154
+
155
+ http.request(req)
156
+ end
157
+
158
+ def build_email(raw)
159
+ Email.new(
160
+ id: raw["id"],
161
+ from: raw["from"],
162
+ to: raw["to"],
163
+ subject: raw["subject"],
164
+ body: extract_body(raw["raw"]),
165
+ raw_body: raw["raw"],
166
+ received_at: parse_time(raw["receivedAt"]),
167
+ otp: raw["otp"].to_s.empty? ? nil : raw["otp"],
168
+ magic_link: raw["magicLink"].to_s.empty? ? nil : raw["magicLink"]
169
+ )
170
+ end
171
+
172
+ def parse_time(str)
173
+ Time.parse(str) if str
174
+ rescue ArgumentError, TypeError
175
+ nil
176
+ end
177
+
178
+ def extract_body(raw)
179
+ return "" if raw.nil? || raw.empty?
180
+
181
+ if (m = raw.match(%r{Content-Type: text/plain[^\r\n]*\r\n\r\n(.*?)(?:\r\n--|\r\n\r\n--)}m))
182
+ return m[1].strip
183
+ end
184
+
185
+ parts = raw.split("\r\n\r\n", 2)
186
+ return "" unless parts.length == 2
187
+
188
+ body = parts[1].strip
189
+ body.length > 5000 ? body[0, 5000] : body
190
+ end
191
+
192
+ def inbox_name(inbox)
193
+ name = inbox.include?("@") ? inbox.split("@").first : inbox
194
+ name.downcase
195
+ end
196
+ end
197
+ end
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zerodrop
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ZeroDrop
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-13 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: ZeroDrop catches verification emails at Cloudflare's edge and auto-extracts
14
+ OTP codes and magic links. Test email verification, magic links, OTP and password
15
+ reset flows in RSpec, Minitest or Capybara — no regex, no Docker, no signup.
16
+ email:
17
+ - founder@zerodrop.dev
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - README.md
24
+ - lib/zerodrop.rb
25
+ - lib/zerodrop/version.rb
26
+ homepage: https://zerodrop.dev
27
+ licenses:
28
+ - MIT
29
+ metadata:
30
+ homepage_uri: https://zerodrop.dev
31
+ source_code_uri: https://github.com/zerodrop-dev/zerodrop-ruby
32
+ documentation_uri: https://docs.zerodrop.dev
33
+ bug_tracker_uri: https://github.com/zerodrop-dev/zerodrop-ruby/issues
34
+ rubygems_mfa_required: 'true'
35
+ post_install_message:
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '3.0'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.4.19
51
+ signing_key:
52
+ specification_version: 4
53
+ summary: Disposable email inboxes for testing auth flows in CI — OTPs and magic links
54
+ auto-extracted.
55
+ test_files: []