DjangoとSendGridでいい感じのメール配信の仕組みを作る

# DjangoとSendGridでいい感じのメール配信の仕組みを作る
この記事は「[Django Advent Calendar 2016](http://qiita.com/advent-calendar/2016/django)」24日目の記事です。

弊社リーディングマークは[レクミーキャリア](https://recme-career.jp/)という転職支援サービスを運営しているのですが、AWSずっぷりで開発を進めております。
リリース当初はMTAとしてSESを利用していたのですが、SESではエラーとなったメールアドレスをサプレッションリストに入れて再度対象アドレスに送付できないようになっており、サプレッションリストから対象メールアドレスを除去するのがAWSの管理画面に入らないとできないのがつらくて色々探した結果SendGridを利用することにしました。

SendGridでは上記にあげたサプレッションリストからAPIで除去する仕組みがあり、HTMLメールにすることでメールの開封チェックやリンククリック情報も簡単に取得できるといううれしさもあり、本記事では弊社で使っているメール配信＆トラッキング（Django管理画面で把握できる）の仕組みを紹介しようと思います。

# SendGridとは
## 選定の流れ
サイタさんが同じような変遷を辿ったもよう。ほぼ同じ変遷でした＆SendGridだとAPIが豊富だったりメルマガ配信の仕組み（マーケティングメール？）があったり、本資料でも記述するイベントトラッキングが簡単にできるというのが強みでした。
[Mailgun](https://www.mailgun.com/)だと[MX設定すればメール受信後に複数処理を行える仕組み](https://documentation.mailgun.com/quickstart-receiving.html)があり、管理画面でメールをきれいに管理する仕組みが作れそうですが、後述のDjango×SendGridのいい仕組みもあるのでこちらで良かったかなと。

- [SES, SendGrid, Mandrill, Mailgunの採用を見送った話 | サイタ 開発者ブログ](http://tech.cunited.jp/post/96318734265/ses-sendgrid-mandrill-mailgun)を読むと、SESはblacklistからのアドレス除去をサポート・スタッフが行うことはできないとのことで断念
- その後SendGridを利用することにした：[スタートアップでのSaaS利用はユーザサポート体制で選ぶべき、あるいはSendGridのサポートが素晴... | サイタ 開発者ブログ](http://tech.cunited.jp/post/128152119060/migrated-mta-sendgrid)

## 料金面
- SESは1,000通$0.1+転送料、SendGridはプランにもよるが、シルバープランで10万通/月 9,480円（概算で、SESが0.01円/通に対してSendGridが0.1円/通）
- [メール配信クラウドサービス13個の価格比較グラフを作りました（SES/SendGrid/Mailgun...）](http://atskimura.hatenablog.com/entry/2014/02/13/084051)あたりに最新はやや異なるかもだがまとまってる

# システム全体像
## 環境
Django 1.9.11
Python 3.5.2
Ubuntu 14.04 LTS

## 動作の流れ
![SendGrid連携.png](https://qiita-image-store.s3.amazonaws.com/0/45068/013db5e6-6c37-706a-1b68-8181b75508e2.png)
※Google図形描画を利用して記述

1. システムからSendGridにmessage_id付きでメールを送信する
2. SendGridはメール送信指示を受けてメールアドレスに対してメールを送信する（実際には送信前にBounceチェックや送付先の死活確認などしているかもだが、本範囲を超えるので割愛）
3. SendGridはメッセージを送信した後[Event Webhook](https://sendgrid.kke.co.jp/docs/API_Reference/Webhooks/event.html)という仕組みを利用してシステムにイベントを通知する
  - 「PROCESSED」「DELIVERED」「BOUNCE」のイベントが届く
4. メール受信者がメールを開封したりメール内のURLをクリックするとSendGridに通知が飛ぶ
5. SendGridは通知を受けて3と同様にシステムにイベント通知をする
  - ここでは「OPEN」「CLICK」が届く
  - おそらく、受信者がメールをスパム認定するなどの処理を行えば「SPAMREPORT」「UNSUBSCRIBE」などが届くはずだが未確認

SendGrid側の設定やイベントの概念に関しては以下など参照ください

- [メール業務の運用を最適化するEvent Webhook機能について | SendGridブログ](https://sendgrid.kke.co.jp/blog/?p=753)
- [Event Webhookでイベントを受信する - ドキュメント | SendGrid](https://sendgrid.kke.co.jp/docs/Tutorials/C_Manage_Events/using_event_webhook.html)

# システム構築手順
前段が長くなってしまいましたが、本題のシステム構築手順です。
動作の流れが簡単に試せる状態がゴールです。

※ちなみに以下リポジトリに完成版&Django Grappeliで管理画面を使いやすくした版を置いております。
https://github.com/charly24/sendgrid_sample

## 環境構築
以下で必要な環境＆ライブラリを導入する

```
virtualenv --prompt "(sendgrid)" --python=/usr/bin/python3.5 virtualenv
source virtualenv/bin/activate

pip install six
pip install Django==1.9.11
pip install -e git+https://github.com/charly24/django-sendgrid.git@master#egg=django_sendgrid

django-admin.py startproject sendgrid_sample
```

settings.pyを追記・修正する

```
# Sendgridで送信するようの設定
EMAIL_BACKEND = 'apps.lib.mail.CustomSendGridEmailBackend'  # django.core.mail#send_mail実行時の挙動を書き換える(日本語でメール送付するために必要)
SENDGRID_EMAIL_HOST = 'smtp.sendgrid.net'
SENDGRID_EMAIL_PORT = 587
SENDGRID_EMAIL_USERNAME = 'XXX'  # SendGridの設定値を記述する
SENDGRID_EMAIL_PASSWORD = 'XXX'
SENDGRID_API_KEY = 'XXX'

# django-sendgridを導入
INSTALLED_APPS = (
    ...
    'django_sendgrid',
    ...
)

# 日本用の設定
LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'
```

urls.pyを以下に修正する

```
from django.conf.urls import url, include
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^sendgrid/', include('django_sendgrid.urls')),  # イベント受信用
]
```

apps/lib/mail.pyを作成して、以下を記述する
※この設定なしに、settings.pyの`EMAIL_BACKEND`を`django_sendgrid.backends.SendGridEmailBackend`にするだけでもSendGridでのメール送信を実現できるが、日本語メールを送信した際に変な箇所で改行されたり、その改行がURLの箇所だとリンクが壊れたりするので、設定を推奨します。挙動はソース内のコメント参照

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

import logging
import re

import urllib.error
import urllib.request
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, EmailMessage
from django_sendgrid.backends import SendGridEmailBackend

logger = logging.getLogger(__name__)

__RE_URL = re.compile(r'((https?):((//)|(\\\\))+[\w\d:#@%/;$()~_?\+-=\\\.&]*)')


def convert_html_mail(body):
    """
    plain textをHTML形式に変換する。
    - URLをリンク形式に修正
    - 改行をbrタグに変換
    - bodyタグの中に本文を入れる
    :param body: plain textの本文
    :return: HTML形式の本文
    """
    # <br />の後に\r\nを挿入していないとSendGrid側で長い１行と判断されて、
    # 990文字毎に改行コードが自動的に挿入されてしまう。
    # ※990文字目がURLだった場合、間違ったURLとなってしまう
    #
    # 以下参考
    # - https://sendgrid.com/docs/Classroom/Build/Format_Content/html_formatting_issues.html
    # - http://bit.ly/2d4M3Pv
    body = __RE_URL.sub(r'<a href="\1" target="_blank">\1</a>', body)
    body = '<html>' \
           '<head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head>' \
           '<body>{}</body></html>'.format(
                '<br />\r\n'.join(body.replace('\r', '').split('\n'))
            )
    return body


class CustomSendGridEmailBackend(SendGridEmailBackend):
    """
    django_sendgrid.SendGridEmailBackendの拡張。
    django.core.mail.send_mailからの送信とSendGridEmailMessage/SendGridEmailMultiAlternativesからの
    送信を透過的に取り扱う。
    この仕組を通すことで、django.core.mail#send_mailを利用した際にもSendGridでのメール送信を行うことができる
    ※django_sendgridのSignalには対応させていない
    """
    def send_messages(self, email_messages):
        # INSTALLED_APPS評価前にこのファイル内のModelがloadされてしまうため、以下の警告が発生する。
        # RemovedInDjango19Warning: Model class django_sendgrid.models.DroppedEvent doesn't
        # declare an explicit app_label and either isn't in an application in INSTALLED_APPS
        # or else was imported before its application was loaded. This will no longer be supported
        # in Django 1.9.
        # そのため、Runtimeで実行時にloadするように修正している
        from django_sendgrid.models import save_email_message
        from django_sendgrid.message import SendGridEmailMessage, SendGridEmailMultiAlternatives

        _email_messages = []
        for m in email_messages:
            if isinstance(m, (SendGridEmailMessage, SendGridEmailMultiAlternatives)):
                # SendGridのメール送信Modelからの送信の場合はそのまま配列に入れる
                _email_messages.append(m)
            else:
                # そうでない場合、EmailMessageを継承しているのであればSendGrid用のインスタンスに詰め替える
                if isinstance(m, EmailMultiAlternatives):
                    instance = SendGridEmailMultiAlternatives(
                        subject=m.subject, body=m.body, from_email=m.from_email, to=m.to, bcc=m.bcc,
                        connection=m.connection, attachments=m.attachments, headers=m.extra_headers,
                        alternatives=m.alternatives, cc=m.cc, reply_to=m.reply_to,
                    )
                elif isinstance(m, EmailMessage):
                    # 後でHTMLメールを設定するので、EmailMessageでもMultiAlternativesに詰め替えている
                    instance = SendGridEmailMultiAlternatives(
                        subject=m.subject, body=m.body, from_email=m.from_email, to=m.to, bcc=m.bcc,
                        connection=m.connection, attachments=m.attachments, headers=m.extra_headers,
                        cc=m.cc, reply_to=m.reply_to,
                    )
                else:
                    raise NotImplementedError(
                        'email_message must be inherited EmailMessage or EmailMultiAlternatives')

                # HTMLメールの指定が無い場合、HTMLメールに変換する
                for alternative in m.alternatives:
                    if alternative[1] == 'text/html':
                        break
                else:
                    instance.attach_alternative(convert_html_mail(m.body), 'text/html')

                instance.prep_message_for_sending()
                save_email_message(sender=instance, message=instance)
                _email_messages.append(instance)

        return super().send_messages(_email_messages)


def remove_bounce(email):
    """
    SendGrid APIを利用してバウンスメールアドレスを除去する。
    ※API呼び出し時に想定外のエラーとなった場合、例外が発生する

    refs: https://sendgrid.kke.co.jp/docs/API_Reference/Web_API_v3/bounces.html
    :param email: 除去対象のメールアドレス
    :return: 除去ないしは未登録であった場合はTrue
    """
    headers = {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer {}'.format(settings.SENDGRID_API_KEY)
    }
    url = 'https://api.sendgrid.com/v3/suppression/bounces/{}'.format(email)
    req = urllib.request.Request(url, headers=headers, method='DELETE')
    try:
        with urllib.request.urlopen(req):
            return True
    except urllib.error.HTTPError as e:
        if e.code == 404:
            # 未登録/解除済のメールアドレスを除去しようとすると404エラーとなる(正常)
            logger.info('{} has not registered as a bounce.'.format(email))
            return True
        else:
            # それ以外のエラーの場合、例外として処理する
            raise

```

## 動作確認
以下でDBを構築して、WEBサーバーを起動させる。

```
./manage.py migrate
./manage.py runserver 127.0.0.1:8000
```

メール送信そのものは以下などで実行可能

```
from django.core.mail import send_mail
send_mail('サンプルメール', '本文\n本文', 'fromアドレス', ['toアドレス'])
```

管理画面にログインするためのsuperuserを作成する

```
./manage.py createsuperuser
```

http://127.0.0.1:8000/admin/ より管理画面にログインでき、以下のような画面になる
<img width="634" alt="SendGrid_一覧.png" src="https://qiita-image-store.s3.amazonaws.com/0/45068/f833a989-ccbf-bd4d-02a1-c5d8df313a0c.png">
「Email Messages」を選択するとさきほど送信したメールを確認できる。

<img width="1466" alt="SendGrid初期状態.png" src="https://qiita-image-store.s3.amazonaws.com/0/45068/2f171dbb-e783-e4ee-659a-7bfcbefcb5f0.png">
※こちらの画面は使いやすさ向上のため[Django Grappelli](http://grappelliproject.com/)適用した画面です

デプロイ済でSendGrid側の[Event Webhook](https://sendgrid.kke.co.jp/docs/API_Reference/Webhooks/event.html)の設定で`/sendgrid/events/`を設定済の場合SendGridからイベントが送付されますが、以下のように擬似的にイベント通知をテストすることもできます。

```
$ curl -i -d 'message_id=4f53f738-afb5-4f35-bf7e-1899af600a65&amp;email=fromアドレス.jp&amp;event=processed' http://127.0.0.1:8000/sendgrid/events/
HTTP/1.0 200 OK
Date: Thu, 22 Dec 2016 08:23:48 GMT
Server: WSGIServer/0.2 CPython/3.5.2
X-Frame-Options: SAMEORIGIN
Content-Type: text/html; charset=utf-8
```
※message_idは管理画面から取得

擬似的にイベントを送付した後に再度画面を閲覧すると、以下のようになります。
<img width="1469" alt="SendGrid送信後.png" src="https://qiita-image-store.s3.amazonaws.com/0/45068/e1a32462-5d92-d0ee-de1b-759c08242cae.png">

# その他必要そうなこと
SendGridをMTAにしてメール送信することで上記のように簡単にイベントのトラッキングができ、それを管理画面で閲覧することができますが、弊社の環境だと１通のメール送信に１秒程度かかってしまうので、ユーザー数が増えてきた際には一括メール配信の仕組みが必要になってきます。
SendGridの場合アプローチ方法は２種はあり、以下などで実現可能ですが本資料の範囲外として割愛します。

- Marketing EmailのAPIを使う([参考１](https://sendgrid.kke.co.jp/docs/API_Reference/Web_API/Legacy_Features/Marketing_Emails_API/index.html), [参考２](http://blog.shibayan.jp/entry/20150222/1424536905))
- Celeryなどでキューイングする

# 使用したライブラリについて（Django SendGrid）
本記事の中でしれっとDjango×SendGridのライブラリが出ておりしれっと私のリポジトリを参照していますが、いくつかの理由がありました。
元々のライブラリは https://github.com/RyanBalfanz/django-sendgrid なのですが、どう見てもメンテしておらず、
https://github.com/RyanBalfanz/django-sendgrid/pull/67#issuecomment-50836387
<img width="777" alt="スクリーンショット 2016-12-22 18.58.53.png" src="https://qiita-image-store.s3.amazonaws.com/0/45068/0ec36dd6-8f6d-7c7d-8985-05303660ac1a.png">
な感じに「オレはもうDjangoもSendGridも使ってないんだ」と言っており案の定2014年下旬以降メンテナンスされていません。
しかし、イベントトラッキングの仕組みやSendGridに即したModelもあり便利そうであったため、私の方でDjango1.8以降対応やPython3対応などを行ったというものです。

主な修正点は以下で、最初Python3対応だけしてPR送ったのですがいっこうに反応なかったので、そのまま育て上げたというライブラリになっております。

- Django1.8対応（元々は1.3系以降用でSouth使っていた）
- ライブラリのパッケージ名をsendgrid→django_sendgridに変更（sendgridはオフィシャルパッケージで使ってるので、、）
- Python3系で動作するように修正
- 気持ち悪かったのでPEP8対応
- タブは嫌だったのでスペースに修正
- イベント受信完了した際のSignals追加
- いくつかバグ修正（結構ダイナミックに、、）

※元々のPython2.7×Django1.6でちゃんと動作確認させたわけではないので、Django1.8対応とPython3対応時に後方互換性が損なわれてデグレっている可能性はあります。ただ、先述のシステム環境において数ヶ月以上の動作実績があります。

ちなみにパッケージ名の変更もあり差分はひどいことになっています、、 https://github.com/RyanBalfanz/django-sendgrid/compare/master...charly24:master?w=
（こーゆーのって育てていくのが正しいんでしょうかねぇ）