はじめに
以前、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を使って実装しました。
実現手順はざっと以下のとおりです。
- ドメイン管理 : メール配送先(MXレコード)に、SESのEメール受信用エンドポイントを設定5
- S3 : SESで受信したメールを保存するためのS3バケットをセットアップ
- SES : S3バケットに受信メールを保存するよう受信ルールを作成
- GitHub : GitHub APIを使用するためのアクセストークン("repo"権限付与)を発行
- Lambda : S3バケットから受信メールを読み込みGitHubレポジトリにissueを追加する関数を実装し配備
- 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。
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
は以下のとおりとなりました。
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上から読み込むようにしています。
{
"<送信元メールアドレス>": {
"GITHUB_OWNER": "<GitHubユーザ名>",
"GITHUB_ACCESS_TOKEN": "<GitHubアクセストークン>",
"GITHUB_DEFAULT_REPOSITORY": "<メールタイトルで指定がなかった場合のレポジトリ名>"
},
...
}
おわりに
別の目的でたまたまAmazon SESを触る機会があり、AWSでメールを受けられるならと、今回のリファクタリングに至りました。メールをトリガーにしたサービスはまだ色々とありそうなので、今回のパターンの応用も引き続き考えてみます。
-
私は、自分で取得したドメインを管理しているVALUE-DOMAINで設定しました。無料のDynamicDNSサービスを活用した事例などもあるようです。(参考:無料DDNSとAmazon SESを使ってメール受信を行う - Qiita) ↩
-
Lambda Event Sources — Python Serverless Microframework for AWS 1.14.0 documentation ↩
-
Pythonの標準ライブラリを使ってメールをデコードしています。(参考:Python の標準ライブラリでメールをデコードする - Qiita) ↩