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

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

DjangoCongress JP 2019の発表資料です

D484f3a4d5f516f943b29b9ff55a2040?s=128

thinkAmi

May 18, 2019
Tweet

Transcript

  1. 2.

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

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

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

    '587' # 送信メールポート EMAIL_HOST_USER = 'test_user' # 送信ユーザ EMAIL_HOST_PASSWORD = 'Passw0rd' # 送信パスワード
  4. 7.

    send_mail() 関数の実行 send_mail(subject='my subject', # 件名 message='My Body', # 本文

    from_email='from@example.com', # 送信者 recipient_list=['to@example.com']) # 受信者(To) のリスト
  5. 9.
  6. 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()
  7. 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> です')
  8. 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')], }, ]
  9. 30.

    テキストメールのテンプレート # base.txt メールのベースです {% block mail_content %} {% endblock

    %} {% include './signature.txt' %} # content.txt {% extends './base.txt' %} {% block mail_content %} {{ message }} {% endblock %} # signature.txt from thinkAmi
  10. 31.

    テンプレートを利用して送信 template_body = render_to_string('mail/content.txt', context={'message': ' 埋め込みます'}) # EmailMessage の場合

    email = EmailMessage(body=template_body, ...) # send_mail() の場合 send_mail(message=template_body, ...)
  11. 34.

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

    ( *) 指定可 attach() 〃 〃 attach_file() ファイルシステム上のパス ファイルシステムと同一 * tuple(filename, content, mimetype)
  12. 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()
  13. 42.

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

    # 送信メールサーバ EMAIL_PORT = '587' # 送信メールポート EMAIL_HOST_USER = 'test_user' # 送信ユーザ EMAIL_HOST_PASSWORD = 'Passw0rd' # 送信パスワード
  14. 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)
  15. 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, )
  16. 52.
  17. 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()
  18. 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)
  19. 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')
  20. 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)
  21. 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(), )
  22. 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())
  23. 71.

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

    site managers Django のエラー通知機能にて、両者の違いを見る
  24. 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']}}
  25. 75.
  26. 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()
  27. 78.
  28. 81.

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

    のメールアドレスを設定 # (' メールアドレスコメント', ' メールアドレス') ADMINS = [('Admin1', 'admin1@example.com')] # 他、使用するEmailBackend の設定を行う
  29. 82.

    サーバーエラー通知設定 ( 任意) # 送信元のメールアドレス # デフォルト:root@localhost SERVER_EMAIL = 'server@example.com'

    # エラー通知メールの件名のPrefix # デフォルト:[Django] EMAIL_SUBJECT_PREFIX = '[Hello]'
  30. 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'
  31. 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>
  32. 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
  33. 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>
  34. 96.

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

    =3D 'Shinano Gold' grape =3D 'Shine Muscat' pear =3D 'Southern Suite' @sensitive_post_parameters デコレータを使う
  35. 98.
  36. 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()
  37. 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>
  38. 105.

    リンク切れの通知設定 # 本番運用モードにする DEBUG = False # リンク切れを通知するミドルウェアを追加 MIDDLEWARE +=

    ['django.middleware.common.BrokenLinkEmailsMiddleware'] # 送信先の site managers のメールアドレスを設定 # (' メールアドレスコメント', ' メールアドレス') MANAGERS = [('Manager1', 'manager1@example.com')] # 他、使用するEmailBackend の設定を行う
  39. 106.

    リンク切れの通知設定 ( 任意) # ADMINS と共用 SERVER_EMAIL = '...' EMAIL_SUBJECT_PREFIX

    = '...' # HTTP404 でもエラーレポートメールを送信したくないURL がある場合は、正規表現で指定 IGNORABLE_404_URLS = [ re.compile(r'^/ignore_404$'), ]
  40. 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>
  41. 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
  42. 111.
  43. 118.