1 The Problem
We want a tool that sends a personalised email to each person in a list, filling a template with their name and details — a “mail merge”. It teaches the email protocol (SMTP) and templating, plus the responsibility that comes with sending email (consent, not spamming).
2 How to Think About It
Think about merge-then-send, before any code:
{name}. → 2. A recipient list gives each person’s details. → 3. For each person, fill the template with their details to make their personal message. → 4. Send via an SMTP server. → Always: only email people who consented, and do not flood the server.
3 The Build — explained part by part
Here is the complete sender. The templating is separated so we can test it without sending real email. Each part is explained below.
import smtplib
from email.message import EmailMessage
def personalise(template, person):
# Fill {placeholders} in the template with this person's details.
return template.format(**person)
def build_email(subject, body, to_address, from_address):
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = from_address
msg["To"] = to_address
msg.set_content(body)
return msg
def send_all(template, subject, recipients, from_address, smtp):
for person in recipients:
body = personalise(template, person)
msg = build_email(subject, body, person["email"], from_address)
smtp.send_message(msg)
if __name__ == "__main__":
template = "Hi {name}, your order {order} has shipped!"
recipients = [{"name": "Alice", "email": "alice@example.com", "order": "A123"}]
# with smtplib.SMTP("smtp.example.com", 587) as smtp:
# smtp.login("user", "password")
# send_all(template, "Shipped", recipients, "shop@example.com", smtp)
print(personalise(template, recipients[0]))
template.format(**person) fills each {placeholder} with the matching value from the person’s dictionary. {name} becomes their name. Isolated so it is testable.EmailMessage — Python’s proper way to build an email, with subject, from, to, and body set as fields. This handles the fiddly email format correctly.
build_email — assemble one message from the parts. Kept separate so we can build and inspect an email without sending it.
send_all(..., smtp) — loop over recipients, personalise, build, and send each. Note
smtp is passed in — this lets tests pass a fake one instead of a real server.smtplib.SMTP(...) (commented) — the real connection: connect, log in, and send. Commented out so the example runs safely without credentials.
EmailMessage to handle headers and encoding correctly.4 Test & Prove Each Part
We never send real email in tests. We test the templating, and use a fake SMTP object to confirm the right number of messages would be sent.
import pytest
def personalise(template, person):
return template.format(**person)
class FakeSMTP:
"""Records messages instead of sending them."""
def __init__(self):
self.sent = []
def send_message(self, msg):
self.sent.append(msg)
def send_all(template, recipients, smtp):
for person in recipients:
body = personalise(template, person)
smtp.send_message(body)
def test_personalise():
"""The template fills with details."""
result = personalise("Hi {name}", {"name": "Alice"})
assert result == "Hi Alice"
def test_one_per_recipient():
"""Each recipient gets exactly one message."""
smtp = FakeSMTP()
recipients = [{"name": "A"}, {"name": "B"}]
send_all("Hi {name}", recipients, smtp)
assert len(smtp.sent) == 2
def test_missing_placeholder():
"""A missing field raises a clear error."""
with pytest.raises(KeyError):
personalise("Hi {name}", {"email": "x@y.com"})
Run with pytest -v. The FakeSMTP records messages instead of sending them, so we verify the logic without emailing anyone. This is the safe, standard way to test code that talks to external services.
5 The Interface
Input
"Hi {name}, order {order} shipped!"
{"name": "Alice", "order": "A123"}Result
Hi Alice, order A123 shipped!6 Run It & Automate It
python3 mailer.pyPrints a personalised message. To really send, uncomment the SMTP block and use your provider's settings. Only email people who agreed to receive it.
Jenkins runs the tests automatically — each line explained below.
Hi Alice, your order A123 has shipped!KeyError filling the template.SMTPAuthenticationError.// Jenkinsfile — automated tests on every change.
pipeline {
agent any
stages {
stage('Get the code') { steps { checkout scm } } // download the code
stage('Install') { steps { sh 'python3 -m venv venv && . venv/bin/activate && pip install pytest' } } // test tool
stage('Test') { steps { sh '. venv/bin/activate && pytest -v' } } // run every test
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Load recipients from CSV. Combine with the CSV analyser project. (Teaches: connecting projects.)
- Rate limiting. Pause between sends to respect the server. (Teaches: being a good citizen.)
- HTML emails. Send formatted messages, not just plain text. (Teaches: richer email.)
str.format, building proper emails with EmailMessage, and the FakeSMTP pattern to test external-service code without using it. And the responsibility: only email with consent. Related: String Methods, Error Handling.