We once sent a transactional email where the unsubscribe link pointed to localhost:3000. Not staging. Not production. Localhost. On someone else's computer, that link went absolutely nowhere. We found out because a user replied 'your unsubscribe button is broken lol' and that was that — one of those moments where you close your laptop and stare at the wall for a bit.
Email is the one place in web development where you can't just open DevTools and poke around. You send it, it either works or it doesn't, and if it doesn't you've already embarrassed yourself in front of every single person on your list. So let's talk about the tools and workflows that catch problems before they go out the door.
Why Email Testing Is Harder Than It Should Be
The core problem is that email clients are a mess. Gmail, Outlook, Apple Mail, and a dozen mobile clients all render HTML differently — sometimes wildly so. Outlook 2019 still uses Microsoft Word's rendering engine, which means it treats your carefully crafted flexbox layout like modern art: vaguely interesting but mostly broken. Meanwhile, Apple Mail on iOS renders the same email beautifully and you think everything's fine until someone screenshots their Outlook.
On top of rendering, you've got deliverability. Your email might look perfect but land in spam because your SPF record has a typo, or your DKIM isn't set up, or your IP reputation is new and Gmail is suspicious. These are separate problems from rendering, but they both result in the same outcome: your email doesn't work.
Then there's the content itself. Wrong variable substitution ("Hello {{first_name}}", the classic), missing images, broken links, the wrong user's data getting injected because of a bug in your template logic. All of this can be caught before you send — if you have the right tools in place.
Local Preview: React Email and the Browser
If you're building emails with React Email (which we use in all our peal.dev templates), you already have a local preview server built in. Run it and you get a browser-based preview that hot-reloads as you edit your template. This alone eliminates 80% of the dumb mistakes — you can see your layout, your fonts, your spacing, all in real time.
# Install React Email dev server
npm install react-email --save-dev
# Add to package.json scripts
"email": "email dev --dir src/emails --port 3001"
# Run it
npm run emailYour email components live in src/emails, and the dev server picks them up automatically. You get a preview of every template side by side, and you can switch between a desktop and mobile view. It's not perfect — it's still a browser rendering an email, not an actual email client — but it's fast and it catches the obvious stuff.
One thing we do: create a preview file alongside each email template with realistic test data. Not 'John Doe' and 'example@email.com'. Real-looking names, actual plan names, plausible invoice amounts. It forces you to think about edge cases — what does a name like 'María José Rodríguez-Fernández' do to your layout? What if the invoice amount is $12,450.00 instead of $9.99?
// src/emails/invoice.tsx
import { Html, Head, Body, Container, Text, Section } from '@react-email/components';
interface InvoiceEmailProps {
customerName: string;
invoiceNumber: string;
amount: string;
dueDate: string;
invoiceUrl: string;
}
export default function InvoiceEmail({
customerName,
invoiceNumber,
amount,
dueDate,
invoiceUrl,
}: InvoiceEmailProps) {
return (
<Html>
<Head />
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f4f4f4' }}>
<Container style={{ maxWidth: '600px', margin: '0 auto', backgroundColor: '#ffffff', padding: '24px' }}>
<Text>Hi {customerName},</Text>
<Text>Invoice {invoiceNumber} for {amount} is due on {dueDate}.</Text>
<Section>
<a href={invoiceUrl} style={{ backgroundColor: '#000', color: '#fff', padding: '12px 24px', textDecoration: 'none', borderRadius: '4px' }}>
View Invoice
</a>
</Section>
</Container>
</Body>
</Html>
);
}
// Preview props — used by React Email dev server
InvoiceEmail.PreviewProps = {
customerName: 'María José Rodríguez-Fernández',
invoiceNumber: 'INV-2024-00847',
amount: '$12,450.00',
dueDate: 'January 31, 2025',
invoiceUrl: 'https://app.yoursite.com/invoices/INV-2024-00847',
} satisfies InvoiceEmailProps;Catching Real Client Rendering Issues With Litmus and Email on Acid
For cross-client testing, there are two main players: Litmus and Email on Acid. Both work the same way conceptually — you paste in your HTML or send a test email to a special address, and they spin up real email client instances and screenshot the result. You get a grid of what your email looks like in Gmail on Chrome, Outlook 2016, Apple Mail on iOS, Samsung Mail, and about 70 other combinations.
They're not cheap. Litmus runs around $99/month for the entry plan, Email on Acid is similar. For a bootstrapped project, that stings. Our honest take: you probably don't need this running continuously. Use it when you first build a new email template, and again if you make significant structural changes. The transactional emails in your SaaS app are the same templates sent thousands of times — one-time investment in testing them properly is worth it.
What you're specifically looking for in these screenshots: images not loading (common in Outlook), buttons that collapse or break, text that overflows its container on mobile, dark mode rendering if you've tried to support it, and anything that looks completely different from your intended design.
The most common Outlook issue: anything using flexbox or CSS grid will silently fail. Always have a table-based fallback for layout, or use a framework like React Email that handles this for you.
Sending Test Emails Without Spamming Real People
Local preview is great but you also need to test the actual send. Not the rendering — the mechanics. Does your template variable substitution work? Does the email arrive? Does it hit spam? For this, you need a safe inbox that isn't your actual users.
Mailtrap is our go-to for development. It's a fake SMTP server that catches all outgoing emails and shows them in a web interface. You configure it as your email provider in development, and every email your app sends — password resets, welcome emails, whatever — lands in Mailtrap instead of going anywhere real. No accidentally emailing users while testing.
// Environment-based email configuration
// Works with Nodemailer, or adapt for Resend/Postmark
const emailConfig = process.env.NODE_ENV === 'production'
? {
// Resend for production
provider: 'resend',
apiKey: process.env.RESEND_API_KEY!,
}
: {
// Mailtrap for development
host: 'sandbox.smtp.mailtrap.io',
port: 587,
auth: {
user: process.env.MAILTRAP_USER!,
pass: process.env.MAILTRAP_PASS!,
},
};
// .env.local
// MAILTRAP_USER=your_mailtrap_user
// MAILTRAP_PASS=your_mailtrap_passwordMailtrap also has a spam analysis tool built in. After catching your email, it'll run it through SpamAssassin and give you a score with specific reasons why it might be flagged. It's not a substitute for real-world deliverability testing, but it catches the obvious stuff like too many exclamation marks in your subject line or an image-to-text ratio that looks spammy.
For staging environments where you want real email delivery but don't want to hit real users, we use Resend's test mode — or just maintain a list of internal test addresses. Anything sent to @yourdomain.com addresses you own is fair game. Set up a catch-all inbox on your domain and use randomly generated test addresses like test-20250115@yourdomain.com so you know exactly what triggered each email.
Deliverability: The Part Everyone Ignores Until Emails Stop Arriving
Deliverability is a completely separate concern from rendering and functionality, and it deserves its own checklist. We learned this the hard way when a client's welcome emails had a 40% open rate, then dropped to 8% after they switched domains. The emails looked fine. They were just silently landing in spam.
The basics you need before sending anything at scale:
- SPF record: tells receiving servers which IPs are allowed to send email for your domain. Should include your email provider (Resend, Postmark, SendGrid).
- DKIM: cryptographically signs your emails so receiving servers can verify they weren't tampered with. Your email provider gives you a DNS record to add.
- DMARC: policy that tells receiving servers what to do when SPF or DKIM fails. Start with p=none to monitor, then move to p=quarantine or p=reject.
- Custom sending domain: don't send from gmail.com or hotmail.com. Set up email on your own domain. Resend and Postmark both walk you through this.
- Warm up new IPs/domains: don't go from zero to 10,000 emails on day one. Spam filters are suspicious of new senders. Start small and ramp up.
To check your configuration, MXToolbox (mxtoolbox.com) is free and will tell you if your SPF/DKIM/DMARC records are set up correctly. Mail-tester.com is another useful one — send an email to the address they give you and get a score out of 10 with specific issues flagged. Both are free and take two minutes.
# Check your DNS records are correct
# SPF lookup
nslookup -type=TXT yourdomain.com | grep spf
# DKIM lookup (replace 'selector' with your actual DKIM selector)
nslookup -type=TXT selector._domainkey.yourdomain.com
# DMARC lookup
nslookup -type=TXT _dmarc.yourdomain.com
# Example of what good records look like:
# SPF: v=spf1 include:_spf.resend.com ~all
# DMARC: v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.comAutomated Checks You Can Wire Into CI
If you're sending emails built with React Email, you can add a build check that ensures all templates compile without errors. It won't catch visual bugs, but it'll catch broken imports, missing props, and TypeScript errors before they hit production.
// scripts/check-emails.ts
// Run this in CI: ts-node scripts/check-emails.ts
import { render } from '@react-email/render';
import WelcomeEmail from '../src/emails/welcome';
import InvoiceEmail from '../src/emails/invoice';
import PasswordResetEmail from '../src/emails/password-reset';
const templates = [
{ name: 'WelcomeEmail', component: WelcomeEmail, props: WelcomeEmail.PreviewProps },
{ name: 'InvoiceEmail', component: InvoiceEmail, props: InvoiceEmail.PreviewProps },
{ name: 'PasswordResetEmail', component: PasswordResetEmail, props: PasswordResetEmail.PreviewProps },
];
async function checkTemplates() {
let hasErrors = false;
for (const template of templates) {
try {
const html = await render(template.component(template.props as any));
// Basic sanity checks
if (html.length < 100) throw new Error('Rendered HTML suspiciously short');
if (html.includes('{{') || html.includes('}}')) throw new Error('Unresolved template variables');
if (html.includes('localhost')) throw new Error('localhost reference in email');
console.log(`✓ ${template.name} (${html.length} chars)`);
} catch (error) {
console.error(`✗ ${template.name}: ${error}`);
hasErrors = true;
}
}
if (hasErrors) process.exit(1);
}
checkTemplates();That localhost check in there? That's from experience. The script runs in your CI pipeline, and if any template renders with a localhost URL in it, the build fails. Simple, effective, and born from genuine embarrassment.
You can extend these checks with link validation — fetch every URL in your rendered HTML and make sure you get a 200 back. That catches broken links before they go out. It's a bit slow if you have a lot of links, so maybe run it nightly rather than on every commit.
The Practical Workflow
Here's the full workflow we use, roughly in order of how often each step runs:
- While building: React Email dev server for instant visual feedback
- Before committing: CI script that renders all templates and checks for obvious mistakes (localhost, unresolved variables, broken imports)
- When a template is first created or significantly changed: Mailtrap for functional testing, Litmus/Email on Acid for cross-client screenshots
- When setting up a new domain or provider: MXToolbox and mail-tester.com for deliverability verification
- Ongoing: Monitor open rates and bounce rates in your email provider's dashboard — sustained drops are usually deliverability problems
Most of our peal.dev templates come with the email setup already wired — React Email templates with preview props, Mailtrap configuration for development, and the Resend integration for production. The CI script above is something you'd add yourself, but the foundation is there so you're not starting from scratch.
The reality is that email testing doesn't need to be complicated. The tools exist, they're mostly free or cheap, and a two-minute sanity check before hitting send has saved us from multiple public embarrassments. The localhost unsubscribe link was the last time we shipped an email without running through at least the basics. Mostly because we told this story enough times that it's now permanently burned into our workflow.
Set up Mailtrap on day one, add a CI check for obvious mistakes, and run your templates through Litmus once before launch. That's it. You don't need a complex QA process — you just need to not skip the basics.
