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

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

首先,安装 Symfony 的 Mailer 组件:

  1. $ symfony composer req mailer

为管理员设置邮箱地址

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

patch_file

  1. --- a/config/services.yaml
  2. +++ b/config/services.yaml
  3. @@ -4,6 +4,7 @@
  4. # Put parameters here that don't need to change on each machine where the app is deployed
  5. # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
  6. parameters:
  7. + default_admin_email: [email protected]
  8. services:
  9. # default configuration for services in *this* file
  10. @@ -13,6 +14,7 @@ services:
  11. bind:
  12. $photoDir: "%kernel.project_dir%/public/uploads/photos"
  13. $akismetKey: "%env(AKISMET_KEY)%"
  14. + $adminEmail: "%env(string:default:default_admin_email:ADMIN_EMAIL)%"
  15. # makes classes in src/ available to be used as services
  16. # 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. --- a/src/MessageHandler/CommentMessageHandler.php
  2. +++ b/src/MessageHandler/CommentMessageHandler.php
  3. @@ -7,6 +7,8 @@ use App\Repository\CommentRepository;
  4. use App\SpamChecker;
  5. use Doctrine\ORM\EntityManagerInterface;
  6. use Psr\Log\LoggerInterface;
  7. +use Symfony\Bridge\Twig\Mime\NotificationEmail;
  8. +use Symfony\Component\Mailer\MailerInterface;
  9. use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
  10. use Symfony\Component\Messenger\MessageBusInterface;
  11. use Symfony\Component\Workflow\WorkflowInterface;
  12. @@ -18,15 +20,19 @@ class CommentMessageHandler implements MessageHandlerInterface
  13. private $commentRepository;
  14. private $bus;
  15. private $workflow;
  16. + private $mailer;
  17. + private $adminEmail;
  18. private $logger;
  19. - public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, LoggerInterface $logger = null)
  20. + public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, MailerInterface $mailer, string $adminEmail, LoggerInterface $logger = null)
  21. {
  22. $this->entityManager = $entityManager;
  23. $this->spamChecker = $spamChecker;
  24. $this->commentRepository = $commentRepository;
  25. $this->bus = $bus;
  26. $this->workflow = $commentStateMachine;
  27. + $this->mailer = $mailer;
  28. + $this->adminEmail = $adminEmail;
  29. $this->logger = $logger;
  30. }
  31. @@ -51,8 +57,13 @@ class CommentMessageHandler implements MessageHandlerInterface
  32. $this->bus->dispatch($message);
  33. } elseif ($this->workflow->can($comment, 'publish') || $this->workflow->can($comment, 'publish_ham')) {
  34. - $this->workflow->apply($comment, $this->workflow->can($comment, 'publish') ? 'publish' : 'publish_ham');
  35. - $this->entityManager->flush();
  36. + $this->mailer->send((new NotificationEmail())
  37. + ->subject('New comment posted')
  38. + ->htmlTemplate('emails/comment_notification.html.twig')
  39. + ->from($this->adminEmail)
  40. + ->to($this->adminEmail)
  41. + ->context(['comment' => $comment])
  42. + );
  43. } elseif ($this->logger) {
  44. $this->logger->debug('Dropping comment message', ['comment' => $comment->getId(), 'state' => $comment->getState()]);
  45. }

MailerInterface 接口是主入口,可以用它的 send() 方法来发送邮件。

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

patch_file

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

扩展通知邮件的模板

通知邮件模板继承自 Symfony 自带的默认通知邮件模板:

templates/emails/comment_notification.html.twig

  1. {% extends '@email/default/notification/body.html.twig' %}
  2. {% block content %}
  3. Author: {{ comment.author }}<br />
  4. Email: {{ comment.email }}<br />
  5. State: {{ comment.state }}<br />
  6. <p>
  7. {{ comment.text }}
  8. </p>
  9. {% endblock %}
  10. {% block action %}
  11. <spacer size="16"></spacer>
  12. <button href="{{ url('review_comment', { id: comment.id }) }}">Accept</button>
  13. <button href="{{ url('review_comment', { id: comment.id, reject: true }) }}">Reject</button>
  14. {% endblock %}

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

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

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

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

  1. $ symfony composer req "twig/cssinliner-extra:^3" "twig/inky-extra:^3"

在 Symfony 命令中生成绝对路径

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

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

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

patch_file

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

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

把路由接入控制器

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

src/Controller/AdminController.php

  1. namespace App\Controller;
  2. use App\Entity\Comment;
  3. use App\Message\CommentMessage;
  4. use Doctrine\ORM\EntityManagerInterface;
  5. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  6. use Symfony\Component\HttpFoundation\Request;
  7. use Symfony\Component\HttpFoundation\Response;
  8. use Symfony\Component\Messenger\MessageBusInterface;
  9. use Symfony\Component\Routing\Annotation\Route;
  10. use Symfony\Component\Workflow\Registry;
  11. use Twig\Environment;
  12. class AdminController extends AbstractController
  13. {
  14. private $twig;
  15. private $entityManager;
  16. private $bus;
  17. public function __construct(Environment $twig, EntityManagerInterface $entityManager, MessageBusInterface $bus)
  18. {
  19. $this->twig = $twig;
  20. $this->entityManager = $entityManager;
  21. $this->bus = $bus;
  22. }
  23. #[Route('/admin/comment/review/{id}', name: 'review_comment')]
  24. public function reviewComment(Request $request, Comment $comment, Registry $registry): Response
  25. {
  26. $accepted = !$request->query->get('reject');
  27. $machine = $registry->get($comment);
  28. if ($machine->can($comment, 'publish')) {
  29. $transition = $accepted ? 'publish' : 'reject';
  30. } elseif ($machine->can($comment, 'publish_ham')) {
  31. $transition = $accepted ? 'publish_ham' : 'reject_ham';
  32. } else {
  33. return new Response('Comment already reviewed or not in the right state.');
  34. }
  35. $machine->apply($comment, $transition);
  36. $this->entityManager->flush();
  37. if ($accepted) {
  38. $this->bus->dispatch(new CommentMessage($comment->getId()));
  39. }
  40. return $this->render('admin/review.html.twig', [
  41. 'transition' => $transition,
  42. 'comment' => $comment,
  43. ]);
  44. }
  45. }

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

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

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

templates/admin/review.html.twig

  1. {% extends 'base.html.twig' %}
  2. {% block body %}
  3. <h2>Comment reviewed, thank you!</h2>
  4. <p>Applied transition: <strong>{{ transition }}</strong></p>
  5. <p>New state: <strong>{{ comment.state }}</strong></p>
  6. {% endblock %}

使用邮件捕获器

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

  1. --- a/docker-compose.yaml
  2. +++ b/docker-compose.yaml
  3. @@ -8,3 +8,7 @@ services:
  4. POSTGRES_PASSWORD: main
  5. POSTGRES_DB: main
  6. ports: [5432]
  7. +
  8. + mailer:
  9. + image: schickling/mailcatcher
  10. + ports: [1025, 1080]

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

  1. $ docker-compose stop
  2. $ docker-compose up -d

你也必须终止消息消费者,因为它还不知道邮件捕捉器的存在:

  1. $ symfony console messenger:stop-workers

并且重新启动它。现在 MAILER_DSN 会被自动暴露出来:

  1. $ symfony run -d --watch=config,src,templates,vendor symfony console messenger:consume async
  1. $ sleep 10

访问网页版邮箱

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

  1. $ symfony open:local:webmail

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

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

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

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

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

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

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

管理长时间运行的脚本

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

异步发送邮件

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

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

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

由于现在我们还没配置用来发送邮件的队列,所以 bus 会同步发送邮件。我们再来用 RabbitMQ:

patch_file

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

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

对邮件进行测试

有很多方式来测试邮件。

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

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

Symfony 自带一些断言,让这样的测试变得很容易,这里有一个测试的例子来演示一些可能性:

  1. public function testMailerAssertions()
  2. {
  3. $client = static::createClient();
  4. $client->request('GET', '/');
  5. $this->assertEmailCount(1);
  6. $event = $this->getMailerEvent(0);
  7. $this->assertEmailIsQueued($event);
  8. $email = $this->getMailerMessage(0);
  9. $this->assertEmailHeaderSame($email, 'To', '[email protected]');
  10. $this->assertEmailTextBodyContains($email, 'Bar');
  11. $this->assertEmailAttachmentCount($email, 1);
  12. }

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

在 SymfonyCloud 上发送邮件

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

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

patch_file

  1. --- a/.symfony.cloud.yaml
  2. +++ b/.symfony.cloud.yaml
  3. @@ -4,6 +4,7 @@ type: php:7.4
  4. runtime:
  5. extensions:
  6. + - xsl
  7. - pdo_pgsql
  8. - apcu
  9. - mbstring

注解

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

  1. $ symfony env:setting:set email on

深入学习


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