# How to send email from Python: requests and smtplib examples

> Two working ways to send email from Python on your own domain, an HTTP API with requests and SMTP with smtplib, plus timeouts, errors and safe retries.

Published 2026-10-21. Source: https://mailions.com/blog/send-email-python

Python gives you two sensible ways to send email from your own domain. One is an HTTPS request with the `requests` library. The other is the standard library's `smtplib`. Both work, and each has a situation where it's the better pick. Both examples below include the error handling most tutorials leave out.

## Before you write code

You need a verified domain, because unauthenticated mail ends up in spam however well the code is written. Publish SPF, DKIM and DMARC first. [What is an SPF record](/blog/what-is-spf-record) and [what is DKIM](/blog/what-is-dkim) explain them, and [a quick check](/blog/check-spf-dkim-dmarc) confirms they work.

Then pick the credential type:

- For the HTTP API, create an API key for the domain.
- For SMTP, create a mailbox such as `noreply@yourdomain.com` and use its address and password.

Keep either one in an environment variable. Never commit it.

## Option 1: the HTTP API with requests

Install the library with `pip install requests`, then:

```python
import os
import requests

API_URL = os.environ["MAIL_API_URL"] + "/api/v1/emails"


class MailError(Exception):
    pass


def send_email(to, subject, text, html=None, idempotency_key=None):
    headers = {"Authorization": f"Bearer {os.environ['MAIL_API_KEY']}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    payload = {
        "from": "Your App <noreply@yourdomain.com>",
        "to": to,
        "subject": subject,
        "text": text,
    }
    if html:
        payload["html"] = html

    r = requests.post(API_URL, headers=headers, json=payload, timeout=15)
    data = r.json() if r.content else {}
    if not r.ok:
        err = data.get("error", {})
        raise MailError(f"{err.get('code', r.status_code)}: {err.get('message', 'send failed')}")
    return data  # {"id": ..., "message_id": ..., "status": ...}
```

Two details matter here. The `timeout=15` stops a stalled network from freezing a web worker; `requests` has no default timeout and will wait forever. And the check on `r.ok` makes failures loud rather than silent.

Call it like this:

```python
send_email("ada@example.com", "Welcome aboard", "Thanks for signing up.",
           html="<p>Thanks for signing up.</p>", idempotency_key=f"welcome/{user.id}")
```

### What the response status means

A `200` with `status: accepted` means the mail server took the message, not that the recipient's inbox did. A `202` with `status: queued` means the mail server was unavailable, so the message is stored and will be sent automatically for up to 24 hours. Don't send it again. Retrying a queued message creates a duplicate, which is the thing an idempotency key exists to prevent. Reuse the same key and body after a timeout, and no second copy is sent.

Errors worth handling specifically: `rate_limited` (wait a minute), `monthly_limit_reached` (the allowance is used up) and `plan_required` (no active trial or paid plan). The [API guide](/help/sending-api) lists them all.

## Option 2: SMTP with smtplib

SMTP needs no extra package and works with any mail server. It also fits frameworks that expect SMTP settings, such as Django's `EMAIL_*` configuration.

```python
import os
import smtplib
from email.message import EmailMessage

def send_smtp(to, subject, text):
    msg = EmailMessage()
    msg["From"] = "Your App <noreply@yourdomain.com>"
    msg["To"] = to
    msg["Subject"] = subject
    msg.set_content(text)

    with smtplib.SMTP("mail.example.com", 587, timeout=15) as smtp:
        smtp.starttls()
        smtp.login("noreply@yourdomain.com", os.environ["SMTP_PASSWORD"])
        smtp.send_message(msg)
```

Port 587 with `starttls()` is the standard submission setup, and [SMTP ports explained](/blog/smtp-ports-explained) covers why. Replace `mail.example.com` with the host name your provider gives you.

If sending from a server fails with a connection timeout, the hosting company may block outgoing SMTP. That is the main reason to prefer the HTTP API for deployed apps.

## Retrying without sending duplicates

Network calls fail. The question is which failures are safe to retry. Retry when the request never reached the server (a connection error or timeout) and when the API returns `rate_limited`. Do not retry a `202 queued`, which already succeeded, or a `4xx` error caused by bad input, which will fail the same way every time.

```python
import time

def send_with_retry(*args, attempts=3, **kwargs):
    for attempt in range(attempts):
        try:
            return send_email(*args, **kwargs)
        except requests.RequestException:
            if attempt == attempts - 1:
                raise
            time.sleep(2 ** attempt)
```

This is only safe if you pass an `idempotency_key`, so a request that actually succeeded before the timeout can't create a second email when it's repeated. Use a key that identifies the email, such as `reset/` plus the reset request ID, never a random value generated per attempt.

## Sending in the background

A web request shouldn't wait on email unless the page depends on it. For non-urgent messages, push the job to a worker such as Celery or RQ, or even a simple database table polled by a script, and let the worker call `send_email`. Keep the job small: store the recipient and a template name, not a rendered message, and render it when the worker runs.

## Django and Flask

Django: set `EMAIL_HOST`, `EMAIL_PORT = 587`, `EMAIL_USE_TLS = True`, `EMAIL_HOST_USER` and `EMAIL_HOST_PASSWORD`, then use `send_mail()` as usual. It sends over SMTP.

Flask: use `smtplib` as above, or call the HTTP API from a small helper function. Either way, send from a background worker rather than inside the request when the email isn't needed for the page to render.

## Choosing between them

| | HTTP API | SMTP |
| --- | --- | --- |
| Works when SMTP ports are blocked | Yes | No |
| Clear status in the response | Yes | Limited |
| Idempotency keys | Yes | No |
| Works with frameworks expecting SMTP | No | Yes |
| Extra dependency | `requests` | None |

## Test safely

While you build, send to a [temporary inbox](/temp) instead of real addresses. Pick any name, send, and watch the message appear in the browser. Free inboxes delete mail after 30 minutes and anyone who knows the address can read it, so use dummy content.

## Next steps

If you also write Node.js, see [sending email from Node.js](/blog/send-email-nodejs). To send from your own domain with Mailions, [contact us](/contact) and we'll set up a trial.

## Frequently asked questions

### How do I send an email with Python?

Use the `requests` library to call an email API with a JSON body and an API key, or the built-in `smtplib` with `EmailMessage` over SMTP on port 587 with `starttls()`. Both work; an API gives clearer error responses.

### Why does smtplib fail to connect?

Usually the wrong port or encryption: use port 587 with `starttls()`, or port 465 with `SMTP_SSL`. Many cloud servers also block outgoing SMTP ports, and an HTTPS email API avoids that problem.

### How do I send email in Django or Flask?

Django has built-in email settings and `send_mail()`. Flask uses an extension such as Flask-Mail, or you call an email API directly. In both, send from a background task so pages stay fast.
