DEV Community

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

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 (1)

Collapse
 
moopet profile image
Ben Sinclair

If you rewrite links to be something like mail/c/34 it obfuscates things and lowers trust. You should also do some kind of sanity check - at the very least check its referrer before saving the click to separate on-mail clicks from ones where people have shared the link. You also have no way of knowing who the email's been forwarded to or how many times.

You could say that what you're doing is trying to up the signal-to-noise ratio, but anything that relies on spying on people like this is always going to have noise, and as time goes by more and more people are likely to either hide the signal or boost the noise.