Developer Tutorial

How to Send Email with Python: smtplib, requests, and the API Approach

Covers smtplib for local scripts, the requests library for REST API sends, and what production apps actually need.

9 min read·Updated March 2026

Python is the most popular language for backend services, data pipelines, and automation scripts — and all of them eventually need to send email. This guide covers every real approach: the standard library's smtplib, sending via the requests library to a REST API, and what changes when you're building a Django or FastAPI app that sends transactional email at scale.

1. smtplib vs REST API — which approach to use

Python gives you two fundamentally different paths. Understanding the tradeoffs upfront saves you from migrating later.

smtplib (Python standard library)

Connects directly to an SMTP server. No third-party dependencies. Works great for scripts, internal tools, and dev environments.

+ Zero dependencies — built into Python
+ Works with any SMTP server (Gmail, Outlook, SES SMTP, etc.)
– No delivery events or bounce webhooks
– SMTP credentials to manage, rotate, and keep secure
– Connection management is manual (especially in async code)
– Deliverability is entirely your responsibility

REST API via requests (recommended for apps)

POST JSON to an email API endpoint. One API key, webhook delivery events, templates, and sequences included.

+ Simple auth — one Bearer token in an env var
+ Webhooks for bounces, spam complaints, opens, clicks
+ Deliverability managed by provider (warm IPs, feedback loops)
+ Idempotency keys prevent duplicate sends in retry scenarios
+ No SMTP connection overhead — especially important in serverless

Use smtplib for local scripts and quick automation. Use a REST API for anything user-facing — password resets, receipts, onboarding emails. The operational overhead of managing SMTP at scale is not worth it compared to a simple HTTP POST.

2. Sending email with smtplib

The standard library approach. This uses smtplib and the email module (also built-in) to send a plain-text email via TLS:

import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import os

def send_email_smtp(to: str, subject: str, text_body: str, html_body: str = None):
    msg = MIMEMultipart("alternative")
    msg["Subject"] = subject
    msg["From"] = os.environ["EMAIL_FROM"]
    msg["To"] = to

    # Plain text first — spam filters prefer it
    msg.attach(MIMEText(text_body, "plain"))

    if html_body:
        msg.attach(MIMEText(html_body, "html"))

    with smtplib.SMTP(os.environ["SMTP_HOST"], int(os.environ["SMTP_PORT"])) as server:
        server.ehlo()
        server.starttls()
        server.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
        server.sendmail(msg["From"], [to], msg.as_string())

# Usage
send_email_smtp(
    to="[email protected]",
    subject="Your order shipped",
    text_body="Hi, your order #1234 has shipped. Track it at https://example.com/track/1234",
    html_body="<p>Hi, your order <strong>#1234</strong> has shipped. <a href='https://example.com/track/1234'>Track it here</a>.</p>"
)

A few things that bite people with smtplib in production:

  • The with block is essential — it calls quit() cleanly. Not using it leaves dangling connections.
  • Port 587 + STARTTLS is the standard for SMTP relay. Port 465 (SMTPS) uses implicit TLS — use smtplib.SMTP_SSL for that.
  • Gmail's "Less secure apps" setting was deprecated in 2022. For Gmail SMTP you need App Passwords or OAuth 2.0 now.
  • Always attach plain text before HTML in MIMEMultipart("alternative"). The order matters — clients render the last matching part.

3. Sending email via REST API with requests

No library needed beyond requests, which is already in most Python projects:

import requests
import os

def send_email(to: str, subject: str, html: str, text: str, idempotency_key: str = None):
    headers = {
        "Authorization": f"Bearer {os.environ['TINYSEND_API_KEY']}",
        "Content-Type": "application/json",
    }
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    response = requests.post(
        "https://api.tinysend.co/v1/emails",
        headers=headers,
        json={
            "from": os.environ["EMAIL_FROM"],
            "to": to,
            "subject": subject,
            "html": html,
            "text": text,
        },
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

# Usage
result = send_email(
    to="[email protected]",
    subject="Reset your password",
    html="<p>Click <a href='https://app.example.com/reset?token=abc123'>here</a> to reset your password.</p>",
    text="Reset your password: https://app.example.com/reset?token=abc123",
    idempotency_key=f"password-reset-{user_id}-{token}",
)
print(f"Sent email, id: {result['id']}")

The Idempotency-Key header is important for password resets and other critical emails. If your job queue retries the task after a timeout, the API will return the original send instead of duplicating it.

Always set a timeout on requests calls. Without it, a slow API response will block your thread (or coroutine) indefinitely in production.

4. HTML email with variables

Building HTML email strings by hand scales poorly. Two patterns that actually work in Python:

Option A: Jinja2 templates (for self-hosted templates)

# pip install jinja2
from jinja2 import Environment, FileSystemLoader
import os

env = Environment(loader=FileSystemLoader("templates/email"))

def render_email(template_name: str, context: dict) -> tuple[str, str]:
    """Returns (html, text) tuple."""
    html_template = env.get_template(f"{template_name}.html")
    text_template = env.get_template(f"{template_name}.txt")
    return html_template.render(**context), text_template.render(**context)

# templates/email/welcome.html:
# <h1>Welcome, !</h1>
# <p>Your account is ready. <a href="">Get started</a>.</p>

html, text = render_email("welcome", {
    "first_name": user.first_name,
    "app_url": f"https://app.example.com/onboarding?token={onboarding_token}",
})

send_email(to=user.email, subject=f"Welcome, {user.first_name}!", html=html, text=text)

Option B: Server-side template variables (recommended)

Store templates in your email platform and pass variables at send time. Editable without a deploy, with a preview UI:

import requests
import os

def send_templated_email(to: str, template: str, variables: dict):
    response = requests.post(
        "https://api.tinysend.co/v1/emails",
        headers={"Authorization": f"Bearer {os.environ['TINYSEND_API_KEY']}"},
        json={
            "from": os.environ["EMAIL_FROM"],
            "to": to,
            "template": template,
            "variables": variables,
        },
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

# Usage — template "trial-ending" stored in tinysend dashboard
send_templated_email(
    to=user.email,
    template="trial-ending",
    variables={
        "first_name": user.first_name,
        "days_left": 3,
        "upgrade_url": f"https://app.example.com/upgrade?plan=pro&uid={user.id}",
        "current_usage": f"{user.emails_sent_this_month:,} emails",
    },
)

5. Django and FastAPI integration patterns

Django

Django's email backend system means you can keep using send_mail() in your views while routing through any SMTP server. For REST API sends, bypass the backend entirely:

# settings.py — for SMTP-based providers
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.tinysend.co"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = os.environ.get("SMTP_USER")
EMAIL_HOST_PASSWORD = os.environ.get("SMTP_PASSWORD")

# For REST API sends from views (recommended for transactional email)
# services/email.py
import requests
from django.conf import settings

def send_transactional(to: str, template: str, variables: dict):
    requests.post(
        "https://api.tinysend.co/v1/emails",
        headers={"Authorization": f"Bearer {settings.TINYSEND_API_KEY}"},
        json={
            "from": settings.DEFAULT_FROM_EMAIL,
            "to": to,
            "template": template,
            "variables": variables,
        },
        timeout=10,
    ).raise_for_status()

# In your views.py or signals.py
def user_registered_signal(sender, user, **kwargs):
    send_transactional(
        to=user.email,
        template="welcome",
        variables={"first_name": user.first_name},
    )

FastAPI / async Python

Use httpx for async HTTP requests — it's a drop-in replacement for requests with full async support:

# pip install httpx
import httpx
import os
from contextlib import asynccontextmanager

# Share an async client across requests (connection pooling)
_client: httpx.AsyncClient | None = None

@asynccontextmanager
async def lifespan(app):
    global _client
    _client = httpx.AsyncClient(
        base_url="https://api.tinysend.co",
        headers={"Authorization": f"Bearer {os.environ['TINYSEND_API_KEY']}"},
        timeout=10.0,
    )
    yield
    await _client.aclose()

async def send_email(to: str, subject: str, html: str, text: str):
    response = await _client.post("/v1/emails", json={
        "from": os.environ["EMAIL_FROM"],
        "to": to,
        "subject": subject,
        "html": html,
        "text": text,
    })
    response.raise_for_status()
    return response.json()

# In a FastAPI route
@app.post("/auth/register")
async def register(data: RegisterRequest, background_tasks: BackgroundTasks):
    user = await create_user(data)

    # Don't block the response — send in background
    background_tasks.add_task(
        send_email,
        to=user.email,
        subject=f"Confirm your email, {user.first_name}",
        html=f"<p>Click <a href='{confirm_url}'>here</a> to confirm.</p>",
        text=f"Confirm your email: {confirm_url}",
    )
    return {"id": str(user.id)}

6. Error handling and retries

HTTP errors from email APIs fall into two categories: validation errors (your payload is wrong — don't retry) and transient errors (5xx — safe to retry with backoff):

import requests
import time
import os
from dataclasses import dataclass

@dataclass
class EmailError(Exception):
    message: str
    status_code: int
    code: str | None = None
    retryable: bool = False

def send_email_with_retry(to: str, subject: str, html: str, text: str, max_retries: int = 2):
    last_error = None

    for attempt in range(max_retries + 1):
        try:
            response = requests.post(
                "https://api.tinysend.co/v1/emails",
                headers={
                    "Authorization": f"Bearer {os.environ['TINYSEND_API_KEY']}",
                    "Content-Type": "application/json",
                },
                json={"from": os.environ["EMAIL_FROM"], "to": to, "subject": subject, "html": html, "text": text},
                timeout=10,
            )

            if response.status_code == 422:
                # Validation error — fix the payload, don't retry
                data = response.json()
                raise EmailError(data.get("message", "Validation failed"), 422, data.get("code"), retryable=False)

            response.raise_for_status()
            return response.json()

        except EmailError:
            raise  # Never retry validation errors

        except requests.exceptions.Timeout:
            last_error = EmailError("Request timed out", 0, retryable=True)

        except requests.exceptions.HTTPError as e:
            last_error = EmailError(str(e), e.response.status_code, retryable=True)

        if attempt < max_retries:
            time.sleep(2 ** attempt)  # Exponential backoff: 1s, 2s

    raise last_error

For high-volume apps, move email sends into a task queue (Celery, RQ, or a managed queue like Inngest). This gives you full retry visibility, dead-letter queues for persistently failing sends, and keeps email failures from breaking your request/response cycle.

7. Deliverability checklist before you launch

SPF, DKIM, and DMARC are configured
Without these DNS records, Gmail and Outlook will reject or spam-folder your email. See the complete DKIM/SPF/DMARC guide.
TINYSEND_API_KEY is in environment variables, not source code
Run git log -S TINYSEND_API_KEY before your first push. A leaked API key will have your account sending spam within hours.
Bounce webhook handler is live
Hard bounces must be suppressed immediately. An unhandled bounce rate above 2% will get your sending account suspended across virtually all providers.
Always include a plain-text body
HTML-only emails score worse in spam filters. Always send a text field alongside your HTML — strip the tags and make it readable.
One-click unsubscribe is implemented
Required by Gmail and Yahoo for bulk senders since February 2024. tinysend adds the List-Unsubscribe header automatically for non-transactional sends.

SMTP vs REST API for Python

The key reason to prefer REST over smtplib in production: SMTP connections are stateful and persistent — in serverless environments (Lambda, Cloud Run) every invocation re-establishes the connection, adding 100–400ms latency. A REST API call with HTTP/2 connection reuse is consistently faster. See our detailed SMTP vs Email API comparison.

Send your first email with Python in 2 minutes

tinysend gives you a clean REST API, templates with variables, bounce handling, and BYOK — so you can use your existing SES or Postmark account as the backend. Free to start.

Get your API key →