asyncio
python
send mail
asynchronous programming
email automation

send mail python asyncio

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Sending email is a network-bound task, so it fits well with Python's asyncio model. The main idea is simple: while one message is waiting on an SMTP server, the event loop can schedule other work instead of blocking the whole program.

Why Async Email Matters

The standard smtplib module is synchronous. It works well for a script that sends one message, but it becomes inefficient when an application needs to send many notifications, password resets, or status emails. With asyncio, you can queue many send operations and let the runtime overlap the network waits.

One important detail is that asyncio does not make blocking libraries non-blocking by itself. You either need an async SMTP client, or you need to move synchronous SMTP work onto a thread so the event loop stays responsive.

Sending Mail with aiosmtplib

The cleanest option is to use an SMTP client built for asyncio. The aiosmtplib package exposes async methods that work naturally with await.

python
1import asyncio
2from email.message import EmailMessage
3
4import aiosmtplib
5
6
7async def send_email(recipient: str, subject: str, body: str) -> None:
8    message = EmailMessage()
9    message["From"] = "[email protected]"
10    message["To"] = recipient
11    message["Subject"] = subject
12    message.set_content(body)
13
14    await aiosmtplib.send(
15        message,
16        hostname="smtp.example.com",
17        port=587,
18        start_tls=True,
19        username="smtp-user",
20        password="smtp-password",
21    )
22
23
24async def main() -> None:
25    await send_email(
26        "[email protected]",
27        "Async delivery",
28        "This message was sent without blocking the event loop.",
29    )
30
31
32asyncio.run(main())

This approach is usually the best fit for web backends and worker processes. The email construction stays the same as the synchronous version, but the SMTP handoff becomes awaitable.

Sending Many Messages Concurrently

Async code becomes more useful when you send multiple messages. asyncio.gather lets you schedule several tasks at once.

python
1import asyncio
2
3
4async def send_batch() -> None:
5    recipients = [
6        "[email protected]",
7        "[email protected]",
8        "[email protected]",
9    ]
10
11    tasks = [
12        send_email(email, "Weekly update", "Here is the latest report.")
13        for email in recipients
14    ]
15    await asyncio.gather(*tasks)
16
17
18asyncio.run(send_batch())

That said, more concurrency is not always better. SMTP providers often limit connections or message rates. In real systems, cap concurrency with a semaphore so you do not open too many parallel sessions.

python
1semaphore = asyncio.Semaphore(5)
2
3
4async def safe_send(recipient: str) -> None:
5    async with semaphore:
6        await send_email(recipient, "Notice", "Limited concurrency example.")

This pattern protects both your application and the mail server.

If You Must Use smtplib

Sometimes a project already depends on smtplib, or an organization has a wrapper around it. In that case, you can still integrate it with asyncio by moving the blocking call to a worker thread with asyncio.to_thread.

python
1import asyncio
2import smtplib
3from email.message import EmailMessage
4
5
6def send_sync_email(recipient: str, subject: str, body: str) -> None:
7    message = EmailMessage()
8    message["From"] = "[email protected]"
9    message["To"] = recipient
10    message["Subject"] = subject
11    message.set_content(body)
12
13    with smtplib.SMTP("smtp.example.com", 587) as server:
14        server.starttls()
15        server.login("smtp-user", "smtp-password")
16        server.send_message(message)
17
18
19async def send_via_thread(recipient: str) -> None:
20    await asyncio.to_thread(
21        send_sync_email,
22        recipient,
23        "Threaded SMTP",
24        "The SMTP call runs outside the event loop.",
25    )

This is a practical bridge, but it is still using threads under the hood. If email sending is a central part of the system, a native async client is usually easier to reason about.

Error Handling and Retries

SMTP operations fail for normal reasons: network loss, authentication problems, TLS misconfiguration, and provider throttling. Wrap send calls in try and except, log the error, and decide whether the message should be retried.

Transient failures are often handled with a queue and retry policy rather than an immediate loop of repeated sends. That prevents accidental bursts against a struggling provider and makes delivery behavior predictable.

Common Pitfalls

Trying to await smtplib directly will not work because its methods are blocking and not coroutines.

Opening unlimited concurrent SMTP connections can trigger rate limits or connection failures. Use a semaphore or a background queue.

Building the message inside async code is fine, but keep CPU-heavy template rendering separate if it becomes expensive.

Hardcoding SMTP credentials in source files is unsafe. Move them into environment variables or a secrets manager.

Forgetting TLS settings is common. Verify whether your provider expects start_tls=True on port 587 or implicit TLS on port 465.

Summary

  • 'asyncio is a good fit for email because SMTP is mostly waiting on the network.'
  • Use an async SMTP library such as aiosmtplib when possible.
  • If a project still uses smtplib, run it in a worker thread with asyncio.to_thread.
  • Limit concurrency so bulk sending does not overwhelm the provider.
  • Handle retries, TLS, and credentials carefully in production code.

Course illustration
Course illustration

All Rights Reserved.