Back to Blog

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.

N

Nikhil Rao

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

About SendPit →

More from the blog