27
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Amazon SESとLambdaを使ってメールをSlackに通知する

Last updated at Posted at 2015-12-24

Slackにメールを通知したい

Slackにメールを通知するなら、一番楽ちんなのはIFTTTを使うことです。
ただし、いくつか問題もあります。

  • 日本語の本文がうまく取れない。UTF-8ならいいけど。
  • gmail経由でもたまに本文が取れない。そしてgmail経由だとpollingなので遅い

通知にSubjectとFrom位しか入れなくていいならIFTTTで十分ですが、本文のダイジェストとかも見たいですよね。
そうなると自前で受信したメールを加工してごにょごにょする必要があります。良くやる手としては

  • SMTPサーバ作る。例えばpostfixとか。
  • aliasからパイプでコマンドを起動する
  • 起動したコマンドの中で、受け取ったメール本文をごにょごにょする

でしょうか。色々面倒ですねぇ。特にSMTPサーバのお守りなんて実現したい内容に比較してめんどくさすぎます。

AWS Lambda使うぞ!

というわけで、時代はサーバレス!(といってみたかっただけ)
AWS SESでメールの受信サーバは作れるし、Lambdaと組み合わせたらメールが来たときだけ動くコンピューティングリソースが手に入るわけですよ。
早速やってみましょう。こういうときは先人の知恵をお借りすると言うことで、matetsuさんの記事の通りにします。

(2015/12/24 23:27修正)
当初、完全に勘違いしてリンク先の記事を「クラスメソッドさんの記事」としてましたが、正しくはmatetsuさんの記事でした。大変申し訳ございませんでした。

lambda初めてって人がハマるところは(というか自分がちょっと引っかかったところは)

  • config.iniを含めるときはZIPでのアップロードを使いましょう
  • slackwebモジュールをZIPに含めるのを忘れないようにしましょう
  • 本体のスクリプトはlambda_function.pyというファイル名にしましょう(変更はできますが)

位でしょうか。slackwebモジュールを含めるには、lambda_function.pyとconfig.iniを置いたワークディレクトリで

%pip install slackweb -t ./

とすると、slackwebというディレクトリができますので、これをそのまま

%zip -r ses-s3-lambda-slack.zip config.ini lambda_function.py slackweb

という感じでZIPで固めたらOKです。

改造しよう

うまく動きましたか?そしたら本文を取れるようになんとなく見よう見まねで

(d_body, body_charset) = decode_header(msg_object['Body'])[0]
d_body = d_body.decode(body_charset)

とかやってみましょう。
残念ながら、これだとマルチパートの時にうまく動きません。gmailなんかだとデフォルトマルチパートで送ってきたりするんで困りますね。
現時点での問題点は

  • マルチパートだと本文をうまく取れない
  • SubjectやFromを取ってくるときも、英数字だけだったりする(us-ascii)とうまく取れない

というあたりでしょうか。
では、以下のように改造しましょう。

# coding: utf-8
import boto3
import json
import ConfigParser
import email
from email.parser import FeedParser
from email.header import decode_header
import slackweb

def lambda_handler(event, context):
  try:
    record = event["Records"][0]
    bucket_region = record["awsRegion"]
    bucket_name =  record["s3"]["bucket"]["name"]
    mail_object_key = record["s3"]["object"]["key"]
  
    s3 = boto3.client('s3', region_name=bucket_region)
    mail_object = s3.get_object(Bucket = bucket_name, Key = mail_object_key)
    mail_body = ''
    try:
      mail_body = mail_object["Body"].read().decode('utf-8')
    except:
      try:
        mail_body = mail_object["Body"].read().decode('iso-2022-jp')
      except:
        mail_body = mail_object["Body"].read()

    msg_object = email.message_from_string(mail_body)
    
    if msg_object.is_multipart():
        #マルチパートなら、最初のパートを取ってくる
        body = msg_object.get_payload()[0]
        if body.is_multipart():
          body = body.get_payload()[0]
    else:
        body = msg_object
 
    try:
      body = body.get_payload(decode=True).decode(body.get_content_charset())
    except:
      #iso-2022-jpなのに丸文字があるとき
      body = body.get_payload(decode=True).replace('\033$B', '\033$(Q').decode('iso-2022-jp-2004')

    (d_sub, sub_charset) = decode_header(msg_object['Subject'])[0]
    if sub_charset == None:
      subject = d_sub
    else:
      subject = d_sub.decode(sub_charset)

    (d_from, from_charset) = decode_header(msg_object['From'])[0]
    if from_charset == None:
        mfrom = d_from
    else:
        mfrom = d_from.decode(from_charset)
  except:
      subject = u"Error!"
      body = u"メールを受信しましたが、エラーが発生しました。"
      mfrom = u"送信元不明"
      
  inifile = ConfigParser.SafeConfigParser()
  inifile.read("./config.ini")
  attachments = []
  attachment = {
                 "fallback": u"メール通知",
		 "pretext": u"From:%s " % mfrom,
                 "color": "#aaaaaa",
                 "fields": [
                   {
                     "title": subject,
                     "value": body,
                     "short": True
                   }
                 ]
               } 
  attachments.append(attachment)

  slack = slackweb.Slack(url=inifile.get('slack', 'hook_url'))
  slack.notify(attachments=attachments,
               channel=inifile.get('slack', 'channel'),
               username=inifile.get('slack', 'username'),
               icon_emoji=inifile.get('slack', 'icon_emoji'))

  return "CONTINUE"

一度メッセージオブジェクトにしてからis_multipart()で判定して操作するのと、charsetがNoneの場合の処理を追加しただけです。
is_multipart()なところでget_payload()からの戻り値(マルチパートのリスト)をうまく扱えば、添付ファイルのプレビュー付けたりとかも自由自在です。
ただし、上記はマルチパートが2回以上ネストしてるとうまく動きません。再帰的にis_multipartで判定して、最初に見つかったパートを文字に・・とかしたらいいんでしょうけど、さすがにそんなメールは滅多に来ないからこのくらいでもいいのかなと思います。

(2015/12/24 18:13追記)
ちょっと動作が怪しいメールもありますね・・・。だいたいのメールはうまくいくんですが。
もうちょっと調整します。すいません。

(2015/12/24 19:08追記)
subjectのフォールバックの所が間違ってましたので修正しました。これで大丈夫かと。

(2015/12/25 18:40追記)
Charsetでiso-2022-jpと言いつつ丸囲み文字あたりを入れたメールへの対応をちょっと入れました。
デコードエラーの場合はこのiso-2022-jpなのに怪しい文字が入ってるもの、と決め打ちしてるのでよろしくはないですが・・

何故S3?

上記、何故S3を使ってるの?SESから直接Lambdaを起動できるじゃない・・・と思いませんか?
私も思ってました。が、調べてみるとSESからLambdaを起動するときにメッセージの本文は送りつけられないんですね。よく考えてみたらでっかい添付ファイルのついたメールなんかLambdaにそのまま渡すなんてしてたらえらいことになりそうだから、まぁこの制限は妥当かな、というとこで理解しました。

じゃあ直接起動でも取れるFromやSubjectしか使わないならS3使わないでもいける?・・かというと、それならおとなしくIFTTT使ったらいいよねってことになりますのでこの場合はやはりS3を経由して本文も取ってきましょう。
あと、S3の設定でオブジェクトは適当なタイミングで消えるようにしておくのを忘れないようにしましょう。

27
32
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
27
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?