1. 程式人生 > >Flask Mega-Tutorial 中文教程 V2.0 第10章:電子郵件支援

Flask Mega-Tutorial 中文教程 V2.0 第10章:電子郵件支援

最近在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=['[email protected]'])
>>> 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變數訪問。