Beginner

You have built a Python script that generates a report, scrapes a website, or monitors a server — and now you need it to tell you what happened. Maybe you want a daily summary email, an alert when something breaks, or a confirmation that a scheduled job finished successfully. Sending email programmatically is one of those kills every Python developer eventually needs, and the good news is that Python has everything you need built right in.

Python’sstandard library includes smtplib for connecting to mail servers and email for building properly formatted messages. You do not need to install any third-party packages. All you need is a Gmail account with an App Password (we will walk through setting that up) and about 10 lines of code to send your first email.

tter properly. You need both: email.message.EmailMessage builds a correctly formatted email (headers, body, attachments), and smtplib delivers it to the mail server.

ModulePurposePart of Standard Library?
smtplibConnect to SMTP server, authenticate, sendYes
email.messageBuild email messages (headers, body, MIME)Yes
email.mimeLegacy API for building MIME messagesYes (use EmailMessage instead)
sslSecure socket layer for encrypted connectionsYes

The modern approach uses EmailMessage (introduced in Python 3.6) instead of the older MIMEText/MIMEMultipart classes. EmailMessage handles plain text, HTML, and attachments through a single clean API. We will use it throughout this tutorial.

Setting Up Gmail App Passwords

Gmail does not allow you to log in with your regular password from a script — it requires an App Password instead. An App Password is a 16-character code that gives your script access to your Gmail account without exposing your main password. Here is how to set one up.

First, you need to enable 2-Step Verification on your Google account if you have not already. Go to myaccount.google.com/security, scroll to “How you sign in to Google,” and turn on 2-Step Verification. Once that is active, go to myaccount.google.com/apppasswords, enter a name like “Python Script,” and click Create. Google will show you a 16-character password — copy it immediately because you will not see it again.

# secure_config.py
import os

# Store your App Password as an environment variable -- never hardcode it
# Set it in your terminal first:
#   export GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
#   export GMAIL_ADDRESS="your_email@gmail.com"

gmail_address = os.environ.get("GMAIL_ADDRESS")
gmail_password = os.environ.get("GMAIL_APP_PASSWORD")

if not gmail_address or not gmail_password:
    raise ValueError("Set GMAIL_ADDRESS and GMAIL_APP_PASSWORD environment variables")

print(f"Configured for: {gmail_address}")
print(f"Password loaded: {'*' * len(gmail_password)}")

Output:

Configured for: your_email@gmail.com
Password loaded: ****************

Never hardcode your App Password directly in your Python files. Use environment variables or a .env file (with python-dotenv) to keep credentials out of your source code. If you accidentally commit a password to Git, revoke the App Password immediately from your Google account settings and create a new one.

SMTP_SSL vs STARTTLS: Choosing a Connection Method

There are two ways to establish a secure connection to an SMTP server: SMTP_SSL and SMTP with STARTTLS. Both encrypt your email traffic, but they work differently.

MethodPortHow It WorksWhen to Use
SMTP_SSL465Encrypted from the startPreferred for Gmail
SMTP + starttls()587Starts unencrypted, upgradesSome corporate servers

Here is how the STARTTLS approach looks in practice. The connection starts as plaintext on port 587, then upgrades to TLS before sending any sensitive data.

# starttls_example.py
import smtplib
import os
from email.message import EmailMessage

msg = EmailMessage()
msg["Subject"] = "STARTTLS Test"
msg["From"] = os.environ["GMAIL_ADDRESS"]
msg["To"] = os.environ["GMAIL_ADDRESS"]  # Send to yourself for testing
msg.set_content("This email was sent using STARTTLS on port 587.")

with smtplib.SMTP("smtp.gmail.com", 587) as server:
    server.ehlo()       # Identify ourselves to the server
    server.starttls()   # Upgrade connection to TLS
    server.ehlo()       # Re-identify after TLS upgrade
    server.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"])
    server.send_message(msg)

print("Email sent via STARTTLS!")

Output:

Email sent via STARTTLS!

For Gmail, SMTP_SSL on port 465 is simpler and preferred — it encrypts the connection from the first byte. Use the STARTTLS approach only if your email provider specifically requires port 587, or if you need to connect to a server that does not support direct SSL.

Sudo Sam choosing between padlocks - SMTP SSL vs STARTTLS comparison
SMTP_SSL wraps the whole conversation in encryption. STARTTLS hopes nobody is listening to the handshake.

Sending HTML-Formatted Emails

Plain text emails get the job done, but HTML emails let you include formatting, links, tables, and images. The EmailMessage class makes it easy to send an email with both a plain-text fallback and an HTML version — email clients that support HTML will display the rich version, while older clients fall back to plain text.

“Subject”] = “Weekly Python Report” msg[“From”] = os.environ[“GMAIL_ADDRESS”] msg[“To”] = os.environ[“GMAIL_ADDRESS”] # Plain text version (fallback) msg.set_content(“Your weekly report: 42 scripts ran, 0 failures, 15.2s avg runtime.”) # HTML version (preferred by most email clients) html_content = “””\

Weekly Python Report

Here is your automated summary for the week:

Metric Value 420Avg Runtime
Scripts Executed
Failures
15.2 seconds

All systems operational. Have a great week!

""" msg.add_alternative(html_content, subtype="html") with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: server.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"]) server.send_message(msg) print("HTML email sent!")

Output:

HTML email sent!

The key method here is msg.add_alternative(html_content, subtype="html"). This tells the email that it has two versions: the plain text you set with set_content() and the HTML alternative. Always provide both -- some email clients strip HTML entirely, and a plain-text fallback ensures your message is readable everywhere.

Adding File Attachments

Sending reports, logs, or CSV files as email attachments is a common automation task. The EmailMessage class handles this with add_attachment(), which automatically detects the file type and encodes it correctly.

# attachment_email.py
import smtplib
import os
import mimetypes
from email.message import EmailMessage
from pathlib import Path

def send_email_with_attachment(to_address, subject, body, file_path):
    """Send an email with a file attachment."""
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = os.environ["GMAIL_ADDRESS"]
    msg["To"] = to_address
    msg.set_content(body)

    # Read and attach the file
    filepath = Path(file_path)
    if not filepath.exists():
        raise FileNotFoundError(f"Attachment not found: {file_path}")

    mime_type, _ = mimetypes.guess_type(str(filepath))
    if mime_type is None:
        mime_type = "application/octet-stream"
    maintype, subtype = mime_type.split("/")

    with open(filepath, "rb") as f:
        msg.add_attachment(
            f.read(),
            maintype=maintype,
            subtype=subtype,
            filename=filepath.name
        )

    with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
        server.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"])
        server.send_message(msg)

    print(f"Email sent to {to_address} with attachment: {filepath.name}")


# Create a sample CSV file for testing
sample_csv = "name,score,grade\nAlice,95,A\nBob,87,B\nCharlie,72,C\n"
Path("report.csv").write_text(sample_csv)

# Send it
send_email_with_attachment(
    to_address=os.environ["GMAIL_ADDRESS"],
    subject="Monthly Report Attached",
    body="Please find the monthly report attached to this email.",
    file_path="report.csv"
)

Output:

Email sent to your_email@gmail.com with attachment: report.csv

The mimetypes.guess_type() function automatically detects the correct MIME type from the file extension -- text/csv for CSV files, application/pdf for PDFs, image/png for images, and so on. If the type cannot be determined, we fall back to application/octet-stream which tells the email client to treat it as a generic binary file. You can attach multiple files by calling add_attachment() multiple times on the same message.

Debug Dee attaching gift box to envelope - Python email attachments
add_attachment() handles the MIME type guessing. You just hand it the file and hope for the best.

Sending to Multiple Recipients

Often you need to send the same email to several people -- maybe a team notification or a batch of personalized messages. There are two approaches: sending one email to multiple recipients (everyone sees all addresses) or sending individual emails (each person sees only their address).

# multiple_recipients.py
import smtplib
import os
from email.message import EmailMessage

def send_to_group(recipients, subject, body)
   """Send one email to multiple recipients (all visible in To field)."""
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = os.environ["GMAIL_ADDRESS"]
    msg["To"] = ", ".join(recipients)
    msg.set_content(body)

    with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
        server.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"])
        server.send_message(msg)

    print(f"Group email sent to {len(recipients)} recipients")


def send_individual(recipients, subject_template, body_template):
    """Send personalized emails to each recipient individualy."""
    with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
        server.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"])

        for name, email_addr in recipients:
            msg = EmailMessage()
            msg["Subject"] = subject_template.format(name=name)
            msg["From"] = os.environ["GMAIL_ADDRESS"]
            msg["To"] = email_addr
            msg.set_content(body_template.format(name=name))
            server.send_message(msg)
            print(f"Sent to {name} ({email_addr})")

    print(f"All {len(recipients)} individual emails sent!")


# Example: group email
team = ["alice@example.com", "bob@example.com", "charlie@example.com"]
send_to_group(team, "Team Update", "Sprint review meeting moved to 3 PM.")

# Example: personalized emails
contacts = [("Alice", "alice@example.com"), ("Bob", "bob@example.com")]
send_individual(
    contacts,
    subject_template="Hey {name}, your weekly summary",
    body_template="Hi {name},\n\nHere is your personalized weekly summary.\n\nBest regards"
)

Real-Life Example: Automated Error Alert System

Let us tie everything together with a practical project. This script monitors a log file for errors and sends an HTML alert email with a summary of any issues found. It combines plain text processing, HTML email formatting, and the EmailNotifier class pattern you can reuse in any project.

# Create a sample log file for demonstration
sample_log = """2026-03-31 08:00:01 INFO  Starting data pipeline
2026-03-31 08:00:05 INFO  Connected to database
2026-03-31 08:00:12 WARNING  Slow query detected (2.3s)
2026-03-31 08:00:15 ERROR  Failed to fetch API data: ConnectionTimeout
2026-03-31 08:00:16 ERROR  Retry 1 of 3 failed
2026-03-31 08:00:20 INFO  Retry 2 succeeded
2026-03-31 08:00:45 CRITICAL  Database connection lost
2026-03-31 08:00:46 INFO  Reconnecting to database...
2026-03-31 08:01:00 INFO  Pipeline completed with errors
"""
Path("app.log").write_text(sample_log)

# Check the log and send an alert if errors are found
def check_log_for_errors(log_path):
    """Scan a log file and return any lines containing ERROR or CRITICAL."""
    errors = []
    path = Path(log_path)
    if not path.exists():
        return errors

    with open(path, "r") as f:
        for line_num, line in enumerate(f, 1):
            stripped = line.strip()
            if "ERROR" in stripped or "CRITICAL" in stripped:
                errors.append({"line": line_num, "text": stripped})

    return errors


def build_alert_html(log_file, errors):
    """Build an HTML alert email from error entries."""
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    rows = ""
    for err in errors:
        rows += f'{err["line"]}'
        rows += f'{err["text"]}'

    return f"""""

sample_log = """2026-03-31 08:00:01 INFO  Starting data pipeline
2026-03-31 08:00:05 INFO  Connected to database
2026-03-31 08:00:12 WARNING  Slow query detected (2.3s)
2026-03-31 08:00:15 ERROR  Failed to fetch API data: ConnectionTimeout
2026-03-31 08:00:16 ERROR  Retry 1 of 3 failed
2026-03-31 08:00:20 INFO  Retry 2 succeeded
2026-03-31 08:00:45 CRITICAL  Database connection lost
2026-03-31 08:00:46 INFO  Reconnecting to database...
2026-03-31 08:01:00 INFO  Pipeline completed with errors
"""
Path("app.log").write_text(sample_log)

# Check the log and send an alert if errors are found
class EmailNotifier:
    """Reusable email notification system" HTML alert built {len(errors)} chars)
In production: notifier.send(admin_email, subject, body, html)
else:
    print("No errors found. All clear!")

Output:

Found 3 error(s) in app.log:
  Line 4: 2026-03-31 08:00:15 ERROR  Failed to fetch API data: ConnectionTimeout
  Line 5: 2026-03-31 08:00:16 ERROR  Retry 1 of 3 failed
  Line 7: 2026-03-31 08:00:45 CRITICAL  Database connection lost

HTML alert built (698 chars)
In production: notifier.send(admin_email, subject, body, html)

This notification system is designed to be dropped into any existing project. The EmailNotifier class handles all the SMTP details, check_log_for_errors() scans for problems, and build_alert_html() creates a readable alert email. You could schedule this to run every hour with cron or the schedule library, and you would have a lightweight monitoring system without any third-party services.

Gmail Sending Limits and Best Practices

Gmail enforces rate limits on how many emails you can send per day. Knowing these limits prevents your script from getting temporarily blocked.

Account TypeDaily LimitPer MinuteMax Recipients Per Email
Free Gmail500 emails~20500
Google Workspace2,000 emails~302,000

If you hit these limits, Gmail returns an SMTPDataError with code 421 or 550. Your script should catch this and wait before retrying. For high-volume sending (marketing emails, large mailing lists), use a dedicated email service like SendGrid, Mailgun, or Amazon SES instead of Gmail -- they are designed for bulk sending and provide analytics, bounce handling, and higher limits.

Frequently Asked Questions

How do I create a Gmail App Password?

Go to myaccount.google.com/apppasswords after enabling 2-Step Verification on your account. Click "Select app," choose "Other," type a name like "Python Script," and click Generate. Copy the 16-character password and use it in your server.login() call instead of your regular Gmail password. You can revoke it anytime from the same page.

Why does Gmail reject my login with SMTPAuthenticationError?

This almost always means you are using your regular Gmail password instead of an App Password. Google disabled "Less Secure App Access" permanently in 2022. You must use an App Password (see the section above) or switch to OAuth2 for more complex applications. Double-check that there are no extra spaces in your password string.

My script hangs when connecting to the SMTP server. What is wrong?

Add a timeout parameter to your SMTP connection: smtplib.SMTP_SSL("smtp.gmail.com", 465, timeout=30). This prevents the script from hanging forever if the server is unreachable. Common causes include corporate firewalls blocking port 465 or 587, VPN interference, or DNS resolution failures. Try pinging smtp.gmail.com from your terminal to verify connectivity.

How do I send emails with special characters or non-English text?

The EmailMessage class handles Unicode correctly by default. Just pass your text normally: msg.set_content("Bonjour! Voici votre rapport."). The message will be encoded as UTF-8 automatically. If you are using the older MIMEText API, explicitly set the charset: MIMEText(body, "plain", "utf-8").

How can I test email sending without actually sending emails?

Python has a built-in debugging SMTP server that prints emails to the terminal instead of sending them. Run python -m smtpd -n -c DebuggingServer localhost:1025 in one terminal, then connect your script to localhost:1025 using smtplib.SMTP("localhost", 1025) (no SSL, no login). You will see the full email content printed to the terminal. For Python 3.12+, use aiosmtpd instead since the built-in smtpd module was removed.

Conclusion

You now have everything you need to send emails from Python scripts: plain text messages with set_content(), HTML-formatted emails with add_alternative(), file attachments with add_attachment(), and a robust error-handling wrapper with retry logic. The notification system project gives you a ready-to-use template for monitoring any automated task.

Try extending the notification system to watch multiple log files, send daily digest emails instead of per-error alerts, or add Slack webhook notifications alongside email. The EmailNotifier class is designed to be subclassed and customized for your specific needs.

For the complete API reference, see the official Python documentation for smtplib and email.message.