How to Send Email via API in Python (Quick Tutorial)
Skip the SMTP boilerplate. This guide shows you how to use the Unipile unified email API to send email in Python - with copy-paste examples for Gmail, Outlook, and IMAP using the requests library.
import requests, os
API_KEY = os.environ['UNIPILE_API_KEY']
DSN = os.environ['UNIPILE_DSN']
ACCOUNT_ID = os.environ['UNIPILE_ACCOUNT_ID']
response = requests.post(
f'{DSN}/api/v1/emails',
headers={'X-API-KEY': API_KEY},
data={
'account_id': ACCOUNT_ID,
'to': '[{"display_name":"Alice","identifier":"alice@acme.com"}]',
'subject': 'Hello from Python',
'body': 'Sent via Unipile!
'
}
)
print(response.json())5-Line Python Example
If you already know what a Send Email API is and just want the email api python code that actually runs, here it is. The full tutorial follows below.
pip install requests python-dotenvUNIPILE_DSN, UNIPILE_API_KEY, and UNIPILE_ACCOUNT_ID to your .env file./api/v1/emailsaccount_id, to, subject, and body. Done.import requests, os
from dotenv import load_dotenv
load_dotenv()
API_KEY = os.environ['UNIPILE_API_KEY']
DSN = os.environ['UNIPILE_DSN']
ACCOUNT_ID = os.environ['UNIPILE_ACCOUNT_ID']
resp = requests.post(
f'{DSN}/api/v1/emails',
headers={'X-API-KEY': API_KEY},
data={
'account_id': ACCOUNT_ID,
'to': '[{"display_name":"Alice","identifier":"alice@acme.com"}]',
'subject': 'Hello from Python',
'body': 'Sent via Unipile!
'
}
)
print(resp.json()) # {'tracking_id': 'msg_...'}Prerequisites & Setup
Before you can use the email API Python workflow in production, you need four things: Python 3.9+, the requests library, an API key with DSN, and a linked email account.
| union types, and standard-library features from 3.9+. Python 3.11 LTS is recommended for production. Check your version with python --version.api4.unipile.com:13444). Both are required in every request header.python -m venv .venv && source .venv/bin/activate. Never install packages in the system Python - this is especially important for credential handling.pip install requests python-dotenv
# Optional: async support
pip install aiohttp httpx
# Optional: retry logic
pip install tenacitypipenv install requests python-dotenv tenacitypoetry add requests python-dotenv tenacity# Unipile credentials - never commit this file
UNIPILE_DSN=https://api4.unipile.com:13444
UNIPILE_API_KEY=your_access_token_here
# The account ID of the linked email account
UNIPILE_ACCOUNT_ID=acc_xxxxxxxxxxxxxxxxAdd .env to your .gitignore. Load with python-dotenv via load_dotenv() at the top of your script. In production, prefer actual environment variables injected by your deployment platform (Heroku, Railway, Docker Compose).
Connecting Your First Email Account
Before you can send, you need to link an email account to Unipile. This is a one-time step per account. See the full unified email API integration guide for more on multi-account flows.
Unipile uses a hosted auth wizard - your Python script generates an auth link, the user clicks it and completes OAuth in the browser, then Unipile calls your webhook with the new account_id. No SMTP credentials are stored in your code for Gmail or Outlook.
import requests, os
from dotenv import load_dotenv
load_dotenv()
API_KEY = os.environ['UNIPILE_API_KEY']
DSN = os.environ['UNIPILE_DSN']
# Step 1: create a hosted auth link for Gmail OAuth
resp = requests.post(
f'{DSN}/api/v1/hosted/accounts/link',
headers={'X-API-KEY': API_KEY},
data={
'type': 'GOOGLE',
'name': 'Alice Gmail',
'success_url': 'https://yourapp.com/oauth/success',
'failure_url': 'https://yourapp.com/oauth/failure'
}
)
# Step 2: send this URL to your user
auth_url = resp.json()['url']
print(f'Direct user to: {auth_url}')
# Step 3: Unipile POSTs {account_id} to your webhook after OAuth
# See /gmail-api-send-email-a-comprehensive-guide-for-developers/ for Gmail detailsimport requests, os
from dotenv import load_dotenv
load_dotenv()
# Outlook OAuth - covers personal Outlook + Microsoft 365
# See /microsoft-graph-api-email-integration-guide/
resp = requests.post(
f'{os.environ["UNIPILE_DSN"]}/api/v1/hosted/accounts/link',
headers={'X-API-KEY': os.environ['UNIPILE_API_KEY']},
data={
'type': 'MICROSOFT',
'name': 'Bob Outlook',
'success_url': 'https://yourapp.com/oauth/success',
'failure_url': 'https://yourapp.com/oauth/failure'
}
)
print(resp.json()['url'])import requests, os, json
# IMAP: pass SMTP/IMAP credentials directly (no OAuth redirect needed)
# See /the-developers-guide-to-imap-api-solution/ for full IMAP details
resp = requests.post(
f'{os.environ["UNIPILE_DSN"]}/api/v1/accounts',
headers={'X-API-KEY': os.environ['UNIPILE_API_KEY']},
json={
'provider': 'IMAP',
'username': 'alice@company.com',
'password': 'app_password_here',
'imap_host': 'imap.company.com',
'smtp_host': 'smtp.company.com'
}
)
account_id = resp.json()['account_id']
print(f'Linked account: {account_id}')Sending Your First Email from Python
The send endpoint accepts multipart/form-data. Use data= (not json=) in requests.post(). The to, cc, and bcc fields are JSON-encoded strings inside the form data.
import requests, os, json
requests.post(
f'{os.environ["UNIPILE_DSN"]}/api/v1/emails',
headers={'X-API-KEY': os.environ['UNIPILE_API_KEY']},
data={
'account_id': os.environ['UNIPILE_ACCOUNT_ID'],
'to': json.dumps([{'display_name': 'Alice', 'identifier': 'alice@acme.com'}]),
'subject': 'Quick update',
'body': 'Hi Alice, just checking in.'
}
)body field accepts both plain text and HTML. Use tags for HTML formatting.import requests, os, json
response = requests.post(
f'{os.environ["UNIPILE_DSN"]}/api/v1/emails',
headers={'X-API-KEY': os.environ['UNIPILE_API_KEY']},
data={
'account_id': os.environ['UNIPILE_ACCOUNT_ID'],
'to': json.dumps([{'identifier': 'alice@acme.com'}]),
'cc': json.dumps([{'identifier': 'manager@acme.com'}]),
'bcc': json.dumps([{'identifier': 'crm@yourapp.com'}]),
'subject': 'Your invoice is ready',
'body': 'Invoice #1042
Please find your invoice attached.
'
}
)
# 202 Accepted = queued for delivery
print(response.status_code, response.json())import requests, os, json
def send_email(to_email: str, subject: str, body: str) -> dict:
"""Send email via Unipile email API Python wrapper."""
response = requests.post(
f'{os.environ["UNIPILE_DSN"]}/api/v1/emails',
headers={'X-API-KEY': os.environ['UNIPILE_API_KEY']},
data={
'account_id': os.environ['UNIPILE_ACCOUNT_ID'],
'to': json.dumps([{'identifier': to_email}]),
'subject': subject,
'body': body,
},
timeout=30
)
response.raise_for_status() # raises HTTPError on 4xx/5xx
return response.json() # {'tracking_id': 'msg_...'}timeout=30 to avoid hanging forever on network issues. Use raise_for_status() to bubble HTTP errors as Python exceptions.Get your API key, link a Gmail or Outlook account in minutes, and run the Python examples from this guide against real mailboxes.
Sending Attachments in Python
Attachments are sent as part of the multipart form data using Python's files= parameter. Open the file in binary mode ('rb') - bytes, not strings.
import requests, os, json
# Single file attachment
with open('invoice.pdf', 'rb') as f:
resp = requests.post(
f'{os.environ["UNIPILE_DSN"]}/api/v1/emails',
headers={'X-API-KEY': os.environ['UNIPILE_API_KEY']},
data={
'account_id': os.environ['UNIPILE_ACCOUNT_ID'],
'to': json.dumps([{'identifier': 'client@example.com'}]),
'subject': 'Invoice attached',
'body': 'Please see the attached invoice.
'
},
files={'attachments': ('invoice.pdf', f, 'application/pdf')}
)
# Multiple attachments: pass a list of tuples
# files=[('attachments', ('a.pdf', f1, 'application/pdf')),
# ('attachments', ('b.png', f2, 'image/png'))]open('file.pdf', 'rb'), not 'r'. Passing a text file object to files= raises a TypeError. This is a common Python-specific gotcha when migrating from smtplib.files=: each tuple is ('attachments', (filename, fileobj, content_type)). Requests handles the multipart boundary automatically.BytesIO object directly: from io import BytesIO; buf = BytesIO(pdf_bytes) then ('report.pdf', buf, 'application/pdf').Replies, Threads & Tracking
For sending email on behalf of a user, threading, and webhook-based delivery tracking, here are the Python patterns you need.
01Threading with in_reply_to
To reply within an existing thread, pass the in_reply_to field with the tracking_id of the email you want to reply to. Unipile handles the References and In-Reply-To headers automatically.
requests.post(
f'{DSN}/api/v1/emails',
headers={'X-API-KEY': API_KEY},
data={
'account_id': ACCOUNT_ID,
'to': json.dumps([{'identifier': 'alice@acme.com'}]),
'subject': 'Re: Your question',
'body': 'Following up on your message.
',
'in_reply_to': 'msg_original_tracking_id'
}
)02Webhooks in Python (Flask example)
Register a webhook URL in your Unipile dashboard to receive delivery events (sent, bounced, opened). Here is a minimal Flask receiver:
from flask import Flask, request, jsonify
import logging
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
@app.route('/webhook/email', methods=['POST'])
def email_webhook():
event = request.get_json()
event_type = event.get('type')
tracking_id = event.get('tracking_id')
logging.info(f'Email event: {event_type} for {tracking_id}')
return jsonify(ok=True), 20003Idempotency keys
To prevent duplicate sends on network retry, pass a unique Idempotency-Key header. If the same key is sent twice, Unipile returns the original response without sending a second email.
import uuid, requests, os, json
key = str(uuid.uuid4()) # generate once, store in DB
requests.post(
f'{os.environ["UNIPILE_DSN"]}/api/v1/emails',
headers={
'X-API-KEY': os.environ['UNIPILE_API_KEY'],
'Idempotency-Key': key
},
data={'account_id': os.environ['UNIPILE_ACCOUNT_ID'],
'to': json.dumps([{'identifier': 'alice@acme.com'}]),
'subject': 'Welcome!', 'body': 'Hi!'}
)Error Handling & Retries
Production Python code for the email API needs proper exception handling, structured logging, and automatic retries with exponential backoff using the tenacity library.
| HTTP Code | Meaning | Action |
|---|---|---|
| 202 | Accepted - queued for delivery | Store tracking_id |
| 400 | Bad request (invalid fields) | Fix payload, do not retry |
| 401 | Invalid API key | Check UNIPILE_API_KEY |
| 403 | Account not authorized | Re-link account |
| 404 | Account ID not found | Check UNIPILE_ACCOUNT_ID |
| 429 | Rate limited | Backoff + retry (see code) |
| 500 | Server error | Retry after 5s delay |
import requests, os, json, logging
from tenacity import (
retry, stop_after_attempt,
wait_exponential, retry_if_exception_type
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class RateLimitError(Exception):
pass
@retry(
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=1, min=2, max=30),
retry=retry_if_exception_type(RateLimitError)
)
def send_with_retry(to: str, subject: str, body: str) -> dict:
resp = requests.post(
f'{os.environ["UNIPILE_DSN"]}/api/v1/emails',
headers={'X-API-KEY': os.environ['UNIPILE_API_KEY']},
data={
'account_id': os.environ['UNIPILE_ACCOUNT_ID'],
'to': json.dumps([{'identifier': to}]),
'subject': subject, 'body': body
},
timeout=30
)
if resp.status_code == 429:
logger.warning('Rate limited, backing off...')
raise RateLimitError()
resp.raise_for_status()
return resp.json()Security Best Practices in Python
For a complete guide to protecting your email API integration, see the email API security guide. Here are the Python-specific essentials.
os.environ or python-dotenv. Never put UNIPILE_API_KEY as a string literal in your source code. If accidentally pushed to Git, rotate the key immediately from your dashboard.venv or conda. This prevents dependency confusion attacks and makes your requirements.txt auditable. Pin versions in production.UNIPILE_API_KEY valid.tracking_id for every sent email to enable delivery audits. Use Python's standard logging module - never print() in production. Ship logs to a SIEM for compliance-heavy use cases.Common Python-Specific Pitfalls
These are the most common mistakes Python developers make when integrating the email API. If you are on Node.js instead, see our JavaScript send email API tutorial.
json= instead of data=multipart/form-data, not JSON. Always use requests.post(..., data={...}). Using json={...} will return a 400 error. The to, cc, and bcc fields are JSON strings inside the form data - use json.dumps() to encode the recipient array.open('file.pdf', 'rb') - binary mode. Text mode ('r') raises a TypeError when passed to the files= parameter. For in-memory content, use io.BytesIO.requests library is synchronous. Calling it inside an async def function blocks the event loop. Use httpx.AsyncClient or aiohttp.ClientSession for async Python contexts (FastAPI, async Django views, asyncio scripts).requests.post() waits forever. A hung connection will block your thread (or Celery worker) indefinitely. Always pass timeout=30 (connect timeout, read timeout in seconds).from datetime import datetime, timezone; datetime.now(timezone.utc). Naive datetimes cause silent off-by-hours errors in multi-region deployments.concurrent.futures.ThreadPoolExecutor) or offload to a Celery queue.Frequently Asked Questions
Common questions about using the email API in Python with the Unipile unified email API.
Use the Unipile unified email API instead of smtplib or a direct SMTP connection. Install requests, get your API key and DSN from the Unipile dashboard, link a Gmail or Outlook account via OAuth, then POST to /api/v1/emails with your account_id, to, subject, and body. No SMTP server, no port 587, no TLS configuration needed in your Python code.
Django: call the API in a view or management command. For async Django (3.1+), use httpx.AsyncClient in async views.
Flask: call the API in a route handler server-side. Never call it from a Jinja template or client-side JS. Use Flask-Celery to offload high-volume sends to background workers.
FastAPI: use httpx.AsyncClient inside async def endpoints. The synchronous requests library blocks the async event loop - always use an async HTTP client in FastAPI.
smtplib connects directly to an SMTP server from your Python process. You manage SMTP credentials, TLS setup, and per-provider quirks (Gmail app passwords, Outlook modern auth). It is also synchronous only.
The Unipile email API is a cloud abstraction: link accounts via OAuth (no SMTP credentials in your code for Gmail/Outlook), get a single consistent HTTP API for all providers, and Unipile handles transport, token refresh, and retries. The trade-off is that sends route through Unipile's infrastructure rather than a direct SMTP connection.
Yes, but you need an async HTTP client - the standard requests library is synchronous and will block your event loop. Use httpx (recommended, drop-in async alternative) or aiohttp.
import httpx, os, json
async def send_email_async(to: str, subject: str, body: str):
async with httpx.AsyncClient() as client:
resp = await client.post(
f'{os.environ["UNIPILE_DSN"]}/api/v1/emails',
headers={'X-API-KEY': os.environ['UNIPILE_API_KEY']},
data={'account_id': os.environ['UNIPILE_ACCOUNT_ID'],
'to': json.dumps([{'identifier': to}]),
'subject': subject, 'body': body}
)
resp.raise_for_status()
return resp.json()Use a Celery task queue with a Redis or RabbitMQ broker. Each email becomes a task - Celery handles concurrency and retries automatically. Limit concurrency per worker to avoid rate limits (typically 5-10 concurrent sends per linked account). For truly high-volume marketing sends (millions/day), combine Unipile for OAuth-based transactional sends with a dedicated ESP for bulk campaigns.
For lighter use cases, concurrent.futures.ThreadPoolExecutor(max_workers=5) with the requests library is a simpler approach that avoids the Celery overhead.
Yes. Create a Celery task that calls requests.post() to the Unipile endpoint. Celery workers are standard synchronous Python processes, so requests works perfectly. Use Celery's built-in autoretry_for=(requests.exceptions.HTTPError,) with max_retries=3 and default_retry_delay=5 for automatic retry on transient failures. Combine with Idempotency-Key headers to prevent duplicate sends on worker restarts.
Still have questions? Our team is here to help.