Email Automation with Python
Python ships with everything you need to work with email. The smtplib and imaplib modules in the standard library handle sending and receiving messages, and third-party packages like yagmail and the SendGrid SDK make common tasks even simpler. This tutorial covers both paths — the stdlib approach for full control, and the library approach for convenience.
Prerequisites
You need Python 3.6 or later. No external packages are required for the standard library examples. For the third-party sections, install with pip:
pip install yagmail sendgrid
You also need an email account that allows SMTP/IMAP access. Gmail requires an App Password if you have 2FA enabled — generate one at your Google Account’s Security settings. Yahoo and Outlook have similar app-password flows. Do not use your actual account password with SMTP; app passwords are specifically designed for this use.
Sending Email with smtplib
The smtplib module is part of Python’s standard library. It gives you direct control over the SMTP protocol, which is what email servers use to relay messages.
A Minimal Example
import smtplib
from email.mime.text import MIMEText
msg = MIMEText("This is the email body.")
msg["From"] = "sender@gmail.com"
msg["To"] = "recipient@example.com"
msg["Subject"] = "Test Subject"
with smtplib.SMTP("smtp.gmail.com", 587) as server:
server.starttls()
server.login("sender@gmail.com", "your_app_password")
server.sendmail("sender@gmail.com", ["recipient@example.com"], msg.as_string())
# Output: no output on success; raises SMTPAuthenticationError on bad credentials
The flow is straightforward: connect on port 587, upgrade the connection to TLS with starttls(), authenticate, then send. Always use a context manager — it ensures the connection closes even if an error occurs.
Sending HTML Email with Attachments
Most real emails include HTML content and file attachments. You need MIMEMultipart to build a message with multiple parts:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
def send_html_email(sender, password, recipient, subject, html_body, attachment_path=None):
msg = MIMEMultipart("alternative")
msg["From"] = sender
msg["To"] = recipient
msg["Subject"] = subject
plain_part = MIMEText("View this email in an HTML-capable client.", "plain")
html_part = MIMEText(html_body, "html")
msg.attach(plain_part)
msg.attach(html_part)
if attachment_path:
with open(attachment_path, "rb") as f:
part = MIMEBase("application", "octet-stream")
part.set_payload(f.read())
encoders.encode_base64(part)
filename = attachment_path.split("/")[-1]
part.add_header("Content-Disposition", f"attachment; filename={filename}")
msg.attach(part)
with smtplib.SMTP("smtp.gmail.com", 587) as server:
server.starttls()
server.login(sender, password)
server.sendmail(sender, [recipient], msg.as_string())
print(f"Email sent to {recipient}")
# Usage
send_html_email(
sender="you@gmail.com",
password="xxxx xxxx xxxx xxxx", # App password
recipient="friend@example.com",
subject="Your Monthly Report",
html_body="<h1>Report</h1><p>Here is your attached report.</p>",
attachment_path="/path/to/report.pdf"
)
# Expected output: Email sent to friend@example.com
MIMEMultipart("alternative") tells the email client that the parts are alternatives — it should prefer the HTML version if it can render it. Including a plain-text fallback is good practice for recipients whose clients block HTML.
Port 465 and SSL
Some providers still support port 465 with SSL from the start. In that case, use SMTP_SSL instead:
import smtplib
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login("sender@gmail.com", "app_password")
server.sendmail("sender@gmail.com", ["recipient@example.com"], msg.as_string())
# No starttls() call needed — SSL is active from the beginning
Do not call starttls() when using SMTP_SSL. Also avoid port 25 — it is commonly blocked by ISPs and cloud providers.
Reading Email with imaplib
The imaplib module handles IMAP, the protocol for fetching and managing emails on a server. Gmail uses port 993 with SSL.
Fetching Recent Emails
import imaplib
import email
from email.header import decode_header
def read_last_n_emails(account, password, n=5):
with imaplib.IMAP4_SSL("imap.gmail.com", 993) as client:
client.login(account, password)
client.select("INBOX")
_, msgs = client.search(None, "ALL")
ids = msgs[0].split()
last_ids = ids[-n:] if len(ids) >= n else ids
for mid in reversed(last_ids):
_, data = client.fetch(mid, "(RFC822)")
msg = email.message_from_bytes(data[0][1])
subject, enc = decode_header(msg["Subject"])[0]
if isinstance(subject, bytes):
subject = subject.decode(enc or "utf-8", errors="replace")
print(f"ID {mid.decode()}: {subject} | From: {msg['From']}")
# Usage
read_last_n_emails("you@gmail.com", "app_password", n=5)
# Expected output:
# ID 123: Meeting Tomorrow | From: boss@company.com
# ID 124: Invoice #9921 | From: billing@vendor.com
search(None, "ALL") returns all message IDs as bytes. The IDs are space-separated in a single byte string, so split() turns them into a list. Use fetch() with "(RFC822)" to get the raw email bytes, then parse it with the email module.
Filtering by Sender or Subject
You can filter which messages to fetch by passing criteria to search():
import imaplib
with imaplib.IMAP4_SSL("imap.gmail.com", 993) as client:
client.login("user@gmail.com", "app_password")
client.select("INBOX")
# Search for messages from a specific sender
_, msgs = client.search(None, 'FROM "newsletter@company.com"')
print(f"Found {len(msgs[0].split())} messages from newsletter")
Criteria must be bytes. Common filters include FROM, SUBJECT, SINCE, and BEFORE. Gmail’s IMAP search syntax supports compound queries like FROM "boss" SINCE "01-Jan-2025".
To mark messages as read or delete them, use store():
# Mark message 123 as seen
client.store(b"123", "+FLAGS", "\\Seen")
# Mark for deletion (actually deleted on EXPUNGE)
client.store(b"123", "+FLAGS", "\\Deleted")
client.expunge()
Using yagmail for Simpler Sending
yagmail wraps smtplib with a friendlier interface. It handles MIME construction automatically and supports attachments with a single parameter:
import yagmail
def send_bulk_newsletter(sender, password, recipients, subject, html_content):
yag = yagmail.SMTP(sender, password, "smtp.gmail.com")
yag.send(
to=recipients,
subject=subject,
contents=[html_content],
)
yag.close()
print(f"Newsletter sent to {len(recipients)} recipients")
# Usage
send_bulk_newsletter(
sender="newsletter@gmail.com",
password="app_password",
recipients=["alice@example.com", "bob@example.com", "carol@example.com"],
subject="March Newsletter",
html_content="<h2>March Update</h2><p>Highlights from this month.</p>"
)
# Expected output: Newsletter sent to 3 recipients
The contents parameter accepts a list to send both plain-text and HTML versions. Use attachments="/path/to/file.pdf" or a list of paths to attach files.
Using SendGrid for Scalable Delivery
SendGrid is a cloud email service that handles delivery, reputation, and analytics. The sendgrid-python SDK is the official client:
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
sg = SendGridAPIClient(api_key="SG.your_api_key_here")
message = Mail(
from_email="sender@example.com",
to_emails="recipient@example.com",
subject="Hello from SendGrid",
plain_text_content="Plain text body",
html_content="<p>HTML body</p>"
)
response = sg.send(message)
print(response.status_code) # 202 means the message was accepted
print(response.body) # b'' on success
# Expected output: 202
# Expected output: b''
A status code of 202 Accepted means SendGrid accepted the message — it does not guarantee delivery to the inbox. SendGrid’s free tier allows 100 emails per day.
Storing Credentials Safely
Never hardcode passwords or API keys in your source files. Use environment variables:
import os
import smtplib
from email.mime.text import MIMEText
app_password = os.environ.get("GMAIL_APP_PASSWORD")
if not app_password:
raise ValueError("GMAIL_APP_PASSWORD environment variable is not set")
msg = MIMEText("Body text")
msg["From"] = "you@gmail.com"
msg["To"] = "them@example.com"
msg["Subject"] = "Subject"
with smtplib.SMTP("smtp.gmail.com", 587) as server:
server.starttls()
server.login("you@gmail.com", app_password)
server.send_message(msg)
Set the variable in your shell before running the script:
export GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
python send_email.py
For production deployments, consider a secrets manager like AWS Secrets Manager or HashiCorp Vault instead of environment variables.
Error Handling
Network errors, authentication failures, and invalid recipients can all cause exceptions. Wrap your email operations in try/except blocks:
import smtplib
try:
with smtplib.SMTP("smtp.gmail.com", 587) as server:
server.starttls()
server.login("user@gmail.com", "app_password")
server.sendmail("user@gmail.com", ["recipient@example.com"], msg.as_string())
except smtplib.SMTPAuthenticationError:
print("Authentication failed — check your app password")
except smtplib.SMTPException as e:
print(f"SMTP error occurred: {e}")
except TimeoutError:
print("Connection timed out — check your network or SMTP host")
For imaplib, catch imaplib.IMAP4.error for general IMAP errors and imaplib.IMAP4.abort for connection-level failures.
Common SMTP Provider Settings
| Provider | SMTP Host | TLS Port (587) | SSL Port (465) |
|---|---|---|---|
| Gmail | smtp.gmail.com | Yes | Yes |
| Outlook | smtp-mail.outlook.com | Yes | Yes |
| Yahoo | smtp.mail.yahoo.com | Yes | Yes |
| Office 365 | smtp.office365.com | Yes | No |
These settings assume you are using an app password, not your actual account password.
See Also
- Working with APIs — REST API calls are how services like SendGrid receive email instructions
- Python Webhooks — Webhooks and email notifications often work together in automated workflows
- Environment Variables — Safely storing SMTP credentials and API keys without hardcoding