DEV Community

Jeffery Kachukwucide
Jeffery Kachukwucide

Posted on

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")
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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",
    # ...
})
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)