SendGridでお手軽メール受信サーバーを構築する
導入
こんにちは。
CastingONE開発部でエンジニアとして働いております Hamoro と申します。
CastingONEでは、非正規雇用の採用担当者さん向けのサービスを展開しております。そのサービスの数ある機能の一つに、ユーザー(採用担当者)さんが求職者と直接コミュニケーションを取ることが出来るものがあります。そのコミュニケーション手段には、LINE と メール があり、そのメール機能に関して今回 SendGrid を利用して実装しました。
実際の機能は以下のようなイメージとなっています。
元々、大勢の求職者に対して求人を配信するために SendGrid はサービスに組み込んでいました。しかし、メールを受信するための機能はもちろん使っておらず、ドキュメントを見つつ検証しながらの実装となりました。
メール受信サーバーと言っても、実態は「SendGrid にメールが届いたら指定したエンドポイントにWebhookリクエストを飛ばしてもらう」というもので、仕組みとしては非常にシンプルです。本記事ではその構築手順や機能についてまとめてみました。誰かの参考になれば幸いです。
Inbound Parse Webhook
SendGrid でメールを受信するための機能は、Inbound Parse Webhook という名前です。
設定したドメインに送られてきたメールを SendGrid が代わりに受信し、自動的に本文・添付ファイル・ヘッダーを解析した上で、指定した自分たちのサービスのエンドポイントにPOSTしてくれるというもので、自分たちでメールサーバーを構築・運用する手間を削減することができます。
必要な設定
Inbound Parse Webhook を利用するためには、大きく分けて以下の 3 つの設定が必要になってきます。
- Sender Authentication ( Domain Authentication )
- Set up an MX Record
- Inbound Parse Setting
Sender Authentication ( Domain Authentication )
その名の通り、Sender(送信者)を Authenticate(認証)するための設定です。より具体的には送信元ドメインを所有していることを受信側のプロバイダーに証明することで、確実にメールが届くようにするために設定します。
Sender Authentication には、Domain Authentication と Single Sender Verification の 2 種類がありますが、Domain Authentication が Recommended になっているのでそちらを設定します。
設定内容としては、DMARC、DKIM、SPF 用に必要な DNS レコードを登録します。これらを設定することで、受信者のメールクライアントで「sendgrid.net 経由」というメッセージが表示されなくなったり、レピュテーションやメールの到達率を高めることができます。
Set up an MX Record
メールを受け取るドメインに対して、MX レコードを設定する必要があります。指定するドメインは SendGrid の Inbound Parse だけに用途を絞るべきです。
実行する設定としては、MX レコードに優先度 10 を割り当て、mx.sendgrid.net
というアドレスを指定します。例えば、parse.yourdomain.com
というサブドメインを利用する場合は以下のようになります。
Inbound Parse Setting
ここでは、Sender Authentication が完了しているホストに対して、Webhook のリクエスト先をマッピングすることができます。1 つのホストに複数のリクエスト先を割り当てることはできません。必ずホストはユニークになっている必要があります。Sender Authentication が設定されていない場合、この設定には失敗するので注意が必要です。
GUI を操作して設定することもできますが、API も提供されているのでアプリケーションに組み込んで設定フローを構築することができます。
ローカルで試してみる
実際にどのような Webhook リクエストが飛んでくるのか確認してみたいと思います。確認方法としては、ローカルで立てたサーバーを cloudflared
や ngrok
のようなツールを使って公開されたエンドポイントを用意し、そこに向かって飛ばすように設定してみます。
$ cloudflared tunnel --url localhost:8080
あとは、Inbound Parse を設定し、そのホストにメールを送信するだけです。メールを送信してみると、multipart/form-data
の形式で送られてきていることが確認できるはずです。
リクエスト内容の全容はこちら(一部削除・加工済み)
--xYzZY
Content-Disposition: form-data; name="html"
<div dir="ltr">test</div>
--xYzZY
Content-Disposition: form-data; name="attachments"
0
--xYzZY
Content-Disposition: form-data; name="headers"
Content-Type: multipart/alternative; boundary="000000000000ad97d006195b5d90"
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=xxxxxxxx-xxx-xx.20230601.gappssmtp.com; s=20230601; t=1716730065; x=1717334865; darn=xxxx.xxxx.xxx; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject :date:message-id:reply-to; bh=Z9DRipTI/+RJF+7SFdl+F2bKFkdFt7wDzU/1a3Ja/PI=; b=QG8L9OfafafoZQLFfBSVnVlnnlxrfaYxuLMYV5dry/GvLhdUp7Ki+afafpXz1oL+/DcC50b10pC 2BjN9oSOlIfafawPdn5UZYJW5XW/xMtUrzNZlpMJrqelJ1h0XWhP5ub3RrI+8ycxQZJ9jUCZ tlFm6dnLU1fuqDtVgbXP+jFKBKLZbib8UyohN2yFwdWPZrK3kiC/4Fyv8O3XPikuhg2I fafaafvqxT1/8KAKHipOhNWH66qNfafadEhCGc7VUVn2gUe8iPHjaioauk7Nk6RgSS5xJSYKU6D6ITipC1G4 SqHA==
Date: Sun, 26 May 2024 22:27:08 +0900
From: xxxx.xxxx <xxxx.xxxx@xxxx.xxxx.xxxx>
MIME-Version: 1.0
Message-ID: <CAPtcbzhiJVOaaaU3aa=Frd=42ODMjGk+2yfffoNL23XBV0fafaO4+rTT0yLQvDw@mail.gmail.com>
Received: from mail-lf1-f52.google.com (mxd [xxx.xxx.xxx.xxx]) by mx.sendgrid.net with ESMTP id oQb6Ci1cS7KEZGSSdHFqwg for <xxxxx@xxxx.xxxx.xxx>; Sun, 26 May 2024 13:27:46.812 +0000 (UTC)
Received: by mail-lf1-f52.google.com with SMTP id 2adb3069b0e04-5295ae273c8so2493395e87.0 for <xxxxx@xxxx.xxxx.xxx>; Sun, 26 May 2024 06:27:46 -0700 (PDT)
Subject: test
To: xxxxx@xxxx.xxxx.xxx
X-Gm-Message-State: AOJu0YyZ2xp0ifafaAv9oXTb6BOcWWRfafa9I12dFVyzLxfafa2YdiRoGdih5j+ HdVIhUFO9XfafafSwMZlDAxQfaafawf98qG98xc7afaaf1fafaEPBfxYBbo64fafaY+f3Iu0wTXoSP+TdtRkSnjlhwjwA/8 65ZSg2QhcST/UomFcnmNEZC0yBTt2DOJIQd18YoI4pCXl/dcy
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1716730065; x=1717334865; h=to:subject:message-id:date:from:mime-version:x-gm-message-state :from:to:cc:subject:date:message-id:reply-to; bh=Z9DRipTI/+RJF+7SFdl+F2bKFkdFt7wDzU/1a3Ja/PI=; b=FEbhdcvQk69k0+U4GaWIPoEe0JfffarFBlCAx8HNPjpC1U4Kq1N3ny8wQfUy1/u5 zaLxSXImk/WeF2gLtCUHN+F8dflS3ggOdwzGnipSiLqS0QjJw9MgCtQT8tOcxh/6XnCi hntDLbcKiutdCenbJn06PM3Ns/eWVcHBYeeEN0GJd7TjfxMVbBq0+ZFqXPoiubxwmdWY FeMhPV3eJyYMYPMyq7kRDMs+VG24sR6I2t1BNn09rfcWVi/LszY+HxrkMmDWiin4v45z sI+FdUWYLlI0muhvuvicGRGWme2CFaOLdyhlTJYOv35Ix9Vp3pH2Xy02dskjDEpcq7bY IvlQ==
X-Google-Smtp-Source: AGHT+IGxkqyP25fga5PlXSI82gvDzJKc4ZuMZKPZ6M0dk15tsO2QcsAsInYrwyb1xo5WY=
X-Received: by 2002:a33:532a:1:b0:112:a2f0:4949 with SMTP id 2adb306-542mr472764e87.40.1716730064631; Sun, 26 May 2024 06:27:44 -0700 (PDT)
--xYzZY
Content-Disposition: form-data; name="charsets"
{"to":"UTF-8","from":"UTF-8","subject":"UTF-8","text":"utf-8","html":"utf-8"}
--xYzZY
Content-Disposition: form-data; name="dkim"
{@xxxxxxxx-xxx-xx.20230601.gappssmtp.com : pass}
--xYzZY
Content-Disposition: form-data; name="sender_ip"
xxx.xxx.xxx.xxx
--xYzZY
Content-Disposition: form-data; name="to"
xxxxx@xxxx.xxxx.xxx
--xYzZY
Content-Disposition: form-data; name="text"
test
--xYzZY
Content-Disposition: form-data; name="SPF"
softfail
--xYzZY
Content-Disposition: form-data; name="from"
xxxx.xxxx <xxxx.xxxx@xxxx.xxxx.xxxx>
--xYzZY
Content-Disposition: form-data; name="subject"
test
--xYzZY
Content-Disposition: form-data; name="envelope"
{"to":["xxxxx@xxxx.xxxx.xxx"],"from":"xxxx.xxxx@xxxx.xxxx.xxxx"}
--xYzZY--
その他の仕様について
Inbound Parse Webhook の細かい仕様についても軽く触れておきたいと思います。詳細についてはドキュメントをご参照ください。
リトライ制御
アプリケーションでハンドリングできないエラーが発生することは往々にしてあり得ます。アプリケーション側がタイムアウトしてしまったとか、一時的にネットワークエラーが起きた、とかがその例です。その際に、Webhook エンドポイントのレスポンスステータスとして 500 番台が返却されると、自動的にリトライをしてくれる機能が備わっています。そのため、リトライされたくない場合は、200 番台を返す必要があります。
ちなみに、リトライは一生されるわけではなく、再送間隔を調整しながら最大 3 日間試行されるという挙動になっているようです。
文字コード
基本的にはほとんどのメーラーは UTF-8 で取り扱っています。しかし、メーラーが古かったり、自由に文字コードを設定できるメーラーだと、UTF-8 以外の文字コードに設定して送信することができます。そのため、そのあたりを考慮せず実装してしまうと画面に表示した時に文字化けが起きてしまう可能性があります。
SendGrid は、to
や from
ヘッダーなどは UTF-8 に直してくれるようになっています。ただ、本文フィールドに関しては、UTF-8 になっていない場合もあるので、文字コードを見て適宜変換する対応が必要です。
例えば、日本語メールで利用される iso-2022-jp
という文字コードだった場合、以下のような JSON を charsets (前述した、multipart/form-data
のフィールドの一つです) から受け取ることができます。
{
"to":"UTF-8",
"from":"UTF-8",
"subject":"UTF-8",
"text":"iso-2022-jp",
"html":"iso-2022-jp"
}
開発してみて学んだこと
今回メール機能を実際してみて初めて知ることが数多くありました。その中でもスレッドの判定について学びがあったので簡単にまとめてみたいと思います。詳細に書きすぎると長くなってしまうので、大分乱暴にまとめています。
スレッド判定
Gmail をイメージしていただくのがわかりやすいですが、関連のあるメッセージをグルーピングされており、それぞれがスレッドのような UI になっています。弊社サービスの UI でもスレッドを表現したいとなった時に、グルーピングの判定、つまり同じスレッドのメッセージかどうかをどのように判定するのかというのが重要でした。
結論、メールヘッダーに In-Reply-To
と References
フィールドがあり、これらが返信の際に使用され、メッセージ同士の関連性を表しています。より厳密には、In-Reply-To
フィールドは、新しいメッセージの宛先となるメッセージを識別するために使われる一方で、References
フィールドは、会話のスレッドを識別するために使われます。この仕様についての詳細は、RFC5322 3.6.4 に記載されています。
例
仮に、A ( aaa@exmple.com ) と B ( bbb@example.com ) の間でやり取りをするとします。
1. A が B にメールを新しく送信
この場合のヘッダーはこうなっているはずです。(必要なもの以外は割愛してます)
From: A <aaa@exmple.com>
Message-ID: <111@exampl.com>
Subject: B さんこんにちは
To: B <bbb@example.com>
2. B が A に返信します
返信する場合、返信の対象のメッセージが存在しています。その場合、返信対象のメッセージの Message-ID
が In-Reply-To
フィールドと References
フィールドに含まれます。こうすることで、メッセージはどのメッセージに対するものなのかであったり、どのメッセージ群が一連のやりとりなのかを識別できるようになります。
From: B <bbb@example.com>
Message-ID: <222@example.com>
Subject: Re: B さんこんにちは
To: A <aaa@example.com>
In-Reply-to: <111@example.com>
References: <111@example.com>
3. A が 再度 B に返信します
その場合は以下のようになります。何回かやり取りが継続している場合は、References
フィールドに Message-ID
が追加されていきます。こうすることで、どのメッセージから始まっているのかということを辿れるようになっているわけです。
From: A <aaa@exmple.com>
Message-ID: <333@exampl.com>
Subject: Re:Re: B さんこんにちは
To: B <bbb@example.com>
In-Reply-to: <222@example.com>
References: <111@example.com> <222@example.com>
Gmail の場合
RFC で定義されているものの、各メーラーごとに仕様が違っていたりする場合が結構あると感じました。そのため、実際に操作してみて検証することが重要です。例えば、Gmail ですと以下のように定義されています。
メール通知をグループに自動送信するシステムを管理している場合、そのグループは自動的に同じスレッドにグループ化されます。
各メールが次の条件を満たしている場合、メールはグループ化されます。
- 受信者、送信者、件名が以前のメールと同じである
- 参照ヘッダーの ID が以前のメールと同じである
- 以前のメールから 1 週間以内に送信されている
このことは、ヘッダー上は関連性があるように見えたとしても、実際の UI が必ず同一のスレッドになっているわけではないとも解釈できます。Gmail でこうなっているからといって、Outlook も同じとは限りません。docomo のようにスレッド自体を UI として表現しないものもあります。UI に関してはメーラーごとに独自のロジックを持っていることが予想されるのでその点はご留意ください。
最後に
弊社ではメンバーを大募集しています。
カジュアル面談もやってますので、お気軽にご連絡ください!
Discussion