Back to Blog

Common Email Bugs and How to Catch Them Before Production

Email bugs are among the most common issues that reach production undetected. Unlike a broken UI element or a failed API call, a broken email does not throw an exception. It sends successfully, looks fine in the developer's test, and then fails silently for a specific subset of users. By the time someone reports it, the damage is done.

This guide covers the 12 most common email bugs that ship to production, why they happen, and specific techniques to catch each one before deployment.

1. Broken or Missing Dynamic Content

The most frequent email bug is dynamic data that renders incorrectly or not at all. A variable that is null, a date formatted for the wrong locale, or a currency amount missing its decimal point.

Why it happens: Templates are built with sample data. Edge cases — empty names, zero-dollar orders, very long strings — are never tested.

How to catch it:

it('handles a user with no first name', function () {
    $user = User::factory()->create(['name' => null]);
    $html = (new WelcomeEmail($user))->render();

    expect($html)->not->toContain('null');
    expect($html)->not->toContain('Hello ,');
});

Test with empty values, very long strings, special characters, and zero amounts. If your email template has conditional sections, test every branch.

2. Wrong Recipient or Missing CC/BCC

Sending an email to the wrong address — or sending it to the correct address but missing a required CC — is a logic bug that looks correct in the SMTP response.

Why it happens: Recipient logic changes during development. A refactor moves the recipient from a hardcoded value to a database lookup, and an edge case is missed.

How to catch it:

it('sends the invoice to the billing email, not the user email', function () {
    Mail::fake();

    $user = User::factory()->create([
        'email' => '[email protected]',
        'billing_email' => '[email protected]',
    ]);

    app(InvoiceService::class)->send($user);

    Mail::assertSent(InvoiceMail::class, function ($mail) {
        return $mail->hasTo('[email protected]')
            && ! $mail->hasTo('[email protected]');
    });
});

3. Broken Links

A link in an email that points to localhost, a staging URL, or a route with a missing parameter. The email looks fine but the links do not work.

Why it happens: Email templates use url() or route() helpers that resolve against the current APP_URL. In development, this is localhost. In staging, it may be a different domain than production.

How to catch it:

it('contains only production URLs', function () {
    config(['app.url' => 'https://app.example.com']);

    $html = (new PasswordReset($user, $token))->render();

    expect($html)->not->toContain('localhost');
    expect($html)->not->toContain('staging.');
    expect($html)->toContain('https://app.example.com');
});

Use an SMTP sandbox like SendPit to visually inspect rendered emails and click every link before deploying.

4. Missing or Broken Images

Images referenced with relative paths, local file paths, or expired S3 signed URLs.

Why it happens: During development, images load from local storage. In production, they need to be served from a public URL. The switch is environment-dependent and easy to miss.

How to catch it: Inspect the raw HTML source in your SMTP sandbox. Every <img> tag should have an absolute URL starting with https://. Relative paths like /images/logo.png will not resolve in email clients.

5. Character Encoding Failures

Garbled text, question marks replacing accented characters, or emoji rendering as ?. This happens when the email content or headers use the wrong character encoding.

Why it happens: The email is generated in UTF-8 but the Content-Type header does not specify the charset, or the SMTP library re-encodes the body incorrectly.

How to catch it:

it('correctly encodes special characters', function () {
    $user = User::factory()->create(['name' => 'José García']);
    $html = (new WelcomeEmail($user))->render();

    expect($html)->toContain('José García');
    expect($html)->not->toContain('Jos');
});

Also test with emoji, CJK characters, and right-to-left text if your application supports them.

6. Incorrect Reply-To Address

Users reply to an email and it goes to [email protected] or a development address instead of the support team.

Why it happens: The Reply-To header is set in a configuration file or hardcoded, and different environments use different values.

How to catch it:

it('sets the reply-to address to the support team', function () {
    $mailable = new OrderConfirmation($order);

    expect($mailable->envelope()->replyTo)
        ->toHaveCount(1)
        ->and($mailable->envelope()->replyTo[0]->address)
        ->toBe('[email protected]');
});

7. HTML That Renders Differently Across Clients

An email that looks perfect in Gmail but is broken in Outlook. Missing styles, collapsed layouts, invisible text on dark backgrounds.

Why it happens: Email clients have wildly inconsistent HTML and CSS support. Outlook uses Word's rendering engine. Gmail strips <style> tags. Apple Mail supports modern CSS but others do not.

How to catch it: There is no automated fix for this — you need visual inspection. Send test emails to an SMTP sandbox and review the rendered HTML. Key things to check:

  • Table-based layouts (still necessary for Outlook)
  • Inline CSS (required for Gmail)
  • Dark mode compatibility (inverted colors, hidden text)
  • Image alt text (images are blocked by default in many clients)
  • Font fallbacks (custom fonts are not supported everywhere)

8. Missing Plain Text Version

Some email clients and accessibility tools require or prefer the plain text version. If your email only contains HTML, certain recipients see a blank message or a garbled HTML dump.

Why it happens: Developers build the HTML version and forget that email should be sent as multipart MIME with both text/html and text/plain parts.

How to catch it:

it('includes a plain text version', function () {
    $mailable = new OrderConfirmation($order);
    $data = $mailable->render();

    // Check the raw MIME includes a text/plain part
    expect($mailable->assertHasAlternativeContent());
});

Most modern mail libraries generate a plain text version automatically from the HTML. Verify this by inspecting the raw source in your SMTP sandbox.

9. Emails Silently Not Sending

The application code runs without errors, but no email is sent. No exception, no log entry, nothing.

Why it happens: The most common cause is a misconfigured mail driver. In Laravel, setting MAIL_MAILER=log in production, or MAIL_MAILER=array left over from tests, will silently swallow all emails.

How to catch it: Add a smoke test that sends through the actual SMTP pipeline:

it('can send email through the configured SMTP server', function () {
    config(['mail.default' => 'smtp']);

    // This will throw an exception if SMTP is misconfigured
    Mail::to('[email protected]')
        ->send(new \Illuminate\Mail\Mailable());
});

Run this test in your CI pipeline against an SMTP sandbox.

10. Queue Jobs That Never Process

Emails are dispatched to the queue but the queue worker is not running, the Redis connection is down, or the job fails and exhausts its retries silently.

Why it happens: Queue infrastructure is often the last thing configured in a new environment. The application appears to work because the email dispatch succeeds, but the job is never processed.

How to catch it: Monitor your queue's failed jobs table. In Laravel:

php artisan queue:failed

Set up alerts for failed jobs and monitor queue depth. If your queue depth grows over time, jobs are being added faster than they are processed.

11. Incorrect Timestamp or Timezone

An email says "Your order was placed on March 14, 2026 at 3:00 AM" when the user placed it at 3:00 PM in their local timezone.

Why it happens: Dates are stored in UTC and displayed without timezone conversion, or the server timezone differs from the user's expected timezone.

How to catch it:

it('displays the order date in the user timezone', function () {
    $order = Order::factory()->create([
        'created_at' => '2026-03-14 15:00:00', // UTC
    ]);
    $user = $order->user;
    $user->timezone = 'America/New_York';

    $html = (new OrderConfirmation($order))->render();

    expect($html)->toContain('11:00 AM'); // EST = UTC-4
});

12. Attachment Failures

Attachments that are empty, corrupted, have the wrong MIME type, or exceed the server's size limit.

Why it happens: The attachment path is incorrect, the file was deleted before the queued email job ran, or the file is generated dynamically and the generation failed silently.

How to catch it: Inspect attachments in your SMTP sandbox. SendPit shows all attachments with their size, MIME type, and a download link. Verify that:

  • The file is not empty (0 bytes)
  • The MIME type matches the actual content
  • The filename is correct
  • The file can be opened after downloading

A Practical Testing Checklist

Before every release that touches email templates or delivery logic:

Check Method
Dynamic data renders correctly with edge cases Unit test with factories
Correct recipients (To, CC, BCC, Reply-To) Unit test with assertions
All links are absolute and point to production URLs Unit test + visual review
Images use absolute URLs Raw source inspection in sandbox
Character encoding is correct Unit test with special characters
HTML renders across clients Visual review in SMTP sandbox
Plain text version exists and is readable Raw source inspection
Emails actually send through SMTP Integration test against sandbox
Queue jobs process successfully Monitor failed jobs table
Attachments are present and valid Sandbox inspection
Timestamps use correct timezone Unit test with timezone data
Subject line contains expected dynamic data Unit test

Summary

Email bugs are silent, user-facing, and expensive to debug after deployment. The most effective defense is a combination of unit tests for content and logic, integration tests against an SMTP sandbox for delivery, and visual inspection for rendering.

Use an SMTP sandbox like SendPit to capture every test email during development. Inspect the rendered HTML, verify headers, download attachments, and share results with your team — all without risking emails to real users.

The 12 bugs listed here account for the vast majority of email issues that reach production. Test for each one systematically and they will never surprise you again.

N

Nikhil Rao

Creator of SendPit. Building developer tools for email testing and SMTP infrastructure.

About SendPit →

More from the blog