mailcapture 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 435a3e55721f1f7c5b9ec7a00fd74d97fc0ae885c2dbdb18f25adff35f93c83f
4
+ data.tar.gz: c40a4f37bcdff35a7a8ecb90aad1b5dc883aad72ffc29dedfd09daa7b2d71b18
5
+ SHA512:
6
+ metadata.gz: 566b16ae0811c9244c458f47afedfb0cf7a7b79abc3741a14e8fd1d91212081b5bcf006b3baa774c44541bf480a680d40dab32b5cefdd14f4cbed954390f7de2
7
+ data.tar.gz: 8a502fd4cf5cbb425da8ab5b0326cb01a40c1929aa497a47fcaa804823cbc1dbdab9843f52f71b6fbee6c24e568f8c30b73799737d7a127a0f1c149b7a636099
data/README.md ADDED
@@ -0,0 +1,271 @@
1
+ # mailcapture
2
+
3
+ Official Ruby gem for [MailCapture](https://mailcapture.app) — a real email capture API for integration testing OTP codes, verification links, and other transactional emails.
4
+
5
+ Zero runtime dependencies. Works with any Ruby web framework (Rails, Sinatra, Hanami, Roda, or plain Rack).
6
+
7
+ ## Requirements
8
+
9
+ - Ruby 3.1+
10
+
11
+ ## Installation
12
+
13
+ Add to your Gemfile:
14
+
15
+ ```ruby
16
+ gem 'mailcapture'
17
+ ```
18
+
19
+ Or install directly:
20
+
21
+ ```bash
22
+ gem install mailcapture
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ```ruby
28
+ mc = MailCapture.new(api_key: ENV['MAILCAPTURE_API_KEY'])
29
+ mc.ping # validates key, caches username
30
+
31
+ # In your test:
32
+ mc.delete('signup')
33
+ MyApp.register(mc.address('signup')) # "alice-signup@mailcapture.app"
34
+ email = mc.wait_for('signup', timeout: 15)
35
+
36
+ puts email.subject # "Verify your account"
37
+ puts email.otp # "123456" — extracted automatically
38
+ ```
39
+
40
+ ## Integration test pattern (RSpec)
41
+
42
+ ```ruby
43
+ # spec/features/user_registration_spec.rb
44
+ require 'rails_helper'
45
+
46
+ RSpec.describe 'User registration email', :integration do
47
+ let(:mc) { MailCapture.new(api_key: ENV['MAILCAPTURE_API_KEY']) }
48
+ let(:inbox) { mc.inbox('signup') }
49
+
50
+ before(:all) { mc.ping } # validates key, caches username
51
+ before(:each) { inbox.clear } # clean inbox before every test
52
+
53
+ it 'sends a 6-digit OTP' do
54
+ post '/users', params: { email: inbox.address }
55
+
56
+ email = inbox.wait_for(timeout: 10)
57
+
58
+ expect(email.subject).to eq('Verify your account')
59
+ expect(email.otp).to match(/\A\d{6}\z/)
60
+ expect(email.latency_ms).to be < 5000
61
+ end
62
+ end
63
+ ```
64
+
65
+ ## Integration test pattern (Minitest / Rails)
66
+
67
+ ```ruby
68
+ # test/integration/signup_test.rb
69
+ class SignupEmailTest < ActionDispatch::IntegrationTest
70
+ setup do
71
+ @mc = MailCapture.new(api_key: ENV['MAILCAPTURE_API_KEY'], username: 'alice')
72
+ @inbox = @mc.inbox('signup')
73
+ @inbox.clear
74
+ end
75
+
76
+ test 'sends verification email with OTP' do
77
+ post sign_up_path, params: { email: @inbox.address }
78
+
79
+ email = @inbox.wait_for(timeout: 10)
80
+
81
+ assert_equal 'Verify your account', email.subject
82
+ assert_match(/\A\d{6}\z/, email.otp)
83
+ end
84
+ end
85
+ ```
86
+
87
+ ## API reference
88
+
89
+ ### `MailCapture.new(api_key:, ...)`
90
+
91
+ ```ruby
92
+ # Minimal
93
+ mc = MailCapture.new(api_key: ENV['MAILCAPTURE_API_KEY'])
94
+
95
+ # All options
96
+ mc = MailCapture.new(
97
+ api_key: ENV['MAILCAPTURE_API_KEY'],
98
+ base_url: 'http://localhost:3002', # local dev
99
+ timeout: 15, # default request timeout in seconds
100
+ username: 'alice', # pre-set to skip ping
101
+ )
102
+ ```
103
+
104
+ ---
105
+
106
+ ### `mc.ping` → `PingResult`
107
+
108
+ Validates your API key and returns your address template. Caches your username so `address` works without a network call.
109
+
110
+ ```ruby
111
+ result = mc.ping
112
+ result.username # => "alice"
113
+ result.address_template # => "alice-{tag}@mailcapture.app"
114
+ result.example # => "alice-signup@mailcapture.app"
115
+ ```
116
+
117
+ ---
118
+
119
+ ### `mc.wait_for(tag, timeout:, poll_timeout:, after:)` → `Capture`
120
+
121
+ Long-polls the API and returns the first email captured for the given tag. The server holds the connection open — no busy-waiting.
122
+
123
+ ```ruby
124
+ # Named arguments (recommended)
125
+ email = mc.wait_for('signup', timeout: 15)
126
+
127
+ # Full options
128
+ email = mc.wait_for('signup',
129
+ timeout: 15, # total seconds to wait (default 30)
130
+ poll_timeout: 5, # per-poll server timeout in seconds, max 30 (default 10)
131
+ after: Time.now - 30, # only captures after this time
132
+ )
133
+ ```
134
+
135
+ Raises `MailCapture::TimeoutError` if no email arrives in time.
136
+
137
+ ---
138
+
139
+ ### `mc.inbox(tag)` → `Inbox`
140
+
141
+ Returns a scoped `Inbox` for a tag. Keeps test code clean.
142
+
143
+ ```ruby
144
+ inbox = mc.inbox('password-reset')
145
+
146
+ inbox.address # => "alice-password-reset@mailcapture.app"
147
+ inbox.wait_for(timeout: 10) # => Capture
148
+ inbox.list(limit: 5) # => CaptureList
149
+ inbox.clear # deletes all captures for this tag
150
+ ```
151
+
152
+ ---
153
+
154
+ ### `mc.address(tag)` → `String`
155
+
156
+ Generates the capture email address synchronously. Requires `ping` first (or `username:` in the constructor).
157
+
158
+ ```ruby
159
+ mc.ping
160
+ mc.address('signup') # => "alice-signup@mailcapture.app"
161
+ ```
162
+
163
+ ---
164
+
165
+ ### `mc.list(tag:, limit:, after:)` → `CaptureList`
166
+
167
+ Lists recent captures (newest first).
168
+
169
+ ```ruby
170
+ result = mc.list(tag: 'signup', limit: 10)
171
+ result.items.each { |email| puts email.subject }
172
+ result.count # => total count
173
+ ```
174
+
175
+ ---
176
+
177
+ ### `mc.get(capture_id)` → `Capture`
178
+
179
+ Gets a single capture by ID. Raises `MailCapture::NotFoundError` if not found.
180
+
181
+ ---
182
+
183
+ ### `mc.delete(tag)` → `nil`
184
+
185
+ Deletes all captures for a tag. Use in `before(:each)` or `setup` for test isolation.
186
+
187
+ ---
188
+
189
+ ## The `Capture` object
190
+
191
+ ```ruby
192
+ email.id # String — UUID
193
+ email.tag # String — e.g. "signup"
194
+ email.subject # String — email subject line
195
+ email.otp # String? — extracted code, nil if none detected
196
+ email.body_text # String? — plain-text body
197
+ email.body_html # String? — HTML body
198
+ email.latency_ms # Integer — send-to-capture time in ms
199
+ email.status # String — e.g. "captured"
200
+ email.received_at # String — ISO 8601 timestamp
201
+ ```
202
+
203
+ The `otp` field is extracted automatically. If your OTP is in the middle of a sentence, the service finds it for you.
204
+
205
+ ---
206
+
207
+ ## Exception handling
208
+
209
+ All exceptions extend `MailCapture::Error` and have a `code` attribute.
210
+
211
+ ```ruby
212
+ begin
213
+ email = mc.wait_for('signup', timeout: 10)
214
+ rescue MailCapture::TimeoutError => e
215
+ puts "Waited #{e.waited_seconds}s for tag: #{e.tag}"
216
+ puts 'Did the email actually send? Check your email service logs.'
217
+ rescue MailCapture::AuthError
218
+ puts 'Check your MAILCAPTURE_API_KEY environment variable.'
219
+ rescue MailCapture::NetworkError => e
220
+ puts "Network error: #{e.message}"
221
+ end
222
+ ```
223
+
224
+ | Exception | `code` | When |
225
+ |---|---|---|
226
+ | `MailCapture::AuthError` | `UNAUTHORIZED` | Invalid or revoked API key |
227
+ | `MailCapture::TimeoutError` | `TIMEOUT` | `wait_for` exceeded its timeout |
228
+ | `MailCapture::NotFoundError` | `NOT_FOUND` | `get` — capture not found |
229
+ | `MailCapture::NetworkError` | `NETWORK_ERROR` | Could not reach the API |
230
+ | `MailCapture::ApiError` | varies | Unexpected API error |
231
+
232
+ ---
233
+
234
+ ## Parallel tests
235
+
236
+ Each tag is its own inbox — safe to run in parallel.
237
+
238
+ ```ruby
239
+ # RSpec with parallel_tests gem:
240
+ describe 'signup email', :parallel do
241
+ let(:inbox) { mc.inbox("signup-#{$$}") } # per-process tag
242
+ before { inbox.clear }
243
+
244
+ it 'sends OTP' do
245
+ email = inbox.wait_for(timeout: 10)
246
+ expect(email.otp).to match(/\A\d{6}\z/)
247
+ end
248
+ end
249
+ ```
250
+
251
+ ---
252
+
253
+ ## Rails configuration
254
+
255
+ ```ruby
256
+ # config/initializers/mailcapture.rb (test environment only)
257
+ if Rails.env.test?
258
+ MAILCAPTURE = MailCapture.new(
259
+ api_key: ENV.fetch('MAILCAPTURE_API_KEY'),
260
+ username: ENV.fetch('MAILCAPTURE_USERNAME', nil), # skip ping if known
261
+ )
262
+ end
263
+ ```
264
+
265
+ ---
266
+
267
+ ## Local development
268
+
269
+ ```ruby
270
+ mc = MailCapture.new(api_key: key, base_url: 'http://localhost:3002')
271
+ ```
@@ -0,0 +1,329 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'uri'
4
+
5
+ module MailCapture
6
+ # MailCapture API client. Create one with {MailCapture.new} or
7
+ # {MailCapture::Client.new} and reuse it across your test suite.
8
+ #
9
+ # @example Basic usage
10
+ # mc = MailCapture.new(api_key: ENV['MAILCAPTURE_API_KEY'])
11
+ # mc.ping
12
+ #
13
+ # mc.delete('signup')
14
+ # MyApp.register(mc.address('signup')) # "alice-signup@mailcapture.app"
15
+ # email = mc.wait_for('signup', timeout: 15)
16
+ # expect(email.otp).to eq('123456')
17
+ #
18
+ # @example With Inbox (recommended)
19
+ # inbox = mc.inbox('signup')
20
+ # inbox.clear
21
+ # MyApp.register(inbox.address)
22
+ # email = inbox.wait_for(timeout: 15)
23
+ class Client
24
+ MAX_POLL_SECONDS = 30
25
+ SERVER_POLL_BUFFER = 5
26
+
27
+ ADJECTIVES = %w[
28
+ angry bold brave calm cold cool dark dizzy dusty eager fierce fluffy
29
+ funky fuzzy glad gloomy grumpy hasty hungry icy itchy jolly jumpy
30
+ keen lazy lucky mad mean moody muddy noisy odd pale peppy proud quick
31
+ quiet rowdy rusty silly sleepy sneaky spooky swift tiny tough vivid
32
+ weird wild young
33
+ ].freeze
34
+
35
+ ANIMALS = %w[
36
+ ant bear boar cat crab crow deer dove duck eel elk finch fox frog
37
+ goat hawk hare ibis jay kiwi lamb lark lion lynx mink mole moth mule
38
+ newt owl panda pig puma ram rat rook seal slug snail swan toad vole
39
+ wasp wolf wren yak zebra bat bee carp
40
+ ].freeze
41
+
42
+ # @param api_key [String] your MailCapture API key (+mc_...+)
43
+ # @param base_url [String] API base URL (override for local dev)
44
+ # @param timeout [Numeric] default request timeout in seconds
45
+ # @param username [String, nil] pre-set username to skip +ping+
46
+ def initialize(api_key:, base_url: 'https://mailcapture.app', timeout: 10, username: nil)
47
+ raise ArgumentError,
48
+ "MailCapture: api_key is required.\n" \
49
+ " MailCapture.new(api_key: ENV['MAILCAPTURE_API_KEY'])" if api_key.to_s.empty?
50
+
51
+ unless api_key.start_with?('mc_')
52
+ warn '[mailcapture] API key does not start with "mc_". Are you sure you copied the full key? ' \
53
+ 'Make sure you copied the full key from https://mailcapture.app/admin/api-keys'
54
+ end
55
+
56
+ @api_key = api_key
57
+ @base_url = base_url.chomp('/')
58
+ @timeout = timeout
59
+ @username = username
60
+ end
61
+
62
+ # -------------------------------------------------------------------------
63
+ # Public API
64
+
65
+ # Validate your API key and return your capture address template.
66
+ # Also caches your username so {#address} works without a network call.
67
+ #
68
+ # @return [PingResult]
69
+ # @raise [AuthError] if the API key is invalid
70
+ def ping
71
+ data = request(:get, '/v1/ping')
72
+ result = PingResult.from_hash(data)
73
+ @username = result.username
74
+ result
75
+ end
76
+
77
+ # Wait for an email to arrive at the given tag and return it.
78
+ #
79
+ # Long-polls the API — the server holds the connection open and responds
80
+ # the instant an email arrives. No busy-waiting.
81
+ #
82
+ # The +after+ cursor defaults to 60 seconds ago so recent emails are
83
+ # included but stale ones from previous runs are ignored.
84
+ # For maximum isolation, call +delete(tag)+ before triggering the email.
85
+ #
86
+ # @param tag [String]
87
+ # @param timeout [Numeric] total seconds to wait (default 60)
88
+ # @param poll_timeout [Integer] per-poll server timeout, max 30 (default 10)
89
+ # @param after [Time, nil] only captures received after this time
90
+ # @return [Capture]
91
+ # @raise [TimeoutError] if no email arrives before +timeout+
92
+ # @raise [AuthError] if the API key is invalid
93
+ #
94
+ # @example
95
+ # email = mc.wait_for('signup', timeout: 15)
96
+ # expect(email.otp).to match(/\A\d{6}\z/)
97
+ def wait_for(tag, timeout: 60, poll_timeout: 10, after: nil)
98
+ poll_timeout = [[poll_timeout.to_i, 1].max, MAX_POLL_SECONDS].min
99
+ deadline = Time.now + timeout
100
+ after ||= Time.now - 60
101
+
102
+ loop do
103
+ remaining = deadline - Time.now
104
+ break if remaining <= 0
105
+
106
+ effective_poll = [poll_timeout, [1, remaining.ceil].max].min
107
+
108
+ result = poll_latest(tag, effective_poll, after)
109
+ if result
110
+ return result[:items].first unless result[:items].empty?
111
+
112
+ after = Time.parse(result[:next_after])
113
+ end
114
+ # result nil => server-side 408, loop again
115
+ end
116
+
117
+ hint = if @username
118
+ "Make sure you're sending to #{@username}-#{tag}@mailcapture.app."
119
+ else
120
+ 'Check that you\'re sending to the right address (call ping first to get your username).'
121
+ end
122
+ raise TimeoutError.new(tag: tag, waited_seconds: timeout, hint: hint)
123
+ end
124
+
125
+ # List recent captures (newest first).
126
+ #
127
+ # @param tag [String, nil]
128
+ # @param limit [Integer, nil] max results (1-100, default 25)
129
+ # @param after [Time, nil] only captures received after this time
130
+ # @return [CaptureList]
131
+ #
132
+ # @example
133
+ # result = mc.list(tag: 'signup', limit: 10)
134
+ # result.items.each { |e| puts e.subject }
135
+ def list(tag: nil, limit: nil, after: nil)
136
+ params = {}
137
+ params[:tag] = tag if tag
138
+ params[:limit] = limit.to_s if limit
139
+ params[:after] = after.utc.strftime('%Y-%m-%dT%H:%M:%SZ') if after
140
+
141
+ CaptureList.from_hash(request(:get, '/v1/captures', params: params))
142
+ end
143
+
144
+ # Get a single capture by ID.
145
+ #
146
+ # @param capture_id [String]
147
+ # @return [Capture]
148
+ # @raise [NotFoundError] if the capture does not exist
149
+ def get(capture_id)
150
+ raise ArgumentError, 'capture_id is required' if capture_id.to_s.empty?
151
+
152
+ Capture.from_hash(request(:get, "/v1/captures/#{encode(capture_id)}"))
153
+ end
154
+
155
+ # Delete all captures for a tag.
156
+ # Call before each test to start with a clean inbox.
157
+ #
158
+ # @param tag [String]
159
+ #
160
+ # @example
161
+ # before(:each) { mc.delete('signup') }
162
+ def delete(tag)
163
+ raise ArgumentError, 'tag is required' if tag.to_s.empty?
164
+
165
+ request(:delete, "/v1/captures/#{encode(tag)}")
166
+ nil
167
+ end
168
+
169
+ # Return a scoped {Inbox} for a specific tag.
170
+ #
171
+ # @param tag [String]
172
+ # @return [Inbox]
173
+ #
174
+ # @example
175
+ # inbox = mc.inbox('password-reset')
176
+ # inbox.clear
177
+ # MyApp.request_password_reset(inbox.address)
178
+ # email = inbox.wait_for(timeout: 10)
179
+ def inbox(tag)
180
+ raise ArgumentError, 'tag is required' if tag.to_s.empty?
181
+
182
+ Inbox.new(self, tag)
183
+ end
184
+
185
+ # Return the capture email address for a tag.
186
+ #
187
+ # Requires {#ping} to have been called first, or +:username+ set in the constructor.
188
+ #
189
+ # @param tag [String]
190
+ # @return [String] e.g. "alice-signup@mailcapture.app"
191
+ # @raise [RuntimeError] if username is not yet known
192
+ def address(tag)
193
+ raise 'MailCapture: username is not known. Call ping first or pass username: to the constructor.' \
194
+ unless @username
195
+
196
+ "#{@username}-#{tag}@mailcapture.app"
197
+ end
198
+
199
+ # Your cached username, set after {#ping} or via the constructor.
200
+ # @return [String, nil]
201
+ def username
202
+ @username
203
+ end
204
+
205
+ # Generate a unique, human-readable tag such as +"funky-otter-a3f2b8"+.
206
+ # Format: +{adjective}-{animal}-{6 hex digits}+.
207
+ # ~42 billion combinations — collision probability < 0.1% across 10 000 tags.
208
+ # No client or network call needed.
209
+ #
210
+ # @return [String]
211
+ #
212
+ # @example
213
+ # tag = MailCapture::Client.generate_tag # "funky-otter-a3f2b8"
214
+ def self.generate_tag
215
+ adj = ADJECTIVES.sample
216
+ animal = ANIMALS.sample
217
+ suffix = format('%06x', rand(0x1000000))
218
+ "#{adj}-#{animal}-#{suffix}"
219
+ end
220
+
221
+ # Instance-level shortcut so you can call +mc.generate_tag+ too.
222
+ # @return [String]
223
+ def generate_tag
224
+ self.class.generate_tag
225
+ end
226
+
227
+ # Generate a unique tag and its capture email address.
228
+ # Requires {#ping} to have been called first (same contract as {#address}).
229
+ #
230
+ # @return [GenerateResult] with +tag+ and +email+ attributes
231
+ #
232
+ # @example
233
+ # mc.ping
234
+ # result = mc.generate
235
+ # # result.tag => "funky-otter-a3f2b8"
236
+ # # result.email => "alice-funky-otter-a3f2b8@mailcapture.app"
237
+ # MyApp.register(result.email)
238
+ # email = mc.wait_for(result.tag, timeout: 15)
239
+ def generate
240
+ tag = generate_tag
241
+ GenerateResult.new(tag: tag, email: address(tag))
242
+ end
243
+
244
+ private
245
+
246
+ # -------------------------------------------------------------------------
247
+ # Internals
248
+
249
+ # Long-poll /v1/latest/:tag once.
250
+ # Returns nil on a server-side 408 (no emails yet — caller loops again).
251
+ def poll_latest(tag, poll_timeout, after)
252
+ params = {
253
+ timeout: poll_timeout.to_i,
254
+ after: after.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
255
+ }
256
+ path = "/v1/latest/#{encode(tag)}"
257
+
258
+ response = send_http(:get, path, params: params,
259
+ read_timeout: poll_timeout + SERVER_POLL_BUFFER)
260
+
261
+ return nil if response.code.to_i == 408
262
+
263
+ raise_for_status(response)
264
+
265
+ data = JSON.parse(response.body)
266
+ {
267
+ items: (data['items'] || []).map { |item| Capture.from_hash(item) },
268
+ next_after: data['next_after']
269
+ }
270
+ rescue JSON::ParserError
271
+ raise ApiError.new('Invalid JSON from API', status_code: 200, code: 'INVALID_RESPONSE')
272
+ end
273
+
274
+ def request(method, path, params: {})
275
+ response = send_http(method, path, params: params, read_timeout: @timeout)
276
+ return {} if response.code.to_i == 204
277
+
278
+ raise_for_status(response)
279
+ JSON.parse(response.body)
280
+ rescue JSON::ParserError
281
+ raise ApiError.new('Invalid JSON from API', status_code: 200, code: 'INVALID_RESPONSE')
282
+ end
283
+
284
+ def send_http(method, path, params: {}, read_timeout: @timeout)
285
+ uri = URI("#{@base_url}#{path}")
286
+ uri.query = URI.encode_www_form(params) unless params.empty?
287
+
288
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
289
+ open_timeout: 10,
290
+ read_timeout: read_timeout) do |http|
291
+ req = case method
292
+ when :get then Net::HTTP::Get.new(uri)
293
+ when :delete then Net::HTTP::Delete.new(uri)
294
+ end
295
+ req['X-API-Key'] = @api_key
296
+ req['Accept'] = 'application/json'
297
+ http.request(req)
298
+ end
299
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
300
+ Net::OpenTimeout, SocketError => e
301
+ raise NetworkError.new(@base_url, e)
302
+ end
303
+
304
+ def raise_for_status(response)
305
+ return if response.code.to_i.between?(200, 299)
306
+
307
+ body = parse_error_body(response.body)
308
+ case response.code.to_i
309
+ when 401 then raise AuthError.new(body[:detail])
310
+ when 404 then raise NotFoundError.new(body[:detail])
311
+ else
312
+ raise ApiError.new(body[:detail] || body[:code],
313
+ status_code: response.code.to_i,
314
+ code: body[:code])
315
+ end
316
+ end
317
+
318
+ def parse_error_body(raw)
319
+ data = JSON.parse(raw)
320
+ { code: data['message'], detail: data['detail'] }
321
+ rescue JSON::ParserError
322
+ { code: 'UNKNOWN_ERROR', detail: nil }
323
+ end
324
+
325
+ def encode(str)
326
+ URI.encode_uri_component(str)
327
+ end
328
+ end
329
+ end
@@ -0,0 +1,87 @@
1
+ module MailCapture
2
+ # Base class for all MailCapture errors.
3
+ # Check +code+ for a machine-readable error type.
4
+ class Error < StandardError
5
+ attr_reader :code
6
+
7
+ def initialize(message, code:)
8
+ super(message)
9
+ @code = code
10
+ end
11
+ end
12
+
13
+ # Raised when authentication fails — the API key is invalid, expired, or revoked.
14
+ #
15
+ # rescue MailCapture::AuthError
16
+ # # Check your MAILCAPTURE_API_KEY environment variable.
17
+ # end
18
+ class AuthError < Error
19
+ def initialize(detail = nil)
20
+ hint = detail ? "Server said: #{detail.inspect}." : 'Your API key was rejected.'
21
+ super(
22
+ "Authentication failed. #{hint} " \
23
+ 'Make sure your key is valid and has not been revoked. ' \
24
+ 'Keys look like: mc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx. ' \
25
+ 'Find your keys at https://mailcapture.app/admin/api-keys',
26
+ code: 'UNAUTHORIZED'
27
+ )
28
+ end
29
+ end
30
+
31
+ # Raised by +wait_for+ when no email arrives before the timeout.
32
+ #
33
+ # rescue MailCapture::TimeoutError => e
34
+ # puts "Waited #{e.waited_seconds}s for tag: #{e.tag}"
35
+ # end
36
+ class TimeoutError < Error
37
+ # @return [String] the tag that was being waited on
38
+ attr_reader :tag
39
+ # @return [Numeric] seconds elapsed before giving up
40
+ attr_reader :waited_seconds
41
+
42
+ def initialize(tag:, waited_seconds:, hint: nil)
43
+ @tag = tag
44
+ @waited_seconds = waited_seconds
45
+ message = "No email arrived for tag #{tag.inspect} within #{waited_seconds.to_i}s."
46
+ message += " #{hint}" if hint
47
+ super(message, code: 'TIMEOUT')
48
+ end
49
+ end
50
+
51
+ # Raised when a capture is not found by ID.
52
+ class NotFoundError < Error
53
+ def initialize(detail = nil)
54
+ super(
55
+ detail || 'Capture not found. It may have expired, been deleted, or the ID is incorrect.',
56
+ code: 'NOT_FOUND'
57
+ )
58
+ end
59
+ end
60
+
61
+ # Raised when the SDK cannot reach the MailCapture API.
62
+ # The original exception is available via +cause+ (Ruby's built-in +cause+ on exceptions).
63
+ class NetworkError < Error
64
+ def initialize(base_url, original_error = nil)
65
+ @original_error = original_error
66
+ super(
67
+ "Could not reach the MailCapture API at #{base_url}. " \
68
+ 'Check your network connection and firewall settings.',
69
+ code: 'NETWORK_ERROR'
70
+ )
71
+ end
72
+ end
73
+
74
+ # Raised when the API returns an unexpected status code.
75
+ class ApiError < Error
76
+ # @return [Integer] HTTP status code
77
+ attr_reader :status_code
78
+ # @return [String, nil] human-readable detail from the server
79
+ attr_reader :detail
80
+
81
+ def initialize(detail, status_code:, code: 'UNKNOWN_ERROR')
82
+ @status_code = status_code
83
+ @detail = detail
84
+ super("API error (#{status_code}): #{detail || code}", code: code)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,53 @@
1
+ module MailCapture
2
+ # A scoped handle for a single capture inbox (tag).
3
+ # Create one with +Client#inbox+.
4
+ #
5
+ # Keeps test code clean by binding the tag once:
6
+ #
7
+ # inbox = mc.inbox('signup')
8
+ # inbox.clear
9
+ # MyApp.register(inbox.address)
10
+ # email = inbox.wait_for(timeout: 15)
11
+ # expect(email.otp).to match(/\A\d{6}\z/)
12
+ class Inbox
13
+ # @return [String] the tag this inbox is scoped to
14
+ attr_reader :tag
15
+
16
+ def initialize(client, tag)
17
+ @client = client
18
+ @tag = tag
19
+ end
20
+
21
+ # The full capture email address for this inbox,
22
+ # e.g. "alice-signup@mailcapture.app".
23
+ #
24
+ # Requires +ping+ to have been called first, or +:username+ set in the constructor.
25
+ #
26
+ # @raise [RuntimeError] if the username is not yet known
27
+ def address
28
+ @client.address(tag)
29
+ end
30
+
31
+ # Wait for an email to arrive. See {Client#wait_for}.
32
+ def wait_for(timeout: 30, poll_timeout: 10, after: nil)
33
+ @client.wait_for(tag, timeout: timeout, poll_timeout: poll_timeout, after: after)
34
+ end
35
+
36
+ # List recent captures. See {Client#list}.
37
+ def list(limit: nil, after: nil)
38
+ @client.list(tag: tag, limit: limit, after: after)
39
+ end
40
+
41
+ # Delete all captures. Call before each test for a clean starting state.
42
+ #
43
+ # before(:each) { inbox.clear }
44
+ def clear
45
+ @client.delete(tag)
46
+ end
47
+
48
+ def to_s
49
+ "#<MailCapture::Inbox tag=#{tag.inspect}>"
50
+ end
51
+ alias inspect to_s
52
+ end
53
+ end
@@ -0,0 +1,97 @@
1
+ module MailCapture
2
+ # Result from {Client#generate}.
3
+ GenerateResult = Struct.new(:tag, :email, keyword_init: true) do
4
+ def to_s
5
+ "#<MailCapture::GenerateResult tag=#{tag.inspect} email=#{email.inspect}>"
6
+ end
7
+ alias inspect to_s
8
+ end
9
+
10
+ # A captured email.
11
+ #
12
+ # email = mc.wait_for('signup')
13
+ # email.otp # => "123456"
14
+ # email.subject # => "Verify your account"
15
+ # email.latency_ms # => 145
16
+ class Capture
17
+ # @return [String] unique capture ID
18
+ attr_reader :id
19
+ # @return [String] tag portion of the address, e.g. "signup"
20
+ attr_reader :tag
21
+ # @return [String] email subject line
22
+ attr_reader :subject
23
+ # @return [String, nil] extracted OTP/code, or nil if none detected
24
+ attr_reader :otp
25
+ # @return [String, nil] plain-text body, or nil if not present
26
+ attr_reader :body_text
27
+ # @return [String, nil] HTML body, or nil if not present
28
+ attr_reader :body_html
29
+ # @return [Integer] send-to-capture latency in milliseconds
30
+ attr_reader :latency_ms
31
+ # @return [String] delivery status, e.g. "captured"
32
+ attr_reader :status
33
+ # @return [String] ISO 8601 timestamp of when the email was received
34
+ attr_reader :received_at
35
+
36
+ def self.from_hash(data)
37
+ new(data)
38
+ end
39
+
40
+ def initialize(data)
41
+ @id = data['id']
42
+ @tag = data['tag']
43
+ @subject = data['subject']
44
+ @otp = data['otp']
45
+ @body_text = data['body_text']
46
+ @body_html = data['body_html']
47
+ @latency_ms = data['latency_ms']
48
+ @status = data['status']
49
+ @received_at = data['received_at']
50
+ end
51
+
52
+ def to_s
53
+ "#<MailCapture::Capture id=#{id.inspect} tag=#{tag.inspect} subject=#{subject.inspect}>"
54
+ end
55
+ alias inspect to_s
56
+ end
57
+
58
+ # Response from +list+.
59
+ class CaptureList
60
+ # @return [Array<Capture>]
61
+ attr_reader :items
62
+ # @return [Integer]
63
+ attr_reader :count
64
+
65
+ def self.from_hash(data)
66
+ new(data)
67
+ end
68
+
69
+ def initialize(data)
70
+ @items = (data['items'] || []).map { |item| Capture.from_hash(item) }
71
+ @count = data['count'].to_i
72
+ end
73
+ end
74
+
75
+ # Response from +ping+.
76
+ class PingResult
77
+ # @return [String] your unique username
78
+ attr_reader :username
79
+ # @return [String] template string — replace {tag} with your desired tag
80
+ attr_reader :address_template
81
+ # @return [String] a concrete example address
82
+ attr_reader :example
83
+ # @return [String]
84
+ attr_reader :status
85
+
86
+ def self.from_hash(data)
87
+ new(data)
88
+ end
89
+
90
+ def initialize(data)
91
+ @status = data['status']
92
+ @username = data['username']
93
+ @address_template = data['address_template']
94
+ @example = data['example']
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,3 @@
1
+ module MailCapture
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,30 @@
1
+ require 'mailcapture/version'
2
+ require 'mailcapture/errors'
3
+ require 'mailcapture/models'
4
+ require 'mailcapture/inbox'
5
+ require 'mailcapture/client'
6
+
7
+ # MailCapture — real email capture for integration tests.
8
+ #
9
+ # @example Quick start
10
+ # mc = MailCapture.new(api_key: ENV['MAILCAPTURE_API_KEY'])
11
+ # mc.ping
12
+ #
13
+ # mc.delete('signup')
14
+ # MyApp.register(mc.address('signup')) # "alice-signup@mailcapture.app"
15
+ # email = mc.wait_for('signup', timeout: 15)
16
+ # email.otp # => "123456"
17
+ #
18
+ # @example With Inbox (recommended for test suites)
19
+ # inbox = mc.inbox('signup')
20
+ # inbox.clear
21
+ # MyApp.register(inbox.address)
22
+ # email = inbox.wait_for(timeout: 15)
23
+ module MailCapture
24
+ # Convenience constructor — equivalent to MailCapture::Client.new.
25
+ #
26
+ # @return [Client]
27
+ def self.new(**kwargs)
28
+ Client.new(**kwargs)
29
+ end
30
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mailcapture
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - MailCapture
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.13'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.13'
40
+ - !ruby/object:Gem::Dependency
41
+ name: webmock
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.23'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.23'
54
+ description: Capture and assert on real transactional emails in integration and CI
55
+ tests.
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - README.md
61
+ - lib/mailcapture.rb
62
+ - lib/mailcapture/client.rb
63
+ - lib/mailcapture/errors.rb
64
+ - lib/mailcapture/inbox.rb
65
+ - lib/mailcapture/models.rb
66
+ - lib/mailcapture/version.rb
67
+ homepage: https://mailcapture.app
68
+ licenses:
69
+ - MIT
70
+ metadata: {}
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.1'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 4.0.11
86
+ specification_version: 4
87
+ summary: Official Ruby SDK for the MailCapture email testing API
88
+ test_files: []