Flask Vue.js全栈开发|第14章:邮件支持

  • 原创
  • Madman
  • /
  • 2018-11-28 16:53
  • /
  • 0
  • 392 次阅读

flask vuejs 全栈开发-min.png

Synopsis: 使用 Flask-Mail 给用户发送邮件,用户注册时需要先通过邮件确认账户,不然不让他访问前端任何路由(防止用户注册时提供虚假邮箱地址)。同时,如果用户忘记了自己的账户密码,也可以通过邮件重置密码。注意,邮件中的链接会包含 JWT,确保用户只能确认自己的账户或重置自己的账户密码

代码已上传到 https://github.com/wangy8961/flask-vuejs-madblog/tree/v0.14 ,欢迎star

1. Flask-Mail

安装 Flask-Mail 插件:

(venv) D:\python-code\flask-vuejs-madblog\back-end>pip install flask-mail
(venv) D:\python-code\flask-vuejs-madblog\back-end>pip freeze > requirements.txt

导入插件,修改 back-end/app/extensions.py

...
from flask_mail import Mail

...
# Flask-Mail plugin
mail = Mail()

初始化插件,修改 back-end/app/__init__.py

from app.extensions import mail

...
def configure_extensions(app):
    '''Configures the extensions.'''
    ...
    # Init Flask-Mail
    mail.init_app(app)

指定相关配置项,修改 back-end/config.py

class Config(object):
    ...
    # 邮件配置
    MAIL_SERVER = os.environ.get('MAIL_SERVER')
    MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
    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')
    MAIL_SENDER = os.environ.get('MAIL_SENDER')
    ...

详细使用方法请参考 Flask-Mail 文档

1.1 Python SMTP Server: smtpd

要测试发送邮件,只需要让 Flask-Mail 连接到 简单邮件传输协议(Simple Mail Transfer Protocol, SMTP)服务器,并把邮件交给这个服务器发送即可

Python提供了 smtpd 模块来启动一个模拟的电子邮件服务器,打开一个新终端,输入如下命令:

C:\Users\wangy>python -m smtpd -n -c DebuggingServer localhost:5001

此时,你本机就启动了一个邮件服务器,并运行在 5001 端口上。然后,在另一个终端设置相关环境变量:

(venv) D:\python-code\flask-vuejs-madblog\back-end>set MAIL_SERVER=localhost
(venv) D:\python-code\flask-vuejs-madblog\back-end>set MAIL_PORT=5001
(venv) D:\python-code\flask-vuejs-madblog\back-end>flask shell
Python 3.6.1 (v3.6.1:69c0db5, Mar 21 2017, 17:54:52) [MSC v.1900 32 bit (Intel)] on win32
App: app [production]
Instance: D:\python-code\flask-vuejs-madblog\back-end\instance
>>> from flask_mail import Message
>>> from app.extensions import mail
>>> msg = Message('test subject', sender='admin@madmalls.com', recipients=['wangy890601@163.com'])
>>> msg.body = "testing"
>>> msg.html = "<b>testing</b>"
>>> mail.send(msg)
send: 'ehlo [172.17.1.80]\r\n'
reply: b'250-DESKTOP-II1RENJ\r\n'
reply: b'250-8BITMIME\r\n'
reply: b'250 HELP\r\n'
reply: retcode (250); Msg: b'DESKTOP-II1RENJ\n8BITMIME\nHELP'
send: 'mail FROM:<admin@madmalls.com>\r\n'
reply: b'250 OK\r\n'
reply: retcode (250); Msg: b'OK'
send: 'rcpt TO:<wangy890601@163.com>\r\n'
reply: b'250 OK\r\n'
reply: retcode (250); Msg: b'OK'
send: 'data\r\n'
reply: b'354 End data with <CR><LF>.<CR><LF>\r\n'
reply: retcode (354); Msg: b'End data with <CR><LF>.<CR><LF>'
data: (354, b'End data with <CR><LF>.<CR><LF>')
send: b'Content-Type: multipart/mixed; boundary="===============0699970148=="\r\nMIME-Version: 1.0\r\nSubject: test subject\r\nFrom: admin@madmalls.com\r\nTo: wangy890601@163.com\r\nDate: Wed, 28 Nov 2018 11:23:35 +0800\r\nMessage-ID: <154337540655.11996.13280566001020547532@DESKTOP-II1RENJ>\r\n\r\n--===============0699970148==\r\nContent-Type: multipart/alternative; boundary="===============0982821201=="\r\nMIME-Version: 1.0\r\n\r\n--===============0982821201==\r\nContent-Type: text/plain; charset="utf-8"\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 7bit\r\n\r\ntesting\r\n--===============0982821201==\r\nContent-Type: text/html; charset="utf-8"\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 7bit\r\n\r\n<b>testing</b>\r\n--===============0982821201==--\r\n\r\n--===============0699970148==--\r\n.\r\n'
reply: b'250 OK\r\n'
reply: retcode (250); Msg: b'OK'
data: (250, b'OK')
send: 'quit\r\n'
reply: b'221 Bye\r\n'
reply: retcode (221); Msg: b'Bye'
>>>

说明 Flask-Mail 成功连接到本机的测试邮件服务器,并发送了邮件,你也可以去启动测试邮件服务器的终端中查看到发送过程

1.2 QQ邮箱

要使用 QQ 邮箱的 SMTP 服务来发送邮件,首先需要开启该服务(设置 -- 账户),并获取授权码

1 qq邮箱开启 smtp 服务

修改 back-end/.env

...
MAIL_SERVER='smtp.qq.com'
MAIL_PORT=465
MAIL_USE_SSL=1
MAIL_USERNAME='你的QQ邮箱地址'
MAIL_PASSWORD='授权码'
MAIL_SENDER='Madman <xxx@qq.com>'

重新开启一个终端,将使用 .env 中设置的环境变量:

(venv) D:\python-code\flask-vuejs-madblog\back-end>flask shell
Python 3.6.1 (v3.6.1:69c0db5, Mar 21 2017, 17:54:52) [MSC v.1900 32 bit (Intel)] on win32
App: app [production]
Instance: D:\python-code\flask-vuejs-madblog\back-end\instance
>>> from flask_mail import Message
>>> from app.extensions import mail
>>> msg = Message('test subject', sender='2169618016@qq.com', recipients=['wangy890601@163.com'])
>>> msg.body = "testing"
>>> msg.html = "<b>testing</b>"
>>> mail.send(msg)

接收者的邮箱应该会收到一份邮件,注意 sender 必须填 QQ 邮箱地址,不然会报错: smtplib.SMTPSenderRefused: (501, b'mail from address must be same as authorization user', 'admin@madmalls.com')

2. 确认账户

有必要确认注册时用户提供的信息是否正确,常见要求是能通过提供的电子邮件地址与用户取得联系。为验证电子邮件地址, 用户注册后,程序会立即发送一封 确认邮件。新账户先被标记成 待确认 状态,用户按照邮件中的说明操作后,才能证明自己可以被联系上。账户确认过程中,往往会要求用户点击一个包含 确认令牌 的特殊 URL 链接

2.1 数据库

修改 back-end/app/models.py

class User(PaginatedAPIMixin, db.Model):
    ...
    # 用户注册后,需要先确认邮箱
    confirmed = db.Column(db.Boolean, default=False)
    ...

    def generate_confirm_jwt(self, expires_in=3600):
        '''生成确认账户的 JWT'''
        now = datetime.utcnow()
        payload = {
            'confirm': self.id,
            'exp': now + timedelta(seconds=expires_in),
            'iat': now
        }
        return jwt.encode(
            payload,
            current_app.config['SECRET_KEY'],
            algorithm='HS256').decode('utf-8')

    def verify_confirm_jwt(self, token):
        '''用户点击确认邮件中的URL后,需要检验 JWT,如果检验通过,则把新添加的 confirmed 属性设为 True'''
        try:
            payload = jwt.decode(
                token,
                current_app.config['SECRET_KEY'],
                algorithms=['HS256'])
        except (jwt.exceptions.ExpiredSignatureError,
                jwt.exceptions.InvalidSignatureError,
                jwt.exceptions.DecodeError) as e:
            # Token过期,或被人修改,那么签名验证也会失败
            return False
        if payload.get('confirm') != self.id:
            return False
        self.confirmed = True
        db.session.add(self)
        return True

数据库迁移:

(venv) D:\python-code\flask-vuejs-madblog\back-end>flask db migrate -m "user need confirm account"
(venv) D:\python-code\flask-vuejs-madblog\back-end>flask db upgrade

2.2 同步发送邮件

新增 back-end/utils/email.py

from flask_mail import Message
from app.extensions 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)

在用户注册视图 create_user() 中增加发送邮件的逻辑,由于前后端分离,邮件中的链接应该是前端的地址拼接而来,所以前端还需要提供 confirm_email_base_url

@bp.route('/users/', methods=['POST'])
def create_user():
    '''注册一个新用户'''
    ...
    # 发送确认账户的邮件
    token = user.generate_confirm_jwt()
    if not data.get('confirm_email_base_url'):
        confirm_url = 'http://127.0.0.1:5000/api/confirm/' + token
    else:
        confirm_url = data.get('confirm_email_base_url') + token

    text_body = '''
    Dear {},
    Welcome to Madblog!
    To confirm your account please click on the following link: {}
    Sincerely,
    The Madblog Team
    Note: replies to this email address are not monitored.
    '''.format(user.username, confirm_url)

    html_body = '''
    <p>Dear {0},</p>
    <p>Welcome to <b>Madblog</b>!</p>
    <p>To confirm your account please <a href="{1}">click here</a>.</p>
    <p>Alternatively, you can paste the following link in your browser's address bar:</p>
    <p><b>{1}</b></p>
    <p>Sincerely,</p>
    <p>The Madblog Team</p>
    <p><small>Note: replies to this email address are not monitored.</small></p>
    '''.format(user.username, confirm_url)

    send_email('[Madblog] Confirm Your Account',
               sender=current_app.config['MAIL_SENDER'],
               recipients=[user.email],
               text_body=text_body,
               html_body=html_body)

前端修改 Register.vue

<script>
export default {
  ...
  methods: {
    onSubmit (e) {
      ...
      const path = '/api/users'
      const payload = {
        confirm_email_base_url: window.location.href.split('/', 4).join('/') + '/unconfirmed/?token=',
        username: this.registerForm.username,
        email: this.registerForm.email,
        password: this.registerForm.password
      }
      this.$axios.post(path, payload)
        .then((response) => {
          // handle success
          this.$toasted.success('A confirmation email has been sent to you by email.', { icon: 'fingerprint' })
          this.$router.push('/login')
        })
        .catch((error) => {
          // handle error
          ...
        }
    }
  }
}
</script>

如何确保用户是 待确认 状态时,无法访问前端其它路由?修改 front-end/src/router/index.js

router.beforeEach((to, from, next) => {
  const token = window.localStorage.getItem('madblog-token')
  if (token) {
    var payload = JSON.parse(atob(token.split('.')[1]))
  }

  if (to.matched.some(record => record.meta.requiresAuth) && (!token || token === null)) {
    // 1. 用户未登录,但想访问需要认证的相关路由时,跳转到 登录 页
    Vue.toasted.show('Please log in to access this page.', { icon: 'fingerprint' })
    next({
      path: '/login',
      query: { redirect: to.fullPath }
    })
  } else if (token && !payload.confirmed && to.name != 'Unconfirmed') {
    // 2. 用户刚注册,但是还没确认邮箱地址时,全部跳转到 认证提示 页面
    Vue.toasted.show('Please confirm your accout to access this page.', { icon: 'fingerprint' })
    next({
      path: '/unconfirmed',
      query: { redirect: to.fullPath }
    })
  } else if (token && payload.confirmed && to.name == 'Unconfirmed') {
    // 3. 用户账户已确认,但又去访问 认证提示 页面时不让他过去
    next({
      path: '/'
    })
  } else if (token && (to.name == 'Login' || to.name == 'Register' || to.name == 'ResetPasswordRequest' || to.name == 'ResetPassword')) {
    // 4. 用户已登录,但又去访问 登录/注册/请求重置密码/重置密码 页面时不让他过去
    next({
      path: from.fullPath
    })
  } else if (to.matched.length === 0) {
    // 5. 要前往的路由不存在时
    Vue.toasted.error('404: Not Found', { icon: 'fingerprint' })
    if (from.name) {
      next({
        name: from.name
      })
    } else {
      next({
        path: '/'
      })
    }
  } else {
    // 6. 正常路由出口
    next()
  }
})

export default router

效果图如下:

2 发送确认账户邮件

2.3 异步发送邮件

上面的动态图中,你应该明显感觉出,当我点击 Register 按钮后,应用会卡住 2-3 秒,直到邮件被发送出去后才继续响应(前端弹出提示信息了)。现在改成 多线程 异步发送邮件,电子邮件的发送将在线程中运行,并且当邮件发送完成后,线程将结束并自行清理

由于开启了新的线程,所以需要推送 Flask应用上下文( 使用 with app.app_context()),不然 Flask-Mail 将找不到配置项,因为它的相关配置存储在 app.config 对象中

修改 back-end/utils/email.py

from threading import Thread
from flask import current_app
from flask_mail import Message
from app.extensions import mail


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


def send_email(subject, sender, recipients, text_body, html_body,
               attachments=None, sync=False):
    msg = Message(subject, sender=sender, recipients=recipients)
    msg.body = text_body
    msg.html = html_body
    if attachments:
        for attachment in attachments:
            msg.attach(*attachment)
    if sync:
        mail.send(msg)
    else:
        Thread(target=send_async_email, args=(current_app._get_current_object(), msg)).start()

现在的 send_mail() 方法默认使用 异步 的方式,也可以指定 sync=True 来使用同步的方式(后续讲任务队列时会用到);同时也支持发送 邮件附件

此时,你再试几次用户注册,就会发现应用响应很快了,并不会因为发送邮件而卡顿

2.4 确认账户

用户点击邮件中的链接后,将访问前端 http://localhost:8080/#/unconfirmed/?token=xxx。修改 Unconfirmed.vue,如果有 token 查询参数,则调用 onConfirm() 函数:

<script>
export default {
  ...
  methods: {
    ...
    onConfirm (token) {
      this.$axios.get(`/api/confirm/${token}`)
        .then((response) => {
          // handle success
          this.$toasted.success(response.data.message, { icon: 'fingerprint' })
          // 更新 JWT
          window.localStorage.setItem('madblog-token', response.data.token)
          store.loginAction()
          // 路由跳转
          this.$router.push('/')
        })
        .catch((error) => {
          // handle error
          console.log(error.response.data)
          this.$toasted.error(error.response.data.message, { icon: 'fingerprint' })
        })
    }
  },
  created () {
    // 点击邮件中的链接后,确认账户
    if (this.$route.query.token) {
      this.onConfirm(this.$route.query.token)
    }

    // 未确认的用户,显示提示信息
    const user_id = this.sharedState.user_id
    this.getUser(user_id)
  }
}
</script>

后端增加 /api/confirm/<token> API:

@bp.route('/confirm/<token>', methods=['GET'])
@token_auth.login_required
def confirm(token):
    '''用户收到验证邮件后,验证其账户'''
    if g.current_user.confirmed:
        return bad_request('You have already confirmed your account.')
    if g.current_user.verify_confirm_jwt(token):
        g.current_user.ping()
        db.session.commit()
        # 给用户发放新 JWT,因为要包含 confirmed: true
        token = g.current_user.get_jwt()
        return jsonify({
            'status': 'success',
            'message': 'You have confirmed your account. Thanks!',
            'token': token
        })
    else:
        return bad_request('The confirmation link is invalid or has expired.')

3. 重置密码

继续为忘记了密码的用户添加密码重置功能,当用户请求重置密码时,应用将发送包含特制链接的电子邮件,用户然后需要点击该链接才能访问设置新密码的表单

修改 back-end/app/models.py

class User(PaginatedAPIMixin, db.Model):
    ...
    def generate_reset_password_jwt(self, expires_in=3600):
        '''生成重置账户密码的 JWT'''
        now = datetime.utcnow()
        payload = {
            'reset_password': self.id,
            'exp': now + timedelta(seconds=expires_in),
            'iat': now
        }
        return jwt.encode(
            payload,
            current_app.config['SECRET_KEY'],
            algorithm='HS256').decode('utf-8')

    @staticmethod
    def verify_reset_password_jwt(token):
        '''用户点击重置密码邮件中的URL后,需要检验 JWT
        如果检验通过,则返回 JWT 中存储的 id 所对应的用户实例'''
        try:
            payload = jwt.decode(
                token,
                current_app.config['SECRET_KEY'],
                algorithms=['HS256'])
        except (jwt.exceptions.ExpiredSignatureError,
                jwt.exceptions.InvalidSignatureError,
                jwt.exceptions.DecodeError) as e:
            # Token过期,或被人修改,那么签名验证也会失败
            return None
        return User.query.get(payload.get('reset_password'))

用户点击 login 登录页面中的 Forgot Your Password? Click to Reset It 时,前端会跳转到 http://localhost:8080/#/reset-password-request

3 重置密码请求的表单

需要填写重置密码的用户对应的注册邮箱地址,此时会请求后端的 reset_password_request(),给该邮箱发送重置密码的邮件。你可能会注意到,即使用户提供的电子邮件不存在,前端也会有已发送重置密码邮件的消息,这样的话,客户端就不能用这个表单来判断一个给定的用户是否已注册

修改 back-end/app/api/users.py

@bp.route('/reset-password-request', methods=['POST'])
def reset_password_request():
    '''请求重置账户密码,需要提供注册时填写的邮箱地址'''
    data = request.get_json()
    if not data:
        return bad_request('You must post JSON data.')

    message = {}
    if 'confirm_email_base_url' not in data or not data.get('confirm_email_base_url').strip():
        message['confirm_email_base_url'] = 'Please provide a valid confirm email base url.'
    pattern = '^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
    if 'email' not in data or not re.match(pattern, data.get('email', None)):
        message['email'] = 'Please provide a valid email address.'
    if message:
        return bad_request(message)

    user = User.query.filter_by(email=data.get('email')).first()
    if user:  # 如果提供的邮箱地址对应的用户实例对象存在,就发邮件
        token = user.generate_reset_password_jwt()

        text_body = '''
        Dear {0},
        To reset your password click on the following link: {1}
        If you have not requested a password reset simply ignore this message.
        Sincerely,
        The Madblog Team
        Note: replies to this email address are not monitored.
        '''.format(user.username, data.get('confirm_email_base_url') + token)

        html_body = '''
        <p>Dear {0},</p>
        <p>To reset your password <a href="{1}">click here</a>.</p>
        <p>Alternatively, you can paste the following link in your browser's address bar:</p>
        <p><b>{1}</b></p>
        <p>If you have not requested a password reset simply ignore this message.</p>
        <p>Sincerely,</p>
        <p>The Madblog Team</p>
        <p><small>Note: replies to this email address are not monitored.</small></p>
        '''.format(user.username, data.get('confirm_email_base_url') + token)

        send_email('[Madblog] Reset Your Password',
                   sender=current_app.config['MAIL_SENDER'],
                   recipients=[user.email],
                   text_body=text_body,
                   html_body=html_body)
    # 不管前端提供的邮箱地址有没有对应的用户实例(不排除有人想恶意重置别人的账户),都给他回应
    return jsonify({
        'status': 'success',
        'message': 'An email with instructions to reset your password has been sent to you.'
    })

用户收到邮件后,点击链接,前端跳转到 http://localhost:8080/#/reset-password/?token=xxx,用户输入新密码,提交到后端即可重置密码

@bp.route('/reset-password/<token>', methods=['POST'])
def reset_password(token):
    '''用户点击邮件中的链接,通过验证 JWT 来重置对应的账户的密码'''
    data = request.get_json()
    if not data:
        return bad_request('You must post JSON data.')
    if 'password' not in data or not data.get('password', None).strip():
        return bad_request('Please provide a valid password.')
    user = User.verify_reset_password_jwt(token)
    if not user:
        return bad_request('The reset password link is invalid or has expired.')
    user.set_password(data.get('password'))
    db.session.commit()
    return jsonify({
        'status': 'success',
        'message': 'Your password has been reset.'
    })

4. 提交代码

$ git add .
$ git commit -m "14. 邮件支持"
$ git checkout master
$ git merge dev
$ git branch -d dev

将本地 master 分支代码上传到 Github 代码仓库中的 master 分支:

$ git push -u origin master

打上标签 tag并上传:

$ git tag v0.14
$ git push origin v0.14

代码已上传到 https://github.com/wangy8961/flask-vuejs-madblog/tree/v0.14 ,欢迎star

未经允许不得转载: LIFE & SHARE - 王颜公子 » Flask Vue.js全栈开发|第14章:邮件支持

分享

作者

作者头像

Madman

如果博文内容有误或其它任何问题,欢迎留言评论,我会尽快回复; 或者通过QQ、微信等联系我

0 条评论

暂时还没有评论.

发表评论前请先登录