← 목록

Building a Transactional Email CLI with Mailgun and Python

devto 2026-07-06 원문 보기 ↗


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:

What you'll build

The app is a small CRUD tool with four actions:

Two files do the real work:

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:

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:

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:

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