PingMyUsers

How to send email with Python through an email API (2026)

Editorial team · updated · facts checked

To send email with Python, use the built-in smtplib with an SMTP server, or call an email provider's API: verify your sending domain, keep the API key in an environment variable and make one HTTPS call with the provider's SDK or requests. Send plain text and HTML, retry only when the provider deduplicates, and read bounces from a signed webhook.

We read every page cited here, and the source code of six SDK packages from PyPI, on 24 September 2026. The editorial team did not create accounts, run the code or send email, so every snippet is illustrative.

smtplib or an email API?

The standard library's smtplib and email.message.EmailMessage talk to any SMTP server, including the relays email providers run:

Illustrative code, written from the official documentation and not run by the editorial team.

# send_smtp.py (standard library only)
import os
import smtplib
import ssl
from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = "Acme <receipts@notify.example.com>"
msg["To"] = "customer@example.com"
msg["Subject"] = "Receipt for order 1042"
msg.set_content("Thanks for your order. Total: $42.00")              # text/plain part
msg.add_alternative("<p>Thanks for your order. Total: <b>$42.00</b></p>", subtype="html")

with smtplib.SMTP(os.environ["SMTP_HOST"], 587, timeout=20) as smtp:
    smtp.starttls(context=ssl.create_default_context())
    smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
    smtp.send_message(msg)

An HTTPS API adds what SMTP lacks: a message ID in the response, errors your code can act on, and at some providers an idempotency key. It also works on hosts that block SMTP ports, as our email API vs SMTP relay explainer shows platform by platform. Flask-Mail and FastAPI-Mail (built on aiosmtplib) are SMTP wrappers too.

Sending through a personal Gmail account needs an app password, which Google offers only with 2-Step Verification on, not for work or school accounts, and revokes when you change your Google password. Gmail Help warns of a sending limit after more than 500 emails in a day. The rest of this tutorial uses a provider and your own domain.

Step 1. Pick a provider and verify a domain

Every email-first provider in our dataset publishes a Python package; the multichannel providers that also send email (Bird, Infobip, Telnyx, Zenvia) are compared in the transactional email API rankings. Package data is from PyPI; free options are from our provider files, re-read on each pricing page on 24 September 2026.

ProviderPyPI packageLatest releasePythonFree option
Amazon SESboto31.43.101, 23 Sep 20263.10+No always-free quota; up to $200 in AWS credits for new accounts
Brevobrevo-python5.0.2, 10 Aug 20263.8+300 emails a day
Mailgunmailgun1.9.1, 21 Sep 20263.11+100 emails a day
Mailjetmailjet-rest1.9.0, 14 Sep 20263.10+6,000 a month, 200 a day
Mailtrapmailtrap2.10.0, 31 Aug 20263.9+4,000 a month, 150 a day
Postmarkpostmark-python0.4.0, 12 Aug 20263.10+100 a month
Resendresend2.47.0, 18 Sep 20263.7+3,000 a month, 100 a day
SendGridsendgrid6.12.5, 19 Sep 20253.10+ (quickstart)60-day trial, 100 a day
SendPulsepysendpulse0.1.8, 11 Feb 2025not stated12,000 a month, 400 a day
SMTP2GOsmtp2go2.3.1, 29 Jul 2022not stated1,000 a month, 200 a day

One naming trap: pip install postmark installs an unrelated third-party package last released in 2017; Postmark's SDK is postmark-python, imported as postmark, and its README calls it Beta. Three packages have had no PyPI release in the past year (SendGrid, SendPulse, SMTP2GO), so check the repository before you depend on one. The catches behind each free tier are in our free email API tiers comparison.

Before a provider sends to other people, verify your domain and publish the SPF and DKIM records it gives you; our transactional email guide covers SPF, DKIM, DMARC and the Gmail and Yahoo rules. Until then, sending is fenced: Resend accepts sends from its test domain only to your own address, and a new SES account sits in a per-Region sandbox that sends only to verified addresses, at most 200 messages per 24 hours.

Step 2. Keep the API key out of your code

Read the key from an environment variable on the server, never from source code, a browser bundle or a shared notebook. Locally, a .env file loaded with python-dotenv works; keep it out of Git.

Illustrative code, written from the official documentation and not run by the editorial team.

# .env (server only; add it to .gitignore)
RESEND_API_KEY=re_...
RESEND_WEBHOOK_SECRET=whsec_...
POSTMARK_SERVER_TOKEN=...
# boto3 reads AWS credentials from the environment or an IAM role; no key in code

Give each key the narrowest scope offered. Resend's sending_access keys can only send and can be tied to one domain. SendGrid's Custom Access keys can be limited to Mail Send, and the console shows a key only once.

Step 3. Send with a provider SDK

The SDK examples use Resend and Amazon SES for different reasons: Resend's Emails.send() accepts an idempotency key, which Step 5 relies on, and boto3, the AWS SDK that AI search engines looked up by name for this task, authenticates with IAM instead of an API key.

Illustrative code, written from the official documentation and not run by the editorial team.

# send_email.py (pip install resend==2.47.0), per Resend's Python guide
import os
import resend

resend.api_key = os.environ["RESEND_API_KEY"]

def send_receipt(order_id: str, to: str, text: str, html: str) -> str:
    params: resend.Emails.SendParams = {
        "from": "Acme <receipts@notify.example.com>",
        "to": [to],
        "subject": f"Receipt for order {order_id}",
        "text": text,
        "html": html,
    }
    options: resend.Emails.SendOptions = {"idempotency_key": f"receipt/{order_id}"}
    return resend.Emails.send(params, options)["id"]  # raises resend.exceptions.ResendError

In the 2.47.0 source, ResendError covers both API errors and network failures, including the SDK's 30-second timeout. Test sends to delivered@resend.dev or bounced@resend.dev simulate each outcome and count against your quota.

Illustrative code, written from the official documentation and not run by the editorial team.

# send_ses.py (pip install boto3==1.43.101)
import boto3
from botocore.config import Config

ses = boto3.client(
    "sesv2",
    region_name="us-east-1",
    config=Config(retries={"max_attempts": 1}),  # SendEmail has no idempotency token; see Step 5
)

resp = ses.send_email(
    FromEmailAddress="Acme <receipts@notify.example.com>",
    Destination={"ToAddresses": ["customer@example.com"]},
    Content={"Simple": {
        "Subject": {"Data": "Receipt for order 1042", "Charset": "UTF-8"},
        "Body": {
            "Text": {"Data": "Thanks for your order. Total: $42.00", "Charset": "UTF-8"},
            "Html": {"Data": "<p>Thanks for your order. Total: <b>$42.00</b></p>", "Charset": "UTF-8"},
        },
    }},
)
print(resp["MessageId"])

The SES mailbox simulator (success@, bounce@, complaint@simulator.amazonses.com) works from the sandbox and does not count toward your quota, though AWS bills those sends.

Step 4. Send from Python without an SDK, using requests

Every provider in Step 1 documents an HTTPS endpoint; SES requests must be signed with AWS credentials, which boto3 does for you. This example calls Postmark because its API takes a flat JSON body with one auth header, while its official SDK is at 0.4.0 and labeled Beta.

Illustrative code, written from the official documentation and not run by the editorial team.

# send_postmark.py (pip install requests==2.34.2)
import os
import requests

resp = requests.post(
    "https://api.postmarkapp.com/email",
    headers={"Accept": "application/json", "X-Postmark-Server-Token": os.environ["POSTMARK_SERVER_TOKEN"]},
    json={
        "From": "Acme <receipts@notify.example.com>",
        "To": "customer@example.com",
        "Subject": "Receipt for order 1042",
        "TextBody": "Thanks for your order. Total: $42.00",
        "HtmlBody": "<p>Thanks for your order. Total: <b>$42.00</b></p>",
        "MessageStream": "outbound",
    },
    timeout=(3.05, 15),  # requests never times out unless you pass a timeout
)
body = resp.json()
if resp.status_code != 200 or body.get("ErrorCode") != 0:
    raise RuntimeError(f"Postmark error {body.get('ErrorCode')}: {body.get('Message')}")
print(body["MessageID"])

Postmark answers 422 with a numeric ErrorCode when it refuses a message, for example 406 for an inactive recipient. The token POSTMARK_API_TEST validates a request without delivering it.

Plain text plus HTML. Every example sends both parts as one multipart/alternative message, so a client that cannot show HTML falls back to text (RFC 2046). Field names differ: text/html (Resend, Mailgun), Body.Text/Body.Html (SES), TextBody/HtmlBody (Postmark), text_content/html_content (Brevo SDK), plain_text_content/html_content (SendGrid's Mail helper).

Step 5. Retry without sending the email twice

The risky moment is a timeout after your request left the server: the provider may already have accepted the email, and a blind retry sends it twice unless the provider deduplicates. Four of these six SDKs retry sends on their own by default.

PackageRetries a send by defaultDefault timeoutIdempotency key on the send call
boto3 1.43.101 (SES v2)Yes: legacy mode, up to 5 attempts, including after read timeouts60 sNone
brevo-python 5.0.2Yes: 2 retries on 408, 429 and 5xx60 sidempotencyKey, a UUID, kept 30 minutes
mailgun 1.9.1Yes: 3 retries on 429, 500–504 and network errors60 sNone in the API reference
postmark-python 0.4.0Yes: 3 retries on 429, 500, 503 and timeouts5 sNone documented
resend 2.47.0No30 sidempotency_key, kept 24 hours
sendgrid 6.12.5NoNone setNone documented
requests 2.34.2NoNoneDepends on the endpoint

Source files behind each row: botocore configprovider.py, endpoint.py; brevo client.py and README; mailgun config.py, client.py, endpoints.py; postmark server_client.py, exceptions.py; resend http_client_requests.py, _emails.py; sendgrid base_interface.py and python-http-client client.py; the Requests docs.

Two vendors contradict themselves. Brevo's idempotency page wants idempotencyKey, a UUID, in the request's headers object, while its SDK docstring shows an Idempotency-Key header with a non-UUID value; follow the API page. Mailgun's SDK README promises exactly-once delivery:

Mailgun Python SDK README, section Exactly-Once Delivery and Retry Policies: a RetryPolicy with 3 retries, an Idempotency-Key header built from uuid4, and a comment saying IdempotencyGuard ensures retries won't result in duplicate emails
Mailgun's Python SDK README on GitHub, section "Exactly-Once Delivery & Retry Policies", captured by the editorial team on 24 September 2026.

The send endpoint's reference documents no idempotency key, and in the SDK source IdempotencyGuard writes an h:X-Idempotency-Key field, which Mailgun's API treats as a custom email header. We found no Mailgun statement that its servers drop repeats, so treat a retried Mailgun send as a possible duplicate.

What to do:

  1. Where a key exists, pass one derived from the business event: receipt/{order_id} at Resend; for Brevo, uuid.uuid5(uuid.NAMESPACE_URL, f"receipt/{order_id}") turns the same string into a stable UUID.
  2. Elsewhere, switch automatic retries off for sends: max_attempts: 1 in boto3, retries=0 on postmark-python's ServerClient, RetryPolicy(max_retries=0) in mailgun.
  3. Keep a sent marker: insert a row keyed on the event before calling, store the returned message ID, and after a timeout check the provider's logs before sending again. At Resend, wait out a 429 using its retry-after header (default limit: 10 requests per second per team).

Step 6. Send email from Flask or FastAPI

In Flask, call the send function inside the route and load the recipient from your database; an endpoint that takes the address or HTML from the request can be abused to send spam in your name.

Illustrative code, written from the official documentation and not run by the editorial team.

# app.py (pip install flask==3.1.3)
from flask import Flask, jsonify
from resend.exceptions import ResendError
from send_email import send_receipt

app = Flask(__name__)

@app.post("/orders/<order_id>/receipt")
def email_receipt(order_id):
    order = load_order(order_id)  # your code; the address comes from your database
    try:
        email_id = send_receipt(order.id, order.email, render_text(order), render_html(order))
    except ResendError as e:
        app.logger.warning("receipt %s not sent: %s", order.id, e.code)  # no address or body in logs
        return jsonify(error="email_failed"), 502
    return jsonify(email_id=email_id), 202

FastAPI's Background Tasks run after the response is returned, and the docs use email notifications as their example. They run in the app's own process; for work spread over several processes or servers, the docs point to Celery.

Illustrative code, written from the official documentation and not run by the editorial team.

# main.py (pip install fastapi==0.141.1)
from fastapi import BackgroundTasks, FastAPI
from send_email import send_receipt

app = FastAPI()

def send_receipt_task(order_id: str) -> None:
    order = load_order(order_id)
    send_receipt(order.id, order.email, render_text(order), render_html(order))

@app.post("/orders/{order_id}/receipt", status_code=202)
def email_receipt(order_id: str, background_tasks: BackgroundTasks):
    background_tasks.add_task(send_receipt_task, order_id)
    return {"queued": True}

For async code, Resend (pip install "resend[async]", then await resend.Emails.send_async(...)), Mailgun (AsyncClient), Brevo (AsyncBrevo) and Postmark (async-first, with a sync wrapper since 0.3.0) ship async clients. In Django 6.1 (released 5 August 2026), send_mail() takes its backend from the new MAILERS setting, and EMAIL_BACKEND is deprecated; django-anymail 15.2 provides API backends for 14 providers, and postmark-python ships its own.

Step 7. Handle bounces with a signed webhook

A successful send call means the provider accepted the email, not that it arrived. Bounces and complaints arrive later as webhooks at a public URL, so verify each request. Resend signs with Svix headers; its Python SDK checks them, rejects timestamps more than 5 minutes off and needs the raw body.

Illustrative code, written from the official documentation and not run by the editorial team.

# add to app.py
import os
import resend
from flask import request

@app.post("/webhooks/resend")
def resend_webhook():
    try:
        event = resend.Webhooks.verify({
            "payload": request.get_data(as_text=True),  # raw body; re-serialized JSON fails the check
            "headers": {
                "id": request.headers.get("svix-id", ""),
                "timestamp": request.headers.get("svix-timestamp", ""),
                "signature": request.headers.get("svix-signature", ""),
            },
            "webhook_secret": os.environ["RESEND_WEBHOOK_SECRET"],
        })
    except ValueError:
        return "", 400
    if seen_before(request.headers["svix-id"]):  # the same event can arrive again
        return "", 200
    if event["type"] in ("email.bounced", "email.complained"):
        suppress_addresses(event["data"]["to"])  # your suppression table
    return "", 200

Resend retries a failed delivery for about 27 hours (our sum of its schedule) and says to deduplicate on svix-id. Elsewhere: SES can publish bounces to Amazon SNS, whose message signature you verify; SendGrid's Signed Event Webhook is opt-in ECDSA, with an EventWebhook helper in sendgrid; Mailgun signs timestamp plus token with HMAC-SHA256; Postmark does not sign webhooks and recommends HTTP Basic auth plus an IP allowlist. Each provider card shows whether its webhooks are signed. After a permanent bounce or a complaint, stop mailing that address; a suppression table in your own database survives a provider change.

Common errors

SymptomCauseFix
import postmark has no ServerClientpip install postmark fetched the unrelated 2017 packagepip uninstall postmark, then pip install postmark-python
Resend 403 validation_error: testing emails only to your own addressSending from Resend's test domainVerify a domain and use it in from
SES MessageRejected while testingOften the sandbox: recipient not verifiedVerify the recipient, use the simulator, or request production access
Mailgun: no exception, no emailmessages.create() returns the HTTP response even for 4xxCheck response.status_code
Postmark HTTP 401, ErrorCode 10Missing token or the Account token on a Server endpointSend the Server token
Resend 409 invalid_idempotent_requestSame key, different payload within 24 hoursNew key only when the content really changes
Resend 429 daily_quota_exceededFree plan's 100-a-day capWait for the UTC day to reset, or upgrade
smtplib.SMTPAuthenticationError on GmailAccount password used, or no 2-Step VerificationApp password, or a provider relay
Worker hangs on a sendrequests or sendgrid with no timeoutPass a timeout

About this guide

The PingMyUsers editorial team wrote this tutorial for Python developers adding transactional email to a script, a Flask or FastAPI app, or Django; Sensaria AG in Switzerland operates the directory. Providers do not pay for placement, and none reviewed this page. To report an error, email contact@sensaria.ch with the page and the source that shows the correct fact.

Methodology

On 24 September 2026 we read the Python, Requests, FastAPI and Django docs, each provider's send, error and webhook pages, and Google's help pages. Package data comes from the PyPI JSON API; the retry table comes from reading each package's source, not from running it. Scoring rules are on the methodology page. Resend, Amazon SES and Postmark get full examples for the reasons given in Steps 3 and 4, not as recommendations.

Last updated

24 September 2026: first version; all package versions, free options and SDK behavior checked that day. Next scheduled re-check: March 2027, or sooner when a package in the Step 5 table ships a major version.

Frequently asked questions

How do I send an email with Python?

Build an EmailMessage and send it with smtplib through an SMTP server, or call an email provider's API with its SDK or requests.

Can I send email from Python without SMTP?

Yes. Every provider in Step 1 has an HTTPS API; most take an API key in a header, while Amazon SES uses signed AWS requests through boto3. It works on hosts that block SMTP ports.

Can I send email with Python for free?

Yes, within a provider's free tier. By monthly volume, the largest in Step 1 are SendPulse (12,000 a month), Brevo (300 a day, about 9,000 over 30 days) and Mailjet (6,000 a month). Amazon SES gives credits to new AWS accounts, not a free quota.

How do I send email from Flask or FastAPI?

Call a send function from the route. In FastAPI, BackgroundTasks sends after the response; for work across several processes or servers, FastAPI's docs point to Celery.

Can I send email from Python with Gmail?

Yes, over SMTP with an app password, which requires 2-Step Verification and is unavailable for work or school accounts. Gmail Help warns of a sending limit after 500 emails in a day.

How do I stop duplicate emails when a send is retried?

Pass an idempotency key where the provider supports one (Resend, Brevo) and switch off SDK auto-retries elsewhere. Record a sent marker keyed on the event before calling.

Sources

  1. Python 3.14.7 docs — smtplib — checked 24 September 2026
  2. Python 3.14.7 docs — email: Examples (set_content, add_alternative) — checked 24 September 2026
  3. RFC 2046 §5.1.4 — multipart/alternative — checked 24 September 2026
  4. Google Account Help — Sign in with app passwords — checked 24 September 2026
  5. Gmail Help — Limits for sending & getting mail — checked 24 September 2026
  6. Requests — Advanced usage (timeouts, retries) — checked 24 September 2026
  7. FastAPI — Background Tasks — checked 24 September 2026
  8. PyPI — flask-mail 0.10.0 — checked 24 September 2026
  9. PyPI — fastapi-mail 1.6.8 (depends on aiosmtplib) — checked 24 September 2026
  10. PyPI — django-anymail 15.2 — checked 24 September 2026
  11. Django 6.1 docs — Sending email (MAILERS; EMAIL_BACKEND deprecated) — checked 24 September 2026
  12. PyPI — python-dotenv 1.2.3 — checked 24 September 2026
  13. Resend — Send emails with Python — checked 24 September 2026
  14. Resend — Idempotency keys — checked 24 September 2026
  15. Resend — Errors — checked 24 September 2026
  16. Resend — Usage limits — checked 24 September 2026
  17. Resend — Account quotas and limits (UTC daily reset) — checked 24 September 2026
  18. Resend — pricing — checked 24 September 2026
  19. Resend — Create API key (sending_access, domain_id) — checked 24 September 2026
  20. Resend — Send test emails — checked 24 September 2026
  21. Resend — Managing webhooks (svix-id for duplicates) — checked 24 September 2026
  22. Resend — Verify webhook requests — checked 24 September 2026
  23. Resend — Webhook event types — checked 24 September 2026
  24. Resend — Webhook retries and replays — checked 24 September 2026
  25. PyPI — resend 2.47.0 (SDK source: timeout, errors, Webhooks.verify) — checked 24 September 2026
  26. Boto3 — SESV2 send_email — checked 24 September 2026
  27. Boto3 — Retries (legacy mode default, 5 attempts) — checked 24 September 2026
  28. PyPI — boto3 1.43.101 (botocore 1.43.101 source: 60-second default timeouts) — checked 24 September 2026
  29. Amazon SES — Moving out of the sandbox — checked 24 September 2026
  30. Amazon SES — Mailbox simulator — checked 24 September 2026
  31. Amazon SES — pricing (Free Tier credits) — checked 24 September 2026
  32. Postmark — Email API (POST /email) — checked 24 September 2026
  33. Postmark — API overview (error codes, POSTMARK_API_TEST) — checked 24 September 2026
  34. Postmark — Official libraries — checked 24 September 2026
  35. PyPI — postmark-python 0.4.0 (README and SDK source: retries, 5-second timeout) — checked 24 September 2026
  36. PyPI — postmark 1.0.0 (unrelated third-party package, 2017) — checked 24 September 2026
  37. Postmark — pricing — checked 24 September 2026
  38. Postmark — Webhooks overview (not signed; Basic auth, IP allowlist) — checked 24 September 2026
  39. Mailgun — Send an email (POST /v3/{domain_name}/messages) — checked 24 September 2026
  40. Mailgun — Securing webhooks — checked 24 September 2026
  41. GitHub — mailgun/mailgun-python README (Exactly-Once Delivery & Retry Policies) — checked 24 September 2026
  42. PyPI — mailgun 1.9.1 (SDK source: RetryPolicy, IdempotencyGuard) — checked 24 September 2026
  43. Mailgun — pricing — checked 24 September 2026
  44. Twilio SendGrid — Python quickstart — checked 24 September 2026
  45. Twilio SendGrid — API keys (Custom Access, Mail Send) — checked 24 September 2026
  46. Twilio SendGrid — Event Webhook security features — checked 24 September 2026
  47. PyPI — sendgrid 6.12.5 (SDK source: no timeout, no retries, EventWebhook helper) — checked 24 September 2026
  48. Twilio SendGrid — Email API pricing — checked 24 September 2026
  49. Brevo — Idempotency for batch emails (idempotencyKey) — checked 24 September 2026
  50. PyPI — brevo-python 5.0.2 (README: retries, 60-second timeout) — checked 24 September 2026
  51. Brevo — Limits of the Free plan — checked 24 September 2026
  52. PyPI — mailjet-rest 1.9.0 — checked 24 September 2026
  53. Mailjet — pricing — checked 24 September 2026
  54. PyPI — mailtrap 2.10.0 — checked 24 September 2026
  55. Mailtrap — pricing — checked 24 September 2026
  56. PyPI — pysendpulse 0.1.8 — checked 24 September 2026
  57. SendPulse — SMTP pricing — checked 24 September 2026
  58. SendPulse — SMTP limits — checked 24 September 2026
  59. PyPI — smtp2go 2.3.1 — checked 24 September 2026
  60. SMTP2GO — Free plan — checked 24 September 2026
  61. PyPI — requests 2.34.2 — checked 24 September 2026