Class: MailCapture::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/mailcapture/client.rb

Overview

MailCapture API client. Create one with new or new and reuse it across your test suite.

Examples:

Basic usage

mc = MailCapture.new(api_key: ENV['MAILCAPTURE_API_KEY'])
mc.ping

mc.delete('signup')
MyApp.register(mc.address('signup'))   # "alice-signup@mailcapture.app"
email = mc.wait_for('signup', timeout: 15)
expect(email.otp).to eq('123456')

With Inbox (recommended)

inbox = mc.inbox('signup')
inbox.clear
MyApp.register(inbox.address)
email = inbox.wait_for(timeout: 15)

Constant Summary collapse

MAX_POLL_SECONDS =
30
SERVER_POLL_BUFFER =
5
ADJECTIVES =
%w[
  angry bold brave calm cold cool dark dizzy dusty eager fierce fluffy
  funky fuzzy glad gloomy grumpy hasty hungry icy itchy jolly jumpy
  keen lazy lucky mad mean moody muddy noisy odd pale peppy proud quick
  quiet rowdy rusty silly sleepy sneaky spooky swift tiny tough vivid
  weird wild young
].freeze
ANIMALS =
%w[
  ant bear boar cat crab crow deer dove duck eel elk finch fox frog
  goat hawk hare ibis jay kiwi lamb lark lion lynx mink mole moth mule
  newt owl panda pig puma ram rat rook seal slug snail swan toad vole
  wasp wolf wren yak zebra bat bee carp
].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(api_key:, base_url: 'https://mailcapture.app', timeout: 10, username: nil) ⇒ Client

Returns a new instance of Client.

Parameters:

  • api_key (String)

    your MailCapture API key (mc_...)

  • base_url (String) (defaults to: 'https://mailcapture.app')

    API base URL (override for local dev)

  • timeout (Numeric) (defaults to: 10)

    default request timeout in seconds

  • username (String, nil) (defaults to: nil)

    pre-set username to skip ping

Raises:

  • (ArgumentError)


46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/mailcapture/client.rb', line 46

def initialize(api_key:, base_url: 'https://mailcapture.app', timeout: 10, username: nil)
  raise ArgumentError,
    "MailCapture: api_key is required.\n" \
    "  MailCapture.new(api_key: ENV['MAILCAPTURE_API_KEY'])" if api_key.to_s.empty?

  unless api_key.start_with?('mc_')
    warn '[mailcapture] API key does not start with "mc_". Are you sure you copied the full key? ' \
         'Make sure you copied the full key from https://mailcapture.app/admin/api-keys'
  end

  @api_key  = api_key
  @base_url = base_url.chomp('/')
  @timeout  = timeout
  @username = username
end

Class Method Details

.generate_tagString

Generate a unique, human-readable tag such as “funky-otter-a3f2b8”. Format: {adjective}-{animal}-{6 hex digits}. ~42 billion combinations — collision probability < 0.1% across 10 000 tags. No client or network call needed.

Examples:

tag = MailCapture::Client.generate_tag   # "funky-otter-a3f2b8"

Returns:

  • (String)


214
215
216
217
218
219
# File 'lib/mailcapture/client.rb', line 214

def self.generate_tag
  adj    = ADJECTIVES.sample
  animal = ANIMALS.sample
  suffix = format('%06x', rand(0x1000000))
  "#{adj}-#{animal}-#{suffix}"
end

Instance Method Details

#address(tag) ⇒ String

Return the capture email address for a tag.

Requires #ping to have been called first, or :username set in the constructor.

Parameters:

  • tag (String)

Returns:

  • (String)

    e.g. “alice-signup@mailcapture.app”

Raises:

  • (RuntimeError)

    if username is not yet known



192
193
194
195
196
197
# File 'lib/mailcapture/client.rb', line 192

def address(tag)
  raise 'MailCapture: username is not known. Call ping first or pass username: to the constructor.' \
    unless @username

  "#{@username}-#{tag}@mailcapture.app"
end

#delete(tag) ⇒ Object

Delete all captures for a tag. Call before each test to start with a clean inbox.

Examples:

before(:each) { mc.delete('signup') }

Parameters:

  • tag (String)

Raises:

  • (ArgumentError)


162
163
164
165
166
167
# File 'lib/mailcapture/client.rb', line 162

def delete(tag)
  raise ArgumentError, 'tag is required' if tag.to_s.empty?

  request(:delete, "/v1/captures/#{encode(tag)}")
  nil
end

#generateGenerateResult

Generate a unique tag and its capture email address. Requires #ping to have been called first (same contract as #address).

Examples:

mc.ping
result = mc.generate
# result.tag   => "funky-otter-a3f2b8"
# result.email => "alice-funky-otter-a3f2b8@mailcapture.app"
MyApp.register(result.email)
email = mc.wait_for(result.tag, timeout: 15)

Returns:



239
240
241
242
# File 'lib/mailcapture/client.rb', line 239

def generate
  tag = generate_tag
  GenerateResult.new(tag: tag, email: address(tag))
end

#generate_tagString

Instance-level shortcut so you can call mc.generate_tag too.

Returns:

  • (String)


223
224
225
# File 'lib/mailcapture/client.rb', line 223

def generate_tag
  self.class.generate_tag
end

#get(capture_id) ⇒ Capture

Get a single capture by ID.

Parameters:

  • capture_id (String)

Returns:

Raises:



149
150
151
152
153
# File 'lib/mailcapture/client.rb', line 149

def get(capture_id)
  raise ArgumentError, 'capture_id is required' if capture_id.to_s.empty?

  Capture.from_hash(request(:get, "/v1/captures/#{encode(capture_id)}"))
end

#inbox(tag) ⇒ Inbox

Return a scoped Inbox for a specific tag.

Examples:

inbox = mc.inbox('password-reset')
inbox.clear
MyApp.request_password_reset(inbox.address)
email = inbox.wait_for(timeout: 10)

Parameters:

  • tag (String)

Returns:

Raises:

  • (ArgumentError)


179
180
181
182
183
# File 'lib/mailcapture/client.rb', line 179

def inbox(tag)
  raise ArgumentError, 'tag is required' if tag.to_s.empty?

  Inbox.new(self, tag)
end

#list(tag: nil, limit: nil, after: nil) ⇒ CaptureList

List recent captures (newest first).

Examples:

result = mc.list(tag: 'signup', limit: 10)
result.items.each { |e| puts e.subject }

Parameters:

  • tag (String, nil) (defaults to: nil)
  • limit (Integer, nil) (defaults to: nil)

    max results (1-100, default 25)

  • after (Time, nil) (defaults to: nil)

    only captures received after this time

Returns:



135
136
137
138
139
140
141
142
# File 'lib/mailcapture/client.rb', line 135

def list(tag: nil, limit: nil, after: nil)
  params = {}
  params[:tag]   = tag                                     if tag
  params[:limit] = limit.to_s                             if limit
  params[:after] = after.utc.strftime('%Y-%m-%dT%H:%M:%SZ') if after

  CaptureList.from_hash(request(:get, '/v1/captures', params: params))
end

#pingPingResult

Validate your API key and return your capture address template. Also caches your username so #address works without a network call.

Returns:

Raises:



70
71
72
73
74
75
# File 'lib/mailcapture/client.rb', line 70

def ping
  data = request(:get, '/v1/ping')
  result = PingResult.from_hash(data)
  @username = result.username
  result
end

#usernameString?

Your cached username, set after #ping or via the constructor.

Returns:

  • (String, nil)


201
202
203
# File 'lib/mailcapture/client.rb', line 201

def username
  @username
end

#wait_for(tag, timeout: 60, poll_timeout: 10, after: nil) ⇒ Capture

Wait for an email to arrive at the given tag and return it.

Long-polls the API — the server holds the connection open and responds the instant an email arrives. No busy-waiting.

The after cursor defaults to 60 seconds ago so recent emails are included but stale ones from previous runs are ignored. For maximum isolation, call delete(tag) before triggering the email.

Examples:

email = mc.wait_for('signup', timeout: 15)
expect(email.otp).to match(/\A\d{6}\z/)

Parameters:

  • tag (String)
  • timeout (Numeric) (defaults to: 60)

    total seconds to wait (default 60)

  • poll_timeout (Integer) (defaults to: 10)

    per-poll server timeout, max 30 (default 10)

  • after (Time, nil) (defaults to: nil)

    only captures received after this time

Returns:

Raises:



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/mailcapture/client.rb', line 97

def wait_for(tag, timeout: 60, poll_timeout: 10, after: nil)
  poll_timeout = [[poll_timeout.to_i, 1].max, MAX_POLL_SECONDS].min
  deadline     = Time.now + timeout
  after      ||= Time.now - 60

  loop do
    remaining = deadline - Time.now
    break if remaining <= 0

    effective_poll = [poll_timeout, [1, remaining.ceil].max].min

    result = poll_latest(tag, effective_poll, after)
    if result
      return result[:items].first unless result[:items].empty?

      after = Time.parse(result[:next_after])
    end
    # result nil => server-side 408, loop again
  end

  hint = if @username
           "Make sure you're sending to #{@username}-#{tag}@mailcapture.app."
         else
           'Check that you\'re sending to the right address (call ping first to get your username).'
         end
  raise TimeoutError.new(tag: tag, waited_seconds: timeout, hint: hint)
end