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.

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.

Sending your first mail

You reach the mailer the same way you reached the database: through the shared extension registry in webfluid.core.ext. Since our project is growing, this is also a good moment to give recurring logic a proper home. We'll 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.

And we call it right after a model is created. We keep working on the app factory we switched to in the Migrate chapter:

fluid/app/routes.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 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.