Code Tips13 min read

Email Debugging Patterns: Trace IDs, Logs, and Snapshots for Production React Email

Stop debugging emails in the dark. Use trace IDs, render validation, snapshots, delivery webhooks, and debug mode to turn email incidents into traceable, fixable issues.

R

React Emails Pro

March 2, 2026

"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.

The problem: Email debugging in production is guesswork without structured logging. You're flying blind between "API called" and "user complains it didn't arrive."

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.

lib/email-tracer.ts
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:

app/api/send-reset/route.ts
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;
  }
}
Pro move: Include the trace ID in your email's HTML footer (hidden text or HTML comment). When users forward a "broken" email to support, you can search your logs instantly.

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.

lib/email-validator.ts
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:

app/api/send-email/route.ts
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...
This catches the "I sent 5,000 emails with a broken button" scenario before it happens.

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.

lib/email-snapshots.ts
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.

Cost optimization: Use S3 lifecycle rules to auto-delete snapshots older than 30 days. Email debugging is time-sensitive — if it's not reported within a month, you probably don't need the snapshot.

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.

app/api/webhooks/email/route.ts
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?

Gotcha: Webhooks can arrive out of order or be duplicated. Always check idempotency and handle events multiple times gracefully.

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.

lib/email-sender.ts
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:

lib/send-email.ts
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.

Debugging checklist:
  • 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
Privacy note: In production, hash or truncate email addresses in logs. Store full addresses only in your database, not in centralized logging (Datadog, CloudWatch, etc.).

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)
lib/monitoring.ts
// 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
Support script: Give your support team a lookup tool that takes a trace ID and shows the full email journey. Turns "I'll escalate to engineering" into "I can see it bounced, try this alternate email."

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.

Real talk: You'll be tempted to skip this. "We'll add logging later." Then an email breaks in production and you're grep-ing through unstructured logs at 11pm. Do future-you a favor: add trace IDs today.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates