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:
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:
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:
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:
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:
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:
[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:
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:
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:
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:
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.