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 +7 -0
- data/README.md +271 -0
- data/lib/mailcapture/client.rb +329 -0
- data/lib/mailcapture/errors.rb +87 -0
- data/lib/mailcapture/inbox.rb +53 -0
- data/lib/mailcapture/models.rb +97 -0
- data/lib/mailcapture/version.rb +3 -0
- data/lib/mailcapture.rb +30 -0
- metadata +88 -0
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
|
data/lib/mailcapture.rb
ADDED
|
@@ -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: []
|