1
2

More than 3 years have passed since last update.

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

Posted at

はじめに

以前、IFTTTのEmailサービス1をメールの受け口にしてAWS Lambda経由でGitHub API2を叩き、メールでGitHubに新規issueを追加する機能を作りました。自作サービスのバグや改善点に気づいたときに、メール一本でGitHubのissueを立てられる、というのは実はとても便利です。今後も継続的に使いそうなので、メールの受け口も含めてAWS上で動くよう作り直してみました。

方針

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

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

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

1から3については、AWSの開発者ガイド『Amazon SES を使用した E メールの受信 - Amazon Simple Email Service』やサポート情報『Amazon SES を使用して Amazon S3 で E メールを受信して保存する』が詳しいです。
また、4については、『GitHub「Personal access tokens」の設定方法 - Qiita』あたりで具体的な手順が解説されています。

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

実装

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

Chalice.on_s3_event()

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

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

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バケットから受信メールを読み込み7、GitHubレポジトリにissueを追加する処理を記述しました。Chaliceのメインコードであるapp.pyは以下のとおりとなりました。

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上から読み込むようにしています。

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

おわりに

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


  1. Email works better with IFTTT 

  2. GitHub API v3 | GitHub Developer Guide 

  3. Amazon SES(高可用性で低価格なEメール送受信サービス)| AWS 

  4. aws/chalice: Python Serverless Microframework for AWS 

  5. 私は、自分で取得したドメインを管理しているVALUE-DOMAINで設定しました。無料のDynamicDNSサービスを活用した事例などもあるようです。(参考:無料DDNSとAmazon SESを使ってメール受信を行う - Qiita) 

  6. Lambda Event Sources — Python Serverless Microframework for AWS 1.14.0 documentation 

  7. Pythonの標準ライブラリを使ってメールをデコードしています。(参考:Python の標準ライブラリでメールをデコードする - Qiita) 

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2