Code Tips12 min read

Email Environment Patterns: How to Handle Dev/Staging/Prod Without Spamming Real Users

Stop accidentally sending test emails to production users. Email gating, subject tagging, local SMTP sinks, test account routing, rate limiting, and audit logging for bulletproof environment handling.

R

React Emails Pro

March 2, 2026

You are about to deploy a fix to the password reset email. You test it locally. Looks good. You push to staging. Then, at 3am, you wake up to 500 Slack messages: "Why did I just get 47 password reset emails?"

Production emails leaked into your test environment. Or worse: test emails went to real customers.

The problem: Without proper environment handling, you either spam real users during development or lose critical test emails in production blackholes. Both are bad.

Here is how to build email workflows that never send the wrong email to the wrong person in the wrong environment.


1) Email Gating: The safety switch

Never let emails reach real recipients unless you are in production. This is the foundational pattern everything else builds on it.

lib/email-gate.ts
type Environment = 'development' | 'staging' | 'production';

interface EmailGateConfig {
  env: Environment;
  allowedDomains?: string[];
  forceTo?: string;
  dryRun?: boolean;
}

export function shouldSendEmail(
  recipient: string,
  config: EmailGateConfig
): { allowed: boolean; reason?: string; redirectTo?: string } {
  const { env, allowedDomains = [], forceTo, dryRun } = config;

  // Always block in dry-run mode
  if (dryRun) {
    return { allowed: false, reason: 'Dry run mode enabled' };
  }

  // Production: send everything (unless explicitly blocked)
  if (env === 'production') {
    return { allowed: true };
  }

  // Non-production: only allow test domains
  const recipientDomain = recipient.split('@')[1];
  if (allowedDomains.includes(recipientDomain)) {
    return { allowed: true };
  }

  // Redirect to safe test address
  if (forceTo) {
    return { 
      allowed: true, 
      redirectTo: forceTo,
      reason: `Redirected from ${recipient} (non-production)`
    };
  }

  return { 
    allowed: false, 
    reason: `Blocked: ${recipient} not in allowed domains for ${env}`
  };
}

Wire it into your send function:

lib/send-email.ts
import { resend } from '@/lib/resend';
import { shouldSendEmail } from '@/lib/email-gate';

const EMAIL_CONFIG = {
  env: process.env.NODE_ENV as Environment,
  allowedDomains: process.env.EMAIL_ALLOWED_DOMAINS?.split(',') || ['example.com', 'test.com'],
  forceTo: process.env.EMAIL_FORCE_TO,
  dryRun: process.env.EMAIL_DRY_RUN === 'true',
};

export async function sendEmail({
  to,
  subject,
  html,
}: {
  to: string;
  subject: string;
  html: string;
}) {
  const gate = shouldSendEmail(to, EMAIL_CONFIG);

  if (!gate.allowed) {
    console.warn('[EMAIL] Blocked:', gate.reason);
    return { success: false, blocked: true, reason: gate.reason };
  }

  const recipient = gate.redirectTo || to;

  if (gate.redirectTo) {
    console.info(`[EMAIL] Redirecting ${to} to ${recipient}`);
  }

  const result = await resend.emails.send({
    from: 'noreply@example.com',
    to: recipient,
    subject: gate.redirectTo ? `[DEV] ${subject}` : subject,
    html,
  });

  return { success: true, recipient, original: to };
}
Environment variables you need:
  • EMAIL_ALLOWED_DOMAINS Comma-separated safe domains
  • EMAIL_FORCE_TO Redirect all emails to this address
  • EMAIL_DRY_RUN Set to true to block all sends

2) Subject Line Tagging: Know what environment sent it

When you are testing in staging and get an email, you need to know instantly: is this from staging or production?

lib/email-subject.ts
export function formatSubject(subject: string): string {
  const env = process.env.NODE_ENV;
  
  if (env === 'production') {
    return subject;
  }

  const prefix = env === 'development' ? '[DEV]' : '[STAGING]';
  return `${prefix} ${subject}`;
}

// Usage
await sendEmail({
  to: 'user@example.com',
  subject: formatSubject('Password reset requested'),
  html,
});

Simple. Effective. Saves you from panicking when you see a password reset email at 11pm.


3) Email Sinks: Catch everything in dev

In development, you do not want emails going anywhere external. Use an email sink a local SMTP server that captures everything.

package.json
{
  "scripts": {
    "dev": "next dev",
    "email:dev": "maildev --smtp 1025 --web 1080",
    "dev:full": "concurrently \"npm run dev\" \"npm run email:dev\""
  }
}

Install maildev:

terminal
npm install -D maildev concurrently

Configure Resend (or your provider) to use the local SMTP in dev:

lib/resend.ts
import { Resend } from 'resend';
import nodemailer from 'nodemailer';

const isDev = process.env.NODE_ENV === 'development';

export const resend = isDev
  ? nodemailer.createTransport({
      host: 'localhost',
      port: 1025,
      ignoreTLS: true,
    })
  : new Resend(process.env.RESEND_API_KEY);

Now run npm run dev:full and all dev emails show up at localhost:1080. No external sends. No risk.

Alternatives to MailDev:
  • MailHog (Go-based, Docker-friendly)
  • Mailpit (MailHog successor, faster)
  • Ethereal Email (cloud-based, free, no install)

4) Test Account Routing: Realistic testing without real users

You need to test emails with realistic data actual user names, edge cases, long addresses without spamming real people.

Solution: maintain a list of test accounts and route them differently.

lib/test-accounts.ts
const TEST_ACCOUNTS = new Set([
  'test@example.com',
  'staging-user@example.com',
  'qa+longemailaddress@verylongdomainfortesting.com',
  'edge-case@example.com',
]);

export function isTestAccount(email: string): boolean {
  return TEST_ACCOUNTS.has(email.toLowerCase());
}

export function getTestAccountRouting(email: string): {
  shouldSend: boolean;
  recipient: string;
  tags?: string[];
} {
  if (!isTestAccount(email)) {
    return { shouldSend: true, recipient: email };
  }

  // In production, send to real test account
  if (process.env.NODE_ENV === 'production') {
    return { 
      shouldSend: true, 
      recipient: email,
      tags: ['test-account'] 
    };
  }

  // In dev/staging, redirect to sink
  return {
    shouldSend: true,
    recipient: process.env.EMAIL_FORCE_TO || email,
    tags: ['test-account', 'redirected'],
  };
}

5) Volume Limiting: Prevent staging email floods

Staging environments can generate massive email volume if you are not careful batch jobs, migrations, load tests. Prevent disaster with rate limiting.

lib/email-rate-limiter.ts
interface RateLimitConfig {
  maxPerMinute: number;
  maxPerHour: number;
}

class EmailRateLimiter {
  private counts = {
    minute: 0,
    hour: 0,
    lastMinuteReset: Date.now(),
    lastHourReset: Date.now(),
  };

  constructor(private config: RateLimitConfig) {}

  canSend(): { allowed: boolean; reason?: string } {
    const now = Date.now();

    // Reset minute counter
    if (now - this.counts.lastMinuteReset > 60_000) {
      this.counts.minute = 0;
      this.counts.lastMinuteReset = now;
    }

    // Reset hour counter
    if (now - this.counts.lastHourReset > 3_600_000) {
      this.counts.hour = 0;
      this.counts.lastHourReset = now;
    }

    // Check limits
    if (this.counts.minute >= this.config.maxPerMinute) {
      return { 
        allowed: false, 
        reason: `Rate limit: ${this.config.maxPerMinute}/min exceeded`
      };
    }

    if (this.counts.hour >= this.config.maxPerHour) {
      return { 
        allowed: false, 
        reason: `Rate limit: ${this.config.maxPerHour}/hour exceeded`
      };
    }

    this.counts.minute++;
    this.counts.hour++;

    return { allowed: true };
  }
}

// Environment-specific limits
const LIMITS: Record<string, RateLimitConfig> = {
  development: { maxPerMinute: 10, maxPerHour: 100 },
  staging: { maxPerMinute: 50, maxPerHour: 500 },
  production: { maxPerMinute: 1000, maxPerHour: 50000 },
};

export const rateLimiter = new EmailRateLimiter(
  LIMITS[process.env.NODE_ENV || 'development']
);
Why this matters: A single staging migration script can send thousands of emails if you are not careful. Rate limiting is your safety net.

6) Audit Logging: Know what went where

When something goes wrong and it will you need a paper trail. Log every email decision: who it was for, where it went, why it was blocked or redirected.

lib/email-audit.ts
interface AuditLog {
  timestamp: string;
  env: string;
  originalRecipient: string;
  finalRecipient: string;
  subject: string;
  action: 'sent' | 'blocked' | 'redirected';
  reason?: string;
  metadata?: Record<string, unknown>;
}

export function logEmailAction(log: Omit<AuditLog, 'timestamp' | 'env'>) {
  const entry: AuditLog = {
    ...log,
    timestamp: new Date().toISOString(),
    env: process.env.NODE_ENV || 'unknown',
  };

  // In production, send to your logging service
  if (process.env.NODE_ENV === 'production') {
    console.log(JSON.stringify(entry));
  } else {
    // In dev/staging, human-readable logs
    console.info(
      `[EMAIL AUDIT] ${entry.action.toUpperCase()}`,
      {
        to: entry.finalRecipient,
        original: entry.originalRecipient !== entry.finalRecipient 
          ? entry.originalRecipient 
          : undefined,
        subject: entry.subject,
        reason: entry.reason,
      }
    );
  }

  return entry;
}

Pre-Deployment Checklist

Before you deploy any email changes, verify:

  • Email gating is active in dev/staging
  • Subject line tagging is working (check an actual email)
  • Email sink is running locally (visit localhost:1080)
  • Test accounts are defined and routing correctly
  • Rate limits are environment-appropriate
  • Audit logging is enabled and visible
  • Production config does not have EMAIL_FORCE_TO or EMAIL_DRY_RUN set
Automate the checks: Add a pre-deploy script that validates your email config and fails the build if something is wrong.

Real-World Example: Full Integration

Here is what a production-ready send function looks like with all patterns combined:

lib/send-email.ts
import { resend } from '@/lib/resend';
import { shouldSendEmail } from '@/lib/email-gate';
import { formatSubject } from '@/lib/email-subject';
import { getTestAccountRouting } from '@/lib/test-accounts';
import { rateLimiter } from '@/lib/email-rate-limiter';
import { logEmailAction } from '@/lib/email-audit';

const EMAIL_CONFIG = {
  env: process.env.NODE_ENV as Environment,
  allowedDomains: process.env.EMAIL_ALLOWED_DOMAINS?.split(',') || [],
  forceTo: process.env.EMAIL_FORCE_TO,
  dryRun: process.env.EMAIL_DRY_RUN === 'true',
};

export async function sendEmail({
  to,
  subject,
  html,
}: {
  to: string;
  subject: string;
  html: string;
}) {
  // 1. Rate limiting
  const rateCheck = rateLimiter.canSend();
  if (!rateCheck.allowed) {
    logEmailAction({
      originalRecipient: to,
      finalRecipient: to,
      subject,
      action: 'blocked',
      reason: rateCheck.reason,
    });
    throw new Error(`Rate limit exceeded: ${rateCheck.reason}`);
  }

  // 2. Test account routing
  const routing = getTestAccountRouting(to);

  // 3. Environment gating
  const gate = shouldSendEmail(routing.recipient, EMAIL_CONFIG);
  if (!gate.allowed) {
    logEmailAction({
      originalRecipient: to,
      finalRecipient: routing.recipient,
      subject,
      action: 'blocked',
      reason: gate.reason,
    });
    return { success: false, blocked: true, reason: gate.reason };
  }

  const finalRecipient = gate.redirectTo || routing.recipient;
  const finalSubject = formatSubject(subject);

  // 4. Send
  const result = await resend.emails.send({
    from: 'noreply@example.com',
    to: finalRecipient,
    subject: finalSubject,
    html,
    tags: routing.tags,
  });

  // 5. Audit
  logEmailAction({
    originalRecipient: to,
    finalRecipient,
    subject: finalSubject,
    action: finalRecipient !== to ? 'redirected' : 'sent',
    reason: finalRecipient !== to ? 'Environment routing' : undefined,
    metadata: { tags: routing.tags, emailId: result.id },
  });

  return { success: true, recipient: finalRecipient, emailId: result.id };
}

What Not to Do

  • Do not rely on manual checks Remember not to test in production does not scale
  • Do not skip audit logs When something breaks at 2am, you will want that paper trail
  • Do not use the same API keys everywhere Dev, staging, and prod should have separate provider accounts
  • Do not forget to test the redirects Your email gating is useless if you never verify it actually works
  • Do not hardcode environment logic Use environment variables so you can change behavior without redeploying

Next Steps

Start with pattern 1 (email gating) and pattern 2 (subject tagging). Those two alone will prevent 90% of environment-related email disasters.

Then add the email sink for local development, and gradually layer in test account routing, rate limiting, and audit logging as your email volume and complexity grow.

Want production-ready templates that already follow these patterns? Check out the SaaS template library.

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