Flask Mega-Tutorial 中文教程 V2.0 第10章:电子邮件支持

巫新知
2023-12-01

最近在Flask Web Development作者博客看到第二版Flask Mega-Tutorial已在2017年底更新,现翻译给大家参考,希望帮助大家学习flask。

这是Flask Mega-Tutorial系列的第十章,其中我将告诉您应用程序如何向用户发送电子邮件,以及如何在电子邮件支持之上构建密码重置功能。

供您参考,以下是本系列文章的列表。

应用程序现在在数据库方面做得很好,所以在本章中我想脱离这个主题并添加大多数Web应用程序需要的另一个重要部分,即发送电子邮件。

为什么应用程序需要向其用户发送电子邮件呢?原因很多,但一个常见的原因是解决与身份验证相关的问题。在本章中,我将为忘记密码的用户添加密码重置功能。当用户请求重置密码时,应用程序将发送包含特制链接的电子邮件。然后,用户需要单击该链接以访问用于设置新密码的表单。

本章的GitHub链接是:BrowseZipDiff


Flask-Mail简介

就实际发送邮件而言,Flask有一个名为Flask-Mail的流行扩展,可以使任务变得非常简单。与往常一样,此扩展程序可以使用pip安装:

(venv) $ pip install flask-mail

密码重置链接中将包含一个安全令牌。为了生成这些令牌,我将使用JSON Web Tokens,它也有一个流行的Python包:

(venv) $ pip install pyjwt

Flask-Mail扩展是通过app.config对象来配置的。还记得在第7章中,我添加了电子邮件配置,以便在生产中发生错误时向自己发送电子邮件吗?当时我没有告诉你,我选择的配置变量是根据Flask-Mail的要求增加的,因此实际上没有任何额外的工作需要,配置变量已经在应用程序中了。

与大多数Flask扩展一样,您需要在创建Flask应用程序后立即创建一个邮件实例。这本例中,mail是类Mail的对象:

# app/__init__.py: Flask-Mail instance.

# ...
from flask_mail import Mail

app = Flask(__name__)
# ...
mail = Mail(app)

如果您打算测试发送电子邮件,您可以使用我在第7章中提到的两种方式。如果您想使用模拟的电子邮件服务器,Python提供了一个非常方便的,您可以使用以下命令在第二个终端中启动:

(venv) $ python -m smtpd -n -c DebuggingServer localhost:8025

要配置此服务器,您需要设置两个环境变量:

venv) $ export MAIL_SERVER=localhost
(venv) $ export MAIL_PORT=8025

如果您希望发送真实的电子邮件,则需要使用真实的电子邮件服务器。那么你只需要设置MAIL_SERVERMAIL_PORTMAIL_USE_TLSMAIL_USERNAMEMAIL_PASSWORD环境变量。

如果您想要快速解决方案,可以使用Gmail帐户发送电子邮件,并使用以下设置:

(venv) $ export MAIL_SERVER=smtp.googlemail.com
(venv) $ export MAIL_PORT=587
(venv) $ export MAIL_USE_TLS=1
(venv) $ export MAIL_USERNAME=<your-gmail-username>
(venv) $ export MAIL_PASSWORD=<your-gmail-password>

如果使用的是微软的Windows,你需要在上述声明中将每个export语句中的export替换为set

请注意,Gmail帐户中的安全功能可能会阻止应用程序通过它发送电子邮件,除非您明确允许“安全性较低的应用”访问您的Gmail帐户。您可以在此处阅读此内容,如果您担心帐户的安全性,可以创建仅为测试电子邮件而配置的辅助帐户,或者您可以暂时启用安全性较低的应用程序来运行测试,然后还原更安全的默认值。

Flask-Mail的使用

要了解Flask-Mail的工作原理,我将向您展示如何从Python shell发送电子邮件。运行flask shell来激活Python ,然后运行以下命令:

>>> from flask_mail import Message
>>> from app import mail
>>> msg = Message('test subject', sender=app.config['ADMINS'][0],
... recipients=['your-email@example.com'])
>>> msg.body = 'text body'
>>> msg.html = '<h1>HTML body</h1>'
>>> mail.send(msg)

上面的代码片段会将电子邮件发送到您在recipients参数中放入的电子邮件地址列表。我把发送人作为第一个配置到网站管理员(我在第7章中添加了ADMINS配置变量)。电子邮件将包含纯文本和HTML版本,根据您的电子邮件客户端的配置方式,您可能会看到其中一个。

如你所见,这很简单。现在让我们将电子邮件集成到应用程序中。

简单的电子邮件框架

我将首先编写一个发送电子邮件的帮助函数,该函数基本上是上一节中shell练习的通用版本。我将把这个函数放在一个名为app/email.py的新模块中:

# app/email.py: Email sending wrapper function.

from flask_mail import Message
from app import mail

def send_email(subject, sender, recipients, text_body, html_body):
    msg = Message(subject, sender=sender, recipients=recipients)
    msg.body = text_body
    msg.html = html_body
    mail.send(msg)

Flask-Mail支持我在这里忽略一些功能,例如抄送和密件抄送列表。如果您对这些选项感兴趣,请务必查看Flask-Mail文档

重置密码请求

正如我上面提到的,我希望用户有权重置密码。因此我将在登录页面中添加一个链接:

<!-- app/templates/login.html: Password reset link in login form. -->

    <p>
        Forgot Your Password?
        <a href="{{ url_for('reset_password_request') }}">Click to Reset It</a>
    </p>

当用户单击该链接时,将出现一个新的Web表单,要求用户输入注册的电子邮件地址,以启动密码重置流程。这是表单类:

# app/forms.py: Reset password request form.

class ResetPasswordRequestForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Email()])
    submit = SubmitField('Request Password Reset')

这是相应的HTML模板:

<!-- app/templates/reset_password_request.html: Reset password request template. -->

{% extends "base.html" %}

{% block content %}
    <h1>Reset Password</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.email.label }}<br>
            {{ form.email(size=64) }}<br>
            {% for error in form.email.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

我还需要一个view函数来处理这个表单:

# app/routes.py: Reset password request view function.

from app.forms import ResetPasswordRequestForm
from app.email import send_password_reset_email

@app.route('/reset_password_request', methods=['GET', 'POST'])
def reset_password_request():
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    form = ResetPasswordRequestForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user:
            send_password_reset_email(user)
        flash('Check your email for the instructions to reset your password')
        return redirect(url_for('login'))
    return render_template('reset_password_request.html',
                           title='Reset Password', form=form)

此视图函数与其他表单的处理函数非常相似。我首先确保用户没有登录。如果用户已登录,则使用密码重置功能没有意义,因此我重定向到首页页面。

当表单被提交并通过验证时,我会通过表单中用户提供的电子邮件查找用户。如果我找到该用户,我会发送密码重置电子邮件。我使用send_password_reset_email()辅助函数来执行此操作。我将在下面向您展示此功能。

发送电子邮件后,我会闪烁一条消息,指示用户查找电子邮件以获取进一步说明,然后重定向回登录页面。您可能会注意到即使用户提供的电子邮件未知,也会显示闪烁的消息。这样客户端就无法使用此表单来确定给定用户是否已注册。

密码重置令牌

在实现send_password_reset_email()函数之前,我需要有一种方法来生成密码重置链接。它将通过电子邮件发送给用户。单击链接时,将向用户显示可以设置新密码的页面。此计划的棘手部分是确保只有使用有效的重置链接才能重置帐户的密码。

这个链接将被配置一个令牌,并且在允许更改密码之前将验证此令牌,以证明请求重置密码的用户是通过访问重置密码邮件中的链接而来的。JSON Web Tokens(JWT)是这类令牌处理的一种流行标准。关于JWTs的好处是它们自成一体的。您可以通过电子邮件向用户发送令牌,当用户单击链接将令牌反馈给应用程序时,还可以独立验证它。

JWT如何运作?没有比Python shell更好的方式来理解它们了:

>>> import jwt
>>> token = jwt.encode({'a': 'b'}, 'my-secret', algorithm='HS256')
>>> token
b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhIjoiYiJ9.dvOo58OBDHiuSHD4uW88nfJikhYAXc_sfUHq1mDi4G0'
>>> jwt.decode(token, 'my-secret', algorithms=['HS256'])
{'a': 'b'}

{'a': 'b'}词典是将要被写入到令牌的示例有效负载。为了使令牌安全,需要提供密钥以用于创建加密签名。在这个例子中,我使用了字符串'my-secret',但是对于应用程序,我将使用配置中的SECRET_KEYalgorithm参数指定令牌使用什么算法来生成。而HS256算法是使用最广泛的算法。

如您所见,生成的令牌是一长串字符。但不要以为这是加密令牌。令牌的内容,包括有效载荷,可以被任何人轻松解码(不相信我?复制上面的令牌,然后在JWT调试器中输入它就能查看其内容)。令牌安全的原因是有效负载是被签名的。如果有人试图在令牌中伪造或篡改有效载荷,则签名将无效,并且生成新签名需要依赖密钥。验证令牌时,有效载荷的内容将被解码并返回给调用者。如果令牌的签名验证通过,有效负载才可以被认为是可信的。

我用于密码重置令牌的有效负载格式为{'reset_password': user_id, 'exp': token_expiration}exp字段是JWT的标准字段,如果存在,则表示令牌的到期时间。如果令牌具有有效签名,但它已过期,则它也将被视为无效。对于密码重置功能,我将给这些令牌提供10分钟的有效期。

当用户点击电子邮件的链接时,该令牌将作为URL的一部分发送回应用程序,处理这个URL的视图功能将首先验证它。如果签名有效,则可以通过存储在有效载荷中的ID来识别用户。一旦知道了用户的身份,应用程序就可以要求输入新密码并将其设置在用户的帐户上。

由于这些令牌属于用户,我将在User模型中编写令牌生成和验证函数编的方法:

# app/models.py: Reset password token methods.

from time import time
import jwt
from app import app

class User(UserMixin, db.Model):
    # ...

    def get_reset_password_token(self, expires_in=600):
        return jwt.encode(
            {'reset_password': self.id, 'exp': time() + expires_in},
            app.config['SECRET_KEY'], algorithm='HS256').decode('utf-8')

    @staticmethod
    def verify_reset_password_token(token):
        try:
            id = jwt.decode(token, app.config['SECRET_KEY'],
                            algorithms=['HS256'])['reset_password']
        except:
            return
        return User.query.get(id)

get_reset_password_token()函数以字符串形式生成一个JWT令牌。请注意,decode('utf-8')是必要的,因为jwt.encode()函数将令牌作为字节序列返回,但在应用程序中将令牌作为字符串更方便。

verify_reset_password_token()是一个静态方法,这意味着它可以直接从类中调用。静态方法类似于类方法,唯一的区别是静态方法不接收类作为第一个参数。这个方法接受一个令牌并尝试通过调用PyJWT的jwt.decode()函数对其进行解码。如果令牌无法验证或过期,则会引发异常,在这种情况下,我会捕获它以防止错误,然后返回None给调用者。如果令牌有效,则令牌有效负载中reset_password的密钥值就是用户的ID,我可以加载用户并将其返回。

发送密码重置电子邮件

现在我有了令牌,我可以生成密码重置电子邮件。send_password_reset_email()函数依赖于我上面写的send_email()函数。

# app/email.py: Send password reset email function.

from flask import render_template
from app import app

# ...

def send_password_reset_email(user):
    token = user.get_reset_password_token()
    send_email('[Microblog] Reset Your Password',
               sender=app.config['ADMINS'][0],
               recipients=[user.email],
               text_body=render_template('email/reset_password.txt',
                                         user=user, token=token),
               html_body=render_template('email/reset_password.html',
                                         user=user, token=token))

这个函数中有趣的部分是电子邮件的文本和HTML内容是使用熟悉的render_template()函数从模板生成的。模板接收用户和令牌作为参数,以便可以生成个性化电子邮件消息。以下是重置密码电子邮件的文本模板:

app / templates / email / reset_password.txt:密码重置电子邮件的文本。

<!-- app/templates/email/reset_password.txt: Text for password reset email. -->

Dear {{ user.username }},

To reset your password click on the following link:

{{ url_for('reset_password', token=token, _external=True) }}

If you have not requested a password reset simply ignore this message.

Sincerely,

The Microblog Team

这是更美观的HTML版本:

app / templates / email / reset_password.html:用于密码重置电子邮件的HTML。

<!-- app/templates/email/reset_password.html: HTML for password reset email. -->

<p>Dear {{ user.username }},</p>
<p>
    To reset your password
    <a href="{{ url_for('reset_password', token=token, _external=True) }}">
        click here
    </a>.
</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('reset_password', token=token, _external=True) }}</p>
<p>If you have not requested a password reset simply ignore this message.</p>
<p>Sincerely,</p>
<p>The Microblog Team</p>

这两个电子邮件模板中的url_for()调用中引用的reset_password路由尚不存在,这将在下一节中添加。

url_for()在两个模板中的调用中包含的参数_external=True也是新的知识点。url_for()默认情况下生成的URL 是相对URL,例如url_for('user', username='susan')调用将返回/ user / susan。这通常足以用于在网页中生成的链接,因为Web浏览器从当前页面获取URL的其余部分。但是,当通过电子邮件发送URL时,该上下文就不存在,因此需要使用完整的URL。当_external=True作为参数传递时,会生成完整的URL,如前面的示例将返回http://localhost:5000/user/susan,或部署应用程序时正式域名上的相应URL。

重置用户密码

当用户单击电子邮件链接时,将触发与此功能关联的第二个路由。这是密码重置视图功能:

# app/routes.py: Password reset view function.

from app.forms import ResetPasswordForm

@app.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token):
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    user = User.verify_reset_password_token(token)
    if not user:
        return redirect(url_for('index'))
    form = ResetPasswordForm()
    if form.validate_on_submit():
        user.set_password(form.password.data)
        db.session.commit()
        flash('Your password has been reset.')
        return redirect(url_for('login'))
    return render_template('reset_password.html', form=form)

在此视图函数中,我首先确保用户未登录,然后通过在User类中调用令牌验证方法来确定用户是谁。如果令牌有效,则此方法返回用户。如果令牌无效,则返回None,并重定向到主页。

如果令牌有效,那么我向用户显示第二个表单,其中需要用户输入新密码。此表单的处理方式与之前的表单类似,并且表单提交验证通过之后,我调用User类的set_password()方法更改密码,然后重定向到登录页面,以便用户可以登录。

这是 ResetPasswordForm 类:

# app/forms.py: Password reset form.

class ResetPasswordForm(FlaskForm):
    password = PasswordField('Password', validators=[DataRequired()])
    password2 = PasswordField(
        'Repeat Password', validators=[DataRequired(), EqualTo('password')])
    submit = SubmitField('Request Password Reset')

这是相应的HTML模板:

<!-- app/templates/reset_password.html: Password reset form template. -->

{% extends "base.html" %}

{% block content %}
    <h1>Reset Your Password</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.password.label }}<br>
            {{ form.password(size=32) }}<br>
            {% for error in form.password.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.password2.label }}<br>
            {{ form.password2(size=32) }}<br>
            {% for error in form.password2.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

密码重置功能现已完成,请务必多试几次。

异步发送电子邮件

如果您使用Python提供的模拟电子邮件服务器,您可能没有注意到这一点,那就是发送电子邮件会大大减慢应用程序的速度。原因是发送电子邮件时需要进行的所有交互都会导致任务变慢,通常需要几秒钟才能收到电子邮件,如果收件人的电子邮件服务器速度很慢,或者有多个收件人,可能会更久。

我真正想要的是send_email()函数是异步执行的。那是什么意思呢?这意味着当调用此函数时,发送电子邮件的任务将在后台执行,释放send_email()后立即返回,以便应用程序在发送电子邮件的同时可以继续运行。

Python支持多种方式运行异步任务。threadingmultiprocessing模块都可以做到这一点。启动一个发送电子邮件的后台线程比重新开启一个全新的进程所需的资源要少得多,因此我将采用这种方法:

# app/email.py: Send emails asynchronously.

from threading import Thread
# ...

def send_async_email(app, msg):
    with app.app_context():
        mail.send(msg)


def send_email(subject, sender, recipients, text_body, html_body):
    msg = Message(subject, sender=sender, recipients=recipients)
    msg.body = text_body
    msg.html = html_body
    Thread(target=send_async_email, args=(app, msg)).start()

send_async_email函数现在在后台线程中运行,通过最后一行中的Thread()类调用send_email()。通过此更改,电子邮件的发送将在线程中运行,并且当进程完成时,线程将结束并自行清理。如果您配置了真实的电子邮件服务器,当您按密码重置请求表单上的提交按钮时,您肯定会注意到速度的提高。

您可能希望只将msg参数发送到线程,但正如您在代码中看到的那样,我也传入了应用程序实例。使用线程时,需要牢记Flask的一个重要设计方面。Flask使用上下文来避免在函数之间传递参数。我不打算详细介绍这个,但要知道有两种类型的上下文,即应用程序上下文请求上下文。在大多数情况下,这些上下文由框架自动管理,但是当应用程序启动自定义线程时,可能需要手动创建这些线程的上下文。

有许多扩展需要应用程序上下文才能工作,因为这允许他们找到Flask应用程序实例,而不需要将其作为参数传递。许多扩展需要知道应用程序实例的原因是,因为它们的配置存储在app.config对象中。这正是Flask-Mail的情况。mail.send()方法需要访问电子邮件服务器的配置值,而这只能通过知道应用程序是什么来完成。使用with app.app_context()调用创建的应用程序上下文,使得应用程序实例可以通过Flask中的current_app变量访问。


原文链接:https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-x-email-support

 类似资料: