DEV3 Technologies home

One-click unsubscribe with List-Unsubscribe header

Add RFC 8058 List-Unsubscribe headers to outgoing mail and a signed endpoint that handles the one-click POST from mailbox providers.

LaravelEmailemailunsubscribeheaderscompliance

Code

<?php

namespace App\Mail;

use App\Models\Contact;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Support\Facades\URL;

class CampaignMail extends Mailable
{
    public function __construct(private Contact $contact, private string $campaignSubject) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: $this->campaignSubject);
    }

    public function content(): Content
    {
        return new Content(view: 'emails.campaign', with: ['unsubscribeUrl' => $this->unsubscribeUrl()]);
    }

    public function headers(): Headers
    {
        return new Headers(text: [
            // Angle brackets are part of the header syntax.
            'List-Unsubscribe' => '<'.$this->unsubscribeUrl().'>',
            // Tells providers they may POST to the URL above without opening a browser.
            'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click',
        ]);
    }

    private function unsubscribeUrl(): string
    {
        return URL::signedRoute('unsubscribe', ['contact' => $this->contact->id]);
    }
}

How it works

What it solves

Large mailbox providers now expect bulk senders to support one-click unsubscribe. Without the right headers your mail shows no native "Unsubscribe" button and complaints go up, which hurts delivery for everyone on your sending domain.

How it works

CampaignMail builds a signed URL for the contact with URL::signedRoute, so no database token is needed and the link cannot be guessed. In headers() it adds two fields: List-Unsubscribe pointing at that URL, and List-Unsubscribe-Post: List-Unsubscribe=One-Click, which tells the provider it may POST to the URL directly with no human involved.

UnsubscribeController answers both verbs on the same route. A GET shows a confirmation page, because link scanners fetch URLs automatically and must never change data. A POST, which is what providers and the confirmation form send, performs the opt-out inside an updateOrCreate, so repeating it is safe. Both paths sit behind the signed middleware.

The POST route is exempt from CSRF checks because mailbox servers cannot supply a token. The signature is the protection instead.

Adapting it

Store unsubscribes per list or globally by changing what the update touches. Add a mailto: entry to the header value if you also want email based opt-out, and also put a visible unsubscribe link in the body.

Pitfalls

  • Signing the header and the body link with the same function keeps them consistent.
  • Never unsubscribe on GET.
  • Sign with a long-lived URL: people unsubscribe from months-old mail.

How to use it

  1. Add an unsubscribed_at timestamp column to your contacts table.
  2. Copy both classes and name the route: Route::match(['get','post'], '/u/{contact}', UnsubscribeController::class)->middleware('signed')->name('unsubscribe').
  3. Add 'u/*' to the CSRF exceptions in bootstrap/app.php.
  4. Send with Mail::to($contact->address)->send(new CampaignMail($contact, $subject)).
  5. Create a emails.unsubscribed view that confirms the opt-out.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets