Laravel 11: Mail přes SMTP relay TopEmail, Mailable ve frontě a HTTP klient pro značky a webhooky
Nastavení .env pro MAIL_MAILER=smtp na smtp.topemail.cz, Mailable s Reply-To a značkami v hlavičkách, odesílání přes frontu, varianta přes REST API s Laravel HTTP klientem a Idempotency-Key, a route pro webhook email.status s ověřením podpisu a výjimkou z CSRF.
Laravel používá pod kapotou Symfony Mailer, takže přepnutí na SMTP relay je změna pěti řádků v .env. Článek ale jde dál: ukazuje Mailable, který posílá značky pro párování, odesílání přes frontu, aby HTTP požadavek zákazníka nečekal na SMTP, variantu přes REST API s Laravel HTTP klientem, a route pro webhook se stavem doručení. Všechno pro Laravel 11 a PHP 8.3.
Krok 1: .env
MAIL_MAILER=smtp
MAIL_HOST=smtp.topemail.cz
MAIL_PORT=587
MAIL_USERNAME=CLIENT_ID
MAIL_PASSWORD=SECRET
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="objednavky@muj-eshop.cz"
MAIL_FROM_NAME="${APP_NAME}"
Novější skeleton Laravelu 11 má v config/mail.php místo encryption klíč scheme; pak použijte MAIL_SCHEME=smtp (pro port 465 MAIL_SCHEME=smtps). Na portu 587 Symfony Mailer vyjedná STARTTLS automaticky, takže proměnná pro šifrování není pro funkčnost rozhodující. Alternativně celé připojení jedním DSN:
MAIL_URL=smtp://CLIENT_ID:SECRET@smtp.topemail.cz:587
Pokud SECRET obsahuje @, : nebo /, v DSN ho URL-zakódujte. MAIL_FROM_ADDRESS musí být na doméně ověřené v TopEmail (TXT topemail._domainkey, SPF include:spf.topemail.cz, _dmarc), jinak relay odpoví 550 5.7.1 a Laravel vyhodí TransportException. Po změně .env spusťte php artisan config:clear.
Rychlý test v Tinkeru:
php artisan tinker
>>> Mail::raw('Test z Laravelu', fn ($m) => $m->to('vas@gmail.com')->subject('Test SMTP'));
Pak v Gmailu „Zobrazit originál" a zkontrolovat SPF, DKIM a DMARC PASS.
Krok 2: Mailable se značkami
php artisan make:mail OrderConfirmed --markdown=emails.orders.confirmed
<?php
// app/Mail/OrderConfirmed.php
namespace App\Mail;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
class OrderConfirmed extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(public Order $order) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "Potvrzení objednávky {$this->order->code}",
replyTo: [new Address('podpora@muj-eshop.cz', 'Podpora')],
);
}
public function content(): Content
{
return new Content(markdown: 'emails.orders.confirmed');
}
public function headers(): Headers
{
return new Headers(text: [
'X-TopEmail-Tag-order' => $this->order->code,
'X-TopEmail-Tag-type' => 'confirm',
]);
}
}
Hlavičky X-TopEmail-Tag-<klíč> relay převede na značky, které se vrátí ve webhooku a v historii; metoda headers() je od Laravelu 10 součást Mailable. Markdown šablona vygeneruje HTML i textovou verzi, což je pro doručitelnost správně.
Odeslání:
Mail::to($order->email)->send(new OrderConfirmed($order));
Protože Mailable implementuje ShouldQueue, send() ho ve skutečnosti zařadí do fronty. Spusťte worker (php artisan queue:work) a v .env nastavte QUEUE_CONNECTION na database nebo redis; s výchozím sync by se e-mail posílal v požadavku a zákazník by čekal na SMTP.
Idempotence u SMTP: pokud worker spadne po odeslání a před označením úlohy, Laravel ji zopakuje a e-mail odejde dvakrát. Relay dedupuje podle Message-ID, takže nastavte stabilní:
public function headers(): Headers
{
return new Headers(
messageId: "order-{$this->order->code}-confirm@muj-eshop.cz",
text: ['X-TopEmail-Tag-order' => $this->order->code],
);
}
Opakovaný pokus se stejným Message-ID server nepošle znovu.
Krok 3: varianta přes REST API
Když chcete ID e-mailu hned v odpovědi a chyby jako JSON, použijte Laravel HTTP klient. Hodí se pro ověřovací kódy a vše, kde potřebujete vědět, že odešlo:
use Illuminate\Support\Facades\Http;
$response = Http::withToken(config('services.topemail.key')) // "CLIENT_ID:SECRET"
->withHeaders(['Idempotency-Key' => "order-{$order->code}-confirm"])
->timeout(15)
->post('https://topemail.cz/api/email/send', [
'from' => 'Můj e-shop <objednavky@muj-eshop.cz>',
'to' => [$order->email],
'subject' => "Potvrzení objednávky {$order->code}",
'html' => view('emails.orders.confirmed-html', compact('order'))->render(),
'text' => view('emails.orders.confirmed-text', compact('order'))->render(),
'replyTo' => 'podpora@muj-eshop.cz',
'tags' => ['order' => $order->code, 'type' => 'confirm'],
]);
if ($response->failed()) {
$code = $response->json('code'); // invalid_recipient, domain_not_verified, rate_limited…
if ($code === 'invalid_recipient') {
$order->update(['email_invalid' => true]);
} elseif ($response->status() === 429 || $response->serverError()) {
throw new \RuntimeException('retry'); // nechat frontu opakovat se stejným klíčem
} else {
report(new \RuntimeException("TopEmail {$code}: {$response->json('error')}"));
}
} else {
$order->update(['confirm_email_id' => $response->json('emails.0.id')]);
}
Klíč dejte do config/services.php jako 'topemail' => ['key' => env('TOPEMAIL_KEY')], ne přímo env() v kódu, aby fungovalo config:cache. Idempotency-Key je odvozený z objednávky; náhodný Str::uuid() by idempotenci zrušil. Přílohy posíláte jako attachments => [['filename' => …, 'content' => base64_encode($pdf), 'contentType' => 'application/pdf']], do 10 MB.
Stav e-mailu: Http::withToken(...)->get("https://topemail.cz/api/email/{$id}")->json('status').
Krok 4: webhook email.status
Při změně stavu pošleme POST s JSON (event, id, to, status, detail, tags, occurredAt) a hlavičkou X-TopEmail-Signature: sha256=<HMAC-SHA256 těla s webhook secretem>.
// routes/web.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Jobs\ProcessEmailStatus;
Route::post('/webhooks/topemail', function (Request $request) {
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), config('services.topemail.webhook_secret'));
abort_unless(hash_equals($expected, $request->header('X-TopEmail-Signature', '')), 401);
ProcessEmailStatus::dispatch($request->json()->all());
return response()->noContent(200);
});
Laravel 11 vyjímá trasu z CSRF ochrany v bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['webhooks/topemail']);
})
Bez toho dostane webhook 419 a my ho budeme opakovat. $request->getContent() je syrové tělo, ze kterého se podpis počítá; hash_equals() porovnává v konstantním čase. Job ProcessEmailStatus musí být idempotentní (událost může přijít dvakrát): ukládejte události s unikátním klíčem a duplicitu ignorujte, jak popisuje návod k webhookům. V jobu pak reagujte na bounced (neplatná adresa) a complained.
Lokální vývoj
V .env.local nechte MAIL_MAILER=log nebo Mailpit, aby testovací objednávky nechodily reálným lidem; na stagingu použijte oddělený API klíč. Webhook lokálně otestujete přes tunel (ngrok, Cloudflare Tunnel) nebo tak, že si v testu podpis vyrobíte sami hash_hmac('sha256', $body, 'test-secret').
Shrnutí
Pět řádků v .env, Mailable s headers() pro značky a stabilním Message-ID, fronta místo sync. Pro kritické e-maily HTTP klient s Idempotency-Key a chybami podle code. Webhook jako route s hash_equals, vyjmutá z CSRF. Oba kanály běží na jednom účtu za 0,06 Kč za e-mail, prvních 1 000 měsíčně zdarma; úplný přehled polí a stavů je v dokumentaci API.
Transakční e-maily z vaší domény přes API nebo SMTP, od 0,025 do 0,06 Kč za e-mail podle objemu, prvních 1 000 e-mailů měsíčně zdarma. Registrace zdarma, bez platební karty.
Registrovat zdarma →