1. 程式人生 > 實用技巧 >15.flask部落格專案實戰十之傳送郵件

15.flask部落格專案實戰十之傳送郵件

配套視訊教程

本文B站配套視訊教程

本章將學習應用程式如何向用戶傳送電子郵件,以及如何在電子郵件支援下構建密碼修改功能。

目前在資料庫方面做得很好了,所以在本章將脫離該主題並新增大多數Web應用程式需要另一個重要部分,即傳送電子郵件

為何需要向用戶傳送電子郵件?原因很多,一個常見的原因是解決與身份驗證相關的問題。在本章中,將為忘記密碼的使用者新增密碼重置功能。當用戶請求重置密碼時,應用程式將傳送包含特製連結的電子郵件。然後使用者需要單擊該連結以訪問用於設定密新密碼的表單。

Flask-Mail簡介

在傳送電子郵件方面,Flask有一個流行擴充套件,名為Flask-Mail,可讓這個任務變得很簡單。安裝:pip install flask-mail,版本0.9.1
附帶安裝

blinker,版本1.4,它提供一個快速的排程系統,允許任何數量的相關方訂閱事件,或“訊號”。

(venv) D:\microblog>pip 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) D:\microblog>pip 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類物件:

#...
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) D:\microblog>flask shell
[2018-08-20 11:40:14,298] INFO in __init__: Microblog startup
Python 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
App: app [production]
Instance: D:\microblog\instance
>>> from flask_mail import Message
>>> from app import mail
>>> msg = Message('test subject',sender=app.config['ADMINS'][0],recipients=['[email protected]'])
>>> 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會話將很容易理解:

C:\Users\Administrator>d:

D:\>cd d:\microblog\venv\scripts

d:\microblog\venv\Scripts>activate
(venv) d:\microblog\venv\Scripts>cd d:\microblog

(venv) d:\microblog>python
Python 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
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) d:\microblog>

上述{'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:重置密碼令牌方法

#...
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

如果令牌有效,那麼我將向用戶顯示第二個表單,其中會請求新密碼。這個表單的處理方式與之前的表單類似,並且作為有效表單提交的結果,我呼叫Userset_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支援以不止一種方式執行非同步任務。threadingmultiprocessing模組 都可以做到這一點。為傳送電子郵件啟動後臺執行緒 比開始一個全新的流程要少得多,因此我採用如下方法:
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的配置屬性,這裡不是讓填你的郵箱登陸密碼的,而是填寫我們這一步即將獲得的授權碼
進入準備作為發件人的郵箱,點選【設定|客戶端授權密碼】,這裡點選開啟,會要先驗證手機號,然後設定一個新密碼並記住它!

安裝flask-dotenv

pip install flask-dotenv

1)、專案根目錄下新增microblog.env檔案:

MAIL_SERVER=smtp.163.com
MAIL_PORT=25
MAIL_USE_TLS=True
MAIL_USE_SSL=False
[email protected]
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) D:\microblog>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按鈕,

點選Request Password Reset提交按鈕,使用者註冊時的QQ電子郵箱將收到一封電子郵件,示例如下:

點選click here連結,或把連結複製到瀏覽器:

重置密碼成功(使用者 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
    microblog.env
    microblog.py
    tests.py

參考:
https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-x-email-support