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 create_app 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 create_app 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 create_app 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 create_app() -> 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__":
    create_app().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_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.

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 for the async client. Default True.
  • MAIL_USE_STARTTLS — upgrade the connection via STARTTLS. Default False.
  • 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.

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.

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.send_async("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 send_async accept attachments, from_email, cc and bcc. An attachment is just a small dictionary:

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

And if our conventions ever get in your way, both mail.client() and mail.async_client() hand you a bare (logged-in) SMTP connection as a context manager, so you can drive the protocol yourself.

 

The mailer keeps no connection pool. Every send opens and closes its own SMTP session. That is perfectly fine for transactional mail, but if you are blasting out large batches you'll want to grab a single client() context and reuse it for the whole run.

Continue reading

From here you can continue straight with Babel.