Extensions

Mail

Our app can store data and migrate its schema by now. The next thing almost every real application needs is a way to talk to its users. So let's wire up the built-in mailer and send our first message — but first, our project has quietly outgrown a single main.py, and this is the perfect moment to tidy it up.

Giving the app room to grow

Back in the Migrate chapter we turned main.py into an app factory, and prepare_fluid has been wiring every route by hand — app.get("/")(home), app.get("/health")(health) and so on. That was fine for four routes, but it won't scale as we keep bolting on features. So this is the moment to give our routes a proper home — exactly the layout wf create project will lay down for us later on.

HTML pages and JSON endpoints have different jobs, so the framework keeps them apart in two packages: fluid/app for pages, fluid/api for JSON. Each package owns a router, and prepare_fluid simply includes them. Let's start with the page. Our home handler moves into fluid/app:

fluid/app/index.py python
from webfluid.core.context import FluidContext


async def handle_request():
    ctx = FluidContext.current()
    return await ctx.fluid.render(
        "index.html",
        title="Hello World!",
        name="my friend"
    )

Notice the handler no longer reaches for a module-level fluid — in a factory there isn't one when the module is imported. Instead it asks the framework for the current request's app through FluidContext.current(). That is the same request context the Runtime chapter digs into; for now just read it as "the app handling this request". The package's __init__.py then exposes the router that prepare_fluid will include:

fluid/app/__init__.py python
from fastapi import APIRouter
from fastapi.responses import HTMLResponse

app_router = APIRouter(default_response_class=HTMLResponse)

# index.py owns the handler; here we attach it to the router.
from fluid.app.index import handle_request as index
app_router.get("/")(index)

The JSON side has the same shape. Our health check and the two my-model endpoints move into fluid/api, attached to an api_router. The health check is a tiny handler of its own:

fluid/api/health.py python
async def handle_request():
    return {"status": "ok"}

The my-model handlers are unchanged from the SQLAlchemy chapter for now — they land in fluid/api/models.py, which we'll look at again in a second once mail gives them a reason to change. The package __init__.py collects everything onto the router:

fluid/api/__init__.py python
from fastapi import APIRouter

api_router = APIRouter()

from fluid.api.health import handle_request as health
from fluid.api.models import get_model, add_model

api_router.get("/health")(health)
api_router.get("/my-model")(get_model)
api_router.post("/my-model")(add_model)

And main.py? It barely has anything left to do. The factory assembles the app from its parts and includes the two routers:

main.py python
from webfluid import Fluid
from webfluid.core.ext import scheduler
from apscheduler.triggers.interval import IntervalTrigger

from extension.main import MyExtension
from fluid.jobs import heart

my_ext = MyExtension()
scheduler.add_job(heart, IntervalTrigger(seconds=5))


def prepare_fluid() -> Fluid:
    app = Fluid(__name__)

    from fluid.app import app_router
    from fluid.api import api_router
    app.include_router(app_router)
    app.include_router(api_router)

    my_ext.expand_fluid(app)

    return app


if __name__ == "__main__":
    prepare_fluid().mix()

That is the whole point of the factory: main.py wires the app together, and every handler lives where you'd expect to find it. With the routes settled, we can get back to what we came here for.

Enabling the mailer

Like every battery, Mail is optioned through a switch. Set EXT_MAIL = 1 and the framework expands your fluid with a ready-to-use Mail instance. The mailer speaks SMTP and ships both a synchronous and an asynchronous client, so it fits whatever half of your app you are calling it from.

All credentials and server settings live in your app config. The only thing the mailer insists on is that MAIL_USERNAME and MAIL_PASSWORD are either both present or both absent. Everything else has a sensible default:

app_configs/app.ini ini
[general]
SECRET_KEY = supersecret

[data]
DATABASE_URI = sqlite:///app.db

[extensions]
EXT_SQLALCHEMY = 1
EXT_MAIL = 1

[mail]
MAIL_SERVER = localhost
MAIL_PORT = 1025
MAIL_USE_TLS = 0
MAIL_USE_STARTTLS = 0
MAIL_DEFAULT_SENDER = noreply@example.org
 

For local testing you do not need a real mail server. A throwaway debugging SMTP is just one command away: python -m aiosmtpd -n -l localhost:1025. Every mail you send will then be printed straight to that terminal instead of leaving your machine. Note the two zeroed TLS flags above: that toy server speaks neither, and STARTTLS is on by default.

The config values

These are the settings the extension reads when your app initializes:

  • MAIL_SERVER — SMTP host. Default localhost.
  • MAIL_PORT — SMTP port. Default 587.
  • MAIL_USE_TLS — implicit TLS, the kind that lives on port 465. Default False.
  • MAIL_USE_STARTTLS — connect in the clear, then upgrade. The port 587 way. Default True.
  • MAIL_TIMEOUT — connection timeout in seconds. Default 10.
  • MAIL_USERNAME / MAIL_PASSWORD — login credentials, all or nothing.
  • MAIL_DEFAULT_SENDER — the From address used when you don't pass one.
 

The two TLS flags describe two different conversations, and you get exactly one of them. The shipped pair — port 587 with MAIL_USE_STARTTLS — is the one most providers want. If yours answers with implicit TLS instead, set MAIL_PORT = 465 and MAIL_USE_TLS = True, and turn STARTTLS off. Setting both flags is not a compromise, it is a contradiction, and the extension refuses to start rather than let each client resolve it differently.

 

If you are upgrading from beta 2 or earlier, this is a defaults change you may need to act on. The old pair was 587 with implicit TLS — a combination no server actually offers — and on top of that the synchronous client ignored the flag and opened a plain connection, so mail.send() against an implicit-TLS server sent your credentials in the clear. Both halves are fixed; if you had worked around it by setting the flags by hand, re-read them now.

A home for services

You reach the mailer the same way you reached the database: through the shared extension registry in webfluid.core.ext. And just like our routes, recurring business logic deserves a home of its own rather than piling up inside a handler. So we start a services package inside the fluid directory and let it own the mailing:

fluid/services/notify.py python
from webfluid.core.ext import mail


def welcome(address: str, value: str):
    # The body is a mapping of MIME subtype -> content. So you can
    # ship a plain text and an html alternative in one go.
    mail.send(
        address,
        subject="Welcome aboard!",
        body={
            "plain": f"Saved your model: {value}. Thanks for trying WebFluid!",
            "html": f"<p>Saved your model <b>{value}</b>.</p>"
        }
    )

    # send() returns immediately. Per default it hands the actual
    # delivery to a background thread (fake_async=True), so your
    # request is never blocked by a slow mail server.
 

A detached send is a promise you cannot collect on: the caller is long gone by the time the connection is refused or the password rejected. What you do get is a line in the log — the worker thread reports its failure through the framework logger rather than dying quietly, which is exactly what it used to do. If a send actually has to succeed, await asend and handle the FrameworkException yourself.

Now the my-model handler from a moment ago finally has its reason to change: it calls welcome right after a model is created. This is the whole fluid/api/models.py, with the new line folded in:

fluid/api/models.py python
from webfluid.core.ext import db
from fastapi import Request
from fastapi.exceptions import HTTPException
from sqlalchemy import select

from fluid.models import MyModel
from fluid.services.notify import welcome


async def get_model(request: Request):
    model_id = request.query_params.get("id")
    if not model_id:
        raise HTTPException(status_code=400, detail="Bad Request")

    async with db.async_executor(model=MyModel) as e:
        results = await e.exec(
            select(MyModel).where(MyModel.id == model_id)
        )
        result = results.first()
        if not result:
            raise HTTPException(status_code=404, detail="Model not found")

        return {"model_id": model_id, "value": result.value}


async def add_model(request: Request):
    data = await request.json()
    value = data.get("value")
    if not value:
        raise HTTPException(status_code=400, detail="Bad Request")

    async with db.async_executor(model=MyModel) as e:
        model = await e.insert(MyModel(value), flush=True)

    # Fire and forget. The threaded sender does the rest.
    welcome("friend@example.org", model.value)

    return {"model_id": model.id, "value": model.value}

Sync, async and raw clients

The mailer mirrors the rest of the framework: everything has an async counterpart. Inside an async handler you can await the delivery directly and keep full control over when it actually happened:

example python
from webfluid.core.ext import mail

# Blocking, but offloaded to a thread per default:
mail.send("to@example.org", "Subject", {"plain": "Hello!"})

# Truly awaited delivery:
await mail.asend("to@example.org", "Subject", {"plain": "Hello!"})

# Force the sync send to block instead of threading:
mail.send("to@example.org", "Subject", {"plain": "Hello!"}, fake_async=False)

Both send and asend accept attachments, from_email, cc and bcc. An attachment is just a small dictionary:

example python
await mail.asend(
    "to@example.org",
    "Your report",
    {"plain": "See attached."},
    attachments=[{
        "bytes": pdf_bytes,        # bytes or str
        "type": "pdf",            # MIME application subtype
        "filename": "report.pdf"
    }]
)

One connection for a batch

Left alone, every send opens and closes its own SMTP session. That is exactly right for transactional mail, and wrong for a newsletter. So both mail.client() and mail.async_client() hand you a bare (logged-in) SMTP connection as a context manager — and while one is open, send and asend notice it and reuse it instead of dialling again:

example python
async with mail.async_client():
    for address in recipients:
        # One connection for the whole loop.
        await mail.asend(address, "Release notes", {"plain": body})

The connection is tracked per task, so this only affects the code inside the block. And if our conventions ever get in your way entirely, the client the context manager yields is a plain SMTP object you can drive yourself.

 

Sync and async clients do not mix. A mail.send() inside an async_client() block ignores the open connection and opens its own, and the other way around too. Keep a batch on one side of the fence.

Continue reading

From here you can continue straight with Babel.