Etap 20: Wysyłanie e-maili do administratorów

5.0 version
Maintained

Wysyłanie e-maili do administratorów

W celu zapewnienia wysokiej jakości informacji zwrotnej, administracja musi moderować wszystkie komentarze. Kiedy komentarz jest w stanie ham lub potential_spam, wiadomość e-mail powinna zostać wysłana do administratorów z dwoma linkami: jednym do zaakceptowania komentarza, a drugim do jego odrzucenia.

Najpierw zainstaluj komponent Symfony Mailer:

1
$ symfony composer req mailer

Ustawianie e-maila dla konta administracyjnego

Aby zapisać adres e-mail konta administracyjnego, użyj parametru kontenera. Dla celów demonstracyjnych, pozwalamy również na ustawienie go za pomocą zmiennej środowiskowej (nie powinna być potrzebna w „prawdziwym życiu”). Aby ułatwić wstrzykiwanie usług, które wymagają adresu e-mail konta administracyjnego, zdefiniuj również ustawienie kontenera bind:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -4,6 +4,7 @@
 # Put parameters here that don't need to change on each machine where the app is deployed
 # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
 parameters:
+    default_admin_email: [email protected]

 services:
     # default configuration for services in *this* file
@@ -13,6 +14,7 @@ services:
         bind:
             $photoDir: "%kernel.project_dir%/public/uploads/photos"
             $akismetKey: "%env(AKISMET_KEY)%"
+            $adminEmail: "%env(string:default:default_admin_email:ADMIN_EMAIL)%"

     # makes classes in src/ available to be used as services
     # this creates a service per class whose id is the fully-qualified class name

Zmienna środowiskowa może zostać „przetworzona” przed użyciem. W tym przypadku, używamy procesora default, aby zwrócić domyślnie wartość parametru default_admin_email, jeśli zmienna środowiskowa ADMIN_EMAIL nie istnieje.

Wysyłanie powiadomień e-mail

Aby wysłać wiadomość e-mail, możesz wybrać pomiędzy kilkoma abstrakcjami klasy Email; od klasy najniższego poziomu Message do najwyższego poziomu NotificationEmail. Prawdopodobnie najczęściej skorzystasz z klasy Email, ale NotificationEmail jest idealnym wyborem dla wewnętrznych wiadomości e-mail.

Przy obsłudze wiadomości zastąpmy reguły automatycznej walidacji:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
--- a/src/MessageHandler/CommentMessageHandler.php
+++ b/src/MessageHandler/CommentMessageHandler.php
@@ -7,6 +7,8 @@ use App\Repository\CommentRepository;
 use App\SpamChecker;
 use Doctrine\ORM\EntityManagerInterface;
 use Psr\Log\LoggerInterface;
+use Symfony\Bridge\Twig\Mime\NotificationEmail;
+use Symfony\Component\Mailer\MailerInterface;
 use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
 use Symfony\Component\Messenger\MessageBusInterface;
 use Symfony\Component\Workflow\WorkflowInterface;
@@ -18,15 +20,19 @@ class CommentMessageHandler implements MessageHandlerInterface
     private $commentRepository;
     private $bus;
     private $workflow;
+    private $mailer;
+    private $adminEmail;
     private $logger;

-    public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, LoggerInterface $logger = null)
+    public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, MailerInterface $mailer, string $adminEmail, LoggerInterface $logger = null)
     {
         $this->entityManager = $entityManager;
         $this->spamChecker = $spamChecker;
         $this->commentRepository = $commentRepository;
         $this->bus = $bus;
         $this->workflow = $commentStateMachine;
+        $this->mailer = $mailer;
+        $this->adminEmail = $adminEmail;
         $this->logger = $logger;
     }

@@ -51,8 +57,13 @@ class CommentMessageHandler implements MessageHandlerInterface

             $this->bus->dispatch($message);
         } elseif ($this->workflow->can($comment, 'publish') || $this->workflow->can($comment, 'publish_ham')) {
-            $this->workflow->apply($comment, $this->workflow->can($comment, 'publish') ? 'publish' : 'publish_ham');
-            $this->entityManager->flush();
+            $this->mailer->send((new NotificationEmail())
+                ->subject('New comment posted')
+                ->htmlTemplate('emails/comment_notification.html.twig')
+                ->from($this->adminEmail)
+                ->to($this->adminEmail)
+                ->context(['comment' => $comment])
+            );
         } elseif ($this->logger) {
             $this->logger->debug('Dropping comment message', ['comment' => $comment->getId(), 'state' => $comment->getState()]);
         }

MailerInterface jest kluczowym interfejsem pozwalającym na wysyłanie e-maili metodą send().

Aby wysłać wiadomość e-mail, potrzebujemy nadawcy (nagłówka From/Sender). Zamiast ustawiać go bezpośrednio w instancji klasy Email, zdefiniuj ją globalnie:

patch_file
1
2
3
4
5
6
7
8
--- a/config/packages/mailer.yaml
+++ b/config/packages/mailer.yaml
@@ -1,3 +1,5 @@
 framework:
     mailer:
         dsn: '%env(MAILER_DSN)%'
+        envelope:
+            sender: "%env(string:default:default_admin_email:ADMIN_EMAIL)%"

Rozszerzanie szablonu powiadomienia e-mail (ang. notification email template)

Szablon powiadomienia e-mail dziedziczy po domyślnym szablonie powiadomienia e-mail, który jest dostarczany wraz z Symfony:

templates/emails/comment_notification.html.twig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{% extends '@email/default/notification/body.html.twig' %}

{% block content %}
    Author: {{ comment.author }}<br />
    Email: {{ comment.email }}<br />
    State: {{ comment.state }}<br />

    <p>
        {{ comment.text }}
    </p>
{% endblock %}

{% block action %}
    <spacer size="16"></spacer>
    <button href="{{ url('review_comment', { id: comment.id }) }}">Accept</button>
    <button href="{{ url('review_comment', { id: comment.id, reject: true }) }}">Reject</button>
{% endblock %}

Szablon nadpisuje kilka bloków, aby dostosować treść wiadomości e-mail i dodać kilka odnośników, które pozwalają administracji zaakceptować lub odrzucić komentarz. Każdy argument trasy (ang. route argument), który nie jest poprawnym parametrem trasy (ang. route parameter) jest dodawany jako element łańcucha zapytań (adres URL odrzucenia wygląda tak /admin/comment/review/42?reject=true).

Domyślny szablon``NotificationEmail`` używa Inky zamiast HTML do projektowania wiadomości e-mail. Jest on pomocny w tworzeniu responsywnych wiadomości e-mail, które są kompatybilne z wszystkimi popularnymi klientami poczty elektronicznej.

W celu zapewnienia maksymalnej kompatybilności z czytnikami e-mail, podstawowy layout powiadomień domyślnie zawiera wszystkie arkusze stylów (poprzez pakiet CSS inliner).

Te dwie funkcje są częścią opcjonalnych rozszerzeń biblioteki Twig, które należy zainstalować:

1
$ symfony composer req twig/cssinliner-extra twig/inky-extra

Generowanie bezwzględnych adresów URL wewnątrz polecenia (ang. command)

W wiadomościach e-mail generuj adresy URL wykorzystując funkcję url() zamiast path(), jako że potrzebujesz bezwzględnych adresów (z protokołem i hostem).

E-mail jest wysyłany za pomocą obsługi wiadomości (ang. message handler) z poziomu konsoli. Generowanie bezwzględnych adresów URL z poziomu przeglądarki jest łatwiejsze, ponieważ znamy protokół i domenę strony, co nie ma miejsca w przypadku wywołania z poziomu konsoli.

Zdefiniuj nazwę domeny i protokół do użycia:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -5,6 +5,11 @@
 # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
 parameters:
     default_admin_email: [email protected]
+    default_domain: '127.0.0.1'
+    default_scheme: 'http'
+
+    router.request_context.host: '%env(default:default_domain:SYMFONY_DEFAULT_ROUTE_HOST)%'
+    router.request_context.scheme: '%env(default:default_scheme:SYMFONY_DEFAULT_ROUTE_SCHEME)%'

 services:
     # default configuration for services in *this* file

Zmienne środowiskowe SYMFONY_DEFAULT_ROUTE_HOST i SYMFONY_DEFAULT_ROUTE_PORT są lokalnie automatycznie ustawiane podczas korzystania z symfony CLI i ustalane na podstawie konfiguracji na SymfonyCloud.

Wiązanie trasy (ang. route) z kontrolerem

Trasa review_comment jeszcze nie istnieje, stwórzmy kontroler administracyjny, który ją obsłuży:

src/Controller/AdminController.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
namespace App\Controller;

use App\Entity\Comment;
use App\Message\CommentMessage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Workflow\Registry;
use Twig\Environment;

class AdminController extends AbstractController
{
    private $twig;
    private $entityManager;
    private $bus;

    public function __construct(Environment $twig, EntityManagerInterface $entityManager, MessageBusInterface $bus)
    {
        $this->twig = $twig;
        $this->entityManager = $entityManager;
        $this->bus = $bus;
    }

    /**
     * @Route("/admin/comment/review/{id}", name="review_comment")
     */
    public function reviewComment(Request $request, Comment $comment, Registry $registry)
    {
        $accepted = !$request->query->get('reject');

        $machine = $registry->get($comment);
        if ($machine->can($comment, 'publish')) {
            $transition = $accepted ? 'publish' : 'reject';
        } elseif ($machine->can($comment, 'publish_ham')) {
            $transition = $accepted ? 'publish_ham' : 'reject_ham';
        } else {
            return new Response('Comment already reviewed or not in the right state.');
        }

        $machine->apply($comment, $transition);
        $this->entityManager->flush();

        if ($accepted) {
            $this->bus->dispatch(new CommentMessage($comment->getId()));
        }

        return $this->render('admin/review.html.twig', [
            'transition' => $transition,
            'comment' => $comment,
        ]);
    }
}

Adres URL recenzji komentarza rozpoczyna się od /admin/ w celu zabezpieczenia go zaporą sieciową zdefiniowaną w poprzednim kroku. Aby uzyskać dostęp do tego zasobu, administracja musi być uwierzytelniona.

Zamiast tworzyć instancję Response, użyliśmy metody-skrótu render() dostarczanej przez klasę bazową kontrolera AbstractController.

Po zakończeniu weryfikacji, krótki szablon podziękuje administracji za jej ciężką pracę:

templates/admin/review.html.twig
1
2
3
4
5
6
7
8
{% extends 'base.html.twig' %}

{% block body %}
    <h2>Comment reviewed, thank you!</h2>

    <p>Applied transition: <strong>{{ transition }}</strong></p>
    <p>New state: <strong>{{ comment.state }}</strong></p>
{% endblock %}

Wykorzystanie Mail Catcher

Zamiast używać „prawdziwego” serwera SMTP lub zewnętrznego dostawcy do wysyłania wiadomości e-mail, użyjmy narzędzia Mail Catcher zapewnianego przez serwer SMTP, który wiadomości e-mail odbiera, ale nie dostarcza, tylko udostępnia poprzez interfejs WWW:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -16,3 +16,7 @@ services:
     rabbitmq:
         image: rabbitmq:3.7-management
         ports: [5672, 15672]
+
+    mailer:
+        image: schickling/mailcatcher
+        ports: [1025, 1080]

Wyłącz i zrestartuj kontenery, aby dodać Mail Catcher:

1
2
$ docker-compose stop
$ docker-compose up -d
1
$ sleep 10

Dostęp do panelu poczty (ang. webmail)

Możesz otworzyć panel poczty z terminala:

1
$ symfony open:local:webmail

albo z poziomu paska narzędzi do debugowania:

Prześlij komentarz. W interfejsie panelu poczty (ang. webmail) powinna pojawić się nowa wiadomość e-mail:

Kliknij na tytuł wiadomości e-mail w interfejsie i zaakceptuj lub odrzuć komentarz, jeśli uznasz to za stosowne:

Sprawdź logi poleceniem server:log, jeśli coś nie działa zgodnie z oczekiwaniami.

Zarządzanie długo działającymi skryptami (ang. long-running scripts)

Posiadanie długo działających skryptów wiąże się z zachowaniami, których powinno się być świadomym. W przeciwieństwie do modelu PHP używanego dla HTTP, gdzie każde żądanie zaczyna się od czystego stanu, przetwarzanie wiadomości działa w tle w sposób ciągły. Każda obsługa komunikatu dziedziczy bieżący stan, w tym pamięć podręczną. Aby uniknąć jakichkolwiek problemów z Doctrine, wszystkie menadżery encji (ang. entity manager) są automatycznie czyszczone po przetworzeniu wiadomości. Sprawdź, czy twoje własne usługi wymagają tego samego, czy też nie.

Asynchroniczne wysyłanie wiadomości e-mail

Wysłanie e-mail poprzez obsługę wiadomości (ang. message handler) może zająć trochę czasu. Może nawet rzucić wyjątek. W przypadku rzucenia wyjątku podczas obsługi wiadomości, zostanie podjęta próba przetworzenia jej ponownie, ale zamiast próbować ponownie ją przetworzyć (ang. consume), lepiej byłoby po prostu spróbować ponownie wysłać wiadomość e-mail.

Wiemy już, jak to zrobić: wyślij wiadomość e-mail na szynę (ang. bus).

Instancja MailerInterface wykonuje za nas ciężką pracę: gdy szyna jest zdefiniowana, przesyła (ang. dispatches) na nią wiadomości e-mail zamiast je wysyłać. Nie są wymagane żadne zmiany w naszym kodzie.

Jednak szyna nadal wysyła wiadomość e-mail synchronicznie, ponieważ nie skonfigurowaliśmy kolejki, którą chcemy wykorzystać do wysyłania wiadomości e-mail. Użyjmy ponownie RabbitMQ:

patch_file
1
2
3
4
5
6
7
--- a/config/packages/messenger.yaml
+++ b/config/packages/messenger.yaml
@@ -19,3 +19,4 @@ framework:
         routing:
             # Route your messages to the transports
             App\Message\CommentMessage: async
+            Symfony\Component\Mailer\Messenger\SendEmailMessage: async

Możemy używać tej samej metody transportu (RabbitMQ) do przesyłania komentarzy i wiadomości e-mail, ale wcale nie musi tak być. Możesz zdecydować się na użycie innego transportu do zarządzania np. wiadomościami o różnych priorytetach. Korzystanie z różnych środków transportu pozwala również na wykorzystanie różnych maszyn robotników (ang. workers) obsługujących różne rodzaje komunikatów. Rozwiązanie to jest elastyczne i zależy tylko od Ciebie.

Testowanie wiadomości e-mail

Istnieje wiele sposobów testowania wiadomości e-mail.

Możesz napisać testy jednostkowe, jeśli napiszesz osobną klasę dla każdego typu wiadomości e-mail (np. poprzez rozszerzenie Email lub TemplatedEmail).

Jednak najczęstszymi testami, które napiszesz, są testy funkcjonalne, sprawdzające wyzwalanie wysyłania poczty przez niektóre działania, oraz prawdopodobnie testy dotyczące treści tych wiadomości e-mail, jeśli są dynamiczne.

Symfony dostarcza asercje (ang. assertions), które ułatwiają takie testy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public function testMailerAssertions()
{
    $client = static::createClient();
    $client->request('GET', '/');

    $this->assertEmailCount(1);
    $event = $this->getMailerEvent(0);
    $this->assertEmailIsQueued($event);

    $email = $this->getMailerMessage(0);
    $this->assertEmailHeaderSame($email, 'To', '[email protected]');
    $this->assertEmailTextBodyContains($email, 'Bar');
    $this->assertEmailAttachmentCount($email, 1);
}

Asercje (ang. assertions) te działają zarówno dla e-maili wysyłanych synchronicznie jak i asynchronicznie.

Wysyłanie wiadomości e-mail poprzez SymfonyCloud

Nie ma konfiguracji przeznaczonej dla SymfonyCloud. Wszystkie konta posiadają domyślnie konto Sendgrid, które jest automatycznie używane do wysyłania wiadomości e-mail.

Nadal musisz zaktualizować konfigurację SymfonyCloud, aby włączyć rozszerzenie PHP xsl, wymagane przez Inky:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
--- a/.symfony.cloud.yaml
+++ b/.symfony.cloud.yaml
@@ -4,6 +4,7 @@ type: php:7.3

 runtime:
     extensions:
+        - xsl
         - amqp
         - redis
         - pdo_pgsql

Informacja

Dla naszego bezpieczeństwa e-maile nie są domyślnie wysyłane na gałęziach innych niż master. Włącz SMTP, jeśli wiesz, co robisz:

1
$ symfony env:setting:set email on

  • « Previous Etap 19: Podejmowanie decyzji przy użyciu komponentu Workflow
  • Next » Etap 21: Użycie pamięci podręcznej w celu zwiększenia wydajności

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.