Gareth Wilson Gareth Wilson

Building AI email triage with Mailgun and Claude

Published


A customer emails support asking whether their subscription includes the feature they need for a presentation that afternoon. A few seconds later, Claude has classified the email as a billing-adjacent feature inquiry (not a bug/outage), tagged it as "time-sensitive" because of the wording around the presentation, identified the customer as on the Pro plan from their email domain, drafted a one-paragraph reply confirming the feature is included, and routed the conversation to the right inbox with the draft attached for a human agent to review and send.

That's a great workflow to build out with an inbound email service, an LLM, and a small triage handler. Mailgun and Claude are both straightforward in isolation. The tricky part is the email side — inbound mail is high-volume, bursty (a marketing send goes out, half the recipients reply within an hour), arrives in inconsistent formats (some plain text, some HTML, some with PDFs attached), and most of what hits a public inbox isn't worth running a model on at all.

This guide walks through the glue layer end to end: the architecture, seven concrete steps to wire it up, and the production concerns to get ahead of.

The flow

flowchart TB
    A[Inbound email<br/>to support@acme.com] --> B[Mailgun<br/>inbound route]
    B -->|POST multipart| C[Hookdeck<br/>inbound source]
    C -->|filter + transform<br/>+ rate limit| D[Claude<br/>triage handler]
    D -.->|messages API| E[claude-sonnet-4]
    D -->|POST triage result| F[Hookdeck<br/>callback source]
    F -->|route by category| G1[Support<br/>helpdesk]
    F -->|route by category| G2[Billing<br/>inbox]
    F -->|route by category| G3[Spam<br/>archive]

There are two webhook flows that need to be reliable:

  1. Mailgun's inbound POST into the AI step — must not drop an email, must not waste tokens on spam, and must not blow up your Claude rate limits when a marketing send produces a reply spike.
  2. The triage result back into your helpdesk and other downstream systems — must reach those systems even when one of them is briefly down. If the helpdesk is mid-deploy when Claude finishes triaging, you don't want the triage result to disappear.

Most teams build this with a POST /webhooks/mailgun endpoint that calls Claude inline, then calls the helpdesk API to create a ticket. That's enough to demo. It's not enough to run in prod, which is where Hookdeck comes in.

What you'll need

  • A Mailgun account with a verified domain and at least one inbound route configured
  • An Anthropic API key with access to a Claude model
  • A Hookdeck Event Gateway account — the free tier covers this entire workflow at low volume
  • Hookdeck CLI installed: npm install hookdeck-cli -g or brew install hookdeck/hookdeck/hookdeck
  • A triage handler — a Cloudflare Worker, a Vercel function, or a small Node service that receives the email payload, calls Claude, and POSTs the result back
  • A helpdesk or inbox destination — Zendesk, Help Scout, Front, or even a Slack channel for prototyping

Step 1: Create the Hookdeck Event Gateway source for Mailgun

In the Hookdeck Event Gateway dashboard:

  • Create Connection → New Source
  • Type: Mailgun (Event Gateway has a pre-configured source for Mailgun — see the Mailgun webhooks guide)
  • Name: mailgun-inbound-support
  • Provide your Mailgun signing key so Event Gateway can verify webhook signatures

Copy the generated source URL.

Step 2: Configure the Mailgun inbound route

In Mailgun:

  • Open Receiving → Create Route
  • Expression type: Match Recipient
  • Recipient: support@acme.com (or a regex if you have multiple addresses)
  • Actions: forward("https://hkdk.events/YOUR_SOURCE_ID") — paste the Hookdeck source URL
  • Priority: 10
  • Description: "AI triage for support inbox"

Save. Send a test email to support@acme.com. The full email payload (including parsed body, headers, attachments, sender, and message ID) should appear in the Hookdeck Event Gateway dashboard within a few seconds.

Step 3: Add the destination — your Claude triage handler

The handler:

  1. Receives the parsed email payload
  2. Calls Claude with a triage prompt that returns structured JSON
  3. POSTs the triage result to a second Hookdeck source for fan-out

A minimal Cloudflare Worker:

export default {
  async fetch(request, env) {
    const email = await request.json();

    const response = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'x-api-key': env.ANTHROPIC_API_KEY,
        'anthropic-version': '2023-06-01',
        'content-type': 'application/json',
      },
      body: JSON.stringify({
        model: 'claude-sonnet-4-5',
        max_tokens: 1024,
        system: TRIAGE_PROMPT,
        messages: [{
          role: 'user',
          content: `From: ${email.from}\nSubject: ${email.subject}\n\n${email.body}`,
        }],
      }),
    });

    const result = await response.json();
    const triage = JSON.parse(result.content[0].text);

    await fetch(env.HOOKDECK_CALLBACK_URL, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        message_id: email.message_id,
        from: email.from,
        subject: email.subject,
        body: email.body,
        triage,
      }),
    });

    return new Response('ok', { status: 200 });
  },
};

Configure the Hookdeck Event Gateway destination:

  • Type: HTTP
  • URL: your handler URL
  • Authentication: an HTTP header carrying a shared secret

Step 4: Add filter, transformation, and rate-limit rules

This is where Hookdeck Event Gateway does most of the work for this use case. Mailgun's inbound POST is a multipart form with dozens of fields, and most inbound mail isn't worth a Claude call.

Transformation — flatten Mailgun's verbose payload into the canonical shape your handler expects:

addHandler('transform', (request, context) => {
  const body = request.body;

  request.body = {
    message_id: body['Message-Id'],
    from: body.from || body.sender,
    to: body.recipient,
    subject: body.subject || '(no subject)',
    body: body['stripped-text'] || body['body-plain'] || '',
    body_html: body['stripped-html'] || null,
    attachments_count: parseInt(body['attachment-count'] || '0', 10),
    received_at: new Date().toISOString(),
    spam_score: parseFloat(body['X-Mailgun-Sflag'] === 'Yes' ? '10' : '0'),
  };

  return request;
});

stripped-text is Mailgun's clean version of the email body with quoted threads and signatures removed — much cheaper to feed to Claude than the full raw text.

Filter — drop spam, auto-replies, and unactionable mail before it reaches the model:

{
  "body": {
    "spam_score": { "$lt": 5 },
    "from": {
      "$not": {
        "$regex": "(no-?reply|mailer-daemon|postmaster|bounces?)@"
      }
    },
    "subject": {
      "$not": {
        "$regex": "^(out of office|automatic reply|undelivered)"
      }
    }
  }
}

This single filter rule typically removes 30-60% of inbound volume before it hits Claude.

Rate limit — protect your Claude API quota from inbound spikes. A common pattern: a marketing newsletter goes out at 9am, a few hundred recipients reply within an hour, your handler suddenly tries to make a few hundred concurrent Claude calls:

  • Rate: 5 per second
  • Burst: 10

Hookdeck Event Gateway queues the rest and feeds Claude at a sustainable rate. The replies all get triaged; you just don't blow your concurrency budget.

Retry policy — handle Claude rate limits and your handler's cold starts:

  • Initial delay: 30 seconds
  • Max attempts: 12
  • Max age: 24 hours
  • Apply on status codes: 408, 429, 500, 502, 503, 504

Claude's 429 responses include a retry-after header; Hookdeck respects it.

Step 5: Test the inbound leg locally with the CLI

Route the inbound connection to the CLI so you can inspect the canonical payload before Claude sees it:

hookdeck login
hookdeck listen 3000 mailgun-inbound-support

Run a local server that prints the transformed body:

// inspect.js
const http = require('http');
http.createServer((req, res) => {
  let body = '';
  req.on('data', chunk => body += chunk);
  req.on('end', () => {
    console.log('Canonical email:', JSON.parse(body));
    res.writeHead(200);
    res.end('ok');
  });
}).listen(3000);

Send a real test email to support@acme.com. Verify the canonical shape looks right and the body field doesn't contain a 20-message quoted thread. Press r to replay events while iterating.

Once it looks correct, point the destination at your real handler.

Step 6: Wire the triage result back through Hookdeck

The triage result needs to fan out — to the helpdesk, possibly to Slack for high-priority items, possibly to a metrics database for reporting.

Create a second connection:

  • Source: an HTTP source named email-triage-results
  • Destinations: one per downstream, each with its own filter on the triage category or priority field

For the helpdesk route, a transformation reshapes the payload into a ticket-creation request:

addHandler('transform', (request, context) => {
  const { from, subject, body, triage } = request.body;

  request.url = 'https://acme.zendesk.com/api/v2/tickets.json';
  request.method = 'POST';
  request.body = {
    ticket: {
      subject,
      comment: { body, public: false },
      requester: { email: from },
      priority: triage.priority,
      tags: ['ai-triaged', triage.category, ...(triage.tags || [])],
      custom_fields: [
        { id: 360000000001, value: triage.summary },
        { id: 360000000002, value: triage.suggested_reply },
      ],
    },
  };
  request.headers = {
    ...request.headers,
    authorization: `Basic ${context.secrets.ZENDESK_AUTH}`,
    'content-type': 'application/json',
  };

  return request;
});

A second connection from the same source routes triage.priority == "urgent" events to a Slack incoming webhook:

{
  "body": {
    "triage": {
      "priority": "urgent"
    }
  }
}

Retry policy — aggressive on these callbacks. Losing a triage result means an unhandled email:

  • Initial delay: 15 seconds
  • Max attempts: 15
  • Max age: 72 hours

Step 7: Run the full chain end to end

Send a test email to support@acme.com that mimics a real customer query. You should see, in order:

  1. Mailgun POSTs to mailgun-inbound-support
  2. Hookdeck Event Gateway verifies the signature, runs the filter (assume it passes), transforms, and delivers to your handler
  3. Your handler calls Claude and POSTs the triage result to email-triage-results
  4. Hookdeck Event Gateway fans out: a ticket appears in Zendesk; a Slack message fires if priority is urgent; metrics land in your reporting database
  5. The original email's Message-Id is preserved end to end so you can trace the full path

If any step fails, the Hookdeck Event Gateway dashboard shows you exactly where, with the request and response payloads at every hop.

Why Hookdeck and not just a try/except in your app server?

Three properties of email triage workflows make a direct integration the wrong choice once you're past the demo:

Email is high-volume and bursty. Inbound mail clusters around business hours, marketing sends, incident announcements, and the moment your CEO posts on LinkedIn. A direct Mailgun-to-Claude pipeline that works at 10 emails per minute falls over at 500. Event Gateway's queueing and rate limiting absorb the burst and feed Claude at a sustainable rate, with no extra code.

Filtering keeps spam and noise out of the AI step. Around half of mail arriving at a public inbox is auto-replies, marketing solicitations, bounce notifications, and outright spam. Running Claude on all of it is expensive and produces garbage triage. Event Gateway filters cut the obvious noise out before it costs you tokens — and you can refine the filter as you discover new patterns without redeploying anything.

Mail arrives in inconsistent formats. Plain text, HTML, multipart, with and without attachments, with quoted threads, with signatures, in different languages. Your downstream AI step works better when it receives a normalized shape. Event Gateway's transformation layer makes that normalization a one-time configuration, not a function you maintain in three different places as your handler evolves.

You can build all of this on your own: a queue, a retry worker, a transformation step, a filter engine, an observability layer, a replay tool. That's the work Hookdeck Event Gateway collapses into a connection in a dashboard. The hours you don't spend building that infrastructure are hours you spend on the triage prompt and the customer experience.

Going to production

Observability for the support manager, not just engineering. Hookdeck Event Gateway's Issues feature surfaces failure patterns automatically. A spike in 429s from Claude is a leading indicator that your support team is about to feel a backlog. Surface it in Slack before the inbox does.

Replay when the triage prompt changes. Prompts evolve. When you add a new category or change priority rules, Hookdeck's replay lets you re-run the last 24 hours through the new prompt — useful for regression-testing before flipping over.

Handle PII responsibly. Email bodies contain account numbers, addresses, sometimes payment details. Configure Hookdeck's payload redaction on sensitive fields before going live. The dashboard should be safe to share with the support team.

Plan for the long-tail of attachments. A customer attaches a 12MB PDF. Hookdeck handles large bodies, but your Claude call shouldn't include the raw bytes — instead, in the transformation, stash attachments to object storage and pass only the URLs to the model.

What to build next

This pattern generalizes: Replace Mailgun with a Slack inbound webhook, an SMS shortcode via Twilio, a Discord bot, a contact form. Replace Claude with OpenAI or any other model. Replace "triage" with sentiment analysis, language detection, intent classification, or full draft replies.

If you're building any of this, the fastest way to get past the demo phase is to stop maintaining your own webhook infrastructure. Start with the Hookdeck free tier (you can run this entire workflow without paying anything until you hit real volume) and use the CLI to keep your development loop fast.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.