Skip to content

Better hooks to customize PasswordResetForm email sending #175

Description

@medmunds

Code of Conduct

  • I agree to follow Django's Code of Conduct

Feature Description

Break up PasswordResetForm.send_mail() to allow custom subclasses to override email rendering and email sending independently, without having to duplicate all its existing logic.

Problem

With the introduction of MAILERS in Django 6.1, projects may wish to use a non-"default" mailer config for sending password reset emails. (E.g., to use a special high-priority queue.)

The current PasswordResetForm.send_mail() implementation combines rendering and composing the email with sending the resulting message. A subclass that overrides it to change to email_message.send(using="priority") would need to also duplicate all of the rendering code—and then carefully track any future changes in the base class.

DEP 0018 suggested (as MAILERS follow-on work) adding a PasswordResetForm.email_using property to simplify this. But that wouldn't handle cases where the mailer choice needs to be dynamic based on other factors, like the user's region: email_message.send(using="eu" if context["user"].gdpr_applies() else "default").

Similarly, there may be cases where a project wants to adjust password reset email rendering without duplicating the sending code. (Adding to the context, ensuring the email uses the user's locale rather than the request's, etc.)

Request or proposal

proposal

Additional Details

No response

Implementation Suggestions

Suggestion:

class PasswordResetForm(forms.Form):
    ...

    email_using = None

    def send_mail(
        self,
        subject_template_name,
        email_template_name,
        context,
        from_email,
        to_email,
        html_email_template_name=None,
    ):
        email_message = self.build_email_message(
            subject_template_name=subject_template_name,
            email_template_name=email_template_name,
            html_email_template_name=html_email_template_name,
            context=context,
            from_email=from_email,
            to_email=to_email,
        )

        try:
            self.send_email_message(
                email_message=email_message, context=context
            )
        except Exception:
            logger.exception(
                "Failed to send password reset email to %s", context["user"].pk
            )

    def build_email_message(
        self,
        *,
        subject_template_name,
        email_template_name,
        html_email_template_name,
        context,
        from_email,
        to_email,
    ):
        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, [to_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")
        return email_message

    def send_email_message(self, *, email_message, context):
        email_message.send(using=self.email_using)

    ...

The email_using property is there to simplify non-dynamic cases, but isn't strictly required. (It's called email_using to avoid any potential confusion with databases' using.)

context is passed to send_email_message() to give access to the user, domain (site), etc. (The from_email and to_email are available in attributes of email_message, so aren't repeated as separate args.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Idea

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions