步骤 20: 向管理员发送邮件

5.0 version
Maintained

向管理员发送邮件

为了保证高质量的反馈,管理员必须管理好所有的评论。当一条评论处于 hampotential_spam 的状态时,一封带有两个链接的 邮件 会发给管理员:一个链接用来接受评论,另一个用来拒绝评论。

首先,安装 Symfony 的 Mailer 组件:

1
$ symfony composer req mailer

为管理员设置邮箱地址

用一个服务容器参数来存储管理员邮箱。出于演示的目的,我们也允许在环境变量里设置它(在“真实场景”中这并不是必需的)。为了便于在需要管理员邮箱的服务中注入这个参数,我们在容器配置的 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

我们可以先“处理”环境变量后再使用它。这里我们使用 default 这个处理器,这样的话,如果环境变量 ADMIN_EMAIL 不存在,就回退到 default_admin_email 参数的值。

发送一封通知邮件

你可以在几个 Email 类的抽象里选择一个来发送邮件:从最低层的 Message 类到最高层的 NotificationEmail 类。很可能你用的最多的是 Email 类,但 NotificationEmail 类是发送内部邮件的最佳选择。

在消息处理器中,我们来替换掉自动验证的逻辑:

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 接口是主入口,可以用它的 send() 方法来发送邮件。

要发送邮件,我们需要一个发送者(用在 FromSender 头里)。我们来对它进行全局定义,而非在单个 Email 实例里设置它。

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

扩展通知邮件的模板

通知邮件模板继承自 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 %}

这个模板通过覆盖一些块来定制邮件内容,也加入了一些链接让管理员可以接受或拒绝一条评论。任何不合规的路由参数都会以查询字符串的形式加在链接中(比如拒绝评论的URL看起来像这样:/admin/comment/review/42?reject=true)。

默认的 NotificationEmail 模板使用 Inky 而非 HTML 来对邮件内容排版。开发兼容所有流行邮件客户端的响应式邮件时,它会很有帮助。

为了最大程度兼容邮件阅读器,通知邮件模板的基础布局默认会使用行内样式(由 CSS inliner 这个包来实现)。

这两个功能都来自可选的 Twig 扩展,我们会安装它:

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

在 Symfony 命令中生成绝对路径

在邮件中,要用 url() 而非 path() 来生成路径,因为你需要绝对路径(即包含了协议和域名的路径)。

消息处理器在控制台上下文中发出邮件。在 web 上下文中,我们知道当前页面的协议和域名,所以生成绝对路径更加容易。但在控制台上下文中却不是这样。

显式定义要使用的域名和协议:

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

当使用 symfony 命令时,SYMFONY_DEFAULT_ROUTE_HOSTSYMFONY_DEFAULT_ROUTE_PORT 这两个环境变量会在本地自动设置;在 SymfonyCloud 上,它们的值根据配置项来自动设置。

把路由接入控制器

review_comment 路由还不存在,我们创建一个用于管理后台的控制器来处理它:

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

审核评论的 URL 以 /admin/ 开头,这样之前定义的防火墙可以将它保护起来。管理员需要认证后才能访问这个页面。

我们使用了 render() 方法,而不是返回一个 Response 实例。render() 方法是 AbstractController 控制器基类提供的一个快捷方法。

当审核完成后,为着管理员的辛勤工作,用一个简洁的模板向他们表达感谢:

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

使用邮件捕获器

我们用邮件捕获器,而不是用一个“真正的” SMTP 服务器或第三方发送服务商来发邮件。邮件捕获器提供一个 SMTP 服务器,但它并不发送邮件,而是让邮件出现在 web 界面:

 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]

关闭并再次启动容器来加入邮件捕获器:

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

访问网页版邮箱

你能在终端里打开网页版邮箱:

1
$ symfony open:local:webmail

或者从 web 调试工具栏中打开:

提交一条评论,然后你应该会在网页版邮箱界面里收到一封邮件:

点开界面里的邮件标题,根据你的判断来接受或拒绝这条评论:

如果这没有按预期工作,用 server:log 来查看日志。

管理长时间运行的脚本

长时间运行的脚本有一些你需要知晓的行为特性。在 HTTP 环境下使用的 PHP 模式里,每个请求都会从一个干净的状态开始,但消息消费者与此不同,它是在后台持续地运行。每次处理消息都会是从当前状态继续,包括内存缓存的状态。为了避免 Doctrine 出现问题,在每次处理完消息后,Doctrine 的实体管理器都需要被清理。你需要去检查下看你自己的服务是否需要这样做。

异步发送邮件

在消息处理器中的邮件可能会要一点时间才能发出去。它甚至可能会抛出异常。如果在处理消息过程中有异常抛出,还会再次尝试发送。但最好只是重试发送邮件,而不是重试消费掉那个评论的消息。

我们已经知道如何做了:在 bus 中发送邮件的消息。

一个实现了 MailerInterface 接口的实例完成了实际的工作:当定义了一个 bus 时,它会分发这个邮件消息,而不是直接发送邮件。你的代码不需要改动。

由于现在我们还没配置用来发送邮件的队列,所以 bus 会同步发送邮件。我们再来用 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

即便我们对评论消息和邮件消息使用了同样的传输(RabbitMQ),但这并不是必须的。比如你可以用不同的传输来管理不同优先级的消息。使用不同的传输可以让你使用不同的服务器来处理不同的消息。它很灵活,控制权在你手里。

对邮件进行测试

有很多方式来测试邮件。

如果你为每个邮件写了一个类(继承自 EmailTemplatedEmail 类),你可以用单元测试。

但你最常写的测试还是功能测试。它们可以用来检查某些动作是否触发了邮件;如果邮件内容是动态生成的话,也可以用来测试这些内容。

Symfony 自带一些断言,让这样的测试变得很容易:

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

不管邮件是同步还是异步发送,都能正常使用这些断言。

在 SymfonyCloud 上发送邮件

SymfonyCloud 上没有特别的配置要做。所有的账户自带一个 SendGrid 账户,它会自动用来发送邮件。

你仍然需要更新 SymfonyCloud 配置,来包含Inky所需的 xsl PHP 扩展:

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

注解

保险起见,默认情况下邮件 仅仅master 分支上发送。如果你知道你所做的意味着什么,你可以启动 SMTP 服务:

1
$ symfony env:setting:set email on

  • « Previous 步骤 19: 用 Workflow 进行决策
  • Next » 步骤 21: 使用缓存提升性能

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