Pro Yearly is on sale from $80 to $50! »

Djangoでのメール送信 - 設定からテストまで/djangocongress-jp-2019-talk

Djangoでのメール送信 - 設定からテストまで/djangocongress-jp-2019-talk

DjangoCongress JP 2019の発表資料です

D484f3a4d5f516f943b29b9ff55a2040?s=128

thinkAmi

May 18, 2019
Tweet

Transcript

  1. Django でのメール送信 ~ 設定からテストまで ~ DjangoCongress JP 2019 talk 2019/5/18

    @thinkAmi
  2. お前誰よ / Who are you ? @thinkAmi Python & Django

    Blog :メモ的な思考的な (http://thinkami.hatenablog.com/) ( 株) 日本システム技研 PyCon JP 2016-18 Silver Sponsor ギークラボ長野 Python Boot Camp in 長野 みんなのPython 勉強会 in 長野
  3. 質問

  4. Django でメール送信の経験がある方

  5. 簡単! Django でメール送信

  6. 設定ファイル # settings.py EMAIL_HOST = 'smtp.example.com' # 送信メールサーバ EMAIL_PORT =

    '587' # 送信メールポート EMAIL_HOST_USER = 'test_user' # 送信ユーザ EMAIL_HOST_PASSWORD = 'Passw0rd' # 送信パスワード
  7. send_mail() 関数の実行 send_mail(subject='my subject', # 件名 message='My Body', # 本文

    from_email='from@example.com', # 送信者 recipient_list=['to@example.com']) # 受信者(To) のリスト
  8. 受信メール

  9. メールの送信 send_mail(subject='my subject', # 件名 message='My Body', # 本文 from_email='from@example.com',

    # 送信者 recipient_list=['to@example.com']) # 受信者(To) のリスト
  10. 話すこと Django のメール送信の流れ EmailMessage EmailBackend メール送信とunittest ショートカット関数 エラー通知メール エラー通知の仕組み 内容のマスク

  11. 話さないこと 仕組み以外の話 メールのRFC メール配信サービスの設定 SendGrid Amazon SES

  12. 注意 このトークを聴いても、Django アプリは作れません スライドは公開 スライド中のソースコードは、一部省略 GitHub に全体を公開済 https://github.com/thinkAmi/DjangoCongress_JP_2019_talk

  13. 環境 Python 3.7.3 Django 2.2.1 LTS

  14. 目次 Django のメール送信の流れ unittest ショートカット関数 エラー通知メール

  15. Django のメールの仕組み send_mail() 関数を元に理解する

  16. send_mail() 関数は何をしているか # django.core.mail.__init__.py def send_mail(subject, message, from_email, recipient_list, fail_silently=False,

    auth_user=None, auth_password=None, connection=None, html_message=None): # EmailBackend オブジェクトを取得 connection = connection or get_connection() # EmailMessage 系オブジェクトを生成 mail = EmailMultiAlternatives( subject, message, from_email, recipient_list, connection=connection) # EmailMessage 系オブジェクトで送信 return mail.send()
  17. send_mail() 関数 メール送信のショートカット関数 実際 EmailMessage EmailBackend

  18. EmailMessage について

  19. EmailMessage の役割 メール属性を持つ EmailBackend を使ってメールを送信

  20. EmailMessage のメール属性 from_email, to, cc, bcc, reply_to subject body attachments

    extra_headers connection EmailBackend
  21. EmailMessage とsend_mail() の違い できること どちらでも EmailMessage のみ

  22. どちらでもできる例 メールアドレスに表示名を付けたい HTML メールを送信したい メールテンプレートを使いたい

  23. メールアドレスの表示名とは from_user <from@example.com>

  24. メールアドレスに表示名を付ける # EmailMessage の場合 email = EmailMessage( from_email='from_user <from@example.com>', ...)

    # send_mail() の場合 send_mail(from_email='from user <from@example.com>')
  25. 受信メール ( ヘッダ) From: from_user <from@example.com>

  26. HTML メールを送信したい # EmailMessage( のサブクラス) の場合 email = EmailMultiAlternatives( body='My

    Body', ...) email.attach_alternative('<strong>HTML メール</strong> です', 'text/html') email.send() # send_mail() の場合 send_mail(html_message='<strong>HTML メール</strong> です')
  27. 受信メール テキスト表示 HTML 表示

  28. メールでテンプレートを使いたい render_to_string() 関数と組み合わせる

  29. テンプレートエンジンを変更 TEMPLATES = [ { # View は、Django エンジン 'BACKEND':

    'django.template.backends.django.DjangoTemplates', 'DIRS': [os.path.join(BASE_DIR, 'templates')], }, { # メールは、Jinja2 エンジン 'BACKEND': 'django.template.backends.jinja2.Jinja2', 'DIRS': [os.path.join(BASE_DIR, 'template_jinja2')], }, ]
  30. テキストメールのテンプレート # base.txt メールのベースです {% block mail_content %} {% endblock

    %} {% include './signature.txt' %} # content.txt {% extends './base.txt' %} {% block mail_content %} {{ message }} {% endblock %} # signature.txt from thinkAmi
  31. テンプレートを利用して送信 template_body = render_to_string('mail/content.txt', context={'message': ' 埋め込みます'}) # EmailMessage の場合

    email = EmailMessage(body=template_body, ...) # send_mail() の場合 send_mail(message=template_body, ...)
  32. 受信メール

  33. EmailMessage のみできる例 添付ファイル

  34. 添付ファイル EmailMessage のメソッドを使用 関数 引数 添付ファイル名 __init__() MIMEBase 系, tuple

    ( *) 指定可 attach() 〃 〃 attach_file() ファイルシステム上のパス ファイルシステムと同一 * tuple(filename, content, mimetype)
  35. メソッドの使い方 # 静的ディレクトリにあるファイルを添付する static_file_dir = pathlib.Path(settings.STATICFILES_DIRS[0]) image_file = static_file_dir.joinpath('images', 'shinanogold.png')

    # __init__ の場合 with image_file.open(mode='rb') as f: EmailMessage(attachments=[('my.png', f.read(), 'image/png')], ...).send() # attach_file の場合 msg = EmailMessage(...) msg.attach_file(image_file) msg.send()
  36. __init__ での結果

  37. attach_file での結果

  38. EmailBackend について

  39. EmailBackend とは メールを送信する手段 拡張や自作も可能

  40. EmailBackend の種類 Django 標準 django.core.mail.backends メール内容をどこに出力するかで使い分け SMTP コンソール ファイル インメモリ

    ダミー
  41. EmailBackend の指定 settings の EMAIL_BACKEND デフォルト SMTP

  42. EmailBackend に必要な設定 EmailBackend により異なる # SMTP 時のsettings EMAIL_HOST = 'smtp.example.com'

    # 送信メールサーバ EMAIL_PORT = '587' # 送信メールポート EMAIL_HOST_USER = 'test_user' # 送信ユーザ EMAIL_HOST_PASSWORD = 'Passw0rd' # 送信パスワード
  43. EmailBackend の拡張 例 console.EmailBackend でも、件名を日本語で見たい Subject: =?utf-8?b?5Lu25ZCN44Gn44GZ?=

  44. console.EmailBackend を拡張 write_message() メソッドをオーバーライド # myapp.email_backends.py class ReadableSubjectEmailBackend(console.EmailBackend): def write_message(self,

    message): from email.header import decode_header subject = message.message().get('Subject') decoded_tuple = decode_header(subject) # => [('Django', None)] # MIME ヘッダエンコーディングなし # => [(b'\xe3\x82\xb8\xe3\x83\xa3\xe3\x83\xb3\xe3\x82\xb4', 'utf-8')] if decoded_tuple[0][1] is not None: readable_subject = decoded_tuple[0][0].decode( decoded_tuple[0][1]) self.stream.write(f'\nSubject ( 日本語表示): {readable_subject}\n') super().write_message(message)
  45. settings EMAIL_BACKEND = 'myapp.email_backends.ReadableSubjectEmailBackend'

  46. 結果 Subject ( 日本語表示): ジャンゴ Content-Type: text/plain; charset="utf-8" Subject: =?utf-8?b?44K444Oj44Oz44K0?=

  47. EmailBackend を自作 send_messages() メソッドを実装したクラスを用意 django.core.mail.backends.base.BaseEmailBackend を継承すると楽

  48. 例 メール送信ではなく、Slack に投稿したい SlackBackend

  49. SlackBackend の実装 メールのBody をSlack へ投稿する from slackclient import SlackClient class

    SlackBackend(BaseEmailBackend): def send_messages(self, email_messages): payload = email_messages[0].message().get_payload() client = SlackClient(settings.SLACK_OAUTH_ACCESS_TOKEN) client.api_call( 'chat.postMessage', channel=settings.SLACK_CHANNEL, text=payload, )
  50. settings EMAIL_BACKEND = 'myapp.email_backends.SlackBackend' SLACK_OAUTH_ACCESS_TOKEN = 'Your OAuth Access Token'

    SLACK_CHANNEL = 'Your Channel ID'
  51. メールの送信処理 EmailMessage( subject='Django', body='DjangoCongress JP 2019 にようこそ!', from_email=settings.REAL_MAIL_FROM, to=settings.REAL_MAIL_TO).send()

  52. 結果

  53. ここまでのまとめ EmailMessage でメール情報を作る EmailBackend を使って送信する

  54. 目次 Django のメール送信の流れ unittest ショートカット関数 エラー通知メール

  55. 開発時の悩み メール内容の確認をしたい 実メールサーバに送信したくない

  56. 実メールサーバ以外での確認 EmailBackend を差し替え コンソールやファイルなど Python のSMTP サーバ DebuggingServer を宛先にして送信

  57. unittest 時 メールは送信されない EmailBackend が locmem.EmailBackend に差し替わる

  58. 差し替えているところ settings.TEST_RUNNER django.test.runner.DiscoverRunner setup_test_environment() django.test.utils.setup_test_environment() # https://github.com/django/django/blob/2.2.1/django/test/utils.py#L103 # EmailBackend の差し替え

    settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' # メールボックスの初期化 mail.outbox = []
  59. locmem に差し替わると django.core.mail.outbox に、EmailMessage のリストが設定

  60. テスト対象の関数 def my_send_mail(encoding='utf-8', has_attachment=False): msg = EmailMessage( subject=' 件名', body='

    本文', from_email=' 差出人 <from@example.com>', to=[' 送信先1 <to1@example.com>', ' 送信先2 <to2@example.com>'], cc=[' シーシー <cc@example.com>'], bcc=[' ビーシーシー <bcc@example.com>'], reply_to=[' 返信先 <reply@example.com>'], headers={'Sender': 'sender@example.com'}) if has_attachment: # 静的ディレクトリにあるファイルを添付する img = pathlib.Path(settings.STATICFILES_DIRS[0]).joinpath( 'images', 'shinanogold.png') msg.attach_file(img) msg.send()
  61. mail.outbox の動作確認 class TestSendMail(TestCase): def _callFUT(self, encoding='utf-8', has_attachment=False): from myapp.utils

    import my_send_mail my_send_mail(encoding=encoding, has_attachment=has_attachment) def test_send_multiple(self): # 実行前はメールボックスに何もない self.assertEqual(len(mail.outbox), 0) # 1 回実行すると、メールが1 通入る self._callFUT() self.assertEqual(len(mail.outbox), 1) # もう1 回実行すると、メールが2 通入る self._callFUT() self.assertEqual(len(mail.outbox), 2)
  62. 各フィールドの検証 def test_mail_fields(self): self._callFUT() actual = mail.outbox[0] self.assertEqual(actual.subject, ' 件名')

    self.assertEqual(actual.body, ' 本文') self.assertEqual(actual.from_email, ' 差出人 <from@example.com>') # 宛先系はlist として設定される self.assertEqual(actual.to, [' 送信先1 <to1@example.com>', ' 送信先2 <to2@example.com>'],) # 追加ヘッダも含まれる self.assertEqual(actual.extra_headers['Sender'], 'sender@example.com')
  63. 添付ファイルがある場合 def test_attachment(self): self._callFUT(has_attachment=True) actual = mail.outbox[0] # 添付ファイル自体を検証 img

    = pathlib.Path(settings.STATICFILES_DIRS[0]).joinpath( 'images', 'shinanogold.png') with img.open('rb') as f: expected_img = f.read() # tuple(filename, content, mimetype) self.assertEqual(actual.attachments[0][1], expected_img)
  64. ここまでのまとめ unittest では、メールは送信されない django.core.mail.outbox に、EmailMessage のリストが設定

  65. 目次 Django のメール送信の流れ unittest ショートカット関数 エラー通知メール

  66. ショートカット関数 User モデル email_user() 一括送信 send_mass_mail() 管理者宛送信 mail_admins() mail_managers()

  67. User モデルの email_user() # ユーザー foo を取得 foo_user = User.objects.get(username='foo')

    # ユーザー foo のメールアドレスへ送信 foo_user.email_user( subject='Hello', message='Welcome!', from_email='from@example.com', connection=console.EmailBackend(), )
  68. send_mass_mail() 1 接続で複数のメールを送信 複数件送信する場合、 send_mail() より効率が良い msg1 = ('shortcut subject1',

    'Shortcut Body', 'from1@example.com', ['to1_1@example.com', 'to1_2@example.com']) msg2 = ('shortcut subject2', 'Shortcut Body', 'from2@example.com', ['to2_1@example.com', 'to2_2@example.com']) send_mass_mail((msg1, msg2), connection=console.EmailBackend())
  69. 注意点 機能 EmailMessage send_mail() send_mass_mail() 宛先 To, Cc, Bcc, Reply-To

    To To HTML メール 添付ファイル
  70. 管理者宛メールのショートカット関数 mail_admins() settings.ADMINS 宛にメール送信 mail_managers() settings.MANAGERS 宛にメール送信

  71. ADMINS とMANAGERS の違い 公式ドキュメントより ADMINS = site admins MANAGERS =

    site managers Django のエラー通知機能にて、両者の違いを見る
  72. 目次 Django のメール送信の流れ unittest ショートカット関数 エラー通知メール

  73. Django のエラー通知メール Django アプリでエラーが発生した時に、メールで通知 サーバーエラー HTTP 500 リンク切れ Referer あり

    & HTTP 404 存在しないページに直接アクセスした時は通知しない
  74. サーバーエラー通知メールの仕組み # django.utils.log.py DEFAULT_LOGGING = { 'filters': { 'require_debug_false': {

    '()': 'django.utils.log.RequireDebugFalse'}, 'handlers': { 'mail_admins': { 'level': 'ERROR', 'filters': ['require_debug_false'], 'class': 'django.utils.log.AdminEmailHandler'}}, 'loggers': { 'django': { 'handlers': ['console', 'mail_admins']}}
  75. 設定の変更例 エラー通知メールは常にコンソール出力 LOGGING = { 'handlers': { 'mail_admins': { ...

    'email_backend': # 追加 'django.core.mail.backends.console.EmailBackend'}
  76. 自作 AdminEmailHandler を継承し、 send_mail() をオーバーライド

  77. 例 エラーレポートのHTML をパスワード付zip ファイルにして送信 class MyAdminEmailHandler(AdminEmailHandler): def send_mail(self, subject, message,

    *args, **kwargs): with TemporaryDirectory() as temp_dir: html_file = pathlib.Path(temp_dir).joinpath('report.html') with html_file.open('w') as f: f.write(kwargs.get('html_message')) # パスワード付zip ファイルを添付・送信 zip_file = pathlib.Path(temp_dir).joinpath('dst.zip') pyminizip.compress(str(html_file), None, str(zip_file), 'pass', 0) msg = EmailMessage(...) msg.attach_file(zip_file) msg.send()
  78. 結果

  79. リンク切れ通知メールの仕組み django.middleware.common.BrokenLinkEmailsMiddleware

  80. 通知先の違い サーバーエラー MAIL_ADMINS リンク切れ MAIL_MANAGERS

  81. サーバーエラーの通知設定 # 本番運用モードにする DEBUG = False # 送信先の site admins

    のメールアドレスを設定 # (' メールアドレスコメント', ' メールアドレス') ADMINS = [('Admin1', 'admin1@example.com')] # 他、使用するEmailBackend の設定を行う
  82. サーバーエラー通知設定 ( 任意) # 送信元のメールアドレス # デフォルト:root@localhost SERVER_EMAIL = 'server@example.com'

    # エラー通知メールの件名のPrefix # デフォルト:[Django] EMAIL_SUBJECT_PREFIX = '[Hello]'
  83. 動作確認 $ curl http://localhost:8000/force_500 <h1>Server Error (500)</h1>

  84. 受信メール エラー内容やsettings 、POST データなどが含まれる デフォルトでは、ローカル変数は含まれない Subject: [Hello]ERROR (EXTERNAL IP): Internal

    Server Error: /force_500 From: server@example.com To: admin1@example.com Internal Server Error: /force_500 ... Request Method: GET Request URL: http://localhost:8000/force_500 ... YEAR_MONTH_FORMAT =3D 'F Y'
  85. ローカル変数もほしい時 単純な方法:Debug=True の時のHTML を含める # settings DEFAULT_LOGGING['handlers']['mail_admins']['include_html'] = True

  86. 動作確認 例外を発生するView def force_500(request): my_local_value = ' ハロー' raise Exception('Error')

  87. ローカル変数を含むサーバーエラー通知メール Subject: [Hello]ERROR (EXTERNAL IP): Internal Server Error: /force_500 From:

    server@example.com To: admin1@example.com ... <!DOCTYPE html> <tr> <td>my_local_value</td> <td class="code"><pre>&#39; ハロー&#39;</pre></td> </tr>
  88. サーバーエラー通知メールの注意点 サーバー情報が漏洩する可能性あり settings ローカル変数 POST データ 必要に応じて、内容をマスクする

  89. settings に対する、自動マスク機能 以下のパターンを含む設定名は、Django が自動でマスク API KEY PASS SECRET SIGNATURE TOKEN

  90. 動作確認 settings DJANGO_CONGRESS_PASSPORT = '123' DJANGO_CONGRESS_PASSWORD = '456' DJANGO_CONGRESS_PASTA =

    '789'
  91. 結果 DJANGO_CONGRESS_PASSPORT =3D '********************' DJANGO_CONGRESS_PASSWORD =3D '********************' DJANGO_CONGRESS_PASTA =3D '789'

  92. ローカル変数に対するマスク 以下の場合、デフォルトではそのまま表示 DEFAULT_LOGGING['handlers']['mail_admins']['include_html'] = True @sensitive_variables デコレータを使う

  93. 一部をマスクする例 View @method_decorator(sensitive_variables('region', 'year'), name='dispatch') class MaskedLocalVariableView(TemplateView): """ 一部ローカル変数をマスク """

    template_name = 'myapp/breaking.html' def get(self, request, *args, **kwargs): conference = 'DjangoCongress' region = 'JP' # マスクする year = '2019' # マスクする raise Exception
  94. 一部マスクの結果 <li class="frame user"> <h2>Local Vars</h2> <td>conference</td> <td class="code"><pre>&#39;DjangoCongress&#39;</pre></td> <td>region</td>

    <td class="code"><pre>&#39;********************&#39;</pre></td> <td>year</td> <td class="code"><pre>&#39;********************&#39;</pre></td>
  95. すべてをマスクする例 View @method_decorator(sensitive_variables(), name='dispatch') class AllMaskedLocalVariableView(TemplateView): ...

  96. エラー通知に含まれるPOST データ デフォルトでは、そのまま表示 Exception Type: Exception at /post_parameters POST: apple

    =3D 'Shinano Gold' grape =3D 'Shine Muscat' pear =3D 'Southern Suite' @sensitive_post_parameters デコレータを使う
  97. 一部をマスクする例 View @method_decorator(sensitive_post_parameters('grape', 'pear'), name='dispatch') class MaskedPostParameterView(FormView): def post(self, request,

    *args, **kwargs): raise Exception
  98. 一部マスクの結果 Request information: POST: apple =3D 'Shinano Gold' grape =3D

    '********************' pear =3D '********************'
  99. すべてをマスクする例 View @method_decorator(sensitive_post_parameters(), name='dispatch') class AllMaskedPostParameterView(FormView): ...

  100. ちなみに ローカル変数とPOST データを両方マスク @method_decorator(sensitive_variables('year'), name='dispatch') @method_decorator(sensitive_post_parameters('grape'), name='dispatch') class DoubleMaskedView(FormView): ...

  101. マスク機能を自作 デコレータをView に付けたくない 一括でマスクしたい 自作のマスク機能を作成することで対応可能 SafeExceptionReporterFilter のサブクラスが推奨

  102. マスク機能 class MyReporterFilter(SafeExceptionReporterFilter): def get_post_parameters(self, request): """ POST データをマスク """

    cleansed = request.POST.copy() cleansed['grape'] = '?' * 30 return cleansed def get_traceback_frame_variables(self, request, tb_frame): """ トレースバック中のローカル変数をマスク """ cleansed = {} for name, value in tb_frame.f_locals.items(): if name == 'year': value = '?' * 30 else: value = self.cleanse_special_types(request, value) cleansed[name] = value return cleansed.items()
  103. 自作マスク機能を使用 settings DEFAULT_EXCEPTION_REPORTER_FILTER = 'myapp.reporter_filter.MyReporterFilter'

  104. マスク結果 Request information: POST: apple =3D 'Shinano Gold' grape =3D

    '??????????????????????????????' pear =3D 'Southern Suite' ... <li class="frame user"> <h2>Local Vars</h2> <td>conference</td> <td class="code"><pre>&#39;DjangoCongress&#39;</pre></td> <td>region</td> <td class="code"><pre>&#39;JP&#39;</pre></td> <td>year</td> <td class="code"><pre>&#39;??????????????????????????????&#39;</pre></td>
  105. リンク切れの通知設定 # 本番運用モードにする DEBUG = False # リンク切れを通知するミドルウェアを追加 MIDDLEWARE +=

    ['django.middleware.common.BrokenLinkEmailsMiddleware'] # 送信先の site managers のメールアドレスを設定 # (' メールアドレスコメント', ' メールアドレス') MANAGERS = [('Manager1', 'manager1@example.com')] # 他、使用するEmailBackend の設定を行う
  106. リンク切れの通知設定 ( 任意) # ADMINS と共用 SERVER_EMAIL = '...' EMAIL_SUBJECT_PREFIX

    = '...' # HTTP404 でもエラーレポートメールを送信したくないURL がある場合は、正規表現で指定 IGNORABLE_404_URLS = [ re.compile(r'^/ignore_404$'), ]
  107. 動作確認 Referer ありで、HTTP 404 # 実際は1 行 $ curl -H

    "Referer:http://localhost:8000/breaking_link" http://localhost:8000/force_404 <h1>Not Found</h1><p>The requested resource was not found on this server.</p>
  108. 受信メール Subject: [Hello]Broken INTERNAL link on localhost:8000 From: server@example.com To:

    manager1@example.com Referrer: http://localhost:8000/breaking_link Requested URL: /force_404 User agent: curl/7.54.0 IP address: 127.0.0.1
  109. まとめ メール送信まわりは標準搭載 unittest ショートカット関数 エラー通知 メール送信まわりの機能は、いずれも拡張・自作可 エラー通知メールは、情報漏えいに注意 必要に応じてマスク

  110. Enjoy Django email life!!

  111. Appendix

  112. エンコーディングの変更 メールのエンコーディング 非ASCII 文字を送信するために使用 ヘッダ 件名 Content-Type

  113. デフォルトの挙動 件名に日本語をセット email = EmailMessage(subject=' 件名です', ...) 受信メール Content-Type: text/plain;

    charset="utf-8" Content-Transfer-Encoding: 7bit Subject: =?utf-8?b?5Lu25ZCN44Gn44GZ?=
  114. メールのエンコーディング設定 settings.DEFAULT_CHARSET utf-8 HttpResponse オブジェクトのエンコーディング

  115. EmailMessage オブジェクトの属性 encoding メールのエンコーディングを変更可能

  116. 例 エンコーディングを ISO-2022-JP に変更 email = EmailMessage(subject=' 件名です', ...) email.encoding

    = 'iso-2022-jp'
  117. 結果 受信メール Content-Type: text/plain; charset="iso-2022-jp" Content-Transfer-Encoding: 7bit Subject: =?iso-2022-jp?b?GyRCN29MPiRHJDkbKEI=?=

  118. Thanks!!