[Перевод] Мега-Учебник Flask Глава 10: Поддержка электронной почты (издание 2024)

3248e15ec28e8ea280742e4ba6b58fcc.jpg

Это десятая часть серии мега-учебника Flask, в которой я собираюсь рассказать вам, как ваше приложение может отправлять электронные письма вашим пользователям и как встроить функцию восстановления пароля благодаря поддержки электронной почты.

Оглавление

Сейчас приложение довольно хорошо справляется с работой с базой данных, поэтому в этой главе я хочу отойти от этой темы и добавить еще одну важную деталь, которая нужна большинству веб-приложений, а именно отправку электронных писем.

Зачем приложению нужно отправлять электронные письма своим пользователям?  Причин много, но одна из самых распространенных — решить проблемы, связанные с аутентификацией. В этой главе я собираюсь добавить функцию сброса пароля для пользователей, которые забывают свой пароль. Когда пользователь запрашивает сброс пароля, приложение отправляет электронное письмо со специально созданной ссылкой. Затем пользователю нужно будет щелкнуть по этой ссылке, чтобы получить доступ к форме для установки нового пароля.

Ссылки на GitHub для этой главы:  Browse,  Zip,  Diff.

Введение в Flask-Mail

Что касается фактической отправки электронных писем, у Flask есть популярное расширение под названием Flask-Mail. Как всегда, это расширение устанавливается с помощью pip:

(venv) $ pip install flask-mail

Ссылки для сброса пароля будут содержать безопасный токен. Для генерации этих токенов я собираюсь использовать веб-токены JSON, которые также имеют популярный пакет Python:

(venv) $ pip install pyjwt

Расширение Flask-Mail настраивается из объекта app.config. Помните, в главе 7 я добавил конфигурацию электронной почты для самостоятельной отправки электронного письма при возникновении ошибки в рабочей среде?  Я не говорил вам этого тогда, но мой выбор переменных конфигурации был смоделирован с учетом требований Flask-Mail, так что на самом деле никакой дополнительной работы не требуется, переменные конфигурации уже есть в приложении.

Как и большинство расширений Flask, вам необходимо создать экземпляр сразу после создания приложения Flask. В данном случае это объект класса Mail:

app/__init__.py: Экземпляр Flask-Mail.

# ...
from flask_mail import Mail

app = Flask(__name__)
# ...
mail = Mail(app)

Если вы планируете протестировать отправку электронных писем, у вас есть те же опции, о которых я упоминал в главе 7. Если вы хотите использовать эмулируемый сервер электронной почты, то вы можете запустить тот же сервер отладки SMTP, который использовался ранее во втором терминале, с помощью следующей команды:

(venv) $ aiosmtpd -n -c aiosmtpd.handlers.Debugging -l localhost:8025

Чтобы настроить приложение для использования этого сервера, вам нужно будет установить две переменные окружения:

(venv) $ export MAIL_SERVER=localhost
(venv) $ export MAIL_PORT=8025

Если вы предпочитаете, чтобы электронные письма отправлялись по-настоящему, вам необходимо использовать настоящий почтовый сервер. Если он у вас есть, то вам просто нужно установить для него переменные окружения MAIL_SERVER, MAIL_PORT, MAIL_USE_TLS, MAIL_USERNAME и MAIL_PASSWORD. Если вам нужно быстрое решение, вы можете использовать учетную запись Gmail для отправки электронной почты со следующими настройками:

(venv) $ export MAIL_SERVER=smtp.googlemail.com
(venv) $ export MAIL_PORT=587
(venv) $ export MAIL_USE_TLS=1
(venv) $ export MAIL_USERNAME=
(venv) $ export MAIL_PASSWORD=

Если вы используете Microsoft Windows, вам необходимо заменить export на set в каждой из приведенных выше инструкций export.

К сожалению, функции безопасности в вашей учетной записи Gmail могут препятствовать отправке электронных писем приложением через нее. Некоторые учетные записи позволяют это, когда вы явно разрешаете «менее безопасным приложениям» доступ к вашей учетной записи Gmail, но это не всегда доступно. Вы можете прочитать об этом здесь.

Примечание от переводчика

Бывают случаи, когда иностранные сервисы заблокированы, поэтому в качестве примера предлагаю сервис Яндекс Почта. В справке подробно описана инструкция по настройке, пункт «Настроить только отправку по протоколу SMTP»

Конфигурация при использовании сервиса Яндекс Почта:

export MAIL_SERVER=smtp.yandex.ru
export MAIL_PORT=587
export MAIL_USE_TLS=1
export MAIL_USERNAME=
export MAIL_PASSWORD=

Если вы хотите использовать настоящий почтовый сервер, но не хотите усложнять настройку Gmail,  SendGrid — хороший вариант, который отправляет вам 100 электронных писем в день с использованием бесплатной учетной записи.

Использование Flask-Mail

Чтобы узнать, как работает Flask-Mail, я покажу вам, как отправить электронное письмо из сеанса оболочки Python. Запустите Python с помощью flask shell, а затем выполните следующие команды:

>>> 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 = '

HTML body

' >>> mail.send(msg)

Приведенный выше фрагмент кода отправит по электронной почте список адресов электронной почты, которые вы ввели в аргумент recipients. Я указываю отправителя в качестве первого настроенного администратора (я добавил переменную конфигурации ADMINS в главе 7). Электронное письмо будет иметь обычную текстовую и HTML-версии, поэтому в зависимости от того, как настроен ваш почтовый клиент, вы можете увидеть ту или иную.

Теперь давайте интегрируем электронную почту в приложение.

Простая структура электронной почты

Я начну с написания вспомогательной функции, отправляющей электронное письмо, которая в основном представляет собой общую версию упражнения с оболочкой из предыдущего раздела. Я добавлю эту функцию в новый модуль под названием app/email.py:

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: Ссылка для сброса пароля в форме входа.

    

Forgot Your Password? Click to Reset It

Когда пользователь нажимает на ссылку, появляется новая веб-форма, которая запрашивает адрес электронной почты пользователя для инициирования процесса сброса пароля. Вот класс формы.:

app/forms.py: Форма запроса сброса пароля.

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

Reset Password

{{ form.hidden_tag() }}

{{ form.email.label }}
{{ form.email(size=64) }}
{% for error in form.email.errors %} [{{ error }}] {% endfor %}

{{ form.submit() }}

{% endblock %}

Мне также нужна функция просмотра для обработки этой формы:

app/routes.py: Функция просмотра запроса на сброс пароля.

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 = db.session.scalar(
            sa.select(User).where(User.email == form.email.data))
        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, или JWT. Преимущество JWT в том, что они автономны. Вы можете отправить токен пользователю по электронной почте, и когда пользователь нажимает на ссылку, которая возвращает токен обратно в приложение, он может быть проверен самостоятельно.

Как работают JWT?  Нет ничего лучше, чем быстрый сеанс работы с оболочкой Python, чтобы разобраться в них:

>>> import jwt
>>> token = jwt.encode({'a': 'b'}, 'my-secret', algorithm='HS256')
>>> token
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhIjoiYiJ9.dvOo58OBDHiuSHD4uW88nfJik_sfUHq1mDi4G0'
>>> jwt.decode(token, 'my-secret', algorithms=['HS256'])
{'a': 'b'}

Словарь {'a': 'b'} — это пример полезной нагрузки, которая будет записана в токен. Чтобы сделать токен безопасным, необходимо предоставить секретный ключ, который будет использоваться при создании криптографической подписи. Для этого примера я использовал строку 'my-secret', но в приложении я собираюсь использовать SECRET_KEY из конфигурации Flask. Аргумент algorithm указывает, как должна генерироваться подпись токена. Алгоритм HS256 является наиболее широко используемым.

Как вы можете видеть, результирующий токен представляет собой длинную последовательность символов. Но не думайте, что это зашифрованный токен. Содержимое токена, включая полезную нагрузку, может быть легко расшифровано любым пользователем (не верите мне?  Скопируйте приведенный выше токен, а затем введите его в JWT debugger, чтобы просмотреть его содержимое). Безопасность токена обеспечивает то, что полезная нагрузка подписана. Если кто-то попытается подделать полезную нагрузку в токене или изменить ее, подпись будет признана недействительной, а для генерации новой подписи необходим секретный ключ. Когда токен проверяется, содержимое полезной нагрузки декодируется и возвращается вызывающему абоненту. Если подпись токена была подтверждена, то полезной нагрузке можно доверять как подлинной.

Полезная нагрузка, которую я собираюсь использовать для токенов сброса пароля, будет иметь следующий формат {'reset_password': user_id, 'exp': token_expiration}. Поле exp является стандартным для JWT, и если оно присутствует, оно указывает время истечения срока действия токена. Если токен имеет действительную подпись, но срок его действия истек, он также будет считаться недействительным. Для функции сброса пароля я собираюсь продлить жизнь этим токенам на 10 минут.

Когда пользователь нажимает на отправленную по электронной почте ссылку, токен будет отправлен обратно в приложение как часть URL-адреса, и первое, что сделает функция просмотра, которая обрабатывает этот URL-адрес, — это подтвердит его. Если подпись действительна, то пользователя можно идентифицировать по идентификатору, хранящемуся в полезной нагрузке. Как только личность пользователя известна, приложение может запросить новый пароль и установить его в учетной записи пользователя.

Поскольку эти токены принадлежат пользователям, я собираюсь написать функции генерации токенов и проверки как методы в модели User:

app/models.py: Методы сброса пароля с помощью токена.

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')

    @staticmethod
    def verify_reset_password_token(token):
        try:
            id = jwt.decode(token, app.config['SECRET_KEY'],
                            algorithms=['HS256'])['reset_password']
        except:
            return
        return db.session.get(User, id)

Функция get_reset_password_token() возвращает токен JWT в виде строки, которая генерируется непосредственно функцией jwt.encode().

Функция verify_reset_password_token() это статический метод, что означает, что его можно вызвать непосредственно из класса. Статический метод похож на метод класса, с той лишь разницей, что статические методы не получают класс в качестве первого аргумента. Этот метод принимает токен и пытается декодировать его, вызывая функцию jwt.decode() PyJWT. Если токен не может быть проверен или срок его действия истек, возникает исключение, и в этом случае я перехватываю его, чтобы предотвратить ошибку, а затем возвращаю None вызывающему. Если токен действителен, то значением ключа reset_password из полезной нагрузки токена является идентификатор пользователя, поэтому я могу загрузить пользователя и вернуть его.

Отправка электронного письма для сброса пароля

Функция send_password_reset_email() для генерации электронных писем со сбросом пароля, основана на функции send_email(), которую я написал выше.

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['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(). Шаблоны получают пользователя и токен в качестве аргументов, так что можно сгенерировать персонализированное сообщение электронной почты.

Чтобы отличать шаблоны электронной почты от обычных HTML-шаблонов, давайте создадим подкаталог email внутри templates:

(venv) $ mkdir app/templates/email

Вот текстовый шаблон для электронного письма со сброшенным паролем:

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 для электронного письма для сброса пароля.



    
        

Dear {{ user.username }},

To reset your password click here .

Alternatively, you can paste the following link in your browser's address bar:

{{ url_for('reset_password', token=token, _external=True) }}

If you have not requested a password reset simply ignore this message.

Sincerely,

The Microblog Team

Маршрут reset_password, на который ссылается вызов url_for() в этих двух шаблонах электронной почты, еще не существует, он будет добавлен в следующем разделе. Аргумент _external=True, который я включил в вызовы url_for() в обоих шаблонах, также является новым. URL-адреса, генерируемые url_for() по умолчанию, являются относительными URL-адресами, которые включают только часть URL-адреса. Обычно этого достаточно для ссылок, которые создаются на веб-страницах, потому что веб-браузер дополняет URL-адрес, беря недостающие части из URL-адреса в адресной строке. Однако при отправке URL-адреса по электронной почте этот контекст отсутствует, поэтому необходимо использовать полные URL-адреса. Когда _external=True передается в качестве аргумента, генерируются полные URL-адреса, поэтому будет возвращен предыдущий пример http://localhost:5000/user/susan или соответствующий URL-адрес при развертывании приложения на доменном имени.

Сброс пароля пользователя

Когда пользователь нажимает на ссылку электронной почты, запускается второй маршрут, связанный с этой функцией. Вот функция просмотра запроса пароля.:

app/routes.py: Функция просмотра сброса пароля.

from app.forms import ResetPasswordForm

@app.route('/reset_password/', 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 если нет. Если токен недействителен, я перенаправляю на домашнюю страницу.

Если токен действителен, то я предоставляю пользователю вторую форму, в которой запрашивается новый пароль. Эта форма обрабатывается способом, аналогичным предыдущим формам, и в результате правильной отправки формы я вызываю метод set_password() класса User для изменения пароля, а затем перенаправляю на страницу входа, где пользователь теперь может войти в систему.

Вот класс ResetPasswordForm:

app/forms.py: Форма для сброса пароля.

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

Reset Your Password

{{ form.hidden_tag() }}

{{ form.password.label }}
{{ form.password(size=32) }}
{% for error in form.password.errors %} [{{ error }}] {% endfor %}

{{ form.password2.label }}
{{ form.password2(size=32) }}
{% for error in form.password2.errors %} [{{ error }}] {% endfor %}

{{ form.submit() }}

{% endblock %}

Функция сброса пароля теперь завершена, поэтому обязательно попробуйте ее.

Асинхронные электронные письма

Если вы используете сервер электронной почты для отладки, вы, возможно, этого не заметили, но отправка электронного письма по-настоящему значительно замедляет работу приложения. Все взаимодействия, которые должны происходить при отправке электронного письма, замедляют выполнение задачи, обычно на отправку электронного письма уходит несколько секунд, а может быть, и больше, если почтовый сервер адресата работает медленно или если адресатов несколько.

Чего я действительно хочу, так это чтобы функция 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 теперь выполняется в фоновом потоке, вызываемая через класс Thread в последней строке функции send_email(). С этим изменением отправка электронного письма будет выполняться в потоке, и когда отправка завершится, поток завершится и очистится сам. Если вы настроили реальный почтовый сервер, вы определенно заметите улучшение скорости при нажатии кнопки отправки в форме запроса на сброс пароля.

Вы, вероятно, ожидали, что в поток будет отправлен только аргумент msg, но, как вы можете видеть в коде, я также отправляю экземпляр приложения. При работе с потоками необходимо учитывать важный аспект дизайна Flask. Flask использует контексты, чтобы избежать необходимости передавать аргументы между функциями. Я пока не собираюсь вдаваться в подробности, но знаю, что существует два типа контекстов:  контекст приложения и контекст запроса. В большинстве случаев эти контексты автоматически управляются Flask, но когда приложение запускает пользовательские потоки, контексты для этих потоков, возможно, потребуется создать вручную.

Существует множество расширений, для работы которых требуется наличие контекста приложения, поскольку это позволяет им находить экземпляр приложения Flask, не передавая его в качестве аргумента. Причина, по которой многим расширениям необходимо знать экземпляр приложения, заключается в том, что их конфигурация хранится в объекте app.config. Именно такая ситуация с Flask-Mail. Методу mail.send() необходимо получить доступ к значениям конфигурации почтового сервера, а это можно сделать, только зная, что это за приложение. Контекст приложения, созданный с помощью вызова with app.app_context(), делает экземпляр приложения доступным через переменную current_app из Flask.

Habrahabr.ru прочитано 7673 раза