Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 055 / 100

Sending email from Python apps: best practices explained

Reliable transactional email.

Expona Admin8 min read

Email is still one of the most critical communication channels in web applications, yet it's easy to ship broken implementations. We've all seen transactional emails arrive hours late or never arrive at all, watched bounce rates climb, and debugged why a password reset landed in spam. Sending email from Python apps: best practices explained covers the practical patterns that keep your transactional mail reliable, fast, and actually deliverable.

Use a provider — SES, SendGrid or Postmark beat raw SMTP for deliverability

The tempting first instinct is to spin up an SMTP server and send directly. It works in development, it costs nothing upfront, and it feels simple. In production, you'll watch your IP reputation tank because the server is on shared infrastructure, your SPF and DKIM configuration will confuse you, and ISPs will throttle or block you based on reputation signals you don't control.

Email service providers (ESPs) exist precisely to solve this. AWS SES, SendGrid, and Postmark all maintain pristine IP reputations, handle bounce management, provide detailed logging, and take abuse complaints seriously so your domain isn't collateral damage. They're also cheaper than the operational overhead of running your own queue.

Here's a minimal example using the popular resend library (Postmark also has similar patterns):

python
import os
from resend import Resend

client = Resend(api_key=os.environ.get("RESEND_API_KEY"))

def send_password_reset(user_email: str, reset_token: str) -> dict:
    """Send password reset email via Resend."""
    reset_link = f"https://app.example.com/reset?token={reset_token}"
    
    response = client.emails.send(
        from_="noreply@example.com",
        to=user_email,
        subject="Reset your password",
        html=f"""
        <p>Click the link below to reset your password:</p>
        <a href="{reset_link}">Reset Password</a>
        <p>This link expires in 1 hour.</p>
        """,
    )
    return response

For AWS SES, you'll use boto3:

python
import boto3
from botocore.exceptions import ClientError

ses_client = boto3.client("ses", region_name="us-east-1")

def send_with_ses(recipient: str, subject: str, body_text: str, body_html: str):
    """Send email via AWS SES."""
    try:
        response = ses_client.send_email(
            Source="noreply@example.com",
            Destination={"ToAddresses": [recipient]},
            Message={
                "Subject": {"Data": subject},
                "Body": {
                    "Text": {"Data": body_text},
                    "Html": {"Data": body_html},
                },
            },
        )
        return response["MessageId"]
    except ClientError as e:
        print(f"Email send failed: {e.response['Error']['Message']}")
        raise

SendGrid follows a similar pattern with their Python library. The key point: delegate deliverability to a service with domain reputation, infrastructure, and legal liability. You focus on your application logic.

Send in the background — Queue emails with Celery so requests stay fast

Never, ever send email synchronously in a request handler. A network call to your ESP can take 500ms to 2 seconds. If you block the user's password reset request on that call, they wait. If the ESP is having latency issues, your entire application appears slow. If the call fails, the user's request fails—even though the email might succeed on retry.

Queue the email task and return immediately. Celery with Redis is the standard pattern here. Your worker processes emails asynchronously, retries on failure, and you can monitor the queue.

Here's a FastAPI example with Celery:

python
from celery import Celery
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()
celery_app = Celery(
    "tasks",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/0",
)

class ResetRequest(BaseModel):
    email: str
    token: str

@celery_app.task(bind=True, max_retries=3)
def send_password_reset_email(self, email: str, token: str):
    """Celery task to send password reset email."""
    try:
        reset_link = f"https://app.example.com/reset?token={token}"
        response = client.emails.send(
            from_="noreply@example.com",
            to=email,
            subject="Reset your password",
            html=f"<a href="{reset_link}">Reset Password</a>",
        )
        return {"status": "sent", "message_id": response.get("id")}
    except Exception as exc:
        # Exponential backoff: retry after 2^retry_count seconds
        self.retry(exc=exc, countdown=2 ** self.request.retries)

@app.post("/password-reset-request")
async def request_password_reset(req: ResetRequest):
    """Queue password reset email and return immediately."""
    # Generate token, store it in your database...
    token = generate_reset_token(req.email)
    
    # Queue the email task
    send_password_reset_email.delay(req.email, token)
    
    # Return to user immediately
    return {
        "status": "ok",
        "message": "Check your email for password reset instructions"
    }

Your request returns in 10ms. The Celery worker picks up the task from Redis, retries it if the ESP is slow, and logs failures. Users get responsive UX; your application stays fast.

Templates — Jinja2 or Django templates for HTML and text versions

Hardcoding HTML strings is unmaintainable. Move email bodies to templates and render them dynamically. Both plain-text and HTML versions are important: plain-text for accessibility and older mail clients, HTML for branding and formatting.

Use Jinja2 (works everywhere) or Django's template engine if you're in a Django project:

python
from jinja2 import Environment, FileSystemLoader, select_autoescape

env = Environment(
    loader=FileSystemLoader("templates/email"),
    autoescape=select_autoescape(["html", "xml"]),
)

def render_password_reset_email(user_name: str, reset_link: str) -> tuple[str, str]:
    """Render plain-text and HTML versions of password reset email."""
    text_template = env.get_template("password_reset.txt")
    html_template = env.get_template("password_reset.html")
    
    context = {"user_name": user_name, "reset_link": reset_link}
    
    text_body = text_template.render(**context)
    html_body = html_template.render(**context)
    
    return text_body, html_body

Your template files:

templates/email/password_reset.txt:

python
Hi {{ user_name }},

Click the link below to reset your password:
{{ reset_link }}

This link expires in 1 hour.

—
The Team

templates/email/password_reset.html:

python
<h1>Hi {{ user_name }},</h1>
<p>Click the button below to reset your password:</p>
<a href="{{ reset_link }}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none;">Reset Password</a>
<p><em>This link expires in 1 hour.</em></p>

Now your Celery task becomes cleaner:

python
@celery_app.task(bind=True, max_retries=3)
def send_password_reset_email(self, email: str, user_name: str, token: str):
    """Send password reset email."""
    reset_link = f"https://app.example.com/reset?token={token}"
    text_body, html_body = render_password_reset_email(user_name, reset_link)
    
    try:
        client.emails.send(
            from_="noreply@example.com",
            to=email,
            subject="Reset your password",
            text=text_body,
            html=html_body,
        )
    except Exception as exc:
        self.retry(exc=exc, countdown=2 ** self.request.retries)

Design teams can iterate on templates independently. Marketing can update copy. You ship templates with your code, version-control them, and test them reliably.

Track failures — Handle bounces and complaints via webhooks

Your email sends, but then the address is invalid, or the user marks it as spam. If you're not listening to those events, your database fills with dead addresses and your reputation suffers.

Every major ESP provides webhooks for bounce, complaint, and delivery events. Subscribe to them, persist the data, and act on it.

Here's a FastAPI webhook handler for Resend (though AWS SES and SendGrid follow similar patterns):

python
from fastapi import FastAPI, Request
from sqlalchemy.orm import Session
from enum import Enum

app = FastAPI()

class EmailStatus(str, Enum):
    DELIVERED = "delivered"
    BOUNCED = "bounced"
    COMPLAINED = "complained"

async def log_email_event(db: Session, email: str, status: EmailStatus, details: dict):
    """Log email event to database."""
    event = EmailEvent(
        email_address=email,
        status=status,
        details=details,
        logged_at=datetime.utcnow(),
    )
    db.add(event)
    db.commit()

@app.post("/webhooks/email-events")
async def handle_email_webhook(request: Request, db: Session = Depends(get_db)):
    """Handle email delivery, bounce, and complaint events."""
    payload = await request.json()
    
    # Verify webhook signature (each ESP has a method)
    if not verify_resend_signature(request, payload):
        return {"status": "unauthorized"}
    
    event_type = payload.get("type")
    email = payload.get("email")
    
    if event_type == "email.delivered":
        await log_email_event(db, email, EmailStatus.DELIVERED, payload)
    
    elif event_type == "email.bounced":
        await log_email_event(db, email, EmailStatus.BOUNCED, payload)
        # Soft bounce? Mark for retry. Hard bounce? Disable the address.
        user = db.query(User).filter(User.email == email).first()
        if user and payload.get("bounce_type") == "permanent":
            user.email_status = "invalid"
            db.commit()
    
    elif event_type == "email.complained":
        await log_email_event(db, email, EmailStatus.COMPLAINED, payload)
        user = db.query(User).filter(User.email == email).first()
        if user:
            user.email_status = "complained"
            db.commit()
    
    return {"status": "processed"}

Now when a user marks your email as spam, you know it. Their address gets flagged. Future emails to that user aren't sent. Your bounce rate stays healthy, and your sender reputation stays intact. ESPs reward accounts with low bounce and complaint rates with better deliverability.

Common mistakes

  • Sending synchronously in request handlers. Network I/O blocks the user. Queue it.
  • Ignoring bounce and complaint webhooks. Your sender reputation degrades silently. Set them up day one.
  • Hardcoding email addresses. Use environment variables or a configuration service. Never hardcode noreply@example.com in code that might be deployed to different environments.
  • No retry logic. Network hiccups happen. Celery's exponential backoff handles this transparently.
  • Sending HTML-only. Always include a plain-text version. Some clients strip HTML, and accessibility requires it.
  • Not testing templates. Render templates in unit tests. Mock the ESP client. Catch typos before production.

Key takeaways

  • Use an email service provider (SES, SendGrid, Postmark) instead of raw SMTP. Deliverability and operational overhead are worth the cost.
  • Queue emails with Celery and Redis. Keep requests fast; let workers handle delivery and retries asynchronously.
  • Render email bodies from templates using Jinja2. Maintain both plain-text and HTML versions. Version-control templates with your code.
  • Subscribe to bounce, complaint, and delivery webhooks. Log events to your database. Disable invalid addresses proactively to protect sender reputation.
  • Test email rendering in unit tests. Mock the ESP client. Catch configuration mistakes before they hit production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles