Dzień 13: Mailer
W Dniu 12 partner może się zgłosić, a admin aktywować jego konto. Brakuje ostatniego elementu: powiadomienia e-mail z tokenem do API po aktywacji. Dziś wyślemy taki e-mail, użyjemy szablonu Twig, przechwycimy wiadomości w Mailpit i wyślemy je asynchronicznie przez kolejkę.
Zmiana vs Symfony 4.2: oryginał używał Swift Mailera (usuniętego w Symfony 5) i zewnętrznego SendGrida. W Symfony 8 korzystamy z Symfony Mailer (
MailerInterface,TemplatedEmail), a w dev łapiemy maile w Mailpit (zamiast MailHog). Zmienna toMAILER_DSN, nieMAILER_URL.
Transport i Mailpit
Symfony Mailer konfiguruje jedna zmienna MAILER_DSN. W naszym stacku Dockera wskazuje ona na Mailpit
— narzędzie, które przechwytuje wszystkie wychodzące maile i pokazuje je w przeglądarce (nic nie leci do
prawdziwych skrzynek). DSN ustawiliśmy już w Dniu 1 (compose.yaml nadpisuje .env):
# .env — wartość domyślna
MAILER_DSN=smtp://localhost:1025
# config/packages/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
Panel Mailpit: http://localhost:8025 — tu pojawią się wszystkie wysłane wiadomości.
Zmiana vs Symfony 4.2: zamiast konfigurować SendGrida i
MAILER_URL, w dev używamy Mailpit (MAILER_DSN=smtp://mailer:1025). Na produkcji wystarczy podmienićMAILER_DSNna transport dostawcy (np.MAILER_DSN=sendgrid+smtp://KEY@default) — kod się nie zmienia.
Prosty e-mail
Najprościej wysłać wiadomość obiektem Email przez wstrzyknięty MailerInterface. Rozszerz akcję
activate() w src/Controller/Admin/AffiliateController.php (z Dnia 12):
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
#[Route('/{id}/activate', name: 'activate', methods: ['POST'], requirements: ['id' => '\d+'])]
public function activate(Request $request, Affiliate $affiliate, EntityManagerInterface $em, MailerInterface $mailer): Response
{
if ($this->isCsrfTokenValid('toggle-affiliate-' . $affiliate->getId(), (string) $request->request->get('_token'))) {
$affiliate->setActive(true);
$em->flush();
$email = (new Email())
->from('jobeet@example.com')
->to($affiliate->getEmail())
->subject('Twoje konto partnera Jobeet zostało aktywowane')
->text('Konto aktywowane. Twój token do API: ' . $affiliate->getToken());
$mailer->send($email);
$this->addFlash('success', 'Partner został aktywowany, wysłano e-mail z tokenem.');
}
return $this->redirectToRoute('admin_affiliate_index');
}
Aktywuj partnera w panelu i otwórz Mailpit — zobaczysz wiadomość z tokenem.
Zmiana vs Symfony 4.2:
MailerInterface+->from()->to()->subject()->text()) zamiast\Swift_Maileri\Swift_Message. Wysyłka to nadal jedno wywołanie$mailer->send().
Refaktoryzacja do serwisu
Budowanie maila w kontrolerze łamie zasadę „cienkich kontrolerów". Przenieśmy logikę do serwisu
src/Service/AffiliateMailer.php. Adres nadawcy wstrzykujemy parametrem:
<?php
namespace App\Service;
use App\Entity\Affiliate;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
final readonly class AffiliateMailer
{
public function __construct(
private MailerInterface $mailer,
private string $senderEmail,
) {}
public function sendActivation(Affiliate $affiliate): void
{
$email = (new TemplatedEmail())
->from($this->senderEmail)
->to($affiliate->getEmail())
->subject('Twoje konto partnera Jobeet zostało aktywowane')
->htmlTemplate('emails/affiliate_activation.html.twig')
->context(['affiliate' => $affiliate]);
$this->mailer->send($email);
}
}
Adres nadawcy podajemy w config/services.yaml (bind argumentu):
services:
_defaults:
bind:
$senderEmail: '%env(MAILER_FROM)%'
# .env
MAILER_FROM=jobeet@example.com
Kontroler chudnie — wstrzykujemy serwis i wołamy jedną metodę:
use App\Service\AffiliateMailer;
public function activate(Request $request, Affiliate $affiliate, EntityManagerInterface $em, AffiliateMailer $mailer): Response
{
if ($this->isCsrfTokenValid('toggle-affiliate-' . $affiliate->getId(), (string) $request->request->get('_token'))) {
$affiliate->setActive(true);
$em->flush();
$mailer->sendActivation($affiliate);
$this->addFlash('success', 'Partner został aktywowany, wysłano e-mail z tokenem.');
}
return $this->redirectToRoute('admin_affiliate_index');
}
E-mail z szablonem Twig
Zamiast składać treść w kodzie, użyliśmy już TemplatedEmail z metodą htmlTemplate() i context().
Utwórz szablon templates/emails/affiliate_activation.html.twig:
<h2>Konto partnera aktywowane 🎉</h2>
<p>Cześć! Twoje konto partnera Jobeet zostało właśnie aktywowane.</p>
<p>Twój token do API:</p>
<p><strong>{{ affiliate.token }}</strong></p>
<p>Używaj go w nagłówku żądań:</p>
<pre>Authorization: Bearer {{ affiliate.token }}</pre>
<p>Pozdrawiamy,<br>Zespół Jobeet</p>
W szablonie mamy dostęp do zmiennych z context() (tutaj affiliate). TemplatedEmail renderuje go jako
część HTML wiadomości.
Zmiana vs Symfony 4.2: oryginał wstrzykiwał
EngineInterface(komponenttemplating, usunięty) i renderował szablon ręcznie. DziśTemplatedEmailintegruje Twiga z mailerem — podajemy tylkohtmlTemplate()icontext(), a renderowanie dzieje się samo.
Wysyłka asynchroniczna (Messenger)
Wysyłka maila w trakcie żądania spowalnia odpowiedź (i grozi błędem, jeśli SMTP nie odpowiada). Symfony
Mailer potrafi wysyłać asynchronicznie przez Messenger — akcja tylko wrzuca wiadomość do kolejki,
a osobny worker ją wysyła. Wystarczy przekierować SendEmailMessage na transport async w
config/packages/messenger.yaml:
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
Uruchom worker, który konsumuje kolejkę i realnie wysyła maile:
docker compose exec app php bin/console messenger:consume async -vv
Od teraz $mailer->send() nie blokuje żądania — mail trafia do kolejki, a worker dostarcza go do
Mailpit. To samo podejście stosuje moduł newslettera w tym projekcie.
Zmiana vs Symfony 4.2: asynchroniczna wysyłka maili przez Messenger to nowość — w oryginale Swift Mailer wysyłał synchronicznie (spooling wymagał osobnej konfiguracji). Dziś wystarczy jedna linia routingu.
Przechwytywanie maili w dev
Mailpit łapie wszystko, ale możesz też wymusić jednego odbiorcę dla wszystkich maili w dev (np. gdy
nie używasz Mailpit) — w config/packages/mailer.yaml:
when@dev:
framework:
mailer:
envelope:
recipients: ['dev@example.com']
Wtedy niezależnie od ->to() każdy mail trafi na dev@example.com.
Zmiana vs Symfony 4.2: to odpowiednik opcji
swiftmailer.delivery_addresses— teraz nazywa sięmailer.envelope.recipientsi konfigurujemy go podwhen@dev.
(Opcjonalnie) Inlining CSS
Klienty pocztowe słabo obsługują <style> — reguły CSS lepiej „wkleić" wprost do atrybutów style.
Służy do tego rozszerzenie Twiga:
docker compose exec app composer require twig/cssinliner-extra
W szablonie maila owijasz treść filtrem inline_css ({% apply inline_css %}…{% endapply %}).
Podsumowanie
Jobeet wysyła e-maile zgodnie ze współczesnym Symfony:
- ✅ Symfony Mailer (
MailerInterface,Email/TemplatedEmail) zamiast Swift Mailera, - ✅ transport przez
MAILER_DSN, w dev przechwytywany w Mailpit (localhost:8025), - ✅ e-mail z szablonem Twig (
TemplatedEmail+htmlTemplate+context), - ✅ powiadomienie partnera z tokenem po aktywacji konta,
- ✅ asynchroniczna wysyłka przez Messenger (worker,
SendEmailMessage: async), - ✅ wymuszony odbiorca w dev (
mailer.envelope.recipients) i opcjonalny inlining CSS.
W Dniu 14 zajmiemy się tłumaczeniami — internacjonalizacją interfejsu Jobeet (komponent Translation, format ICU, przełącznik języka).