Skip to content

Send email with Flask

Send transactional email from Flask through PostStack.

There are two ways to send email from Flask with PostStack. If your app already uses Flask-Mail — including a Flask-Mail setup pointed at another provider or Gmail — change four config values and keep every Message and mail.send() call as it is. If you are starting fresh or need more than SMTP can give you (the email id back, delivery and bounce webhooks, templates, idempotent retries), call the REST API with the PostStack Python SDK. Both are below, along with background sending with RQ or Celery and the errors people hit most often.

Quickstart#

Before you start

You need an API key (sk_live_…, or sk_test_… to try it without delivering anything) and a sending domain you have verified in PostStack. See Authentication and Domains.

  1. Install Flask-Mail or the SDK

    bash
    pip install Flask-Mail   # SMTP path: keep your existing Flask-Mail code
    pip install poststack    # REST path: the PostStack Python SDK
  2. Configure Flask-Mail for PostStack

    python
    # config.py — Flask-Mail pointed at PostStack's SMTP relay
    import os
    
    class Config:
        MAIL_SERVER = "smtp.poststack.dev"
        MAIL_PORT = 587
        MAIL_USE_TLS = True          # STARTTLS on 587. For port 465 use MAIL_USE_SSL = True instead — not both
        MAIL_USE_SSL = False
        MAIL_USERNAME = "poststack"  # any non-empty value; the API key authenticates
        MAIL_PASSWORD = os.environ["POSTSTACK_API_KEY"]   # sk_live_... / sk_test_...
        MAIL_DEFAULT_SENDER = ("Acme", "hello@yourdomain.com")   # must be on a verified domain
    
    # app.py
    from flask import Flask
    from flask_mail import Mail
    
    mail = Mail()
    
    def create_app():
        app = Flask(__name__)
        app.config.from_object("config.Config")
        mail.init_app(app)
        return app
  3. Send an email with Flask-Mail

    python
    from flask import Blueprint
    from flask_mail import Message
    from .app import mail
    
    bp = Blueprint("auth", __name__)
    
    @bp.post("/welcome")
    def welcome():
        msg = Message(
            subject="Welcome to Acme",
            recipients=["user@example.com"],
            body="Thanks for signing up.",               # text/plain part
            html="<h1>Welcome!</h1><p>Thanks for signing up.</p>",
        )
        mail.send(msg)
        return {"ok": True}
  4. Handle errors

    python
    import logging
    import smtplib
    from flask_mail import Message
    from .app import mail
    
    logger = logging.getLogger(__name__)
    
    def send_or_log(msg: Message) -> bool:
        try:
            mail.send(msg)
            return True
        except smtplib.SMTPAuthenticationError:
            # 535 — MAIL_PASSWORD is not a valid, unrevoked PostStack API key
            logger.exception("PostStack rejected the SMTP credentials")
            raise
        except smtplib.SMTPResponseException as exc:
            # e.g. "Domain 'example.com' not found for this team": the sender is
            # not on a verified domain. The reason is in exc.smtp_error.
            logger.error("PostStack refused the message: %s %s", exc.smtp_code, exc.smtp_error)
            return False
        except (smtplib.SMTPException, OSError):
            # connection refused / timed out — outbound 587 (or 465) blocked?
            logger.exception("Could not reach smtp.poststack.dev")
            raise

Good to know

  • SMTP host smtp.poststack.dev, port 587 + MAIL_USE_TLS (STARTTLS) or port 465 + MAIL_USE_SSL (implicit TLS); password = your API key
  • Moving from another SMTP provider? Change MAIL_SERVER, the username and the password — your Message code stays the same
  • Senders must be on a domain you have verified in PostStack

Migrating Flask-Mail from another SMTP provider#

Flask-Mail does not care which provider is on the other end, so switching is a config change. PostStack accepts any non-empty username and authenticates with the API key in the password. Remove your old provider’s Python package too if you only used it for sending.

python
# Before — your previous provider
MAIL_SERVER = "smtp.old-provider.example"
MAIL_PORT = 587
MAIL_USE_TLS = True
MAIL_USERNAME = "old-username"
MAIL_PASSWORD = os.environ["OLD_PROVIDER_API_KEY"]

# After — PostStack
MAIL_SERVER = "smtp.poststack.dev"
MAIL_PORT = 587
MAIL_USE_TLS = True
MAIL_USERNAME = "poststack"
MAIL_PASSWORD = os.environ["POSTSTACK_API_KEY"]

# Checklist:
# 1. Add and verify your sending domain in PostStack (SPF + DKIM records)
# 2. Make sure MAIL_DEFAULT_SENDER / every Message(sender=...) uses that domain
# 3. Send a test with an sk_test_ key, then switch to sk_live_

Attachments, cc/bcc and custom headers with Flask-Mail#

Message.attach(filename, content_type, data) adds a file. PostStack accepts up to 10 attachments per message, 10 MB per file and 25 MB in total. X-PostStack-Tags and X-PostStack-Idempotency-Key headers are read by the relay; other X- headers pass through.

python
from flask import current_app
from flask_mail import Message
from .app import mail

msg = Message(
    subject="Invoice #1042",
    recipients=["customer@example.com"],
    cc=["accounts@example.com"],
    bcc=["archive@yourdomain.com"],
    reply_to="billing@yourdomain.com",
    body="Your invoice is attached.",
    extra_headers={
        "X-PostStack-Tags": "invoice,transactional",
        "X-PostStack-Idempotency-Key": "invoice-1042",
    },
)
msg.attach("invoice-1042.pdf", "application/pdf", pdf_bytes)

with current_app.open_resource("static/terms.pdf") as fp:
    msg.attach("terms.pdf", "application/pdf", fp.read())

mail.send(msg)

Send in the background (RQ) with an app context#

Flask-Mail reads its settings from current_app, which does not exist in an RQ or Celery worker. Create the app once in the worker module and push an app context around the send.

python
# tasks.py — executed by "rq worker"
from flask_mail import Message
from myapp.app import create_app, mail

app = create_app()

def send_welcome(email: str, name: str):
    with app.app_context():
        mail.send(Message(
            subject="Welcome to Acme",
            recipients=[email],
            html=f"<h1>Welcome, {name}!</h1>",
        ))

# views.py
from redis import Redis
from rq import Queue
from .tasks import send_welcome

queue = Queue(connection=Redis.from_url("redis://localhost:6379"))

@bp.post("/signup")
def signup():
    user = create_user(request.form)
    queue.enqueue(send_welcome, user.email, user.name)   # response stays fast
    return {"ok": True}

HTML email from Jinja templates#

Keep email bodies in templates/email/ and render them with the same render_template your views use, so autoescaping and template inheritance work. Render a plain-text twin too: mail with only an HTML part scores worse with spam filters. render_template needs an app context, which a view already has.

python
# templates/email/welcome.html  →  {% extends "email/base.html" %} … Hi {{ user.name }} …
# templates/email/welcome.txt   →  Hi {{ user.name }}, thanks for signing up.

from flask import render_template
from flask_mail import Message
from .app import mail

def send_welcome(user):
    mail.send(Message(
        subject="Welcome to Acme",
        recipients=[user.email],
        body=render_template("email/welcome.txt", user=user),    # text/plain
        html=render_template("email/welcome.html", user=user),   # text/html
    ))

Handle bounces and complaints with a webhook route#

Add a webhook endpoint in PostStack for email.bounced and email.complained. Verify X-PostStack-Signature against request.get_data() — the raw bytes; request.json re-serialises them and the HMAC will not match. PostStack suppresses the address itself, so the route only updates your own data and returns any 2xx (anything else is retried). verify is a local HMAC check in the SDK (pip install poststack); it makes no API call.

python
import os
from flask import Blueprint, current_app, request

hooks = Blueprint("hooks", __name__)

@hooks.post("/webhooks/poststack")
def poststack_webhook():
    ps = current_app.extensions["poststack"]           # SDK client, see the SDK section below
    if not ps.webhooks.verify(
        request.get_data(),                             # raw body
        request.headers.get("X-PostStack-Signature", ""),
        os.environ["POSTSTACK_WEBHOOK_SECRET"],         # whsec_...
    ):
        return "Invalid signature", 401

    event = request.get_json()
    data = event["data"]   # email_id, from, subject, and "to": EVERY recipient of the message
    if event["type"] == "email.bounced":
        for address in data["to"]:
            mark_undeliverable(address, reason=data.get("bounce_message"))
    elif event["type"] == "email.complained":
        for address in data["to"]:
            unsubscribe_everywhere(address)
    return "", 204

Alternative: send through the REST API with the Python SDK#

Use the SDK when you want the email id back, per-send open/click tracking, stored templates with variables, or batch sends of up to 100 emails per request.

python
# app.py
import os
from flask import Flask, current_app
from poststack import PostStack, PostStackError

def create_app():
    app = Flask(__name__)
    app.extensions["poststack"] = PostStack(api_key=os.environ["POSTSTACK_API_KEY"])
    return app

# views.py
@bp.post("/signup")
def signup():
    user = create_user(request.form)
    ps = current_app.extensions["poststack"]
    try:
        result = ps.emails.send({
            "from": "Acme <hello@yourdomain.com>",
            "to": [user.email],                  # always a list
            "subject": "Welcome to Acme",
            "html": f"<h1>Welcome, {user.name}!</h1>",
            "text": f"Welcome, {user.name}!",
            "tags": ["welcome"],
        })
    except PostStackError as exc:
        # SDK already retried 408/429/5xx. 400 = invalid payload,
        # 422 = unverified domain or suppressed recipient.
        current_app.logger.error("PostStack %s: %s (req %s)", exc.status_code, exc.error, exc.request_id)
        return {"ok": False}, 502
    return {"ok": True, "email_id": result["id"]}   # "em_…"

Framework integrations#

Flask-Mail over SMTP

Flask-Mail is a thin wrapper around smtplib. Point it at smtp.poststack.dev and all existing Message objects — HTML bodies, attachments, cc/bcc, custom headers — send through PostStack unchanged. Custom X-PostStack-* headers add tags, idempotency keys and scheduled sends.

PostStack SDK on app.extensions

Create one PostStack client in create_app() and store it on app.extensions["poststack"]; views reach it through current_app. emails.send() returns an em_… id and the SDK retries 408/429/5xx for you with an automatic idempotency key.

RQ / Celery background jobs

Enqueue the send rather than calling it in the view. Workers run outside the request, so Flask-Mail needs with app.app_context(): there, while the SDK needs only the API key from the environment.

Blueprints

Keep sending helpers in one module (emails.py) imported by your auth, billing and account blueprints, so templates and sender addresses live in one place.

Common pitfalls#

SMTPAuthenticationError (535)

MAIL_PASSWORD must be a PostStack API key (sk_live_… or sk_test_…), not your account password. A stray newline from a secrets file or a revoked key produces the same error.

"Domain '…' not found for this team"

The sender must be on a domain you added and verified in PostStack. Check MAIL_DEFAULT_SENDER and any explicit sender= on Message — a leftover address from your previous provider is the usual cause.

MAIL_USE_TLS and MAIL_USE_SSL both True

Pick one: port 587 with MAIL_USE_TLS = True (STARTTLS), or port 465 with MAIL_USE_SSL = True (implicit TLS). Mixing them, or using 465 with STARTTLS, ends in a TLS handshake error or a timeout.

"Working outside of application context"

Flask-Mail needs current_app. In RQ/Celery workers, threads and scripts, wrap the send in with app.app_context():.

Nothing arrives, no error

MAIL_SUPPRESS_SEND (on automatically when TESTING = True) makes Flask-Mail skip delivery, and sk_test_ keys accept mail without delivering it. Check both before debugging DNS.

FAQ#

How do I configure Flask-Mail for PostStack?

Set MAIL_SERVER = "smtp.poststack.dev", MAIL_PORT = 587, MAIL_USE_TLS = True, MAIL_USERNAME = "poststack" and MAIL_PASSWORD to your API key. Port 465 with MAIL_USE_SSL = True also works. Your Message and mail.send() code does not change.

How do I switch Flask-Mail from another provider to PostStack?

Set MAIL_SERVER to smtp.poststack.dev, the username to any non-empty value such as poststack, and the password to a PostStack API key. Verify your sending domain in PostStack first so the From address is accepted.

Flask-Mail or the PostStack SDK?

Flask-Mail if you already use it or want provider-neutral SMTP code. The SDK if you need the email id back to match webhooks, stored templates, batch sends or per-send tracking settings. You can use both in one app.

Does Flask-Mail work with Flask 3?

Yes — Flask-Mail 0.10 supports current Flask releases. The SDK has no Flask dependency at all.

How do I know when a Flask email bounced?

SMTP accepts the message before the receiving server decides, so a bounce never shows up as an exception in mail.send(). Subscribe a webhook endpoint to email.bounced and verify its signature against request.get_data(). PostStack suppresses the address automatically after a hard bounce or complaint.

How do I send email in the background with Flask?

Enqueue a function with RQ or Celery from your view. In the worker, create the app and send inside with app.app_context(): (Flask-Mail) or construct a PostStack client from the environment (SDK).

Reference#

The docs this guide builds on:

  • SDKs — every official client, its retries and idempotency keys
  • SMTP relay — hosts, ports, TLS modes and the X-PostStack-* headers
  • Emails API — every send field, attachments, scheduling and batch sends
  • Templates — stored templates with variables
  • Webhooks — event types, payloads and signature verification
  • Suppressions — what happens to an address after a hard bounce or complaint

Ready to send from Flask?

Create a free account and an API key — no card needed.

Get started free

Next steps

Was this page helpful?