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"), 413secure_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_LENGTHrejects 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| Asset | Cache policy | Why |
|---|---|---|
| HTML pages | no-cache or a short TTL | The response changes per user and per request |
| Hashed CSS/JS | One year, immutable | The filename changes when the content changes |
| Unhashed CSS/JS | Minutes to hours | A stale file is served until the TTL expires |
| Uploaded media | Private, short TTL or authenticated route | Access must be checked, not cached publicly |
| API responses | no-store by default | User-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_messagesor the console backend and assertlen(mail.outbox)and the recipient — never let a test send real mail. current_appis a proxy that is only valid inside the request. A background thread needs the real object, taken withcurrent_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.
Related
Deployment: gunicorn, nginx and Docker Building and testing a JSON API
Last refreshed 2026-09-18.