zerodrop 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dce1e4e518096ffccfd4fdc04f743750b64230b58cd11e631b49ee116b1ad143
4
- data.tar.gz: a847df3318ff736eb8500a1407b1e2d602c637f54e18dfb0959db2d1d786fc0c
3
+ metadata.gz: 8f2142d8cff25793a9e3449ebf8bf47ebb553ef0bfc4a2e3cf4403798ccaa36e
4
+ data.tar.gz: 57cdc59a7a7d60554f7db1b9caecae6430f93cea49a1a979dc6c04daad4cf4a5
5
5
  SHA512:
6
- metadata.gz: a8bfc595cd95a7b75ed9c652c27ccea75353e25b7c4355d6512c514a000773eedc09749e107738d1820c6e71cef87f619a2f41b06ebeb9f253f42798ea555eae
7
- data.tar.gz: 0fbf9f95d8772a227f9a2b46c2b9092238b9dbdb75f0ddbc8a835df83bd77f28cde57c6b9b966a66b2917da534b7b5d7382217e755eaff4ba7227edc02c2c8b3
6
+ metadata.gz: a1e838f2ae4fb4aa72fe1030ccbf55aea3e5b95f3e0667bae811ac36d180065b7f4ef26de2c1ff3c7578f2b475315d6359f0b3b22f14309ef0a2754f2071eb74
7
+ data.tar.gz: f882b41e4ff02f0882d39af36af3793a6e327f04ed7510f1b7d86bd6059230739f1a8505b23e4a2fd7eb47edf3b557771230ad30d52cef5adcc2997c7f175563
data/README.md CHANGED
@@ -126,6 +126,21 @@ visit email.magic_link
126
126
  expect(page).to have_current_path("/dashboard")
127
127
  ```
128
128
 
129
+ ## Free API key (private inboxes)
130
+
131
+ Without a key, anyone who knows an inbox's name can read it. With a free key, your inboxes start with your own prefix and only your key can read them.
132
+
133
+ 1. Sign in with GitHub at [zerodrop.dev/keys](https://zerodrop.dev/keys) and create a key.
134
+ 2. Set it as `ZERODROP_API_KEY`. The client reads it automatically, or you can pass `api_key:`.
135
+
136
+ ```ruby
137
+ mail = ZeroDrop::Client.new # reads ZERODROP_API_KEY when it's set
138
+ mail.generate_inbox
139
+ # => "k7x2-swift-x7k29ab@zerodrop-sandbox.online", where k7x2 is your prefix
140
+ ```
141
+
142
+ Reading a `k7x2-…` inbox without your key raises `ZeroDrop::AuthError` with the reason. `mail.prefix` holds your prefix if you build an address yourself. A key holding a character that can't be sent in an HTTP header (a line break, a byte-order mark) makes `ZeroDrop::Client.new` raise `ArgumentError`; the message never includes the key.
143
+
129
144
  ## Parallel test runs
130
145
 
131
146
  `generate_inbox` runs locally — no network request, no collisions:
@@ -143,8 +158,8 @@ begin
143
158
  email = mail.wait_for_latest(inbox, timeout: 15)
144
159
  rescue ZeroDrop::TimeoutError
145
160
  # No email arrived — check your app is sending correctly
146
- rescue ZeroDrop::AuthError
147
- # Invalid API key
161
+ rescue ZeroDrop::AuthError => e
162
+ # The inbox belongs to another key's prefix, or your key is wrong — e.message says which
148
163
  rescue ZeroDrop::NetworkError => e
149
164
  # Transport failure — e.message includes status page link
150
165
  end
@@ -166,8 +181,9 @@ mail = ZeroDrop::Client.new(base_url: "https://your-instance.yourdomain.com")
166
181
 
167
182
  | Method | Description |
168
183
  |---|---|
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. |
184
+ | `Client.new(api_key: nil, base_url: ...)` | Create a client. `api_key` defaults to `ENV["ZERODROP_API_KEY"]`; without a key, inboxes are public to anyone who knows the name. |
185
+ | `#generate_inbox` | Instant inbox address. No network request. With a key, the name starts with your prefix. |
186
+ | `#prefix` | Your key's inbox prefix (`"k7x2"`), or `nil` without a key. |
171
187
  | `#fetch_latest(inbox, filter: nil)` | Latest matching email or nil. |
172
188
  | `#wait_for_latest(inbox, timeout: 10, poll_interval: 2, filter: nil)` | Block until email arrives. |
173
189
 
@@ -181,6 +197,7 @@ mail = ZeroDrop::Client.new(base_url: "https://your-instance.yourdomain.com")
181
197
  | Email filtering | ✓ | ✓ |
182
198
  | Email retention | 30 min | Extended |
183
199
  | Custom domains | ✗ | ✓ |
200
+ | API key | ✓ free at [zerodrop.dev/keys](https://zerodrop.dev/keys) | ✓ |
184
201
 
185
202
  Get a Workspace at [zerodrop.dev](https://zerodrop.dev)
186
203
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZeroDrop
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/zerodrop.rb CHANGED
@@ -22,6 +22,18 @@ module ZeroDrop
22
22
  FREE_DOMAIN = "zerodrop-sandbox.online"
23
23
  ADJECTIVES = %w[swift dark cold null void zero dead raw base core].freeze
24
24
 
25
+ # A free key from https://zerodrop.dev/keys: zd_<prefix>_<32 letters/digits>.
26
+ # Inboxes named <prefix>-... can be read only with that key.
27
+ KEY_FORMAT = /\Azd_([a-z0-9]{4})_[A-Za-z0-9]{32}\z/
28
+
29
+ # A key goes into an HTTP header, so after trimming it may hold only visible
30
+ # ASCII. Anything else is refused without echoing the key.
31
+ KEY_CHARS = /\A[\x21-\x7e]+\z/
32
+
33
+ # Whitespace around a key, including the no-break spaces and byte-order
34
+ # marks that copying and pasting can bring along.
35
+ KEY_PADDING = /\A[[:space:]\0\u{FEFF}]+|[[:space:]\0\u{FEFF}]+\z/
36
+
25
37
  # Raised when no email arrives within the timeout.
26
38
  class TimeoutError < StandardError
27
39
  def initialize(inbox, timeout)
@@ -30,10 +42,11 @@ module ZeroDrop
30
42
  end
31
43
  end
32
44
 
33
- # Raised when an invalid API key is provided.
45
+ # Raised on a 401: the inbox belongs to an account's prefix and the request
46
+ # had no key or the wrong one. The message carries the server's reason.
34
47
  class AuthError < StandardError
35
- def initialize
36
- super("invalid or missing API key")
48
+ def initialize(detail = nil)
49
+ super(detail || "invalid or missing API key")
37
50
  end
38
51
  end
39
52
 
@@ -74,21 +87,38 @@ module ZeroDrop
74
87
 
75
88
  # The ZeroDrop API client.
76
89
  class Client
77
- # @param api_key [String, nil] Workspace API key. Omit for free sandbox mode.
90
+ # Your key's inbox prefix ("k7x2"), or nil without a key.
91
+ attr_reader :prefix
92
+
93
+ # @param api_key [String, nil] Free API key from https://zerodrop.dev/keys.
94
+ # Defaults to ENV["ZERODROP_API_KEY"]. Without a key, anyone who knows
95
+ # an inbox's name can read it.
78
96
  # @param base_url [String] Override for self-hosted instances.
97
+ # @raise [ArgumentError] if the key holds a character that can't be sent in
98
+ # an HTTP header (a line break, a byte-order mark...)
79
99
  def initialize(api_key: nil, base_url: DEFAULT_BASE_URL)
80
- @api_key = api_key
100
+ key = trim_key(api_key)
101
+ key = trim_key(ENV.fetch("ZERODROP_API_KEY", "")) if key.empty?
102
+ unless key.empty? || key.match?(KEY_CHARS)
103
+ raise ArgumentError, "ZeroDrop: the API key contains a character that can't be sent in an HTTP header, " \
104
+ "such as a line break or a byte-order mark. Copy it again from https://zerodrop.dev/keys."
105
+ end
106
+ @api_key = key.empty? ? nil : key
107
+ @prefix = KEY_FORMAT.match(@api_key.to_s)&.[](1)
81
108
  @base_url = base_url.chomp("/")
82
109
  end
83
110
 
84
111
  # Returns a ready-to-use email address instantly.
85
- # No network request is made.
112
+ # No network request is made. With a key, the name starts with your prefix.
86
113
  #
87
- # @return [String] e.g. "swift-x7k29ab@zerodrop-sandbox.online"
114
+ # @return [String] e.g. "swift-x7k29ab@zerodrop-sandbox.online",
115
+ # or "k7x2-swift-x7k29ab@zerodrop-sandbox.online" with the key zd_k7x2_...
88
116
  def generate_inbox
89
117
  adjective = ADJECTIVES.sample
90
118
  suffix = SecureRandom.alphanumeric(7).downcase
91
- "#{adjective}-#{suffix}@#{FREE_DOMAIN}"
119
+ name = "#{adjective}-#{suffix}"
120
+ name = "#{@prefix}-#{name}" if @prefix
121
+ "#{name}@#{FREE_DOMAIN}"
92
122
  end
93
123
 
94
124
  # Returns the newest email matching the filter, or nil.
@@ -101,7 +131,7 @@ module ZeroDrop
101
131
  uri = URI("#{@base_url}/api/inbox/#{name}?source=ruby-sdk")
102
132
 
103
133
  res = http_get(uri)
104
- raise AuthError if res.code == "401"
134
+ raise AuthError.new(error_detail(res)) if res.code == "401"
105
135
  raise NetworkError, "API returned #{res.code}" unless res.code == "200"
106
136
 
107
137
  payload = JSON.parse(res.body)
@@ -142,6 +172,16 @@ module ZeroDrop
142
172
 
143
173
  private
144
174
 
175
+ # ENV strings can arrive as binary or US-ASCII (no UTF-8 locale), so the
176
+ # bytes are read as UTF-8 before trimming.
177
+ def trim_key(value)
178
+ text = value.to_s.dup.force_encoding(Encoding::UTF_8)
179
+ return text.gsub(KEY_PADDING, "") if text.valid_encoding?
180
+
181
+ # Not UTF-8: a plain strip, and the character check refuses it.
182
+ value.to_s.b.strip
183
+ end
184
+
145
185
  def http_get(uri)
146
186
  http = Net::HTTP.new(uri.host, uri.port)
147
187
  http.use_ssl = uri.scheme == "https"
@@ -155,6 +195,15 @@ module ZeroDrop
155
195
  http.request(req)
156
196
  end
157
197
 
198
+ # The server explains a 401 in its JSON body; pass that on.
199
+ def error_detail(res)
200
+ body = JSON.parse(res.body.to_s)
201
+ detail = body["error"] if body.is_a?(Hash)
202
+ detail.is_a?(String) ? detail : nil
203
+ rescue JSON::ParserError, TypeError
204
+ nil
205
+ end
206
+
158
207
  def build_email(raw)
159
208
  Email.new(
160
209
  id: raw["id"],
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zerodrop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZeroDrop
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-13 00:00:00.000000000 Z
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: ZeroDrop catches verification emails at Cloudflare's edge and auto-extracts
14
14
  OTP codes and magic links. Test email verification, magic links, OTP and password