Building a Transactional Email CLI with Mailgun and Python
What you need before starting
Before you start, gather the following:
- Basic Python knowledge.
- Python installed.
- A Mailgun account (the free tier works).
- The
requestslibrary (pip install requests).
What you'll build
The app is a small CRUD tool with four actions:
- Send an email.
- List what's been sent.
- Check delivery status.
- Delete a record.
Two files do the real work:
mailgun_client.py- the only file that talks to Mailgun; it sends emails and checks their status.storage.py- logs each sent email to a local JSON file, so there's something to check later.
main.py wires these two together into a menu, and config.py loads your Mailgun credentials. Neither file does anything Mailgun-specific. This tutorial moves through them quickly and spends most of its time on mailgun_client.py, where the real subject lives.
Set up your Mailgun account
Sign up for a free Mailgun account. Every account starts with a sandbox domain - a Mailgun-provided domain you can send test emails from immediately, without verifying your own domain's DNS records.
Grab two things from the dashboard, both under Sending โ Domain settings:
- Your domain (the sandbox domain, e.g.
sandboxXXXX.mailgun.org, or your own verified domain) - Your API key
One sandbox-specific rule catches people the first time: sandbox domains only deliver to authorized recipients. Before you can send yourself a test email, add your own address under Authorized Recipients in the dashboard. Skip this step, and Mailgun accepts the request but never delivers it - exactly the silent failure the status-check step later in this tutorial is built to catch.
Configure your credentials
Store your API key, domain, and sender address as environment variables - never in code. config.py loads them with a small dotenv reader (no extra dependencies), then fails fast if anything's missing:
MAILGUN_API_KEY = os.environ.get("MAILGUN_API_KEY")
MAILGUN_DOMAIN = os.environ.get("MAILGUN_DOMAIN")
MAILGUN_SENDER = os.environ.get("MAILGUN_SENDER")
MAILGUN_BASE_URL = os.environ.get("MAILGUN_BASE_URL", "https://api.mailgun.net/v3")
MAILGUN_BASE_URL defaults to Mailgun's US host. If your account is on Mailgun's EU region, set this to https://api.eu.mailgun.net/v3 instead - worth knowing before a mismatched region costs you a confusing 401.
With MAILGUN_API_KEY, MAILGUN_DOMAIN, and MAILGUN_SENDER set in a .env file, the app is ready to talk to Mailgun.
Send the email
This function delivers on the tutorial's promise. Everything before it was setup; everything after it is bookkeeping.
def send_email(to, subject, body, sender):
response = requests.post(
f"{MAILGUN_BASE_URL}/{MAILGUN_DOMAIN}/messages",
auth=("api", MAILGUN_API_KEY),
data={
"from": sender,
"to": to,
"subject": subject,
"text": body,
},
)
response.raise_for_status()
return response.json()
A transactional email, structurally, is four fields: from, to, subject, text. Mailgun's Messages API takes exactly those four as form fields on a POST to /{domain}/messages. There's no envelope object, no MIME construction, no SMTP handshake - you build a dictionary and make one request.
Two details here are easy to get wrong the first time:
- Auth is HTTP Basic Auth, not a bearer token. The username is the literal string
"api"; your API key is the password.requestshandles the encoding through theauth=tuple - but if you're used to APIs that expectAuthorization: Bearer <token>, this is a different shape. - The payload is form-encoded, not JSON.
data={...}sendsapplication/x-www-form-urlencoded, which is what Mailgun's Messages API expects - even though the response comes back as JSON. Passingjson=here instead ofdata=is a common first mistake, and the resulting error doesn't make the cause obvious.
response.raise_for_status() turns a 4xx or 5xx response into a Python exception immediately, instead of letting a failed send look successful. response.json() hands back Mailgun's confirmation, including a message ID - the thread that connects sending an email to checking on its delivery later.
Confirm the email actually delivered
Here's the detail that matters most: Mailgun accepting your request is not the same as Mailgun delivering your email. A 200 response means "I've queued this to send," not "your recipient has it."
Confirming delivery means asking Mailgun a second, separate question. Mailgun doesn't expose a "get status by message ID" endpoint. Instead, you query its Events API - a log of everything that's happened to your domain's messages - and filter by ID:
def get_delivery_status(message_id):
clean_id = message_id.strip("<>")
response = requests.get(
f"{MAILGUN_BASE_URL}/{MAILGUN_DOMAIN}/events",
auth=("api", MAILGUN_API_KEY),
params={"message-id": clean_id},
)
response.raise_for_status()
events = response.json().get("items", [])
if not events:
return "unknown"
return events[0].get("event", "unknown")
Two quirks are worth knowing before you hit them:
- The message ID
send_email()returns is wrapped in angle brackets (<abc123@sandbox...mailgun.org>), but the Events filter expects the bare ID.strip("<>")handles the mismatch. - The event log can lag a few seconds behind the send. Check status immediately after sending, and you may get
"unknown"back - not because anything failed, but because Mailgun hasn't logged the event yet. Waiting a moment and checking again usually resolves it.
events[0] is the most recent event for that message - typically delivered, accepted, or failed. That single string is confirmation that the email didn't just leave your app; it actually arrived.
Wire the send and status functions into the app
main.py calls these two functions and logs the result. The send step looks like this:
result = mailgun_client.send_email(
to=to,
subject=subject,
body=body,
sender=config.MAILGUN_SENDER,
)
record = storage.create_record({
"to": to,
"mailgun_message_id": result["id"],
"status": "queued",
# ...
})
storage.py writes that record to a local JSON file - not because Mailgun requires it, but because Mailgun doesn't keep "your" list of sent emails, only a raw event log. The local record is what lets you look up a message ID later and ask get_delivery_status() about it.
Run the app
$ python main.py
--- Mailgun Transactional Email Manager ---
1. Send a new email (Create)
2. List logged emails (Read)
3. Refresh delivery status (Update)
4. Delete a logged email (Delete)
5. Quit
Choose an option: 1
Recipient email: you@example.com
Subject: Test send
Body text: Hello from the Mailgun API.
Sent. Local record #1 created. Mailgun says: Queued.
Choose option 3 and enter 1, and the app queries the Events API and updates the record - queued becomes delivered (or accepted, depending on timing). If it still shows unknown, wait a few seconds and check again; that's the event-log lag, not a bug.
Treat sending email as an API call
Nowhere in this project is there SMTP configuration, a mail server, or a queuing system to install. There's one POST to send an email and one GET to confirm it arrived - both plain HTTP calls made with requests. That's the mental model worth keeping: sending email from an app isn't a mail-server problem. It's an API call.
The repository for the code: https://github.com/anthonyonyenaobijeffery-debug/Sending-Transactional-Emails-in-Python-with-Mailgun
Comments
No comments yet. Start the discussion.