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]);
}
}
<?php
namespace App\Http\Controllers;
use App\Models\Contact;
use Illuminate\Http\Request;
use Illuminate\Contracts\View\View;
class UnsubscribeController extends Controller
{
public function __invoke(Request $request, Contact $contact): View
{
// Link scanners issue GET requests, so a GET must never change anything.
if ($request->isMethod('get')) {
return view('emails.unsubscribe-confirm', ['contact' => $contact]);
}
// POST comes from the provider's one-click button or from the confirm form.
$contact->forceFill(['unsubscribed_at' => $contact->unsubscribed_at ?? now()])->save();
return view('emails.unsubscribed');
}
}
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
- Add an
unsubscribed_attimestamp column to yourcontactstable. - Copy both classes and name the route:
Route::match(['get','post'], '/u/{contact}', UnsubscribeController::class)->middleware('signed')->name('unsubscribe'). - Add
'u/*'to the CSRF exceptions inbootstrap/app.php. - Send with
Mail::to($contact->address)->send(new CampaignMail($contact, $subject)). - Create a
emails.unsubscribedview 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.