How to Send Email via API in Python (Quick Tutorial)

Table of Contents
Table of Contents 11 sections
Getting Started
Sending Emails
Production
Reference
Python Tutorial

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.

email api python send email api python Gmail / Outlook / IMAP requests / aiohttp Flask / Django / FastAPI
send_email.py
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())
Email delivered - 202 Accepted
Works with: Gmail Outlook IMAP
TL;DR

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.

1
Install requests
pip install requests python-dotenv
2
Set env vars
Add UNIPILE_DSN, UNIPILE_API_KEY, and UNIPILE_ACCOUNT_ID to your .env file.
3
Link an email account
OAuth for Gmail/Outlook or SMTP credentials for any IMAP server. One API call - you only do this once per account.
4
POST to /api/v1/emails
Pass account_id, to, subject, and body. Done.
The same Python code works for Gmail, Outlook, and any IMAP server - no provider-specific logic needed. Check the Email API guide for the full concept overview.
send_email.py
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_...'}
Setup

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.

Python 3.9+ (3.11 recommended)
All examples use f-strings, | union types, and standard-library features from 3.9+. Python 3.11 LTS is recommended for production. Check your version with python --version.
Unipile API Key & DSN
Sign up at the Unipile dashboard to get your access token and DSN (a personal HTTPS endpoint like api4.unipile.com:13444). Both are required in every request header.
Virtual environment
Always use a venv to isolate dependencies: python -m venv .venv && source .venv/bin/activate. Never install packages in the system Python - this is especially important for credential handling.
A linked email account
You send email through a linked account (Gmail, Outlook, or IMAP). The next section walks you through the OAuth flow to link one. You only do this once per account.
Installing dependencies
pip
pipenv
poetry
pip install requests python-dotenv # Optional: async support pip install aiohttp httpx # Optional: retry logic pip install tenacity
pipenv install requests python-dotenv tenacity
poetry add requests python-dotenv tenacity
.env
# 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_xxxxxxxxxxxxxxxx

Add .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).

Ready to run your first email send?
Get a free API key - takes 30 seconds, no credit card required.
Get your free API key
Account Linking

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.

GmailGmail OAuth
OutlookOutlook OAuth
IMAPIMAP / SMTP
connect_gmail.py
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 details
import 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}')
Gmail
Gmail
Uses Google OAuth 2.0. No passwords stored. Token refresh is automatic. See the Gmail API send email guide for scope details.
Outlook
Outlook / Microsoft 365
Uses Microsoft Graph OAuth. Covers personal Outlook and Microsoft 365 / Exchange Online. See the Microsoft Graph email guide for admin consent flows.
IMAP
IMAP / SMTP
Pass credentials directly. Works with any IMAP server: Zoho, Yahoo, FastMail, custom Exchange. See the IMAP API solution guide for port configuration.
Core API

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.

1
Plain text email
Basic
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.' } )
Note: The body field accepts both plain text and HTML. Use

tags for HTML formatting.

2
HTML email with CC and BCC
Common
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())
3
Response handling
Production
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_...'}
Tip: Always pass timeout=30 to avoid hanging forever on network issues. Use raise_for_status() to bubble HTTP errors as Python exceptions.
Free to start
Try it now - free API key in 30 seconds

Get your API key, link a Gmail or Outlook account in minutes, and run the Python examples from this guide against real mailboxes.

Attachments

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.

attach.py
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'))]
Always open in binary mode
Use 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.
Multiple attachments
Pass a list of tuples to files=: each tuple is ('attachments', (filename, fileobj, content_type)). Requests handles the multipart boundary automatically.
In-memory files (BytesIO)
For dynamically generated PDFs or CSV exports, pass a BytesIO object directly: from io import BytesIO; buf = BytesIO(pdf_bytes) then ('report.pdf', buf, 'application/pdf').
Provider limits: Gmail allows up to 25 MB total per send. Outlook allows up to 20 MB. IMAP limits depend on your server configuration. For files above these limits, send a download link instead.
Need larger attachments or higher send limits?
Unipile plans scale from prototypes to production workloads. Compare quotas on the pricing page.
Unipile - Advanced Python API
Advanced

Replies, Threads & Tracking

For sending email on behalf of a user, threading, and webhook-based delivery tracking, here are the Python patterns you need.

01
Threading 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.

reply.py
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' } )
02
Webhooks 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:

webhook_flask.py
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), 200
03
Idempotency 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.

idempotency.py
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!'} )
Production

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 CodeMeaningAction
202Accepted - queued for deliveryStore tracking_id
400Bad request (invalid fields)Fix payload, do not retry
401Invalid API keyCheck UNIPILE_API_KEY
403Account not authorizedRe-link account
404Account ID not foundCheck UNIPILE_ACCOUNT_ID
429Rate limitedBackoff + retry (see code)
500Server errorRetry after 5s delay
retry.py
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

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.

Never hardcode keys
Use 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.
Virtual environments
Always isolate dependencies with venv or conda. This prevents dependency confusion attacks and makes your requirements.txt auditable. Pin versions in production.
OAuth token refresh
Unipile handles OAuth token refresh automatically for Gmail and Outlook. You never store or rotate provider tokens yourself - just keep your UNIPILE_API_KEY valid.
Server-side only
Never call the Unipile API from client-side code (browser or mobile app). In Flask/Django/FastAPI, always keep API calls in server-side views or background tasks (Celery).
Validate webhook payloads
When receiving Unipile webhooks in Flask or FastAPI, validate the request origin via secret header or HMAC signature before processing the event. Never trust raw incoming payloads blindly.
Audit & logging
Log 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.
DKIM & SPF: These are DNS-level configurations, not Python code. Set up SPF and DKIM records for your sending domain. Read the full email API security guide for step-by-step DNS setup.
Pitfalls

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.

Using json= instead of data=
The Unipile send endpoint requires 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.
Fix: use data= with json.dumps() for recipient arrays
Opening attachment files in text mode
Always open file attachments with 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.
Fix: always open files as 'rb'
Mixing sync and async (asyncio)
The 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).
Fix: use httpx.AsyncClient for async/await contexts
Missing timeout on requests
By default, 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).
Fix: always pass timeout=(5, 30) to requests.post()
Timezone-naive datetimes in scheduling
If you schedule emails with a timestamp field, always use timezone-aware datetimes: from datetime import datetime, timezone; datetime.now(timezone.utc). Naive datetimes cause silent off-by-hours errors in multi-region deployments.
Fix: always use timezone.utc for datetime objects
GIL impact on threaded high-volume sends
Python's GIL limits true thread parallelism for CPU-bound work, but HTTP requests are I/O-bound - threads work fine. For high-volume sends (1000+/day), use a thread pool (concurrent.futures.ThreadPoolExecutor) or offload to a Celery queue.
Fix: use ThreadPoolExecutor or Celery for batch sends

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.

en_USEN