Schritt 20: E-Mails an Administrator*innen senden

5.0 version
Maintained

E-Mails an Administrator*innen senden

Um eine hohe Feedbackqualität zu gewährleisten, müssen Administrator*innen alle Kommentare moderieren. Wenn ein Kommentar den Zustand ham oder potential_spam hat, soll eine E-Mail mit zwei Links an die Administrator*innen gesendet werden: Ein Link, um den Kommentar zu akzeptieren; und einer, um ihn abzulehnen.

Installiere zunächst die Symfony-Mailer-Komponente:

1
$ symfony composer req mailer

Eine E-Mail-Adresse für die Administrator*innen einrichten

Verwende einen Container-Parameter, um die Admin-E-Mail-Adresse zu speichern. Zu Demonstrationszwecken ermöglichen wir den Parameter über eine Environment-Variable zu setzen (dies sollte im „echten Leben“ nicht benötigt werden). Ergänze in der Container-Konfiguration um eine bind-Einstellung, um das Injizieren der E-Mail-Adresse für Services, welche die Admin-E-Mail-Adresse benötigen, zu erleichtern:

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

Eine Environment-Variable kann vor der Verwendung „verarbeitet“ werden. Hier verwenden wir den default-Processor, um den Wert des default_admin_email-Parameters zu nutzen, falls die Environment-Variable ADMIN_EMAIL nicht existiert.

Eine Benachrichtigungs-E-Mail senden

Um eine E-Mail zu versenden, kannst Du zwischen mehreren Email- Klassenabstraktionen wählen, von Message, der allgemeinsten Variante, bis zur NotificationEmail mit der höchsten Konkretisierung. Du wirst wahrscheinlich am häufigsten die Email-Klasse verwenden. Die NotificationEmail-Klasse ist jedoch die perfekte Wahl für interne E-Mails.

Lass uns im Message-Handler die automatische Validierungslogik ersetzen.

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()]);
         }

Das MailerInterface ist der Haupteinstiegspunkt und ermöglicht das Senden von E-Mails mittels send().

Um eine E-Mail zu senden, benötigen wir einen Absender (den From/Sender-Header). Anstatt ihn explizit für diese E-Mail-Instanz zu setzen, definiere ihn global:

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)%"

Das E-Mail-Template für Benachrichtigungen erweitern

Das Template für die Benachrichtigungs-E-Mail wird vom Standard-E-Mail-Template für Benachrichtigungen, das mit Symfony ausgeliefert wird, abgeleitet:

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 %}

Das Template überschreibt ein paar Blöcke, um die Nachricht der E-Mail anzupassen und Links hinzuzufügen, die es den Administrator*innen ermöglicht einen Kommentar anzunehmen oder abzulehnen. Jedes Routen-Argument, das kein gültiger Routen-Parameter ist, wird als Query-String-Element hinzugefügt (Die „abgelehnt“-URL sieht folgendermaßen aus: /admin/comment/review/42?reject=true).

Das Standard-Template NotificationEmail verwendet Inky anstelle von HTML, um E-Mails zu gestalten. Inky hilft bei der Erstellung responsiver E-Mails, die mit allen gängigen E-Mail-Clients kompatibel sind.

Um maximale Kompatibilität mit E-Mail-Programmen zu ermöglichen, verwandelt das Benachrichtigungs-Basislayout standardmäßig alle Stylesheets in Inline-Style-Attribute (mit Hilfe des CSS-Inliner-Pakets).

Diese beiden Funktionen sind Teil optionaler Twig-Erweiterungen, die dafür installiert werden müssen:

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

Absolute URLs in einem CLI-Befehl generieren

In E-Mails musst Du URLs mit url() anstatt path() erzeugen, da du absolute URLs, mit Schema und Host, benötigst.

Die E-Mail wird vom Message-Handler im Konsolen-Kontext verschickt. In einem Web-Kontext ist das Erzeugen von absoluten URLs einfacher, da wir das Schema und die Domäne der aktuellen Seite kennen. Im Konsolen-Kontext ist dies nicht der Fall.

Definiere explizit die zu verwendende Domain und das zu verwendende Schema:

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

Die Environment-Variablen SYMFONY_DEFAULT_ROUTE_PORT und SYMFONY_DEFAULT_ROUTE_HOST werden bei Verwendung der symfony-CLI automatisch lokal gesetzt. In der SymfonyCloud werden sie anhand der Konfiguration bestimmt.

Eine Route mit einem Controller verknüpfen

Die review_comment-Route existiert noch nicht, lass uns dafür einen Admin-Controller erstellen:

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,
        ]);
    }
}

Die URL zum prüfen von Kommentaren beginnt mit /admin/ und ist damit durch die im vorherigen Schritt definierte Firewall geschützt. Die Administrator*innen müssen authentifiziert sein, um auf diese Ressource zugreifen zu können.

Anstatt eine Response-Instanz zu erstellen, haben wir die Shortcut-Methode render() verwendet, die von der Controller-Basisklasse AbstractController bereitgestellt wird.

Sobald die Überprüfung abgeschlossen ist, wird den Administrator*innen in einem kurzen Template für deren harte Arbeit gedankt:

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 %}

Einen Mail-Catcher verwenden

Anstatt einen „echten“ SMTP-Server oder einen Drittanbieter zum Senden von E-Mails zu verwenden, nutzen wir einen Mail-Catcher. Ein Mail-Catcher stellt einen SMTP-Server zur Verfügung, der die E-Mails nicht zustellt, sondern über ein Web-Interface zur Verfügung stellt:

 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]

Stoppe die Container und starte sie neu, um den Mail-Catcher hinzuzufügen:

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

Auf E-Mails zugreifen

Du kannst das E-Mail-Interface von einem Terminal aus öffnen:

1
$ symfony open:local:webmail

Oder über die Web-Debug-Toolbar:

Gib ein Kommentar ab. Du solltest anschließend eine E-Mail im E-Mail-Interface zugestellt bekommen:

Klicke auf den E-Mail-Titel im E-Mail-Interface und akzeptiere den Kommentar oder lehne ihn ab, wie Du es für richtig hältst:

Überprüfe die Logs mittels server:log, falls der Versand nicht wie erwartet funktioniert.

Lang laufende Skripte verwalten

Du solltest dir der Verhaltensweisen lang laufender Skripte bewusst sein. Im Gegensatz zum PHP-Modell für HTTP, bei dem jede Anfrage mit einem sauberen Zustand beginnt, läuft der Message-Consumer kontinuierlich im Hintergrund. Jede Behandlung einer Nachricht erbt den aktuellen Zustand, einschließlich des Speicher-Caches. Um Probleme mit Doctrine zu vermeiden, werden die Entity-Manager nach der Abarbeitung einer Nachricht automatisch gelöscht. Du solltest überprüfen, ob deine eigenen Dienste das Gleiche tun müssen oder nicht.

E-Mails asynchron versenden

Das Übertragen der im Message-Handler verschickten E-Mail kann einige Zeit in Anspruch nehmen. Es könnte sogar eine Exception ausgelöst werden. Falls während der Abarbeitung einer Message eine Exception ausgelöst wird, wird diese später erneut in der Queue erscheinen. Aber anstatt zu versuchen, die Message zu erneut konsumieren, wäre es besser, nur den E-Mail-Versand zu wiederholen.

Wir wissen bereits, wie man das macht: Sende die E-Mail-Nachricht über den Bus.

Eine MailerInterface-Instanz nimmt uns diese Arbeit ab: Falls ein Bus definiert ist, verschickt sie die E-Mail-Nachrichten über den Bus, anstatt sie direkt zu versenden. Es sind keine Änderungen an deinem Code erforderlich.

Im Moment sendet der Bus die E-Mail synchron, da wir noch keine Queue (Warteschlange) für das Senden von E-Mails konfiguriert haben. Lass uns wieder RabbitMQ verwenden:

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

Wir verwenden den gleichen Transport-Layer (RabbitMQ) für Kommentar- und E-Mail-Messages, aber das ist nicht zwingend. Du kannst Dich beispielsweise dafür entscheiden, einen anderen Transport-Layer zu verwenden, um mit verschieden priorisierten Messages unterschiedlich umzugehen. Die Verwendung verschiedener Transport-Layer gibt Dir auch die Möglichkeit, dass verschiedene Maschinen unterschiedliche Message-Arten abarbeiten. Die Messenger-Komponente ist flexibel, Du hast die Wahl.

E-Mails testen

Es gibt viele Möglichkeiten, E-Mails zu testen.

Du kannst Unit-Tests schreiben, wenn Du eine Klasse pro E-Mail schreibst (durch Erweiterung der Klasse Email oder TemplatedEmail zum Beispiel).

Allerdings sind die häufigsten Tests, die Du schreiben wirst, Funktionale Tests, die prüfen ob bestimmte Aktionen einen E-Mail-Versand auslösen. Falls der Inhalt der E-Mails dynamisch ist, wird er wahrscheinlich auch geprüft.

Symfony enthält Assertations, die solche Tests erleichtern:

 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);
}

Diese Assertions funktionieren sowohl wenn E-Mails synchron als auch wenn sie asynchron gesendet werden.

E-Mails in der SymfonyCloud versenden

Es gibt keine spezielle Konfiguration für die SymfonyCloud. Alle SymfonyCloud-Konten verfügen über einen Sendgrid-Zugang, welcher automatisch zum Versenden von E-Mails verwendet wird.

Du musst noch die SymfonyCloud-Konfiguration aktualisieren, um die von Inky benötigte xsl-PHP-Erweiterung einzubinden:

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

Bemerkung

Zur Sicherheit werden E-Mails standardmäßig nur im master-Branch verschickt, nicht in anderen Branches. Aktiviere SMTP explizit, wenn Du weißt, was Du tust:

1
$ symfony env:setting:set email on

  • « Previous Schritt 19: Mit einem Workflow Entscheidungen treffen
  • Next » Schritt 21: Performance durch Caching

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