十、flask博客项目实战-之发送邮件

oakcdrom0条评论 489 次浏览

概述

本章将学习应用程序如何向用户发送电子邮件,以及如何在电子邮件支持下构建密码修改功能。

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

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

Flask-Mail简介

在发送电子邮件方面,Flask有一个流行扩展,名为Flask-Mail,可让这个任务变得很简单。安装:pip install flask-mail,版本0.9.1
附带安装blinker,版本1.4,它提供一个快速的调度系统,允许任何数量的相关方订阅事件,或“信号”。

(venv) [root@python blog]# pip3 install flask-mail
Collecting flask-mail
  Downloading https://files.pythonhosted.org/packages/05/2f/6a545452040c2556559779db87148d2a85e78a26f90326647b51dc5e81e9/Flask-Mail-0.9.1.tar.gz (45kB)
    100% |████████████████████████████████| 51kB 41kB/s
Requirement already satisfied: Flask in d:\microblog\venv\lib\site-packages (from flask-mail)
Collecting blinker (from flask-mail)
  Downloading https://files.pythonhosted.org/packages/1b/51/e2a9f3b757eb802f61dc1f2b09c8c99f6eb01cf06416c0671253536517b6/blinker-1.4.tar.gz (111kB)
    100% |████████████████████████████████| 112kB 26kB/s
Requirement already satisfied: Werkzeug>=0.14 in d:\microblog\venv\lib\site-packages (from Flask->flask-mail)
Requirement already satisfied: itsdangerous>=0.24 in d:\microblog\venv\lib\site-packages (from Flask->flask-mail)
Requirement already satisfied: Jinja2>=2.10 in d:\microblog\venv\lib\site-packages (from Flask->flask-mail)
Requirement already satisfied: click>=5.1 in d:\microblog\venv\lib\site-packages (from Flask->flask-mail)
Requirement already satisfied: MarkupSafe>=0.23 in d:\microblog\venv\lib\site-packages (from Jinja2>=2.10->Flask->flask-mail)
Installing collected packages: blinker, flask-mail
  Running setup.py install for blinker ... done
  Running setup.py install for flask-mail ... done
Successfully installed blinker-1.4 flask-mail-0.9.1

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

(venv) [root@python blog]# pip3 install pyjwt
Collecting pyjwt
  Downloading https://files.pythonhosted.org/packages/93/d1/3378cc8184a6524dc92993090ee8b4c03847c567e298305d6cf86987e005/PyJWT-1.6.4-py2.py3-none-any.whl
Installing collected packages: pyjwt
Successfully installed pyjwt-1.6.4

Flask-Mail扩展是从app/config对象配置。

和大多数Flask扩展一样,需要在创建Flask应用程序之后立即创建实例。下方是创建一个Mail类对象:
app/__init__.py

#...
from flask_login import LoginManager
from flask_mail import Mail

app = Flask(__name__)
#...
login.login_view = 'login'

mail = Mail(app)
#...

Flask-Mail用法

为了解Flask-Mail是如何工作的,下方将展示在Python shell发送电子邮件,用flask shell启动Python,运行如下命令:

(venv) [root@python blog]# flask shell
[2020-10-30 09:44:03,312] INFO in __init__: blog startup
Python 3.9.0 (default, Oct 16 2020, 10:57:11) 
[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linux
App: app [production]
Instance: /root/blog/instance
>>> from flask_mail import Message
>>> from app import mail
>>> msg = Message('test subject',sender=app.config['ADMINS'][0],recipients=['jemwah96@gmail.com'])
>>> msg.body = 'text body'
>>> msg.html = '<h1>HTML body</h1>'
>>> mail.send(msg)

上述代码将发送电子邮件到recipients参数的电子邮件地址列表。将发送者作为第一个配置的管理员(即在第7章中添加的配置变量)。电子邮件包含纯文本和HTML版本,因此根据电子邮件客户端的配置方式,可能会看到其中一个。

如上所见,很简单。现在将电子邮件集成到应用程序中。

简单的电子邮件架构

首先,编写一个发送电子邮件的辅助函数,它是上一节shell中的通用版本。 app/email.py:发送电子邮件的封装函数

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还支持一些在此没有使用到的功能,如抄送(Cc) 和密件抄送(Bcc)列表。更多详情可查看Flask-Mail文档。

请求重置密码

用户可以选择重置密码。为此,在登录页面中添加一个链接: app/templates/login.html:登录表单中的密码重置链接

#...
    <p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>
    <p>
        Forgot Your Password?
        <a href="{{ url_for('reset_password_request') }}">Click to Reset It</a>
    </p>
{% endblock %}

当用户单击这个链接时,将出现一个新的Web表单,用于请求用户的电子邮件地址作为启动密码重置过程的方法。这个表单类如下:
app/forms.py:重置密码 表单

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

对应的HTML模板如下:
app/templates/reset_password_request.html:重置密码请求的模板

{% 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 %}

当然,还需要一个视图函数来处理这个表单:
app/routes.py:重置密码请求的视图函数

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

#...
def register():
    #...
@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)
#...

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

当表单提交并有效时,将通过表单中用户提供的电子邮件来查找用户。如果找到用户,就发送一封密码重置电子邮件。执行这个操作使用的是send_password_reset_email()辅助函数,稍后展示。

发送电子邮件后,会闪烁一条消息,指示用户查找电子邮件以获取进一步说明,然后重定向回 /login页面。注意到,即使用户提供的电子邮件未知,也会显示闪烁消息。这样的话,客户端将无法使用这个表单来确定给定用户是否为成员。

密码重置令牌

在实现send_password_reset_email()函数之前,我们需要有一种方法来生成密码请求链接。这是提供电子邮件发送给用户的链接。点击链接时,将向用户显示可以设置新密码的页面。这个计划棘手的部分是 确保只有有效的重置链接才可以用来重置账户的密码。

生成的链接中会包含令牌,它将在允许密码变更之前被验证,以证明请求重置密码的用户是通过访问重置密码邮件中的链接而来的。JSON Web Token(JWT)是这类令牌处理的流行标准。它的好处是本身是自成一体的,不仅可以生成令牌,还可以提供对应的验证方法。

JWT是如何工作的?通过Python shell会话将很容易理解:

(venv) [root@python blog]# python3
Python 3.9.0 (default, Oct 16 2020, 10:57:11) 
[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linux
Type "help", "copyright", "credits" or "license" for more information.

>>> 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'}
>>> quit()

(venv) [root@python blog]# 

上述{'a':'b'}字典是一个将要被写入这个令牌的实例有效负载。为了令牌安全,需要提供一个密钥用于创建加密签名。对于这个例子,用了字符串'my-secret',但是在应用程序中将使用配置中的SECRET_KEY。algorithm参数指定令牌如何被生成,HS256算法是最常用的。

如上所见,生成的令牌是一个长串字符。但不要认为这是一个加密令牌。令牌的内容,包括有效载荷,可被任何人轻松解码(复制上述令牌,然后在JWT调试器中输入它以查看其内容)。使令牌安全的是:有效载荷是签名的。假如有人试图在一个令牌中伪造或篡改有效载荷,那么这个签名将无效,并且为了生成一个新签名,需要密钥。验证令牌时,有效载荷的内容被解码并返回给调用者。如果验证了令牌的签名,那么可以将有效载荷视为可信。
file

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

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

由于这些令牌属于用户,因此将在User模型中编写令牌生成和验证的方法:
app/models.py:重置密码令牌方法

#...
from time import time
import jwt
from app import app
#...
class User(UserMixin, db.Model):
    # ...
    def followed_posts(self):
        #...
    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_mail()方法(上述email.py模块中写的)。 app/email.py:发送密码重置电子邮件函数

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['MAIL_USERNAME'],
               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:密码重置电子邮件的文本

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

<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页面中生成链接这通常足够了,因为web浏览器从当前页面中获取URL的其余部分。但是,当通过电子邮件发送一个URL时,该上下文不存在,因此需要使用完全限定的URL。当_external=True作为参数传递时,会生成完整的URL,因此前面的示例将返回http://localhost:5000/user/susan,或在域名上部署应用程序时的相应URL。

重置用户密码

当用户点击电子邮件链接时,将触发与此功能关联的第二个路由。这是密码请求的视图函数: app/routes.py:密码重置的视图函数

#...
from app.forms import ResetPasswordForm
#...
def reset_password_request():
    #...
@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,那么这个方法返回用户。如果令牌无效,会重定向到/index。

如果令牌有效,那么我将向用户显示第二个表单,其中会请求新密码。这个表单的处理方式与之前的表单类似,并且作为有效表单提交的结果,我调用User的set_password()方法去更改密码,然后重定向到用户现在可以登录的登录页面。

下方是ResetPasswordForm类:
app/forms.py:密码重置表单

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

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:密码重置表单模板

{% 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支持以不止一种方式运行异步任务。threading 和 multiprocessing模块 都可以做到这一点。为发送电子邮件启动后台线程 比开始一个全新的流程要少得多,因此我采用如下方法:
app/email.py:异步发送电子邮件

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

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

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

接下来使用163邮箱进行测试。

这个flask-mail中有个MAIL_PASSWORD的配置属性,这里不是让填你的邮箱登陆密码的,而是填写我们这一步即将获得的授权码
进入准备作为发件人的邮箱,点击【设置|客户端授权密码】,这里点击开启,会要先验证手机号,然后设置一个新密码并记住它!

file

安装flask-dotenv

(venv) [root@python blog]#pip3 install flask-dotenv

1)、项目根目录下添加microblog.env文件:

MAIL_SERVER=smtp.163.com
MAIL_PORT=25
MAIL_USE_TLS=True
MAIL_USE_SSL=False
MAIL_USERNAME=songboriceboy2@163.com
MAIL_PASSWORD=客户端授权密码

2)、修改config.py中的配置项:
microblog/config.py:

#...
import os

basedir = os.path.abspath(os.path.dirname(__file__))  # 获取当前.py文件的绝对路径

from dotenv import load_dotenv

basedir = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(basedir, 'microblog.env'))

class Config:
    #...

    MAIL_SERVER = os.environ.get('MAIL_SERVER')
    MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
    MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS')
    MAIL_USE_SSL = os.environ.get('MAIL_USE_SSL', 'false').lower() in ['true', 'on', '1']
    MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
    MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')#客户端授权密码
    #...

3)、flask run 运行程序,效果:

(venv) [root@python blog]#flask run
 * Environment: production
   WARNING: Do not use the development server in a production environment.
   Use a production WSGI server instead.
 * Debug mode: off
[2018-08-22 12:07:18,392] INFO in __init__: Microblog startup
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
127.0.0.1 - - [22/Aug/2018 12:07:24] "GET /login HTTP/1.1" 200 -
127.0.0.1 - - [22/Aug/2018 12:07:24] "GET /favicon.ico HTTP/1.1" 404 -
127.0.0.1 - - [22/Aug/2018 12:07:28] "GET /reset_password_request HTTP/1.1" 200 -
127.0.0.1 - - [22/Aug/2018 12:07:36] "POST /reset_password_request HTTP/1.1" 302 -
127.0.0.1 - - [22/Aug/2018 12:07:36] "GET /login HTTP/1.1" 200 -
127.0.0.1 - - [22/Aug/2018 12:07:56] "GET /reset_password/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyZXNldF9wYXNzd29yZCI6NiwiZXhwIjoxNTM0OTExNDU2LjM1NDI0MDd9.AczmZ5WjKX1Lu6Iv6w3a0tL9LtHs7HbXETbSZ5nqJuY HTTP/1.1" 200 -
127.0.0.1 - - [22/Aug/2018 12:09:26] "POST /reset_password/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyZXNldF9wYXNzd29yZCI6NiwiZXhwIjoxNTM0OTExNDU2LjM1NDI0MDd9.AczmZ5WjKX1Lu6Iv6w3a0tL9LtHs7HbXETbSZ5nqJuY HTTP/1.1" 302 -
127.0.0.1 - - [22/Aug/2018 12:09:26] "GET /login HTTP/1.1" 200 -

在/login页面点击 Click to Reset It按钮,
file

点击 Request Password Reset提交按钮,用户注册时的QQ电子邮箱将收到一封电子邮件,示例如下:
file

点击 click here链接,或把链接复制到浏览器:
file
重置密码成功(用户 oldiron,b123456(原密码a123456))。

目前为止,项目结构:


    microblog/
    app/
        templates/
            email/
                reset_password.html
                reset_password.txt
            _post.html
            404.html
            500.html
            base.html
            edit_profile.html
            index.html
            login.html
            register.html
            reset_password.html
            reset_password_request.html
            user.html
        __init__.py
        email.py
        errors.py
        forms.py
        models.py
        routes.py
    logs/
        microblog.log
    migrations/
    venv/
    app.db
    config.py
    blog.env
    microblog.py
    tests.py

发表评论

? razz sad evil ! smile oops grin eek shock ??? cool lol mad twisted roll wink idea arrow neutral cry mrgreen