Skip to content

webteractive/mailulator

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

31 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Mailulator

Self-hosted, Laravel-native email testing. One package ships both sides:

  • Receiver — stores ingested email in an isolated database (default SQLite; any Laravel-supported driver works), serves a Vue 3 inbox UI at /mailulator.
  • Driver — a Symfony Mailer transport registered as mailulator. Set MAIL_MAILER=mailulator and outbound mail flows to the receiver — over HTTP for standalone deployments, in-process for in-app ones.

Requirements

  • PHP ^8.3
  • Laravel ^11 || ^12 || ^13

Install

composer require webteractive/mailulator
php artisan mailulator:install

mailulator:install publishes:

  • app/Providers/MailulatorServiceProvider.php — customize the auth gate here.
  • config/mailulator.php — receiver + driver config.

It then runs migrations against the isolated mailulator connection and creates a protected Default inbox (it cannot be renamed or deleted; at least one inbox must always exist).

The install prints the Default inbox's bearer token. In-app deployments don't need it for delivery, but save it anyway — any sender app you later point at this receiver will need it, and it isn't shown again.

Publish compiled UI assets:

php artisan vendor:publish --tag=mailulator-assets

Re-run this tag after each composer update.

Deployment modes

Two shapes, distinguished by where the inbox UI lives.

1. In-app — UI lives with the app it captures

The app sending the mail is also the app you read it from. Install Mailulator there, set MAIL_MAILER=mailulator, and the captured mail shows up at /mailulator on the same host. One app, one inbox, no plumbing.

# .env
MAIL_MAILER=mailulator

No URL, no token. The transport detects in-app mode (driver enabled + receiver enabled + no MAILULATOR_URL) and writes directly to the Default inbox via StoreIncomingEmail, bypassing HTTP entirely. Open /mailulator on the same app to read the captured mail.

Best for: local development, staging, demo environments — any app that just wants to "swallow" its own outbound mail and read it back in place.

2. Standalone — UI lives in a dedicated app, shared by many senders

A single Laravel app exists just to host inboxes and the UI. Any number of other apps point at it via MAILULATOR_URL and route to the right inbox via MAILULATOR_TOKEN. One UI, many senders.

On the standalone receiver — install the package as receiver-only:

# .env
MAILULATOR_RECEIVER_ENABLED=true
MAILULATOR_DRIVER_ENABLED=false

Create one inbox per sender app from the UI (or reuse the seeded Default). Each inbox has its own bearer token; that token is the only thing that ties a sender to its inbox.

On each sender app — install the package as driver-only and point it at the receiver with the inbox's token:

# .env
MAIL_MAILER=mailulator
MAILULATOR_URL=https://mailulator.your-domain.test
MAILULATOR_TOKEN=<token for this app's inbox>
MAILULATOR_RECEIVER_ENABLED=false
MAILULATOR_DRIVER_ENABLED=true

To onboard another sender app, repeat the sender-side .env with a different inbox token. The receiver doesn't care how many apps point at it. Open /mailulator on the receiver to read mail across all inboxes.

Serve the UI at the domain root. In standalone mode the app exists only to be Mailulator, so you usually want it at / rather than /mailulator:

# .env (on the standalone receiver)
MAILULATOR_UI_PATH=/

Both / and an empty value serve the UI shell at the root. The SPA's own JSON/ajax API lives under a separate reserved namespace (/_mailulator/* by default) and the cross-app ingest endpoint at POST /api/mailulator/emails — both independent of MAILULATOR_UI_PATH and separately configurable (see the env table). Because the SPA claims a greedy /{any?} catch-all at the root, remove your app's own / route (e.g. the default welcome route in routes/web.php) so Mailulator owns the index.

Register the routes yourself (advanced). Instead of the config-driven path, you can mount the UI explicitly from your own route file — useful for choosing the exact position, middleware, or domain in code:

# .env
MAILULATOR_REGISTER_ROUTES=false
// routes/web.php
use Webteractive\Mailulator\Mailulator;

// pick ONE of the following — calling route() more than once registers the group more than once:
Mailulator::route('/');                                        // at root
Mailulator::route('/mail');                                    // custom path
Mailulator::route('/mail', ['middleware' => ['web', 'auth']]); // add your app's auth

The gate is always applied on top of whatever middleware you pass. The ingest endpoint (POST /api/mailulator/emails) is unaffected and stays registered automatically. Note that routes/web.php already runs inside Laravel's web middleware group, so calling Mailulator::route() there nests web within web (harmless); for a pristine stack, register it from a dedicated route file without the web group.

Gate

The SPA is gated. Edit app/Providers/MailulatorServiceProvider.php:

protected function gate(): void
{
    Gate::define('viewMailulator', fn ($user) =>
        in_array(optional($user)->email, [
            'you@example.com',
        ])
    );
}

Default: local environment only. Non-local without a customized gate → 403.

Optional hooks in the same provider:

public function boot(): void
{
    parent::boot();

    Mailulator::canViewInbox(fn ($user, $inboxId) =>
        $user->inboxes()->where('inboxes.id', $inboxId)->exists()
    );

    Mailulator::manage(fn ($user) => $user->is_admin ?? false);
}

Sender-side (.env)

Variable Default Purpose
MAIL_MAILER Set to mailulator to route outbound email through this driver.
MAILULATOR_URL Base URL of the receiver.
MAILULATOR_TOKEN Per-inbox bearer token printed by mailulator:install or the admin UI.
MAILULATOR_TIMEOUT 5 HTTP timeout (seconds) for ingest calls.
MAILULATOR_ON_FAILURE log log (warn + return), silent (return), or throw (raise TransportException).
MAILULATOR_DRIVER_ENABLED true Set false to install as receiver-only.

A receiver outage will not break the sender app's request unless MAILULATOR_ON_FAILURE=throw.

Receiver-side (.env)

Variable Default Purpose
MAILULATOR_RECEIVER_ENABLED true Turn receiver off to install as driver-only.
MAILULATOR_DB_CONNECTION mailulator Connection name to use. Set to any connection defined in your host app's config/database.php (e.g. mysql) to share that DB; leave as mailulator for an isolated, package-managed connection.
MAILULATOR_DB_DRIVER sqlite Driver for the auto-managed connection — only used when MAILULATOR_DB_CONNECTION=mailulator and the host hasn't pre-defined it.
MAILULATOR_SQLITE_PATH database_path('mailulator.sqlite') SQLite file, auto-touched.
MAILULATOR_DB_HOST / _PORT / _DATABASE / _USERNAME / _PASSWORD / _CHARSET Credentials for the auto-managed connection (non-SQLite drivers).
MAILULATOR_ATTACHMENTS_DISK local Filesystem disk for attachment bytes.
MAILULATOR_RATE_LIMIT 600 Ingest requests/min per inbox.
MAILULATOR_RETENTION_DAYS 30 Default retention for newly created inboxes. Per-inbox override available; null keeps forever.
MAILULATOR_UI_PATH mailulator UI shell path prefix.
MAILULATOR_AJAX_PATH _mailulator Reserved prefix for the SPA's JSON/ajax API. Kept off the UI path so it never collides with host routes.
MAILULATOR_INGEST_PATH api/mailulator Prefix for the HTTP ingest endpoint (POST {path}/emails). Senders must resolve to the same path. Set api for a bare /api/emails.
MAILULATOR_UI_DOMAIN Optional subdomain (e.g. mail.your-staging.com).
MAILULATOR_REALTIME_ENABLED true Master switch for realtime updates.
MAILULATOR_REALTIME polling polling or broadcast.
MAILULATOR_POLL_INTERVAL 3 Polling interval (seconds).
MAILULATOR_BROADCASTER reverb reverb or pusher when MAILULATOR_REALTIME=broadcast.

Ingest API

POST /api/mailulator/emails — bearer-token authenticated, rate-limited (an IP-keyed guard in front of the token check, then a per-inbox limit). Runs a self-contained stateless middleware stack — it does not inherit the host app's web/api groups, so no CSRF, session, or host throttles apply.

Accepts JSON (base64 attachments) or multipart/form-data (UploadedFile attachments). Returns 201 { "id": <email_id> }.

The path prefix is configurable via MAILULATOR_INGEST_PATH (default api/mailulator). HTTP senders using the mailulator driver derive the same path from their own config, so if you customize it on the receiver, set the matching value on each sender.

Programmatic API

Manage inboxes, query emails, and assert delivered mail from host-app code — seeders, Artisan commands, or tests — without HTTP. This runs in-process on the isolated mailulator connection and bypasses the UI gate (it is trusted app code, not a web request).

use Webteractive\Mailulator\Facades\Mailulator;

// Manage inboxes
$billing = Mailulator::createInbox('Billing', ['retention_days' => 7, 'color' => '#0f172a']);
$token   = $billing->token();               // one-time plaintext key
Mailulator::inbox('Billing')->rename('Invoices')->settings(['color' => '#111827']);
Mailulator::inbox('Invoices')->regenerateToken();
Mailulator::inbox('Marketing')->delete();   // throws on the Default or last inbox

// Query emails
Mailulator::inbox('Invoices')->emails();                 // Collection
Mailulator::latest();                                    // newest anywhere
Mailulator::inbox('Invoices')->to('user@app.test')->subject('Welcome')->get();
Mailulator::inbox('Invoices')->unread()->count();

// Test assertions (PHPUnit context)
Mailulator::inbox('Invoices')
    ->assertReceivedFrom('noreply@app.test')
    ->assertSubject('Welcome');
Mailulator::assertNothingReceived();
Mailulator::flush();                                     // reset between tests

// Seed without sending
Mailulator::inbox('Invoices')->receive([
    'from' => 'noreply@app.test',
    'to' => ['user@app.test'],
    'subject' => 'Seeded',
    'text_body' => 'Hi',
]);

Realtime

Three states, controlled by two env vars:

MAILULATOR_REALTIME_ENABLED MAILULATOR_REALTIME Behavior
true (default) polling (default) UI polls every MAILULATOR_POLL_INTERVAL seconds. Zero extra deps.
true broadcast Echo subscribes to mailulator.inbox.{id} private channels.
false Static UI. No polling, no broadcast.

To enable broadcasting, install Reverb / Pusher in the host app, then:

MAILULATOR_REALTIME=broadcast
MAILULATOR_BROADCASTER=reverb
MAILULATOR_ECHO_KEY=...
MAILULATOR_ECHO_CLUSTER=...      # Pusher only
MAILULATOR_ECHO_HOST=...          # Reverb only
MAILULATOR_ECHO_PORT=...
MAILULATOR_ECHO_SCHEME=https

EmailReceived dispatches to mailulator.inbox.{id} on every ingest. Channel authorization routes through Mailulator::canViewInbox. If MAILULATOR_REALTIME=broadcast is set without a configured MAILULATOR_ECHO_KEY, the client logs a warning and falls back to polling.

Inboxes

  • Each inbox has a name, optional retention period, optional color (UI accent), and a hashed bearer token.
  • The seeded Default inbox is protected — it cannot be renamed or deleted.
  • The last remaining inbox cannot be deleted regardless of name.
  • Regenerating a key invalidates the previous token immediately at the ingest boundary.

Retention

Set retention_days per inbox; the daily PruneEmails job deletes older emails and cleans their attachment files. null = keep forever.

The job is auto-scheduled. Ensure your host app runs schedule:run via cron or schedule:work.

Upgrade

composer update webteractive/mailulator
php artisan vendor:publish --tag=mailulator-assets --force
php artisan migrate --database=mailulator

License

MIT.

About

Self-hosted, Laravel-native Mailtrap alternative.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages