Email testing with Java
Built on java.net.http, no HTTP dependency of its own. Made for Selenium and JUnit suites that need a real address per test.
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. Tick Read messages and Create and delete inboxes (inbox:read + inbox:manage): a new key can only read, so opening an inbox fails with 403 until you add the second one. Keep it in an environment variable, never in the repo. See API keys and authentication.
- Java 17+That is the whole toolchain requirement.
Install
<!-- pom.xml: add the JitPack repository first, or resolution fails -->
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependency>
<groupId>com.github.MailFlat</groupId>
<artifactId>mailflat-sdks</artifactId>
<version>v0.4.5</version>
</dependency>
<!-- Gradle -->
<!-- repositories { maven { url "https://jitpack.io" } } -->
<!-- implementation "com.github.MailFlat:mailflat-sdks:v0.4.5" -->
Package: mailflat-sdks
Your first inbox and one-time code
The whole loop: open an address, let your app mail it, read the code back, clean up.
import net.mailflat.MailFlat;
import net.mailflat.Inbox;
import net.mailflat.CreateInboxOptions;
MailFlat mf = new MailFlat(System.getenv("MAILFLAT_API_KEY"));
// 1. a real, deliverable address
// prefix = the local part. mf.create("signup") sets the LABEL (a display name)
// and still gives you a random address. Use the builder when you want the prefix.
Inbox inbox = mf.create(CreateInboxOptions.builder().prefix("signup").build());
System.out.println(inbox.address()); // signup@a7f2c.mailflat.net
// 2. your app sends the code to that address
myApp.register(inbox.address());
// 3. read it back, no mocking anywhere
String otp = inbox.waitForOtp(30); // seconds
System.out.println(otp); // "482913"
// 4. done with it
inbox.delete();
Line by linewhat each step of this Java 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.
// seconds, not milliseconds
String otp = inbox.waitForOtp(30);
// need the whole message?
Message msg = inbox.waitForMessage(30);
System.out.println(msg.subject() + " from " + msg.sender());
System.out.println(msg.text());
// throws on timeout, so a missing email fails the test loudly
try {
inbox.waitForOtp(5);
} catch (MailFlatException e) {
System.out.println("no code arrived: " + e.getMessage());
}
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
for (Message msg : inbox.messages()) { // received mail, newest first
System.out.println(msg.subject() + " " + msg.receivedAt());
System.out.println(msg.text()); // plain text body
System.out.println(msg.html()); // HTML body
System.out.println(msg.otp()); // extracted code, or null
System.out.println(msg.links()); // URLs written in THIS message
// (links quoted from an earlier one are excluded)
System.out.println(msg.attachments()); // metadata; .download() fetches the bytes
System.out.println(msg.headers()); // raw headers, null on an encrypted inbox
System.out.println(msg.header("message-id")); // case-insensitive lookup
}
// Mail YOU sent from this address is not in the list above. Ask for it:
List<Message> sent = inbox.messages(Direction.OUT);
Message one = inbox.message(42); // a single message by id
Optional<Message> latest = inbox.latest(); // empty until the first message lands
latest.filter(m -> m.subject().contains("Reset"))
.ifPresent(m -> inbox.deleteMessage(m.id()));
Message msg = inbox.waitForMessage(60);
msg.markRead(); // so the next poll can skip it
// reply() targets replyToAddress() and adds In-Reply-To / References, so Gmail and
// Outlook keep it in the same conversation. sender() is the ENVELOPE sender, which for
// transactional mail is usually a bounce address, so replying there reaches a machine.
msg.reply("Got it, thanks.");
inbox.burn(); // delete every message, KEEP the address
| Field | Type | What it is | JSON field (msg.raw) |
|---|---|---|---|
| subject() | string | Subject line | subject |
| sender() | string | From address | sender |
| text() | string | Plain text body | body_text |
| html() | string | HTML body | body_html |
| otp() | string | null | One-time code, extracted by us | otp_code |
| toAddress() | string | The exact address it was sent to, tag included | to_address |
| tag() | string | null | Plus-addressing tag, if the sender used one | tag |
| raw().get("received_at") | ISO 8601 UTC (ends with Z) | When it landed | received_at |
| raw().get("is_encrypted") | boolean | True on end-to-end encrypted inboxes, where body and code are unavailable | is_encrypted |
Sending mail from the inbox
Useful in reverse: point your own inbound pipeline at a MailFlat address and check that it handles what arrives.
On the Free plan you can send to your own addresses (your account email or any of your inboxes). To send anywhere else, connect and verify a domain. Free includes one; Pro plans include more.
// mail leaves from the inbox address, DKIM-signed by our own MTA
inbox.send("someone@example.com", "Welcome", "Plain text body");
// with an HTML body
inbox.send("someone@example.com", "Welcome", "Plain text", "<p>HTML body</p>");
// everything a mail can carry: attachments, cc, bcc, threading
SendResult result = inbox.send("someone@example.com", SendOptions.builder()
.subject("Invoice 2026-08")
.body("Attached, as discussed.")
.cc("team@example.com") // written into the headers
.bcc("archive@example.com") // in NO header, not even in its own copy
.attach(Path.of("invoice.pdf")) // read and base64-encoded for you
.attach("report.csv", csvBytes) // or bytes you already hold
.build());
// send() returns when the mail is ACCEPTED (HTTP 202), not when it is delivered:
// delivery runs on a queue. SendResult carries the id you follow it with.
System.out.println(result.messageId() + " queued=" + result.queued());
try {
Message sent = inbox.waitUntilSent(result); // or waitUntilSent(id, seconds)
System.out.println(sent.sendStatus()); // "sent", or "unsigned" without DKIM
} catch (SendFailedException e) {
System.out.println("permanently failed: " + e.getMessage()); // the queue gave up
} catch (SendTimeoutException e) {
// NOT a failure: the queue is still retrying. Sending again delivers it twice.
System.out.println("still queued; check back later");
}
// Answering keeps the threading headers and takes the same fields:
msg.reply(SendOptions.builder().body("Paid.").attach("receipt.pdf", pdfBytes).build());
// Attachment size and count depend on your plan. Free is deliberately small.
// Read GET /api/plans (max_attachment_bytes · max_attachments) instead of hard-coding.
Cleaning up
inbox.delete(); // inbox and every message, immediately
// or leave it: messages expire on their own at the retention you asked for
Inbox inbox = mf.create(CreateInboxOptions.builder()
.label("ci")
.retentionHours(2)
.build());
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 JUnit 5, Selenium, TestNG, Cucumber and anything else that gives you a setup and teardown hook.
// SignupTest.java: a fresh inbox per test, alongside Selenium
import net.mailflat.MailFlat;
import net.mailflat.Inbox;
import org.junit.jupiter.api.*;
import org.openqa.selenium.By;
class SignupTest {
static MailFlat mf = new MailFlat(System.getenv("MAILFLAT_API_KEY"));
Inbox inbox;
@BeforeEach
void openInbox() {
inbox = mf.create("ci-" + java.util.UUID.randomUUID().toString().substring(0, 8));
}
@AfterEach
void closeInbox() {
inbox.delete();
}
@Test
void userCanSignUpWithARealCode() {
driver.get("https://staging.example.com/signup");
driver.findElement(By.id("email")).sendKeys(inbox.address());
driver.findElement(By.id("submit")).click();
driver.findElement(By.id("code")).sendKeys(inbox.waitForOtp(60));
Assertions.assertTrue(driver.getCurrentUrl().endsWith("/dashboard"));
}
}
Line by linewhat each step of this Java 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.
Java reference
| Method | What it does |
|---|---|
| mf.create() / mf.create(label) / mf.create(CreateInboxOptions) | Open an inbox, returns Inbox |
| mf.list() | Every inbox this key opened |
| mf.inbox(address) | Attach to an existing address without an API call |
| inbox.waitForOtp(seconds) | Poll until a code arrives, throws on timeout |
| inbox.waitForMessage(seconds) | Same, but returns the whole Message |
| inbox.messages() / inbox.latest() | List<Message> (newest first) / Optional<Message> |
| inbox.send(to, subject, body[, html]) | Send from this address, DKIM-signed |
| inbox.delete() / inbox.deleteMessage(id) | Drop the inbox / one message |
Full endpoint reference, including error shapes and rate limits: Agent API and MCP.