How to Send Email from Python (and Read the Reply)
Python sends email with two standard-library modules: email.message.EmailMessage builds the message and smtplib hands it to a mail server. With Gmail as that server, you log in with a 16-character app password, because Google no longer accepts your normal account password over SMTP.
That covers sending. Almost every tutorial stops there, and the first time someone replies, your script can’t see it.
The quick answer: create a Gmail app password, then run the 12 lines below: EmailMessage for the message, smtplib.SMTP_SSL("smtp.gmail.com", 465) to deliver it. If your code also has to read and answer replies (an AI agent, a support bot, an approval flow), give it its own inbox on CarlyEmail instead of borrowing your Gmail: pip install carlyemail, then one call sends, one reads the thread, and one replies inside it. Free for 3 inboxes and 1,000 emails a month.
import os
import smtplib
import ssl
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = os.environ["GMAIL_USER"]
msg["To"] = "josh@example.com"
msg["Subject"] = "Nightly build passed"
msg.set_content("All 412 tests passed. Reply YES to deploy.")
with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=ssl.create_default_context()) as server:
server.login(os.environ["GMAIL_USER"], os.environ["GMAIL_APP_PASSWORD"])
server.send_message(msg)
Below: the app-password setup that trips most people up, HTML and attachments, the errors you’ll actually hit, and then the part other tutorials skip, getting Josh’s “YES” back into Python.
Step 1: Create a Gmail app password
smtp.gmail.com still works in 2026, but not with your regular password. Google switched off password sign-in for “less secure apps” on personal accounts on May 30, 2022, and on Google Workspace accounts in 2025, when SMTP, IMAP and POP stopped accepting plain passwords. Any tutorial that tells you to turn on “Less secure app access” is describing a toggle that no longer exists.
What still works is an app password, a 16-character code that stands in for your password in one app:
- Turn on 2-Step Verification for your Google Account. App passwords don’t exist without it.
- Go to
myaccount.google.com/apppasswords, type a name likepython-script, and click Create. - Copy the code. Google shows it in four groups of four; store it without the spaces.
- Put the address and the code in environment variables, never in the script:
export GMAIL_USER="you@gmail.com"
export GMAIL_APP_PASSWORD="abcdefghijklmnop"
Three things to know before you depend on it:
- No “App passwords” page? Google can hide it when 2-Step Verification uses only security keys, when the account has Advanced Protection, or on a work or school account. On Workspace, ask the admin, who can also set up Google’s SMTP relay (
smtp-relay.gmail.com) for apps. - Changing your Google password revokes every app password. The script starts failing with a 535 error the next time anyone rotates it.
- It isn’t limited to sending. The same 16 characters let a mail client read the whole inbox over IMAP, so a leaked
.envfile leaks your mail.
Step 2: Send a plain-text email
The quick-answer script, line by line:
EmailMessageis the modern API (final since Python 3.6). It handles encoding, so non-ASCII subjects and names just work. Older tutorials assembleMIMEMultipartandMIMETextobjects by hand; that still runs, but you manage the MIME tree yourself.SMTP_SSLon port 465 encrypts the connection from the first byte.context=ssl.create_default_context()matters. Without it, smtplib encrypts but never checks the server’s certificate, and that is still the default in Python 3.14.send_message()collects recipients from the To, Cc and Bcc headers and drops the Bcc header from what it transmits, so blind copies stay blind.
Several recipients are comma-separated strings:
msg = EmailMessage()
msg["From"] = os.environ["GMAIL_USER"]
msg["To"] = "josh@example.com, emily@example.com"
msg["Cc"] = "michael@example.com"
msg["Bcc"] = "claire@example.com"
Each of these headers can be set once. Assigning msg["To"] a second time raises ValueError: There may be at most 1 To headers in a message, so del msg["To"] before reusing a message for someone else.
Port 465 or 587?
Both work on Gmail. What breaks is pairing the wrong class with the port.
| Port 465 | Port 587 | |
|---|---|---|
| Python class | smtplib.SMTP_SSL | smtplib.SMTP, then .starttls() |
| How it encrypts | TLS from the first byte | Plain connection upgraded with STARTTLS |
| Gmail calls it | SSL | TLS |
The 587 version:
with smtplib.SMTP("smtp.gmail.com", 587, timeout=30) as server:
server.starttls(context=ssl.create_default_context())
server.login(os.environ["GMAIL_USER"], os.environ["GMAIL_APP_PASSWORD"])
server.send_message(msg)
Pass timeout= either way: on the wrong port, a script without one sits waiting for a greeting that never comes. Skip port 25 entirely, since most cloud providers block outbound traffic on it.
Send HTML email with a plain-text fallback
msg = EmailMessage()
msg["From"] = os.environ["GMAIL_USER"]
msg["To"] = "emily@example.com"
msg["Subject"] = "Your weekly report"
msg.set_content("Your report is ready: https://example.com/reports/42")
msg.add_alternative(
"""\
<html>
<body>
<p>Your report is ready.</p>
<p><a href="https://example.com/reports/42">Open the report</a></p>
</body>
</html>
""",
subtype="html",
)
Call set_content() first and add_alternative() second. Mail clients show the last version they can render, so HTML wins where it’s supported and the text part covers the rest. Keep styles inline; email clients support far less CSS than browsers.
Attach a file
import mimetypes
from pathlib import Path
path = Path("report.pdf")
ctype, _ = mimetypes.guess_type(path.name)
maintype, subtype = (ctype or "application/octet-stream").split("/", 1)
msg.add_attachment(path.read_bytes(), maintype=maintype, subtype=subtype, filename=path.name)
This works on top of the HTML message above; EmailMessage restructures itself into the right multipart shape. Personal Gmail accounts cap attachments at 25 MB per message.
Errors you’ll actually hit
| Error | What happened | Fix |
|---|---|---|
SMTPAuthenticationError: (535, b'5.7.8 Username and Password not accepted...') | Gmail rejected the login | Use the app password, not your account password. The username is the full address. If you changed your Google password, create a new app password |
SMTPAuthenticationError: (534, b'5.7.9 Application-specific password required...') | 2-Step Verification is on and you sent your normal password | Create an app password |
SMTPNotSupportedError: SMTP AUTH extension not supported by server. | You called login() on port 587 before starttls(). Gmail only offers login once the connection is encrypted | Call starttls() first |
ssl.SSLError: [SSL: WRONG_VERSION_NUMBER] wrong version number | SMTP_SSL pointed at port 587 | SMTP_SSL goes with 465, SMTP plus starttls() with 587 |
SMTPServerDisconnected: Connection unexpectedly closed: timed out | Plain SMTP pointed at port 465, or your network blocks the port | Match the class to the port, or try the other port |
ssl.SSLCertVerificationError: certificate verify failed | Python can’t find root certificates, common with the python.org installer on macOS | Run Install Certificates.command in your Python folder under Applications |
| ”You have reached a limit for sending mail” | Gmail’s daily cap: 500 emails a day on a personal account, 2,000 through smtp.gmail.com on Workspace | Wait up to 24 hours, or move sending off your personal account |
Where a Gmail script runs out
smtplib plus Gmail is the right tool for a cron job that emails you, or an alert to your own team. It’s free and it’s in the standard library. It stops fitting when:
- Someone replies. smtplib only sends. Reading means a second integration,
imaplibon the same account or the Gmail API with its OAuth review (see Gmail API limits), and then you parse MIME, strip quoted history and match replies to what you sent, all by hand. - It isn’t you sending. Every message goes out from your personal address under your daily cap. A loop bug burns your 500 for the day, from your own account.
- It runs on a server. The app password in that server’s environment is a key to your mailbox.
That’s the gap CarlyEmail fills: an email API that gives code its own inbox, which can send, receive and reply.
Send email from Python with an API (CarlyEmail)
CarlyEmail talks HTTPS instead of SMTP: no app password, no port choice, and mail leaves with SPF, DKIM and DMARC passing. The Python SDK’s only dependency is httpx.
pip install carlyemail
Get a key. There’s no dashboard step. Sign-up is one unauthenticated call (or npx carlyemail signup in a terminal):
from carlyemail import CarlyEmail
signup = CarlyEmail().agent.sign_up({"human_email": "you@example.com", "username": "build-bot"})
print(signup["api_key"]) # shown once: store it as CARLYEMAIL_API_KEY
A six-digit code arrives at human_email. Until you confirm it, the account can only email you, which is enough to test the whole loop. Then:
carly = CarlyEmail(api_key=signup["api_key"])
carly.agent.verify({"otp_code": "123456"})
Send. The same build notification as the smtplib version, from an address that belongs to the bot:
from carlyemail import CarlyEmail
carly = CarlyEmail() # reads CARLYEMAIL_API_KEY
# client_id makes this idempotent: rerun the script and you get the same inbox back
inbox = carly.inboxes.create({"username": "acme-build-bot", "client_id": "build-bot"})
sent = carly.messages.send(
inbox["email"],
{
"to": ["josh@example.com"],
"subject": "Nightly build passed",
"text": "All 412 tests passed. Reply YES to deploy.",
"html": "<p>All 412 tests passed. Reply <b>YES</b> to deploy.</p>",
},
)
print(sent["message_id"], sent["thread_id"])
Attachments go in as base64, up to 25 MB:
import base64
from pathlib import Path
report = Path("report.pdf")
carly.messages.send(
inbox["email"],
{
"to": ["josh@example.com"],
"subject": "Nightly report",
"text": "Report attached.",
"attachments": [
{
"filename": report.name,
"content_type": "application/pdf",
"content": base64.b64encode(report.read_bytes()).decode(),
}
],
},
)
Errors say what to do. Compare a bare “535 Username and Password not accepted” with this:
from carlyemail import CarlyEmailError
try:
carly.messages.send(inbox["email"], {"to": ["josh@example.com"], "subject": "Hi", "text": "Hi"})
except CarlyEmailError as error:
print(error.status, error.code) # e.g. 403 organization_unverified
print(error.fix) # the step that clears it
print(error.docs) # a link to the relevant docs page
A plan limit comes back as a 429 naming the cap it hit, rather than mail quietly going missing. Full reference: CarlyEmail Python SDK docs.
Receive the reply in Python
The sent response above carried a thread_id. When Josh answers “YES”, his message lands in that thread, matched to yours even when his mail client mangles the headers. You can poll for it or have it pushed to you.
Poll the thread (scripts and cron jobs)
import time
def wait_for_reply(inbox_id, thread_id, timeout=900, every=20):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
thread = carly.threads.get(inbox_id, thread_id)
replies = [
m for m in thread.get("messages", [])
if "received" in m.get("labels", [])
and not {"spam", "unauthenticated"} & set(m.get("labels", []))
]
if replies:
return replies[-1] # messages are oldest first
time.sleep(every)
return None
reply = wait_for_reply(inbox["email"], sent["thread_id"])
if reply and (reply.get("extracted_text") or "").strip().lower().startswith("yes"):
carly.messages.reply(inbox["email"], reply["message_id"], {"text": "Approved. Deploying now."})
Three details doing the work:
extracted_textis the new writing with the quoted history stripped, so you read “YES”, not “YES” plus your own email underneath.- The label check skips mail that failed SPF, DKIM or DMARC. CarlyEmail keeps that mail and labels it
unauthenticatedinstead of dropping it, so a forged “YES” can’t approve a deploy. messages.replysetsIn-Reply-ToandReferences, so the answer shows up inside Josh’s existing conversation instead of as a new email.
To scan a whole inbox rather than one thread, carly.messages.list(inbox_id, labels=["received"]) returns headers and a preview but no bodies. Fetch the bodies with carly.messages.batch_get() before reading them.
Get a webhook when mail arrives (servers and agents)
Polling is fine for a script. For anything long-running, let CarlyEmail call you. The SDK ships the receiver:
pip install carlyemail fastapi uvicorn
# app.py: run with `uvicorn app:app --port 8080`
from carlyemail import CarlyEmail
from carlyemail.inbound import create_email_router
from fastapi import FastAPI
carly = CarlyEmail()
app = FastAPI()
def on_email(email):
# email.text has quoted history stripped; email.thread_id is the conversation key
if email.text.strip().lower().startswith("yes"):
answer = "Approved. Deploying now."
else:
answer = "Not approved. Reply YES to deploy."
carly.messages.reply(email.inbox_id, email.message_id, {"text": answer})
app.include_router(create_email_router(on_email, path="/hooks/carlyemail"))
Register the endpoint once:
hook = carly.webhooks.create(
{"url": "https://yourapp.com/hooks/carlyemail", "event_types": ["message.received"]}
)
print(hook["secret"]) # whsec_..., shown once
Then set three environment variables for the router: CARLYEMAIL_WEBHOOK_SECRET (that secret), CARLYEMAIL_INBOX (the bot’s address, so it ignores its own mail) and ALLOWED_SENDERS=josh@example.com (so only Josh can approve).
create_email_router makes the decisions a hand-written webhook usually gets wrong:
- It verifies the signature over the raw bytes and answers
401to anything that fails, including a malformed signature header, the case that crashes many hand-written handlers into a500. - It admits only
message.received. Spam and mail that fails SPF, DKIM or DMARC arrive as separate event types, so a forged sender never reacheson_email. - It drops mail the inbox sent itself, so an auto-replier can’t answer itself in a loop.
- It ignores redeliveries of the same
event_id. - It answers
202before your handler runs, so a slow handler doesn’t trigger a retry and a second reply.
On Flask, Django or Lambda, InboundReceiver().decide(body, headers) is the same logic without FastAPI. On a laptop with no public URL, a WebSocket delivers the same events with nothing to tunnel. More patterns: turning incoming email into a webhook and the receiving guide.
smtplib, Gmail or an email API: which to use
| smtplib + Gmail | Transactional email API | CarlyEmail | |
|---|---|---|---|
| Setup | 2-Step Verification, app password | Verify a domain, API key | pip install carlyemail, one sign-up call |
| Sends from | Your personal address | Your domain | Its own address, or your domain |
| Daily limit | 500 personal, 2,000 Workspace | Set by plan | 100 on free, no daily cap on paid plans |
| Replies | Separate IMAP code, parse it yourself | Inbound webhook on most; threading is yours to build | Threads, webhooks, WebSockets, reply in thread |
| Best for | Alerts to yourself | One-way volume: receipts, newsletters | Mail that comes back: agents, support, approvals |
Be clear on scope. If all you do is send receipts and password resets at volume, a transactional sender is priced per message for exactly that (see Resend pricing and SendGrid pricing). If the mail has to come back and be answered, that’s what CarlyEmail is for: free for 3 inboxes, 1,000 emails a month and a custom domain, with no “sent via” footer; $20 a month for 25 inboxes and 10,000 emails with no daily cap; $200 a month for 250 inboxes and 100,000 emails. Received mail doesn’t count against the email quota. Building an AI agent specifically? Start with the email API for AI agents, or compare the field in best email APIs.
FAQ
Can I still use my Gmail password with smtplib?
No. Google stopped accepting account passwords from third-party apps on personal accounts on May 30, 2022, and on Google Workspace accounts in 2025. Use a 16-character app password, which requires 2-Step Verification, or OAuth.
Does smtp.gmail.com still work in 2026?
Yes. Google’s setup page, last updated October 1, 2026, still lists smtp.gmail.com on port 465 (SSL) or 587 (TLS), with your full address as the username and an app password.
Should I use port 465 or 587 for Gmail?
Either. Use 465 with smtplib.SMTP_SSL, or 587 with smtplib.SMTP followed by starttls(). Most connection errors come from pairing the wrong class with the port.
How do I send email from Python without Gmail?
Point the same smtplib code at your own provider’s SMTP server, or send over HTTPS with an email API. CarlyEmail gives your script its own address with pip install carlyemail, and that address receives replies too.
How do I read email replies in Python?
With Gmail, use imaplib and the same app password, then parse the MIME and match replies to your sent mail yourself. With CarlyEmail, carly.threads.get(inbox_id, thread_id) returns the conversation with replies already matched, and a signed webhook can call your code the moment one arrives.
How many emails can I send from Python through Gmail?
About 500 a day from a personal account and 2,000 a day through smtp.gmail.com on Google Workspace. Past that, Gmail refuses with “You have reached a limit for sending mail” for up to 24 hours.
Give your agent a real inbox
Your agent gets its own email address. People can email it, it answers in the same thread, and your personal inbox stays out of it. Start with 3 inboxes, no card needed.
Get startedSee the prompt
Read https://docs.carlyemail.com/llms.txt and set yourself up with an email address.


