Flask leaves email to you, which means either a small extension or smtplib directly. This guide uses Flask-Mailman — the maintained successor to Flask-Mail — against Postwing, and covers Jinja2 HTML templates, attachments and moving the send off the request.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
pip install flask-mailmanFlask-Mail, which has not had a meaningful release in years and breaks on recent Python versions. Flask-Mailman is a drop-in replacement with essentially the same configuration keys. # config.py
import os
class Config:
MAIL_SERVER = "smtp.postwing.app"
MAIL_PORT = 587
MAIL_USE_TLS = True # STARTTLS on 587
MAIL_USE_SSL = False # mutually exclusive with MAIL_USE_TLS
MAIL_USERNAME = os.environ["SMTP_USER"]
MAIL_PASSWORD = os.environ["SMTP_PASS"]
MAIL_DEFAULT_SENDER = ("Acme", "noreply@your-domain.com")
MAIL_TIMEOUT = 10# app.py
from flask import Flask
from flask_mailman import Mail
from config import Config
mail = Mail()
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
mail.init_app(app)
return appfrom flask import current_app
from flask_mailman import EmailMessage
@app.post("/orders")
def create_order():
order = save_order(request.json)
EmailMessage(
subject=f"Your order #{order.id} is confirmed",
body="Thanks! Your order ships tomorrow.",
to=[order.customer_email],
).send()
return {"id": order.id}, 201Email templates are ordinary Jinja2 templates. Render both an HTML and a text version — a message with no plain-text part is a spam signal:
from flask import render_template
from flask_mailman import EmailMultiAlternatives
html = render_template("email/order_confirmed.html", order=order)
msg = EmailMultiAlternatives(
subject=f"Your order #{order.id} is confirmed",
body=render_template("email/order_confirmed.txt", order=order),
to=[order.customer_email],
)
msg.attach_alternative(html, "text/html")
msg.send()msg = EmailMessage(
subject="Your invoice",
body="The invoice for March is attached.",
to=["customer@example.com"],
)
msg.attach_file("/srv/invoices/2026-03.pdf")
msg.attach("report.csv", csv_bytes, "text/csv")
msg.send()Sending inside the view holds the response open for the whole SMTP round trip. A thread is the quick fix, but it needs an application context:
from threading import Thread
def send_async(app, msg):
# A thread has no request context — push an application context manually.
with app.app_context():
msg.send()
def queue_email(msg):
Thread(target=send_async, args=(current_app._get_current_object(), msg)).start()For anything a user depends on, use Celery — a thread dies with the process and gives you no retries:
# tasks.py — durable background sending
from celery import shared_task
from flask_mailman import EmailMessage
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_order_confirmation(self, order_id):
order = Order.query.get(order_id)
try:
EmailMessage(
subject=f"Your order #{order.id} is confirmed",
body=render_confirmation(order),
to=[order.customer_email],
).send()
except Exception as exc:
raise self.retry(exc=exc)Flask requires no mail extension at all. If you would rather not add a dependency, the standard library covers it:
# No extension at all — Flask does not require one.
import smtplib
from email.message import EmailMessage
def send_email(to, subject, text):
msg = EmailMessage()
msg["From"] = "Acme <noreply@your-domain.com>"
msg["To"] = to
msg["Subject"] = subject
msg.set_content(text)
with smtplib.SMTP("smtp.postwing.app", 587, timeout=10) as smtp:
smtp.starttls()
smtp.login(current_app.config["MAIL_USERNAME"],
current_app.config["MAIL_PASSWORD"])
smtp.send_message(msg)# Flask-Mailman collects messages instead of sending them in tests.
def test_order_sends_confirmation(app, client):
with mail.record_messages() as outbox:
client.post("/orders", json={"email": "customer@example.com"})
assert len(outbox) == 1
assert "confirmed" in outbox[0].subject| Error | Cause and fix |
|---|---|
Working outside of application context | Sending from a thread. Wrap it in with app.app_context(). |
SMTPAuthenticationError | Wrong MAIL_USERNAME / MAIL_PASSWORD. |
| Both TLS flags set | MAIL_USE_TLS and MAIL_USE_SSL are exclusive — pick one. |
| Requests hang under load | Synchronous sends occupying workers. Move to Celery and set MAIL_TIMEOUT. |
| Connection times out on the server | Outbound port blocked. Use 8587 or 8465. |
SMTPSenderRefused (550) | MAIL_DEFAULT_SENDER is not on a verified domain. |
Flask-Mailman. Flask-Mail has been effectively unmaintained for years and breaks on newer Python releases. Flask-Mailman is the maintained successor, ports Django's well-tested mail API to Flask, and keeps a largely compatible configuration surface so migrating is mostly a rename.
No — they are mutually exclusive and setting both raises an error. Use MAIL_USE_TLS = True with port 587 for STARTTLS, or MAIL_USE_SSL = True with port 465 for implicit TLS.
The thread does not inherit Flask's context. Capture the real app object with current_app._get_current_object() and wrap the send in with app.app_context(). For anything that matters, use Celery instead — a thread dies with the process and takes the unsent email with it.
Use render_template exactly as you would for a page, then pass the result to attach_alternative(html, 'text/html') on an EmailMultiAlternatives message. Keep a .txt template alongside it for the plain-text body.
No. Flask has no opinion about email, so smtplib from the standard library works fine and is one less dependency. An extension mainly buys you configuration wiring, a testing outbox and connection reuse.
Most cloud providers block outbound ports 25, 465 and 587. Set MAIL_PORT to 8587 for STARTTLS or 8465 for implicit TLS. Always set MAIL_TIMEOUT so a blocked port fails fast instead of tying up a worker.