13

Dzień 13 z 18

Mailer

Opublikowany

Wysyłamy powiadomienie e-mail do partnera z tokenem po aktywacji konta. Poznamy Symfony Mailer, TemplatedEmail z szablonem Twig, przechwytywanie maili w Mailpit oraz asynchroniczną wysyłkę przez Messenger.

Czego się nauczysz

  • Transport przez MAILER_DSN i przechwytywanie w Mailpit
  • Prosty e-mail: MailerInterface + Email
  • Refaktoryzacja do serwisu (cienki kontroler)
  • E-mail z szablonem Twig (TemplatedEmail)
  • Asynchroniczna wysyłka przez Messenger (worker)
  • Przechwytywanie maili w dev (envelope.recipients, CSS inlining)

Co zmieniło się w Symfony 8 vs 4.2

  • Symfony Mailer zamiast Swift Mailer (usunięty w SF5)
  • MAILER_DSN zamiast MAILER_URL; Mailpit zamiast MailHog/SendGrid
  • TemplatedEmail + htmlTemplate zamiast EngineInterface (templating usunięty)
  • Asynchroniczna wysyłka przez Messenger (SendEmailMessage: async)

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, Email/TemplatedEmail), a w dev łapiemy maile w Mailpit (zamiast MailHog). Zmienna to MAILER_DSN, nie MAILER_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_DSN na 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 + Email (->from()->to()->subject()->text()) zamiast \Swift_Mailer i \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 (komponent templating, usunięty) i renderował szablon ręcznie. Dziś TemplatedEmail integruje Twiga z mailerem — podajemy tylko htmlTemplate() i context(), 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.recipients i konfigurujemy go pod when@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).

Narzędzia / paczki

symfony/mailer Messenger Mailpit

Spis treści