Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

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

DjangoCongress JP 2019の発表資料です

thinkAmi

May 18, 2019
Tweet

More Decks by thinkAmi

Other Decks in Programming

Transcript

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

    Blog :メモ的な思考的な (http://thinkami.hatenablog.com/) ( 株) 日本システム技研 PyCon JP 2016-18 Silver Sponsor ギークラボ長野 Python Boot Camp in 長野 みんなのPython 勉強会 in 長野
  2. 設定ファイル # settings.py EMAIL_HOST = 'smtp.example.com' # 送信メールサーバ EMAIL_PORT =

    '587' # 送信メールポート EMAIL_HOST_USER = 'test_user' # 送信ユーザ EMAIL_HOST_PASSWORD = 'Passw0rd' # 送信パスワード
  3. 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()
  4. 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> です')
  5. テンプレートエンジンを変更 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')], }, ]
  6. テキストメールのテンプレート # base.txt メールのベースです {% block mail_content %} {% endblock

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

    email = EmailMessage(body=template_body, ...) # send_mail() の場合 send_mail(message=template_body, ...)
  8. 添付ファイル EmailMessage のメソッドを使用 関数 引数 添付ファイル名 __init__() MIMEBase 系, tuple

    ( *) 指定可 attach() 〃 〃 attach_file() ファイルシステム上のパス ファイルシステムと同一 * tuple(filename, content, mimetype)
  9. メソッドの使い方 # 静的ディレクトリにあるファイルを添付する 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()
  10. EmailBackend に必要な設定 EmailBackend により異なる # SMTP 時のsettings EMAIL_HOST = 'smtp.example.com'

    # 送信メールサーバ EMAIL_PORT = '587' # 送信メールポート EMAIL_HOST_USER = 'test_user' # 送信ユーザ EMAIL_HOST_PASSWORD = 'Passw0rd' # 送信パスワード
  11. 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)
  12. 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, )
  13. テスト対象の関数 def my_send_mail(encoding='utf-8', has_attachment=False): msg = EmailMessage( subject=' 件名', body='

    本文', from_email=' 差出人 <[email protected]>', to=[' 送信先1 <[email protected]>', ' 送信先2 <[email protected]>'], cc=[' シーシー <[email protected]>'], bcc=[' ビーシーシー <[email protected]>'], reply_to=[' 返信先 <[email protected]>'], headers={'Sender': '[email protected]'}) if has_attachment: # 静的ディレクトリにあるファイルを添付する img = pathlib.Path(settings.STATICFILES_DIRS[0]).joinpath( 'images', 'shinanogold.png') msg.attach_file(img) msg.send()
  14. 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)
  15. 各フィールドの検証 def test_mail_fields(self): self._callFUT() actual = mail.outbox[0] self.assertEqual(actual.subject, ' 件名')

    self.assertEqual(actual.body, ' 本文') self.assertEqual(actual.from_email, ' 差出人 <[email protected]>') # 宛先系はlist として設定される self.assertEqual(actual.to, [' 送信先1 <[email protected]>', ' 送信先2 <[email protected]>'],) # 追加ヘッダも含まれる self.assertEqual(actual.extra_headers['Sender'], '[email protected]')
  16. 添付ファイルがある場合 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)
  17. User モデルの email_user() # ユーザー foo を取得 foo_user = User.objects.get(username='foo')

    # ユーザー foo のメールアドレスへ送信 foo_user.email_user( subject='Hello', message='Welcome!', from_email='[email protected]', connection=console.EmailBackend(), )
  18. ADMINS とMANAGERS の違い 公式ドキュメントより ADMINS = site admins MANAGERS =

    site managers Django のエラー通知機能にて、両者の違いを見る
  19. サーバーエラー通知メールの仕組み # 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']}}
  20. 例 エラーレポートの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()
  21. サーバーエラーの通知設定 # 本番運用モードにする DEBUG = False # 送信先の site admins

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

    # エラー通知メールの件名のPrefix # デフォルト:[Django] EMAIL_SUBJECT_PREFIX = '[Hello]'
  23. 受信メール エラー内容やsettings 、POST データなどが含まれる デフォルトでは、ローカル変数は含まれない Subject: [Hello]ERROR (EXTERNAL IP): Internal

    Server Error: /force_500 From: [email protected] To: [email protected] Internal Server Error: /force_500 ... Request Method: GET Request URL: http://localhost:8000/force_500 ... YEAR_MONTH_FORMAT =3D 'F Y'
  24. 一部をマスクする例 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
  25. 一部マスクの結果 <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>
  26. エラー通知に含まれるPOST データ デフォルトでは、そのまま表示 Exception Type: Exception at /post_parameters POST: apple

    =3D 'Shinano Gold' grape =3D 'Shine Muscat' pear =3D 'Southern Suite' @sensitive_post_parameters デコレータを使う
  27. マスク機能 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()
  28. マスク結果 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>
  29. リンク切れの通知設定 # 本番運用モードにする DEBUG = False # リンク切れを通知するミドルウェアを追加 MIDDLEWARE +=

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

    = '...' # HTTP404 でもエラーレポートメールを送信したくないURL がある場合は、正規表現で指定 IGNORABLE_404_URLS = [ re.compile(r'^/ignore_404$'), ]
  31. 動作確認 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>
  32. 受信メール Subject: [Hello]Broken INTERNAL link on localhost:8000 From: [email protected] To:

    [email protected] Referrer: http://localhost:8000/breaking_link Requested URL: /force_404 User agent: curl/7.54.0 IP address: 127.0.0.1