Python
AWS
mail
amazonses
serverless

メールにパスワード付きzipを添付して「パスワードは別途お送りいたします」とする慣習がめんどくさいのでなんとかした

あの慣習

メールにパスワード付きzipを添付して「パスワードは別途お送りいたします」とする慣習、ありますよね。
自分からはやらないけど、相手に合わせてやらざるを得なかったりしてめんどくさい。

ここでは、このやり方の是非は問題にしません。
どんなに是非を説いても、この慣習があるという状況は変わらないので。

そして、この慣習を無くすことも考えません。
そういうのは巨大な力を持った何かにおまかせします。

昔のエラい人は言いました。「長いものには巻かれろ」と。
ただし、巻かれ方は考えたほうがいいと思うのです。

スマートな巻かれ方を考える

巻かれるにあたって、解決したいことはただ一つ。めんどくさくないこと。
このためにWebシステム作って、ブラウザ開いてどうのこうのなんてやってると本末転倒です。
可能な限り、普通のメール送信に近い形で実現したい。

というわけで、あれこれ考えた末、一部の制約を許容しつつ、AmazonSESを使ってサーバーレスな感じで解決してみました。

仕様

  1. 普通にメールを書く(新規・返信・転送問わず)
  2. ファイルをzipで固めずにそのまま放り込む
  3. SES宛のメールアドレスをToに、実際にファイルを送りたい相手をReply-Toに設定する。
  4. システムを信じて送信ボタンを押す
  5. 自分と相手に、パスワード付きzipが添付されたメールとパスワードのお知らせメールが届く

ただし、以下の制約があります。個人的には許容範囲です。

  • 結果的に相手方には全員Toで届く。Ccはできない(自分はBcc)
  • zipファイルの名前は日時(yymmddHHMMSS.zip)になる(中身のファイル名はそのまま)

システム構成

flow_01.png

  1. SESに宛ててメールを送る
  2. メールデータがS3に保存される
  3. それをトリガーにしてLambdaが起動する
  4. Lambdaがメールの内容を解析してパスワードとzipファイルを生成する
  5. いい感じにメールを送る(念のため自分にもBccで送る)

実装

Lambda

真面目にpython書いたの初めてだけどこんな感じでいいのかな?
大体メールと文字コードとファイルとの戦いです。

# -*- coding: utf-8 -*-

import os
import sys
import string
import random
import json
import urllib.parse
import boto3
import re
import smtplib
import email
import base64
from email                import encoders
from email.header         import decode_header
from email.mime.base      import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text      import MIMEText
from email.mime.image     import MIMEImage
from datetime             import datetime

sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'vendored'))
import pyminizip

s3 = boto3.client('s3')

class MailParser(object):
    """
    メール解析クラス
    (参考) http://qiita.com/sayamada/items/a42d344fa343cd80cf86
    """

    def __init__(self, email_string):
        """
        初期化
        """
        self.email_message    = email.message_from_string(email_string)
        self.subject          = None
        self.from_address     = None
        self.reply_to_address = None
        self.body             = ""
        self.attach_file_list = []

        # emlの解釈
        self._parse()

    def get_attr_data(self):
        """
        メールデータの取得
        """
        attr = {
                "from":         self.from_address,
                "reply_to":     self.reply_to_address,
                "subject":      self.subject,
                "body":         self.body,
                "attach_files": self.attach_file_list
                }
        return attr


    def _parse(self):
        """
        メールファイルの解析
        """

        # メッセージヘッダ部分の解析
        self.subject          = self._get_decoded_header("Subject")
        self.from_address     = self._get_decoded_header("From")
        self.reply_to_address = self._get_decoded_header("Reply-To")

        # メールアドレスの文字列だけ抽出する
        from_list =  re.findall(r"<(.*@.*)>", self.from_address)
        if from_list:
            self.from_address = from_list[0]
        reply_to_list =  re.findall(r"<(.*@.*)>", self.reply_to_address)
        if reply_to_list:
            self.reply_to_address = ','.join(reply_to_list)

        # メッセージ本文部分の解析
        for part in self.email_message.walk():
            # ContentTypeがmultipartの場合は実際のコンテンツはさらに
            # 中のpartにあるので読み飛ばす
            if part.get_content_maintype() == 'multipart':
                continue
            # ファイル名の取得
            attach_fname = part.get_filename()
            # ファイル名がない場合は本文のはず
            if not attach_fname:
                charset = str(part.get_content_charset())
                if charset != None:
                    if charset == 'utf-8':
                        self.body += part.get_payload()
                    else:
                        self.body += part.get_payload(decode=True).decode(charset, errors="replace")
                else:
                    self.body += part.get_payload(decode=True)
            else:
                # ファイル名があるならそれは添付ファイルなので
                # データを取得する
                self.attach_file_list.append({
                    "name": attach_fname,
                    "data": part.get_payload(decode=True)
                })

    def _get_decoded_header(self, key_name):
        """
        ヘッダーオブジェクトからデコード済の結果を取得する
        """
        ret = ""

        # 該当項目がないkeyは空文字を戻す
        raw_obj = self.email_message.get(key_name)
        if raw_obj is None:
            return ""
        # デコードした結果をunicodeにする
        for fragment, encoding in decode_header(raw_obj):
            if not hasattr(fragment, "decode"):
                ret += fragment
                continue
            # encodeがなければとりあえずUTF-8でデコードする
            if encoding:
                ret += fragment.decode(encoding)
            else:
                ret += fragment.decode("UTF-8")
        return ret

class MailForwarder(object):

    def __init__(self, email_attr):
        """
        初期化
        """
        self.email_attr = email_attr
        self.encode     = 'utf-8'

    def send(self):
        """
        添付ファイルにパスワード付き圧縮を行い転送、さらにパスワード通知メールを送信
        """

        # パスワード生成
        password = self._generate_password()

        # zipデータ生成
        zip_name = datetime.now().strftime('%Y%m%d%H%M%S')
        zip_data = self._generate_zip(zip_name, password)

        # zipデータを送信
        self._forward_with_zip(zip_name, zip_data)

        # パスワードを送信
        self._send_password(zip_name, password)

    def _generate_password(self):
        """
        パスワード生成
        記号、英字、数字からそれぞれ4文字ずつ取ってシャッフル
        """
        password_chars = ''.join(random.sample(string.punctuation, 4)) + \
                         ''.join(random.sample(string.ascii_letters, 4)) + \
                         ''.join(random.sample(string.digits, 4))

        return ''.join(random.sample(password_chars, len(password_chars)))

    def _generate_zip(self, zip_name, password):
        """
        パスワード付きZipファイルのデータを生成
        """
        tmp_dir  = "/tmp/" + zip_name
        os.mkdir(tmp_dir)

        # 一旦ローカルにファイルを保存
        for attach_file in self.email_attr['attach_files']:
            f = open(tmp_dir + "/" + attach_file['name'], 'wb')
            f.write(attach_file['data'])
            f.flush()
            f.close()

        # パスワード付きzipに
        dst_file_path = "/tmp/%s.zip" % zip_name
        src_file_names = ["%s/%s" % (tmp_dir, name) for name in os.listdir(tmp_dir)]

        pyminizip.compress_multiple(src_file_names, dst_file_path, password, 4)

        # # 生成したzipファイルを読み込み
        r = open(dst_file_path, 'rb')
        zip_data = r.read()
        r.close()

        return zip_data

    def _forward_with_zip(self, zip_name, zip_data):
        """
        パスワード付きZipファイルのデータを生成
        """
        self._send_message(
                self.email_attr['subject'],
                self.email_attr["body"].encode(self.encode),
                zip_name,
                zip_data
                )
        return

    def _send_password(self, zip_name, password):
        """
        zipファイルのパスワードを送信
        """

        subject = self.email_attr['subject']

        message = """
先ほどお送りしたファイルのパスワードのお知らせです。

[件名] {}
[ファイル名] {}.zip
[パスワード] {}
        """.format(subject, zip_name, password)

        self._send_message(
                '[password]%s' % subject,
                message,
                None,
                None
                )
        return

    def _send_message(self, subject, message, attach_name, attach_data):
        """
        メール送信
        """

        msg = MIMEMultipart()

        # ヘッダ
        msg['Subject'] = subject
        msg['From']    = self.email_attr['from']
        msg['To']      = self.email_attr['reply_to']
        msg['Bcc']     = self.email_attr['from']

        # 本文
        body = MIMEText(message, 'plain', self.encode)
        msg.attach(body)

        # 添付ファイル
        if attach_data:
            file_name = "%s.zip" % attach_name
            attachment = MIMEBase('application', 'zip')
            attachment.set_param('name', file_name)
            attachment.set_payload(attach_data)
            encoders.encode_base64(attachment)
            attachment.add_header("Content-Dispositon", "attachment", filename=file_name)
            msg.attach(attachment)

        # 送信
        smtp_server   = self._get_decrypted_environ("SMTP_SERVER")
        smtp_port     = self._get_decrypted_environ("SMTP_PORT")
        smtp_user     = self._get_decrypted_environ("SMTP_USER")
        smtp_password = self._get_decrypted_environ("SMTP_PASSWORD")
        smtp = smtplib.SMTP(smtp_server, smtp_port)
        smtp.ehlo()
        smtp.starttls()
        smtp.ehlo()
        smtp.login(smtp_user, smtp_password)
        smtp.send_message(msg)
        smtp.quit()
        print("Successfully sent email")

        return

    def _get_decrypted_environ(self, key):
        """
        暗号化された環境変数を復号化
        """

        client = boto3.client('kms')
        encrypted_data = os.environ[key]
        return client.decrypt(CiphertextBlob=base64.b64decode(encrypted_data))['Plaintext'].decode('utf-8')

def lambda_handler(event, context):

    # イベントからバケット名、キー名を取得
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'])

    try:
        # S3からファイルの中身を読み込む
        s3_object = s3.get_object(Bucket=bucket, Key=key)
        email_string = s3_object['Body'].read().decode('utf-8')

        # メールを解析
        parser = MailParser(email_string)

        # メール転送
        forwarder = MailForwarder(parser.get_attr_data())
        forwarder.send()
        return

    except Exception as e:
        print(e)
        raise e

pyminizip

パスワード付きzipは標準のライブラリじゃできないっぽい。
ということで、ここだけpyminizipという外部ライブラリに頼りました。
ただこれ、インストール時にビルドしてバイナリ作る系のライブラリだったので、Lambdaで動かすためにローカルでAmazonLinuxのDockerコンテナ立ててバイナリを作りました。何かほかにいい方法あるのかな。。

AWS SAM

ちなみに、これはAWS SAMを使ってローカルテストしてみました。
SMTPサーバーの情報を直書きして試してたところまでは良かったけど、それを環境変数に移すとうまく動かなくて挫折しました。修正はされてるけどリリースされてないっぽい。

導入方法

せっかくなので公開してみます。コードネームzaru
かなり設定方法が泥臭いままですがご容赦ください。。
https://github.com/Kta-M/zaru

自分の環境(Mac, Thunderbird)でしか試してないので、メーラーやその他環境によってはうまくいかないかも?自己責任でお願いします。

SES

SESはまだ東京リージョンで使えないので、オレゴンリージョン(us-west-2)で構築します。

ドメイン検証

まずはSESに向けてメールが送れるように、ドメインの検証を行います。
やり方はいろいろなので、このあたりは割愛。
たとえばこのあたりとか参考になるかも -> RailsでAmazon SES・Route53を用いてドメインメールを送信する

Rule作成

ドメインの検証ができたら、Ruleを作成します。

メニュー右側のRule Setsから、View Active Rule Setをクリック。
ses_rule_01.png

Create Ruleをクリック。
ses_rule_02.png

受信するメールアドレスを登録。検証を行なったドメインのメールアドレスを入力して、Add Recipientをクリック。
ses_rule_03.png

メール受信時のアクションを登録。
アクションタイプとしてS3を選択し、受信したメールデータを保存するバケットを指定します。このとき、Create S3 bucketでバケットを作成してあげると、必要なバケットポリシーが自動で登録されて便利。
SESからバケットへのファイルアップロードを許可するポリシーが設定されます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowSESPuts-XXXXXXXXXXXX",
            "Effect": "Allow",
            "Principal": {
                "Service": "ses.amazonaws.com"
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::<ses-bucket-name>/*",
            "Condition": {
                "StringEquals": {
                    "aws:Referer": "XXXXXXXXXXXX"
                }
            }
        }
    ]
}

また、バケットに保存されたメールデータは、貯めておいても仕方ないので、ライフサイクルを設定して一定期間経過後削除されるようにしておくといいかも。
ses_rule_04.png

ルールに名前を付けます。あとはデフォルトで。
ses_rule_05.png

登録内容を確認して、登録!
ses_rule_06.png

Lambda

デプロイ

SESと同じくオレゴンリージョンにデプロイします。
CloudFormationを利用するので、データをアップロードするS3バケットを作っておいてください。

# git clone git@github.com:Kta-M/zaru.git
# cd zaru
# aws cloudformation package --template-file template.yaml --s3-bucket <cfn-bucket-name> --output-template-file packaged.yaml
# aws cloudformation deploy --template-file packaged.yaml --stack-name zaru-stack --capabilities CAPABILITY_IAM --region us-west-2

Lambdaのコンソールに行くと、関数が作成されています。
また、この関数の実行に必要なIAMロールも作成されています。
lambda_01.png

トリガー設定

バケットにメールデータが入るのをトリガーにして、Lambdaが動くように設定します。

関数の詳細画面のトリガータブに移動します。
lambda_02.png

トリガーを追加をクリックし、S3のイベントを作成します。
SESからデータが来るバケット、イベントタイプはPutです。それ以外はデフォルト。
バケットはlambda_03.png

暗号化キーを作成

このLambda関数内では、暗号化された環境変数からSMTP関連の情報を取得しています。
その暗号化に使用するキーを作成します。

IAMコンソールから、左下にある暗号化キーをクリックします。
リージョンをオレゴンに変更し、キーを作成してください。
lambda_04.png

設定内容は、任意のエイリアスを設定するだけで、残りはデフォルトでOKです。
lambda_05.png

環境変数数設定

Lambdaに戻って、関数内で使用する環境変数を設定します。
コードタブの下のほうに、環境変数を設定するフォームがあります。
暗号化ヘルパーを有効にするにチェックを入れ、先ほど作成した暗号化キーを指定します。
環境変数は、変数名と値(平文)を入力し、暗号化ボタンを押します。すると、指定した暗号化キーで暗号化してくれます。
設定する環境変数は以下の4つです。

変数名 説明
SMTP_SERVER smtpサーバー smtp.example.com
SMTP_PORT smtpポート 587
SMTP_USER smtpサーバーにログインするユーザー名 test@example.com
SMTP_PASSWORD SMTP_USERのパスワード

lambda_06.png

ロール設定

最後に、このLambda関数を実行するロールに必要な権限を付けます。
- メールデータを保存するS3バケットからデータを取得する権限
- 暗号化キーを使って環境変数を復号する権限

まず、IAMコンソールのポリシーに行き、ポリシーの作成->独自のポリシーを作成で以下の2つのポリシーを作成します。
lambda_07.png

ポリシー:s3-get-object-zaru
<ses-bucket-name>には、SESからメールデータを受け取るバケット名を指定してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1505586008000",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::<ses-bucket-name>/*"
            ]
        }
    ]
}

ポリシー;kms-decrypt-zaru
<kms-arn>には、暗号化キーのARNを指定してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1448696327000",
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt"
            ],
            "Resource": [
                "<kms-arn>"
            ]
        }
    ]
}

最後に、この2つのポリシーを、Lambda関数実行ロールにアタッチします。
まず、IAMコンソールのロールに行き、ロールを選択し、ポリシーのアタッチからアタッチします。
lambda_08.png

動作確認

これで動くようになったはずです。
ToにSES向けに設定したメールアドレス、Reply-Toに相手のメールアドレスを設定し、適当なファイルを添付して送ってみてください。どうでしょう?

まとめ

どんとこいzip添付!