All posts
4 min readPigeonify

How to send email with Python

Send transactional email from Python with requests or smtplib — including the error handling, retries and Django integration you need in production.

pythonapismtptutorial

Python's standard library has smtplib, which is why so many Python applications send mail badly: it works immediately, so nobody looks further until messages start landing in spam.

Before you start

You need three things, and they take about five minutes:

  1. An account. Sign up free — 5,000 emails a month, no card.
  2. A verified sending domain. Add your domain in the dashboard and publish the DKIM and SPF records it gives you. You cannot send from a domain you have not proved you control, and neither can anyone pretending to be you — that is the whole point. If the terms are unfamiliar, SPF, DKIM and DMARC explained covers them properly.
  3. An API key. Create one under API Keys. Treat it like a password: it can send mail as you.

Put the key in your environment rather than your source. Python reads it from there in every example below.

With the REST API

No dependencies beyond requests:

import os
import requests

response = requests.post(
    "https://www.pigeonify.com/api/v1/send",
    headers={"X-API-Key": os.environ["PIGEONIFY_API_KEY"]},
    json={
        "from": "you@yourdomain.com",
        "to": "customer@example.com",
        "subject": "Welcome aboard",
        "html": "<p>Thanks for signing up.</p>",
    },
    timeout=10,
)

response.raise_for_status()
print(response.json()["id"])

Always pass a timeout. requests has no default one, so a hung connection hangs your worker indefinitely. This is the most common production bug in Python code that calls any HTTP API.

Retrying properly

Retry what is transient and nothing else:

import time
import requests

RETRYABLE = {429, 500, 502, 503, 504}

def send_email(payload, attempts=3):
    for attempt in range(attempts):
        try:
            response = requests.post(
                "https://www.pigeonify.com/api/v1/send",
                headers={"X-API-Key": os.environ["PIGEONIFY_API_KEY"]},
                json=payload,
                timeout=10,
            )
        except requests.RequestException:
            if attempt == attempts - 1:
                raise
            time.sleep(2 ** attempt)
            continue

        if response.status_code not in RETRYABLE:
            response.raise_for_status()   # 4xx raises; the address is wrong
            return response.json()

        # Honour the provider's own backoff when it sends one.
        wait = int(response.headers.get("Retry-After", 2 ** attempt))
        time.sleep(wait)

    response.raise_for_status()

A 4xx means the request was wrong — bad address, missing field, unverified domain. Retrying it produces the same answer and counts against you.

With smtplib

import os
import smtplib
from email.message import EmailMessage

message = EmailMessage()
message["From"] = "you@yourdomain.com"
message["To"] = "customer@example.com"
message["Subject"] = "Welcome aboard"
message.set_content("Thanks for signing up.")
message.add_alternative("<p>Thanks for signing up.</p>", subtype="html")

with smtplib.SMTP("smtp.pigeonify.com", 587) as smtp:
    smtp.starttls()
    smtp.login(os.environ["PIGEONIFY_SMTP_USER"], os.environ["PIGEONIFY_SMTP_PASS"])
    smtp.send_message(message)

EmailMessage with both a plain-text and an HTML part is worth the extra line. Some clients prefer plain text, some spam filters treat HTML-only mail with suspicion, and a text fallback costs nothing.

In Django

Point Django's existing mail backend at the SMTP relay and every send_mail call in your codebase starts using it:

# settings.py
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.pigeonify.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = os.environ["PIGEONIFY_SMTP_USER"]
EMAIL_HOST_PASSWORD = os.environ["PIGEONIFY_SMTP_PASS"]
DEFAULT_FROM_EMAIL = "you@yourdomain.com"

Django sends synchronously inside the request by default. Move it to Celery or django-q before it matters:

from celery import shared_task
from django.core.mail import EmailMultiAlternatives

@shared_task(bind=True, max_retries=3, retry_backoff=True)
def send_welcome_email(self, email, name):
    message = EmailMultiAlternatives(
        subject="Welcome aboard",
        body=f"Thanks for signing up, {name}.",
        to=[email],
    )
    message.attach_alternative(f"<p>Thanks for signing up, {name}.</p>", "text/html")
    message.send()

What to get right before production

Do not send from a request handler and wait. An HTTP call to any email provider can take a second or more, and a slow provider becomes a slow checkout page. Queue the send and return.

Handle the failure. A send can fail because the address is wrong, because you are rate limited, or because the provider is having a bad minute. The first is permanent and the other two are not — retrying a dead address forever is how a sender's reputation degrades. See how to handle bounces.

Use a subdomain. Send transactional mail from mail.yourdomain.com rather than the domain that carries your business correspondence. If sending reputation is ever damaged, the damage stays contained.

Never send to an address that has bounced or unsubscribed. Suppression should be automatic, not a thing you remember. Pigeonify suppresses permanent bounces and complaints for you and refuses to send to them afterwards.

Where to go next

If you are weighing providers, we publish honest comparisons that say where the other product is better.

Send email that actually arrives

Authentication, suppression and bounce handling built in. Start free, no card required.

Start free