Why I Built My Own Transactional Email Service (and Replaced Resend Across 12 Projects)
July 1, 2026
I run a portfolio of SaaS products under CommVergent Automation. BildOut for invoicing. Elysian Money for personal finance. FigrOut for project accounting. MerchIQ for e-commerce. HackHunters for cybersecurity. A handful of others. Twelve projects in total, each with its own sending domain, its own email needs, and — until recently — its own Resend account.
That last part was the problem.
The credential sprawl problem
Every project that sends email needs an account with an email service provider. For me, that meant twelve sets of API keys across multiple Resend accounts, each configured independently. Twelve dashboards to check delivery rates. Twelve DKIM records to manage. Twelve places where a forgotten password rotation could silently break password reset emails for real users.
It wasn't a cost problem — Resend's pricing is fair. It was an operational problem. I couldn't remember which credentials belonged to which project. I had no consolidated view of what was being sent across all my platforms. And when MerchIQ needed to provision custom sending domains for tenants, I was doing it manually through Resend's UI for each one.
For a solo developer running everything, this kind of overhead compounds. Every hour spent managing email infrastructure is an hour not spent building product.
What I already had
I wasn't starting from zero. My on-premises mail infrastructure already included a two-node Linux mail cluster running Postfix, Dovecot, and rspamd — handling inbound mail forwarding for six domains and hosting mailboxes for macmillancloud.com. I had a working relationship with SocketLabs as my SMTP relay, with established IP reputation and DKIM signing. The plumbing was there. What I needed was an application layer on top of it.
What I built
MailWain is a thin transactional email API service. The architecture is deliberately simple:
A NestJS API running on Fly.io accepts email send requests via REST API or SMTP, validates the sender domain against a per-organization registry, queues the message through BullMQ/Redis, and injects it into SocketLabs for delivery. A Next.js admin dashboard on Vercel provides domain management, delivery logs, suppression lists, usage metering, and organization management.
The key design decision was multi-tenancy from day one. Each of my projects is an "organization" in MailWain with its own API key, its own registered sending domains, its own usage tracking, and its own rate limits. The data model doesn't distinguish between my internal platforms and hypothetical future external customers — they're all organizations. Row-Level Security in Supabase enforces isolation at the database level, not just the application layer.
Domain provisioning is automated. When a project needs a new sending domain, the API generates a 2048-bit RSA DKIM key pair, registers the domain with SocketLabs, and returns the DNS records to publish. After DNS propagates, a verify call provisions DKIM signing in SocketLabs and activates the domain. The whole flow takes minutes, not the half hour of manual dashboard clicking it used to require.
For Supabase integration — which most of my projects use for authentication — MailWain exposes an SMTP submission endpoint with a valid Let's Encrypt certificate. Each Supabase project points its Custom SMTP settings at MailWain, using the organization's API key as the username and API secret as the password. Auth emails (signup confirmations, password resets, magic links) flow through the same pipeline as application emails, with the same delivery tracking and suppression management.
The migration
Migrating twelve projects sounds daunting. It wasn't. Each project followed the same pattern:
- Create an organization in the MailWain admin dashboard
- Register the sending domain and add DNS records
- Create a lightweight HTTP client in the project (a 40-line wrapper around fetch)
- Replace every
resend.emails.send()call with the new client - Remove the Resend SDK dependency
- Update environment variables
- Deploy
Most projects took about 30 minutes. The simpler ones — a single API route sending a newsletter or a contact form confirmation — were done in 15. The templates didn't need to change at all. React Email components render to HTML strings, which MailWain accepts as-is. Plain-text templates pass through as the text field. Same HTML, different transport.
BildOut was the most complex migration. It had eight separate sending routes (invoices, payment receipts, team invitations, overdue reminders, scheduled reports), a custom domain management layer for tenants, and a delivery webhook system that tracked bounces and complaints for domain health monitoring. The Resend SDK was deeply embedded. Even so, the migration was a matter of swapping the transport layer — the business logic, templates, and sending rules stayed the same.
MerchIQ was the most interesting migration because it tests the multi-tenant domain provisioning flow. MerchIQ is a multi-tenant e-commerce platform where merchants can configure custom sending domains for their stores. Previously, this required Resend's domain API. Now it goes through MailWain's domain API, which handles DKIM key generation, SocketLabs registration, DNS verification, and activation. When a MerchIQ merchant adds a custom domain, they see the DNS records to publish, click verify when ready, and start sending — no manual intervention from me.
One project — Occasion.fyi, our eCard platform — sends emails with inline image attachments (the rendered eCard as a CID-embedded PNG). MailWain didn't support attachments initially. Adding it was straightforward: accept a base64-encoded attachment array in the send payload, map it to SocketLabs' attachment format with ContentId for inline images, and raise Fastify's body size limit to accommodate the larger payloads. The eCard rendering pipeline didn't change at all — just the final delivery call.
What went wrong
This wasn't a clean, first-try success. Real engineering never is, and I think it's worth being honest about the stumbles.
SocketLabs' CNAME DKIM feature turned out to be a dead end. The documentation suggested you could delegate DKIM signing via a CNAME record pointing to their infrastructure, but the target hostname didn't actually resolve. I wasted time debugging DNS before realizing the feature simply didn't work as documented. Switching to TXT-based DKIM — where I generate the key pair and SocketLabs validates the public key against DNS — worked immediately and gave me full control.
The DKIM private key format was another surprise. SocketLabs requires PKCS#1 format (BEGIN RSA PRIVATE KEY), but Node.js crypto.generateKeyPairSync produces PKCS#8 (BEGIN PRIVATE KEY) by default. The SocketLabs error message said "private key contains invalid characters... carriage returns/new lines," which was misleading — the actual issue was the key format. A 5-line conversion function fixed it, but finding the root cause took real debugging.
A recurring bug across both the admin UI and the MerchIQ integration: sending Content-Type: application/json on HTTP requests with no body. Fastify (MailWain's HTTP framework) correctly rejects this, but it manifested as confusing errors on DELETE requests and bodyless POST requests like domain verification. The fix was trivial — only set the content type header when there's actually a body — but it bit us three separate times across different clients.
And the most embarrassing one: deploying secrets to Fly.io without deploying the code that reads them. Fly.io creates a new "release" when you set secrets, which restarts the machines, but it doesn't rebuild the application image. The old code kept running, checking for the old environment variables, while the new secrets sat unused. The app looked deployed but was running stale code. A fly deploy (which rebuilds and pushes a new image) fixed it, but the mismatch between "secrets deployed" and "code deployed" was a genuine operational trap.
The result
Twelve projects, one email service, one admin dashboard. Here's what the daily reality looks like now:
I open admin.mailwain.com and see everything: sends across all projects, delivery rates, bounce and complaint rates, suppression lists. If a sending domain has deliverability issues, I see it in one place. If I need to add a new project, I create an organization, add a domain, and hand the API key to the new codebase. Five minutes.
Supabase auth emails for every project route through the same SMTP endpoint. Application emails go through the same REST API. Delivery webhooks flow back through SocketLabs to MailWain to individual projects. It's one pipeline with consistent logging, consistent tracking, and consistent suppression management.
The infrastructure cost is minimal. SocketLabs at $15/month for 15,000 emails. Fly.io at roughly $12/month for the API (two machines with a dedicated IPv4 for SMTP). Vercel's free tier for the admin dashboard. Upstash Redis free tier for the job queue. Under $30/month total for transactional email across twelve production applications.
Should you do this?
Probably not — yet. Here's my honest take.
If you're running one or two projects, Resend (or Postmark, or SendGrid) is the right answer. The managed service handles deliverability, bounce processing, complaint handling, and infrastructure. Your time is better spent building your product.
If you're running five or more projects and the credential management overhead is real, the consolidation value starts to justify the build. But only if you already have the infrastructure chops. I had a working mail cluster, an established SMTP relay, and deep familiarity with DKIM, SPF, and DMARC before I started. If those acronyms are unfamiliar, you'll spend more time learning email infrastructure than you'll save on operations.
The hardest part of running your own email service isn't sending — it's deliverability management at scale. IP reputation, bounce rate monitoring, feedback loop processing, gradual IP warming for new domains. SocketLabs handles most of this behind the scenes, but if I ever outgrow their shared IP pool, I'll need dedicated IPs and the operational discipline that comes with them.
The build itself took a few days of focused work, spread across the initial service (API, SMTP endpoint, admin UI, webhook processing, domain provisioning) and the migration of each project. It's not a weekend hack, but it's not a quarter-long project either. The code is straightforward — the complexity is in understanding email standards and the operational surface area.
What's next
I built MailWain to solve my own problem, but the architecture doesn't care whether the organizations in the system are mine or someone else's. The multi-tenant model, per-organization API keys, usage metering, rate limiting, and abuse monitoring are already built. The admin UI already has an organization management page with tier and billing status fields.
If managing email across multiple projects is a pain point you recognize, I'd like to hear about it. Not a sales pitch — I'm genuinely curious whether this is a problem other portfolio builders, indie hackers, and small agencies are dealing with, or whether I'm an edge case. If there's demand, opening MailWain to others is a natural next step. If not, it's still the best infrastructure decision I made this year.