メールでGitHubに新規issueを追加する (Amazon SES活用版)

# はじめに
以前、IFTTTのEmailサービス[^iftttemail]をメールの受け口にしてAWS Lambda経由でGitHub API[^githubapi]を叩き、[メールでGitHubに新規issueを追加する](https://qiita.com/nikotan/items/ff516aa90eb87c5140e7)機能を作りました。自作サービスのバグや改善点に気づいたときに、メール一本でGitHubのissueを立てられる、というのは実はとても便利です。今後も継続的に使いそうなので、メールの受け口も含めてAWS上で動くよう作り直してみました。

# 方針
メールの受け口として、Amazon SES (Simple Email Service)[^ses]を使います。自分で管理しているドメインのメール配送先をSESの受信用エンドポイントに向けることで、メールを、Amazon SES → Amazon S3 → AWS Lambdaとバケツリレー。メールの中身に応じてGitHub API[^githubapi]を叩きGitHubレポジトリにissueを追加するLambda関数を、PythonフレームワークのAWS Chalice[^chalice]を使って実装しました。

実現手順はざっと以下のとおりです。

1. **ドメイン管理** : メール配送先(MXレコード)に、SESのEメール受信用エンドポイントを設定[^dns]
1. **S3** : SESで受信したメールを保存するためのS3バケットをセットアップ
1. **SES** : S3バケットに受信メールを保存するよう受信ルールを作成
1. **GitHub** : GitHub APIを使用するためのアクセストークン("repo"権限付与)を発行
1. **Lambda** : S3バケットから受信メールを読み込みGitHubレポジトリにissueを追加する関数を実装し配備
1. **S3** : 配備したLambda関数を受信メール保存時に実行するようS3バケットにイベントを設定

1から3については、AWSの開発者ガイド『[Amazon SES を使用した E メールの受信 - Amazon Simple Email Service](https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/receiving-email.html)』やサポート情報『[Amazon SES を使用して Amazon S3 で E メールを受信して保存する](https://aws.amazon.com/jp/premiumsupport/knowledge-center/ses-receive-inbound-emails/)』が詳しいです。
また、4については、『[GitHub「Personal access tokens」の設定方法 - Qiita](https://qiita.com/kz800/items/497ec70bff3e555dacd0)』あたりで具体的な手順が解説されています。

というわけで、本記事では、5と6の実装を以下でまとめます。

# 実装
上記の5と6でやりたいことは、結局、S3バケットに新たな受信メールが保存されたら、S3バケットから受信メールを読み込み、GitHubレポジトリにissueを追加する、という処理です。この処理、Lambda活用した開発のためのPythonフレームワークであるChalice[^chalice]では、`on_s3_event`というデコレーターを使ってとても簡単に実現できます。

## Chalice.on_s3_event()
S3には、バケットに何らかの変更があった場合に、Lambdaなどへ通知を飛ばす仕掛けがあります。この仕掛を使うには、S3で通知を飛ばすイベントを設定すると共に、Lambdaで通知を受け取る関数を作成する必要があるのですが、Chaliceを使えばこれらの設定をほぼ自動でやってくれます。

ChaliceでS3イベントを受け取るLambda関数を実装する基本的なコードは、以下のとおりです[^on_s3_event]。

```python:app.py(sample)
from chalice import Chalice

app = chalice.Chalice(app_name='s3eventdemo')
app.debug = True

@app.on_s3_event(bucket='mybucket-name',
                 events=['s3:ObjectCreated:*'])
def handle_s3_event(event):
    app.log.debug("Received event for bucket: %s, key: %s",
                  event.bucket, event.key)
```

`Chalice.on_s3_event()`デコレーターを付けた関数を定義してコードを書けば、`chalice deploy`で関数をLambda上に配備する際に、S3とLambdaに対するロールやイベントの設定を全て自動でやってくれます。

## コード
というわけで、今回は、この`Chalice.on_s3_event()`デコレーターを付けた関数の中で、S3バケットから受信メールを読み込み[^email]、GitHubレポジトリにissueを追加する処理を記述しました。Chaliceのメインコードである`app.py`は以下のとおりとなりました。

```python:app.py
from chalice import Chalice
import logging, os, json, re
import boto3
from botocore.exceptions import ClientError
import email
from email.header import decode_header
from email.utils import parsedate_to_datetime
import urllib.request


# setup chalice
app = Chalice(app_name='mail2issue')
app.debug = False

# setup logger
logger = logging.getLogger()
logger.setLevel(logging.INFO)
logformat = (
    '[%(levelname)s] %(asctime)s.%(msecs)dZ (%(aws_request_id)s) '
    '%(filename)s:%(funcName)s[%(lineno)d] %(message)s'
)
formatter = logging.Formatter(logformat, '%Y-%m-%dT%H:%M:%S')
for handler in logger.handlers:
    handler.setFormatter(formatter)


# on_s3_event
@app.on_s3_event(
    os.environ.get('BUCKET_NAME'),
    events = ['s3:ObjectCreated:*'],
    prefix = os.environ.get('BUCKET_KEY_PREFIX')
)
def receive_mail(event):
    logger.info('received key: {}'.format(event.key))

    # read S3 object (email message)
    obj = getS3Object(os.environ.get('BUCKET_NAME'), event.key)
    if obj is None:
        logger.warning('object not found!')
        return

    # read S3 object (config)
    config = getS3Object(os.environ.get('BUCKET_NAME'), 'mail2issue-config.json')
    if config is None:
        logger.warning('mail2issue-config.json not found!')
        return
    settings = json.loads(config)

    # メールを解析
    msg = email.message_from_bytes(obj)
    msg_from = get_header(msg, 'From')
    msg_subject = get_header(msg, 'Subject')
    msg_content = get_content(msg)

    # メールアドレスを抽出
    pattern = "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+"
    adds = re.findall(pattern, msg_from)
    # メールアドレスに対応する設定を抽出
    config = None
    for add in settings:
        if add in adds:
            config = settings[add]
            break
    if config is None:
        logger.info('there is no config for {}'.format(', '.join(adds)))
        return

    # レポジトリを取得
    repos = getRepositories(config['GITHUB_ACCESS_TOKEN'])
    logger.info('repositories: {}'.format(repos))

    # メールタイトルからレポジトリを判定
    repo = config['GITHUB_DEFAULT_REPOSITORY']
    title = msg_subject
    spaceIdx = msg_subject.find(' ')
    if spaceIdx > 0:
        repo_tmp = msg_subject[0:spaceIdx]
        if repo_tmp in repos:
            title = msg_subject[spaceIdx+1:]
            repo = repo_tmp
    title = title.lstrip().rstrip()
    logger.info("repository: '{}'".format(repo))
    logger.info("title: '{}'".format(title))

    # issueをPOST
    postIssue(
        config['GITHUB_ACCESS_TOKEN'],
        config['GITHUB_OWNER'],
        repo, title, msg_content
    )

    # メールを削除
    deleteS3Object(os.environ.get('BUCKET_NAME'), event.key)


# S3からオブジェクトを取得
def getS3Object(bucket, key):
    ret = None
    s3obj = None
    try:
        s3 = boto3.client('s3')
        s3obj = s3.get_object(
            Bucket = bucket,
            Key = key
        )
    except ClientError as e:
        logger.warning('S3 ClientError: {}'.format(e))
    if s3obj is not None:
        ret = s3obj['Body'].read()
    return ret

# S3のオブジェクトを削除
def deleteS3Object(bucket, key):
    try:
        s3 = boto3.client('s3')
        s3.delete_object(
            Bucket = bucket,
            Key = key
        )
    except ClientError as e:
        logger.warning('S3 ClientError: {}'.format(e))


# メールヘッダを取得
def get_header(msg, name):
    header = ''
    if msg[name]:
        for tup in decode_header(str(msg[name])):
            if type(tup[0]) is bytes:
                charset = tup[1]
                if charset:
                    header += tup[0].decode(tup[1])
                else:
                    header += tup[0].decode()
            elif type(tup[0]) is str:
                header += tup[0]
    return header

# メール本文を取得
def get_content(msg):
    charset = msg.get_content_charset()
    payload = msg.get_payload(decode=True)
    try:
        if payload:
            if charset:
                return payload.decode(charset)
            else:
                return payload.decode()
        else:
            return ""
    except:
        return payload


# githubレポジトリ一覧を取得
def getRepositories(token):
    req = urllib.request.Request(
        'https://api.github.com/user/repos',
        method = 'GET',
        headers = {
            'Authorization': 'token {}'.format(token)
        }
    )
    repos = []
    try:
        with urllib.request.urlopen(req) as res:
            for repo in json.loads(res.read().decode('utf-8')):
                repos.append(repo['name'])
    except Exception as e:
        logger.exception("urlopen error: %s", e)
    return set(repos)

# githubレポジトリにissueを追加
def postIssue(token, owner, repository, title, content):
    req = urllib.request.Request(
        'https://api.github.com/repos/{}/{}/issues'.format(owner, repository),
        method = 'POST',
        headers = {
            'Content-Type': 'application/json',
            'Authorization': 'token {}'.format(token)
        },
        data = json.dumps({
            'title': title,
            'body': content
        }).encode('utf-8'),
    )
    try:
        with urllib.request.urlopen(req) as res:
            logger.info(res.read().decode("utf-8"))
    except Exception as e:
        logger.exception("urlopen error: %s", e)
```

送信元のメールアドレスに応じて、GitHub APIを使用するためのアクセストークンを切り替えられるよう、以下のような設定ファイルをS3上から読み込むようにしています。

```json:mail2issue-config.json
{
    "<送信元メールアドレス>": {
        "GITHUB_OWNER": "<GitHubユーザ名>",
        "GITHUB_ACCESS_TOKEN": "<GitHubアクセストークン>",
        "GITHUB_DEFAULT_REPOSITORY": "<メールタイトルで指定がなかった場合のレポジトリ名>"
    },
    ...
}
```

# おわりに
別の目的でたまたまAmazon SESを触る機会があり、AWSでメールを受けられるならと、今回のリファクタリングに至りました。メールをトリガーにしたサービスはまだ色々とありそうなので、今回のパターンの応用も引き続き考えてみます。


[^iftttemail]: [Email works better with IFTTT](https://ifttt.com/email)
[^githubapi]: [GitHub API v3 | GitHub Developer Guide](https://developer.github.com/v3/)
[^ses]: [Amazon SES（高可用性で低価格なEメール送受信サービス）| AWS](https://aws.amazon.com/jp/ses/)
[^dns]: 私は、自分で取得したドメインを管理している[VALUE-DOMAIN](https://www.value-domain.com/)で設定しました。無料のDynamicDNSサービスを活用した事例などもあるようです。（参考：[無料DDNSとAmazon SESを使ってメール受信を行う - Qiita](https://qiita.com/mochidamochiko/items/f027ba444ef13d277e0b)）
[^chalice]: [aws/chalice: Python Serverless Microframework for AWS](https://github.com/aws/chalice)
[^on_s3_event]: [Lambda Event Sources — Python Serverless Microframework for AWS 1.14.0 documentation](https://chalice.readthedocs.io/en/latest/topics/events.html#s3-events)
[^email]: Pythonの標準ライブラリを使ってメールをデコードしています。（参考：[Python の標準ライブラリでメールをデコードする - Qiita](https://qiita.com/jsaito/items/a058611cf9386addbc12)）