Developer Tutorial
Covers smtplib for local scripts, the requests library for REST API sends, and what production apps actually need.
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.
Python gives you two fundamentally different paths. Understanding the tradeoffs upfront saves you from migrating later.
Connects directly to an SMTP server. No third-party dependencies. Works great for scripts, internal tools, and dev environments.
POST JSON to an email API endpoint. One API key, webhook delivery events, templates, and sequences included.
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.
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:
with block is essential — it calls quit() cleanly. Not using it leaves dangling connections.smtplib.SMTP_SSL for that.MIMEMultipart("alternative"). The order matters — clients render the last matching part. 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.
Building HTML email strings by hand scales poorly. Two patterns that actually work in Python:
# 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)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",
},
) 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},
) 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)}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_errorFor 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.
git log -S TINYSEND_API_KEY before your first push. A leaked API key will have your account sending spam within hours.text field alongside your HTML — strip the tags and make it readable.List-Unsubscribe header automatically for non-transactional sends.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.
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 →