A colleague new to the Studomia codebase got the application running this week, reached the sign in screen, and asked a fair question. What is Mailpit?
Nobody had told them. Nothing in the repository had either. The tool had sat on the critical path of every local sign in since the day we chose to sign people in by emailed code, and the only people who knew were the ones who no longer needed to ask.
This post is the answer we should have written down sooner, and the lessons that came with writing it.
Why a mailbox is on the critical path
Studomia is a learning environment we work on, and it has no passwords. A person arrives by invitation, enters their email address, and receives a six digit code. The code is the only way in.
That is a good decision for the people using it and an awkward one for the people building it. Every local run, every demonstration and every end to end test now depends on receiving an email. A developer's laptop has no mail server. The addresses we test with belong to nobody. And the one thing we must never do from a development machine is send a real message to a real person.
So the question is not whether to fake the mailbox. It is where to put the fake.
What Mailpit is
Mailpit is a small open source email testing tool. It accepts mail the way a mail server does, keeps every message, and delivers none of them. A web page shows what arrived. An HTTP API returns the same messages as JSON.
We did not install it. Studomia runs on Supabase, and the Supabase command line tool starts a local stack in Docker: a database, the authentication service, an API gateway and Mailpit. The authentication service is already configured to send its mail there.
pnpm exec supabase start
Once the stack is up, the inbox is a page in the browser at http://127.0.0.1:54324. Request a code on the sign in screen and the message appears a moment later.
Lesson one: keep the email boring
Our local email template is a single line.
<p>{{ .Token }}</p>
The body of the message is the code and nothing else. No greeting, no logo, no link.
This is deliberate. A person reading the inbox sees the six digits at once. A test reading the inbox needs one small pattern to find them. Every sentence we add to that template is a sentence a test has to read around, and a future change to the wording becomes a failing build that has nothing to do with signing in.
The template is marked in our configuration as local only. What a hosted provider sends to a real person is a separate decision, and we keep the two apart on purpose.
Lesson two: let the test read its own mail
Our end to end test signs in the way a person does. It opens the invitation, asks for a code, and then goes to the inbox to fetch it. This is the helper, trimmed for the page.
const syntheticEmail = `synthetic-${randomUUID()}@example.test`;
const readSyntheticOtp = async (): Promise<string> => {
for (let attempt = 0; attempt < 40; attempt += 1) {
const response = await fetch(`${mailpitUrl}/api/v1/messages`);
const mailbox = await response.json();
const message = mailbox.messages?.find((candidate) =>
candidate.To?.some(({ Address }) => Address === syntheticEmail),
);
if (message) {
const detail = await (
await fetch(`${mailpitUrl}/api/v1/message/${message.ID}`)
).json();
const match = `${detail.Text ?? ""} ${detail.HTML ?? ""}`.match(
/\b\d{6}\b/,
);
if (match) return match[0];
}
await wait(250);
}
throw new Error("Synthetic local OTP was not captured");
};
Three choices in that helper earned their place.
A fresh address for every run. The address contains a random identifier, so the test only ever finds its own message. Yesterday's run, a colleague's run and a half finished manual session can all share one inbox without confusing each other. We never clear the inbox to make a test pass.
A reserved domain. Every synthetic address ends in example.test. The .test ending is reserved and can never belong to a real mailbox. If a configuration mistake one day points local code at a real mail provider, the message has nowhere to go.
A deadline with a name. Email is asynchronous even when the mail server is a container on the same machine. The helper looks forty times, a quarter of a second apart, and then fails with a sentence that says what is missing. Ten seconds is long enough for a slow laptop and short enough that a broken stack is reported as a broken stack.
Lesson three: a test that reads mail must know where it is
A test that creates invitations, reads inboxes and signs in is a powerful thing to point at the wrong environment. Ours refuses to start unless every address it is given is on the local machine and on the port we expect.
const requireLocalUrl = (name: string, expectedPort: string): string => {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
const url = new URL(value);
if (
!["127.0.0.1", "localhost", "::1"].includes(url.hostname) ||
url.port !== expectedPort
) {
throw new Error(`${name} must use the expected loopback port`);
}
return value;
};
The check runs before any browser opens. It costs a dozen lines and removes a whole category of accident.
Lesson four: one service, two names
Here is the part that cost our colleague the most time.
The tool is called Mailpit. Our configuration file calls it something else.
[inbucket]
enabled = true
port = 54324
Inbucket is the mail catcher the Supabase tooling used before it moved to Mailpit. The configuration section kept the old name, and so did the container. Ask the tool for its status and it lists an Inbucket address alongside a Mailpit one.
So a search of the repository for "mailpit" finds test code and no configuration. A search for "inbucket" finds configuration and no explanation. Neither search finds a sentence telling a newcomer that their sign in code is waiting in a browser tab.
The names are not ours to change. The sentence is.
What the inbox does not prove
Mailpit proves that our application asked for a message and that the code inside it works. It stops there, and so should our claims.
It does not prove that a real provider will accept the message, that the message will pass a spam filter, or that what a person receives in production reads well on a phone. Those are questions about delivery, and nothing on a laptop can answer them. A green local run is evidence that signing in works. It is not evidence that the email arrives.
It is also not a safe place. The inbox asks for no password by default, and every message in it is a working key to the local stack. That is acceptable on a developer's own machine and a poor idea anywhere others can reach.
The move you can reproduce
If your application signs people in by email, four things make the local experience dependable.
- Run a mail catcher as part of the stack, started by the same command as the database.
- Make the local message as plain as it can be. The code, alone.
- Give every test run its own address on a reserved domain, and let the test fetch its own code against a deadline.
- Write one paragraph in the README, beside the start command, that says where the code will appear.
The fourth is the one we had skipped. This is the paragraph we were missing.
## Signing in locally
Sign in codes are never sent to a real inbox. After `pnpm exec supabase start`,
open http://127.0.0.1:54324 and read the code there. The tool is Mailpit.
The configuration file calls it `inbucket`.
Four lines. They would have saved a morning.
Why we work this way
The tools a team stops noticing are the ones a newcomer trips over. Mailpit had become furniture to us: always there, never mentioned, holding up something important.
"What is this?" is the cheapest review a codebase will ever receive, and only someone who does not know yet can give it. We tell learners in the Dojo that a question like that is a contribution. This week a colleague handed the lesson back to us, and they were right.
The inbox still delivers nowhere. The explanation, at last, has arrived.


