How I Built a Disposable Email Service in a Weekend (And Learned Why Architecture Matters More Than Code)
The Spark
It all started with a recruiter's email. Not the exciting job offer kind — the "we'd love to see a full-stack project that isn't a to-do list" kind.
I’d been writing React components for two years, but when I looked at my portfolio, a sinking feeling hit me. I had nothing that proved I truly understood the messy realities of production: sessions, databases, rate limiting, or securely talking to external APIs without leaking secrets.
I needed something real. Not a tutorial clone. Not a pixel-perfect Netflix UI replica. I wanted something that actually processes data, handles abuse, and stays secure while doing it.
I landed on a disposable email service. On the surface, it’s deceptively simple: click a button, get an email address, read your messages, and walk away. But peeling back the layers to actually build it? That's where the real engineering starts.
The Honest Disclaimer
Before we dive into the code, let me be brutally transparent: I did not build an SMTP server. I didn't configure MX records, spin up Postfix, or fight the endless war against spam blacklists. The underlying email infrastructure is powered by the Mail.tm API (and later, mail.gw).
What I did build was the entire application layer around it — the session management, the database schema, the API abstraction, the abuse protection, and the UI that ties it all together.
If you're reading this hoping I reverse-engineered legacy email protocols, you might be disappointed. But if you want to know how to architect a production-grade, anonymous service on top of third-party infrastructure? Keep reading.
The Stack Decision
I reached for Next.js 15 with the App Router. Not just because it's trendy, but because I wanted to wrestle with server components, server actions, and API routes within a single mental model. TypeScript was non-negotiable — I've been burned by any types in production far too many times to go back.
For the UI, I went with Tailwind CSS and shadcn/ui. I know every new developer is using shadcn right now, but here's the reality: I didn't want to burn three days writing accessible dropdowns and focus traps. I wanted to spend those three days figuring out why my session cookies weren't persisting. shadcn gave me the beautiful primitives; I wired the logic.
For persistence, I chose Prisma and PostgreSQL. Though, to be completely honest, I started with SQLite because it was "easy." That single decision almost destroyed the entire project later. We'll get to that.
Phase 1: Talking to the Beast
The first real hurdle wasn't the code itself — it was deciphering Mail.tm's sparse documentation. You hit /domains to get available domains, POST /accounts to create a mailbox, POST /token to authenticate, and finally, you can read messages.
I started entirely outside the app. I wrote a standalone test script—no UI, no database—just raw fetch calls to see if I could string it all together:
- Get a domain
- Create an account
- Authenticate
- Wait for an email to arrive
That last step was a revelation. I sent a test email from my personal Gmail to the temporary address and watched my terminal. Suddenly, the API response changed from an empty array to a populated message object. That moment — seeing real email data flow through an API I was about to abstract — was when the project stopped feeling theoretical and became real.
I immediately wrapped those raw HTTP calls into a clean, abstract client: getDomains(), createAccount(), getToken(), getMessages(). The rest of my application never imports fetch directly. If Mail.tm changes their API tomorrow, I only have to update one file.
Phase 2: The Session Problem
Here’s where most tutorials would tell you to npm install next-auth, configure Google OAuth, and call it a day. But BlinkMail doesn't have users. There are no passwords, no logins, no user table. The entire value proposition is zero-friction anonymity.
So, how do you prove ownership of a mailbox without a user account?
I had to architect an opaque session system from scratch:
- When a user clicks "Create Temporary Email," the server generates a cryptographically secure random token (using 32 bytes from
crypto.randomBytes). - The server hashes that token with SHA-256 and stores only the hash in the database.
- The raw token is sent back to the browser in an
HTTP-only,Secure,SameSite=Strictcookie. - Every subsequent request sends that cookie automatically.
- The server hashes the incoming cookie, looks up the hash in the database, and verifies the session hasn't expired.
The user never even sees a token. The browser can't read the cookie from JavaScript. If an attacker manages to dump the database, they only get hashes — they can't forge cookies because they don't have the raw tokens.
This was honestly the most intellectually satisfying part of the build. I spent an entire afternoon just reading about cookie security attributes and testing edge cases. It felt like building a lock from scratch and then trying to pick it.
Phase 3: The Database Schema
I designed a tight three-table structure:
- Session:
id,token(hashed),ipAddress,userAgent,expiresAt,lastAccessedAt - Mailbox:
id,sessionId(1:1),mailtmId,mailtmToken,address,domain,localPart,expiresAt - Message:
id,mailboxId,mailtmId,fromAddress,subject,intro,text,html,isRead,createdAt
The relationship chain here is critical for security: Session → Mailbox → Message. When a user requests a specific message, the API doesn't just blindly look up the message ID. It joins through the mailbox to verify the session actually owns it. A user with a valid session cannot read another user's messages, even if they guess the message UUID.
I also learned the hard way that Prisma's upsert is your best friend when syncing third-party data. Without deduplication via mailtmId, every single inbox refresh created duplicate rows.
Phase 4: The Encryption Realization
Here's a terrifying detail most "build a temp mail" tutorials gloss over: you are storing third-party bearer tokens in your database.
When a user creates a mailbox, Mail.tm gives you an authentication token to fetch messages later. If you store it in plaintext and your database leaks, an attacker can read every user's emails directly from Mail.tm's API.
Realizing this, I implemented AES-256-GCM encryption for the Mail.tm tokens. The encryption key is derived from a SESSION_SECRET via SHA-256. The tokens are encrypted at rest and decrypted only in memory when the API needs to make a request. It added maybe 20 lines of code, but the peace of mind? Priceless.
Phase 5: Abuse Protection (The Paranoid Phase)
Anonymous access is a massive magnet for abuse. Without rate limiting, someone could script mailbox creation, burn through Mail.tm's domains, and exhaust my database connections in minutes.
I implemented two layers of defense:
- IP-based rate limiting on creation: 5 mailboxes per hour per IP. I used a simple in-memory sliding window for the MVP.
- Session-based rate limiting on reads: 60 requests per minute per session to prevent aggressive inbox polling.
I also added a cleanup endpoint that purges expired sessions and their associated mailboxes. On Vercel, this runs as a cron job at 3 AM.
The rate limiter is admittedly crude — it's an in-memory Map with timestamps. For a production product at scale, I'd drop in Redis (Upstash has a great free tier). But for a portfolio project proving I understand the concept? The in-memory version does exactly what it needs to.
Phase 6: The UI (Finally)
After all that backend architecture, building the UI felt almost like a vacation. A clean landing page with a single call-to-action. An inbox with a one-click copy-to-clipboard button. A message list with unread indicators, and a clean dialog for reading full message content.
I used Sonner for toast notifications instead of jarring alert() boxes. I added a refresh button with a spinning icon and implemented auto-polling every 30 seconds so the inbox feels alive and responsive.
The most underrated UI decision I made? Using skeleton screens instead of spinners. When data is loading, showing a gray placeholder shaped exactly like the content reduces perceived load time and looks significantly more polished than a spinning circle.
Phase 7: The Deployment Disaster (SQLite vs. PostgreSQL)
This is the part where I almost rage-quit.
I built the entire project using SQLite because it was frictionless. file:./dev.db, run migrations, and I was coding. Then I tried to deploy to Vercel.
Vercel is serverless. Every request can hit a different physical server. SQLite is a literal file on a disk. You see the problem?
I would create a mailbox on one server. The user would refresh the page. The request would hit a different server. That server had a different dev.db file (or none at all). 404 Mailbox not found. Every. Single. Time.
I had to execute an 11th-hour migration to Neon PostgreSQL. I created a Neon project, updated the Prisma provider from sqlite to postgresql, ran migrations against the cloud database, updated my environment variables, and redeployed.
The hard lesson: Pick your database for your deployment target, not your development comfort. SQLite is amazing for local prototypes. PostgreSQL is for anything that touches the internet.
Phase 8: The IP Block Nightmare
I deployed. The database connected. The session system worked flawlessly. I confidently clicked "Create Temporary Email."
500 Internal Server Error.
The logs were heartbreaking: MailtmNetworkError: Mail.tm network error. Status 503.
Mail.tm was actively blocking Vercel's outbound IP addresses. Not because I did anything wrong, but because Vercel's datacenter IPs are shared by thousands of applications, and Mail.tm blocks them to prevent, ironically, the exact abuse I was trying to rate-limit on my end.
I tried switching to mail.gw, a similar service. It worked... until it didn't. The harsh reality of free temporary email APIs is that they all eventually block major cloud providers.
The honest fix: I documented the limitation. The live demo perfectly showcases the architecture, the UI, the session flow, and the database integration. For full email receiving functionality, you just have to run it locally where your residential IP isn't blocklisted.
Is it ideal? No. Is it a valid portfolio piece that demonstrates full-stack engineering? Absolutely. Every recruiter I've shown it to has been infinitely more interested in the session architecture and rate limiting than whether they can receive a Netflix verification code on the live URL.
What I Learned
- Architecture is more valuable than syntax. I could have vibe-coded a pretty frontend in an hour. But understanding why HTTP-only cookies matter, why you hash session tokens before storing them, and how to structure a relational database for security — that's actual engineering.
- External APIs are a dependency, not a feature. You must abstract them. If Mail.tm shuts down tomorrow, I change exactly one file. The rest of the application doesn't even know Mail.tm exists.
- Deployment teaches you things localhost never will. CORS, environment variables, connection pooling, serverless constraints — these demons only show up when you finally leave
localhost:3000. - Security is a series of small, deliberate decisions. Encrypt the token. Hash the session. Validate the input. Rate limit the endpoint. None of these are flashy features. Together, they make the difference between a fragile demo and a robust product.
The Code & The Live App
If you want to poke around or see the full implementation, it's entirely open source. The README explicitly states what I built and what I didn't.
BlinkMail is live here: https://blinkmail-blush.vercel.app/
Try it out. Break it. Check your network tab and watch how the session cookie flows. That's where the real magic is happening.
The Stack:
- Next.js 15 App Router
- TypeScript
- Tailwind CSS + shadcn/ui
- Prisma ORM
- PostgreSQL (Neon)
- Mail.tm / mail.gw API
- AES-256-GCM encryption
- In-memory rate limiting (with Redis upgrade path documented)
What's Next
If I ever revisit this project, here's what I'd add:
- WebSocket or Server-Sent Events for real-time inbox updates (instead of 30-second polling).
- HTML sanitization with DOMPurify for bulletproof message rendering.
- Redis-backed rate limiting to support multi-server deployments.
- Custom domain support (if I ever decide to run my own mail infrastructure).
But for a portfolio piece built to learn full-stack architecture? It's done. It works. It taught me more about sessions, security, and deployment than any tutorial ever could.
If you're a junior developer reading this: Don't build another to-do list. Find a service with an API, wrap it in your own architecture, handle the nasty edge cases, and deploy it. The gaps in your knowledge only show up when you're responsible for the whole stack.
Thoughts, feedback, or ideas?
Have a question about this technical breakdown or want to discuss engineering ideas?