File uploads, static files and sending email

Accept uploads without writing an attacker-controlled filename, cache static assets correctly, and send mail without blocking the request that triggered it.

Handling uploads

import os, uuid
from pathlib import Path
from flask import Blueprint, current_app, flash, request
from werkzeug.utils import secure_filename

ALLOWED = {".png", ".jpg", ".jpeg", ".webp", ".pdf"}

bp = Blueprint("uploads", __name__, url_prefix="/uploads")

def upload_dir() -> Path:
    path = Path(current_app.instance_path) / "uploads"
    path.mkdir(parents=True, exist_ok=True)
    return path

@bp.post("/avatar")
def avatar():
    file = request.files.get("avatar")
    if file is None or file.filename == "":
        flash("Choose a file first.", "error")
        return redirect(url_for("profile.edit"))

    ext = Path(secure_filename(file.filename)).suffix.lower()
    if ext not in ALLOWED:
        flash("PNG, JPEG, WebP or PDF only.", "error")
        return redirect(url_for("profile.edit"))

    name = f"{uuid.uuid4().hex}{ext}"          # we choose the stored name
    target = upload_dir() / name
    file.save(target)

    current_user.avatar_path = name
    db.session.commit()
    return redirect(url_for("profile.edit"))

# app/__init__.py — cap the body size at the WSGI layer as well
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024

@app.errorhandler(413)
def too_large(err):
    return render_template("413.html"), 413
  • secure_filename() strips directory components and unsafe characters, but it is not a policy: it keeps the extension. Decide the extension yourself and generate the stored name.
  • MAX_CONTENT_LENGTH rejects the request as it is read; a reverse proxy limit should be equal or slightly larger so the app sees the request that nginx admits.
  • Storing uploads inside the static root means they are served by the web server with no permission check. Keep them in the instance folder or object storage and serve them through a route that authenticates the request.
  • An uploaded file is untrusted input: re-encoding images with Pillow strips metadata and defeats malformed-file tricks, and anything a user can upload should be scanned before it is offered for download.

Static assets and caching

<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
<img src="{{ url_for('static', filename='img/logo.svg') }}" alt="Logo">
<!-- cache buster when the file has no content hash in its name -->
<img src="{{ url_for('static', filename='img/chart.png', v=asset_version) }}" alt="Chart">
{# asset_version is a build id such as a commit sha, injected by a context processor #}
# per-response caching for everything Flask serves from static/
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0                  # dev: no caching
# production: hand /static/ to nginx or a CDN and let it set the header
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 60 * 60 * 24 * 365
AssetCache policyWhy
HTML pagesno-cache or a short TTLThe response changes per user and per request
Hashed CSS/JSOne year, immutableThe filename changes when the content changes
Unhashed CSS/JSMinutes to hoursA stale file is served until the TTL expires
Uploaded mediaPrivate, short TTL or authenticated routeAccess must be checked, not cached publicly
API responsesno-store by defaultUser-specific data must not sit in a shared cache

Sending email with Flask-Mail

# app/extensions.py
from flask_mail import Mail
mail = Mail()

# app/__init__.py
mail.init_app(app)

# app/config.py
class ProductionConfig(Config):
    MAIL_SERVER = "smtp.example.com"
    MAIL_PORT = 587
    MAIL_USE_TLS = True
    MAIL_USERNAME = required("MAIL_USERNAME")
    MAIL_PASSWORD = required("MAIL_PASSWORD")
    MAIL_DEFAULT_SENDER = ("Blog", "[email protected]")

class DevelopmentConfig(Config):
    MAIL_BACKEND = "flask_mail.backends.console.EmailBackend"   # print, do not send

# sending
from flask_mail import Message
from threading import Thread

def send_async(app, msg):
    with app.app_context():
        try:
            mail.send(msg)
        except Exception:
            app.logger.exception("email send failed")

def notify(app, user, post):
    msg = Message(subject="Your post is live",
                  recipients=[user.email],
                  body=render_template("email/published.txt", post=post),
                  html=render_template("email/published.html", post=post))
    Thread(target=send_async, args=(app, msg), daemon=True).start()

# in the view
notify(current_app._get_current_object(), current_user, post)
  • In tests, replace the backend with record_messages or the console backend and assert len(mail.outbox) and the recipient — never let a test send real mail.
  • current_app is a proxy that is only valid inside the request. A background thread needs the real object, taken with current_app._get_current_object(), plus its own app context.
  • For anything beyond a few messages per minute, use a queue: Celery, RQ or your platform's task runner gives you retries, a dead-letter path and visibility of failures that a fire-and-forget thread does not.
⚠️
A blocking mail.send() inside a view ties the response to the SMTP round trip. When the mail server is slow or unreachable, every request that sends mail becomes slow and then times out — a mail outage turns into a web outage. Move sending off the request path, set explicit timeouts on the SMTP connection, and never send mail from inside an exception handler without a guard, or a failing send will trigger another failing send.

FAQ

Where should uploaded files live?
Outside the source tree and outside the static root, in the instance folder for a single server or in object storage (S3-compatible) as soon as you run more than one instance — local disk is invisible to the other workers.
Why do my static changes not appear for users?
The browser cached the old file. Add a content hash or a version query string to the URL and set a long TTL only on those hashed names; the unhashed URL then stops being the thing clients hold on to.

Deployment: gunicorn, nginx and Docker Building and testing a JSON API

Last refreshed 2026-09-18.