Reliable email tests. Finally.
Programmable inboxes for devs and QA. Send emails to Plop, fetch via API, assert in tests.
No mail server. No flaky waits. Just a simple API.
Website | Dashboard | Docs | API
Testing email flows is painful:
- Flaky waits - Polling IMAP servers with arbitrary timeouts
- Infrastructure overhead - Running Mailhog, MailCatcher, or real SMTP servers
- Unpredictable routing - Shared test inboxes causing race conditions
- No observability - Email sits outside your product stack
Plop makes email testing deterministic and fast:
1. Send to Plop → qa+signup@in.plop.email
2. Fetch via API → GET /v1/messages/latest
3. Assert in tests → Extract OTP, verify links, check content
That's it. Email arrives, you fetch it as JSON, your test passes.
- Mailboxes + Tags - Route flows with
mailbox+tagaddresses (e.g.,qa+login@in.plop.email) - REST API - List, filter, and fetch emails. Poll for the latest message
- Scoped API Keys - Full access, email-only, or mailbox-scoped permissions
- Instant Delivery - Email arrives → stored → available via API instantly
- Message Retention - 14-90 days depending on plan
- Private Inboxes - Mailboxes require API key authentication
- Strict Routing - Unknown mailboxes are rejected and never stored
- Mailbox Isolation - API keys can be scoped to specific mailboxes
- Reserved Names - 180+ protected mailbox names prevent phishing/impersonation
- Works with Cypress, Playwright, and any Node test framework
- Simple REST API - just
fetch()with an API key - OpenAPI documentation with interactive Scalar UI
- Comprehensive filtering by mailbox, tag, date range, and search query
Sign up at app.plop.email and create a team. Your first mailbox is auto-generated.
Navigate to Team Settings → API Keys and create a key.
Cypress
// Use a Plop address for signup
cy.get('[data-testid="email-input"]').type('qa+signup@in.plop.email')
cy.get('[data-testid="submit"]').click()
// Fetch the verification email
cy.request({
method: 'GET',
url: 'https://api.plop.email/v1/messages/latest?mailbox=qa&tag=signup',
headers: { Authorization: 'Bearer YOUR_API_KEY' },
}).then(({ body }) => {
// Extract OTP from email content
const otp = body.data.textContent?.match(/\b\d{6}\b/)?.[0]
cy.get('[data-testid="otp-input"]').type(otp)
})Playwright
import { test, expect } from '@playwright/test'
test('user can verify email', async ({ page, request }) => {
await page.fill('[data-testid="email"]', 'qa+verify@in.plop.email')
await page.click('[data-testid="submit"]')
// Fetch verification email from Plop
const res = await request.get(
'https://api.plop.email/v1/messages/latest?mailbox=qa&tag=verify',
{ headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` } }
)
const { data } = await res.json()
const otp = data.textContent?.match(/\b\d{6}\b/)?.[0]
await page.fill('[data-testid="otp"]', otp)
await expect(page.locator('[data-testid="success"]')).toBeVisible()
})Node.js / Any Framework
const response = await fetch(
'https://api.plop.email/v1/messages/latest?mailbox=qa&tag=password-reset',
{
headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` },
}
)
const { data } = await response.json()
console.log(data.subject) // "Reset your password"
console.log(data.textContent) // Plain text body
console.log(data.htmlContent) // HTML body| Endpoint | Description |
|---|---|
GET /v1/messages |
List messages with filters |
GET /v1/messages/latest |
Get most recent matching message |
GET /v1/messages/:id |
Get specific message by ID |
| Parameter | Description |
|---|---|
mailbox |
Filter by mailbox name |
tag |
Filter by tag (from mailbox+tag@) |
from |
Filter by date range start |
to |
Filter by date range end |
q |
Full-text search query |
{
"data": {
"id": "msg_abc123",
"mailbox": "qa",
"tag": "signup",
"from": "noreply@yourapp.com",
"to": "qa+signup@in.plop.email",
"subject": "Verify your email",
"textContent": "Your verification code is 482913",
"htmlContent": "<html>...</html>",
"receivedAt": "2024-01-15T10:30:00Z"
}
}┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Your App │────▶│ Cloudflare │────▶│ Plop Worker │
│ sends email │ │ Email Routing │ │ (apps/inbox) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Your Tests │◀────│ Messages API │◀────│ PostgreSQL │
│ fetch emails │ │ GET /latest │ │ + R2 Storage │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Data Flow:
- Email arrives at Cloudflare Email Routing
- Cloudflare Worker (
apps/inbox) receives and parses email - Metadata stored in PostgreSQL, raw
.emlin R2 - Messages API serves emails with filtering and search
- Your tests fetch the latest matching email
This is a Turborepo monorepo with Bun workspaces.
| App | Stack | Purpose |
|---|---|---|
apps/app |
Next.js 16 (App Router) | Product dashboard with inbox management |
apps/web |
Next.js | Marketing website |
apps/docs |
Next.js + Fumadocs | Documentation site |
apps/api |
Bun + Hono + tRPC | Backend API with OpenAPI docs |
apps/inbox |
Cloudflare Worker | Email ingestion worker |
| Package | Purpose |
|---|---|
@plop/db |
Drizzle schema + Postgres client with read replicas |
@plop/supabase |
Supabase clients (server/client/middleware/job) |
@plop/ui |
Shared UI components (shadcn-style) |
@plop/kv |
Upstash Redis + rate limiting |
@plop/jobs |
Trigger.dev background jobs |
@plop/email |
React Email templates |
@plop/billing |
Polar.sh billing integration |
@plop/logger |
Pino structured logger |
| Service | Provider |
|---|---|
| Auth, Database, Storage | Supabase |
| Web & App | Vercel |
| Email Routing & Workers | Cloudflare |
| Background Jobs | Trigger.dev |
| Rate Limiting | Upstash Redis |
# Clone the repository
git clone https://github.com/plop-email/plop.git
cd plop
# Install dependencies
bun install
# Start local Supabase
bun dev:supabase
# Run migrations
bun migrate
# Start all apps in parallel
bun dev# Development
bun dev # Run all apps in parallel
bun dev:app # Product app at localhost:3000
bun dev:web # Marketing site at localhost:3001
bun dev:docs # Documentation at localhost:3002
bun dev:api # API at localhost:3003
bun dev:email # Email preview at localhost:3004
# Quality
bun run format # Format with Biome
bun run lint # Lint with Biome + sherif
bun run typecheck # TypeScript check all workspaces
# Database
bun migrate # Run migrations
bun seed # Generate and run seeds
bun reset # Reset local database
bun generate # Regenerate DB types| Service | URL |
|---|---|
| Product App | http://localhost:3000 |
| Marketing Site | http://localhost:3001 |
| Documentation | http://localhost:3002 |
| API | http://localhost:3003 |
| Email Preview | http://localhost:3004 |
| Supabase Studio | http://localhost:54323 |
| Plan | Price | Mailboxes | Emails/Month | Retention |
|---|---|---|---|---|
| Starter | $6.99/mo | 1 | 5,000 | 14 days |
| Pro | $49/mo | 10 | 60,000 | 90 days |
| Enterprise | Contact us | Unlimited | Unlimited | Custom |
All plans include unlimited tags and a 14-day free trial. No credit card required.
Verify OTP codes, password reset links, and signup confirmations in your automated test suite.
Test email flows in CI/CD pipelines with deterministic, isolated inboxes per test run.
Assert on receipts, notifications, and order confirmations before shipping to production.
Verify welcome emails, verification flows, and user activation sequences.
We welcome contributions! Please see our Contributing Guide for details.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the AGPL-3.0 license for non-commercial use.
- Website: plop.email
- Dashboard: app.plop.email
- Documentation: docs.plop.email
- API: api.plop.email
- Twitter: @vahaah
Built with care by Alex Vakhitov at Comonad Limited
