2021/03/23

Sendgridを使ってメールの受信を行う

pythonemail

概要

メールの送信にはSendgridというサービスをよく利用させてもらっています。
サービスの規模が小さい場合には無料で使えるというのも重宝しています。(徐々にサービスが大きくなってきたら有料のプランに切り替えて使っています。)

通常は返信不可のアドレスでメールを送信するため、受信についてはほとんど何も考えない場合が多いのですが

  • メールを受信したら、その受信した内容を閲覧したい。
  • またその後、webアプリから(メールクライアントではない)返信したい。

というような要件が発生したので、調査して検証しました。

Sendgridはメールの送信だけでなく、受信をする方法も提供していて、それがParse Webhookという機能です。

以下のような特徴があります。

メール受信を契機に特定のURLにそのメールの内容をPOSTしてくれる

今回はこちらの機能をローカル環境で検証してみたので、その内容のメモになります。

ちなみにローカル環境はFastAPIというpythonのWebサーバーで書いています。

検証

DNSレコードの設定

メールを受信したいドメインの MX レコードを登録します。

例えば [email protected] のようなドメインのメールアドレスを受信したい場合には mx.sendgrid.net という値をもつ MX レコードを登録しておきます。

HOST TYPE VALUE PRIORITY
your-domain.com MX mx.sendgrid.net 任意(10とか)

Webアプリケーションの実装

  • どのようなデータがリクエストされるかはここが参考になります。
  • こちらのアプリをローカルで起動させます。
    • 例として、 http://localhost:8000/path/to/sendgrid_parse_webhook/ でwebhookを待ち受けるようにします。
    • 本番環境などでは基本認証をサポートしているようなので、基本認証を導入するのが良さそうでした。
from typing import Optional, List, Callable
from fastapi import APIRouter, status, Depends, Form, UploadFile, Response, Request
from fastapi.routing import APIRoute

router = APIRouter()


class CustomSendgridWebhookRoute(APIRoute):
    def get_route_handler(self) -> Callable:
        original_route_handler = super().get_route_handler()

        async def custom_route_handler(request: Request) -> Response:
            body = await request.body()
            # TODO ここでbodyの中身をlogファイルに残しておくと何かあった時に便利だと思います。
            response: Response = await original_route_handler(request)
            return response

        return custom_route_handler


router.route_class = CustomSendgridWebhookRoute


async def get_attachment(request: Request):
    """
    attachmentはattachment1/attachment2/attachment3/...
    のような名前でフォームに入っている。
    FastAPIはこのような枝番が末尾についている形でのリクエストに対応していないので
    以下のようにカスタマイズしてあげる必要があります。
    """
    form = await request.form()
    attachments = form.get("attachments")
    attachment = []
    if attachments:
        attachments = int(attachments)
        if attachments > 0:
            for i in range(attachments):
                attachment.append(
                    form.get(f"attachment{i + 1}")
                )
    return attachment


@router.post("/", status_code=status.HTTP_200_OK)
async def handle_sendgrid_parse_webhook(
        headers: Optional[str] = Form(None),
        envelope: Optional[str] = Form(None),
        subject: Optional[str] = Form(None),
        text: Optional[str] = Form(None),
        html: Optional[str] = Form(None),
        charsets: Optional[str] = Form(None),
        SPF: Optional[str] = Form(None),
        attachments: Optional[int] = Form(None),
        attachment: List[UploadFile] = Depends(get_attachment),
):
    # TODO headers/envelope/text/htmlなどの情報をデータベースに保存します。

    if attachment:
        for file in attachment:
            uploaded_file = file.file
            uploaded_filename = file.filename
            # TODO ここで添付ファイルをS3とかGCSとかに保存します。

    return "OK"

Webアプリをngrokを使って公開

local環境に外から(sendgridのサーバーがローカルに)接続するために、 ngrok を使います。

% ngrok http 8000
https://xxxxxxxxxxxx.ngrok.io -> http://localhost:8000  

これを起動すると、https://xxxxxxxxxxxx.ngrok.io というようなURLの形式でローカル環境に外部からアクセスできるようになります。

SendgridポータルサイトでInboud Parseを設定

Webポータルのメニューから「Settings > Inbound Parse」を選択して、「Add Host & URL」ボタンを選択します。

以下のような設定を追加します。

key value
Subdomain (空欄)
Domain your-domain.com
Destination URL https://xxxxxxxxxxxx.ngrok.io/path/to/sendgrid_parse_webhook/
Check incoming emails for spam 今回はチェックを外しましたが、チェックつけても良いと思います
POST the raw, full MIME message 今回はチェックを外しましたが、チェックつけても良いと思います

上記の設定で、ちゃんと [email protected] にメールを送信するとWebhookが呼び出されて、内容がDBに格納されていることが確認できました。

ここ にあるように

Parse Webhookでは、パースしたメールを指定されたURLにPOSTすることができます。POSTに対して5XXステータスエラーが返された場合、SendGridは自動的にリトライします。また、WebサイトやPOST URLの設定を誤った場合にデータの損失を防ぐため、4XXステータスが返された場合もリトライします。
有効な 2xx HTTP レスポンスを 受け取らなかった 場合、SendGridのサーバは、メッセージの送信に失敗したと考え、送信をし続けます。3日経っても送信できなかったメッセージはドロップされます。

短期的にwebサーバーが落ちてもリカバーできそうなので、そこそこ堅牢に作られていれば使い勝手良さそうです。

References

今回は以上になります。