Mail That Arrives
Domain authentication, why a transactional provider beats your own server, bounce handling, and keeping secrets out of the body.
URL::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
<?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.
; 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.
// 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.
// 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