SMTP Testing in CI/CD Pipelines: A Complete Guide
Your email tests pass locally. You merge to main. The deployment goes out and a customer reports they never received their password reset email. The template renders with broken variables, the subject line has a typo from three PRs ago, and the unsubscribe link points to localhost.
This happens because most CI/CD pipelines skip email testing entirely. Unit tests mock the mail layer, which validates logic but not output. Integration tests need an SMTP server to capture and inspect actual rendered emails. Most teams never set one up in CI.
This guide shows you how to add SMTP capture to your CI/CD pipeline using Docker-based tools for ephemeral testing and cloud SMTP sandboxes for persistent inspection.
Why CI/CD Email Testing Matters
Mocking Mail::fake() or its equivalent in other frameworks tells you that your code called the right method with the right arguments. It does not tell you:
- Whether the HTML template renders correctly with the data passed to it
- Whether images and links resolve to valid URLs
- Whether the email passes basic spam checks
- Whether the SMTP handshake succeeds with your production-like configuration
- Whether queued emails actually get processed and sent
Integration-level email testing in CI catches an entire class of bugs that unit tests cannot. You send a real email through a real SMTP server, capture it, and assert against the captured result.
Option 1: Mailpit in Docker-Based CI
Mailpit is a lightweight SMTP server that captures all incoming mail and exposes it via a REST API. It runs in a single container with no dependencies, making it well-suited for CI environments.
GitHub Actions
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
mailpit:
image: axllent/mailpit:latest
ports:
- 1025:1025
- 8025:8025
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo_mysql
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Configure environment
run: |
cp .env.ci .env
php artisan key:generate
- name: Run migrations
run: php artisan migrate --force
- name: Run tests
run: php artisan test
Your .env.ci file should point mail at the Mailpit service:
MAIL_MAILER=smtp
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
GitLab CI
test:
image: php:8.3-cli
services:
- name: axllent/mailpit:latest
alias: mailpit
variables:
MAIL_HOST: mailpit
MAIL_PORT: 1025
MAIL_ENCRYPTION: ""
before_script:
- apt-get update && apt-get install -y libzip-dev
- docker-php-ext-install zip pdo_mysql
- composer install --no-interaction
- cp .env.ci .env
- php artisan key:generate
script:
- php artisan test
Note the difference: in GitLab CI, services are accessed by their alias name (mailpit), not localhost. GitHub Actions maps service ports to localhost on the runner.
CircleCI
version: 2.1
jobs:
test:
docker:
- image: cimg/php:8.3
- image: axllent/mailpit:latest
name: mailpit
steps:
- checkout
- run: composer install --no-interaction
- run: cp .env.ci .env && php artisan key:generate
- run: php artisan test
In CircleCI, secondary containers are accessible via localhost since all containers share a network namespace. Set MAIL_HOST=localhost in your CI environment config.
Asserting Against Captured Emails
Capturing emails is only half the job. You need to verify their content programmatically. Mailpit exposes a REST API on port 8025 that you can query in your test suite.
Querying Mailpit's API in PHP Tests
it('sends a properly formatted invoice email', function () {
// Trigger the action that sends email
$this->actisan('invoices:send-monthly');
// Give the queue worker a moment if emails are queued
sleep(1);
// Query Mailpit's API
$response = Http::get('http://localhost:8025/api/v1/messages');
$messages = $response->json()['messages'];
expect($messages)->toHaveCount(1);
$message = $messages[0];
expect($message['Subject'])->toBe('Your January Invoice');
expect($message['To'][0]['Address'])->toBe('[email protected]');
// Get the full message to inspect HTML body
$fullMessage = Http::get("http://localhost:8025/api/v1/message/{$message['ID']}")->json();
expect($fullMessage['HTML'])->toContain('$49.99');
expect($fullMessage['HTML'])->toContain('/invoices/download');
expect($fullMessage['HTML'])->not->toContain('{{'); // No unrendered template variables
});
Clearing the Mailbox Between Tests
Mailpit accumulates messages across tests. Clear the inbox before each test that asserts on email content:
beforeEach(function () {
Http::delete('http://localhost:8025/api/v1/messages');
});
This prevents flaky tests caused by leftover messages from previous test cases.
Option 2: Cloud SMTP Sandbox for Persistent Inspection
Mailpit in CI has one fundamental limitation: when the CI job ends, the container is destroyed along with every captured email. If a test fails and you need to inspect the actual rendered email, it is gone.
A cloud-hosted SMTP sandbox like SendPit solves this by persisting captured emails outside of your CI environment. Emails are available for inspection in a web UI long after the CI run finishes.
Configuration
# .env.ci
MAIL_MAILER=smtp
MAIL_HOST=smtp.sendpit.com
MAIL_PORT=2525
MAIL_USERNAME=mb_ci_pipeline_mailbox
MAIL_PASSWORD=your_ci_mailbox_password
MAIL_ENCRYPTION=tls
Each CI run sends its emails to the same shared mailbox. Your team can log in to the web UI and inspect rendered emails, check headers, and verify formatting without needing to reproduce the CI environment locally.
Per-Branch Mailboxes
For larger teams, consider creating a mailbox per branch or per PR to avoid cross-contamination:
# GitHub Actions
- name: Configure mail for this PR
run: |
echo "MAIL_USERNAME=mb_pr_${{ github.event.pull_request.number }}" >> .env.ci
echo "MAIL_PASSWORD=${{ secrets.SENDPIT_CI_PASSWORD }}" >> .env.ci
This keeps emails from concurrent CI runs separated and makes it easy to find the emails associated with a specific pull request.
Combining Both Approaches
The most robust setup uses both. Mailpit runs in the CI container for fast programmatic assertions during the test run. A cloud sandbox captures the same emails for post-run inspection.
You can do this by configuring Laravel to send through both transports using a custom mailer or by running tests in two passes. The simpler approach is to use the cloud sandbox as your primary SMTP target and query its API for assertions.
Webhook-Based Assertions
Some SMTP sandboxes support webhooks that fire when an email is received. This enables event-driven testing patterns:
# GitHub Actions step that waits for email delivery confirmation
- name: Wait for email webhook
run: |
timeout 30 bash -c '
while [ ! -f /tmp/email_received ]; do
sleep 1
done
'
This is more resilient than polling an API endpoint with sleep() calls, though it requires more infrastructure setup.
Common Pitfalls
1. Ephemeral Containers Losing Emails
The most common mistake. Your CI spins up Mailpit, runs tests, the job passes, and then someone wants to see the actual email that was sent. Too late.
Fix: Use a persistent SMTP sandbox for any environment where you need post-run inspection. Keep Mailpit for fast, in-pipeline assertions only.
2. Port Conflicts
Mailpit defaults to port 1025 for SMTP and 8025 for the web UI. If you are running multiple services or parallel CI jobs on the same runner, ports will collide.
Fix: Explicitly map to different ports per job:
services:
mailpit:
image: axllent/mailpit:latest
ports:
- "0:1025" # Random available port for SMTP
- "0:8025" # Random available port for API
Then read the assigned port from the job context. In GitHub Actions, this requires the docker socket and is more complex. The simpler approach is to ensure jobs do not run in parallel on the same runner, or use isolated containers.
3. DNS Resolution Differences
In GitHub Actions, services are on localhost. In GitLab CI, services use their alias. In Docker Compose, services use their service name. Your .env.ci must match your CI provider's networking model.
Fix: Use environment variables in your CI config rather than hardcoding hostnames:
env:
MAIL_HOST: ${{ job.services.mailpit.id && 'localhost' || 'mailpit' }}
Or more practically, just maintain separate .env files per CI provider.
4. Queued Emails Not Processed
Your test sends an email, but it goes onto a queue. The test immediately checks Mailpit and finds nothing.
Fix: Either run the queue synchronously in CI or process it explicitly:
# .env.ci - Process all jobs synchronously
QUEUE_CONNECTION=sync
If you need to test the actual queue behavior, process it manually in your test:
it('sends the email after the queue processes it', function () {
dispatch(new SendInvoiceJob($invoice));
Artisan::call('queue:work', [
'--once' => true,
'--queue' => 'emails',
]);
$response = Http::get('http://localhost:8025/api/v1/messages');
expect($response->json()['messages'])->toHaveCount(1);
});
5. TLS/SSL Handshake Failures
CI environments often lack the CA certificates needed for TLS connections. If your SMTP sandbox requires encryption, the connection will fail silently or throw a cryptic OpenSSL error.
Fix: Either disable encryption for CI-only sandboxes:
MAIL_ENCRYPTION=null
MAIL_PORT=1025
Or install CA certificates in your CI image:
- name: Install CA certificates
run: apt-get update && apt-get install -y ca-certificates
6. Not Testing Email in CI at All
The most common pitfall. Teams rely entirely on Mail::fake() and never send a real email through SMTP in their CI pipeline. Template rendering bugs, broken links, and missing variables all slip through.
Fix: Add at least one integration test that sends a real email through SMTP, captures it, and asserts on the rendered HTML content. Start with your most critical email (password reset, account verification) and expand from there.
A Minimal Starter Configuration
If you want to add SMTP testing to an existing GitHub Actions pipeline with minimal effort, here is the smallest viable change:
# Add to your existing jobs.<job>.services section:
services:
mailpit:
image: axllent/mailpit:latest
ports:
- 1025:1025
- 8025:8025
# Add to .env.ci:
MAIL_MAILER=smtp
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_ENCRYPTION=null
QUEUE_CONNECTION=sync
// Add one test:
it('sends real emails through SMTP in CI', function () {
Http::delete('http://localhost:8025/api/v1/messages');
Mail::to('[email protected]')->send(new WelcomeEmail($user));
$messages = Http::get('http://localhost:8025/api/v1/messages')->json()['messages'];
expect($messages)->toHaveCount(1);
expect($messages[0]['Subject'])->not->toBeEmpty();
});
That is three additions to your existing setup. From there, you can expand coverage to every critical email path in your application.
Summary
Unit-level mocks like Mail::fake() validate logic. SMTP capture in CI validates output. You need both.
Use Mailpit or a similar lightweight SMTP server in your CI containers for fast, programmatic assertions during test runs. Use a cloud SMTP sandbox like SendPit when you need persistent email inspection that survives after the CI job terminates.
The cost of setting this up is a few lines of CI config and one or two integration tests. The cost of not setting it up is the next time a broken email template reaches production.
Nikhil Rao
Creator of SendPit. Building developer tools for email testing and SMTP infrastructure.
About SendPit →