Lambda・SESを使って、添付ファイル付きメールを送信する

記事タイトルとURLをコピーする

渡辺です。
S3バケットにあるファイルをメールで毎月1回送信すべしというお仕事が発生しました。
パッと思いついた実装方法は以下となります。

  1. CloudWatch Events ルールのスケジュール式 で毎月1回Lambda Function を動かす。
  2. Lambda Functionの実装
    1. S3バケットにある指定ファイルを取得する。
    2. SESのメールで添付ファイルとして送信する。

CloudWatch Eventsの部分は特に問題なかったのですが、LambdaでS3から添付ファイル付きメールをSESで送信するという部分で少しコツがいることがわかりました。

SES

まず、SESの部分です。
AWSのドキュメントにサンプルコードがあるので、それを利用しました。
毎度おなじみのPythonとboto3での実装例が載っています。

  1. AWS SDK for Python (Boto) を使用して E メールを送信する
  2. AWS SDKs を使用して raw E メールを送信する

サンプルコードは上記の通り、2つありました。
1の方はsend_email()メソッドを使ったサンプル、2の方はsend_raw_email()メソッドを使ったサンプルとなっています。
添付ファイルをつけるには、send_raw_email()を使う必要があるようなので、2のサンプルコードを元にして開発してみました。

その前にsend_raw_email()のドキュメントを少しみてみます。

  • verified email addresses or domainsからしかメール送信できません。
  • もしAWSアカウントが、Amazon SES sandbox のままなら、verified email addressesか、 Amazon SES mailbox simulatorにしか送信できません。

今回は特定のメールアドレスのみへの送信でいいため、サンドボックスのまま利用します。
マネージメントコンソールでSESにメールアドレスを登録します。
すると、登録したメールアドレスに確認メールが来るので、そこに書かれたURLリンクをクリックするとverifidとなります。

  • 添付ファイルを含む最大メッセージサイズは10MBです。

添付ファイルのサイズが大きい時などは要注意ですね。
今回はLambdaで圧縮処理し、サイズを小さくしてから、メールに添付します。
「え、S3保存した時点で圧縮した方がいい?ですよね。」

Lambda

次にサンプルコードから変更が必要な箇所をみていきます。

送信元アドレスと宛先アドレスの設定

# Replace sender@example.com with your "From" address.
# This address must be verified with Amazon SES.
SENDER = "Sender Name <sender@example.com>"

# Replace recipient@example.com with a "To" address. If your account
# is still in the sandbox, this address must be verified.
RECIPIENT = "recipient@example.com"

送信元アドレスと宛先アドレスは、SESでverifiedされたものに変更しましょう。

CONFIGURATION_SETの設定

# Specify a configuration set. If you do not want to use a configuration
# set, comment the following variable, and the
# ConfigurationSetName=CONFIGURATION_SET argument below.
CONFIGURATION_SET = "ConfigSet"

SESの設定でConfiguration Setというのがあるのですが、それを指定します。
マネージメントコンソールで簡単に作成できるので、それを指定しても構いません。(設定値はデフォルトのままでOK)
または、サンプルコードの下の方にあるConfigurationSetName=CONFIGURATION_SET をコメントアウトしても動きます。

S3オブジェクトをローカルに一時保存してから添付する

S3オブジェクトを添付ファイルとして扱いたいのですが、直接S3オブジェクトのURLやARNは指定ができません。
Lambda関数が動いているローカルマシンのファイルシステムに一時的に保存した後で、それを添付ファイルとして指定します。
但し、Lambdaには、書き込み可能なディレクトリが /tmp のみという制限があります。

以下はs3オブジェクトをローカルに保存する例です。
例では、Bucket名/reports/yyyy/mm/xxxx.csv といった形で保存されているオブジェクトをdownload_fileで/tmpに保存し、zipfileで圧縮しています。
このコードはさておき、Lambdaでは/tmpにしか保存できないとだけ覚えておいていただければと思います。

    # get last month
    today = datetime.datetime.today()
    thismonth = datetime.datetime(today.year, today.month, 1)
    lastmonth = thismonth + datetime.timedelta(days=-1)
    lastmonth_begin = lastmonth.strftime("%Y-%m") + '-01'
    lastmonth_end = lastmonth.strftime("%Y-%m-%d")

    # s3 location
    obj_path = f'reports/{lastmonth.strftime("%Y")}/{lastmonth.strftime("%m")}'

    s3 = boto3.resource('s3')
    bucket = s3.Bucket(Bucket名)

    for obj in bucket.objects.filter(Prefix=obj_path):
        basename = os.path.basename(obj.key)
        if re.search(r'\.csv$',basename):
            bucket.download_file(obj.key, '/tmp/' + basename)
            with zipfile.ZipFile('/tmp/report.zip', "w", zipfile.ZIP_DEFLATED) as zf:
                zf.write('/tmp/' + basename, "report.csv")

そして、保存したファイルをATTACHMENTで指定します。

    # The full path to the file that will be attached to the email.
    ATTACHMENT = "/tmp/report.zip"</pre>

これで添付ファイル付きのメールを送信できるようになっています。
以下はAWS SDKs を使用して raw E メールを送信するを元にして作ったコードの全文となります。
(本題と関係ないため説明省略しましたが、datetimeとreとzipfileをimportしています。)

import os
import boto3
import datetime
import re
import zipfile
from botocore.exceptions import ClientError
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
 
def lambda_handler(event, context):
 
    # get last month
    today = datetime.datetime.today()
    thismonth = datetime.datetime(today.year, today.month, 1)
    lastmonth = thismonth + datetime.timedelta(days=-1)
    lastmonth_begin = lastmonth.strftime("%Y-%m") + '-01'
    lastmonth_end = lastmonth.strftime("%Y-%m-%d")
 
    # s3 location
    obj_path = f'reports/{lastmonth.strftime("%Y")}/{lastmonth.strftime("%m")}'
 
    s3 = boto3.resource('s3')
    bucket = s3.Bucket(Bucket名)
 
    for obj in bucket.objects.filter(Prefix=obj_path):
        basename = os.path.basename(obj.key)
        if re.search(r'\.csv$',basename):
            bucket.download_file(obj.key, '/tmp/' + basename)
            with zipfile.ZipFile('/tmp/report.zip', "w", zipfile.ZIP_DEFLATED) as zf:
                zf.write('/tmp/' + basename, "report.csv")
 
    # Replace sender@example.com with your "From" address.
    # This address must be verified with Amazon SES.
    SENDER = "XXX <xxx@example.co.jp>"
 
    # Replace recipient@example.com with a "To" address. If your account
    # is still in the sandbox, this address must be verified.
    RECIPIENT = "yyy@example.co.jp"
 
    # Specify a configuration set. If you do not want to use a configuration
    # set, comment the following variable, and the
    # ConfigurationSetName=CONFIGURATION_SET argument below.
    CONFIGURATION_SET = "ConfigSet"
 
    # If necessary, replace us-west-2 with the AWS Region you're using for Amazon SES.
    AWS_REGION = "us-east-1"
 
    # The subject line for the email.
    SUBJECT = "Customer service contact info"
 
    # The full path to the file that will be attached to the email.
    ATTACHMENT = "/tmp/report.zip"
 
    # The email body for recipients with non-HTML email clients.
    BODY_TEXT = "Hello,\r\nPlease see the attached file for a list of customers to contact."
 
    # The HTML body of the email.
    BODY_HTML = """\
    <html>
    <head></head>
    <body>
    <h1>Hello!</h1>
    <p>Please see the attached file for a list of customers to contact.</p>
    </body>
    </html>
    """
 
    # The character encoding for the email.
    CHARSET = "utf-8"
 
    # Create a new SES resource and specify a region.
    client = boto3.client('ses',region_name=AWS_REGION)
 
    # Create a multipart/mixed parent container.
    msg = MIMEMultipart('mixed')
    # Add subject, from and to lines.
    msg['Subject'] = SUBJECT
    msg['From'] = SENDER
    msg['To'] = RECIPIENT
 
    # Create a multipart/alternative child container.
    msg_body = MIMEMultipart('alternative')
 
    # Encode the text and HTML content and set the character encoding. This step is
    # necessary if you're sending a message with characters outside the ASCII range.
    textpart = MIMEText(BODY_TEXT.encode(CHARSET), 'plain', CHARSET)
    htmlpart = MIMEText(BODY_HTML.encode(CHARSET), 'html', CHARSET)
 
    # Add the text and HTML parts to the child container.
    msg_body.attach(textpart)
    msg_body.attach(htmlpart)
 
    # Define the attachment part and encode it using MIMEApplication.
    att = MIMEApplication(open(ATTACHMENT, 'rb').read())
 
    # Add a header to tell the email client to treat this part as an attachment,
    # and to give the attachment a name.
    att.add_header('Content-Disposition','attachment',filename=os.path.basename(ATTACHMENT))
 
    # Attach the multipart/alternative child container to the multipart/mixed
    # parent container.
    msg.attach(msg_body)
 
    # Add the attachment to the parent container.
    msg.attach(att)
    #print(msg)
    try:
        #Provide the contents of the email.
        response = client.send_raw_email(
            Source=SENDER,
            Destinations=[
                RECIPIENT
            ],
            RawMessage={
                'Data':msg.as_string(),
            },
            #ConfigurationSetName=CONFIGURATION_SET
        )
    # Display an error if something goes wrong.
    except ClientError as e:
        print(e.response['Error']['Message'])
    else:
        print("Email sent! Message ID:"),
        print(response['ResponseMetadata']['RequestId'])
 
# Main Function
if __name__ == "__main__":
    lambda_handler({}, {})

まとめ

  • SESで添付ファイルが必要な時は、send_raw_emailメソッドを使う。
  • SESで添付ファイルはs3オブジェクトを指定できないので、Lambdaに一時保存する。
  • Lambdaは/tmpにしかファイルを保存できない。

渡辺 信秀 (記事一覧)