ステップ 20: 管理者へメールを送信する

5.0 version
Maintained

管理者へメールを送信する

良いフィードバックをするために、管理者はすべてのコメントをモデレートする必要があります。コメントが hampotential_spam の状態であったなら、 メール が管理者へ送られるようにします。そして、そのメールには、コメントを受理するか拒否するかの2つリンクを入れるようにします。

まず、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

環境変数が使用される前に "処理された"状態になるかもしれません。ここでは、ADMIN_EMAIL の環境変数が存在しなければ、 デフォルト のプロセッサーを使用して default_admin_email パラメーターの値にフォールバックするようにします。

通知メールを送信する

メールを送信する際に、低レベルの Message や高レベルの NotificationEmail といった、いくつかの Email クラスのアブストラクションから選ぶことができます。ほとんどの場合、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()]);
         }

MainInterface() は、メール送信のエントリーポイントで、 send() メソッドを使用してメールを送ることができるようになっています。

メールを送信するには、 センダー(From/Sender ヘッダー)が必要です。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 のデフォルトのテンプレートは、メールを装飾するのに HTML ではなく、 Inky を使用します。Inky は、ほとんどのメールクライアントに互換性のあるレスポンシブなメールを作ってくれます。

メールリーダーへの互換性のため、通知メールのベースのレイアウトは、デフォルトで全てのスタイルシートをインラインにします(CSSインライナーパッケージが使われます)。

これらの2つの機能は、Twig拡張のオプショナルな機能で、別にインストールする必要があります:

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

コマンド内で絶対 URL を生成する

メールにおいては、 path() の代わりに url() を使用して、スキームやホストなどの情報も入っている絶対 URL を生成してください。

コンソールのコンテキストで、メッセージハンドラーからメールが送られます。Web のコンテキストでは、現在のページのスキームやドメインがわかるので絶対 URL を生成するのは簡単ですが、コンソールのコンテストはそうはいきません。

ドメイン名とスキームを明示的に定義する

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_DEFAULT_ROUTE_HOSTSYMFONY_DEFAULT_ROUTE_PORT の環境変数は、symfony CLI では自動的にローカルに設定されます。また、SymfonyCloud の際は設定をベースに指定されます。

コントローラーへのルートをワイヤーする

review_comment ルートはまだ作成していないので、Admin Controller でハンドルするように作成しましょう:

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/ から始まり、前のステップで定義したファイアーウォールで保護されます。管理者は、このリソースへアクセスするのに認証が必要です。

Response インスタンスを作成するのではなく、ベースクラスの AbstractController にある render() メソッドを使用しました。

レビューが終わったら、簡単なテンプレートで管理者へ感謝しましょう:

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

Webメールへアクセスする

ターミナルから Web メールを開くことが可能です:

1
$ symfony open:local:webmail

Webデバッグツールバーからも可能です:

コメントを投稿すると、Webメールのインターフェースで、メールを受け取るはずです:

Webメールのインターフェースからメールのタイトルをクリックして、コメントを受理もしくは拒否してみましょう:

期待どおりに動作しない場合は、server:log でログをチェックしてください。

長時間実行するスクリプトを管理する

長時間実行されるスクリプトには気をつけるべきです。HTTP で使われる PHP のモデルではリクエストはクリーンな状態から開始されますが、メッセージの取得実行は、バックグラウンドで継続的に実行されます。メッセージを処理する度に、メモリキャッシュを含む現在の状態の影響を受けます。Doctrine の問題を避けるために、エンティティマネージャーは、メッセージのハンドリングの後に自動的にクリアされます。あなたの実装するサービスが同じようにクリアすべきか否かを確認してください。

非同期にメールを送信する

メッセージハンドラーに送られたメールの送信は時間がかかることもあります。また、例外が投げられるかもしれません。メッセージを処理しているときに例外が投げられたときには、リトライを行います。 しかし、コメントメッセージの取得実行をリトライするのではなく、メールの送信のみをリトライした方がより良いです。

すでにこのやり方は知っているはずです。メッセージバスにメール・メッセージを送信してください。

MailerInterface のインスタンスは、次の処理を行います。メッセージバスが定義されていたら、メールを送るのではなく、メール・メッセージをディスパッチします。コードを修正する必要はありません。

しかし、まだメール送信でキューを使用するように設定していないので、このままではメッセージバスがメールを同期的に送信してしまいます。また 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 のような同じトランスポートを使用していますが、異なるようにすることも可能です。たとえば、メッセージの優先度を管理するために別のトランスポートを使うことも可能です。別のトランスポートを使用すると、異なるワーカーマシンでメッセージを処理することも可能になり、とても柔軟です。

メールをテストする

メールをテストするには複数の方法があります。

各メール毎のクラスを作成するのであれば、ユニットテストを書くことが可能です(Email, TemplateEmail を拡張することで)。

ここで書くテストのほとんどは、アクションがメールをトリガーするかをチェックしたり、メールの内容の確認の機能テストになります。

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 のアカウントが付いてくるので、メール送信の際には、自動的にそのアカウントが使用されます。

Inky で使用する PHP エクステンションの xsl をインクルードするように、SymfonyCloud の設定を変更する必要があります:

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 ブランチのみで送られます。 master ブランチ以外で送るには SMTP を明示的に有効化してください:

1
$ symfony env:setting:set email on

  • « Previous ステップ 19: ワークフローを使って判定する
  • Next » ステップ 21: パフォーマンス向上のためにキャッシュする

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