pythonflaskpython-multithreadingpython-3.10flask-mail

Email component in flask application is not able to access the app instance in an application factory pattern


I am practising flask. I have made a simple flask app which sends an email to the admin saying "New User has joined." once someone enters their name and submit on the form.

This is email component:

from flask import current_app, render_template
from flask_mail import Message
from threading import Thread
from app import mail


# Send aync email functions
def send_aysnc_email(app, msg):
    with app.app_context():
        mail.send(msg)


def send_mail(to, subject, template, **kwargs):
    app = current_app
    msg = Message(
        app.config["FLASKY_MAIL_SUBJECT_PREFIX"] + subject,
        sender=app.config["FLASKY_MAIL_SENDER"],
        recipients=[to],
    )

    msg.body = render_template(template + ".txt", **kwargs)
    msg.html = render_template(template + ".html", **kwargs)
    thr = Thread(target=send_aysnc_email, args=[app, msg])
    thr.start()
    return thr

When I run the app, and enter the name on the form and press submit, email doesn't get sent.

UI of my app

And I get an error on the log saying

Exception in thread Thread-3 (send_aysnc_email):
Traceback (most recent call last):
  File "/usr/lib/python3.10/threading.py", line 1016, in _bootstrap_inner
127.0.0.1 - - [02/Sep/2023 14:39:29] "POST / HTTP/1.1" 302 -
    self.run()
  File "/usr/lib/python3.10/threading.py", line 953, in run
    self._target(*self._args, **self._kwargs)
  File "/home/cadbay53/Desktop/practice/flask/app/emails.py", line 9, in send_aysnc_email
    with app.app_context():
  File "/home/cadbay53/Desktop/practice/flask/venv/lib/python3.10/site-packages/werkzeug/local.py", line 311, in __get__
    obj = instance._get_current_object()
  File "/home/cadbay53/Desktop/practice/flask/venv/lib/python3.10/site-packages/werkzeug/local.py", line 508, in _get_current_object
    raise RuntimeError(unbound_message) from None
RuntimeError: Working outside of application context.

This typically means that you attempted to use functionality that needed
the current application. To solve this, set up an application context
with app.app_context(). See the documentation for more information.

I have found a work around this but I don't understand why is this current code not working.

This is my application factory:

from flask import Flask, render_template
from flask_bootstrap import Bootstrap
from flask_mail import Mail
from flask_moment import Moment
from flask_sqlalchemy import SQLAlchemy
from config import config

bootstrap = Bootstrap()
mail = Mail()
moment = Moment()
db = SQLAlchemy()


def create_app(config_name):
    app = Flask(__name__)
    app.config.from_object(config[config_name])
    config[config_name].init_app(app)

    bootstrap.init_app(app)
    mail.init_app(app)
    moment.init_app(app)
    db.init_app(app)

    from .main import main as main_blueprint

    app.register_blueprint(main_blueprint)

    return app

and this is my file creating app instance:

import os
from app import create_app
from app.models import User, Role
from flask_migrate import Migrate
from app import db

app = create_app("default")
migrate = Migrate(app, db)


@app.shell_context_processor
def make_shell_context():
    return dict(db=db, User=User, Role=Role)


@app.cli.command()
def test():
    """Run unit tests."""
    import unittest

    tests = unittest.TestLoader().discover("tests")
    unittest.TextTestRunner(verbosity=2).run(tests)

and these are the configurations:

import os

basedir = os.path.abspath(os.path.dirname(__file__))


class Config:
    SECRET_KEY = "***"
    MAIL_SERVER = "smtp.gmail.com"
    MAIL_PORT = 587
    MAIL_USE_TLS = True
    MAIL_USERNAME = "***@***.com"
    MAIL_PASSWORD = "****"
    FLASKY_MAIL_SUBJECT_PREFIX = "[Flasky]"
    FLASKY_MAIL_SENDER = "Flasky Admin <***@***.com>"
    FLASKY_ADMIN = "***@***.com"
    SQLALCHEMY_TRACK_MODIFICATIONS = False

    @staticmethod
    def init_app(app):
        pass


class DevelopmentConfig(Config):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(basedir, "data-dev.sqlite")


class TestConfig(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = "sqlite:///"


class ProductionConfig(Config):
    SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(basedir, "data.sqlite")


config = {
    "development": DevelopmentConfig,
    "testing": TestConfig,
    "production": ProductionConfig,
    "default": DevelopmentConfig,
}

This is my work around:

# Send aync email functions
def send_aysnc_email(app, msg):
    with app.app_context():
        mail.send(msg)


def send_mail(to, subject, template, **kwargs):
    app = current_app._get_current_object()
    msg = Message(
        app.config["FLASKY_MAIL_SUBJECT_PREFIX"] + subject,
        sender=app.config["FLASKY_MAIL_SENDER"],
        recipients=[to],
    )

    msg.body = render_template(template + ".txt", **kwargs)
    msg.html = render_template(template + ".html", **kwargs)
    thr = Thread(target=send_aysnc_email, args=[app, msg])
    thr.start()
    return thr

I don't understand why app.app_context() cannot access the current app instance when provided the current app as argument and works well with current_app._get_current_object().


Solution

  • Why cannot app.app_context() access the current app instance when provided the current app as an argument?

    app_context() actually returns a proxy-object, which should not be passed to another thread (docs):

    Some of the objects provided by Flask are proxies to other objects. The proxies are accessed in the same way for each worker thread, but point to the unique object bound to each worker [...]

    Most of the time you don’t have to care about that, but there are some exceptions where it is good to know that this object is actually a proxy: [...] The reference to the proxied object is needed in some situations, such as sending Signals or passing data to a background thread.

    That's why you have to use app._get_current_object(). Since conventionally it's a private method, it could be a good idea to suppress linter's warnings with # noqa:

    
    def send_mail(to, subject, template, **kwargs):
    
        app = current_app._get_current_object()  # noqa: there is no workaround
        ...