Recently, I said C# and PHP can’t receive email, and Python is no different 😀. If you read either of those guides, this one will look familiar on purpose: the same three doors, the same measurements, a different language. Skip to the code if you have been here before.
There is no socket.listen_for_mail(), and pip won’t give you one. Instead, Python reacts to an email that something else has already accepted. Again, we’ve got exactly three somethings:
- An inbound email API that POSTs a webhook to your app
- An IMAP mailbox you read
- A mail server you run yourself
Following my inbound coding spree, I built all three ways to receive email in Python. They run on versions 3.13 and 3.14, and I timed them. Here’s the scoop.
The API path took a hosted inbox from nothing to a parsed message in a Flask route in a few minutes, and to a row in a Django database in about thirty lines.
The IMAP path taught me that the half-second everyone blames on Python isn’t Python. The self-hosted path was the fastest, and the one I would least like to own.
If your app has to ingest mail, whether that is a support pipeline turning replies into tickets or an agent reading its own inbox, read on.
Sending is a different job. If you searched for “Python receive email” and got smtplib tutorials, that is the SERP for this phrase, not you. If you want to send and receive emails from the same app, the sending half lives in our Python send email tutorial.
How to receive email in Python: three ways, compared
Use the inbound email API unless you already own the mailbox or already run the mail server. It’s the only option that hands you parsed JSON and needs no DNS to start, and replying is a method call on the same client.
Here is the whole decision in one table, with the numbers I measured on 22nd September 2026:
| Approach | Needs a domain? | Needs a mailbox? | Auth | Who parses MIME | Time to your code (measured) | Best for |
|---|---|---|---|---|---|---|
| Mailtrap Inbound Email API | No (hosted inbox) or yes (catch-all on your domain) | No | API token + webhook signing secret | Mailtrap | Stored in 328 ms; webhook on a ~30 s tick | Your app is the destination |
| IMAP (imaplib, imap-tools, IMAPClient) | No | Yes | App password or OAuth 2.0 | You, with email | ~515 ms IDLE notification on Dovecot | Reading a mailbox you already have |
| Your own SMTP server (aiosmtpd) | Yes, plus port 25 and TLS | No | None; you are the server | You, with email | 7 ms to your handler | You must be the MX |
I highlighted this in a few other tutorials, but it won’t hurt to repeat it for the Python crowd. POP3 (the Post Office Protocol) downloads and deletes, IMAP keeps the mailbox in sync. Everything below uses IMAP. If you need the long version, read POP3 vs IMAP.
Prerequisites
- Python 3.9 or newer for the Mailtrap SDK. Python 3.14 if you want IMAP IDLE from the standard library (more on that below).
pip install mailtrap(2.10.0 at the time of writing) for the API path. imaplib and email are in the standard library.pip install imap-toolsorpip install IMAPClientare optional wrappers. And if you’re on a self-hosted path, usepip install aiosmtpd; the old smtpd module was removed in Python 3.12, so import smtpd fails on anything current.- A Mailtrap account for path one. Inbound Email ships with Email API/SMTP; it is not a separate purchase.
- For IMAP, a mailbox and its credentials. For aiosmtpd, a host the internet can reach. The snippets refer to the token, the account ID and the webhook signing secret as
API_TOKEN,ACCOUNT_IDandSIGNING_SECRET; load those from environment variables rather than pasting them into code.
Receive and reply to email with the Mailtrap Inbound Email API (recommended)
Short version:
- Create an inbox through the API
- Point a webhook at your app
- Fetch each email as JSON
- Reply from the same API
No MX records and no MIME parser; you won’t import smtplib or open an SMTP_SSL connection once.
Create a hosted inbox and get its inbound email address
The SDK does the whole provisioning step. You need an API token and your account ID; GET https://mailtrap.io/api/accounts with the token returns the ID if you don’t have it handy.
import mailtrap as mt
client = mt.MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID)
folder = client.inbound_api.folders.create(mt.CreateInboundFolderParams(name="support"))
inbox = client.inbound_api.inboxes.create(folder.id, mt.CreateInboundInboxParams(name="tickets"))
print(inbox.address) # tickets-c50d7ea5@inbound-mailtrap.io
That address works immediately. If you would rather receive at anything@yourdomain.com, verify the domain in the dashboard, enable “Inbound domain receiving”, add the MX record it gives you, and pass domain_id when you create the inbox. A custom-domain inbox is a catch-all: every username under the domain lands in it.
Inbound email webhook or poll: what actually arrives in Flask
Register a webhook for the inbox. For local development you need a public URL; I ran cloudflared tunnel --url http://localhost:5001 and used the address it printed. The create call is the only place the API returns the signing secret, so store it (an environment variable or your secrets manager) before you do anything else. I lost one to a crash between create and save; you can read or reset it in the webhook’s details in the dashboard, but there is no API call to fetch it again.
hook = client.webhooks_api.webhooks.create(mt.CreateWebhookParams(
url="https://your-app.example.com/webhook",
webhook_type="inbound_receiving",
inbound_inbox_id=inbox.id,
))
SIGNING_SECRET = hook.signing_secret # returned once; keep it
Now, there’s a thing you should pay attention to. The webhook carries no email, only a batch of small events. The Inbound Email API signs the request body with HMAC-SHA256 and puts the hex digest in a Mailtrap-Signature header; you verify against the raw bytes, then fetch each message by ID.
Here is the Flask route I ran in late September:
from flask import Flask, request
import mailtrap as mt
app = Flask(__name__)
client = mt.MailtrapClient(token=API_TOKEN)
@app.post("/webhook")
def webhook():
raw = request.get_data() # bytes, exactly as sent
sig = request.headers.get("Mailtrap-Signature", "")
if not mt.verify_signature(raw, sig, SIGNING_SECRET):
return "bad signature", 401
for event in request.get_json()["events"]: # a batch, not one message
msg = client.inbound_api.messages.get_by_id(event["inbox_id"], event["message_id"])
handle(msg)
return "", 200
And, based on my lab tests, there are two measurements you should design around.
First, the raw body isn’t optional. I verified 5 of 5 deliveries against request.get_data() and 0 of 5 after json.dumps(json.loads(body)), with default, compact or indented separators. The bytes differ, the digest differs, the check fails.
Therefore, if your framework parses the body before your code runs, find the raw-body accessor (Django’s is request.body) or the signature will never match and you’ll blame the secret.
Second, the webhook runs on a schedule. I sent 20 messages 1.5 seconds apart and they arrived in three POSTs, 5, 10 and 5 of them per batch (the third POST also carried two messages I sent afterwards); two isolated sends later on arrived as single-event POSTs.
The POSTs themselves landed 29.5, 30.0, 30.2 and then 60.0 seconds apart. The Inbound Email API collects events and flushes them every 30 seconds if there is anything to flush. This is by design and exactly what the webhook docs say.
Simply, the webhook is a bus, not a taxi: it leaves on schedule whether one message is aboard or ten.
So: median 21 seconds from send to webhook in my burst, 875 ms at best, 29.7 s at worst. The email itself was stored 328 ms after I sent it (p95 640 ms).
But don’t frown thinking we built a snail instead of a webhook, it’s Jabba the Hutt in disguise. If half a minute is too slow for your use case, poll the list endpoint instead. It paginates with last_id and the email is there in well under a second. Loop over events, never assume one.
To the above, if your endpoint is down, the API retries every five minutes for about three hours and then pauses the webhook and emails you. Return a 200 quickly and do the work afterwards. Error handling belongs in that background job: a handler that raises on one bad message fails the whole POST, and the retry brings the same batch back.
Read the parsed email: body, headers and attachments
The object you get back is already parsed. No MIME, no charset guessing, no walking a tree. (handle, save and the other lowercase helpers in this article are yours to write; everything else ran as shown.)
import requests
def handle(msg):
print(msg.from_, msg.subject, msg.received_at) # 'from' is a keyword, hence from_
print(msg.text_body or msg.html_body)
print(msg.headers["mime-version"], msg.thread_id)
for a in msg.attachments:
data = requests.get(a.download_url, timeout=60).content
save(a.filename, a.content_type, data)
Every email carries thread_id, so a reply that comes back three days later is already grouped with the original. Attachments arrive as signed URLs with a filename, a content type, a disposition and, for inline images, a content_id.
The rich test message I sent had plain text, HTML, a PDF, an inline PNG and a subject with an em dash and a check mark. It came back with both attachments listed and both bodies split out, which is more than Python’s own parser managed on the same bytes; the parsing section has that story.
Note: Download attachments soon after you fetch, the system keeps URLs valid for an hour. A fresh get_by_id gives you fresh URLs, so re-fetching is cheap.
Reply to the email without smtplib
Annoyingly, most “how to receive email in Python” tutorials end with the body parsed, and then switch to a second tutorial about smtplib, MIMEText, MIMEMultipart and smtp.gmail.com to answer the email.
You don’t need any of that here. The reply is a method on the same API, threaded correctly, sent from the inbox that received the original:
client.inbound_api.messages.reply(
inbox_id, msg.id,
mt.ReplyInboundMessageParams(text="Thanks, we've opened ticket #4821 and will be in touch."),
)
Also, reply_all and forward work the same way, and none of them needs MIMEText or an SMTP_SSL session. Transactional sending from the same app is Email API/SMTP with the same token; the receive-and-answer loop above never touches it.
This is where the article has its one ask: if your Python app is the destination for email, start with the Mailtrap Inbound Email API. It’s the only path in this guide where I didn’t have to write a parser.
Receive email in Django with django-anymail
If you are on Django (and sending from Django already), there is now a shorter road than the Flask route above. django-anymail 15.2 (released 2026-09-05) added the Mailtrap inbound webhook.
anymail verifies the signature, fetches the email from the API and hands you a normal Django signal. I had it store a message in a model in about thirty lines, the model included:
# settings.py
import os
INSTALLED_APPS += ["anymail", "intake"]
ANYMAIL = {
"MAILTRAP_API_TOKEN": os.environ["MAILTRAP_API_TOKEN"],
"MAILTRAP_INBOUND_SECRET": os.environ["MAILTRAP_INBOUND_SECRET"], # the webhook's signing secret
}
# urls.py
from django.urls import include, path
urlpatterns += [path("anymail/", include("anymail.urls"))]
# intake/apps.py
class IntakeConfig(AppConfig):
name = "intake"
def ready(self):
from . import signals # registers the receiver at startup; without this it never fires
# intake/models.py
class InboundEmail(models.Model):
message_id = models.CharField(max_length=64, unique=True)
from_email = models.CharField(max_length=254)
subject = models.CharField(max_length=998, blank=True)
text = models.TextField(blank=True)
html = models.TextField(blank=True)
attachments = models.JSONField(default=list)
# intake/signals.py
from django.dispatch import receiver
from anymail.signals import inbound
from .models import InboundEmail
@receiver(inbound)
def store(sender, event, esp_name, **kwargs):
m = event.message # an EmailMessage subclass
InboundEmail.objects.create(
message_id=event.esp_event["id"],
from_email=str(m.from_email),
subject=m.subject or "",
text=m.text or "",
html=m.html or "",
attachments=[(a.get_filename(), a.get_content_type()) for a in m.attachments],
)
Point the inbox’s webhook at https://your-site/anymail/mailtrap/inbound/ and you are done. In my run, 5 of 5 emails became database rows, with a median of 14.1 seconds from send to signal (5.8 to 29.4 s, the same 30-second tick as above plus Anymail’s own fetch).
An unsigned POST to that URL gets a 400 with the message “Mailtrap webhook called without signature”, which is the right kind of failure. Your API token needs viewer permission on the inbox, and event.esp_event holds the full API response if you need a field the signal doesn’t surface.
The package most people find first for this is django-mailbox, but it does a different job. The package polls IMAP or POP3 on a cron, or takes a Postfix pipe on stdin, and its last release was April 2024. It still works, however, you own the mailbox and the schedule.
Give an AI agent its own inbound email address
An agent gets an inbox the same way, with the same inboxes.create call, and receives the same JSON. Nothing about the API is agent-shaped; the difference is what reads it. If you are building that, the Agent Inbox page has the framing and the same endpoints.
Inbound email limits: reply cap, message size, URL expiry
- Replies from hosted inboxes are capped, and the cap is account-wide, not per inbox. On 2026-09-22 the very first
reply()from a brand-new inbox on my test account came back withAPIError: Sending usage of this inbox has reached its limit., because another inbox on the same account had used the allowance the day before. The cap doesn’t care which door the mail came in through. The product page says connecting a custom domain lifts the limit. Treat hosted addresses as a way to receive and prototype; but put replies on a domain you own, with SPF and DKIM in place, because that is also where their deliverability rests. - Size is plan-tiered: 10 MB encoded on lower tiers, up to 30 MB on Business and Enterprise. Oversize mail is rejected at SMTP, nothing is stored and no webhook fires, so your app never sees it. Log the sender’s bounce, not your webhook.
- Signed URLs expire; see above. Fetch, download, move on.
Read email in Python over IMAP with imaplib
Use this path when the mail already lives in a mailbox you control: a shared support address on Google Workspace, a Microsoft 365 mailbox, your own Dovecot. imaplib ships with Python and can do everything below. Two wrappers make it pleasant: imap-tools (1.15.0, August 2026) and IMAPClient (4.1.0, September 2026). I used all three.
Python IMAP authentication for Gmail and Microsoft 365: app passwords and OAuth 2.0
The connect line, an IMAP4_SSL session over TLS on port 993, has not changed in a decade. The authentication line has.
import imaplib
M = imaplib.IMAP4_SSL("imap.gmail.com", 993)
Gmail. An app password still works, with conditions: your account needs 2-Step Verification, it must not be a work or school account, and it cannot be on security-key-only 2SV or Advanced Protection.
Google’s own page adds that app passwords “aren’t recommended and are unnecessary in most cases”. For a script on your laptop, M.login(user, app_password) is fine. For anything that runs unattended, use OAuth 2.0 with the https://mail.google.com/ scope and authenticate with XOAUTH2:
def xoauth2(user, token):
return lambda _challenge: f"user={user}\x01auth=Bearer {token}\x01\x01".encode()
M.authenticate("XOAUTH2", xoauth2("support@example.com", access_token))
imaplib base64-encodes what your callable returns; the \x01 bytes are the Control-A separators the protocol wants. Gmail IMAP sessions on OAuth last about as long as the access token, usually an hour, so refresh and reconnect.
Microsoft 365. Passwords are gone. Basic authentication is disabled in every tenant and cannot be switched back on; Microsoft removed the switch.
Register an app in Entra and request the https://outlook.office.com/IMAP.AccessAsUser.All scope for a user login, or IMAP.AccessAsApp for a daemon; the daemon route also needs an admin to run New-ServicePrincipal and Add-MailboxPermission in Exchange Online PowerShell.
Get a token with msal and send the same XOAUTH2 string to outlook.office365.com. If you were about to reach for exchangelib instead, don’t: it speaks Exchange Web Services, and Microsoft starts blocking EWS from non-Microsoft apps on 2026-10-01. Read the mailbox through Microsoft Graph (the O365 package wraps it) if IMAP won’t do.
Both wrappers hide the string building. imap-tools has mailbox.xoauth2(user, token); IMAPClient has client.oauth2_login(user, token).
And yes, it’s one of the most complicated, you-jump-through-hoops setups, where it’s kinda easy to miss a step and get back to the start.
Get unread messages: search, fetch, mark read
The stdlib flow is select, search, fetch, flag.
M.select("INBOX")
typ, data = M.search(None, "UNSEEN", "SUBJECT", '"invoice"')
for num in data[0].split():
typ, parts = M.fetch(num, "(RFC822)")
raw = parts[0][1] # bytes: the whole MIME message
process(raw) # see the parsing section
M.store(num, "+FLAGS", "\\Seen")
There is no expunge() call: it permanently removes every message flagged \Deleted, which this loop never sets. RFC822 gets you the raw message; imaplib won’t parse it, and the search syntax belongs to IMAP. The same in imap-tools reads like Python:
from imap_tools import MailBox, A
with MailBox("imap.gmail.com").login(user, app_password, "INBOX") as mailbox:
for msg in mailbox.fetch(A(seen=False, subject="invoice")):
print(msg.date, msg.from_, msg.subject, msg.text or msg.html)
for att in msg.attachments:
save(att.filename, att.content_type, att.payload)
Choose by taste: the stdlib costs nothing and does everything; the wrappers cost one dependency and save you the parser. Either way, catch imaplib.IMAP4.error around login and fetch; error handling on IMAP is mostly reconnecting after the server drops a quiet session.
Python IMAP IDLE: stop polling, by Python version
Here is the pain that started this deep dive. An ancient python-related Reddit thread has someone who “every 10 seconds” logs in with imaplib and searches, worried that it “might upset the email server”, and asking how to get pushed instead.
Two commenters pointed at IMAP IDLE, one of them naming imaplib2, and that same one also suggested parsing Gmail’s Atom feed with feedparser.
It would be a great start of coding pulp fiction, yet that advice is from 2019 (though it still pops up in basic search) and both halves have expired: imaplib2’s last release was June 2021, and app-password feeds are not something I would put in production.
Being pedantic and kinda in love with Python, I want to end the conundrum 😀 Aaaand the answer in 2026 depends on your Python version.
Python 3.14 and later have IDLE in the standard library. IMAP4.idle() returns a context manager you iterate; leaving the block sends DONE.
M.select("INBOX")
while True:
with M.idle(duration=29 * 60) as idler: # servers drop idle sessions around 30 min
for typ, data in idler:
if typ == "EXISTS":
break # leave the block first: that sends DONE
fetch_new(M) # the connection is free for FETCH again
You cannot FETCH while IDLE is active (RFC 2177 wants DONE first), which is why the fetch sits outside the with block and the loop re-enters IDLE afterwards. If that re-entry cost matters, fetch on a second connection and let this one idle.
idler.burst() collects every response that arrives within 0.1 s of the first, which is what you want after a bulk delete.
Python 3.8 to 3.13 have no idle(); on 3.13 you get AttributeError: Unknown IMAP4 command: 'idle'. So, use a wrapper:
# imap-tools
responses = mailbox.idle.wait(timeout=60) # [] on timeout
if any(line.endswith(b"EXISTS") for line in responses):
fetch_new(mailbox)
# IMAPClient
client.idle()
responses = client.idle_check(timeout=60) # [(1, b'EXISTS'), (1, b'RECENT')]
client.idle_done()
IMAPClient’s docs tell you to renew the IDLE every ten minutes; the stdlib docs say keep duration under 29. Either way, put it in a loop.
Now the number that surprised me. I ran all three clients against a local Dovecot 2.4.5, appended five emails each and timed the notification, here’s the breakdown:
stdlib516 msimap-tools515 msIMAPClient514 ms- A bare TLS socket speaking IDLE by hand, 516 ms
Four ways to ask, one answer, always half a second.
That half second is Dovecot’s NOTIFY_DELAY_MSECS. It’s a compiled-in 500 ms coalescing delay in its mailbox watcher, and I can only suppose there’s a method to the wait. In contrast, a NOOP every 20 ms saw the same message in 17 ms.
Anyway, once an IDLE session had been quiet long enough for Dovecot to hand it to its hibernation process (five seconds on the official image), notifications arrived in about 30 ms.
Two lessons to share from this archaeology:
- The Python library is not where the time goes, and a long-lived
IDLEbeats a loop that sendsDONEand re-IDLEs after every email by half a second each time. Gmail and Microsoft run their own notify pipelines, and I didn’t measure those. For comparison, the poll loop from that Reddit thread had a median of 807 ms in my test and ranged from 69 ms to 1.6 s depending on where in the two-second window the mail landed. - The first untagged line inside
IDLEisn’t alwaysEXISTS. Dovecot sent me* OK Still heretwo seconds in and my first test script recorded a negative latency. Filter on the response type. (Yes, the server is allowed to make small talk duringIDLE. RFC 2177 says so.)
Parse incoming email in Python with the email module
You need this section on the IMAP and aiosmtpd paths. On the API path the email is already JSON and you can skip ahead.
Python’s email package has two APIs living in one namespace:
- The modern one behind
policy.default(EmailMessage,get_body,iter_attachments) - The legacy one behind the default
compat32policy (walk,get_payload,decode_header)
Most tutorials you’ll find, and the top results for this query, use the legacy one. I parsed the same real email both ways: the one from the webhook test, fetched through raw_message_url, with text plus HTML, an inline PNG, a PDF and a UTF-8 subject.
Extract the email headers
from email import policy
from email.parser import BytesParser
msg = BytesParser(policy=policy.default).parsebytes(raw)
print(msg["subject"]) # 'B5 rich — ünïcode ✓ multipart', already decoded
print(msg["from"].addresses[0].addr_spec)
print(msg["date"].datetime)
print(len(msg.get_all("Received", []))) # 2 hops
With policy.default, email headers are typed: the subject is a decoded string, from gives you parsed addresses, date a datetime. The legacy parser hands you =?UTF-8?q?B5_rich_=E2=80=94_=C3=BCn=C3=AFcode_=E2=9C=93_multipart?= and a job for decode_header and make_header. Headers can repeat; get_all is the honest accessor for Received and friends.
Get the plain-text vs HTML body
body = msg.get_body(preferencelist=("plain", "html"))
text = body.get_content() # str, charset handled
Pass the preference list; without it the default is ("related", "html", "plain"). On my test email, an HTML email with an inline image, get_body() returned the multipart/related container.
Important Note: Calling get_content() on it raised KeyError: 'multipart/related', which is not the error message you want at 2 a.m.
The legacy equivalent, msg.get_payload(decode=True) on the top-level object, returns None for any multipart, which is the “body is None” bug that fills the forums. If you must walk the legacy way, take the first text/plain leaf from walk(), skip parts where is_multipart() is true, and decode with the part’s own charset.
Extract email attachments in Python and save them
import os
for part in msg.iter_attachments():
print(part.get_filename(), part.get_content_type()) # invoice-B5.pdf application/pdf
name = os.path.basename(part.get_filename() or "attachment") # never trust a sender's path
with open(name, "wb") as f:
f.write(part.get_content())
The iter_attachments() command yields the PDF, not an inline PNG, because the PNG lives inside the multipart/related body candidate rather than beside it. The legacy rule that “disposition equals attachment” works the same. If you also build outgoing mail with EmailMessage, this is the mirror image of add_attachment() and add_alternative(): what those put in, iter_attachments() and get_body() take out.
If inline images matter to you (they do for anything that stores HTML mail), walk the tree and keep parts whose get_content_disposition() is inline or whose content type starts with image/. Then, expect application/octet-stream from senders who never set a MIME type; mimetypes.guess_type(filename) is the usual fallback.
For scale, the modern parse took 2.5 ms on 33 KB and the legacy one 0.8 ms; however, you’ll never notice the difference.
Run your own SMTP listener in Python with aiosmtpd, when your process must be the MX
Sometimes your process has to be the thing the internet delivers to, speaking the Simple Mail Transfer Protocol itself. aiosmtpd (1.4.6) is the replacement for the smtpd module Python removed in 3.12, and the minimum is small:
from aiosmtpd.controller import Controller
from email import policy
from email.parser import BytesParser
class Handler:
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
envelope.rcpt_tos.append(address) # accept every address: catch-all
return "250 OK"
async def handle_DATA(self, server, session, envelope):
msg = BytesParser(policy=policy.default).parsebytes(envelope.content)
store(envelope.mail_from, envelope.rcpt_tos, msg)
return "250 Message accepted for delivery"
controller = Controller(Handler(), hostname="0.0.0.0", port=8025)
controller.start()
On loopback, ten emails with a PDF each reached handle_DATA a median 7.1 ms after smtplib.send_message started sending (the 250 reached the client at 12.5 ms, so the handler ran before the send call returned), and parsing added 4.6 ms.
Nothing else in this article is that fast. Also, nothing else in this article makes you responsible for port 25, TLS, SPF and DKIM checks, spam filtering, retry queues and the 3 a.m. page either.
Before you choose this path, read what running an SMTP server involves and what spam filters do to a fresh IP. The handler above is where the fun stops and the operations begin.
Route a catch-all email address to one Python handler
All three paths can accept anything@yourdomain.com and route on the local part.
- Inbound Email API: a custom-domain inbox is a catch-all by design. One webhook, and msg.to tells you whether it was billing@ or ticket-4821@. The hosted @inbound-mailtrap.io addresses are one inbox each. If you want per-customer addresses without a domain, create one inbox per customer, which is one API call.
- aiosmtpd: the
handle_RCPTabove already accepts every recipient. Add a domain check and a routing dict. - IMAP: the catch-all is a mailbox-provider setting, not a Python one; your code just reads
To.
Auto-replies are a reply() call on the API path and a sending job everywhere else. Don’t put auto-reply logic in a catch-all without an allow-list; the first backscatter loop teaches that lesson for you.
Wrapping up: which way to receive email in Python
Pick by destination. If your application is where the mail is supposed to end up, use the Mailtrap Inbound Email API: parsed JSON, a signed webhook on a 30-second tick. Or use a poll that sees the message in under a second, threads, and replies from the same client.
If the mail lives in a mailbox you already own, read it over IMAP, authenticate with OAuth 2.0 because passwords are mostly gone, and use IDLE (stdlib on 3.14, a wrapper below that) instead of a ten-second loop.
If you have to be the mail server, aiosmtpd will receive in seven milliseconds and then hand you every other problem.
Whichever path you take, that is how to receive email in Python in 2026: let something accept the mail, verify it, read the parts you need, and answer from the same place. The next time you need to read email in Python, the time will go into your handler. MIME can have the afternoon off.
FAQ
How do you receive email in Python: IMAP or POP3?
IMAP, unless you want the server to forget the mail after you download it. IMAP keeps the mailbox in sync, supports search and flags, and has IDLE for push. POP3 is download-and-delete. If your application is the destination rather than a reader of someone’s mailbox, skip both and use an inbound email API that delivers parsed JSON to a webhook.
Should you use imaplib or a helper library like imap-tools or IMAPClient?
imaplib is enough for select, search, fetch and flag, and it’s already installed. Reach for imap-tools or IMAPClient when you want parsed messages and attachments without touching the email module, an xoauth2 helper, or IDLE on Python 3.13 and older. On 3.14 the standard library has IDLE too.
How do you connect to Gmail or Outlook over IMAP now that OAuth replaced passwords?
Gmail: Use an app password if the account has 2-Step Verification and is not a Workspace, security-key-only or Advanced Protection account. Otherwise, and for anything unattended, OAuth 2.0 with the https://mail.google.com/ scope sent as XOAUTH2.
Microsoft 365: Use OAuth 2.0 only, scope https://outlook.office.com/IMAP.AccessAsUser.All, via an Entra app registration; basic authentication is disabled in every tenant, and Exchange Web Services is being switched off for third-party apps from October 2026, so use IMAP with XOAUTH2 or Microsoft Graph.
How do you parse the body and extract attachments from a received email?
On the Inbound Email API path this is already done: text_body, html_body and a list of attachments with download URLs.
On the IMAP or aiosmtpd path, parse with BytesParser(policy=policy.default), take the body with get_body(preferencelist=("plain", "html")), and iterate iter_attachments(). Pass the preference list, or a message with an inline image hands you a multipart/related container instead of text; and walk the tree yourself if inline images matter, because iter_attachments() skips them.
How do you get notified of new email instead of polling?
Use IMAP IDLE: IMAP4.idle() in Python 3.14, or idle.wait() in imap-tools and idle_check() in IMAPClient on older versions. Keep the session alive and re-issue IDLE before the server’s timeout instead of re-entering it after every message; on Dovecot that is the difference between 30 ms and 500 ms. If your app owns the address, a webhook from an inbound API removes the connection entirely.
How do you receive inbound email in a Flask or Django app?
Give the app an inbox from the Mailtrap Inbound Email API and point the inbox’s webhook at a route. In Flask, verify Mailtrap-Signature against the raw request body with the SDK’s verify_signature, then fetch each message in the events array by ID. In Django, install django-anymail 15.2 or newer, set MAILTRAP_INBOUND_SECRET, include anymail.urls, and handle the inbound signal; the message arrives already fetched and parsed.