Intermediate
Sending emails from Python is straightforward with smtplib — until your application needs to handle hundreds of notifications at once without blocking. A web server that pauses every request while waiting for an SMTP handshake is a web server that frustrates users. If your application already uses asyncio for concurrent I/O — whether through FastAPI, aiohttp, or plain async/await — blocking SMTP calls break the async model entirely, forcing every email to serialize through a synchronous bottleneck.
aiosmtplib is a fully async SMTP client that integrates cleanly with Python’s asyncio event loop. It exposes the same concepts as smtplib — connections, TLS, authentication, MIME messages — but with await-based calls that never block the event loop. You’ll need Python 3.7+ and pip install aiosmtplib. No other dependencies are required for basic use, and it works with Gmail, Outlook, SendGrid, Mailgun, or any standard SMTP server.
This article walks through everything you need to send emails asynchronously in Python: connecting to an SMTP server with TLS, building plain-text and HTML messages with email.message, adding attachments, sending to multiple recipients concurrently with asyncio.gather, and building a practical async notification system. By the end you’ll have a reusable async email module ready to drop into any Python application.
Sending an Email with aiosmtplib: Quick Example
Here’s the minimal working example — connect to Gmail’s SMTP server over TLS and send a plain-text message in under 20 lines:
# quick_email.py
import asyncio
import aiosmtplib
from email.message import EmailMessage
async def send_quick_email():
msg = EmailMessage()
msg["From"] = "you@gmail.com"
msg["To"] = "recipient@example.com"
msg["Subject"] = "Hello from aiosmtplib"
msg.set_content("This email was sent asynchronously with Python.")
await aiosmtplib.send(
msg,
hostname="smtp.gmail.com",
port=465,
username="you@gmail.com",
password="your-app-password",
use_tls=True,
)
print("Email sent successfully.")
asyncio.run(send_quick_email())
Output:
Email sent successfully.
The aiosmtplib.send() convenience function handles connecting, authenticating, sending, and closing the connection in one call. The EmailMessage object from Python’s standard library email.message module handles message formatting. For Gmail, use an App Password (Google Account → Security → 2-Step Verification → App passwords) rather than your regular login password — Google blocks direct password auth for third-party apps.
The sections below cover TLS vs STARTTLS, HTML messages, attachments, bulk sending, and error handling in detail.
What Is aiosmtplib and When Should You Use It?
aiosmtplib is an async implementation of the SMTP protocol built on top of Python’s asyncio transport layer. It mirrors the API of the standard library’s smtplib.SMTP class but every network operation is a coroutine. This matters because SMTP involves multiple round-trips: TCP connect, EHLO handshake, STARTTLS upgrade, AUTH, DATA transfer, and QUIT — any of which can take hundreds of milliseconds on a slow network.
| Library | Blocking? | Async support | Best for |
|---|---|---|---|
smtplib | Yes | No | Scripts, CLI tools, simple apps |
aiosmtplib | No | Native asyncio | FastAPI, aiohttp, async web apps |
smtplib + thread pool | Offloaded | Via executor | Quick workaround in async code |
Use aiosmtplib when your application already uses asyncio and you need non-blocking email delivery. For simple one-off scripts, the standard smtplib is perfectly fine. Running smtplib inside loop.run_in_executor() works as a stopgap but adds threading overhead and complexity that aiosmtplib avoids entirely.
Installing aiosmtplib
# install_aiosmtplib.sh
pip install aiosmtplib
Output:
Successfully installed aiosmtplib-3.0.1
The package has no mandatory third-party dependencies — it uses only Python’s standard library (asyncio, ssl, email). For building rich HTML emails with inline images or attachments, you’ll use the standard library’s email.mime or email.message modules, which are already installed. Check the version with python -m pip show aiosmtplib.
TLS vs STARTTLS: Choosing the Right Connection
SMTP servers offer two encryption modes. Getting this wrong causes connection errors that look identical but have completely different fixes:
| Mode | Port | How it works | aiosmtplib parameter |
|---|---|---|---|
| SSL/TLS (implicit) | 465 | TLS from the first byte | use_tls=True |
| STARTTLS (explicit) | 587 | Plaintext connect, then upgrade | start_tls=True |
| Plain (no encryption) | 25 | Unencrypted — do not use for auth | Neither |
# tls_connection.py
import asyncio
import aiosmtplib
from email.message import EmailMessage
async def send_via_starttls():
msg = EmailMessage()
msg["From"] = "you@yourdomain.com"
msg["To"] = "recipient@example.com"
msg["Subject"] = "STARTTLS connection test"
msg.set_content("Sent via STARTTLS on port 587.")
# Port 587 with STARTTLS (common for most business SMTP providers)
await aiosmtplib.send(
msg,
hostname="smtp.gmail.com",
port=587,
username="you@yourdomain.com",
password="your-app-password",
start_tls=True, # upgrade to TLS after connecting
)
print("Sent via STARTTLS.")
async def send_via_ssl():
msg = EmailMessage()
msg["From"] = "you@yourdomain.com"
msg["To"] = "recipient@example.com"
msg["Subject"] = "SSL/TLS connection test"
msg.set_content("Sent via implicit TLS on port 465.")
# Port 465 with implicit TLS
await aiosmtplib.send(
msg,
hostname="smtp.gmail.com",
port=465,
username="you@yourdomain.com",
password="your-app-password",
use_tls=True, # TLS from first byte
)
print("Sent via SSL/TLS.")
asyncio.run(send_via_starttls())
Output:
Sent via STARTTLS.
Most modern providers (Gmail, Outlook/Hotmail, SendGrid, Mailgun) support both ports. Gmail prefers port 465 with use_tls=True. Office 365 uses port 587 with start_tls=True. Never pass both use_tls=True and start_tls=True — that raises a ValueError. If you see SMTPConnectError, you’re almost certainly using the wrong port/mode combination.
Sending HTML Emails with Fallback Plain Text
Real notification emails need HTML — formatted text, buttons, links with proper styling. The email.mime module’s MIMEMultipart("alternative") structure lets you include both a plain-text fallback and the HTML version. Email clients choose the richest format they support:
# html_email.py
import asyncio
import aiosmtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
SMTP_HOST = "smtp.gmail.com"
SMTP_PORT = 465
USERNAME = "you@gmail.com"
PASSWORD = "your-app-password"
async def send_html_email(to: str, subject: str, plain: str, html: str) -> None:
msg = MIMEMultipart("alternative")
msg["From"] = USERNAME
msg["To"] = to
msg["Subject"] = subject
# Plain text part first (fallback for clients that don't render HTML)
msg.attach(MIMEText(plain, "plain", "utf-8"))
# HTML part second (preferred by modern clients)
msg.attach(MIMEText(html, "html", "utf-8"))
await aiosmtplib.send(
msg,
hostname=SMTP_HOST,
port=SMTP_PORT,
username=USERNAME,
password=PASSWORD,
use_tls=True,
)
plain_body = "Your order #1234 has shipped. Track it at: https://example.com/track/1234"
html_body = """
Your order has shipped!
Order #1234 is on its way.
"""
asyncio.run(send_html_email(
to="customer@example.com",
subject="Your order has shipped",
plain=plain_body,
html=html_body,
))
print("HTML email sent.")
Output:
HTML email sent.
The order of attach() calls matters: plain text first, HTML second. MIME clients render the last attached alternative they understand, which is why HTML goes last. Always include a plain-text version — spam filters penalise HTML-only messages, and some corporate mail servers strip HTML entirely.
Adding File Attachments
To attach a file, switch to MIMEMultipart("mixed") and add a MIMEBase or MIMEApplication part. The pattern works for any file type — PDFs, images, CSV exports:
# email_with_attachment.py
import asyncio
import aiosmtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from pathlib import Path
SMTP_CONFIG = dict(
hostname="smtp.gmail.com", port=465,
username="you@gmail.com", password="your-app-password",
use_tls=True
)
async def send_with_attachment(to: str, subject: str, body: str, filepath: str) -> None:
msg = MIMEMultipart()
msg["From"] = SMTP_CONFIG["username"]
msg["To"] = to
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain", "utf-8"))
# Read file and attach
path = Path(filepath)
with open(path, "rb") as f:
attachment = MIMEApplication(f.read(), Name=path.name)
attachment["Content-Disposition"] = f'attachment; filename="{path.name}"'
msg.attach(attachment)
await aiosmtplib.send(msg, **SMTP_CONFIG)
print(f"Sent email with attachment: {path.name}")
# Create a sample CSV to attach
Path("report.csv").write_text("date,sales\n2026-05-01,1200\n2026-05-02,1450\n")
asyncio.run(send_with_attachment(
to="manager@example.com",
subject="Daily Sales Report",
body="Please find the daily sales report attached.",
filepath="report.csv",
))
Output:
Sent email with attachment: report.csv
The Content-Disposition header tells the email client to treat the part as a downloadable file rather than inline content. The Name parameter in MIMEApplication and the filename in Content-Disposition should match. For images you want to display inline rather than attach, use Content-Disposition: inline and a Content-ID header referenced in the HTML body with cid: URIs.
Sending Emails Concurrently with asyncio.gather
The biggest advantage of aiosmtplib over smtplib is concurrent sending. Instead of waiting for each SMTP round-trip to finish before starting the next, asyncio.gather() fires all sends simultaneously and waits for them all to complete:
# bulk_email.py
import asyncio
import aiosmtplib
from email.message import EmailMessage
from typing import NamedTuple
SMTP_CONFIG = dict(
hostname="smtp.gmail.com", port=465,
username="you@gmail.com", password="your-app-password",
use_tls=True
)
class Recipient(NamedTuple):
email: str
name: str
async def send_one(recipient: Recipient, subject: str, body_template: str) -> tuple[str, bool]:
"""Send to a single recipient; return (email, success)."""
msg = EmailMessage()
msg["From"] = SMTP_CONFIG["username"]
msg["To"] = recipient.email
msg["Subject"] = subject
msg.set_content(body_template.format(name=recipient.name))
try:
await aiosmtplib.send(msg, **SMTP_CONFIG)
return recipient.email, True
except aiosmtplib.SMTPException as e:
print(f" Failed {recipient.email}: {e}")
return recipient.email, False
async def send_bulk(recipients: list[Recipient], subject: str, template: str) -> None:
tasks = [send_one(r, subject, template) for r in recipients]
results = await asyncio.gather(*tasks, return_exceptions=False)
sent = sum(1 for _, ok in results if ok)
print(f"Sent {sent}/{len(recipients)} emails successfully.")
RECIPIENTS = [
Recipient("alice@example.com", "Alice"),
Recipient("bob@example.com", "Bob"),
Recipient("carol@example.com", "Carol"),
]
TEMPLATE = "Hi {name},\n\nYour weekly summary is ready. Log in to view your dashboard.\n\nThanks!"
asyncio.run(send_bulk(RECIPIENTS, "Your Weekly Summary", TEMPLATE))
Output:
Sent 3/3 emails successfully.
With asyncio.gather(), all three SMTP connections are opened in parallel. For a list of 50 recipients, this can reduce total send time from 50× the per-email latency to roughly 1× (plus connection setup overhead). For very large batches (1000+), add a semaphore to cap concurrent connections: sem = asyncio.Semaphore(20) and wrap each coroutine with async with sem: to avoid overwhelming the SMTP server’s connection limit.
Reusing a Connection with SMTP Context Manager
The aiosmtplib.send() convenience function opens and closes a new SMTP connection for every message. For batch sending within a single function, it’s more efficient to open one connection and send multiple messages through it:
# persistent_smtp.py
import asyncio
import aiosmtplib
from email.message import EmailMessage
async def send_batch_single_connection(messages: list[dict]) -> None:
"""Send multiple messages over a single persistent SMTP connection."""
async with aiosmtplib.SMTP(
hostname="smtp.gmail.com",
port=465,
username="you@gmail.com",
password="your-app-password",
use_tls=True,
) as smtp:
for m in messages:
msg = EmailMessage()
msg["From"] = "you@gmail.com"
msg["To"] = m["to"]
msg["Subject"] = m["subject"]
msg.set_content(m["body"])
await smtp.send_message(msg)
print(f"Sent to {m['to']}")
messages = [
{"to": "user1@example.com", "subject": "Invoice #001", "body": "Your invoice is attached."},
{"to": "user2@example.com", "subject": "Invoice #002", "body": "Your invoice is attached."},
{"to": "user3@example.com", "subject": "Invoice #003", "body": "Your invoice is attached."},
]
asyncio.run(send_batch_single_connection(messages))
Output:
Sent to user1@example.com
Sent to user2@example.com
Sent to user3@example.com
The async with aiosmtplib.SMTP(...) as smtp: context manager connects, authenticates, and ensures the connection is properly closed via QUIT even if an exception occurs. smtp.send_message(msg) sends a pre-built EmailMessage object without re-connecting. This approach is ideal for sending invoices, receipts, or reports in a nightly batch job where all messages are sent in one pass.
Real-Life Example: Async Notification System
# notification_system.py
"""
Async email notification system with retry logic.
Sends transactional emails (welcome, password reset, order confirmation)
without blocking the event loop.
"""
import asyncio
import aiosmtplib
from dataclasses import dataclass
from email.message import EmailMessage
from enum import Enum
class NotificationType(Enum):
WELCOME = "welcome"
PASSWORD_RESET = "password_reset"
ORDER_CONFIRM = "order_confirm"
TEMPLATES = {
NotificationType.WELCOME: {
"subject": "Welcome to MyApp, {name}!",
"body": "Hi {name},\n\nYour account is ready. Log in at https://myapp.example.com\n\nWelcome aboard!"
},
NotificationType.PASSWORD_RESET: {
"subject": "Reset your MyApp password",
"body": "Hi {name},\n\nClick here to reset your password (valid 30 min):\nhttps://myapp.example.com/reset/{token}\n\nIgnore this if you didn't request it."
},
NotificationType.ORDER_CONFIRM: {
"subject": "Order #{order_id} confirmed",
"body": "Hi {name},\n\nYour order #{order_id} for {item} has been confirmed.\nEstimated delivery: {delivery_date}\n\nThank you for your purchase!"
},
}
@dataclass
class Notification:
to: str
type: NotificationType
context: dict # template variables
SMTP_CONFIG = dict(
hostname="smtp.gmail.com", port=465,
username="notifications@myapp.example.com",
password="your-app-password", use_tls=True
)
async def send_notification(notif: Notification, retries: int = 3) -> bool:
"""Send a single notification with automatic retry on transient errors."""
template = TEMPLATES[notif.type]
subject = template["subject"].format(**notif.context)
body = template["body"].format(**notif.context)
msg = EmailMessage()
msg["From"] = SMTP_CONFIG["username"]
msg["To"] = notif.to
msg["Subject"] = subject
msg.set_content(body)
for attempt in range(1, retries + 1):
try:
await aiosmtplib.send(msg, **SMTP_CONFIG)
print(f"[OK] {notif.type.value} → {notif.to}")
return True
except aiosmtplib.SMTPRecipientsRefused:
print(f"[SKIP] Invalid address: {notif.to}")
return False # don't retry invalid addresses
except (aiosmtplib.SMTPConnectError, aiosmtplib.SMTPServerDisconnected) as e:
if attempt < retries:
await asyncio.sleep(2 ** attempt) # exponential backoff
else:
print(f"[FAIL] {notif.to} after {retries} attempts: {e}")
return False
async def process_notification_queue(queue: list[Notification]) -> None:
"""Process all notifications concurrently with a max of 10 parallel connections."""
semaphore = asyncio.Semaphore(10)
async def guarded_send(n: Notification) -> bool:
async with semaphore:
return await send_notification(n)
results = await asyncio.gather(*[guarded_send(n) for n in queue])
sent = sum(results)
print(f"\nQueue complete: {sent}/{len(queue)} notifications delivered.")
# Example notification queue
notifications = [
Notification("alice@example.com", NotificationType.WELCOME, {"name": "Alice"}),
Notification("bob@example.com", NotificationType.ORDER_CONFIRM,
{"name": "Bob", "order_id": "5501", "item": "Python Handbook",
"delivery_date": "May 22, 2026"}),
Notification("carol@example.com", NotificationType.PASSWORD_RESET,
{"name": "Carol", "token": "abc123xyz"}),
]
asyncio.run(process_notification_queue(notifications))
Output:
[OK] welcome → alice@example.com
[OK] order_confirm → bob@example.com
[OK] password_reset → carol@example.com
Queue complete: 3/3 notifications delivered.
This system handles three real-world concerns: template-based messages that keep HTML/text out of application logic, a semaphore that caps concurrent SMTP connections at 10 (preventing server-side rate limiting), and per-notification retry with exponential backoff for transient network errors. Extend it by reading the queue from a database, persisting failed notifications for a dead-letter queue, or integrating with a task queue like Celery or asyncio.Queue for continuous processing.
Frequently Asked Questions
Why does Gmail reject my login even with the correct password?
Gmail requires App Passwords for third-party SMTP access when 2-Step Verification is enabled, which it should always be. Go to Google Account → Security → 2-Step Verification → App passwords, generate a 16-character password, and use that in your code. Never use your main Gmail password — Google blocks it by default through a security setting called “Less secure app access,” which is being phased out entirely.
How do I use aiosmtplib inside a FastAPI endpoint?
Call await aiosmtplib.send(...) directly inside your async def endpoint — no special setup needed since FastAPI already runs in an asyncio event loop. For fire-and-forget sends that shouldn’t delay the HTTP response, use asyncio.create_task(send_email(...)) inside the endpoint and return the response immediately. Be careful with this pattern: if the task fails after the response is sent, you’ll need to log errors explicitly since there’s no caller waiting for the result.
How do I test code that sends emails without actually sending them?
Run python -m smtpd -n -c DebuggingServer localhost:1025 to start a local debug SMTP server that prints emails to stdout instead of delivering them. Point your code at hostname="localhost", port=1025 with no authentication or TLS. For unit tests, mock aiosmtplib.send with unittest.mock.AsyncMock — this lets you assert what arguments it was called with without any network access.
How do I add CC and BCC recipients?
Set msg["Cc"] = "cc@example.com" on the EmailMessage object for CC. For BCC, do not set a Bcc header — instead, pass the BCC addresses directly in the recipients parameter of aiosmtplib.send(): await aiosmtplib.send(msg, recipients=["to@example.com", "bcc@example.com"], ...). The SMTP server delivers to all addresses in recipients but only the To and Cc headers appear in the message, keeping BCC addresses hidden.
How do I set a connection timeout?
Pass timeout=30 (seconds) to aiosmtplib.send() or the SMTP constructor. This sets both the connection timeout and the per-command timeout. If the SMTP server is unresponsive, the coroutine raises aiosmtplib.SMTPConnectError after the timeout rather than hanging indefinitely. Always set a timeout in production code — the default is no timeout, which can cause your event loop to stall silently on a network failure.
Conclusion
aiosmtplib brings proper async support to Python email sending without requiring you to change how you build messages — the standard library’s EmailMessage, MIMEMultipart, and MIMEText work exactly as before. You learned how to connect with TLS and STARTTLS, build HTML emails with plain-text fallbacks, attach files, send to multiple recipients concurrently with asyncio.gather(), reuse a single SMTP connection for batch sends, and build a production-ready notification system with retry logic and connection limiting.
The notification system is a practical starting point — extend it by storing unsent notifications in a database for durability, adding Jinja2 templating for richer HTML emails, integrating with a webhook receiver to trigger sends on application events, or wrapping it behind a simple HTTP API so other services can request notifications without knowing the SMTP details. Official documentation: aiosmtplib.readthedocs.io.