🪟 Windows TippsThe Gemini desktop app is now available for Windows(11.09.2026 um 17:06 Uhr)
🪟 Windows TippsHeader and Footer not showing in Excel(14.09.2026 um 22:43 Uhr)
🕵️ SicherheitslückenBurn Out, Or Fade Away(14.09.2026 um 14:25 Uhr)
🪟 Windows TippsKB5129194 Windows 11 26H1 Out of Band Update - Deskmodder.de(14.09.2026 um 19:25 Uhr)
🪟 Windows TippsThe Gemini desktop app is now available for Windows(11.09.2026 um 17:06 Uhr)
🪟 Windows TippsHeader and Footer not showing in Excel(14.09.2026 um 22:43 Uhr)
🕵️ SicherheitslückenBurn Out, Or Fade Away(14.09.2026 um 14:25 Uhr)
🪟 Windows TippsKB5129194 Windows 11 26H1 Out of Band Update - Deskmodder.de(14.09.2026 um 19:25 Uhr)

🔧 Programmierung 🕛 vor 2 Monaten 7 Min Lesezeit
0

Building a Transactional Email CLI with Mailgun and Python

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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:




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




CODE
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:




CODE
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 (<[email protected]>), 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:




CODE
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






CODE
$ 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: [email protected]
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

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
The Gemini desktop app is now available for Windows
1 Quelle
Header and Footer not showing in Excel
1 Quelle
Burn Out, Or Fade Away
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Building a Transactional Email CLI with Mailgun and Python

Thematisch verwandte Begriffe: Building, Transactional, Email, with · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...