Sending email and text messages
EmailMessage, smtplib, mail merge and an SMS API, with secrets kept out of code.
Why it matters
Programs that tell people things are some of the most useful you can write: an order shipped, a price dropped, rain is forecast for Saturday's hike. Email and text messages are how those messages reach people. They're also easy to get badly wrong: the wrong name in a thousand emails, or a password pasted into code that ends up online. This lesson teaches the code and the care.
What you'll be able to do
- Build an email with EmailMessage, including an HTML version and an attachment
- Send it with smtplib: connect, starttls, log in with an app password, send
- Fill a message template for many people with string.Template, without sending anyone the wrong thing
- Send a text message through an API, with the keys kept out of your code
What it covers
- An email is a message object
- Sending: the practice outbox
- Keep passwords out of code
- Attachments
- Many people, one template
- An email with an HTML version
- Text messages through an API
- Worked example: a rain check before Saturday's hike
An email is a message object
Python's email package builds messages and smtplib sends them. Start with EmailMessage: set its headers like dictionary keys, then its body:
from email.message import EmailMessage msg = EmailMessage() msg["From"] = "Trail Club <club@shop.example.com>" msg["To"] = "ada@example.com" msg["Subject"] = "Your order has shipped" msg.set_content("Hi Ada,\n\nYour Ridgeline tent is on its way. It should arrive by Friday.\n\nThe shop team") print(msg["Subject"]) print(msg.as_string()[:300])
as_string() shows the message as it travels: headers first, a blank line, then the body. The headers have to look exactly like this, which is why you let EmailMessage write them rather than gluing strings together yourself.
This lesson uses example.com addresses. That domain is reserved for examples, so it can never reach a real person by accident.
Sending: the practice outbox
To send, a program connects to a mail server with SMTP, the protocol mail servers speak. The steps are always the same:
import os import smtplib from email.message import EmailMessage PASSWORD = os.environ.get("SMTP_PASSWORD", "practice-app-password") msg = EmailMessage() msg["From"] = "club@shop.example.com" msg["To"] = "ada@example.com" msg["Subject"] = "Your order has shipped" msg.set_content("Your Ridgeline tent is on its way.") with smtplib.SMTP("smtp.example.com", 587) as server: server.starttls() server.login("club@shop.example.com", PASSWORD) server.send_message(msg) print("sent")
- Connect to the server on port 587.
starttls()switches the connection to encryption, so the password isn't sent as plain text. (smtplib.SMTP_SSL(host, 465)is encrypted from the start instead.)- Log in. Mail providers like Gmail and Outlook don't accept your normal password from a program. You create an app password in your account's security settings: a separate password that can only send mail, and that you can cancel at any time.
- Send, then the
withblock closes the connection.
The academy can't send real email, so here smtplib is swapped for a practice outbox. It checks the same steps, and the message appears under the output instead of in someone's inbox. Try deleting the starttls() line: the practice server refuses the password, just as a real one would.
Keep passwords out of code
The password above comes from os.environ: an environment variable, set outside the program, as the API-keys lesson showed. Code gets copied, shared, pasted into chats and pushed to GitHub, and anything written in it should be treated as public. Passwords and keys belong in the environment or a secrets manager, never in the file.
The "practice-app-password" fallback is only there so this lesson runs. In real code, leave the fallback out. os.environ["SMTP_PASSWORD"] then fails loudly when the setting is missing, which is much better than quietly trying to log in with nothing.
Two more habits protect the people you write to. Test with your own address, or a test inbox, before you send to anyone else. And log what you sent and when, without the passwords, so that if something goes wrong you can see exactly who received what, and a rerun doesn't send it twice.
from email.message import EmailMessage m = EmailMessage() m["To"] = "a@example.com, b@example.com" m["Subject"] = "Hi" m.set_content("Body") print(m["To"].addresses[1].username, m.get_content_type())
Show the answer
b text/plain
The To header holds two addresses; the second's username (before the @) is b. set_content with a string makes a plain-text message.
Attachments
Reports and receipts often travel as attachments. add_attachment takes the file's bytes, its type and a file name. The message then becomes "multipart": the text, the HTML version if any, and each file, packed into one email:
import csv import io import smtplib from email.message import EmailMessage rows = [["sku", "units"], ["TE-101", 12], ["PA-108", 30]] buffer = io.StringIO() csv.writer(buffer).writerows(rows) msg = EmailMessage() msg["From"] = "reports@shop.example.com" msg["To"] = "manager@example.com" msg["Subject"] = "Weekly units sold" msg.set_content("This week's units are attached.") msg.add_attachment(buffer.getvalue().encode("utf-8"), maintype="text", subtype="csv", filename="units.csv") with smtplib.SMTP_SSL("smtp.example.com", 465) as server: server.login("reports@shop.example.com", "practice-app-password") server.send_message(msg)
The file is built in memory with io.StringIO, so nothing is written to disk. This time the code connects with SMTP_SSL on port 465, which is encrypted from the first byte, so there's no starttls() step. Keep attachments small: many mail servers refuse messages over about 20 MB, and a link to a shared file is often better.
Texts interrupt people and cost money per message, so save them for what's urgent and short. Everything else is an email, and some things shouldn't be sent at all.
Many people, one template
A mail merge sends the same message to many people, each with their own details. string.Template is made for it: $name marks a gap, and substitute fills every gap from a dictionary:
from string import Template OFFER = Template("Hi $first,\n\nIt's your birthday month! Here's 15% off with code $code.\nYou have $points points.") members = [ {"first": "Ada", "code": "BDAY-0417", "points": 340}, {"first": "Kwame", "code": "BDAY-0233", "points": 85}, ] for m in members: print(OFFER.substitute(m)) print("---") try: OFFER.substitute({"first": "Sam"}) except KeyError as missing: print("Missing value:", missing)
substitute raises KeyError if any gap has no value. That's what you want before sending a thousand emails: a crash beats a thousand messages that say "Hi $first". (safe_substitute leaves gaps unfilled instead, which is handy for drafts but dangerous for sending.) Unlike an f-string, a Template can live in a file that someone else edits, without letting them run code.
Before any bulk send, check three things. Send only to people who opted in. Do a test run that prints instead of sending. And open one connection for the whole batch, not one per message.
An email with an HTML version
Most mail apps show HTML email, but some people read plain text. Build msg with the plain text below, then add_alternative an HTML version with the same words, where the code TRAIL15 is in <strong>. Send it to ada@example.com through the practice outbox, as the lesson showed.
import smtplib from email.message import EmailMessage msg = EmailMessage() msg["From"] = "club@shop.example.com" msg["To"] = "ada@example.com" msg["Subject"] = "15% off this weekend" msg.set_content("Use code TRAIL15 for 15% off this weekend.")
Text messages through an API
Text messages (SMS) go through a provider's web API rather than SMTP: you POST the number and the message, with a key in the Authorization header. The practice API has one at /v1/sms, and what it "sends" lands in the same practice outbox:
import os import requests SMS_KEY = os.environ.get("SMS_KEY", "sk_demo_sms_42_not_a_real_secret") reply = requests.post( "https://api.gradient.test/v1/sms", headers={"Authorization": f"Bearer {SMS_KEY}"}, json={"to": "+15035550134", "body": "Your order is ready to collect at the Portland store."}, ) print(reply.status_code, reply.json()) bad = requests.post("https://api.gradient.test/v1/sms", headers={"Authorization": f"Bearer {SMS_KEY}"}, json={"to": "503-555-0134", "body": "Hi"}) print(bad.status_code, bad.json()["error"]["message"])
Phone numbers must be in E.164 form: a plus sign, the country code, then the number, with no spaces: +15035550134. Numbers from 555-0100 to 555-0199 are reserved for fiction, so they're safe in examples. A text is limited to 160 characters. Longer ones are split into parts, and each part costs money, so keep alerts short.
Worked example: a rain check before Saturday's hike
The Trail Club's Saturday hike starts at Fern Hollow at 8:00. If rain is likely during the hike, text the leader so they can move it.
import os import requests SMS_KEY = os.environ.get("SMS_KEY", "sk_demo_sms_42_not_a_real_secret") RAINY = {3, 4, 5} # drizzle, rain, thunderstorm in the practice API's codes data = requests.get("https://api.gradient.test/v1/forecast", params={"trailhead": "fern-hollow"}).json() hike = [h for h in data["hours"] if h["time"].startswith("2025-06-14T") and "08" <= h["time"][11:13] <= "11"] wet = [h for h in hike if h["code"] in RAINY or h["precip_chance"] >= 60] print(len(hike), "hike hours,", len(wet), "look wet") if wet: first = wet[0]["time"][11:16] text = f"Rain check: {data['name']} forecast shows {data['codes'][str(wet[0]['code'])]} from {first}. Move Saturday's hike?" reply = requests.post("https://api.gradient.test/v1/sms", headers={"Authorization": f"Bearer {SMS_KEY}"}, json={"to": "+15035550188", "body": text}) print(reply.status_code, len(text), "characters")
The forecast is hourly, with each time in ISO 8601 and the trailhead's offset. Slicing the time string picks the hike's hours, 08 to 11 on June 14; comparing two-digit hours as text works because they're zero-padded. "Wet" means a rainy code or a high chance of rain. The program texts only if it found something, and the message fits in one 160-character text. JSON keys are always strings, which is why the code reads data['codes'][str(code)].
Practice what you just read
- 6 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why call starttls() before login()?