DjangoとSendGridでいい感じのメール配信の仕組みを作る
この記事は「Django Advent Calendar 2016」24日目の記事です。
弊社リーディングマークはレクミーキャリアという転職支援サービスを運営しているのですが、AWSずっぷりで開発を進めております。
リリース当初はMTAとしてSESを利用していたのですが、SESではエラーとなったメールアドレスをサプレッションリストに入れて再度対象アドレスに送付できないようになっており、サプレッションリストから対象メールアドレスを除去するのがAWSの管理画面に入らないとできないのがつらくて色々探した結果SendGridを利用することにしました。
SendGridでは上記にあげたサプレッションリストからAPIで除去する仕組みがあり、HTMLメールにすることでメールの開封チェックやリンククリック情報も簡単に取得できるといううれしさもあり、本記事では弊社で使っているメール配信&トラッキング(Django管理画面で把握できる)の仕組みを紹介しようと思います。
SendGridとは
選定の流れ
サイタさんが同じような変遷を辿ったもよう。ほぼ同じ変遷でした&SendGridだとAPIが豊富だったりメルマガ配信の仕組み(マーケティングメール?)があったり、本資料でも記述するイベントトラッキングが簡単にできるというのが強みでした。
MailgunだとMX設定すればメール受信後に複数処理を行える仕組みがあり、管理画面でメールをきれいに管理する仕組みが作れそうですが、後述のDjango×SendGridのいい仕組みもあるのでこちらで良かったかなと。
- SES, SendGrid, Mandrill, Mailgunの採用を見送った話 | サイタ 開発者ブログを読むと、SESはblacklistからのアドレス除去をサポート・スタッフが行うことはできないとのことで断念
- その後SendGridを利用することにした:スタートアップでのSaaS利用はユーザサポート体制で選ぶべき、あるいはSendGridのサポートが素晴... | サイタ 開発者ブログ
料金面
- SESは1,000通$0.1+転送料、SendGridはプランにもよるが、シルバープランで10万通/月 9,480円(概算で、SESが0.01円/通に対してSendGridが0.1円/通)
- メール配信クラウドサービス13個の価格比較グラフを作りました(SES/SendGrid/Mailgun...)あたりに最新はやや異なるかもだがまとまってる
システム全体像
環境
Django 1.9.11
Python 3.5.2
Ubuntu 14.04 LTS
動作の流れ
- システムからSendGridにmessage_id付きでメールを送信する
- SendGridはメール送信指示を受けてメールアドレスに対してメールを送信する(実際には送信前にBounceチェックや送付先の死活確認などしているかもだが、本範囲を超えるので割愛)
- SendGridはメッセージを送信した後Event Webhookという仕組みを利用してシステムにイベントを通知する
- 「PROCESSED」「DELIVERED」「BOUNCE」のイベントが届く
- メール受信者がメールを開封したりメール内のURLをクリックするとSendGridに通知が飛ぶ
- SendGridは通知を受けて3と同様にシステムにイベント通知をする
- ここでは「OPEN」「CLICK」が届く
- おそらく、受信者がメールをスパム認定するなどの処理を行えば「SPAMREPORT」「UNSUBSCRIBE」などが届くはずだが未確認
SendGrid側の設定やイベントの概念に関しては以下など参照ください
システム構築手順
前段が長くなってしまいましたが、本題のシステム構築手順です。
動作の流れが簡単に試せる状態がゴールです。
※ちなみに以下リポジトリに完成版&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側で長い1行と判断されて、
# 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/ より管理画面にログインでき、以下のような画面になる
「Email Messages」を選択するとさきほど送信したメールを確認できる。
デプロイ済でSendGrid側のEvent Webhookの設定で/sendgrid/events/
を設定済の場合SendGridからイベントが送付されますが、以下のように擬似的にイベント通知をテストすることもできます。
$ curl -i -d 'message_id=4f53f738-afb5-4f35-bf7e-1899af600a65&email=fromアドレス.jp&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は管理画面から取得
擬似的にイベントを送付した後に再度画面を閲覧すると、以下のようになります。
その他必要そうなこと
SendGridをMTAにしてメール送信することで上記のように簡単にイベントのトラッキングができ、それを管理画面で閲覧することができますが、弊社の環境だと1通のメール送信に1秒程度かかってしまうので、ユーザー数が増えてきた際には一括メール配信の仕組みが必要になってきます。
SendGridの場合アプローチ方法は2種はあり、以下などで実現可能ですが本資料の範囲外として割愛します。
使用したライブラリについて(Django SendGrid)
本記事の中でしれっとDjango×SendGridのライブラリが出ておりしれっと私のリポジトリを参照していますが、いくつかの理由がありました。
元々のライブラリは https://github.com/RyanBalfanz/django-sendgrid なのですが、どう見てもメンテしておらず、
https://github.com/RyanBalfanz/django-sendgrid/pull/67#issuecomment-50836387
な感じに「オレはもう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=
(こーゆーのって育てていくのが正しいんでしょうかねぇ)