Intermediate
Introduction
Email is everywhere in modern software — from order confirmations to password resets to automated reports. If you’re building a Python application that needs to send messages to users, you don’t need to pay for a third-party email service right away. Gmail, which most developers already use, has a built-in SMTP (Simple Mail Transfer Protocol) server that you can connect to directly. This opens up a world of possibilities: send alerts when your scripts finish, notify team members of important events, or automate bulk communication — all without leaving Python.
The good news: you don’t need to understand SMTP inside and out to get started. Python’s smtplib library and the email module handle the complex parts, and Gmail provides clear documentation for developers. You’ll need to set up Gmail for programmatic access (it’s a one-time configuration), but after that, it takes just a few lines of Python to send your first email.
This article covers the complete journey: setting up Gmail for Python access, connecting via SMTP, sending plain text and HTML emails, attaching files, handling errors gracefully, and using secure authentication practices. We’ll start with a working example you can run in 30 seconds, then dive into each concept in detail. By the end, you’ll be able to send formatted emails with attachments, implement proper error handling, and understand the security best practices that separate a toy script from production-ready code.
How To Send Emails From Gmail: Quick Example
Before we dive into the details, here’s a working script that sends a simple email from Gmail. This is the absolute minimum to get a message sent:
# quick_gmail_send.py
import smtplib
from email.mime.text import MIMEText
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
recipient = "recipient@example.com"
message = MIMEText("This is the body of the email.")
message['Subject'] = "Hello from Python"
message['From'] = sender_email
message['To'] = recipient
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(sender_email, sender_password)
server.send_message(message)
print("Email sent successfully!")
# Expected Output:
# Email sent successfully!
The script creates a MIMEText message (MIME stands for Multipurpose Internet Mail Extensions — it’s the standard email format), connects to Gmail’s SMTP server using SSL encryption on port 465, authenticates with your email and password, and sends. The with statement handles closing the connection automatically.
Three critical things are happening here: (1) we’re reading the email and password from environment variables, not hardcoding them into the script — this keeps your credentials safe; (2) we’re using port 465 with SMTP_SSL for secure, encrypted communication; and (3) we’re using the send_message method instead of the older sendmail, which is cleaner and handles headers automatically. The next sections explain each piece in depth.
What Is SMTP and Why Use Gmail?
SMTP is the protocol computers use to send email across the internet. When you hit “send” in your email client, it connects to an SMTP server, authenticates, and hands off your message. The server then delivers it to the recipient’s mailbox server (which uses IMAP or POP3 on the receiving end — but that’s outside our scope).
Gmail’s SMTP server is smtp.gmail.com on port 465 (for SSL/TLS encryption) or port 587 (for STARTTLS). Most developers use port 465 because it’s simpler: the connection is encrypted from the start. You authenticate using your Gmail address and a special app password (more on that in the next section), and Gmail handles delivery for you.
The advantage: you get a reliable, professional email infrastructure without hosting your own mail server or paying for a service like SendGrid. The trade-off: Gmail has rate limits (you can send up to 500 emails per day for a typical account), and bulk email is better handled by a service built for that purpose. For automating scripts, notifications, and moderate-volume communication, Gmail is perfect.
| Approach | Setup Complexity | Cost | Volume Limit | Use Case |
|---|---|---|---|---|
| Gmail SMTP | Low | Free | 500/day | Notifications, automated alerts, low-volume |
| SendGrid / Mailgun | Medium | Pay-as-you-go | Higher limits | Production bulk email, webhooks, analytics |
| Gmail API + OAuth2 | High | Free | 500/day | Production apps, user consent, best practices |
| Self-hosted SMTP | Very High | Server costs | Unlimited (delivery dependent) | Enterprise, full control |
For the purposes of this article, we’re focusing on SMTP — it’s direct, easy to understand, and enough for most use cases. If you’re building a production app that sends email on behalf of users, you’ll eventually want to move to the Gmail API with OAuth2 (we’ll touch on that at the end).
Setting Up Gmail for Programmatic Access
Step 1: Enable Two-Factor Authentication
Gmail no longer allows you to use your regular password in third-party apps for security reasons. First, you need to enable Two-Factor Authentication (2FA) on your Gmail account — this is a one-time setup. Go to your Google Account security page, find “How you sign in to Google,” and enable 2-Step Verification. You’ll need a phone to receive a verification code. Once that’s done, you’re ready for the next step.
Step 2: Generate an App Password
After 2FA is enabled, Google will give you the option to create “App Passwords.” An App Password is a 16-character random password that grants access to your Gmail account without ever sharing your real password. Go back to the security page, find “App passwords” (it appears under “How you sign in to Google” once 2FA is on), select “Mail” and “Windows Computer” (or your device), and Google generates a unique password. Copy this password and save it somewhere safe — you’ll only see it once.
Why use an App Password instead of your real password? If your script is compromised (or worse, your script source code is leaked on GitHub), an attacker gets access to send email from your account, but not to change your password or access other Google services. It’s a security boundary. Always use App Passwords for programmatic access.
Step 3: Store Credentials Securely
Now you have an app password. Never hardcode it in your script. If your script ends up on GitHub or in a log file, your credentials are exposed. Instead, store them in environment variables. Create a .env file in your project directory (and add .env to your .gitignore so it’s never committed):
# .env
GMAIL_EMAIL=your-email@gmail.com
GMAIL_PASSWORD=your-16-char-app-password
In your Python script, read these values using the os module or the python-dotenv library (which loads .env automatically). Here’s the secure pattern:
# secure_email_setup.py
import os
from dotenv import load_dotenv
load_dotenv() # Loads GMAIL_EMAIL and GMAIL_PASSWORD from .env
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
if not sender_email or not sender_password:
raise ValueError("GMAIL_EMAIL and GMAIL_PASSWORD must be set in environment.")
print(f"Using email: {sender_email}")
# Expected Output:
# Using email: your-email@gmail.com
Install python-dotenv with pip install python-dotenv if it’s not already available. The load_dotenv() call reads your .env file and makes the variables available via os.getenv(). Checking that both values exist with the if not guard prevents confusing errors later if someone forgets to set up their .env file.
Connecting to Gmail’s SMTP Server
Python’s smtplib module is your gateway to sending email. Let’s break down the connection pattern:
# connect_to_gmail_smtp.py
import smtplib
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
# Method 1: SMTP_SSL (port 465, encrypted from start)
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(sender_email, sender_password)
print("Connected and authenticated!")
# Expected Output:
# Connected and authenticated!
SMTP_SSL creates a secure connection to Gmail’s SMTP server on port 465. The connection is encrypted immediately, and the with statement ensures the connection closes automatically when done. The login() method authenticates using your email and app password. If the credentials are wrong, smtplib raises an SMTPAuthenticationError.
There’s an older alternative, SMTP with port 587 and starttls():
# connect_with_starttls.py
import smtplib
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
# Method 2: SMTP with STARTTLS (port 587, upgrade to encryption)
with smtplib.SMTP("smtp.gmail.com", 587) as server:
server.starttls() # Upgrade to encrypted connection
server.login(sender_email, sender_password)
print("Connected and authenticated via STARTTLS!")
# Expected Output:
# Connected and authenticated via STARTTLS!
Both methods are secure. SMTP_SSL (port 465) is simpler and preferred; STARTTLS (port 587) starts with a plain connection then upgrades to encryption. For Gmail, use SMTP_SSL unless your network blocks port 465 (rare, but it happens). The rest of this article uses port 465.
Sending Plain Text Emails
The simplest email is plain text. You create a MIMEText message, set the subject and recipients, and send. Here’s the complete flow:
# send_plain_text_email.py
import smtplib
from email.mime.text import MIMEText
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
recipient = "recipient@example.com"
# Create the email message
message = MIMEText("This is the body of a plain text email.")
message['Subject'] = "Hello from Python"
message['From'] = sender_email
message['To'] = recipient
# Send via Gmail SMTP
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(sender_email, sender_password)
server.send_message(message)
print("Plain text email sent successfully!")
# Expected Output:
# Plain text email sent successfully!
The MIMEText() constructor takes the email body as a string. We then set the standard email headers: Subject, From, and To. These headers are visible to the recipient and email clients. The send_message() method (added in Python 3.2) is cleaner than the older sendmail() method because it extracts the sender and recipients from the message headers automatically.
You can send to multiple recipients by setting To as a comma-separated string and passing a list to send_message():
# send_to_multiple_recipients.py
import smtplib
from email.mime.text import MIMEText
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
recipients = ["alice@example.com", "bob@example.com"]
message = MIMEText("Hello everyone!")
message['Subject'] = "Group notification"
message['From'] = sender_email
message['To'] = ", ".join(recipients)
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(sender_email, sender_password)
server.send_message(message)
print(f"Email sent to {len(recipients)} recipients!")
# Expected Output:
# Email sent to 2 recipients!
The ", ".join(recipients) line converts the list into a comma-separated string for the To header, making it readable in the recipient’s email client. You still pass the original list to send_message() so SMTP delivers to each address directly.
Sending HTML-Formatted Emails
Plain text is fine for simple messages, but modern emails are formatted with HTML: colors, images, links, bold text, and multi-column layouts. The MIMEText constructor accepts a second argument, _subtype='html', which tells email clients to render the content as HTML instead of plain text.
# send_html_email.py
import smtplib
from email.mime.text import MIMEText
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
recipient = "recipient@example.com"
# HTML body
html_body = """
<html>
<body>
<h1 style="color: #0066cc;">Welcome!</h1>
<p>This is an <strong>HTML email</strong> with <em>formatting</em>.</p>
<a href="https://pythonhowtoprogram.com">Visit our site</a>
</body>
</html>
"""
message = MIMEText(html_body, 'html')
message['Subject'] = "Formatted HTML Email"
message['From'] = sender_email
message['To'] = recipient
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(sender_email, sender_password)
server.send_message(message)
print("HTML email sent successfully!")
# Expected Output:
# HTML email sent successfully!
The key difference: MIMEText(html_body, 'html') tells MIME that this is HTML content. Email clients that support HTML will render the formatted version; older clients fall back to plain text (the raw HTML appears, but at least the message is readable). Always make sure your HTML is valid and test in multiple email clients, as Gmail, Outlook, Apple Mail, and mobile clients each have slightly different HTML rendering engines.
For production emails, consider using a templating approach — write your HTML in a separate file and load it into the script:
# send_html_from_template.py
import smtplib
from email.mime.text import MIMEText
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
recipient = "recipient@example.com"
# Load HTML template
with open("email_template.html", "r") as f:
html_body = f.read()
message = MIMEText(html_body, 'html')
message['Subject'] = "Email from template"
message['From'] = sender_email
message['To'] = recipient
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(sender_email, sender_password)
server.send_message(message)
print("Templated email sent!")
# Expected Output:
# Templated email sent!
Keeping templates in separate files makes your code cleaner and easier to update without touching Python logic. For even more power, use a library like Jinja2 to insert variables into templates: pip install jinja2, then Template(html_body).render(user_name="Alice").
Adding Attachments
Emails often carry files — invoices, PDFs, images, spreadsheets. To attach files, you need to use MIMEMultipart instead of just MIMEText. A multipart message can contain multiple components: text body, attachments, embedded images, etc.
# send_email_with_attachment.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
recipient = "recipient@example.com"
# Create multipart message (can contain text + attachments)
message = MIMEMultipart()
message['Subject'] = "Email with PDF attachment"
message['From'] = sender_email
message['To'] = recipient
# Add text body
body = "Please find the report attached."
message.attach(MIMEText(body, 'plain'))
# Attach a file
filename = "report.pdf"
if os.path.exists(filename):
with open(filename, 'rb') as attachment:
part = MIMEBase('application', 'octet-stream')
part.set_payload(attachment.read())
encoders.encode_base64(part)
part.add_header('Content-Disposition', f'attachment; filename= {filename}')
message.attach(part)
# Send
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(sender_email, sender_password)
server.send_message(message)
print(f"Email with {filename} sent successfully!")
# Expected Output:
# Email with report.pdf sent successfully!
This pattern uses MIMEBase for generic attachments and MIMEText for the body. The file is read in binary mode (‘rb’), the bytes are base64-encoded (so they survive email transmission as text), and a Content-Disposition header tells email clients it’s an attachment with a filename. The os.path.exists() check ensures the file actually exists before trying to read it — defensive programming that prevents crashes on missing files.
For common file types, Python provides shortcuts:
# send_email_with_image_attachment.py
import smtplib
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
recipient = "recipient@example.com"
message
= MIMEMultipart()
message['Subject'] = "Email with image"
message['From'] = sender_email
message['To'] = recipient
body = "Here's a photo:"
message.attach(MIMEText(body, 'plain'))
# Attach an image
image_file = "screenshot.png"
if os.path.exists(image_file):
with open(image_file, 'rb') as img:
part = MIMEImage(img.read())
part.add_header('Content-Disposition', f'attachment; filename= {image_file}')
message.attach(part)
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(sender_email, sender_password)
server.send_message(message)
print("Email with image sent!")
# Expected Output:
# Email with image sent!
MIMEImage is simpler for images than MIMEBase — it handles the MIME type automatically. For PDFs, Word docs, and binary formats, use MIMEBase with 'application', 'octet-stream' (a generic binary type). For plain text files, you can use MIMEText directly without needing multipart.
Error Handling and Debugging
Email sending can fail for many reasons: wrong credentials, network issues, recipient address is invalid, rate limits hit, or the SMTP server is temporarily down. Good error handling makes debugging easier and prevents your scripts from crashing silently.
# send_email_with_error_handling.py
import smtplib
from email.mime.text import MIMEText
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
recipient = "recipient@example.com"
try:
message = MIMEText("Test email body.")
message['Subject'] = "Test"
message['From'] = sender_email
message['To'] = recipient
with smtplib.SMTP_SSL("smtp.gmail.com", 465, timeout=10) as server:
server.login(sender_email, sender_password)
server.send_message(message)
print("Email sent successfully!")
except smtplib.SMTPAuthenticationError:
print("Error: Invalid email or password.")
except smtplib.SMTPException as e:
print(f"SMTP error occurred: {e}")
except Exception as e:
print(f"Unexpected error: {e}")
# Expected Output (on success):
# Email sent successfully!
The key exceptions to catch: SMTPAuthenticationError (wrong credentials), SMTPException (SMTP-level issues like invalid recipients or server errors), and generic Exception as a catch-all. The timeout=10 parameter tells Python to wait up to 10 seconds for a server response before giving up. Without a timeout, a hung connection can block your script forever.
Common exceptions and their causes:
| Exception | Cause | Fix |
|---|---|---|
SMTPAuthenticationError | Wrong email/password | Verify credentials in .env file. Regenerate app password. |
SMTPNotSupportedError | SMTP command not supported | Check Gmail account type; some limits apply to newer accounts. |
socket.timeout | Connection timeout | Check internet connection; increase timeout value. |
ConnectionRefusedError | Can’t reach SMTP server | Verify SMTP server address; check firewall/network. |
SMTPSenderRefused | Sender address rejected | Ensure sender email matches authenticated account. |
For debugging, enable smtplib debug mode:
# debug_smtp_connection.py
import smtplib
from email.mime.text import MIMEText
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
try:
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.set_debuglevel(1) # Print all SMTP commands and responses
server.login(sender_email, sender_password)
message = MIMEText("Test")
message['Subject'] = "Test"
message['From'] = sender_email
message['To'] = "recipient@example.com"
server.send_message(message)
except Exception as e:
print(f"Error: {e}")
# Expected Output (with debug info):
# send: b'ehlo [your.ip.address]\r\n'
# reply: b'250-smtp.gmail.com at your service...'
# ... (many more debug lines)
The set_debuglevel(1) call prints every command sent to the server and every response received. This is invaluable for understanding what’s happening under the hood. Use it when your script fails unexpectedly.
Security Best Practices
Sending email is straightforward, but there are security pitfalls that can compromise your account or expose user data.
Never Hardcode Credentials
This is rule #1. If you commit credentials to GitHub, you’ve publicly leaked them, even if you delete them later (GitHub’s history is searchable). Always use environment variables or a secrets management system:
# bad_example.py (DO NOT DO THIS)
sender_email = "my-email@gmail.com" # Exposed on GitHub!
sender_password = "xxxxxx" # Exposed on GitHub!
# good_example.py
import os
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
For local development, use a .env file (remember to add it to .gitignore). For production (servers, CI/CD pipelines, cloud environments), use your platform’s native secrets: GitHub Secrets for Actions, AWS Secrets Manager for Lambda, Google Secret Manager for Cloud Functions, etc.
Use App Passwords, Not Your Real Password
Google App Passwords are specifically designed for third-party apps. If an attacker gets an App Password, they can only send email; they can’t access your Google Drive, Gmail inbox, or change your password. If you accidentally leaked your real Gmail password, an attacker could take over your entire account. Always use App Passwords for programmatic access.
Validate Recipient Addresses
User input for email addresses should be validated. A simple regex check catches obvious typos:
# validate_email_addresses.py
import re
import smtplib
from email.mime.text import MIMEText
import os
def is_valid_email(email):
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
recipients = ["alice@example.com", "bob@example", "charlie@domain.co.uk"]
valid_recipients = [e for e in recipients if is_valid_email(e)]
invalid_recipients = [e for e in recipients if not is_valid_email(e)]
print(f"Valid: {valid_recipients}")
print(f"Invalid: {invalid_recipients}")
# Expected Output:
# Valid: ['alice@example.com', 'charlie@domain.co.uk']
# Invalid: ['bob@example']
This regex is simple and covers most real email formats. It’s not bulletproof (the RFC 5322 standard for email addresses is insanely complex), but it catches common mistakes. For production systems, consider sending a confirmation email and only adding to your list after the user clicks a link in the confirmation.
Be Aware of Rate Limits
Gmail limits you to 500 emails per day for standard accounts (business/workspace accounts have higher limits). If you hit this limit, Gmail temporarily blocks further sends. For bulk email, you’ll need a specialized service like SendGrid or AWS SES. For monitoring, keep a log of sent emails:
# log_sent_emails.py
import smtplib
from email.mime.text import MIMEText
import os
import json
from datetime import datetime
sender_email = os.getenv('GMAIL_EMAIL')
sender_password = os.getenv('GMAIL_PASSWORD')
log_file = "email_log.json"
daily_count = 0
# Count today's emails
if os.path.exists(log_file):
with open(log_file, 'r') as f:
logs = json.load(f)
today = datetime.now().strftime("%Y-%m-%d")
daily_count = sum(1 for log in logs if log['date'] == today)
if daily_count >= 500:
print("Error: Daily email limit reached.")
else:
# Send email
message = MIMEText("Test")
message['Subject'] = "Test"
message['From'] = sender_email
message['To'] = "recipient@example.com"
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(sender_email, sender_password)
server.send_message(message)
# Log the send
log_entry = {
'date': datetime.now().strftime("%Y-%m-%d"),
'time': datetime.now().strftime("%H:%M:%S"),
'to': "recipient@example.com"
}
logs = []
if os.path.exists(log_file):
with open(log_file, 'r') as f:
logs = json.load(f)
logs.append(log_entry)
with open(log_file, 'w') as f:
json.dump(logs, f, indent=2)
print(f"Email sent. Daily count: {daily_count + 1}/500")
# Expected Output:
# Email sent. Daily count: 1/500
This script maintains a JSON log of sends and checks the count before sending. For production, a database is more robust, but a file works for simple scripts.
Real-Life Example: Automated Report Sender
Let’s combine all the concepts into a practical project: an automated script that generates a daily report and emails it to team members. This is a common pattern for data analysis, monitoring, and notifications.
# daily_report_sender.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
import os
from datetime impo
rt datetime
import json
class ReportSender:
def __init__(self):
self.sender_email = os.getenv('GMAIL_EMAIL')
self.sender_password = os.getenv('GMAIL_PASSWORD')
if not self.sender_email or not self.sender_password:
raise ValueError("GMAIL_EMAIL and GMAIL_PASSWORD not set.")
def generate_report(self):
"""Generate a sample daily report."""
report_data = {
'date': datetime.now().strftime("%Y-%m-%d"),
'items_processed': 1250,
'errors': 3,
'success_rate': 99.76
}
return report_data
def create_html_report(self, data):
"""Create HTML-formatted report."""
html = f"""
<html>
<body style="font-family: Arial, sans-serif;">
<h2>Daily Report - {data['date']}</h2>
<table border="1" cellpadding="10">
<tr>
<td><strong>Items Processed</strong></td>
<td>{data['items_processed']}</td>
</tr>
<tr>
<td><strong>Errors</strong></td>
<td>{data['errors']}</td>
</tr>
<tr>
<td><strong>Success Rate</strong></td>
<td>{data['success_rate']}%</td>
</tr>
</table>
<p><em>Report generated by your Python automation script.</em></p>
</body>
</html>
"""
return html
def send_report(self, recipients, report_data):
"""Send the report to recipients."""
try:
message = MIMEMultipart('alternative')
message['Subject'] = f"Daily Report - {report_data['date']}"
message['From'] = self.sender_email
message['To'] = ", ".join(recipients)
# Create both plain text and HTML versions
text_body = f"Daily Report: {report_data['items_processed']} items, {report_data['errors']} errors."
html_body = self.create_html_report(report_data)
message.attach(MIMEText(text_body, 'plain'))
message.attach(MIMEText(html_body, 'html'))
with smtplib.SMTP_SSL("smtp.gmail.com", 465, timeout=10) as server:
server.login(self.sender_email, self.sender_password)
server.send_message(message)
return True, f"Report sent to {len(recipients)} recipients."
except smtplib.SMTPAuthenticationError:
return False, "Authentication failed. Check credentials."
except smtplib.SMTPException as e:
return False, f"SMTP error: {e}"
except Exception as e:
return False, f"Unexpected error: {e}"
# Main execution
if __name__ == "__main__":
try:
sender = ReportSender()
report_data = sender.generate_report()
recipients = ["alice@example.com", "bob@example.com"]
success, message = sender.send_report(recipients, report_data)
print(message)
except ValueError as e:
print(f"Setup error: {e}")
# Expected Output:
# Report sent to 2 recipients.
This example demonstrates several best practices: class-based organization separates concerns, the generate_report() method can be extended to pull real data, the create_html_report() method creates a professional-looking email, and error handling returns success/failure status. For production, you’d schedule this with cron (Unix/Linux), Task Scheduler (Windows), or a cloud scheduler (AWS EventBridge, Google Cloud Scheduler).
Alternative: Using the Gmail API with OAuth2
For production applications where your script sends email on behalf of users (not just from your own account), the Gmail API with OAuth2 is the right approach. It’s more complex than SMTP but offers better security, built-in analytics, and compliance with Google’s policies.
The difference: SMTP requires storing your password (or app password) in the script. The Gmail API uses OAuth2, where users grant permission through Google’s login flow, and you receive a token that expires. If the token is compromised, it only works for the specific permissions granted and only for a limited time.
Here’s the high-level flow: (1) Register your app in Google Cloud Console, (2) Configure OAuth2 credentials, (3) Direct users to Google’s login page where they grant permission, (4) Receive an access token, (5) Use the Gmail API (not SMTP) to send email on their behalf.
For detailed instructions, follow Google’s Gmail API sending guide. The google-auth-oauthlib and google-auth-httplib2 libraries handle the OAuth2 flow. SMTP is simpler for personal scripts and low-volume automation; the Gmail API is essential when you’re handling user accounts.
Frequently Asked Questions
My app password isn’t working. What do I check first?
Most likely culprits: (1) Two-factor authentication isn’t enabled on your Gmail account yet — go to myaccount.google.com/security and enable it. (2) You copied the app password with extra spaces — the 16-character password is sensitive to trailing/leading whitespace. (3) You’re using your regular Gmail password instead of the app password — they’re different; always use the app password for scripts. (4) Your environment variables aren’t being loaded — verify print(os.getenv('GMAIL_PASSWORD')) returns the password, not None.
I hit Gmail’s 500-email limit. How do I recover?
The limit resets daily at midnight PST. Wait until the next day, and you can send again. If you regularly need to send more than 500 emails per day, you need a transactional email service: SendGrid (100/month free, then $20+/month), Mailgun, AWS SES, or similar. These services are designed for bulk email and have much higher limits (thousands per day).
My HTML email renders differently in Gmail vs Outlook. Why?
Email clients have inconsistent CSS and HTML support. Gmail strips `