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.
| Provider | PyPI package | Latest release | Python | Free option |
|---|---|---|---|---|
| Amazon SES | boto3 | 1.43.101, 23 Sep 2026 | 3.10+ | No always-free quota; up to $200 in AWS credits for new accounts |
| Brevo | brevo-python | 5.0.2, 10 Aug 2026 | 3.8+ | 300 emails a day |
| Mailgun | mailgun | 1.9.1, 21 Sep 2026 | 3.11+ | 100 emails a day |
| Mailjet | mailjet-rest | 1.9.0, 14 Sep 2026 | 3.10+ | 6,000 a month, 200 a day |
| Mailtrap | mailtrap | 2.10.0, 31 Aug 2026 | 3.9+ | 4,000 a month, 150 a day |
| Postmark | postmark-python | 0.4.0, 12 Aug 2026 | 3.10+ | 100 a month |
| Resend | resend | 2.47.0, 18 Sep 2026 | 3.7+ | 3,000 a month, 100 a day |
| SendGrid | sendgrid | 6.12.5, 19 Sep 2025 | 3.10+ (quickstart) | 60-day trial, 100 a day |
| SendPulse | pysendpulse | 0.1.8, 11 Feb 2025 | not stated | 12,000 a month, 400 a day |
| SMTP2GO | smtp2go | 2.3.1, 29 Jul 2022 | not stated | 1,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.
| Package | Retries a send by default | Default timeout | Idempotency key on the send call |
|---|---|---|---|
boto3 1.43.101 (SES v2) | Yes: legacy mode, up to 5 attempts, including after read timeouts | 60 s | None |
brevo-python 5.0.2 | Yes: 2 retries on 408, 429 and 5xx | 60 s | idempotencyKey, a UUID, kept 30 minutes |
mailgun 1.9.1 | Yes: 3 retries on 429, 500–504 and network errors | 60 s | None in the API reference |
postmark-python 0.4.0 | Yes: 3 retries on 429, 500, 503 and timeouts | 5 s | None documented |
resend 2.47.0 | No | 30 s | idempotency_key, kept 24 hours |
sendgrid 6.12.5 | No | None set | None documented |
requests 2.34.2 | No | None | Depends 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:

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:
- 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. - Elsewhere, switch automatic retries off for sends:
max_attempts: 1in boto3,retries=0on postmark-python'sServerClient,RetryPolicy(max_retries=0)in mailgun. - 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-afterheader (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
| Symptom | Cause | Fix |
|---|---|---|
import postmark has no ServerClient | pip install postmark fetched the unrelated 2017 package | pip uninstall postmark, then pip install postmark-python |
Resend 403 validation_error: testing emails only to your own address | Sending from Resend's test domain | Verify a domain and use it in from |
SES MessageRejected while testing | Often the sandbox: recipient not verified | Verify the recipient, use the simulator, or request production access |
| Mailgun: no exception, no email | messages.create() returns the HTTP response even for 4xx | Check response.status_code |
Postmark HTTP 401, ErrorCode 10 | Missing token or the Account token on a Server endpoint | Send the Server token |
Resend 409 invalid_idempotent_request | Same key, different payload within 24 hours | New key only when the content really changes |
Resend 429 daily_quota_exceeded | Free plan's 100-a-day cap | Wait for the UTC day to reset, or upgrade |
smtplib.SMTPAuthenticationError on Gmail | Account password used, or no 2-Step Verification | App password, or a provider relay |
| Worker hangs on a send | requests or sendgrid with no timeout | Pass a timeout |
Related guides
- Email API vs SMTP relay: duplicate sends and blocked ports
- Free email API and SMTP relay tiers for 13 providers
- Transactional email guide: SPF, DKIM, DMARC and inbox rules
- Send email from Next.js with React Email, the Node.js counterpart
- Send SMS from Node.js and Python
- Best transactional email APIs, scored
- Resend, Amazon SES and Postmark provider cards
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
- Python 3.14.7 docs — smtplib — checked 24 September 2026
- Python 3.14.7 docs — email: Examples (set_content, add_alternative) — checked 24 September 2026
- RFC 2046 §5.1.4 — multipart/alternative — checked 24 September 2026
- Google Account Help — Sign in with app passwords — checked 24 September 2026
- Gmail Help — Limits for sending & getting mail — checked 24 September 2026
- Requests — Advanced usage (timeouts, retries) — checked 24 September 2026
- FastAPI — Background Tasks — checked 24 September 2026
- PyPI — flask-mail 0.10.0 — checked 24 September 2026
- PyPI — fastapi-mail 1.6.8 (depends on aiosmtplib) — checked 24 September 2026
- PyPI — django-anymail 15.2 — checked 24 September 2026
- Django 6.1 docs — Sending email (MAILERS; EMAIL_BACKEND deprecated) — checked 24 September 2026
- PyPI — python-dotenv 1.2.3 — checked 24 September 2026
- Resend — Send emails with Python — checked 24 September 2026
- Resend — Idempotency keys — checked 24 September 2026
- Resend — Errors — checked 24 September 2026
- Resend — Usage limits — checked 24 September 2026
- Resend — Account quotas and limits (UTC daily reset) — checked 24 September 2026
- Resend — pricing — checked 24 September 2026
- Resend — Create API key (sending_access, domain_id) — checked 24 September 2026
- Resend — Send test emails — checked 24 September 2026
- Resend — Managing webhooks (svix-id for duplicates) — checked 24 September 2026
- Resend — Verify webhook requests — checked 24 September 2026
- Resend — Webhook event types — checked 24 September 2026
- Resend — Webhook retries and replays — checked 24 September 2026
- PyPI — resend 2.47.0 (SDK source: timeout, errors, Webhooks.verify) — checked 24 September 2026
- Boto3 — SESV2 send_email — checked 24 September 2026
- Boto3 — Retries (legacy mode default, 5 attempts) — checked 24 September 2026
- PyPI — boto3 1.43.101 (botocore 1.43.101 source: 60-second default timeouts) — checked 24 September 2026
- Amazon SES — Moving out of the sandbox — checked 24 September 2026
- Amazon SES — Mailbox simulator — checked 24 September 2026
- Amazon SES — pricing (Free Tier credits) — checked 24 September 2026
- Postmark — Email API (POST /email) — checked 24 September 2026
- Postmark — API overview (error codes, POSTMARK_API_TEST) — checked 24 September 2026
- Postmark — Official libraries — checked 24 September 2026
- PyPI — postmark-python 0.4.0 (README and SDK source: retries, 5-second timeout) — checked 24 September 2026
- PyPI — postmark 1.0.0 (unrelated third-party package, 2017) — checked 24 September 2026
- Postmark — pricing — checked 24 September 2026
- Postmark — Webhooks overview (not signed; Basic auth, IP allowlist) — checked 24 September 2026
- Mailgun — Send an email (POST /v3/{domain_name}/messages) — checked 24 September 2026
- Mailgun — Securing webhooks — checked 24 September 2026
- GitHub — mailgun/mailgun-python README (Exactly-Once Delivery & Retry Policies) — checked 24 September 2026
- PyPI — mailgun 1.9.1 (SDK source: RetryPolicy, IdempotencyGuard) — checked 24 September 2026
- Mailgun — pricing — checked 24 September 2026
- Twilio SendGrid — Python quickstart — checked 24 September 2026
- Twilio SendGrid — API keys (Custom Access, Mail Send) — checked 24 September 2026
- Twilio SendGrid — Event Webhook security features — checked 24 September 2026
- PyPI — sendgrid 6.12.5 (SDK source: no timeout, no retries, EventWebhook helper) — checked 24 September 2026
- Twilio SendGrid — Email API pricing — checked 24 September 2026
- Brevo — Idempotency for batch emails (idempotencyKey) — checked 24 September 2026
- PyPI — brevo-python 5.0.2 (README: retries, 60-second timeout) — checked 24 September 2026
- Brevo — Limits of the Free plan — checked 24 September 2026
- PyPI — mailjet-rest 1.9.0 — checked 24 September 2026
- Mailjet — pricing — checked 24 September 2026
- PyPI — mailtrap 2.10.0 — checked 24 September 2026
- Mailtrap — pricing — checked 24 September 2026
- PyPI — pysendpulse 0.1.8 — checked 24 September 2026
- SendPulse — SMTP pricing — checked 24 September 2026
- SendPulse — SMTP limits — checked 24 September 2026
- PyPI — smtp2go 2.3.1 — checked 24 September 2026
- SMTP2GO — Free plan — checked 24 September 2026
- PyPI — requests 2.34.2 — checked 24 September 2026