Learning Goals
3 minBy the end of this lesson you can:
- Build an email with
EmailMessage(from, to, subject, body). - Connect to an SMTP server securely and authenticate.
- Use an app password from the environment, not your real password.
- Send to multiple recipients and handle send failures gracefully.
Warm-Up · How Email Actually Sends
5 minSending mail has two parts: compose the message (headers + body, the MIME format) and transmit it through an SMTP server (the postal service). Python gives you EmailMessage for the first and smtplib for the second.
Gmail: smtp.gmail.com port 587 (STARTTLS) or 465 (SSL) Outlook: smtp.office365.com port 587 Most: 587 + STARTTLS is the modern default
You don't need your own mail server — you connect to your provider's SMTP server and authenticate, exactly like your email app does. The two non-negotiables: the connection must be encrypted (TLS), and you authenticate with an app password stored in the environment, never your real account password and never in code.
New Concept · Compose & Send
14 minBuild the message
from email.message import EmailMessage msg = EmailMessage() msg["From"] = "bot@example.com" msg["To"] = "boss@example.com" msg["Subject"] = "Daily report — 2026-05-28" msg.set_content("Hi,\n\nToday's numbers are attached.\n\n— The Bot")
EmailMessage handles MIME for you. Set headers like dict keys; set_content sets the plain-text body. (HTML and attachments come next lesson.)
Send over TLS
import os, smtplib from dotenv import load_dotenv load_dotenv() SMTP_HOST = "smtp.gmail.com" SMTP_PORT = 587 USER = os.environ["SMTP_USER"] # your email address PASS = os.environ["SMTP_PASS"] # an APP PASSWORD, from .env with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: server.starttls() # upgrade the connection to encrypted server.login(USER, PASS) server.send_message(msg) print("sent ✅")
SMTP(...)in awithblock closes the connection automatically.starttls()encrypts the connection — always call it beforelogin, or your password travels in plaintext.send_message(msg)transmits theEmailMessage.
App passwords, not real passwords
Gmail/Outlook block your normal password for SMTP and require an app password: a provider-generated, revocable token scoped to one app (you enable 2FA, then create one in account settings). Store it as SMTP_PASS in .env (git-ignored, Lesson 8). If it ever leaks, revoke just that one — your main account stays safe. Never put any password in source code.
SSL alternative (port 465)
import smtplib, ssl context = ssl.create_default_context() with smtplib.SMTP_SSL(SMTP_HOST, 465, context=context) as server: server.login(USER, PASS) server.send_message(msg)
SMTP_SSL is encrypted from the first byte (no starttls needed). Use whichever port your provider prefers — 587+STARTTLS is the common default.
Multiple recipients
msg["To"] = "a@example.com, b@example.com" # comma-separated msg["Cc"] = "manager@example.com" msg["Bcc"] = "archive@example.com" # hidden recipients
send_message reads To/Cc/Bcc automatically. Use Bcc for bulk so recipients don't see each other's addresses.
Worked Example · A Reusable Mailer
12 minGoal: a small send_email() function you can call from any automation, with config from the environment, TLS, logging, and graceful error handling.
import os, smtplib, logging from email.message import EmailMessage from dotenv import load_dotenv load_dotenv() logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") log = logging.getLogger("mailer") def send_email(to: list[str], subject: str, body: str, cc: list[str] | None = None) -> bool: host = os.getenv("SMTP_HOST", "smtp.gmail.com") port = int(os.getenv("SMTP_PORT", "587")) user = os.environ["SMTP_USER"] password = os.environ["SMTP_PASS"] msg = EmailMessage() msg["From"] = user msg["To"] = ", ".join(to) if cc: msg["Cc"] = ", ".join(cc) msg["Subject"] = subject msg.set_content(body) try: with smtplib.SMTP(host, port, timeout=30) as server: server.starttls() server.login(user, password) server.send_message(msg) log.info("sent '%s' to %d recipient(s)", subject, len(to)) return True except smtplib.SMTPAuthenticationError: log.error("auth failed — check SMTP_USER and app password SMTP_PASS") except smtplib.SMTPException as e: log.error("send failed: %s", e) return False send_email( to=["boss@example.com"], subject="Daily report — 2026-05-28", body="Hi,\n\nReport summary: 412 orders, total 84,210.50.\n\n— The Bot", )
INFO sent 'Daily report — 2026-05-28' to 1 recipient(s)
Read the code
Everything risky is handled: config and the app password come from the environment, the connection is TLS-encrypted, and the two most common failures — bad auth and general send errors — are caught with actionable messages rather than a raw traceback. The function returns a bool so a calling pipeline can decide what to do if delivery fails. Drop this into the report generator (Lesson 22) and your reports email themselves; next lesson we attach the actual files.
For development, use a catch-all inbox like Mailtrap or a local debug server: python -m smtpd -c DebuggingServer -n localhost:1025 prints emails to the console instead of sending them. Point SMTP_HOST=localhost, SMTP_PORT=1025 and you can test the whole flow safely.
Try It Yourself
13 minUse the local debug server (above) or Mailtrap so you don't send real mail while learning.
Compose and send a plain-text email to the debug server. Confirm it appears in the console/inbox with the right subject and body.
Write welcome(name, email) that fills a body template ("Hi {name}, welcome!") and sends it. Loop over a small list to send personalised mail to several people.
Hint
def welcome(name, email): body = f"Hi {name},\n\nWelcome aboard!\n\n— The Team" return send_email([email], "Welcome!", body) for name, email in [("Aisha", "a@x.com"), ("Ben", "b@x.com")]: welcome(name, email)
Send to a list of recipients individually (so one bad address doesn't block the rest), collecting a results dict of email → ok/failed. Add a small sleep between sends to be polite to the server.
Hint
import time def bulk(recipients, subject, body): results = {} for addr in recipients: results[addr] = send_email([addr], subject, body) time.sleep(1) # gentle pacing return results
Mini-Challenge · The Alert Function
8 minWrite alert(subject, message, severity="info") that emails an alert with the severity in the subject (e.g. [ERROR] Disk full) and a timestamp in the body. This becomes the email channel of the multi-channel alert system in Lesson 34.
Show a sample solution
from datetime import datetime def alert(subject: str, message: str, severity: str = "info") -> bool: full_subject = f"[{severity.upper()}] {subject}" body = (f"Severity: {severity.upper()}\n" f"Time: {datetime.now():%Y-%m-%d %H:%M:%S}\n\n" f"{message}\n") to = os.environ.get("ALERT_TO", "").split(",") return send_email(to, full_subject, body) alert("Disk almost full", "Server disk at 92% on /var", severity="error")
Non-negotiables: severity in subject, timestamped body, recipients from env, returns success bool.
Recap
3 minSending email = compose with EmailMessage (From/To/Subject headers, set_content for the body) and transmit with smtplib. Connect with SMTP(host, 587) + starttls() (or SMTP_SSL on 465), then login and send_message inside a with block. Authenticate with a revocable app password from the environment — never your real password, never in code. Catch SMTPAuthenticationError and SMTPException for clear failures, send to lists individually for robustness, and test against a debug/Mailtrap server so you never accidentally spam.
Vocabulary Card
- SMTP
- The protocol/server for sending email; you connect and authenticate to it.
- EmailMessage
- Python's builder for a MIME email (headers + body).
- STARTTLS
- Upgrades a connection to encrypted before you log in.
- app password
- A revocable, app-scoped token used instead of your real password.
Homework
4 minBuild mailer.py as a reusable module with your send_email() and alert() functions, fully config-driven from .env (host, port, user, app password). Write a demo that sends a templated message to the local debug server, and document the exact steps to switch it to a real provider (enable 2FA → create app password → set env vars). Confirm no secret is in the code with git status.
Sample · mailer.py setup notes
# .env (git-ignored) SMTP_HOST=localhost # dev: python -m smtpd -c DebuggingServer -n localhost:1025 SMTP_PORT=1025 SMTP_USER=bot@example.com SMTP_PASS=unused-in-dev # To go live with Gmail: # 1. Enable 2-Step Verification on the account. # 2. Create an App Password (Google Account → Security → App passwords). # 3. Set in .env: # SMTP_HOST=smtp.gmail.com # SMTP_PORT=587 # SMTP_USER=you@gmail.com # SMTP_PASS=<the 16-char app password> # 4. git status → confirm .env is NOT staged.
Non-negotiables: reusable env-driven module, debug-server demo, real-provider switch steps, no secret committed.