「干貨」帶你認(rèn)識(shí) flask 郵件發(fā)送
原創(chuàng): 志學(xué)Python 志學(xué)Python
01 Flask-Mail 簡(jiǎn)介
就實(shí)際的郵件發(fā)送而言,F(xiàn)lask有一個(gè)名為Flask-Mail的流行插件,可以使任務(wù)變得非常簡(jiǎn)單。和往常一樣,該插件是用pip安裝的:
(venv) $ pip install flask-mail
密碼重置鏈接將包含有一個(gè)安全令牌。為了生成這些令牌,我將使用JSON Web Tokens,它也有一個(gè)流行的Python包:
(venv) $ pip install pyjwt
Flask-Mail插件是通過(guò)app.config對(duì)象來(lái)配置的。還記得在第七章中,我添加了用于在生產(chǎn)環(huán)境中發(fā)生錯(cuò)誤時(shí)發(fā)送電子郵件的配置項(xiàng)? 當(dāng)時(shí)我沒(méi)有告訴你,不過(guò),我選擇的配置變量都是Flask-Mail的需求的,所以不需要任何額外的工作,配置的活已經(jīng)完工。
像大多數(shù)Flask插件一樣,你需要在Flask應(yīng)用創(chuàng)建之后創(chuàng)建一個(gè)郵件實(shí)例。本處,mail是類(lèi)Mail的一個(gè)實(shí)例:
# ...from flask_mail import Mail
app = Flask(__name__)# ...mail = Mail(app)
第七章中我提到過(guò),測(cè)試發(fā)送電子郵件的方式有兩種。如果你想使用一個(gè)模擬的電子郵件服務(wù)器,Python提供了一個(gè)非常好用的方法,你可以使用下面的命令在第二個(gè)終端中啟動(dòng)它:
(venv) $ python -m smtpd -n -c DebuggingServer localhost:8025
要配置此服務(wù)器,需要設(shè)置兩個(gè)環(huán)境變量:
(venv) $ export MAIL_SERVER=localhost(venv) $ export MAIL_PORT=8025
如果你希望真實(shí)地發(fā)送電子郵件,則需要使用真實(shí)的電子郵件服務(wù)器。那么你只需要為它設(shè)置MAIL_SERVER、MAIL_PORT、MAIL_USE_TLS、MAIL_USERNAME和MAIL_PASSWORD環(huán)境變量。如果你想要快速解決方案,可以使用Gmail帳戶(hù)發(fā)送電子郵件,并使用以下設(shè)置:
(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>
如果你使用的是Microsoft Windows,則需要在上面的每個(gè)export語(yǔ)句中將export替換為set。
Gmail帳戶(hù)中的安全功能可能會(huì)阻止應(yīng)用通過(guò)它發(fā)送電子郵件,除非你明確允許“安全性較低的應(yīng)用程序”訪問(wèn)你的Gmail帳戶(hù)??梢蚤喿x此處來(lái)了解具體情況,如果你擔(dān)心帳戶(hù)的安全性,可以創(chuàng)建一個(gè)輔助郵箱帳戶(hù),配置它來(lái)僅用于測(cè)試電子郵件功能,或者你可以暫時(shí)啟用允許不太安全的應(yīng)用程序來(lái)運(yùn)行此測(cè)試,完成后恢復(fù)為默認(rèn)值。
02 Flask-Mail 使用
為了學(xué)習(xí)Flask-Mail如何工作,我將向你展示如何用Python shell發(fā)送電子郵件。那么,運(yùn)行flask shell以激活Python,然后運(yùn)行下面的命令:
>>> 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)
上面的代碼片段將發(fā)送一個(gè)電子郵件到你在recipients參數(shù)中設(shè)置的電子郵件地址列表。發(fā)件人配置項(xiàng)我在第七章中已經(jīng)配置過(guò)了,是ADMINS。該電子郵件將具有純文本和HTML版本,所以根據(jù)你的電子郵件客戶(hù)端的配置,可能會(huì)看到它們之中的其中之一。
如你所見(jiàn),相當(dāng)簡(jiǎn)單。現(xiàn)在讓我們將電子郵件整合到應(yīng)用中。
03 簡(jiǎn)單的電子郵件框架
我將從編寫(xiě)一個(gè)發(fā)送電子郵件的幫助函數(shù)開(kāi)始,這個(gè)函數(shù)基本上是上一節(jié)中shell函數(shù)的通用版本。我將把這個(gè)函數(shù)放在一個(gè)名為app/email.py的新模塊中:
from flask_mail import Messagefrom 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支持一些我不在這里使用的功能,如抄送和密件抄送列表。如果你對(duì)這些選項(xiàng)感興趣,務(wù)必查閱Flask-Mail文檔。
04 請(qǐng)求重置密碼
我上面提到過(guò),用戶(hù)有權(quán)利重置密碼。因此我將在登錄頁(yè)面提供一個(gè)鏈接:
<p> Forgot Your Password? <a href="{{ url_for('reset_password_request') }}">Click to Reset It</a></p>
當(dāng)用戶(hù)點(diǎn)擊鏈接時(shí),會(huì)出現(xiàn)一個(gè)新的Web表單,要求用戶(hù)輸入注冊(cè)的電子郵件地址,以啟動(dòng)密碼重置過(guò)程。這里是表單類(lèi):
class ResetPasswordRequestForm(FlaskForm): email = StringField('Email', validators=[DataRequired(), Email()]) submit = SubmitField('Request Password Reset')
當(dāng)然也需要一個(gè)視圖函數(shù)來(lái)處理表單:
from app.forms import ResetPasswordRequestFormfrom 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)
該視圖函數(shù)與其他的表單處理視圖函數(shù)非常相似。我從確保用戶(hù)沒(méi)有登錄開(kāi)始,如果用戶(hù)登錄,那么使用密碼重置功能就沒(méi)有意義,所以我重定向到主頁(yè)。
當(dāng)表格被提交并驗(yàn)證通過(guò),我使用表格中的用戶(hù)提供的電子郵件來(lái)查找用戶(hù)。如果我找到用戶(hù),就發(fā)送一封密碼重置電子郵件。我執(zhí)行此操作使用的send_password_reset_email()輔助函數(shù),將在下面向你展示。
電子郵件發(fā)送后,我會(huì)閃現(xiàn)一條消息,指示用戶(hù)查看電子郵件以獲取進(jìn)一步說(shuō)明,然后重定向回登錄頁(yè)面。你可能會(huì)注意到,即使用戶(hù)提供的電子郵件不存在,也會(huì)顯示閃現(xiàn)的消息,這樣的話,客戶(hù)端就不能用這個(gè)表單來(lái)判斷一個(gè)給定的用戶(hù)是否已注冊(cè)。
05 請(qǐng)求重置密碼
在實(shí)現(xiàn)send_password_reset_email()函數(shù)之前,我需要一種方法來(lái)生成密碼重置鏈接,它將被通過(guò)電子郵件發(fā)送給用戶(hù)。當(dāng)鏈接被點(diǎn)擊時(shí),將為用戶(hù)展現(xiàn)設(shè)置新密碼的頁(yè)面。這個(gè)計(jì)劃中棘手的部分是確保只有有效的重置鏈接可以用來(lái)重置帳戶(hù)的密碼。
生成的鏈接中會(huì)包含令牌,它將在允許密碼變更之前被驗(yàn)證,以證明請(qǐng)求重置密碼的用戶(hù)是通過(guò)訪問(wèn)重置密碼郵件中的鏈接而來(lái)的。JSON Web Token(JWT)是這類(lèi)令牌處理的流行標(biāo)準(zhǔn)。JWTs的優(yōu)點(diǎn)是它是自成一體的,不但可以生成令牌,還提供對(duì)應(yīng)的驗(yàn)證方法。
如何運(yùn)行JWTs?讓我們通過(guò)Python shell來(lái)學(xué)習(xí)一下:
>>> import jwt>>> token = jwt.encode({'a': 'b'}, 'my-secret', algorithm='HS256')>>> tokenb'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhIjoiYiJ9.dvOo58OBDHiuSHD4uW88nfJikhYAXc_sfUHq1mDi4G0'>>> jwt.decode(token, 'my-secret', algorithms=['HS256']){'a': 'b'}
{'a':'b'}字典是要寫(xiě)入令牌的示例有效載荷。為了使令牌安全,需要提供一個(gè)秘密密鑰用于創(chuàng)建加密簽名。在這個(gè)例子中,我使用了字符串'my-secret',但是在應(yīng)用中,我將使用配置中的SECRET_KEY。algorithm參數(shù)指定使用什么算法來(lái)生成令牌,而HS256是應(yīng)用最廣泛的算法。
如你所見(jiàn),得到的令牌是一長(zhǎng)串字符。但是不要認(rèn)為這是一個(gè)加密的令牌。令牌的內(nèi)容,包括有效載荷,可以被任何人輕易解碼(不相信我?復(fù)制上面的令牌,然后粘貼在JWT調(diào)試器上就可以看到它的內(nèi)容)。使令牌安全的是,有效載荷是被簽名的。如果有人試圖偽造或篡改令牌中的有效載荷,則簽名將會(huì)無(wú)效,并且生成新的簽名依賴(lài)秘密密鑰。令牌驗(yàn)證通過(guò)時(shí),有效負(fù)載的內(nèi)容將被解碼并返回給調(diào)用者。如果令牌的簽名驗(yàn)證通過(guò),有效載荷才可以被認(rèn)為是可信的。
我要用于密碼重置令牌的有效載荷格式為{'reset_password':user_id,'exp':token_expiration}。 exp字段是JWTs的標(biāo)準(zhǔn),如果它存在,則表示令牌的到期時(shí)間。如果一個(gè)令牌有一個(gè)有效的簽名,但是它已經(jīng)過(guò)期,那么它也將被認(rèn)為是無(wú)效的。對(duì)于密碼重置功能,我會(huì)給這些令牌10分鐘的有效期。
當(dāng)用戶(hù)點(diǎn)擊電子郵件鏈接時(shí),令牌將被作為URL的一部分發(fā)送回應(yīng)用,處理這個(gè)URL的視圖函數(shù)首先要做的就是驗(yàn)證它。如果簽名是有效的,則可以通過(guò)存儲(chǔ)在有效載荷中的ID來(lái)識(shí)別用戶(hù)。一旦得知用戶(hù)的身份,應(yīng)用可以要求一個(gè)新的密碼,并將其設(shè)置在用戶(hù)的帳戶(hù)上。
由于這些令牌屬于用戶(hù),因此我將在User模型中編寫(xiě)令牌生成和驗(yàn)證的方法:
from time import timeimport jwtfrom 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()函數(shù)以字符串形式生成一個(gè)JWT令牌。請(qǐng)注意,decode('utf-8')是必須的,因?yàn)閖wt.encode()函數(shù)將令牌作為字節(jié)序列返回,但是在應(yīng)用中將令牌表示為字符串更方便。
verify_reset_password_token()是一個(gè)靜態(tài)方法,這意味著它可以直接從類(lèi)中調(diào)用。靜態(tài)方法與類(lèi)方法類(lèi)似,唯一的區(qū)別是靜態(tài)方法不會(huì)接收類(lèi)作為第一個(gè)參數(shù)。這個(gè)方法需要一個(gè)令牌,并嘗試通過(guò)調(diào)用PyJWT的jwt.decode()函數(shù)來(lái)解碼它。如果令牌不能被驗(yàn)證或已過(guò)期,將會(huì)引發(fā)異常,在這種情況下,我會(huì)捕獲它以防止出現(xiàn)錯(cuò)誤,然后將None返回給調(diào)用者。如果令牌有效,那么來(lái)自令牌有效負(fù)載的reset_password的值就是用戶(hù)的ID,所以我可以加載用戶(hù)并返回它。
06 發(fā)送密碼重置郵件
現(xiàn)在我有了令牌,可以生成密碼重置電子郵件。 send_password_reset_email()函數(shù)依賴(lài)于上面寫(xiě)的send_email()函數(shù)。
from flask import render_templatefrom 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))
這個(gè)函數(shù)中有趣的部分是電子郵件的文本和HTML內(nèi)容是使用熟悉的render_template()函數(shù)從模板生成的。模板接收用戶(hù)和令牌作為參數(shù),以便可以生成個(gè)性化的電子郵件消息。以下是重置密碼電子郵件的文本模板:
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版本:
<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>
請(qǐng)注意,這兩個(gè)電子郵件模板中的url_for()調(diào)用中引用的reset_password路由尚不存在,這將在下一節(jié)中添加。在這兩個(gè)模板中,url_for()函數(shù)中的_external=True參數(shù)是一個(gè)新玩意兒。不帶這個(gè)參數(shù)的情況下,url_for()函數(shù)生成的是相對(duì)路徑。例如url_for('user', username='susan')生成/user/susan。這樣的路徑在本站的Web頁(yè)面中使用是完全足夠的,因?yàn)槠溆嗟膮f(xié)議、主機(jī)、端口部分,會(huì)沿用本站的當(dāng)前值。一旦通過(guò)郵件發(fā)送時(shí),就脫離了這個(gè)上下文,這時(shí)候就需要URL的完全路徑了。一旦傳入_external=True參數(shù)給url_for()函數(shù),就會(huì)生成一個(gè)URL的完全路徑。本處示例為http://localhost:5000/user/susan。如果應(yīng)用被部署到一個(gè)域名下,則協(xié)議、主機(jī)名和端口會(huì)發(fā)生對(duì)應(yīng)的變化。
07 重置用戶(hù)密碼
當(dāng)用戶(hù)點(diǎn)擊電子郵件鏈接時(shí),會(huì)觸發(fā)與此功能相關(guān)的第二個(gè)路由。這是密碼重置視圖函數(shù):
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)
在這個(gè)視圖函數(shù)中,我首先確保用戶(hù)沒(méi)有登錄,然后通過(guò)調(diào)用User類(lèi)的令牌驗(yàn)證方法來(lái)確定用戶(hù)是誰(shuí)。如果令牌有效,則此方法返回用戶(hù);如果不是,則返回None,并將重定向到主頁(yè)。
如果令牌是有效的,那么我向用戶(hù)呈現(xiàn)第二個(gè)表單,需要用戶(hù)其中輸入新密碼。這個(gè)表單的處理方式與以前的表單類(lèi)似,表單提交驗(yàn)證通過(guò)后,我調(diào)用User類(lèi)的set_password()方法來(lái)更改密碼,然后重定向到登錄頁(yè)面,以便用戶(hù)登錄。
這是ResetPasswordForm類(lèi):
class ResetPasswordForm(FlaskForm): password = PasswordField('Password', validators=[DataRequired()]) password2 = PasswordField( 'Repeat Password', validators=[DataRequired(), EqualTo('password')]) submit = SubmitField('Request Password Reset')
這是相應(yīng)的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 %}
密碼重置功能現(xiàn)已完成,一定要多嘗試幾次。
08異步電子郵件
如果你正在使用Python提供的模擬電子郵件服務(wù)器,可能沒(méi)有注意到這一點(diǎn),那就是發(fā)送電子郵件會(huì)大大減慢應(yīng)用的速度,原因是發(fā)送電子郵件時(shí)所發(fā)生的和電子郵件服務(wù)器的網(wǎng)絡(luò)交互。通常需要幾秒鐘的時(shí)間才能收到電子郵件,如果收件人的電子郵件服務(wù)器速度較慢,或者收件人有多個(gè),則可能會(huì)更久。
我真正想要的send_email()函數(shù)是異步的。那是什么意思?這意味著當(dāng)這個(gè)函數(shù)被調(diào)用時(shí),發(fā)送郵件的任務(wù)被安排在后臺(tái)進(jìn)行,釋放send_email()函數(shù)以立即返回,以便應(yīng)用可以在發(fā)送郵件的同時(shí)繼續(xù)運(yùn)行。
Python實(shí)際上有多種方式支持運(yùn)行異步任務(wù),threading和multiprocessing模塊都可以做到這一點(diǎn)。為發(fā)送電子郵件啟動(dòng)一個(gè)后臺(tái)線程,比開(kāi)始一個(gè)全新的進(jìn)程需要的資源少得多,所以我打算采用這種方法:
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函數(shù)現(xiàn)在運(yùn)行在后臺(tái)線程中,它通過(guò)send_email()的最后一行中的Thread()類(lèi)來(lái)調(diào)用。 有了這個(gè)改變,電子郵件的發(fā)送將在線程中運(yùn)行,并且當(dāng)進(jìn)程完成時(shí),線程將結(jié)束并自行清理。 如果你已經(jīng)配置了一個(gè)真正的電子郵件服務(wù)器,當(dāng)你按下密碼重置請(qǐng)求表單上的提交按鈕時(shí),肯定會(huì)注意到訪問(wèn)速度的提升。
你可能預(yù)期只有msg參數(shù)會(huì)被發(fā)送到線程,但正如你在代碼中所看到的那樣,我也傳入了應(yīng)用實(shí)例。 使用線程時(shí),需要牢記Flask的一個(gè)重要設(shè)計(jì)方面。 Flask使用上下文來(lái)避免必須跨函數(shù)傳遞參數(shù)。 我不打算詳細(xì)討論這個(gè)問(wèn)題,但是需要知道的是,有兩種類(lèi)型的上下文,即應(yīng)用上下文和請(qǐng)求上下文。 在大多數(shù)情況下,這些上下文由框架自動(dòng)管理,但是當(dāng)應(yīng)用啟動(dòng)自定義線程時(shí),可能需要手動(dòng)創(chuàng)建這些線程的上下文。
許多Flask插件需要應(yīng)用上下文才能工作,因?yàn)檫@使得他們可以在不傳遞參數(shù)的情況下找到Flask應(yīng)用實(shí)例。這些插件需要知道應(yīng)用實(shí)例的原因是因?yàn)樗鼈兊呐渲么鎯?chǔ)在app.config對(duì)象中,這正是Flask-Mail的情況。mail.send()方法需要訪問(wèn)電子郵件服務(wù)器的配置值,而這必須通過(guò)訪問(wèn)應(yīng)用屬性的方式來(lái)實(shí)現(xiàn)。 使用with app.app_context()調(diào)用創(chuàng)建的應(yīng)用上下文使得應(yīng)用實(shí)例可以通過(guò)來(lái)自Flask的current_app變量來(lái)進(jìn)行訪問(wèn)。
最后,我自己是一名從事了多年開(kāi)發(fā)的Python老程序員,辭職目前在做自己的Python私人定制課程,今年年初我花了一個(gè)月整理了一份最適合2019年學(xué)習(xí)的Python學(xué)習(xí)干貨,可以送給每一位喜歡Python的小伙伴,想要獲取的可以關(guān)注我的頭條號(hào)并在后臺(tái)私信我:01,即可免費(fèi)獲取。