MailHog taught a generation of developers to catch email locally instead of spamming real people from dev. It hasn't shipped a release since 2020. If you're moving off it, the honest answer is two tools: Mailpit for localhost, and a real inbox for the tests that leave localhost.
mailhog/mailhog Docker image was last updated the same day.brew install mailhog no longer works.Most teams that ran MailHog used it for two different things. The first is local development: point the dev server's SMTP at port 1025, open the web UI on 8025, and look at what your app would have sent. That job is alive and well — Mailpit does it today, is actively maintained, and keeps MailHog's default ports, so for many setups the migration is swapping the container image.
The second job is the one that hurts in CI: a test fixture. A shared MailHog container, a helper that calls /api/v2/search?kind=to, a retry loop, and a regex over Content.Body — the raw MIME body, quoted-printable soft line breaks and all. Moving that helper to Mailpit keeps every problem it already had: one shared store across parallel workers, polling instead of waiting, regex instead of extraction, and a container that only sees mail from apps whose SMTP config you control.
MailFixture covers only that second job, from the other side of the wire. Inboxes are real addresses on real domains with real MX records; your app sends through whatever it uses in that environment, the test waits server-side, and the code comes back as a typed field. It can't replace the first job — it's hosted, so it is no help when mail must stay on your machine. The wider layer-by-layer version of this decision is the fake SMTP vs. real test inbox guide.
extracted.otp.best, typed
Your regex on the raw body
Your regex on the body
Here's the shape of a typical MailHog-backed CI helper — search the shared store by recipient, take the first hit, regex the raw body:
// every message ever sent to that address const url = `http://mailhog:8025/api/v2/search` + `?kind=to&query=${encodeURIComponent(email)}`; const { items } = await (await fetch(url)).json(); // raw MIME body: maybe quoted-printable, maybe multipart const body = items[0]?.Content.Body ?? ''; const code = body.match(/\b\d{6}\b/)?.[0]; // …inside a retry loop with a sleep: nothing waits for you
The same test against MailFixture. The inbox is created per test, the wait is held server-side, and the code arrives already extracted — and because the address is real, it runs unchanged against localhost, staging, or a preview deploy:
const mail = new MailFixture(); // reads MAILFIXTURE_API_KEY const inbox = await mail.createInbox({ ttlSeconds: 900 }); await page.fill('#email', inbox.address); // a real, routable address await page.click('text=Send code'); const otp = await inbox.waitForOtp({ timeout: 30_000 }); await page.fill('#otp', otp);