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. 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.onderyentar21</groupId>
<artifactId>mailflat-sdks</artifactId>
<version>v0.4.4</version>
</dependency>
<!-- Gradle -->
<!-- repositories { maven { url "https://jitpack.io" } } -->
<!-- implementation "com.github.onderyentar21:mailflat-sdks:v0.4.4" -->
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 | 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.
// 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.