Building a Transactional Email CLI with Mailgun and Python
If your app needs to send a welcome email, a password reset link, or an order confirmation, you don't need to stand up a mail server. You need one HTTP call.
This tutorial builds a small Python CLI that sends transactional email through Mailgun's API, then checks whether Mailgun actually delivered it. By the end, you'll have replaced "sending email" with a simpler model: call an API, read the response, done.
## What you need before starting
Before you start, gather the following:
* Basic Python knowledge.
* Python installed.
* A Mailgun account (the free tier works).
* The `requests` library (`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. `requests` handles the encoding through the `auth=` tuple — but if you're used to APIs that expect `Authorization: Bearer <token>`, this is a different shape.
* **The payload is form-encoded, not JSON.** `data={...}` sends `application/x-www-form-urlencoded`, which is what Mailgun's Messages API expects — even though the _response_ comes back as JSON. Passing `json=` here instead of `data=` 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. Thank you.
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 link : https://github.com/anthonyonyenaobijeffery-debug/Sending-Transactional-Emails-in-Python-with-Mailgun**