Guide · Transactional Email

MCP Tools for Mailgun — API key auth, regional endpoints, domain verification, webhook HMAC, and bounce handling

Mailgun is a developer-focused transactional email API used in MCP tools for sending confirmation emails, alerts, and reports. Three constraints distinguish it from simpler email APIs: authentication uses HTTP Basic auth with a mandatory api: prefix — not Bearer, not a custom header; US and EU accounts use completely different base URLs (api.mailgun.net vs api.eu.mailgun.net) and sending from the wrong region returns 404, not an authentication error; and every sending domain must pass DNS verification before any message is accepted — a domain in unverified state silently queues or rejects all sends. A health probe that only checks API key validity misses all three of these runtime failure modes.

TL;DR

Auth: HTTP Basic with username api and password {YOUR_API_KEY} — in code: Authorization: Basic base64("api:{key}"). US base: https://api.mailgun.net/v3. EU base: https://api.eu.mailgun.net/v3. Send: POST /v3/{domain}/messages with from, to, subject, text (and/or html) as form-encoded body. Check domain: GET /v3/domains/{domain} — look for state: "active". Webhook HMAC: verify HMAC-SHA256(timestamp + token, signing_key) matches the signature field. Health probe: GET /v3/domains to verify API key; check state on each sending domain.

Authentication — HTTP Basic with the api: prefix

Mailgun uses HTTP Basic authentication where the username is always the literal string api and the password is your API key. This is not a typo — the username must be exactly api, not your account email, not your domain name, not blank. The resulting Authorization header is Basic base64("api:{your_api_key}").

Mailgun provides two types of API keys: private API keys (full access — starts with key- in older accounts) and sending API keys (domain-scoped, can only send via specific domains). Use sending API keys in production MCP tools to limit blast radius if a key is leaked.

// Mailgun client — handles regional endpoint and auth
class MailgunClient {
  constructor(apiKey, domain, region = 'us') {
    this.apiKey = apiKey;
    this.domain = domain;
    // Region selection is critical — EU keys fail on US endpoint with 404
    this.baseUrl = region === 'eu'
      ? 'https://api.eu.mailgun.net/v3'
      : 'https://api.mailgun.net/v3';
    // Basic auth: username is always the literal string "api"
    const credentials = Buffer.from(`api:${apiKey}`).toString('base64');
    this.authHeader = `Basic ${credentials}`;
  }

  async request(method, path, body = null, isFormData = false) {
    const url = `${this.baseUrl}${path}`;
    const headers = { 'Authorization': this.authHeader };

    let fetchBody = null;
    if (body) {
      if (isFormData) {
        // Mailgun's send endpoint uses application/x-www-form-urlencoded
        headers['Content-Type'] = 'application/x-www-form-urlencoded';
        fetchBody = new URLSearchParams(body).toString();
      } else {
        headers['Content-Type'] = 'application/json';
        fetchBody = JSON.stringify(body);
      }
    }

    const res = await fetch(url, {
      method,
      headers,
      body: fetchBody,
      signal: AbortSignal.timeout(30_000),
    });

    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new Error(`Mailgun ${method} ${path} → ${res.status}: ${text.slice(0, 300)}`);
    }
    return res.json();
  }
}

// Send a transactional email
async function sendEmail(client, { from, to, subject, text, html, tags = [] }) {
  const body = { from, to: Array.isArray(to) ? to.join(',') : to, subject };
  if (text) body.text = text;
  if (html) body.html = html;
  // Tags let you filter events in the Mailgun dashboard and webhooks
  if (tags.length) body['o:tag'] = tags;

  const result = await client.request('POST', `/${client.domain}/messages`, body, true);
  // result: { id: '<msgid@domain>', message: 'Queued. Thank you.' }
  return result;
}

Note: the response "Queued. Thank you." means Mailgun accepted the message — it does not mean delivery was attempted. Actual delivery status comes through webhook events or the GET /v3/{domain}/events endpoint.

Regional endpoints — US vs EU

Mailgun maintains separate infrastructure for US (api.mailgun.net) and EU (api.eu.mailgun.net) accounts. Domains created under an EU Mailgun account process email exclusively through EU infrastructure and must use the EU endpoint. Sending to the wrong endpoint returns 404 Domain not found — not a 401 authentication error — which causes confusing failures that look like domain configuration problems.

There is no automatic redirect or fallback between regions. Determine the correct region when the account is set up and encode it in configuration, not as a runtime detection:

// DO: configure region at startup
const mailgun = new MailgunClient(
  process.env.MAILGUN_API_KEY,
  process.env.MAILGUN_DOMAIN,
  process.env.MAILGUN_REGION || 'us'   // 'us' or 'eu'
);

// DON'T: try both regions as a fallback — masks misconfiguration
// and doubles the latency on the sad path

Domain verification — state must be active

Every Mailgun sending domain must pass DNS verification before email is accepted. During DNS propagation (which can take up to 48 hours after adding the required TXT, MX, and CNAME records), the domain is in unverified state. Messages sent from an unverified domain are queued but not delivered, or rejected with a non-obvious error. Always check domain state before attempting a send.

async function checkDomain(client, domain) {
  const result = await client.request('GET', `/domains/${domain}`);
  const d = result.domain;

  return {
    name: d.name,
    state: d.state,        // 'active' | 'unverified' | 'disabled'
    isActive: d.state === 'active',
    // DNS records Mailgun needs for verification:
    dnsRecords: {
      sending: d.sending_dns_records,    // TXT record for SPF
      receiving: d.receiving_dns_records // MX records
    },
    // Tracking subdomain for open/click events
    trackingDomain: d.tracking,
  };
}

// Full domain status check — surface which DNS records are missing
async function getDomainDnsStatus(client, domain) {
  const result = await client.request('GET', `/domains/${domain}`);
  const d = result.domain;

  const unverifiedRecords = [
    ...d.sending_dns_records,
    ...d.receiving_dns_records,
  ].filter(r => r.valid !== 'valid');

  return {
    active: d.state === 'active',
    state: d.state,
    unverifiedRecords: unverifiedRecords.map(r => ({
      type: r.record_type,
      name: r.name,
      value: r.value,
      valid: r.valid,
    })),
  };
}

Webhook HMAC signature validation

Mailgun sends webhook POSTs for delivery events (delivered, failed, bounced, complained, unsubscribed, opened, clicked). Each payload includes a signature object with timestamp, token, and signature fields. Validate all three before processing: the signature is HMAC-SHA256(timestamp + token, webhook_signing_key), and the signing key is distinct from your API key — find it in Mailgun's dashboard under Webhooks.

import { createHmac } from 'crypto';

function validateMailgunWebhook(signingKey, { timestamp, token, signature }) {
  // Guard: reject replays older than 5 minutes
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
  if (age > 300) return false;

  const computed = createHmac('sha256', signingKey)
    .update(timestamp + token)
    .digest('hex');

  // Constant-time comparison to prevent timing attacks
  if (computed.length !== signature.length) return false;
  let mismatch = 0;
  for (let i = 0; i < computed.length; i++) {
    mismatch |= computed.charCodeAt(i) ^ signature.charCodeAt(i);
  }
  return mismatch === 0;
}

// Express webhook handler example
app.post('/webhooks/mailgun', express.json(), (req, res) => {
  const { signature } = req.body;

  if (!validateMailgunWebhook(process.env.MAILGUN_WEBHOOK_SIGNING_KEY, signature)) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  const eventData = req.body['event-data'];
  const eventType = eventData.event; // 'delivered', 'failed', 'complained', etc.
  const recipient = eventData.recipient;
  const timestamp = eventData.timestamp;

  if (eventType === 'failed') {
    const severity = eventData.severity;   // 'temporary' | 'permanent'
    const reason = eventData.reason;       // e.g., 'bounce', 'suppress-bounce'
    // Permanent failures: remove from your active list immediately
    // Temporary failures: retry is automatic; log for monitoring
    console.log(`Delivery ${severity} failure to ${recipient}: ${reason}`);
  }

  if (eventType === 'complained') {
    // Spam complaint: MUST suppress immediately to protect sender reputation
    console.log(`Spam complaint from ${recipient} — suppress this address`);
  }

  res.status(200).json({ ok: true });
});

Bounce and suppression handling

Mailgun maintains automatic suppression lists per sending domain: bounces (hard bounce addresses), complaints (spam reporters), and unsubscribes. Addresses on any of these lists are automatically skipped on future sends, and Mailgun returns {"message": "Address is in the suppression list."} with HTTP 200 — not an error code. Monitor these lists proactively.

// Fetch suppression list — combine all three lists for a complete picture
async function getSuppressions(client, domain) {
  const [bounces, complaints, unsubscribes] = await Promise.all([
    client.request('GET', `/${domain}/bounces?limit=100`),
    client.request('GET', `/${domain}/complaints?limit=100`),
    client.request('GET', `/${domain}/unsubscribes?limit=100`),
  ]);

  return {
    bounces: {
      total: bounces.total_count,
      items: (bounces.items || []).map(b => ({
        address: b.address,
        code: b.code,      // e.g., 550, 421
        error: b.error,    // bounce diagnostic
        createdAt: b.created_at,
      })),
    },
    complaints: {
      total: complaints.total_count,
      items: (complaints.items || []).map(c => ({ address: c.address, createdAt: c.created_at })),
    },
    unsubscribes: {
      total: unsubscribes.total_count,
      items: (unsubscribes.items || []).map(u => ({
        address: u.address,
        tags: u.tags,   // which unsubscribe tags triggered
        createdAt: u.created_at,
      })),
    },
  };
}

// Remove an address from bounce list (e.g., after user updates their email)
async function removeBounce(client, domain, address) {
  await client.request('DELETE', `/${domain}/bounces/${encodeURIComponent(address)}`);
}

Health monitoring for Mailgun MCP tools

A minimal Mailgun health probe must verify: (1) API key validity, (2) domain state is active, and (3) the regional endpoint is reachable. A process that can authenticate but whose sending domain is unverified or disabled will silently drop all email.

async function probeMailgun(client) {
  const results = await Promise.allSettled([

    // 1. API key + regional endpoint reachability
    (async () => {
      const result = await client.request('GET', '/domains?limit=1');
      return {
        kind: 'auth',
        totalDomains: result.total_count,
        region: client.baseUrl.includes('eu') ? 'eu' : 'us',
      };
    })(),

    // 2. Sending domain state
    (async () => {
      const result = await client.request('GET', `/domains/${client.domain}`);
      const state = result.domain.state;
      if (state !== 'active') {
        throw new Error(`Domain ${client.domain} is ${state} — emails will not be delivered`);
      }
      return { kind: 'domain', state, name: client.domain };
    })(),

    // 3. Recent event log — confirms pipeline is processing
    (async () => {
      const result = await client.request(
        'GET',
        `/${client.domain}/events?event=delivered&limit=1`
      );
      const items = result.items || [];
      return {
        kind: 'events',
        recentDelivery: items[0]?.timestamp
          ? new Date(items[0].timestamp * 1000).toISOString()
          : 'none in log',
      };
    })(),
  ]);

  return {
    healthy: results.every(r => r.status === 'fulfilled'),
    components: results.map(r =>
      r.status === 'fulfilled'
        ? { ...r.value, ok: true }
        : { ok: false, error: r.reason?.message }
    ),
  };
}

Register the domain state check with AliveMCP as a separate monitor from general API health. A domain can flip from active to disabled if Mailgun detects excessive bounces or spam complaints — this is the most common runtime failure in mature integrations.

Common integration pitfalls

Wrong Basic auth format
The username for Basic auth is the literal string api, not your account email or domain. The common mistake is base64("{email}:{key}") instead of base64("api:{key}"). The resulting 401 looks identical to a wrong API key, making it hard to diagnose.
Sending from the wrong region
EU accounts must use api.eu.mailgun.net. Using the US endpoint returns 404 with "Domain not found" — which looks like a domain configuration problem, not an endpoint misconfiguration. Always make the region an explicit configuration value.
Not checking domain state before sending
Mailgun accepts API calls for unverified domains but silently queues or drops the messages. A successful POST to /{domain}/messages does not guarantee delivery — only an active domain state combined with a clean suppression list does.
Using the API key as the webhook signing key
Mailgun uses a separate webhook signing key for HMAC validation. It is not the same as your API key. Using the API key for webhook HMAC verification produces valid-looking code that always returns a signature mismatch in production.
Ignoring spam complaints
A spam complaint rate above 0.08% (Google Postmaster threshold) triggers inbox filtering at major providers. Mailgun adds complainers to the suppression list automatically, but your MCP tool must also remove them from any internal lists to prevent re-adding them via API sends.

Related guides