DEV Community

Cover image for Delivered, opened, clicked: mail tracking in Laravel with zero external services
Nasrul Hazim Bin Mohamad
Nasrul Hazim Bin Mohamad

Posted on

Delivered, opened, clicked: mail tracking in Laravel with zero external services

TL;DR

  • You can record outgoing mail — sent, delivered-ish, opened, clicked — using Laravel's own mail events. No SaaS ESP required.
  • MessageSending / MessageSent events give you the send-side hooks; a tracking pixel handles opens; rewritten links handle clicks.
  • Gate the whole thing behind a runtime toggle so you can switch it off per environment without a deploy.
  • Caveat up front: "opened" via pixel is best-effort, not truth. Know what the signal actually means.

The two send-side events

Laravel fires MessageSending before a message hits the transport and MessageSent after. Listen to both and you get a durable record of every outgoing mail — recipient, subject, a generated message id — without touching your mailables.

class StoreMessageSending
{
    public function handle(MessageSending $event): void
    {
        if (! MailTracking::enabled()) {
            return; // runtime toggle — no deploy needed to switch off
        }

        $event->message->getHeaders()->addTextHeader('X-Mail-Id', (string) Str::uuid());
    }
}
Enter fullscreen mode Exit fullscreen mode

Stamping your own id in a header on the way out is what lets you correlate the open and click callbacks back to the exact message later. Register both listeners in the provider and you have a mail history table filling itself.

Opens: a pixel, honestly labelled

An "open" is a 1×1 transparent image at the end of the HTML body pointing at a tracked route. When the mail client loads it, your endpoint marks the message opened and returns the pixel.

Route::get('mail/o/{mailId}', function (string $mailId) {
    MailHistory::where('mail_id', $mailId)->update(['opened_at' => now()]);

    return response(base64_decode(self::PIXEL), 200)
        ->header('Content-Type', 'image/png');
})->name('mail.open');
Enter fullscreen mode Exit fullscreen mode

Be honest about what this measures. Many clients block remote images, Apple Mail Privacy Protection pre-fetches them (false positives), and plain-text readers never load it. "Opened" is a soft signal, not a fact. Track it, but don't build billing on it.

Clicks: rewrite links through a redirector

For clicks, rewrite outgoing links to pass through a tracking route that records the hit and then 302s to the real destination.

Route::get('mail/c/{mailId}', function (Request $request, string $mailId) {
    MailHistory::where('mail_id', $mailId)->update(['clicked_at' => now()]);

    return redirect()->away($request->query('u'));
})->name('mail.click');
Enter fullscreen mode Exit fullscreen mode

One security note: validate or sign the u target. An open redirector is a phishing gift. Sign the URL when you rewrite it and reject anything that fails the signature — don't redirect to arbitrary user-supplied URLs.

Why a runtime toggle matters

Tracking is a policy decision, not a code decision. Some environments (or some recipients, legally) shouldn't be tracked. A runtime flag — a system setting, not a .env read at boot — lets you turn it off instantly and audit the change, without shipping a release.

it('records nothing while tracking is disabled', function () {
    MailTracking::disable();

    Mail::to('user@example.test')->send(new WelcomeMail());

    expect(MailHistory::count())->toBe(0);
});
Enter fullscreen mode Exit fullscreen mode

What the signals actually mean

Signal How Trust level
Sent MessageSending fired High — you know you handed it to the transport
Delivered Transport accepted (no bounce) Medium — accepted ≠ inboxed
Opened Tracking pixel loaded Low — blocked/pre-fetched by many clients
Clicked Redirector hit High — a human (or bot) followed the link

Takeaway

You don't need an external mail service to get visibility into outgoing mail — Laravel's mail events plus a pixel and a signed redirector cover most of it. Just label the signals honestly (opens lie, clicks don't), sign your redirect targets, and put the whole thing behind a runtime toggle so tracking stays a choice, not a hard-coded default.

Top comments (0)