"The password reset email isn't arriving."
That's when you realize: your email system is a black box. No logs. No trace IDs. No idea if it rendered, if it sent, or where it died.
Here's how to fix it: five debugging patterns that turn email mysteries into traceable, fixable incidents.
1) Trace IDs: Connect the dots across systems
Generate a unique ID for every email send. Use it everywhere: logs, error tracking, support tickets, and email headers.
import { nanoid } from 'nanoid';
export function createEmailTrace() {
return {
traceId: nanoid(16),
timestamp: new Date().toISOString(),
};
}
export function logEmailEvent(
traceId: string,
event: string,
metadata?: Record<string, unknown>
) {
console.log(
JSON.stringify({
service: 'email',
traceId,
event,
timestamp: new Date().toISOString(),
...metadata,
})
);
}Use it in your send flow:
import { render } from '@react-email/render';
import { resend } from '@/lib/resend';
import PasswordResetEmail from '@/emails/password-reset';
import { createEmailTrace, logEmailEvent } from '@/lib/email-tracer';
export async function POST(req: Request) {
const { traceId } = createEmailTrace();
try {
const body = await req.json();
logEmailEvent(traceId, 'send_requested', { recipient: body.email });
const html = await render(<PasswordResetEmail {...body} />);
logEmailEvent(traceId, 'template_rendered');
const result = await resend.emails.send({
from: 'security@example.com',
to: body.email,
subject: 'Password reset requested',
html,
headers: {
'X-Email-Trace-Id': traceId, // Include in email headers
},
});
logEmailEvent(traceId, 'send_success', { messageId: result.id });
return Response.json({ traceId, messageId: result.id });
} catch (error) {
logEmailEvent(traceId, 'send_failed', {
error: error instanceof Error ? error.message : 'Unknown error'
});
throw error;
}
}2) Render validation: Catch template errors before send
Templates can break at render time. Missing props, malformed data, TypeScript lies. Validate the rendered output before it leaves your server.
export function validateRenderedEmail(html: string): {
valid: boolean;
issues: string[];
} {
const issues: string[] = [];
// Check for undefined/null leaks
if (html.includes('undefined') || html.includes('null')) {
issues.push('Template contains undefined or null values');
}
// Check for broken links
const hrefMatches = html.match(/href="([^"]*)"/g) || [];
for (const match of hrefMatches) {
if (match.includes('undefined') || match.includes('{{')) {
issues.push(`Broken link detected: ${match}`);
}
}
// Check for missing required content
if (!html.includes('<!DOCTYPE')) {
issues.push('Missing DOCTYPE declaration');
}
// Check for inline JS (spam filter trigger)
if (html.includes('<script') || html.includes('javascript:')) {
issues.push('Contains inline JavaScript (deliverability risk)');
}
return {
valid: issues.length === 0,
issues,
};
}Use it as a pre-send gate:
const html = await render(<MyEmail {...props} />);
const validation = validateRenderedEmail(html);
if (!validation.valid) {
logEmailEvent(traceId, 'render_validation_failed', {
issues: validation.issues
});
return Response.json(
{ error: 'Email failed validation', issues: validation.issues },
{ status: 500 }
);
}
logEmailEvent(traceId, 'render_validation_passed');
// Proceed to send...3) Preview snapshots: Visual debugging for email issues
When a user reports "the email looks broken," you need to see what they saw. Store a preview snapshot of every sent email.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function saveEmailSnapshot(
traceId: string,
html: string
): Promise<string> {
const key = `email-snapshots/${new Date().toISOString().split('T')[0]}/${traceId}.html`;
await s3.send(
new PutObjectCommand({
Bucket: process.env.EMAIL_SNAPSHOTS_BUCKET!,
Key: key,
Body: html,
ContentType: 'text/html',
})
);
// Return signed URL (expires in 7 days)
return `https://${process.env.EMAIL_SNAPSHOTS_BUCKET}.s3.amazonaws.com/${key}`;
}Store the snapshot URL in your database alongside the email send record. When support gets a ticket, they can pull up the exact HTML that was sent.
4) Delivery webhooks: Track what actually happened
You sent the email. Great. Did it bounce? Did the user open it? Did it land in spam? Set up delivery webhooks with your ESP.
import { headers } from 'next/headers';
import { logEmailEvent } from '@/lib/email-tracer';
export async function POST(req: Request) {
// Verify webhook signature (Resend example)
const signature = headers().get('svix-signature');
// ... verify signature ...
const event = await req.json();
switch (event.type) {
case 'email.delivered':
logEmailEvent(event.data.email_id, 'delivered', {
recipient: event.data.to,
});
break;
case 'email.bounced':
logEmailEvent(event.data.email_id, 'bounced', {
recipient: event.data.to,
reason: event.data.bounce?.message,
});
break;
case 'email.complained':
logEmailEvent(event.data.email_id, 'spam_complaint', {
recipient: event.data.to,
});
// Maybe pause sending to this user
break;
}
return Response.json({ received: true });
}Now when a user says "I never got the email," you can check: did it bounce? Did it deliver but they didn't open it? Is their inbox full?
5) Debug mode: Test emails without spamming real users
In development and staging, you want to test email flows without actually sending. Use a debug mode that logs instead of sending.
import { render } from '@react-email/render';
import { resend } from './resend';
const DEBUG_MODE = process.env.EMAIL_DEBUG_MODE === 'true';
export async function sendEmail<TProps>(
template: React.ComponentType<TProps>,
props: TProps,
options: {
from: string;
to: string;
subject: string;
}
) {
const html = await render(template(props));
if (DEBUG_MODE) {
console.log('📧 [DEBUG] Email would be sent:', {
from: options.from,
to: options.to,
subject: options.subject,
htmlPreview: html.substring(0, 200),
});
// Optionally write to disk for local preview
if (process.env.NODE_ENV === 'development') {
const fs = await import('fs/promises');
await fs.writeFile(
`./debug-emails/${Date.now()}-${options.subject.replace(/\s+/g, '-')}.html`,
html
);
}
return { debugMode: true, id: 'debug-' + Date.now() };
}
return resend.emails.send({
...options,
html,
});
}Set EMAIL_DEBUG_MODE=true in your .env.local, and all emails get logged instead of sent. Perfect for testing flows without hitting real inboxes.
Bringing it all together: A production-ready flow
Here's what a fully instrumented email send looks like:
import { render } from '@react-email/render';
import { createEmailTrace, logEmailEvent } from './email-tracer';
import { validateRenderedEmail } from './email-validator';
import { saveEmailSnapshot } from './email-snapshots';
import { sendEmail } from './email-sender';
export async function sendTrackedEmail<TProps>(
template: React.ComponentType<TProps>,
props: TProps,
options: {
from: string;
to: string;
subject: string;
}
) {
const { traceId } = createEmailTrace();
try {
// 1. Render template
logEmailEvent(traceId, 'render_started', { template: template.name });
const html = await render(template(props));
logEmailEvent(traceId, 'render_completed');
// 2. Validate rendered output
const validation = validateRenderedEmail(html);
if (!validation.valid) {
logEmailEvent(traceId, 'validation_failed', { issues: validation.issues });
throw new Error(`Email validation failed: ${validation.issues.join(', ')}`);
}
logEmailEvent(traceId, 'validation_passed');
// 3. Save snapshot (async, don't block send)
saveEmailSnapshot(traceId, html).catch((err) =>
console.error('Failed to save snapshot:', err)
);
// 4. Send email
logEmailEvent(traceId, 'send_started', { to: options.to });
const result = await sendEmail(template, props, {
...options,
headers: { 'X-Email-Trace-Id': traceId },
});
logEmailEvent(traceId, 'send_completed', { messageId: result.id });
return { traceId, messageId: result.id };
} catch (error) {
logEmailEvent(traceId, 'send_failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}Now every email send is traceable from API call to delivery webhook. When something breaks, you have logs, snapshots, and trace IDs to debug with.
- Generate trace IDs for every send
- Log every stage (render, validate, send, deliver)
- Validate rendered HTML before sending
- Save snapshots for visual debugging
- Set up delivery webhooks with your ESP
- Use debug mode in dev/staging environments
What to log (and what not to)
Good email logs are specific, structured, and searchable. Bad logs are noisy walls of text.
✅ Do log:
- Trace IDs (always)
- Recipient email (hashed or truncated in production)
- Template name and version
- Event timestamps
- Error messages and stack traces
- Provider message IDs (Resend, SendGrid, etc.)
- Bounce/complaint reasons
❌ Don't log:
- Full email HTML (use snapshots instead)
- Sensitive prop values (passwords, tokens, PII)
- Raw API responses (too verbose)
- Excessive debug info in production
Set up monitoring alerts
Logs are useless if you don't watch them. Set up alerts for abnormal patterns:
- Bounce rate spike: Alert if bounces exceed 5% in a 1-hour window
- Send failures: Alert on any template validation failures
- Render time: Alert if P95 render time exceeds 2 seconds
- Spam complaints: Alert on any spam complaint (rare but serious)
// Example: Alert on high bounce rate
export function checkBounceRate(events: EmailEvent[]) {
const bounces = events.filter(e => e.event === 'bounced').length;
const total = events.length;
const rate = bounces / total;
if (rate > 0.05) {
// Alert via Slack, PagerDuty, etc.
console.error(`🚨 High bounce rate detected: ${(rate * 100).toFixed(1)}%`);
}
}The debugging checklist
When an email issue is reported, work through this checklist:
- 1. Find the trace ID — from logs, support ticket, or email headers
- 2. Check send logs — did the API call succeed?
- 3. Check render validation — did the template render without errors?
- 4. Check delivery webhooks — did it bounce? Deliver?
- 5. Pull the snapshot — what did the user actually receive?
- 6. Check spam folder — sometimes it's just a filter
Start with trace IDs, add the rest later
Don't try to implement everything at once. Start with trace IDs and basic logging. That alone will save hours when the next email incident hits.
Then add render validation. Then snapshots. Then webhooks. Build your email debugging system incrementally, driven by real incidents.