Abstract flat illustration of a code window launching a paper-plane envelope that loops back along a curved arrow into an open inbox tray

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:

  1. Turn on 2-Step Verification for your Google Account. App passwords don’t exist without it.
  2. Go to myaccount.google.com/apppasswords, type a name like python-script, and click Create.
  3. Copy the code. Google shows it in four groups of four; store it without the spaces.
  4. 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 .env file leaks your mail.

Step 2: Send a plain-text email

The quick-answer script, line by line:

  • EmailMessage is the modern API (final since Python 3.6). It handles encoding, so non-ASCII subjects and names just work. Older tutorials assemble MIMEMultipart and MIMEText objects by hand; that still runs, but you manage the MIME tree yourself.
  • SMTP_SSL on 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 465Port 587
Python classsmtplib.SMTP_SSLsmtplib.SMTP, then .starttls()
How it encryptsTLS from the first bytePlain connection upgraded with STARTTLS
Gmail calls itSSLTLS

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

ErrorWhat happenedFix
SMTPAuthenticationError: (535, b'5.7.8 Username and Password not accepted...')Gmail rejected the loginUse 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 passwordCreate 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 encryptedCall starttls() first
ssl.SSLError: [SSL: WRONG_VERSION_NUMBER] wrong version numberSMTP_SSL pointed at port 587SMTP_SSL goes with 465, SMTP plus starttls() with 587
SMTPServerDisconnected: Connection unexpectedly closed: timed outPlain SMTP pointed at port 465, or your network blocks the portMatch the class to the port, or try the other port
ssl.SSLCertVerificationError: certificate verify failedPython can’t find root certificates, common with the python.org installer on macOSRun 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 WorkspaceWait 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, imaplib on 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_text is 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 unauthenticated instead of dropping it, so a forged “YES” can’t approve a deploy.
  • messages.reply sets In-Reply-To and References, 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 401 to anything that fails, including a malformed signature header, the case that crashes many hand-written handlers into a 500.
  • It admits only message.received. Spam and mail that fails SPF, DKIM or DMARC arrive as separate event types, so a forged sender never reaches on_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 202 before 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 + GmailTransactional email APICarlyEmail
Setup2-Step Verification, app passwordVerify a domain, API keypip install carlyemail, one sign-up call
Sends fromYour personal addressYour domainIts own address, or your domain
Daily limit500 personal, 2,000 WorkspaceSet by plan100 on free, no daily cap on paid plans
RepliesSeparate IMAP code, parse it yourselfInbound webhook on most; threading is yours to buildThreads, webhooks, WebSockets, reply in thread
Best forAlerts to yourselfOne-way volume: receipts, newslettersMail 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 started
See the prompt
Read https://docs.carlyemail.com/llms.txt and set yourself up with an email address.