Django的password_reset支持HTML邮件模板吗?

11 投票
10 回答
6909 浏览
提问于 2025-04-17 05:24

我觉得Django默认只支持发送普通文本的密码重置邮件。那我该怎么用HTML模板来实现这个功能呢?

10 个回答

4

在Django 3.0+中创建带图片的HTML密码重置邮件

概述

  1. 创建三个模板:
    • password_reset_email.html
    • password_reset_email.txt
    • password_reset_subject.txt
  2. 通过创建一个子类来重写 django.contrib.auth.forms.PasswordResetForm
  3. 修改 PasswordResetForm.save() 方法的参数,让它指向你自定义的模板。
  4. 如果你想在HTML邮件中嵌入图片,可以重写 PasswordResetForm.send_email() 方法。
  5. django.contrib.auth.views.PasswordResetView.form_class 设置为使用你新创建的 PasswordResetForm 子类。

1. 创建三个模板

  • 在你的Django模板目录下,创建一个名为“registration”的子目录。
  • 在“templates/registration”下添加三个模板:
    • password_reset_email.html
    • password_reset_email.txt
    • password_reset_subject.txt
  • HTML邮件模板应该是一个结构良好的HTML文档。下面有示例。
  • “.txt”模板应该是一个简单的文本模板(没有HTML)。下面也有示例。

示例 password_reset_email.html

{% autoescape off %}
<!DOCTYPE html>

<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1"/>

        <title>Password Reset</title>

        <style type="text/css">
            
            body {
                background-color: #ffffff;
                font-size: 14px;
                line-height: 16px;
                font-family: PTSansRegular,Arial,Helvetica,sans-serif;
                height: 100%;
                margin: 0;
                padding: 0;
                border: 0;
                outline: 0;
            }

            a.button {
                background-color: #007bff;
                border-color: #007bff;
                border-radius: 5px;
                color: #ffffff;
                cursor: pointer;
                display: inline-block;
                font-size: 15px;
                line-height: 18px;
                font-weight: bold;
                font-family: PTSansRegular,Arial,Helvetica,sans-serif;
                padding: 7px;
                text-align: center;
                text-decoration: none;
                white-space: nowrap;
                width: 150px;
            }

            .center {
                text-align: center
            }

            .container {
                min-height: 100%;
                min-width: 650px;
                position: relative;
                width: 100%;
            }

            p {
                text-align:left
            }

            table {
                margin: auto;
                width:650px;
            }

            td {
                padding-right: 14px;
                padding-left: 14px;
            }
        </style>
    </head>

    <body>

    <div class="container">

    <!-- BEGIN EMAIL -->
    <table align="center" border="0" cellpadding="0" cellspacing="0">
    <tr>
        <td>
            <p>Hello {{ user.get_username }},</p>

            <p>A request has been received to change the password for your account.</p>

            <p class="center">
                <a target="_blank" class="button"
                        href="{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}">
                    Reset Password
                </a>
            </p>

            <p>This link can only be used once. If you need to reset your password again, please visit
            <a href="{{ protocol }}://{{domain}}">{{ site_name }}</a> and request another reset.</p>

            <p>If you did not make this request, please contact us immediately at
            <a href="mailto: YOUR_SUPPORT_EMAIL">YOUR_SUPPORT_EMAIL</a>.</p>

            <p>Sincerely,</p>
            <p>The YOUR_COMPANY_NAME Team</p>
        </td>
    </tr>
    </table>
    <!-- END EMAIL -->

    <table class="spacer">
        <tr><td class="spacer">&nbsp;</td></tr>
    </table>

    <!-- BEGIN FOOTER -->
    <table align="center">
        <tr>
            <td>
                <p class="center"><img src="cid:logo" /></p>
            </td>
        </tr>
        <tr>
            <td class="center">YOUR_ADDRESS_AND_OR_COPYRIGHT</td>
        </tr>
    </table>
    <!-- END FOOTER -->
    </div>

    </body>
</html>
{% endautoescape %}

示例 password_reset_email.txt

{% autoescape off %}
Hello {{ user.get_username }},

A request has been received to change the password for your account. Click the link below to reset your password.

{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}">

This link can only be used once. If you need to reset your password again, please visit
{{ protocol }}://{{domain}}">{{ site_name }} and request another reset.

If you did not make this request, please contact us immediately at YOUR_SUPPORT_EMAIL.

Sincerely,

The YOUR_COMPANY_NAME Team



YOUR_COMPANY_NAME
YOUR_ADDRESS_AND_OR_COPYRIGHT
{% endautoescape %}

2. 重写 django.contrib.auth.forms.PasswordResetForm

  • 在你的Django应用目录下,创建一个名为“forms.py”的模块。
  • 创建一个 django.contrib.auth.forms.PasswordResetForm 的子类,并重写 save() 方法。

示例:forms.py

class CustomPasswordResetForm(PasswordResetForm):
    """Override the default Django password-reset form to send the password reset 
    email using both HTML and plain text.
    """

    def save(
        self,
        domain_override: Optional[str] = None,
        subject_template_name: str = PASSWORD_RESET_SUBJECT_TEMPLATE,
        email_template_name: str = PASSWORD_RESET_TEXT_TEMPLATE,
        use_https: Optional[bool] = None,
        token_generator: PasswordResetTokenGenerator = default_token_generator,
        from_email: Optional[str] = FROM_EMAIL,
        request: Optional[WSGIRequest] = None,
        html_email_template_name: Optional[str] = PASSWORD_RESET_HTML_TEMPLATE,
        extra_email_context: Optional[Dict[str, str]] = None
    ) -> None:
        """Generate a one-use only link for resetting password and email it to 
        the user.

        Args:
            domain_override: Optional; Domain name to use in the email message 
                template that overrides the actual domain from which the email is 
                sent. Defaults to None.
            subject_template_name: Optional; Warning: this argument is overridden 
                by the global variable ``PASSWORD_RESET_SUBJECT_TEMPLATE``.
            email_template_name: Optional; Warning: this argument is overridden by 
                the global variable ``PASSWORD_RESET_TEXT_TEMPLATE``.
            use_https: Optional; If True, use HTTPS, otherwise use HTTP. Defaults 
                to False. Note that if the password reset HTTP request is received 
                via HTTPS, `use_https` will be set to True by the auth view.
            token_generator: Optional; Strategy object used to generate and check 
                tokens for the password reset mechanism. Defaults to an instance 
                of ``django.contrib.auth.tokens.PasswordResetTokenGenerator``.
            from_email: Optional; Warning: this argument is overridden by the 
                global variable``FROM_EMAIL``.
            request: Optional; The HttpRequest object. Defaults to None.
            html_email_template_name: Warning: this argument is overridden by the 
                global variable ``PASSWORD_RESET_HTML_TEMPLATE``.
            extra_email_context: Optional; Key-value pairs to add to the context 
                dictionary used to render the password reset email templates. 
                    Defaults to None.
        """
        email_template_name = PASSWORD_RESET_TEXT_TEMPLATE
        from_email = FROM_EMAIL
        html_email_template_name = PASSWORD_RESET_HTML_TEMPLATE
        subject_template_name = PASSWORD_RESET_SUBJECT_TEMPLATE

        email = self.cleaned_data["email"]
        if not domain_override:
            current_site = get_current_site(request)
            site_name = current_site.name
            domain = current_site.domain
        else:
            site_name = domain = domain_override
        UserModel = get_user_model()
        email_field_name = UserModel.get_email_field_name()  # type: ignore

        for user in self.get_users(email):
            user_email = getattr(user, email_field_name)
            context = {
                'email': user_email,
                'domain': domain,
                'site_name': site_name,
                'uid': urlsafe_base64_encode(force_bytes(user.pk)),
                'user': user,
                'token': token_generator.make_token(user),
                'protocol': 'https' if use_https else 'http',
                **(extra_email_context or {}),
            }

            self.send_mail(
                subject_template_name = subject_template_name,
                email_template_name = email_template_name,
                context = context,
                from_email = from_email,
                to_email = user_email,
                html_email_template_name = html_email_template_name
            )

3. 修改 PasswordResetForm.save() 方法的参数,让它指向你的自定义模板。

  • 在你的“forms.py”模块中,添加全局变量,如下所示:

示例 forms.py

from typing import Final


# Constants for sending password-reset emails.
LOGO_FILE_PATH: Final[str] = "img/logo.png"
LOGO_CID_NAME: Final[str] = "logo"
PASSWORD_RESET_FORM_TEMPLATE: Final[str] = "registration/password_reset_form.html"
PASSWORD_RESET_HTML_TEMPLATE: Final[str] = "registration/password_reset_email.html"
PASSWORD_RESET_TEXT_TEMPLATE: Final[str] = "registration/password_reset_email.txt"
PASSWORD_RESET_SUBJECT_TEMPLATE: Final[str] = "registration/password_reset_subject.txt"
SUPPORT_EMAIL: Final[str] = "YOUR_SUPPORT_EMAIL_ADDRESS"
FROM_EMAIL: Final[str] = f"YOUR_COMPANY_NAME Support <{SUPPORT_EMAIL}>"

4. 如果你想在HTML邮件中嵌入图片,可以重写 PasswordResetForm.send_email() 方法。

示例:forms.py

def get_as_mime_image(image_file_path: str, cid_name: str) -> MIMEImage:
    """Fetch an image file and return it wrapped in a ``MIMEImage`` object for use 
    in emails.

    After the ``MIMEImage`` has been attached to an email, reference the image in 
    the HTML using the Content ID.

    Example:

    If the CID name is "logo", then the HTML reference would be:

    <img src="cid:logo" />

    Args:
        image_file_path: The path of the image. The path must be findable by the 
            Django staticfiles app.
        cid_name: The Content-ID name to use within the HTML email body to 
            reference the image.

    Raises:
        FileNotFoundError: If the image file cannot be found by the staticfiles app.

    Returns:
        MIMEImage: The image wrapped in a ``MIMEImage`` object and the Content ID 
        set to ``cid_name``.
    """
    paths = finders.find(image_file_path)
    if paths is None:
        raise FileNotFoundError(f"{image_file_path} not found in static files")

    if isinstance(paths, list):
        final_path = paths[0]
    else:
        final_path = paths
    with open(final_path, 'rb') as f:
        image_data = f.read()

    mime_image = MIMEImage(image_data)
    mime_image.add_header("Content-ID", f"<{cid_name}>")
    return mime_image


class CustomPasswordResetForm(PasswordResetForm):
    """Override the default Django password-reset form to send the password reset email using both HTML and plain text.
    """
    def send_mail(
        self,
        subject_template_name: str,
        email_template_name: str,
        context: Dict[str, str],
        from_email: Optional[str],
        to_email: str,
        html_email_template_name: Optional[str] = None,
    ) -> None:
        """Send a ``django.core.mail.EmailMultiAlternatives`` to ``to_email``.

        This method also attaches the company logo, which can be added to the 
        email HTML template using:

        <img src="cid:logo" />

        Args:
            subject_template_name: Path of the template to use as the email 
                subject.
            email_template_name: Path of the template to use for the plain text 
                email body.
            context: A context dictionary to use when rendering the password reset 
                email templates.
            from_email: The From email address.
            to_email: The To email address.
            html_email_template_name: Optional; Path of the template to use for 
                the HTML email body. Defaults to None.
        """
        subject = loader.render_to_string(subject_template_name, context)
        # Email subject *must not* contain newlines
        subject = ''.join(subject.splitlines())
        body = loader.render_to_string(email_template_name, context)

        email_message = EmailMultiAlternatives(subject, body, 
                                               from_email=from_email, to=[to_email],
                                               reply_to=[from_email])
        if html_email_template_name is not None:
            html_email = loader.render_to_string(html_email_template_name, context)
            email_message.attach_alternative(html_email, 'text/html')
            email_message.mixed_subtype = "related"
            mime_image = get_as_mime_image(image_file_path=LOGO_FILE_PATH, cid_name=LOGO_CID_NAME)
            email_message.attach(mime_image)  # type: ignore

        email_message.send()

5. 将 django.contrib.auth.views.PasswordResetView.form_class 设置为使用你新创建的 PasswordResetForm 子类。

Django urls.py 文件

from django.contrib.auth import views

from your_app.forms import CustomPasswordResetForm


views.PasswordResetView.form_class = CustomPasswordResetForm

urlpatterns = [
    path('', home_view, name='home'),
    path('accounts/', include('django.contrib.auth.urls')),
    ...
]

完整的 forms.py

"""Module that overrides the default Django password reset functionality by 
sending emails containing both plain text as well as HTML along with the company logo.
"""

from email.mime.image import MIMEImage
from typing import Dict, Final, Optional

from django.contrib.auth import get_user_model
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.tokens import default_token_generator, PasswordResetTokenGenerator
from django.contrib.sites.shortcuts import get_current_site
from django.contrib.staticfiles import finders
from django.core.handlers.wsgi import WSGIRequest
from django.core.mail import EmailMultiAlternatives
from django.template import loader
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode


# Constants for sending password-reset emails.
LOGO_FILE_PATH: Final[str] = "img/logo.png"
LOGO_CID_NAME: Final[str] = "logo"
PASSWORD_RESET_FORM_TEMPLATE: Final[str] = "registration/password_reset_form.html"
PASSWORD_RESET_HTML_TEMPLATE: Final[str] = "registration/password_reset_email.html"
PASSWORD_RESET_TEXT_TEMPLATE: Final[str] = "registration/password_reset_email.txt"
PASSWORD_RESET_SUBJECT_TEMPLATE: Final[str] = "registration/password_reset_subject.txt"
SUPPORT_EMAIL: Final[str] = "YOUR_SUPPORT_EMAIL_ADDRESS"
FROM_EMAIL: Final[str] = f"YOUR_COMPANY_NAME Support <{SUPPORT_EMAIL}>"


def get_as_mime_image(image_file_path: str, cid_name: str) -> MIMEImage:
    """Fetch an image file and return it wrapped in a ``MIMEImage`` object for use 
    in emails.

    After the ``MIMEImage`` has been attached to an email, reference the image in 
    the HTML using the Content ID.

    Example:

    If the CID name is "logo", then the HTML reference would be:

    <img src="cid:logo" />

    Args:
        image_file_path: The path of the image. The path must be findable by the 
            Django staticfiles app.
        cid_name: The Content-ID name to use within the HTML email body to 
            reference the image.

    Raises:
        FileNotFoundError: If the image file cannot be found by the staticfiles app.

    Returns:
        MIMEImage: The image wrapped in a ``MIMEImage`` object and the Content ID 
        set to ``cid_name``.
    """
    paths = finders.find(image_file_path)
    if paths is None:
        raise FileNotFoundError(f"{image_file_path} not found in static files")

    if isinstance(paths, list):
        final_path = paths[0]
    else:
        final_path = paths
    with open(final_path, 'rb') as f:
        image_data = f.read()

    mime_image = MIMEImage(image_data)
    mime_image.add_header("Content-ID", f"<{cid_name}>")
    return mime_image


class CustomPasswordResetForm(PasswordResetForm):
    """Override the default Django password-reset form to send the password reset email using both HTML and plain text.
    """
    def send_mail(
        self,
        subject_template_name: str,
        email_template_name: str,
        context: Dict[str, str],
        from_email: Optional[str],
        to_email: str,
        html_email_template_name: Optional[str] = None,
    ) -> None:
        """Send a ``django.core.mail.EmailMultiAlternatives`` to ``to_email``.

        This method also attaches the company logo, which can be added to the 
        email HTML template using:

        <img src="cid:logo" />

        Args:
            subject_template_name: Path of the template to use as the email 
                subject.
            email_template_name: Path of the template to use for the plain text 
                email body.
            context: A context dictionary to use when rendering the password reset 
                email templates.
            from_email: The From email address.
            to_email: The To email address.
            html_email_template_name: Optional; Path of the template to use for 
                the HTML email body. Defaults to None.
        """
        subject = loader.render_to_string(subject_template_name, context)
        # Email subject *must not* contain newlines
        subject = ''.join(subject.splitlines())
        body = loader.render_to_string(email_template_name, context)

        email_message = EmailMultiAlternatives(subject, body, 
                                               from_email=from_email, to=[to_email],
                                               reply_to=[from_email])
        if html_email_template_name is not None:
            html_email = loader.render_to_string(html_email_template_name, context)
            email_message.attach_alternative(html_email, 'text/html')
            email_message.mixed_subtype = "related"
            mime_image = get_as_mime_image(image_file_path=LOGO_FILE_PATH, cid_name=LOGO_CID_NAME)
            email_message.attach(mime_image)  # type: ignore

        email_message.send()

    def save(
        self,
        domain_override: Optional[str] = None,
        subject_template_name: str = PASSWORD_RESET_SUBJECT_TEMPLATE,
        email_template_name: str = PASSWORD_RESET_TEXT_TEMPLATE,
        use_https: Optional[bool] = None,
        token_generator: PasswordResetTokenGenerator = default_token_generator,
        from_email: Optional[str] = FROM_EMAIL,
        request: Optional[WSGIRequest] = None,
        html_email_template_name: Optional[str] = PASSWORD_RESET_HTML_TEMPLATE,
        extra_email_context: Optional[Dict[str, str]] = None
    ) -> None:
        """Generate a one-use only link for resetting password and email it to 
        the user.

        Args:
            domain_override: Optional; Domain name to use in the email message 
                template that overrides the actual domain from which the email is 
                sent. Defaults to None.
            subject_template_name: Optional; Warning: this argument is overridden 
                by the global variable ``PASSWORD_RESET_SUBJECT_TEMPLATE``.
            email_template_name: Optional; Warning: this argument is overridden by 
                the global variable ``PASSWORD_RESET_TEXT_TEMPLATE``.
            use_https: Optional; If True, use HTTPS, otherwise use HTTP. Defaults 
                to False. Note that if the password reset HTTP request is received 
                via HTTPS, `use_https` will be set to True by the auth view.
            token_generator: Optional; Strategy object used to generate and check 
                tokens for the password reset mechanism. Defaults to an instance 
                of ``django.contrib.auth.tokens.PasswordResetTokenGenerator``.
            from_email: Optional; Warning: this argument is overridden by the 
                global variable``FROM_EMAIL``.
            request: Optional; The HttpRequest object. Defaults to None.
            html_email_template_name: Warning: this argument is overridden by the 
                global variable ``PASSWORD_RESET_HTML_TEMPLATE``.
            extra_email_context: Optional; Key-value pairs to add to the context 
                dictionary used to render the password reset email templates. 
                    Defaults to None.
        """
        email_template_name = PASSWORD_RESET_TEXT_TEMPLATE
        from_email = FROM_EMAIL
        html_email_template_name = PASSWORD_RESET_HTML_TEMPLATE
        subject_template_name = PASSWORD_RESET_SUBJECT_TEMPLATE

        email = self.cleaned_data["email"]
        if not domain_override:
            current_site = get_current_site(request)
            site_name = current_site.name
            domain = current_site.domain
        else:
            site_name = domain = domain_override
        UserModel = get_user_model()
        email_field_name = UserModel.get_email_field_name()  # type: ignore

        for user in self.get_users(email):
            user_email = getattr(user, email_field_name)
            context = {
                'email': user_email,
                'domain': domain,
                'site_name': site_name,
                'uid': urlsafe_base64_encode(force_bytes(user.pk)),
                'user': user,
                'token': token_generator.make_token(user),
                'protocol': 'https' if use_https else 'http',
                **(extra_email_context or {}),
            }

            self.send_mail(
                subject_template_name = subject_template_name,
                email_template_name = email_template_name,
                context = context,
                from_email = from_email,
                to_email = user_email,
                html_email_template_name = html_email_template_name
            )
5

你可以重写 django.contrib.auth.forms.PasswordResetForm 里的 save 方法,然后把新的表单作为参数传给 password_reset 这个视图。

6

下面是如何进行重写的方法:

urls.py

url(r'^user/password/reset/$', 
    'YOUR_APP.views.password_reset', 
    {'post_reset_redirect' : '/#/login?resetemail=true'},
    name="password_reset"),

views.py

from django.contrib.auth.views import password_reset as django_password_reset
from YOUR_APP.forms import CustomPasswordResetForm

def password_reset(*args, **kwargs):
    """
        Overriding the Email Password Resert Forms Save to be able to send HTML email
    """
    kwargs['password_reset_form'] = CustomPasswordResetForm
    return django_password_reset(*args, **kwargs)

form.py

from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.tokens import default_token_generator

class CustomPasswordResetForm(PasswordResetForm):
    """
        Overriding the Email Password Resert Forms Save to be able to send HTML email
    """
    def save(self, domain_override=None, email_template_name='registration/password_reset_email.html',
             use_https=False, token_generator=default_token_generator, request=None, email_subject_name='registration/password_reset_subject.txt', **kwargs):
        from django.core.mail import EmailMultiAlternatives
        from django.utils.html import strip_tags
        from django.template.loader import render_to_string
        from django.contrib.sites.models import get_current_site
        from django.utils.http import int_to_base36

        for user in self.users_cache:
            if not domain_override:
                current_site = get_current_site(request)
                site_name = current_site.name
                domain = current_site.domain
            else:
                 site_name = domain = domain_override

            c = {
                'email': user.email,
                'domain': domain,
                'site_name': site_name,
                'uid': int_to_base36(user.id),
                'user': user,
                'token': token_generator.make_token(user),
                'protocol': use_https and 'https' or 'http',
            }
            render = render_to_string(email_template_name, c)
            render_subject = render_to_string(email_subject_name, c)

            msg = EmailMultiAlternatives(render_subject, strip_tags(render), None, [user.email])
            msg.attach_alternative(render, "text/html")
            msg.send()

撰写回答