Email Testing Best Practices for Development Teams
Email is one of the most critical communication channels in any application. Password resets, order confirmations, team invitations, billing alerts -- if any of these fail silently or arrive malformed, users lose trust fast. Yet email testing remains one of the most neglected areas of software quality assurance.
This guide covers eight concrete practices that development teams should adopt to catch email bugs before they reach production.
Why Email Testing Is Often an Afterthought
Most development teams treat email as a fire-and-forget operation. You call a mail function, pass in a recipient and a template, and assume it works. The reasons for this neglect are understandable:
- Email delivery is asynchronous and hard to observe in real time.
- Setting up proper test infrastructure feels like overhead.
- "It worked when I tested it manually" is considered good enough.
- Email bugs are often reported by end users, not caught by automated tests.
The consequences, however, are anything but minor. A broken password reset flow locks users out. A misconfigured Reply-To header sends customer responses into the void. An HTML email that renders perfectly in Chrome's dev tools looks like a wall of broken markup in Outlook. These are production incidents, not cosmetic issues.
The fix is straightforward: treat email as a first-class feature with its own testing discipline.
Best Practice 1: Never Use Production SMTP in Non-Production Environments
This is the most important rule, and the one most frequently violated. Using production SMTP credentials in development or staging environments creates two serious risks:
Accidental delivery to real users. A developer testing a bulk notification feature sends 10,000 emails to actual customers. This happens more often than anyone wants to admit.
Credential exposure. Production SMTP credentials shared across environments end up in .env files on developer laptops, CI logs, and Docker images.
The solution is to use an SMTP sandbox -- a service that accepts emails via standard SMTP but never delivers them to real recipients. Instead, it captures them for inspection.
# .env.development
MAIL_MAILER=smtp
MAIL_HOST=sandbox.sendpit.com
MAIL_PORT=1025
MAIL_USERNAME=mb_your_dev_mailbox
MAIL_PASSWORD=your_dev_password
MAIL_ENCRYPTION=tls
# .env.production (completely separate credentials)
MAIL_MAILER=smtp
MAIL_HOST=smtp.your-esp.com
MAIL_PORT=587
MAIL_USERNAME=production_user
MAIL_PASSWORD=production_password
MAIL_ENCRYPTION=tls
Every environment -- local development, CI, staging, QA -- should have its own SMTP configuration pointing to a sandbox, never to a production mail server.
Best Practice 2: Use Dedicated Mailboxes per Project and Environment
When multiple developers or multiple projects share a single test mailbox, things get noisy. You cannot tell which emails belong to which feature branch, which developer triggered them, or which environment they came from.
Create separate mailboxes for each context:
| Mailbox | Purpose |
|---|---|
project-api-dev |
API service, local development |
project-api-staging |
API service, staging environment |
project-web-dev |
Web app, local development |
project-web-ci |
Web app, CI pipeline |
With a tool like SendPit, you can create multiple mailboxes under one organization, each with its own credentials. This keeps test data isolated and makes it easy to find the emails you care about.
# docker-compose.yml - per-service SMTP config
services:
api:
environment:
SMTP_HOST: sandbox.sendpit.com
SMTP_PORT: 1025
SMTP_USER: mb_api_dev_abc123
SMTP_PASS: ${API_SMTP_PASSWORD}
web:
environment:
SMTP_HOST: sandbox.sendpit.com
SMTP_PORT: 1025
SMTP_USER: mb_web_dev_def456
SMTP_PASS: ${WEB_SMTP_PASSWORD}
Best Practice 3: Test Email Content, Not Just Delivery
The most common email "test" is: send an email, check that no error was thrown. This tells you almost nothing. The email could have an empty body, broken HTML, missing variables, or the wrong subject line.
Write assertions against the actual email content:
// Laravel + Pest example
it('sends a welcome email with the correct content', function () {
Mail::fake();
$user = User::factory()->create(['name' => 'Alice']);
// Trigger the action that sends the email
app(SendWelcomeEmail::class)->execute($user);
Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email)
&& $mail->hasSubject('Welcome to Acme')
&& str_contains($mail->render(), $user->name);
});
});
# Python + pytest example
def test_welcome_email_content(smtp_sandbox, new_user):
send_welcome_email(new_user)
messages = smtp_sandbox.get_messages(to=new_user.email)
assert len(messages) == 1
msg = messages[0]
assert msg.subject == "Welcome to Acme"
assert new_user.name in msg.html_body
assert "Confirm your email" in msg.html_body
assert msg.headers["Reply-To"] == "[email protected]"
Test the rendered output, not just the fact that a mail class was instantiated.
Best Practice 4: Include Email Testing in Your CI Pipeline
If email tests only run on developer machines, they will be skipped. Add them to your CI pipeline with a dedicated SMTP sandbox mailbox.
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
env:
MAIL_HOST: sandbox.sendpit.com
MAIL_PORT: 1025
MAIL_USERNAME: ${{ secrets.CI_SMTP_USER }}
MAIL_PASSWORD: ${{ secrets.CI_SMTP_PASS }}
steps:
- uses: actions/checkout@v4
- name: Run tests
run: php artisan test --filter=Email
For integration-level tests where you want to verify actual SMTP delivery (not just Mail::fake()), configure your test suite to send through the sandbox and then query its API to verify receipt:
it('delivers the invoice email via SMTP', function () {
// Send a real email through the SMTP sandbox
Mail::mailer('smtp')->send(new InvoiceEmail($order));
// Query the sandbox API to verify the email arrived
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . config('services.sendpit.api_key'),
])->get('https://sendpit.com/api/v1/mailboxes/' . config('services.sendpit.mailbox_id') . '/messages');
$messages = $response->json('data');
$latest = collect($messages)->firstWhere('subject', "Invoice #{$order->number}");
expect($latest)->not->toBeNull();
expect($latest['to'][0]['address'])->toBe($order->customer_email);
});
Best Practice 5: Test Edge Cases
Standard emails work fine. It is the edge cases that break in production:
Large attachments. Does your application handle a 25MB PDF attachment without timing out? Does your email provider reject it? Does the SMTP connection stay alive long enough?
Special characters in subjects and bodies. Names like O'Brien, subjects with non-ASCII characters (Bestellung bestatigt), and emoji in subject lines all need proper encoding.
Long subject lines. RFC 5322 recommends a maximum line length of 78 characters. Some mail clients truncate or wrap subjects in unexpected ways.
Multiple recipients. CC and BCC fields behave differently across providers. Test that BCC recipients actually receive the email and that their addresses are not visible to other recipients.
it('handles special characters in the subject', function () {
Mail::fake();
$subject = 'Ihre Bestellung #12345 wurde bestätigt — vielen Dank!';
Mail::to('[email protected]')->send(new OrderConfirmation($subject));
Mail::assertSent(OrderConfirmation::class, function ($mail) use ($subject) {
return $mail->hasSubject($subject);
});
});
it('sends emails with large attachments', function () {
$pdf = UploadedFile::fake()->create('report.pdf', 10240); // 10MB
Mail::fake();
Mail::to('[email protected]')->send(new ReportEmail($pdf));
Mail::assertSent(ReportEmail::class, function ($mail) {
return count($mail->attachments) === 1;
});
});
Best Practice 6: Verify Email Headers
The visible content of an email is only half the story. Headers control delivery, threading, authentication, and unsubscribe behavior. Verify these programmatically:
- From: Must match your sending domain. Mismatches cause SPF failures.
- Reply-To: Should point where you actually want replies to go.
- List-Unsubscribe: Required by Gmail and Yahoo for bulk senders as of early 2024.
- Message-ID: Must be unique per message. Duplicates cause threading issues.
- Content-Type: Multipart emails need correct boundary definitions.
it('includes required headers', function () {
$mailable = new WeeklyDigest($user);
$mailable->assertHasReplyTo('[email protected]');
// Render and inspect raw headers
$message = $mailable->build();
$headers = $message->getHeaders();
expect($headers->get('List-Unsubscribe'))->not->toBeNull();
expect($headers->get('List-Unsubscribe')->getValue())
->toContain('https://acme.com/unsubscribe');
});
Best Practice 7: Test Across Email Clients
HTML email rendering is notoriously inconsistent. Outlook uses the Word rendering engine. Gmail strips <style> tags from the <head>. Apple Mail handles media queries differently than most.
While full cross-client testing requires specialized tools like Litmus or Email on Acid, you can catch the most common issues by:
- Using an SMTP sandbox to capture the raw HTML and inspecting it in multiple browsers.
- Sticking to table-based layouts for maximum compatibility.
- Inlining CSS rather than relying on
<style>blocks. - Testing plain-text alternatives -- some users and corporate environments prefer or require them.
it('includes a plain text alternative', function () {
$mailable = new WelcomeEmail($user);
$rendered = $mailable->render();
// Verify both HTML and text parts exist
expect($mailable->hasHtml())->toBeTrue();
expect($mailable->hasText())->toBeTrue();
});
Best Practice 8: Document Your Email Testing Workflow
Every team should have a written answer to these questions:
- Where do test emails go? (Which sandbox, which mailboxes?)
- How do I view captured test emails? (URL, credentials, access)
- Which emails have automated test coverage?
- What is the process for testing a new email template?
- Who is responsible for cross-client testing?
- How are email templates reviewed before deployment?
A simple document in your project repository removes ambiguity and eliminates the "I assumed someone else tested it" failure mode.
Team Workflows: Who Tests What and When
For teams of more than a couple of developers, define clear responsibilities:
During development: The developer writing the email feature tests delivery, content, and headers using their local SMTP sandbox mailbox.
During code review: The reviewer checks that email tests exist, cover edge cases, and verify headers. They can also pull the branch and trigger the email to inspect it in the shared staging mailbox.
In CI: Automated tests verify content, headers, and delivery for every pull request.
Before release: A QA pass checks rendering in target email clients using the staging sandbox.
Post-release: Monitor bounce rates, delivery metrics, and spam complaint rates from your production email provider.
Tools That Support These Practices
SendPit -- Cloud-hosted SMTP sandbox with shared mailboxes, team access, encrypted credentials, and a free tier. Good fit for teams that need collaborative email inspection without self-hosting.
Mailpit -- Open-source, self-hosted SMTP testing tool. Works well for local development and Docker-based workflows.
Litmus / Email on Acid -- Cross-client rendering testing platforms. Essential for teams sending marketing or transactional emails with complex HTML.
Laravel Mail::fake() -- Built-in mail faking for unit-level tests. Does not test actual SMTP delivery but is fast and reliable for content assertions.
The right combination depends on your team size, deployment model, and how many email templates you maintain. At minimum, every team should have an SMTP sandbox for non-production environments and automated content tests in CI.
Conclusion
Email testing does not require exotic tools or complex infrastructure. It requires discipline: isolate your environments, test content and headers, automate in CI, and handle edge cases. The eight practices outlined here will catch the vast majority of email bugs before they reach your users.
Start with the highest-impact change: replace your production SMTP credentials in non-production environments with a sandbox. Everything else builds from there.
Nikhil Rao
Creator of SendPit. Building developer tools for email testing and SMTP infrastructure.
About SendPit →