PR TIMESにおけるメールをSendGridで送信するように実装しました

PR TIMESにおけるメールをSendGridで送信するように実装しました
  • URLをコピーしました!

こんにちは、PR TIMESのバックエンドエンジニアのSongです。今回はPR TIMESにおけるメールの一部をSendGridで送信するようにしたことについて紹介します。

目次

背景

PR TIMESにおけるメールはPrTimesMailerというメール送信機能から送信され、Postfixを利用してSMTP経由で外部のメールサービスに送信して、実際の配信は外部のメールサービス経由で行っています。

PrTimesMailerが作成された話については、以下の記事をご覧ください。

あわせて読みたい
PR TIMESにおけるメール送信機能をリファクタリングしました こんにちは、開発本部のソンです。最近、PR TIMESのPHPバージョンアッププロジェクトに参加していて、PR TIMESにおけるメール送信機能のリファクタリングを行いました。...

しかし、PR TIMESがプレスリリース配信サービスである特性上、プレスリリース配信が多い時間帯にはメール送信が集中するため、実際に配信を行っている外部のメールサービスの負荷が増大し、遅延が発生することがあります。その結果、本登録確認メールやパスワード再設定案内メールなど、即時に届く必要があるメールの送信がその時間帯のみ10~15分ほど遅延し、ユーザー体験が低下していました。

外部のメールサービスの負荷軽減と、即時に届く必要があるメールの遅延問題を解決するために、これらのメールの送信方法をSMTPからSendGridメール送信用Web APIに切り替えることにしました。

SendGridは、世界中で広く利用されているメール配信サービスで、SMTP APIやWeb APIを使ってメールを迅速に送信できます。また、直感的な管理画面を提供しており、メール管理が容易です。

その上、弊社ではSendGridのメール送信用Web APIを活用したアプリケーションを既に持っています。これは、メールの情報を含むメッセージをSQSにエンキューするだけでメールを送信できるアプリケーションです。今回、そのままこのアプリケーションを活用できるため、新規にアプリケーションを作成する必要はなく、実装コストを削減することができます。

このアプリケーションの仕組みについては、以下の記事をご覧ください。

あわせて読みたい
SendGridとAWSを使って、メールを送信するアプリケーションを作成しました こんにちは、普段PR TIMES STORY(以下STORY)の開発リーダーをしている岩下(@iwashi623)です。 今回はSQS とLambdaを使って、AWS Fargate上で動作しているLaravelから...

本記事では、即時に届く必要があるメールをこのアプリケーションで送信できるように、どう実装したかや、実装中に直面した課題ことなどについて書きたいと思います。

実装方法

今回、改修するシステムはSendGridでメールを送信するアプリケーションのクライアントとして機能し、メール情報を含むメッセージをSQSにエンキューするだけで簡単にメール送信を行うことができます。

それでは、この仕組みをどのように実装するか、詳しく見てみましょう。

改修前のシステムでは、以下の手順でメールを送信していました。

  1. PrTimesMailerは、呼び出し元からメール送信に必要な情報を持つMailableインスタンスを受け取って、実際に送信を実装するメールトランスポーター(以降MailTransporter)に渡す
  2. MailTransporterはMailableインスタンスを適切にメッセージに変換して、送信を行う

現在、PrTimesMailerにはSMTPでメール送信を実装するMailTransporter(以降SMTP Transporter)のみが使用されています。ただし、設計時に、将来的にSMTP以外のメール送信方法に切り替える可能性を考慮して、その変更や拡張が容易になるようにMailTransporterにインターフェース(以降MailTransporterInterface)を作成しておきました。

既存の設計は以下のような感じです。

そして、ついにメール送信方法を変更する時が来ました。

今回、PrTimesMailerにSendGridでメールを送信するアプリケーション用の新たなMailTransporterを作成することで、同アプリケーションを使用してメールを送信できるようになります。このMailTransporterはMailTransporterInterfaceを実装して、中身はMailableインスタンスをSendGrid Web APIのリクエストボディー情報を含むメッセージへ変換してから、SQSにエンキューする形で実装します。

クラス設計とコード実装

今回のクラス設計は以下のようになっています。緑色が新規作成したクラスやインターフェースとなります。

この設計の中心は、SendGridでメールを送信するアプリケーション用のSendGridTransporterです。このMailTransporterの実装は、次の2つの部分に分かれています。

Mailableインスタンスの変換

クラス設計の左側に位置し、MailableインスタンスをSendGrid Web APIのリクエストボディを含むメッセージに変換する役割を担います。

具体的には、Mailableインスタンスを受け取り、RequestBodyFactoryを通じてRequestBodyのインスタンスを作成します。このRequestBodyは、SendGrid Web APIのリクエストボディの情報(宛先・本文・etc…)を保持するクラスです。JSON形式でリクエストが送信されるため、それをサポートするtoJson()メソッドも実装しました。

RequestBodyのエンキュー

クラス設計の右側に位置し、作成されたRequestBodyをSQSにエンキューする役割を担います。

PHPを利用してPR TIMESからSQSと通信できるように、aws-sdk-phpパッケージを導入し、SqsClientを使用しました。また、SqsMessageQueueにはインターフェースを実装しておきました。これは、将来の拡張や変更を容易にするためで、別のキューサービスに移行する際にも、システム全体を大幅に変更せずに対応できる柔軟性を提供します。

今回、実装したコードは以下のような感じです。なお、RequestBodyFactoryの実装には困ったことがあったので、次のセクションで書きたいと思います。

// 既存のMailTransporterInterace や 新規作成 SendGridTransporter

interface MailTransporterInterface
{
    public function send(MailableInterface $mailable): bool;
}

class SendGridTransporter implements MailTransporterInterface
{
    public function __construct(
        private MessageQueueInterface $messageQueue,
        private string $serverMode = SERVER_MODE
    ) {}
 
    public function send(MailableInterface $mailable): bool
    {
        try {
            $requestBodyFactory = new RequestBodyFactory(
                $mailable, 
                $this->serverMode
            );
            $requestBody = $requestBodyFactory->create();

            $this->messageQueue->enqueue($requestBody->toJson());
        } catch (Exception $e) {
            throw new MailTransporterException(
                $e->getMessage(), 
                $e->getCode(), 
                $e
            );
        }

        return true;
    }
}
/**
 * @see <https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send#request-body>
 */
class RequestBody
{
    /**
     * @param Content[] $content
     * @param Personalization[] $personalizations
     * ...
     */
    public function __construct(
        public string $subject,
        public Address $from,
        public array $content,
        public array $personalizations,
				...
    ) {}
		
    /**
     * JSON形式に変換する
     */
    public function toJson(): string
    {
        $requestBody = [];

        $requestBody['subject'] = $this->subject;
        $requestBody['from'] = $this->from->toArray();

        foreach ($this->content as $content_item) {
            $requestBody['content'][] = $content_item->toArray();
        }

        foreach ($this->personalizations as $personalization) {
            $requestBody['personalizations'][] = $personalization->toArray();
        }

        // ...

        try {
            $json = json_encode($requestBody, JSON_THROW_ON_ERROR);
        } catch (JsonException $e) {
            throw new FailedToJsonEncodeException(
                'Failed to json_encode: ' . $e->getMessage(), 
                $e->getCode(), 
                $e
            );
        }

        return $json;
    }
}
// MessageQueueInterface や SqsMessageQueue

interface MessageQueueInterface
{
    public function enqueue(string $message): void;
}

class SqsMessageQueue implements MessageQueueInterface
{
    public function __construct(
        private SqsClient $sqsClient,
        private string $queueUrl
    ) {}

    public function enqueue(string $message): void
    {
        try {
            $this->sqsClient->sendMessage([
                'QueueUrl' => $this->queueUrl,
                'MessageBody' => $message,
            ]);
        } catch (AwsException $e) {
            throw new FailedToEnqueueException(
                $e->getMessage(), 
                $e->getCode(), 
                $e
            );
        }
    }
}

新しいMailTransporterの使い方

新規作成したSendGridTransporterは以下のコードのように使えます。

PrTimesMailerに注入されるSmtpTransporterをSendGridTransporterでリプレイスすることで、メールをSMTPからSendGridのWeb APIで送信に切り替えることができます。

// SmtpTransporterのインスタンスを作成する
// $smtpTransporter = new SmtpTransporter();

$sqsClient = new SqsClient(['region' => REGION, 'version' => VERSION]);
$messageQueue = new SqsMessageQueue($sqsClient, QUEUE_URL);

// SendGridTransporterのインスタンスを作成する
$sendgridTransporter = new SendGridTransporter($messageQueue);

// SMTPでメールを送信するMailerインスタンスを作成する
//$mailer = new PrTimesMailer($smtpTransporter, $logger);

// SendGridのWeb APIでメールを送信するMailerインスタンスを作成する
$mailer = new PrTimesMailer($sendgridTransporter, $logger);

// Mailableインスタンスを作成(パスワード再設定案内メール)
$mailable = MailableFactory::createPasswordResetGuideMail();

try {
	$mailer->send($mailable); // メール送信を行う
} catch (MailTransporterException $e) {
	// 例外ハンドリング
	$logger->error('Failed to send mail' . $e->getMessage());
}

実装中に直面した課題

今回の実装には以下の課題を直面しました。

弊社ではステージング環境でPostfixを利用して、会社のメールアドレス以外には外部にメール送信を行わない設定しています。これにより、ステージング環境上でお客様のメールアドレス宛にメールが送信されることを防げます。

しかし、今回はメールがSMTPではなく、SendGridのWeb APIで送信されるため、このPostfixの設定は効果がありません。そのため、ステージング環境上でメールが外部に出て、お客様のメールアドレス宛に送信されてしまうので、非常に危険です。

この課題を解決するため、ステージング環境で送信されるメールの宛先やCCを開発用のメールアドレスに上書きすることにしました。

これにより、ステージング環境で送信された全てのメールは開発用のメールボックスに入り、そこで確認できて、お客様宛に直接メールを送信してしまう問題を回避することができました。

さらに、上書き前の宛先やCCはX-Original-ToやX-Original-CCというカスタムヘッダーに追加することで、元の情報を確認できるようにしました。

この解決方法のデメリットとしては、ステージング環境と本番環境の挙動が異なるため、ステージング環境でのみ実行されるコードになってしまいます。しかし、チームと相談した結果、これ以外の良い方法が見つからなかったため、現時点ではこの方法が最善策であると判断しました。

この上書き処理がRequestBodyFactoryで実装しています。コードは以下のような感じです。

class RequestBodyFactory
{
    public function __construct(
        private MailableInterface $mailable,
        private string $serverMode = SERVER_MODE
    ) {}

    public function create(): RequestBody
    {
        if ($this->serverMode !== PRODUCTION) {
            return $this->createForNonProductionEnv();
        }
        return $this->createForProductionEnv();
    }

    private function createForNonProductionEnv(): RequestBody
    {
        [
	        $personalizations,
	        $originalToEmailList,
	        $originalCCEmailList
	    ] = $this->getPersonalizationsWithOverriddenToAndCC();

        $requestBody = new RequestBody($personalizations, ...);

        if (count($originalToEmailList) > 0) {
            $header = new Header('X-Original-To', implode(',', $originalToEmailList));
            $requestBody->addHeader($header);
        }
        if (count($originalCCEmailList) > 0) {
            $header = new Header('X-Original-Cc', implode(',', $originalCCEmailList));
            $requestBody->addHeader($header);
        }

        return $requestBody;
    }
    
    /**
     * ステージング環境の場合は、メールの宛先やCCを開発用のメールアドレスで上書きする。
     * ステージング環境でメールを直接ユーザーに送信しないようにするため
     */
    private function getPersonalizationsWithOverriddenToAndCC(): array
    {
        $personalization = new Personalization();
        $originalToEmailList = [];
        $originalCCEmailList = [];

	    // 宛先の上書き処理を実行
        $originalToList = $this->mailable->getToList();
        for ($i = 0; $i < count($originalToList); $i++) {
            // メールアドレスを開発用のメールアドレスで上書きする
            $address = new Address(
                "devteam_mail{$i}@prtimes.co.jp",
                $originalToList[$i]->display_name
            );
            // 元のメールアドレスを "{上書きしたメールアドレス}->{オリジナルメールアドレス}" の形式で保存する
            $originalToEmailList[] = "{$address->email}->{$originalToList[$i]->email}";
            
            $personalization->addTo($address);
        }

	    // CCの上書き処理を実行
        $originalCCList = $this->mailable->getCC();
        // ...

        return [
            [$personalization],
            $originalToEmailList,
            $originalCCEmailList,
        ];
    }
    
    /**
     * このメソッドは本番環境のみに動く
     */
    private function createForProductionEnv(): RequestBody
    {
        return new RequestBody($this->getPersonalizations(), ...);
    }

    // ...
}

最後に

今回の実装により、PR TIMESの一部のメールをSendGridで送信に移行することができ、SMTPでの送信方法の負荷を減らすことができました。その上、即時に届く必要があるメールの延長など問題を解決することができて、メール配信の信頼性とスピードが向上し、ユーザーの体験も改善されました。

また、PR TIMESにおけるメールはSMTPのみならず、SendGridのWeb APIを利用して送信できるようになったため、柔軟に適切なメソッドを選択し、必要に応じて切り替えることが可能になりました。

We are hiring!

バックエンドエンジニアを含む各種ポジションでの採用を進めています!興味があればぜひご応募ください。

あわせて読みたい
株式会社PR TIMES
02.開発部 の求人一覧 - 株式会社PR TIMES 株式会社PR TIMESが公開している、02.開発部 の求人一覧です
  • URLをコピーしました!

この記事を書いた人

株式会社PR TIMES 開発部 バックエンドエンジニア

目次