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.
Basic
5 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.
Challenge 1
4 minWrite 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)
Challenge 2
4 minSend 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
Challenge 3 · 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.
Extra Mission
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.