16 min read

> "The best email is the one you never had to write manually."

Chapter 19: Email Automation and Notifications

"The best email is the one you never had to write manually." — Priya Kapoor, Operations Lead, Acme Corp


Introduction

Every business runs on communication, and a huge portion of that communication flows through email. Status reports go out every Monday morning. Invoices get followed up every thirty days. KPI dashboards land in executive inboxes before the weekly meeting. Alert messages fire when a metric crosses a threshold.

Most of these emails are not creative work. They are structured, repetitive, and rule-driven — which makes them ideal candidates for automation. A human being spending twenty minutes every Monday morning copying numbers from a spreadsheet into an email template is not doing creative work. They are doing mechanical work, and mechanical work is something Python does extremely well.

In this chapter you will learn how email actually works at the protocol level, how Python's standard library handles the mechanics of sending messages, how to build rich emails with HTML and file attachments, and how to manage credentials securely so your scripts are safe to share. You will also look at Slack webhooks as a lightweight alternative for internal notifications. By the end of the chapter you will have a complete toolkit for building automated communication systems that run reliably without human intervention.

What You Will Learn

  • How the SMTP protocol works and what Python's smtplib does
  • How to build plain text and HTML emails using the email module
  • How to attach files (PDF, Excel, CSV) to outgoing messages
  • How to manage credentials safely using environment variables and .env files
  • How to set up App Passwords for Gmail and Outlook
  • How to send to multiple recipients including CC and BCC
  • How to build reusable email templates with string formatting and Jinja2
  • How to trigger emails conditionally based on business metrics
  • How to send Slack notifications via webhooks
  • Real Acme Corp and Maya Reyes automation scenarios

Prerequisites

  • Chapters 1 through 18 of this book (particularly Chapter 9 on file I/O, Chapter 14 on pandas, and Chapter 16 on Excel automation)
  • A working email account for testing (Gmail with App Password recommended)
  • The following packages: python-dotenv, jinja2, openpyxl (install via pip)

19.1 How Email Works: A Brief Technical Primer

Before writing any code, it helps to understand the machinery behind email delivery. You do not need to become a network engineer, but a mental model of what happens when Python sends a message will save you hours of debugging.

19.1.1 SMTP: The Delivery Protocol

SMTP stands for Simple Mail Transfer Protocol. When you send an email, your email client (Gmail, Outlook, Thunderbird) hands the message to an SMTP server, which routes it toward the recipient's mail server. The analogy to physical mail is useful: SMTP is the postal carrier, not the mailbox.

Key facts about SMTP:

  • SMTP operates over TCP, typically on port 25 (server-to-server), port 587 (submission with STARTTLS encryption), or port 465 (SSL/TLS)
  • Modern email systems require authentication — you must prove you are allowed to send from a given address
  • SMTP is purely a sending protocol. Reading mail uses IMAP or POP3, which are separate protocols (and out of scope for this chapter)

19.1.2 MIME: The Message Format

MIME stands for Multipurpose Internet Mail Extensions. It defines how an email message is structured — how you embed HTML, how attachments are included, how images are referenced. Before MIME, email could only carry plain ASCII text. MIME extends that to support HTML, binary files, images, and international character sets.

A MIME message is built from parts:

  • A text/plain part for the fallback plain text version
  • A text/html part for the formatted HTML version
  • application/octet-stream or specific MIME types for attachments
  • image/png or similar for inline images

Python's email module (part of the standard library) handles MIME construction for you.

19.1.3 The Python Stack

Python provides several tools for email work:

Tool What It Does
smtplib Connects to an SMTP server and sends the message
email module Constructs MIME messages (headers, body, attachments)
email.mime.text Creates plain text and HTML parts
email.mime.multipart Creates multi-part messages (HTML + attachment)
email.mime.base Base class for non-text attachments
email.encoders Encodes binary attachments (base64)
python-dotenv Loads credentials from .env files
jinja2 Templating engine for complex HTML email bodies

The standard library (smtplib, email) handles the core mechanics. Third-party packages (python-dotenv, jinja2) add convenience.


19.2 Sending Your First Email with smtplib

Let us start with the simplest possible case: a plain text email.

19.2.1 A Minimal Example

import smtplib
from email.mime.text import MIMEText
import os

# --- Message construction ---
body = "Hello Sandra, your weekly report is ready. See attached."
msg = MIMEText(body, "plain")
msg["Subject"] = "Weekly Operations Report"
msg["From"] = os.environ["EMAIL_SENDER"]
msg["To"] = "sandra.chen@acmecorp.com"

# --- Sending ---
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
    server.login(os.environ["EMAIL_SENDER"], os.environ["EMAIL_PASSWORD"])
    server.send_message(msg)

print("Email sent successfully.")

Before this runs, you need two environment variables set: EMAIL_SENDER and EMAIL_PASSWORD. We will cover exactly how to do that in section 19.4. For now, focus on the structure.

There are two phases:

  1. Construction — you build the message object, set headers, add a body
  2. Transmission — you open a connection to the SMTP server, authenticate, and deliver

19.2.2 SMTP vs. SMTP_SSL vs. STARTTLS

Python's smtplib offers three connection modes:

# Mode 1: Plain (no encryption) — never use for real credentials
# smtplib.SMTP("smtp.example.com", 25)

# Mode 2: SSL from the start — port 465
# smtplib.SMTP_SSL("smtp.gmail.com", 465)

# Mode 3: Start unencrypted, upgrade to TLS — port 587
# server = smtplib.SMTP("smtp.gmail.com", 587)
# server.starttls()

For Gmail, port 465 with SMTP_SSL is the most straightforward. For Outlook/Microsoft 365, port 587 with starttls() is standard. Both are secure; the difference is when the encryption negotiation happens.

19.2.3 Connection as a Context Manager

Note the with statement in the example above:

with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
    server.login(...)
    server.send_message(msg)

Using a context manager ensures the connection is properly closed even if an error occurs. This is the preferred pattern. Avoid calling server.quit() manually unless you have a specific reason to.


19.3 Building Rich Emails with MIME

Plain text works for simple notifications. Business communications often require formatting — tables, company colors, bold headers. That means HTML email.

19.3.1 HTML Emails

An HTML email needs two parts: the plain text fallback (for email clients that cannot render HTML) and the HTML version. The container for this is MIMEMultipart("alternative").

from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

msg = MIMEMultipart("alternative")
msg["Subject"] = "Operations Report — Week 47"
msg["From"] = "priya@acmecorp.com"
msg["To"] = "sandra.chen@acmecorp.com"

# Plain text fallback
plain_text = """
Hi Sandra,

Your weekly operations report is attached.

Key numbers this week:
- Total Revenue: $284,500
- Units Shipped: 1,847
- Customer Satisfaction: 91%

Full details in the attached Excel file.

Best,
Priya
"""

# HTML version
html_content = """
<html>
<body>
<h2 style="color: #2c3e50;">Operations Report — Week 47</h2>
<p>Hi Sandra,</p>
<p>Your weekly operations report is attached. Here are the key numbers:</p>
<table border="1" cellpadding="8" cellspacing="0"
       style="border-collapse: collapse; font-family: Arial, sans-serif;">
  <thead>
    <tr style="background-color: #2c3e50; color: white;">
      <th>Metric</th>
      <th>This Week</th>
      <th>Target</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Total Revenue</td>
      <td>$284,500</td>
      <td>$275,000</td>
    </tr>
    <tr style="background-color: #f2f2f2;">
      <td>Units Shipped</td>
      <td>1,847</td>
      <td>1,800</td>
    </tr>
    <tr>
      <td>Customer Satisfaction</td>
      <td>91%</td>
      <td>90%</td>
    </tr>
  </tbody>
</table>
<p>Full details are in the attached Excel file.</p>
<p>Best regards,<br><strong>Priya Kapoor</strong><br>
Operations Lead, Acme Corp</p>
</body>
</html>
"""

# Attach both parts — email clients will use the LAST one they can render
msg.attach(MIMEText(plain_text, "plain"))
msg.attach(MIMEText(html_content, "html"))

The MIMEMultipart("alternative") container signals to email clients that the two parts are alternative representations of the same content. Well-behaved clients choose the richest format they support.

19.3.2 Why You Still Need Plain Text

You might wonder why anyone bothers with plain text in 2026. Several reasons:

  • Corporate email filters sometimes strip HTML and check the plain text version
  • Some recipients use text-only email clients (still common in enterprise environments)
  • Plain text renders perfectly on every device without CSS quirks
  • Spam filters are more likely to flag HTML-only messages with no text alternative

Always include both.

19.3.3 Inline Images

For embedding a company logo or chart directly in the email body, you use MIMEMultipart("related") and reference the image with a Content-ID:

import base64
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

msg = MIMEMultipart("related")
msg["Subject"] = "Acme Corp — Q4 Snapshot"

html_with_image = """
<html>
<body>
<h2>Q4 Performance Snapshot</h2>
<img src="cid:acme_logo" alt="Acme Corp Logo" width="200">
<p>See the attached report for full details.</p>
</body>
</html>
"""

msg.attach(MIMEText(html_with_image, "html"))

with open("acme_logo.png", "rb") as f:
    img = MIMEImage(f.read())
    img.add_header("Content-ID", "<acme_logo>")
    img.add_header("Content-Disposition", "inline", filename="acme_logo.png")
    msg.attach(img)

The cid:acme_logo in the HTML matches the Content-ID header <acme_logo> on the image part. This is the standard mechanism for inline images.


19.4 Managing Credentials Securely

This section may be the most important in the chapter. Hardcoding passwords in your scripts is a serious security error. Even if you never share the file, the password can end up in version control history, log files, or clipboard history. The correct approach is to keep credentials out of your code entirely.

19.4.1 Environment Variables

An environment variable is a named value stored in the operating system, accessible to any process running in that environment. Python reads them with os.environ:

import os

sender = os.environ["EMAIL_SENDER"]    # raises KeyError if not set
password = os.environ.get("EMAIL_PASSWORD")  # returns None if not set

Setting environment variables:

# macOS/Linux (temporary — only lasts for current terminal session)
export EMAIL_SENDER="priya@acmecorp.com"
export EMAIL_PASSWORD="your-app-password-here"

# Windows Command Prompt (temporary)
set EMAIL_SENDER=priya@acmecorp.com
set EMAIL_PASSWORD=your-app-password-here

# Windows PowerShell (temporary)
$env:EMAIL_SENDER = "priya@acmecorp.com"
$env:EMAIL_PASSWORD = "your-app-password-here"

For permanent settings on macOS/Linux, add the export lines to ~/.bashrc or ~/.zshrc. On Windows, use System Properties > Environment Variables.

19.4.2 The .env File and python-dotenv

Manually setting environment variables in the terminal is tedious and error-prone. The standard development practice is to store them in a .env file and load them automatically using python-dotenv.

First, install the package:

pip install python-dotenv

Create a .env file in your project directory:

# .env — NEVER commit this file to version control
EMAIL_SENDER=priya@acmecorp.com
EMAIL_PASSWORD=abcd efgh ijkl mnop
EMAIL_SMTP_HOST=smtp.gmail.com
EMAIL_SMTP_PORT=465

Then load it at the top of your script:

from dotenv import load_dotenv
import os

load_dotenv()  # loads .env file from current directory

sender = os.environ["EMAIL_SENDER"]
password = os.environ["EMAIL_PASSWORD"]

load_dotenv() reads the .env file and sets the variables as environment variables in the current process. If the variables are already set in the environment (e.g., in production), it does not override them — which means the same code works in both development (using .env) and production (using real environment variables).

19.4.3 The .gitignore Rule

Add .env to your .gitignore file immediately:

# .gitignore
.env
*.env
.env.local
.env.production

A .env.example file with placeholder values (no real credentials) is helpful for teammates:

# .env.example — safe to commit, shows required variables
EMAIL_SENDER=your-email@example.com
EMAIL_PASSWORD=your-app-password-here
EMAIL_SMTP_HOST=smtp.gmail.com
EMAIL_SMTP_PORT=465

19.4.4 Setting Up App Passwords

Most major email providers no longer allow your main account password to be used by third-party applications. Instead, they issue App Passwords — separate, limited-scope passwords that can be revoked independently.

Gmail App Password Setup:

  1. Sign in to your Google Account at myaccount.google.com
  2. Go to Security
  3. Under "How you sign in to Google," ensure 2-Step Verification is enabled
  4. Search for "App passwords" in the settings
  5. Select "Mail" as the app and your device type
  6. Google generates a 16-character password (e.g., abcd efgh ijkl mnop)
  7. Copy this password — you will not see it again
  8. Use this password as EMAIL_PASSWORD in your .env file

Microsoft/Outlook App Password Setup:

  1. Go to account.microsoft.com
  2. Navigate to Security > Advanced security options
  3. Under "App passwords," create a new app password
  4. Copy and store it immediately

Important: App passwords are long, random strings. Do not try to memorize them. Store them immediately in your .env file or password manager. If you lose it, generate a new one.


19.5 Sending to Multiple Recipients

19.5.1 To, CC, and BCC

Email has three recipient fields with different behaviors:

  • To: Primary recipients. Everyone can see who is in this field.
  • CC (Carbon Copy): Secondary recipients. Everyone can see who is CC'd.
  • BCC (Blind Carbon Copy): Hidden recipients. Nobody except the sender knows who is BCC'd.

In Python, these are set as headers. The critical detail is that send_message() uses the header to determine delivery — but for BCC, you must pass addresses to sendmail() directly:

from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import smtplib
import os

msg = MIMEMultipart()
msg["Subject"] = "Q4 Regional Sales Summary"
msg["From"] = "priya@acmecorp.com"
msg["To"] = "sandra.chen@acmecorp.com"
msg["Cc"] = "marcus.webb@acmecorp.com"
# Note: BCC addresses are NOT put in a header

msg.attach(MIMEText("Please find the Q4 summary below.", "plain"))

to_addresses = ["sandra.chen@acmecorp.com"]
cc_addresses = ["marcus.webb@acmecorp.com"]
bcc_addresses = ["cfo@acmecorp.com", "coo@acmecorp.com"]

all_recipients = to_addresses + cc_addresses + bcc_addresses

with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
    server.login(os.environ["EMAIL_SENDER"], os.environ["EMAIL_PASSWORD"])
    server.sendmail(
        from_addr=os.environ["EMAIL_SENDER"],
        to_addrs=all_recipients,
        msg=msg.as_string()
    )

Notice that BCC recipients are included in the to_addrs list passed to sendmail() but are not written into any header. This is what makes them "blind."

19.5.2 Sending Personalized Bulk Emails

For a list of recipients where each gets a personalized message, build and send individual emails in a loop:

import smtplib
import os
import time
from email.mime.text import MIMEText
from dotenv import load_dotenv

load_dotenv()

recipients = [
    {"name": "Sandra Chen", "email": "sandra.chen@acmecorp.com", "region": "West"},
    {"name": "Marcus Webb", "email": "marcus.webb@acmecorp.com", "region": "East"},
    {"name": "Rachel Kim", "email": "rachel.kim@acmecorp.com", "region": "Central"},
]

for recipient in recipients:
    body = f"""Hi {recipient['name']},

Your {recipient['region']} Region report for Week 47 is now available.

Please log in to the dashboard for full details.

Best regards,
Priya Kapoor
Operations Lead
"""
    msg = MIMEText(body, "plain")
    msg["Subject"] = f"Week 47 Report — {recipient['region']} Region"
    msg["From"] = os.environ["EMAIL_SENDER"]
    msg["To"] = recipient["email"]

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

    print(f"Sent to {recipient['name']} ({recipient['email']})")
    time.sleep(1)  # small pause to avoid triggering rate limits

Opening a new connection per recipient is slightly less efficient than reusing one connection, but it is more robust — a failure sending to one recipient does not affect the others. For small lists (under a hundred), this approach is fine.


19.6 Email Attachments

The most common business use case for email automation involves attaching files: reports, invoices, spreadsheets, PDFs.

19.6.1 Attaching a Single File

import smtplib
import os
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
from dotenv import load_dotenv

load_dotenv()

def send_with_attachment(
    to_address: str,
    subject: str,
    body: str,
    attachment_path: str
) -> None:
    """Send an email with a single file attachment."""
    msg = MIMEMultipart()
    msg["Subject"] = subject
    msg["From"] = os.environ["EMAIL_SENDER"]
    msg["To"] = to_address

    msg.attach(MIMEText(body, "plain"))

    # Read and attach the file
    filename = os.path.basename(attachment_path)
    with open(attachment_path, "rb") as f:
        part = MIMEBase("application", "octet-stream")
        part.set_payload(f.read())

    encoders.encode_base64(part)
    part.add_header(
        "Content-Disposition",
        f"attachment; filename={filename}"
    )
    msg.attach(part)

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

    print(f"Email sent to {to_address} with attachment: {filename}")

The MIMEBase("application", "octet-stream") is a generic binary MIME type. For specific file types, you can be more precise:

File Type MIME Type
PDF application/pdf
Excel (.xlsx) application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
CSV text/csv
ZIP application/zip
PNG image/png

For attaching a report to an executive's inbox, the specific MIME type helps email clients display the correct icon and handle the download properly.

19.6.2 Multiple Attachments

def send_with_multiple_attachments(
    to_address: str,
    subject: str,
    body: str,
    attachment_paths: list[str]
) -> None:
    """Send an email with multiple file attachments."""
    msg = MIMEMultipart()
    msg["Subject"] = subject
    msg["From"] = os.environ["EMAIL_SENDER"]
    msg["To"] = to_address

    msg.attach(MIMEText(body, "plain"))

    for attachment_path in attachment_paths:
        filename = os.path.basename(attachment_path)
        with open(attachment_path, "rb") as f:
            part = MIMEBase("application", "octet-stream")
            part.set_payload(f.read())
        encoders.encode_base64(part)
        part.add_header(
            "Content-Disposition",
            f"attachment; filename={filename}"
        )
        msg.attach(part)

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

    print(f"Sent {len(attachment_paths)} attachment(s) to {to_address}")

19.7 Email Templates with String Formatting and Jinja2

19.7.1 Python String Formatting

For straightforward templates, Python's f-strings or .format() method work well:

def build_weekly_report_email(
    recipient_name: str,
    week_number: int,
    metrics: dict
) -> str:
    """Build a plain text weekly report email body."""
    return f"""Hi {recipient_name},

Here is the Acme Corp operations summary for Week {week_number}.

PERFORMANCE METRICS
-------------------
Revenue:              ${metrics['revenue']:>12,.2f}
Units Shipped:        {metrics['units_shipped']:>12,}
New Orders:           {metrics['new_orders']:>12,}
Customer Satisfaction:{metrics['satisfaction']:>11.1f}%
Return Rate:          {metrics['return_rate']:>11.1f}%

{'** ALERT: Revenue below target **' if metrics['revenue'] < metrics['revenue_target'] else 'All metrics within target range.'}

This report was generated automatically. Please do not reply to this email.
Contact priya@acmecorp.com for questions.

Best regards,
Priya Kapoor
Operations Lead, Acme Corp
"""

The format specifiers ({:>12,.2f}) right-align numbers and add thousand separators and decimal places, making the plain text version much more readable.

19.7.2 Jinja2 for HTML Templates

For complex HTML emails, Jinja2 provides a proper templating language with loops, conditionals, and inheritance. It is the same engine used by Flask and Django for web pages.

Install it:

pip install jinja2

Create a template file templates/weekly_report.html:

<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: Arial, sans-serif; color: #333; }
    h2 { color: #2c3e50; }
    table { border-collapse: collapse; width: 100%; }
    th { background-color: #2c3e50; color: white; padding: 10px; text-align: left; }
    td { padding: 8px 10px; border-bottom: 1px solid #ddd; }
    tr:nth-child(even) { background-color: #f9f9f9; }
    .alert { color: #e74c3c; font-weight: bold; }
    .ok { color: #27ae60; font-weight: bold; }
  </style>
</head>
<body>
  <h2>Acme Corp — Week {{ week_number }} Operations Report</h2>
  <p>Hi {{ recipient_name }},</p>
  <p>Here is your weekly operations summary.</p>

  <table>
    <thead>
      <tr>
        <th>Metric</th>
        <th>Actual</th>
        <th>Target</th>
        <th>Status</th>
      </tr>
    </thead>
    <tbody>
      {% for row in metrics %}
      <tr>
        <td>{{ row.label }}</td>
        <td>{{ row.actual_display }}</td>
        <td>{{ row.target_display }}</td>
        <td>
          {% if row.on_target %}
            <span class="ok">On Target</span>
          {% else %}
            <span class="alert">Below Target</span>
          {% endif %}
        </td>
      </tr>
      {% endfor %}
    </tbody>
  </table>

  {% if any_alerts %}
  <p class="alert">One or more metrics are below target. Review attached report for details.</p>
  {% endif %}

  <p>Full data is in the attached Excel file.</p>
  <p>Best regards,<br><strong>Priya Kapoor</strong><br>
  Operations Lead, Acme Corp</p>
</body>
</html>

Render it in Python:

from jinja2 import Environment, FileSystemLoader
import os

def render_report_template(
    recipient_name: str,
    week_number: int,
    metrics_data: list[dict]
) -> str:
    """Render the weekly report HTML template."""
    env = Environment(loader=FileSystemLoader("templates"))
    template = env.get_template("weekly_report.html")

    any_alerts = any(not row["on_target"] for row in metrics_data)

    html = template.render(
        recipient_name=recipient_name,
        week_number=week_number,
        metrics=metrics_data,
        any_alerts=any_alerts
    )
    return html

Jinja2 templates separate your HTML structure from your Python logic cleanly. As templates grow more complex — multiple sections, conditional blocks, loops over data — this separation pays dividends in maintainability.


19.8 Conditional Email Triggers: KPI Alerts

One of the highest-value email automation patterns is the conditional alert: a script runs on a schedule, checks metrics against thresholds, and sends an email only when something requires attention.

19.8.1 The Alert Pattern

def check_kpis_and_alert(metrics: dict, thresholds: dict) -> list[dict]:
    """
    Check each metric against its threshold.
    Returns a list of breaches (metric name, actual value, threshold).
    """
    breaches = []
    for metric_name, actual_value in metrics.items():
        if metric_name in thresholds:
            threshold = thresholds[metric_name]
            if actual_value < threshold["min"]:
                breaches.append({
                    "metric": metric_name,
                    "actual": actual_value,
                    "threshold": threshold["min"],
                    "type": "below_minimum",
                    "display": threshold.get("display", metric_name)
                })
    return breaches


def send_kpi_alert(breaches: list[dict], recipients: list[str]) -> None:
    """Send a KPI alert email listing all threshold breaches."""
    if not breaches:
        print("All KPIs within acceptable range. No alert sent.")
        return

    lines = ["The following KPIs are outside acceptable thresholds:\n"]
    for breach in breaches:
        lines.append(
            f"  {breach['display']}: {breach['actual']:.2f} "
            f"(minimum threshold: {breach['threshold']:.2f})"
        )
    lines.append("\nPlease review and take corrective action.")

    body = "\n".join(lines)

    msg = MIMEMultipart()
    msg["Subject"] = f"[ALERT] KPI Threshold Breach — {len(breaches)} metric(s)"
    msg["From"] = os.environ["EMAIL_SENDER"]
    msg["To"] = ", ".join(recipients)
    msg.attach(MIMEText(body, "plain"))

    with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
        server.login(os.environ["EMAIL_SENDER"], os.environ["EMAIL_PASSWORD"])
        server.sendmail(
            from_addr=os.environ["EMAIL_SENDER"],
            to_addrs=recipients,
            msg=msg.as_string()
        )
    print(f"KPI alert sent to {len(recipients)} recipient(s).")

19.8.2 Scheduling Automated Checks

The script itself is just Python — it does not know when to run. Scheduling is handled externally:

On macOS/Linux: cron

# Run kpi_alert.py every weekday at 7:00 AM
0 7 * * 1-5 /usr/bin/python3 /home/priya/scripts/kpi_alert.py

On Windows: Task Scheduler

Use Windows Task Scheduler (search for it in Start Menu) to create a task that runs python kpi_alert.py on a daily schedule.

Cloud options:

For scripts running on servers or cloud environments, services like AWS Lambda with EventBridge, Google Cloud Scheduler, or GitHub Actions scheduled workflows provide robust scheduling with logging and retry capabilities.


19.9 Slack Webhook Notifications

Email is not always the right tool for internal alerts. If your team uses Slack, sending a Slack message is often faster, more visible, and creates less inbox noise than an email. Slack's Incoming Webhooks feature provides a simple HTTP endpoint you can POST a JSON message to — no authentication beyond the webhook URL itself.

19.9.1 Setting Up a Slack Webhook

  1. Go to api.slack.com/apps and create a new app (or use an existing one)
  2. Under "Features," select "Incoming Webhooks"
  3. Enable Incoming Webhooks and click "Add New Webhook to Workspace"
  4. Choose the channel where messages should appear
  5. Copy the webhook URL — it looks like https://hooks.slack.com/services/T.../B.../...
  6. Store this URL in your .env file as SLACK_WEBHOOK_URL

19.9.2 Sending a Slack Message

import requests
import json
import os
from dotenv import load_dotenv

load_dotenv()

def send_slack_alert(message: str, channel: str = None) -> bool:
    """
    Send a message to Slack via an Incoming Webhook.
    Returns True if successful, False otherwise.
    """
    webhook_url = os.environ["SLACK_WEBHOOK_URL"]

    payload = {"text": message}
    if channel:
        payload["channel"] = channel

    response = requests.post(
        webhook_url,
        data=json.dumps(payload),
        headers={"Content-Type": "application/json"}
    )

    if response.status_code == 200:
        print("Slack notification sent.")
        return True
    else:
        print(f"Slack notification failed: {response.status_code} — {response.text}")
        return False


# Example usage
def check_and_notify(metrics: dict, thresholds: dict) -> None:
    """Check metrics and send Slack alert if thresholds are breached."""
    for metric, value in metrics.items():
        if metric in thresholds and value < thresholds[metric]:
            message = (
                f":warning: *KPI Alert* — {metric} is at {value:.1f}, "
                f"below threshold of {thresholds[metric]:.1f}. "
                f"Immediate review required."
            )
            send_slack_alert(message)

19.9.3 Richer Slack Messages with Block Kit

Slack supports a richer formatting system called Block Kit. You can build structured messages with sections, fields, buttons, and dividers:

def send_slack_kpi_report(metrics: dict) -> None:
    """Send a formatted KPI summary to Slack using Block Kit."""
    webhook_url = os.environ["SLACK_WEBHOOK_URL"]

    fields = []
    for metric_name, data in metrics.items():
        status_emoji = ":white_check_mark:" if data["on_target"] else ":red_circle:"
        fields.append({
            "type": "mrkdwn",
            "text": f"{status_emoji} *{metric_name}*\n{data['display_value']}"
        })

    payload = {
        "blocks": [
            {
                "type": "header",
                "text": {
                    "type": "plain_text",
                    "text": "Acme Corp — Weekly KPI Snapshot"
                }
            },
            {
                "type": "section",
                "fields": fields[:10]  # Block Kit max 10 fields per section
            },
            {
                "type": "context",
                "elements": [
                    {
                        "type": "mrkdwn",
                        "text": "Generated automatically. Contact priya@acmecorp.com for questions."
                    }
                ]
            }
        ]
    }

    requests.post(
        webhook_url,
        data=json.dumps(payload),
        headers={"Content-Type": "application/json"}
    )

19.9.4 Email vs. Slack: Choosing the Right Channel

Situation Better Choice
External communication (clients, vendors) Email
Formal records, audit trails Email
Large attachments (reports, spreadsheets) Email
Real-time internal alerts Slack
Casual team notifications Slack
Urgent warnings needing immediate attention Slack
Automated status updates your team ignores Neither — reconsider the workflow

19.10 Error Handling and Reliability

Production email scripts need to handle failures gracefully. Networks are unreliable. SMTP servers go down. Credentials expire.

19.10.1 Catching SMTP Exceptions

import smtplib
import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s — %(levelname)s — %(message)s"
)
logger = logging.getLogger(__name__)


def send_email_safe(msg, recipients: list[str]) -> bool:
    """
    Send email with comprehensive error handling.
    Returns True on success, False on failure.
    """
    try:
        with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
            server.login(
                os.environ["EMAIL_SENDER"],
                os.environ["EMAIL_PASSWORD"]
            )
            server.sendmail(
                from_addr=os.environ["EMAIL_SENDER"],
                to_addrs=recipients,
                msg=msg.as_string()
            )
        logger.info(f"Email sent successfully to {', '.join(recipients)}")
        return True

    except smtplib.SMTPAuthenticationError:
        logger.error("Authentication failed. Check EMAIL_SENDER and EMAIL_PASSWORD.")
        return False

    except smtplib.SMTPRecipientsRefused as e:
        logger.error(f"Recipient(s) refused: {e.recipients}")
        return False

    except smtplib.SMTPConnectError as e:
        logger.error(f"Could not connect to SMTP server: {e}")
        return False

    except smtplib.SMTPException as e:
        logger.error(f"SMTP error: {e}")
        return False

    except OSError as e:
        logger.error(f"Network error: {e}")
        return False

19.10.2 Retry Logic

For transient failures (network blips, temporary server unavailability), a simple retry with exponential backoff is appropriate:

import time

def send_with_retry(msg, recipients: list[str], max_retries: int = 3) -> bool:
    """Attempt to send email with exponential backoff on failure."""
    for attempt in range(max_retries):
        if send_email_safe(msg, recipients):
            return True
        if attempt < max_retries - 1:
            wait_time = 2 ** attempt  # 1s, 2s, 4s
            logger.warning(
                f"Attempt {attempt + 1} failed. Retrying in {wait_time}s..."
            )
            time.sleep(wait_time)

    logger.error(f"Failed to send email after {max_retries} attempts.")
    return False

19.11 Acme Corp Scenario: Sandra's Monday Report

Let us bring the concepts together in a realistic scenario.

Every Monday morning, Sandra Chen, Acme Corp's VP of Operations, needs a summary report covering the previous week's performance across all regions. Previously, Priya spent about ninety minutes each Monday pulling numbers from three regional CSV files, calculating totals, formatting them into a table, and sending an email with an Excel attachment.

Priya's automation plan:

  1. The script runs automatically at 6:30 AM every Monday
  2. It reads the three regional CSV files (pre-populated by each region's system by end of day Sunday)
  3. It calculates totals and compares against weekly targets
  4. It generates an Excel file with the formatted data and a simple bar chart
  5. It builds an HTML email with an inline summary table
  6. It sends the email to Sandra with the Excel file attached, CC'ing the three regional directors

The complete implementation is in case-study-01.md with the full production-ready script. The key insight is that Priya does not send emails — she writes and tests a script once, and the script does the work every week thereafter. Priya's Monday morning starts with a coffee, not with a spreadsheet.


19.12 Maya's Scenario: Late Payment Reminder Drafts

Maya Reyes runs a freelance consulting practice. Late payments are a chronic problem. She invoices clients on net-30 terms, but some clients routinely pay on net-45 or net-60. Chasing payments manually is awkward and time-consuming.

Maya's approach is slightly different from Priya's. Because payment reminders carry reputational risk — you do not want to accidentally send an aggressive reminder to a client who paid yesterday — Maya does not auto-send anything. Instead, her script:

  1. Reads her invoice tracker (a CSV file she maintains)
  2. Identifies invoices that are 30, 45, and 60+ days overdue
  3. Generates a personalized reminder email draft for each overdue invoice
  4. Saves each draft to a text file (named draft_CLIENT_DATE.txt)
  5. Prints a summary of what was generated

Maya reviews the drafts, edits any she wants to personalize further, and then sends them manually through her email client. This gives her the efficiency of automated drafting with the safety of human review.

The complete implementation is in case-study-02.md.


19.13 Putting It All Together: Best Practices

The Credential Checklist

Before deploying any email automation script:

  • [ ] No passwords in the source code
  • [ ] .env file listed in .gitignore
  • [ ] App Password used (not main account password)
  • [ ] Script tested with a non-production recipient first (email yourself)
  • [ ] BCC used for bulk sends where appropriate (avoids reply-all chaos)
  • [ ] Unsubscribe mechanism considered for marketing-adjacent communications

The Reliability Checklist

  • [ ] Error handling covers authentication failures, network errors, bad addresses
  • [ ] Failures are logged, not silently swallowed
  • [ ] Automated scripts monitored (you should know when they fail)
  • [ ] Rate limiting respected when sending bulk messages

The Design Checklist

  • [ ] Plain text fallback included in all HTML emails
  • [ ] Email subject line is specific and actionable
  • [ ] Attachments are below 10 MB (most servers reject larger)
  • [ ] "From" display name is human-readable (not just the email address)
  • [ ] Reply-to address set if different from the sending address

Setting a Reply-To Address

msg["From"] = "Acme Corp Reports <reports@acmecorp.com>"
msg["Reply-To"] = "priya@acmecorp.com"

This lets the sending mailbox be a no-reply address while directing replies to a human inbox.


Chapter Summary

Email automation is one of the highest-leverage applications of Python in a business context. The concepts in this chapter travel well:

  • smtplib provides the delivery mechanism — connecting to an SMTP server and authenticating
  • The email module provides the construction mechanism — building MIME messages with headers, bodies, and attachments
  • python-dotenv keeps credentials out of your code and out of version control
  • App Passwords let you use service accounts without exposing your main credentials
  • Jinja2 elevates simple string formatting into a proper templating system for complex HTML emails
  • Conditional triggers transform passive scripts into proactive alerting systems
  • Slack webhooks offer a lightweight alternative to email for internal real-time notifications

The pattern you have learned — check a condition, build a message, deliver it — applies to an enormous range of business communication problems. Invoice reminders, shipment confirmations, KPI alerts, daily digests, error notifications from other scripts — they all follow the same structure.

In the next chapter, you will learn web scraping, which provides another source of data for these automated workflows: public web pages.


Key Concepts Reference

Concept What to Remember
SMTP The protocol for sending email; Python uses smtplib
MIME The format for structured email content; Python uses the email module
App Password A secondary password for third-party apps; never use your main password
.env file Stores credentials; never commit to version control
load_dotenv() Loads .env file variables into os.environ
MIMEMultipart("alternative") Container for HTML + plain text versions
MIMEMultipart("related") Container for HTML + inline images
BCC Add to to_addrs in sendmail() but do not add a BCC header
Jinja2 Template engine for complex HTML email bodies
Slack webhook HTTP endpoint for sending messages to Slack channels