thecodex.expert · The Codex Family of Knowledge
Tier 3 · Upper-Intermediate · Python Project

Bulk Email Sender

Send personalised emails to a list of people from a template. Learn email protocols and mail-merge templating — responsibly.

🧠 Teaches how to think spoonfed, every age Last verified:

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).

Where this shows up: newsletters, order confirmations, password resets, notifications, marketing (with consent). Sending email programmatically is a near-universal need — done responsibly.

2 How to Think About It

Think about merge-then-send, before any code:

The plan — in plain English
1. A template has placeholders like {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.

Template with placeholders

For each recipient

Fill template with their details

Build the email

Send via SMTP

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.

Pythonmailer.py
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]))
What each part does — in plain words
personalise(template, person) — the mail-merge: 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.
Common mistakes — and how to avoid them
✗ Sending real email in tests — slow, and you might spam people.
✓ Use a fake SMTP object that records messages instead.
✗ Building email strings by hand — you get the format subtly wrong.
✓ Use EmailMessage to handle headers and encoding correctly.
✗ Emailing people without consent — that is spam, and often illegal.
✓ Only send to people who opted in.

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.

The template fills with a person's details
Each recipient gets one message
A missing placeholder is caught
Pythontest_mailer.py
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

TEMPLATEwith {placeholders}personalised per recipient
Input
"Hi {name}, order {order} shipped!"
{"name": "Alice", "order": "A123"}
SENDvia SMTPone email each
Result
Hi Alice, order A123 shipped!

6 Run It & Automate It

Run it locally
python3 mailer.py
Prints 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.

What you should see when it works
Terminala real run
Hi Alice, your order A123 has shipped!
If it breaks — how to fix it
🚨 KeyError filling the template.
A placeholder has no matching field. Make sure every recipient has all the keys the template uses.
🚨 SMTPAuthenticationError.
Wrong credentials, or your provider needs an app-specific password. Check your email provider's SMTP settings.
GroovyJenkinsfile
// 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.' }
    }
}
🎯 Try this next — make it yours
  1. Load recipients from CSV. Combine with the CSV analyser project. (Teaches: connecting projects.)
  2. Rate limiting. Pause between sends to respect the server. (Teaches: being a good citizen.)
  3. HTML emails. Send formatted messages, not just plain text. (Teaches: richer email.)
What you learned
You learned mail-merge templating with 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.