Mail That Arrives

Domain authentication, why a transactional provider beats your own server, bounce handling, and keeping secrets out of the body.

SyntaxURL::temporarySignedRoute('statements.show', now()->addMinutes(30), ['statement' => $id])

Accepted by your transport is not the same as delivered to a person. Most of the difference is DNS.

SPF, DKIM, DMARC

SPF (RFC 7208) is a DNS TXT record listing which servers may send for your domain. DKIM (RFC 6376) is a signature over the headers and body, verified against a public key in DNS, so it survives a forward that changes the sending server. DMARC (RFC 7489) publishes what a receiver should do when neither authenticated identity lines up with the visible From: domain, and where to send reports.

These are not optional any more. Gmail requires SPF or DKIM from every sender, matching forward and reverse DNS, TLS, and a spam rate under 0.30%. From senders of more than 5,000 messages a day it requires SPF and DKIM, a DMARC record, and one-click unsubscribe headers on marketing mail.

Why a provider wins

A transactional provider hands you the records to publish, sends from warm addresses, keeps TLS and retries current, and reports bounces and complaints. Your own server starts on a cold IP inside a hosting range receivers already distrust, and that reputation work never ends.

Bounces and secrets

A hard bounce is permanent: no such mailbox. Record it and stop sending, because repeat attempts raise your complaint rate for every other message. A soft bounce is temporary and the provider retries. Separately: email sits in cleartext on machines you do not control and gets quoted into replies. Send no passwords, no API keys, no full account numbers - send a short-lived signed link and let the application do the rest.

Example

Example · php
<?php
// app/Mail/StatementReady.php

declare(strict_types=1);

namespace App\Mail;

use App\Models\Statement;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;

class StatementReady extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    public function __construct(
        protected Statement $statement,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Your '.$this->statement->period.' statement is ready',
        );
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'mail.statements.ready',
            with: [
                // A link that expires in 30 minutes. Not the document,
                // and not a token that stays valid forever in an inbox.
                'url' => URL::temporarySignedRoute(
                    'statements.show',
                    now()->addMinutes(30),
                    ['statement' => $this->statement->id],
                ),
            ],
        );
    }
}

When to use it

  • Move an application off its own sendmail onto a provider without existing mail disappearing.
  • Stop mailing addresses that hard-bounced so the domain keeps its reputation.
  • Deliver a document by expiring link instead of attaching it to the message.
  • Work out why a password-reset email lands in spam for one customer's domain.

More examples

The records a provider asks you to publish

Written out because the names matter: SPF is a TXT record on the domain itself, DKIM lives at the selector followed by ._domainkey, DMARC at _dmarc. The alignment rule is the one that catches people - SPF can pass for the provider's own bounce domain while DMARC still fails, because the From: header says something else.

Example · text
; One SPF record only. Adding a provider means editing this line,
; not adding a second TXT record.
example.com.            TXT   "v=spf1 include:_spf.provider.example ~all"

; DKIM: the provider gives you the selector and the public key, or a
; CNAME that points at a key it rotates for you.
s1._domainkey.example.com.  CNAME  s1.dkim.provider.example.

; DMARC: start at p=none and read the reports before tightening to
; quarantine, then reject.
_dmarc.example.com.     TXT   "v=DMARC1; p=none; rua=mailto:[email protected]"

; MAIL_FROM_ADDRESS must be on the domain these records cover, or
; DMARC alignment fails even though SPF and DKIM pass.

The route that signed link opens

The signed middleware alias maps to Illuminate\Routing\Middleware\ValidateSignature, which rejects a URL whose signature or expiry has been altered with a 403. Thirty minutes is a deliberate choice: long enough for someone to read the mail, short enough that a forwarded message or a shared screenshot is worthless later.

Example · php
// routes/web.php
use App\Http\Controllers\StatementController;

Route::get('/statements/{statement}', [StatementController::class, 'show'])
    ->name('statements.show')
    ->middleware('signed');   // ValidateSignature: 403 if tampered or expired

// app/Http/Controllers/StatementController.php
public function show(Request $request, Statement $statement): Response
{
    // The signature proves the link came from us and has not expired.
    // It does not prove who is holding it, so still check what matters.
    abort_unless($statement->is_downloadable, 410);

    return Storage::disk('private')->download(
        $statement->path,
        $statement->filename,
    );
}

Taking a hard bounce out of circulation

The webhook route needs to be excluded from CSRF and should verify the provider's signature; both belong in middleware rather than this method. The second half is the part teams forget: recording the bounce changes nothing until some code stops choosing the mail channel for that address.

Example · php
// app/Http/Controllers/Webhooks/BounceController.php
// The payload shape is the provider's, not Laravel's: read their docs
// and validate the fields you use.
public function __invoke(Request $request): Response
{
    $data = $request->validate([
        'email' => ['required', 'email'],
        'type' => ['required', 'string'],
    ]);

    if ($data['type'] === 'HardBounce') {
        User::where('email', $data['email'])->update([
            'email_bounced_at' => now(),
        ]);
    }

    return response()->noContent();
}

// Then exclude them wherever mail is queued.
public function via(object $notifiable): array
{
    return $notifiable->email_bounced_at ? ['database'] : ['mail', 'database'];
}

Discussion

  • Be the first to comment on this lesson.