Djangoのフォームエディタ Fobi で受け取り確認メールが送信できない問題に対応した時のメモ

# はじめに

WebフレームワークのDjangoで利用できるフォームエディタのFOBIを利用しています。
豊富なバリデータ付きのWidgetを自由にレイアウトできるため、Google Formのような手軽さでフォームを作成することができます。

![Screenshot_2023-03-08_20-16-21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78296/2fc303a0-ed67-027e-7492-115dd137c3d8.png)


FOBI自体はフレームワークで、フォームに並べることができるWidgetをelement(エレメント)として個別に定義、拡張できます。そしてフォームを受け付けた後の処理についても、DBへの保存や、受領メールの送信処理などがhandler(ハンドラ)として個別に定義できるようになっており、それぞれ lib/fobi/contrib/plugins/ ディレクトリで個別に管理されています。

システムに含まれて配布されている mail_sender ハンドラを利用して、フォームの送信者に送信済みメールを送信しようとしたところ問題が発生しました。

```text:ログメッセージ
INFO 2023-03-06 17:41:06,466 views 9 139766637927240 FormCreateView::dispatch causes the error, 'NoneType' object has no attribute 'split'
```

splitを実行しているロジックを確認すると、次のようなコードが該当しそうです。

```bash:
$ $ grep split venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/*
venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/base.py:    \      
    to_email = to_email.split(MULTI_EMAIL_FIELD_VALUE_SPLITTER)
venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/mixins.py:  \    
    to_email = to_email.split(MULTI_EMAIL_FIELD_VALUE_SPLITTER)
```

ここで元々のフォームに送信先のメールアドレスが含まれていないといけないことに気がつきました。

そこが分かったとはいっても、いやそれって悪用すると任意のメールアドレスが指定されて悪戯に利用されてしまいますよね、と思うのでこのハンドラは使えないかなといった感じです。

メールアドレスをIDとしているアプリとしては、送信先のメールアドレスを手で入力させるとか有り得ないので、ログイン時に認証しているユーザーID宛てにメールを送る方法を検討してみました。

# アプローチ

FOBIで作成したフォームに送信先のメールアドレスを埋め込んでおけば、mail_sender ハンドラがそのまま使えます。

とはいえ、mail_sender にはいくつか課題があります。

## mail_sender ハンドラを利用する際の考慮点

* アップロードしたファイルをそのままメールに添付する
* 独自のMinioにファイルをアップロードするモジュールを自作していたため、メールにファイル自体は添付されないが無意味なファイルパスがメール本文に含まれてしまう

そのため、mail_sender をそのまま使うか、独自のハンドラを作成するか、いくつか方法を検討してみます。

## 検討した解決手段

1. mail_senderをそのまま使うため、フォームに変更不可なユーザーIDを埋め込む独自エレメントを作成する
2. mail_senderを改造し、セッションからユーザーIDと送信先とする。フォーム本体には手を加えない

これらの方法には、いくつかのPros & Consがあります。

(1)のmail_senderを無改造で使うために、ユーザーIDを埋め込むフィールドを作成する方法は汎用性もあり便利そうですが、ユーザーからの入力を信じてしまうことになるためWebアプリのバッドプラクティスに抵触します。

結局はセッションIDに関連するIDと同一か検証することになるため、あまり良い方法とはいえません。

また mail_sender は、アップロードされたファイルを確認用に送り返します。これも良いマナーとはいえません。これは利用方法を誤るとメール爆弾に悪用される可能性がありますし、アップロードされたファイルのサイズが大きい場合には不適切な利用方法です。

これらはそもそもの mail_sender の設計に起因する問題なので、いずれにしてもmail_senderを利用することは推奨できません。ここは独自にハンドラを開発することにします。

(2)の方法は、request.user に必ずメールアドレスが入っていることを保証しなければいけません。

幸い、ユーザーIDは管理者IDを含めて全てメールアドレスに統一しているため、メールアドレスがIDである状態になっています。

今回はこの(2)の方法について検討していきます。

# authuser_mail_sender ハンドラの作成

環境はvenvを利用しています。まずはFOBIに含まれるmail_senderを適当なディレクトリにコピーします。

```bash:
$ mkdir -p myapp/fobi_plugins/handlers/auth_mail_sender
$ rsync -av venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/. myapp/fobi_plugins/handlers/auth_mail_sender/.
```

次に auth_mail_sender を変更していきますが、全体の差分は次のようになりました。

```diff:
diff -uNr venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./apps.py myapp/fobi_plugins/handlers/authuser_mail_sender/./apps.py
--- venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./apps.py	2023-03-07 09:47:08.300555021 +0900
+++ myapp/fobi_plugins/handlers/authuser_mail_sender/./apps.py	2023-03-07 13:49:07.557893030 +0900
@@ -2,6 +2,5 @@
 
 class Config(AppConfig):
     """Config."""
 
-    name = "fobi.contrib.plugins.form_handlers.mail_sender"
-    label = "fobi_contrib_plugins_form_handlers_mail_sender"
+    name = "myapp.fobi_plugins.handlers.authuser_mail_sender"
diff -uNr venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./base.py myapp/fobi_plugins/handlers/authuser_mail_sender/./base.py
--- venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./base.py	2023-03-07 09:47:08.300555021 +0900
+++ myapp/fobi_plugins/handlers/authuser_mail_sender/./base.py	2023-03-07 13:50:12.306007127 +0900
@@ -1,5 +1,8 @@
 from __future__ import absolute_import
 
+import logging
+logger = logging.getLogger("django.app")
+
 import datetime
 import os
 from mimetypes import guess_type
@@ -9,30 +12,30 @@
 from django.utils.translation import gettext_lazy as _
 from six import PY3, string_types
 
-from .....base import (
+from fobi.base import (
     FormHandlerPlugin,
     FormWizardHandlerPlugin,
     get_processed_form_data,
     get_processed_form_wizard_data,
 )
-from .....helpers import (
+from fobi.helpers import (
     extract_file_path,
     get_form_element_entries_for_form_wizard_entry,
     safe_text,
 )
 from . import UID
-from .forms import MailSenderForm
+from .forms import UserMailSenderForm
 from .helpers import send_mail
-from .mixins import MailSenderHandlerMixin
+from .mixins import UserMailSenderHandlerMixin
 from .settings import MULTI_EMAIL_FIELD_VALUE_SPLITTER
 
 
 # *****************************************************************************
 # **************************** Form handler ***********************************
 # *****************************************************************************
 
 
-class MailSenderHandlerPlugin(FormHandlerPlugin, MailSenderHandlerMixin):
+class UserMailSenderHandlerPlugin(FormHandlerPlugin, UserMailSenderHandlerMixin):
     """Mail handler plugin.
 
     Sends emails to the person specified. Should be executed before
@@ -48,8 +53,8 @@
     """
 
     uid = UID
-    name = _("Mail the sender")
-    form = MailSenderForm
+    name = _("AuthUser Mail the sender")
+    form = UserMailSenderForm
 
     def run(self, form_entry, request, form, form_element_entries=None):
         """Run.
@@ -74,7 +79,7 @@
 
         files = self._prepare_files(request, form)
 
-        self.send_email(rendered_data, cleaned_data, files)
+        self.send_email(request.user, rendered_data, cleaned_data, files)
 
     def plugin_data_repr(self):
         """Human readable representation of plugin data.
@@ -83,7 +88,7 @@
         """
         context = {
             "to_name": safe_text(self.data.to_name),
-            "form_field_name_to_email": self.data.form_field_name_to_email,
+            ## "form_field_name_to_email": self.data.form_field_name_to_email,
             "subject": safe_text(self.data.subject),
         }
         return render_to_string("mail_sender/plugin_data_repr.html", context)
@@ -94,7 +99,7 @@
 # *****************************************************************************
 
 
-class MailSenderWizardHandlerPlugin(FormWizardHandlerPlugin):
+class UserMailSenderWizardHandlerPlugin(FormWizardHandlerPlugin):
     """Mail wizard handler plugin.
 
     Sends emails to the person specified. Should be executed before
@@ -103,7 +108,7 @@
 
     uid = UID
     name = _("Mail the sender")
-    form = MailSenderForm
+    form = UserMailSenderForm
 
     def run(
         self,
@@ -159,7 +164,9 @@
 
         files = self._prepare_files(request, form_list)
 
-        to_email = cleaned_data.get(self.data.form_field_name_to_email)
+        to_email = str(request.user)
+        messageid = make_msgid(domain = self.data.messageid_domain)
+        logger.info("Message-id: %s to %s" % (messageid, to_email))
         # Handling more than one email address
         if isinstance(to_email, (list, tuple)):
             pass  # Anything else needed here?
@@ -169,13 +176,12 @@
 
         send_mail(
             safe_text(self.data.subject),
-            "{0}\n\n{1}".format(
-                safe_text(self.data.body), "".join(rendered_data)
-            ),
+            safe_text(self.data.body),
             self.data.from_email,
             to_email,
             fail_silently=False,
-            attachments=files.values(),
+            ## attachments=files.values(),
+            messageid=messageid,
         )
 
     def _prepare_files(self, request, form_list):
@@ -221,9 +227,9 @@
         """
         context = {
             "to_name": safe_text(self.data.to_name),
-            "form_field_name_to_email": safe_text(
-                self.data.form_field_name_to_email
-            ),
+            ## "form_field_name_to_email": safe_text(
+            ##     self.data.form_field_name_to_email
+            ## ),
             "subject": safe_text(self.data.subject),
         }
         return render_to_string("mail_sender/plugin_data_repr.html", context)
diff -uNr venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./fobi_form_handlers.py myapp/fobi_plugins/handlers/authuser_mail_sender/./fobi_form_handlers.py
--- venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./fobi_form_handlers.py	2023-03-07 09:47:08.300555021 +0900
+++ myapp/fobi_plugins/handlers/authuser_mail_sender/./fobi_form_handlers.py	2023-03-07 13:50:35.905794768 +0900
@@ -1,10 +1,10 @@
-from .....base import (
+from fobi.base import (
     form_handler_plugin_registry,
     form_wizard_handler_plugin_registry,
 )
-from .base import MailSenderHandlerPlugin, MailSenderWizardHandlerPlugin
+from .base import UserMailSenderHandlerPlugin, UserMailSenderWizardHandlerPlugin
 
 
 
-form_handler_plugin_registry.register(MailSenderHandlerPlugin)
-form_wizard_handler_plugin_registry.register(MailSenderWizardHandlerPlugin)
+form_handler_plugin_registry.register(UserMailSenderHandlerPlugin)
+form_wizard_handler_plugin_registry.register(UserMailSenderWizardHandlerPlugin)
diff -uNr venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./forms.py myapp/fobi_plugins/handlers/authuser_mail_sender/./forms.py
--- venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./forms.py	2023-03-07 09:47:08.300555021 +0900
+++ myapp/fobi_plugins/handlers/authuser_mail_sender/./forms.py	2023-03-07 13:50:44.529734474 +0900
@@ -3,20 +3,21 @@
 from django import forms
 from django.utils.translation import gettext_lazy as _
 
-from .....base import BasePluginForm, get_theme
+from fobi.base import BasePluginForm, get_theme
 
 
 theme = get_theme(request=None, as_instance=True)
 
 
-class MailSenderForm(forms.Form, BasePluginForm):
+class UserMailSenderForm(forms.Form, BasePluginForm):
     """Form for ``BooleanSelectPlugin``."""
 
     plugin_data_fields = [
         ("from_name", ""),
         ("from_email", ""),
         ("to_name", ""),
-        ("form_field_name_to_email", ""),
+        ## ("form_field_name_to_email", ""),
+        ("messageid_domain", "localhost"),
         ("subject", ""),
         ("body", ""),
     ]
@@ -47,10 +48,18 @@
             attrs={"class": theme.form_element_html_class}
         ),
     )
-    form_field_name_to_email = forms.CharField(
-        label=_("Form field name to email"),
+    ## form_field_name_to_email = forms.CharField(
+    ##     label=_("Form field name to email"),
+    ##     required=True,
+    ##     help_text=_("Name of the form field to be used as email."),
+    ##     widget=forms.widgets.TextInput(
+    ##         attrs={"class": theme.form_element_html_class}
+    ##     ),
+    ## )
+    messageid_domain = forms.CharField(
+        label=_("FQDN for Message-ID domain"),
         required=True,
-        help_text=_("Name of the form field to be used as email."),
+        help_text=_("FQDN of the Message-ID to be used as email."),
         widget=forms.widgets.TextInput(
             attrs={"class": theme.form_element_html_class}
         ),
diff -uNr venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./helpers.py myapp/fobi_plugins/handlers/authuser_mail_sender/./helpers.py
--- venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./helpers.py	2023-03-07 09:47:08.304555821 +0900
+++ myapp/fobi_plugins/handlers/authuser_mail_sender/./helpers.py	2023-03-07 12:31:04.038716340 +0900
@@ -1,8 +1,8 @@
 from __future__ import absolute_import
 
 from django.core.mail import get_connection
-from django.core.mail.message import EmailMultiAlternatives
+from django.core.mail.message import EmailMessage
 
 
 
 def send_mail(
@@ -21,6 +21,7 @@
     connection=None,
     html_message=None,
     attachments=None,
+    messageid=None,
 ):
     """Send email.
 
@@ -36,15 +37,18 @@
     connection = connection or get_connection(
         username=auth_user, password=auth_password, fail_silently=fail_silently
     )
-    mail = EmailMultiAlternatives(
+    email_headers = None
+    if messageid: 
+        email_headers = {'Message-ID': messageid}
+        pass
+    mail = EmailMessage(
         subject,
         message,
         from_email,
         recipient_list,
         connection=connection,
         attachments=attachments,
+        headers=email_headers,
     )
-    if html_message:
-        mail.attach_alternative(html_message, "text/html")
 
     return mail.send()
diff -uNr venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./__init__.py myapp/fobi_plugins/handlers/authuser_mail_sender/./__init__.py
--- venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./__init__.py	2023-03-07 09:47:08.300555021 +0900
+++ myapp/fobi_plugins/handlers/authuser_mail_sender/./__init__.py	2023-03-07 13:48:58.201849668 +0900
@@ -1,6 +1,6 @@
 
 default_app_config = (
-    "fobi.contrib.plugins.form_handlers.mail_sender.apps." "Config"
+    "myapp.fobi_plugins.handlers.authuser_mail_sender.apps." "Config"
 )
 
-UID = "mail_sender"
+UID = "authuser_mail_sender"
diff -uNr venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./mixins.py myapp/fobi_plugins/handlers/authuser_mail_sender/./mixins.py
--- venv/myapp/lib/python3.10/site-packages/fobi/contrib/plugins/form_handlers/mail_sender/./mixins.py	2023-03-07 09:47:08.304555821 +0900
+++ myapp/fobi_plugins/handlers/authuser_mail_sender/./mixins.py	2023-03-07 13:51:00.817640124 +0900
@@ -1,5 +1,8 @@
 from __future__ import absolute_import, unicode_literals
 
+import logging
+logger = logging.getLogger("django.app")
+
 import datetime
 import os
 from mimetypes import guess_type
@@ -7,17 +10,17 @@
 from django.conf import settings
 from six import PY3, string_types
 
-from .....helpers import extract_file_path, safe_text
+from fobi.helpers import extract_file_path, safe_text
 from .helpers import send_mail
 from .settings import MULTI_EMAIL_FIELD_VALUE_SPLITTER
 
 
 # *****************************************************************************
 # **************************** Form handler ***********************************
 # *****************************************************************************
 
 
-class MailSenderHandlerMixin(object):
+class UserMailSenderHandlerMixin(object):
     """Mail handler mixin."""
 
     def get_base_url(self, request):
@@ -67,12 +72,14 @@
             )
         return rendered_data
 
-    def send_email(self, rendered_data, cleaned_data, files):
+    def send_email(self, to_email, rendered_data, cleaned_data, files):
         """Send email.
 
         Might be used in integration packages.
         """
-        to_email = cleaned_data.get(self.data.form_field_name_to_email)
+        to_email = str(to_email)
+        messageid = make_msgid(domain = self.data.messageid_domain)
+        logger.info("Message-id: %s to %s" % (messageid, to_email))
         # Handling more than one email address
         if isinstance(to_email, (list, tuple)):
             pass  # Anything else needed here?
@@ -82,13 +89,12 @@
 
         send_mail(
             safe_text(self.data.subject),
-            "{0}\n\n{1}".format(
-                safe_text(self.data.body), "".join(rendered_data)
-            ),
+            safe_text(self.data.body),
             self.data.from_email,
             to_email,
             fail_silently=False,
-            attachments=files.values(),
+            ## attachments=files.values(),
+            messageid=messageid,
         )
 
     def _prepare_files(self, request, form):

```

主な変更点はクラス名の変更や参照関係を整理してあげることなので、ファイルを一つ一つ開きながら修正していきました。

ある程度全体の構成を把握しておく必要はありますが、実際にはシンプルな作業です。

## 作成時の課題

あまり問題はありませんでしたが、いくつかの課題に遭遇しました。

### request.user にsplit()を適用してエラーになる

request.user がオブジェクトになっていることが原因です。

```python:
type(request.user) 
    → <class 'django.utils.functional.SimpleLazyObject'>
```

これは str(request.user) のように文字列に変換することで解決しています。

## Message-IDのドメインが "localhost" になってしまう

Message-IDを生成する時に、必ずドメインが"@localhost"となってしまいます。

これはdjangoの問題で、メーリングリストなどで話題になっているようですが根本的には解決されていません。

"localhost"が絶対に悪いわけではないですが、Message-IDの趣旨には反するので、これを変更できるようにフォームにフィールドを追加しています。

Message-IDをSMTPサーバーに任せるケースもありますが、手元で生成してログに記録する方が良い設計です。

# まとめ

djangoではIDをメールIDに統一するのはデフォルトの挙動ではないので、少し変更が必要です。

とはいえ外部に公開する可能性がある場合には、到達確認はdjangoの標準機能で達成できるので、IDをメールアドレスにするのがお勧めです。

最終的には自前のハンドラを利用することで、ファイルをアップロードしてきたユーザーにシンプルな受領通知を出すことができましt

以上