Email testing with Ruby
No gem yet, and none needed: the API is six endpoints and Ruby ships net/http. Copy the helper below into spec/support and you are done.
Before you begin
- A MailFlat accountFree to start, no card. Every plan can open inboxes from the API.
- An account API keyAgents → API keys in the dashboard. It looks like mf_live_… and goes in the X-API-Key header. Keep it in an environment variable, never in the repo. See API keys and authentication.
- Ruby 3.0+ (standard library only)That is the whole toolchain requirement.
The client
There is no Ruby package yet, and you do not need one: the API is six endpoints. Paste this helper into your test support directory and every example below works.
# Nothing to install: Ruby ships net/http and json.
# Drop this helper in spec/support/mailflat.rb and require it.
require "net/http"
require "json"
require "uri"
module MailFlat
API = "https://mailflat.net/api/v1"
KEY = ENV.fetch("MAILFLAT_API_KEY")
def self.call(klass, path, body = nil)
uri = URI("#{API}#{path}")
req = klass.new(uri)
req["X-API-Key"] = KEY
if body
req["Content-Type"] = "application/json"
req.body = JSON.dump(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)
end
end
Your first inbox and one-time code
The whole loop: open an address, let your app mail it, read the code back, clean up.
# 1. a real, deliverable address
created = MailFlat.call(Net::HTTP::Post, "/inboxes",
{ prefix: "signup", retention_hours: 2 })
address = created["address"] # signup@a7f2c.mailflat.net
# 2. your app sends the code to that address
MyApp.register(email: address)
# 3. read it back, no mocking anywhere
otp = MailFlat.wait_for_otp(address)
puts otp # "482913"
# 4. done with it
MailFlat.call(Net::HTTP::Delete, "/inboxes/#{address}")
Line by linewhat each step of this Ruby example does
- create(...)
- Returns immediately with a real, deliverable address. Nothing is queued or simulated.
- retention_hours
- Optional. Messages purge themselves after it, so a skipped teardown never leaks.
- wait_for_otp
- Polls for you and fails loudly on timeout, instead of returning nothing three lines before the real error.
- delete()
- Optional but tidy. Retention would clean up anyway.
The address is real
Mail actually travels: SMTP, DKIM, the lot. Nothing is stubbed, so a broken template or a misconfigured sender fails here exactly like it would in production.
Waiting for the code
We extract the one-time code server-side and hand it to you as a field, so you never write a regex against an email body.
module MailFlat
# Poll /latest until the server has extracted a code, then fail loudly.
def self.wait_for_otp(address, timeout: 60, interval: 3)
deadline = Time.now + timeout
while Time.now < deadline
res = call(Net::HTTP::Get, "/inboxes/#{address}/latest")
raise res["note"] if res["encrypted"] # E2E inbox: server cannot read it
otp = res.dig("email", "otp_code")
return otp if otp
sleep interval
end
raise "no OTP arrived for #{address} within #{timeout}s"
end
end
Always set a timeout
A poll loop without a deadline turns a missing email into a hung job. Fail loudly instead: the error message should name the address you were waiting on.
Reading every message
res = MailFlat.call(Net::HTTP::Get, "/inboxes/#{address}/messages")
res["emails"].each do |msg| # newest first
puts msg["subject"], msg["sender"], msg["received_at"]
puts msg["body_text"] # plain text body
puts msg["body_html"] # HTML body
puts msg["otp_code"] # extracted code, or nil
end
# drop one message, keep the inbox
first = res["emails"].first
MailFlat.call(Net::HTTP::Delete, "/inboxes/#{address}/messages/#{first['id']}")
| Field | Type | What it is |
|---|---|---|
| subject | string | Subject line |
| sender | string | From address |
| body_text | string | Plain text body |
| body_html | string | HTML body |
| otp_code | string | null | One-time code, extracted by us |
| to_address | string | The exact address it was sent to, tag included |
| tag | string | null | Plus-addressing tag, if the sender used one |
| received_at | ISO 8601 | When it landed |
| is_encrypted | boolean | True on end-to-end encrypted inboxes, where body and code are unavailable |
Sending mail from the inbox
Useful in reverse: point your own inbound pipeline at a MailFlat address and check that it handles what arrives.
# mail leaves from the inbox address, DKIM-signed by our own MTA
MailFlat.call(Net::HTTP::Post, "/inboxes/#{address}/send", {
to: "someone@example.com",
subject: "Welcome",
body: "Plain text body",
html: "<p>Optional HTML body</p>",
})
Cleaning up
MailFlat.call(Net::HTTP::Delete, "/inboxes/#{address}")
# or leave it: retention_hours on create means it cleans itself up
MailFlat.call(Net::HTTP::Post, "/inboxes", { prefix: "ci", retention_hours: 2 })
Two safety nets, use both
Delete in teardown so the list stays readable, and set retention_hours so a crashed run still cleans itself up.
In a test suite
Works with RSpec, Minitest, Capybara and anything else that gives you a setup and teardown hook.
# spec/signup_spec.rb — a fresh inbox per example, torn down even on failure
require "securerandom"
RSpec.describe "Signup" do
let(:address) do
MailFlat.call(Net::HTTP::Post, "/inboxes",
{ prefix: "ci-#{SecureRandom.hex(4)}", retention_hours: 2 })["address"]
end
after { MailFlat.call(Net::HTTP::Delete, "/inboxes/#{address}") }
it "accepts a real verification code" do
visit "/signup"
fill_in "email", with: address
click_button "Sign up"
fill_in "code", with: MailFlat.wait_for_otp(address)
expect(page).to have_current_path("/dashboard")
end
end
Line by linewhat each step of this Ruby example does
- unique prefix
- One inbox per test, so parallel workers never read each other's mail.
- teardown hook
- Runs even when the test fails — that is exactly when inboxes get left behind.
- real code, real email
- No test-mode backdoor in your app: the path under test is the one your users take.
API reference
| Endpoint | What it does | Returns |
|---|---|---|
| POST /api/v1/inboxes | Open an inbox | { ok, address, api_key, retention_hours } |
| GET /api/v1/inboxes | Every inbox this key opened | { ok, inboxes: [...] } |
| GET /api/v1/inboxes/{address}/latest | Newest message, the polling call | { ok, email: {...} | null } |
| GET /api/v1/inboxes/{address}/messages | Every message, newest first | { ok, emails: [...] } |
| POST /api/v1/inboxes/{address}/send | Send from this address | { ok } |
| DELETE /api/v1/inboxes/{address} | Drop the inbox and its mail | { ok } |
| DELETE /api/v1/inboxes/{address}/messages/{id} | Drop one message | { ok } |
Full endpoint reference, including error shapes and rate limits: Agent API and MCP.