はじめに
Amazon SES では、メール送信に加えて、メールを受信する機能があります。メール受信といっても、通常のメール受信のように Outlook や Thunderbird で利用する IMAP / POP というわけではなく、Lambda 関数などを利用した自動化のための機能です。IMAP などの通常のメールサーバーを利用したい場合は、Amazon WorkMail の利用を検討頂くといいかもしれません。
SES でメールを受信するときに、メールの本文やファイル添付を使ってなんらかの自動化を取り組みたいときがあります。今回の記事では、SES で受信したメールから、本文とファイル添付を抜き出して、S3 に保存する手順を確認してみる内容となっています。
構成図
今回の検証記事の構成図はこんな感じです。
S3 を利用する理由は、SES から直接 Lambda を起動すると、メールの本文や添付ファイルを受け取ることができませんでした。一度、S3 や SNS を経由すると、うまく受け取ることが出来たので、今回は S3 を採用しています。
S3 の裏にある Lambda では、好きなように実装が出来るので、例えば DynamoDB にメールデータを格納しておくことも出来ます。
前提条件
既に SES 上でドメイン認証が済んでいる状態です。
MX レコードの作成
SES でメールを受信するためには、該当のドメインで MX レコードを作成し、SES の受信用エンドポイントを指定する必要があります。詳細は次の Document を参照してください。
既にメールサーバーが存在していて、MX レコードの上書きが出来ない場合は、新たなサブドメインの利用も検討できます。この記事の環境では、MX レコードは存在していないので、Route 53 で新規作成していきます。
Create Record を押します。
利用するリージョンに合わせて、エンドポイントを変更します。Virginia は次のエンドポイントになります。
10 inbound-smtp.us-east-1.amazonaws.com
作成できました
SES でメール受信設定
SES でメールを受信するためのルールを設定していきます。SES の画面の中で、Create rule set を押します。
適当に名前を指定します。
メールを受信するルールを設定していきます。Create rule を押します。
名前を入れて、Next を押します。
受信したいメールアドレスを指定します。SES で認証済みのドメインのなかから、適当な名前を指定します。
receive@sugiaws.tokyo
上記のように特定のメールアドレスだけ受信することもできますし、ドメインすべてのメールを受信する、といった設定も可能です。次のガイドラインの設定例が参考にできます。
Action を設定する欄で、Deliver to S3 bucket を指定します。受信したメールのデータを、S3 に格納するアクションです。
保存する S3 バケットを指定して、Next を押します。S3 のイベント通知がやりやすいように、prefix も指定しておきます。
ses-inbound-bucket-sugi01
Create rule を押します。
作成したあとは inactive 状態なので、Set as active を押します
Set as active を押します。
メールを SES に送信
ここまでの設定で、送信したメールが無事に S3 に保存されるか確認します。Gmail で適当にメールを送信します。
S3 上にファイルが保存されています。この中にメール本文や添付ファイルが含まれています。
Lambda 関数作成
S3 に保存されるメールデータの中から、メールの本文と添付ファイルを抜き出す処理を行います。S3 にメールデータが置かれたことをイベント通知で検知して、Lambda 関数を起動します。次の Python コードで、Lambda 関数を適当に作成します。
行っている内容は、ざっくり以下の通りです。
- S3 イベント通知をトリガーに Lambda 関数を起動する
- Lambda 関数は、新たに格納されたメールデータの格納パスを
event
から受け取る - S3 からメールデータをダウンロード (
get_object
) する - Python の email ライブラリをつかって、メール本文や添付ファイルを取り出す
- 取り出したメール本文や添付ファイルは、S3 に格納する
例外処理など雑な実装になっているので、以下のコードを参考に利用する場合は、丁寧に動作確認しながら実装改善をお願いします。
import boto3
import base64
import email
import urllib.parse
import json
from logging import getLogger, INFO
logger = getLogger(__name__)
logger.setLevel(INFO)
print('Loading function')
s3 = boto3.resource('s3')
def lambda_handler(event, context):
print("============ logger.info の出力 ============")
logger.info(json.dumps(event))
# Get the object from the event and show its content type
bucket = event['Records'][0]['s3']['bucket']['name']
key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'], encoding='utf-8')
messageid = key.split("/")[1] # key が [ses-output/5kavdqn4o74qn2l7957ut940t04re7kubpuuvk81] といったデータになるので、スラッシュ以降を抽出する
try:
response = s3.meta.client.get_object(Bucket=bucket, Key=key)
email_body = response['Body'].read().decode('utf-8')
email_object = email.message_from_string(email_body)
body = ""
body_html = ""
body_text = ""
for part in email_object.walk():
# ContentTypeがmultipartの場合は実際のコンテンツはさらに中のpartにあるので読み飛ばす
print("maintype : " + part.get_content_maintype())
if part.get_content_maintype() == 'multipart':
continue
# ファイル名の取得
attach_fname = part.get_filename()
print(attach_fname)
# ファイル名がない場合は本文のはず
if not attach_fname:
charset = str(part.get_content_charset())
if charset:
body += part.get_payload(decode=True).decode(charset,
errors="replace")
else:
body += part.get_payload(decode=True)
if part.get_content_type() == "text/html":
body_html += body
elif part.get_content_type() == "text/plain":
body_text += body
else:
# メールフォルダ内のfileディレクトリに添付ファイルを保存する
attach_data = part.get_payload(decode=True)
bucket_source = s3.Bucket(bucket)
bucket_source.put_object(ACL='private', Body=attach_data,
Key='file' + "/" + attach_fname, ContentType='text/plain')
# 本文を text ディレクトリに保存する
bucket_source = s3.Bucket(bucket)
bucket_source.put_object(ACL='private', Body=body_text,
Key='text' + "/" + messageid + ".txt", ContentType='text/plain')
return 'end'
except Exception as e:
print(e)
print('Error getting object {} from bucket {}. Make sure they exist and your bucket is in the same region as this function.'.format(key, bucket))
raise e
S3 イベント通知で Lambda を起動する設定を入れる
対象の S3 バケットにメールデータが置かれたことを検知するために、イベント通知設定をします。
イベントの名前と、対象の Prefix を指定します。SES 側のアクションで指定した Prefix と一致させます。
新規ファイル作成をトリガーに Lambda を起動させたいので、Put を指定します。
作成した Lambda 関数を指定します。
メールにファイルを添付して送信
ここまでで設定が完了しました。実際に、SES に対して添付ファイル付きのメールを送ってみます。
すると、SES に送られたメールデータから、Lambda 関数を経由して、メール本文が抜き出されて text
ディレクトリにテキストファイルとして出力されています。実際にdownloadしてみましょう。
このように、メール本文のデータを抜き出すことが出来ました。
同様に、Lambda 関数の中で、添付ファイルも抜き出して S3 に保存しています。こちらは、file
ディレクトリの中に出来上がっています
実際の画像のデータを確認できました。
検証を通じてわかったこと
- メール受信を利用できるリージョンが、US East (N. Virginia)・US West (Oregon)・Europe (Ireland) の3つ
- 東京リージョンや大阪リージョンで SES を使ったメール受信は出来ない
- https://docs.aws.amazon.com/ja_jp/ses/latest/dg/regions.html#region-receive-email
- メール受信に使用するメールアドレスは、認証済みのドメインで好きなメールアドレスを指定可能
- メール受信の時に、スパムの検証やウイルススキャンを行ってくれる機能がある。スキャンした結果、メール受信を拒否するのではなく、検出した結果を SNS や S3 上で確認できる。FAIL になったときの挙動は、ユーザー側でハンドリングする。
- ドメインで MX レコードを作成する必要があるため、既に社内メールサーバーなどで MX レコードを作成している場合は、上書きしてしまうことになる。上書きしたくない場合は、新たにサブドメインの追加を検討すると良さそう。
- SES から直接 Lambda 関数を起動したとき、Lambda 関数はメールの本文を受け取ることが出来ない。メールの本文を取得したい場合は、先に S3 に保存するルールを定義すると良い。S3 イベント通知設定を行い、その後の Lambda 関数を起動してメール本文や添付ファイルを読み取ると
- SES から S3 アクションを指定すると、多目的インターネットメール拡張 (MIME) 形式の、変更を加えていない raw E メールを格納する文字列が保存される。
- Python ではなく、Node.js だと、素敵なメールのモジュールがあるらしい。そのうち試してみたい。
Appendix : SES から Lambda を直接呼び出した時に受け取るイベント
SES から直接 Lambda を呼びだしたときに、Lambda 関数が受け取る Event です。次のように gmail から送付すると、
以下のイベントを受信できます。メールのタイトルは取得できるが、メールの本文は取得できないです。なので、メール本文を取得したい場合は、今回の記事のように S3 や SNS などを経由して取得する必要があります。
{
"Records": [
{
"eventSource": "aws:ses",
"eventVersion": "1.0",
"ses": {
"mail": {
"timestamp": "2022-07-22T09:15:01.161Z",
"source": "masked@gmail.com",
"messageId": "tcgk3ak3dlhjfcekj81pd2humpbuigtovo981fg1",
"destination": [
"receive@sugiaws.tokyo"
],
"headersTruncated": false,
"headers": [
{
"name": "Return-Path",
"value": "<masked@gmail.com>"
},
{
"name": "Received",
"value": "from mail-pj1-f47.google.com (mail-pj1-f47.google.com [209.85.216.47]) by inbound-smtp.us-east-1.amazonaws.com with SMTP id tcgk3ak3dlhjfcekj81pd2humpbuigtovo981fg1 for receive@sugiaws.tokyo; Fri, 22 Jul 2022 09:15:01 +0000 (UTC)"
},
{
"name": "X-SES-Spam-Verdict",
"value": "PASS"
},
{
"name": "X-SES-Virus-Verdict",
"value": "PASS"
},
{
"name": "Received-SPF",
"value": "pass (spfCheck: domain of _spf.google.com designates 209.85.216.47 as permitted sender) client-ip=209.85.216.47; envelope-from=masked@gmail.com; helo=mail-pj1-f47.google.com;"
},
{
"name": "Authentication-Results",
"value": "amazonses.com; spf=pass (spfCheck: domain of _spf.google.com designates 209.85.216.47 as permitted sender) client-ip=209.85.216.47; envelope-from=masked@gmail.com; helo=mail-pj1-f47.google.com; dkim=pass header.i=@gmail.com; dmarc=pass header.from=gmail.com;"
},
{
"name": "X-SES-RECEIPT",
"value": "AEFBQUFBQUFBQUFHQVN2T2Y1REhJTmkwQVBNSlpudjkxV1FIenZ5bTJCdVdFT1dWZGJPNld3cHlOeWl3VmxKdTVYSmxFRnRtNk9ScGlpUlIxZ25ORFZLYTRZR3RBT2R6MlFEemVBV2xZRjg0TG9VRG53TFFWTkVkTEVjTFhxbGd6alVqMXZWNEEzLzBQbzVoVzR4dW05ZWxSVXpVN0hNWm9uclhIS0NIZWNSTGd3QmRrQ1UxdmNWcjFRYjF6NVRwOGM5TjVoVEk5V1Y2dW9KQnpuT3V1RHV2dGpMM09KT095eUlrQnpyWlJiQ2MxVjFRbnRMeWYxZVdjai9NeVovU0QvVGhjblRjYmF1c3BqazRCRndDcVJhelFYRS9TU3ZlWk5ZblhPbFdDejBaM3RCSG9PRmw1WUE9PQ=="
},
{
"name": "X-SES-DKIM-SIGNATURE",
"value": "a=rsa-sha256; q=dns/txt; b=iQaBIraTkcVTmfd41pGq2FDRkHOACThePmzT2eJK+50TVm9I+EW3isXezB457+Krf0bAjdzBTUP1Zy51a9tGi/HSNzkL+23O1FiJjEG+CiRdmTfZ/sGikp9SysQRQ7H/MtL8sc26dgpNzYA2bbNGCy7OUijSff9q4zMu66IsgKQ=; c=relaxed/simple; s=6gbrjpgwjskckoa6a5zn6fwqkn67xbtw; d=amazonses.com; t=1658481301; v=1; bh=rSPPeK7RiIcyMjTDN3561J9SfRjOCCgYkfdVW1vq0d0=; h=From:To:Cc:Bcc:Subject:Date:Message-ID:MIME-Version:Content-Type:X-SES-RECEIPT;"
},
{
"name": "Received",
"value": "by mail-pj1-f47.google.com with SMTP id p6-20020a17090a680600b001f2267a1c84so5948506pjj.5 for <receive@sugiaws.tokyo>; Fri, 22 Jul 2022 02:15:00 -0700 (PDT)"
},
{
"name": "DKIM-Signature",
"value": "v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=mime-version:from:date:message-id:subject:to; bh=rSPPeK7RiIcyMjTDN3561J9SfRjOCCgYkfdVW1vq0d0=; b=H1oLUkyU7m+y3EtpuTCE8BhfSLl5s1/zo1CEiabDnA75B4oU6s1ax8TajVg9Ez+OPR0YdCeEptcoCcDvbqszdCAPvvYgp6ql6iIO/TBY2SPeqwmmKSYeQgS4Vifu6nyNQI5K+Z3lbm9txU6ft91m71VsBt7k1IdTAwEdyO8cu1LMIAR5iRyqSCiDl4MwhfwtUeJzpcpTtDGcDzOzHmKT1S5SkoDAXt/43/h3/btjlTE6H62zwpm8JYRgbQN/Qy69BS3oKIX4eMlyhePUiDKB/Oxj1hixfdxz6mKRpzjLhKTXcxC2gPgFv/bLfQ85jnzGjGHL76+Vi2WsfBfiy3zAqA=="
},
{
"name": "X-Google-DKIM-Signature",
"value": "v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20210112; h=x-gm-message-state:mime-version:from:date:message-id:subject:to; bh=rSPPeK7RiIcyMjTDN3561J9SfRjOCCgYkfdVW1vq0d0=; b=Nm/5Piap/WjnTmiWnpe0dorYWj6chCsn/HXuPejLC7YmfNc0+OpAsld727vwn2SXsA cApCn397SdqF/3o4MBkZl1hrgQ7R0gGEGClMZpJ6UZRotyPN2vBnB7yRjF6PpRO8LbWV mdRHD7oVSVFrCalZ9TPsEXWACxCzwAb4KSvSyvgRKaArJAUMiQq8NKo7cmhDGLTETR2k zTZbJyFH+rhEHdD/oa5rP6YbhakA5ayGmxQLrF4FDMo2owe4LU2nv29309kZpqj3X1h1 /gTMUSo5jXmZTpQxbdaoWedNb4Ndf1WkPEkCFai+h9EoGds83H7yob+ICPdyTgNDfh1t N/fA=="
},
{
"name": "X-Gm-Message-State",
"value": "AJIora/nlHS7fGK6ZEFxHq5tyfkfAExCuF6ICaxqObb2k6pfxHJd3IKp 8RefqWh5PQXCBGXtbr1Eb1HrBN+gx3g4v/4zv5zGQ4Hw"
},
{
"name": "X-Google-Smtp-Source",
"value": "AGRyM1sEjWEMNak62zURZdbBUNOjZug/LH/rx89S/uG+JAOLMyIC/FpuJ8a7FG8jCskcRBBn1uzRajR+M4FxT2PMCxs="
},
{
"name": "X-Received",
"value": "by 2002:a17:90a:a007:b0:1f2:1362:70cb with SMTP id q7-20020a17090aa00700b001f2136270cbmr3151662pjp.104.1658481300009; Fri, 22 Jul 2022 02:15:00 -0700 (PDT)"
},
{
"name": "MIME-Version",
"value": "1.0"
},
{
"name": "From",
"value": "Masked Name <masked@gmail.com>"
},
{
"name": "Date",
"value": "Fri, 22 Jul 2022 18:14:49 +0900"
},
{
"name": "Message-ID",
"value": "<CAPscKEsUQrTVb7JsEjN_qhSJyDoSv7rxNPYYBKm-oSnip5DV5Q@mail.gmail.com>"
},
{
"name": "Subject",
"value": "title desu"
},
{
"name": "To",
"value": "receive@sugiaws.tokyo"
},
{
"name": "Content-Type",
"value": "multipart/alternative; boundary=\"000000000000c0f5f705e4614413\""
}
],
"commonHeaders": {
"returnPath": "masked@gmail.com",
"from": [
"Masked Name <masked@gmail.com>"
],
"date": "Fri, 22 Jul 2022 18:14:49 +0900",
"to": [
"receive@sugiaws.tokyo"
],
"messageId": "<CAPscKEsUQrTVb7JsEjN_qhSJyDoSv7rxNPYYBKm-oSnip5DV5Q@mail.gmail.com>",
"subject": "title desu"
}
},
"receipt": {
"timestamp": "2022-07-22T09:15:01.161Z",
"processingTimeMillis": 833,
"recipients": [
"receive@sugiaws.tokyo"
],
"spamVerdict": {
"status": "PASS"
},
"virusVerdict": {
"status": "PASS"
},
"spfVerdict": {
"status": "PASS"
},
"dkimVerdict": {
"status": "PASS"
},
"dmarcVerdict": {
"status": "PASS"
},
"action": {
"type": "Lambda",
"functionArn": "arn:aws:lambda:us-east-1:xxxxxxxx:function:print-event-function",
"invocationType": "Event"
}
}
}
}
]
}
Appendix : S3 から Lambda を呼びだしたときに受け取るイベント
こちらが、S3 にメールデータを格納したときに、Lambda 関数が受け取るイベントです。
{
"Records": [
{
"eventVersion": "2.1",
"eventSource": "aws:s3",
"awsRegion": "us-east-1",
"eventTime": "2022-07-22T14:37:57.154Z",
"eventName": "ObjectCreated:Put",
"userIdentity": {
"principalId": "AWS:AIDAIE26RTG3F45XIHQFI"
},
"requestParameters": {
"sourceIPAddress": "xx.xx.xx.xx"
},
"responseElements": {
"x-amz-request-id": "DJZMRAGK3E3DC97Y",
"x-amz-id-2": "HNSvzOweh/eMlUiAJUK2z0jcx4IXnJ3nPaoP80YqzhhWI/+Qkn6pv5fqm/t80OjRZi4EQ3XoczJc4iTZKPTNHaR8p5kdjtO6+xGG2IHxRnM="
},
"s3": {
"s3SchemaVersion": "1.0",
"configurationId": "invoke-ses-lambda-test",
"bucket": {
"name": "ses-inbound-bucket-sugi01",
"ownerIdentity": {
"principalId": "A3A42BDEZS91PL"
},
"arn": "arn:aws:s3:::ses-inbound-bucket-sugi01"
},
"object": {
"key": "ses-output/2g6k0t72liqqbifdooj70kklp8lpaf5090f8pcg1",
"size": 2641841,
"eTag": "4b8e62a840b50fda051c64744cdfdbf4",
"sequencer": "0062DAB6450E2FF3EB"
}
}
}
]
}
参考URL
AWS Document
https://docs.aws.amazon.com/ja_jp/ses/latest/dg/receiving-email.html
SESで受信したメールからLambda(python)で添付ファイル(テキストファイル)を取得してみる
https://dev.classmethod.jp/articles/ses-get-attachfile-lambda/
SES + S3 + LambdaでGmailの添付ファイルを自動抽出する
https://techblog.lclco.com/entry/2019/07/22/101913
SESで受信したメールを、Lambdaで適切に変換し、件名や本文を取得する
https://qiita.com/hirai-11/items/458b4b886edb58735836
SESでメール受信時に、タイトルと各アドレスを取得する
https://encr.jp/blog/posts/20200228_morning/
htmlメールを何とかする
https://encr.jp/blog/posts/20200229_morning/