Your Agent Has an Inbox Now

Ragwalla agents can now have an email channel. Mail to an address you own becomes a threaded conversation the agent answers, the agent can start and follow up conversations itself, and delivery runs through your own mail infrastructure. Here is how to wire it up, with working code for both directions.

Share:

Most of the people you want an agent to work with already live in email. Customers, vendors, the ops lead who will never install your Slack app. Until now, getting a Ragwalla agent into those conversations meant gluing a mail parser, a thread tracker, and a send queue onto the side of it yourself.

Ragwalla agents can now have an email channel. Mail sent to an address you own arrives as a conversation the agent answers, replies thread properly in the recipient's mail client, and the agent can start and follow up conversations on its own when you ask it to.

This post covers what you get, how to wire it up, and the handful of settings worth thinking about before you point a real address at it.

What you get

Each email conversation is its own thread. A message to your agent's address starts a Ragwalla thread. When the person replies, their reply lands in that same thread and the agent answers with the whole conversation in front of it. Replies go out with correct In-Reply-To and References headers, so Gmail, Outlook and Apple Mail stack them where they belong.

The agent can write first. Three tools, email_send, email_reply and email_list_threads, let the agent start a conversation, follow one up, and see what is open. When the recipient answers, that answer comes back to the agent as a normal inbound conversation.

You keep your mail infrastructure. Ragwalla does not need your MX records or your SMTP credentials. Inbound mail is POSTed to Ragwalla by whatever already receives it, and outbound mail is POSTed to a webhook you run, which sends it through whatever provider you already use. Your sending domain, your deliverability, your logs.

It does not reply to the wrong things. Auto-replies, bounces, mailing lists and anything marked Auto-Submitted get no response, so two autoresponders cannot get stuck talking to each other. Senders are rate-limited per hour. Mail without a passing DMARC result is dropped by default.

Retries are safe in both directions. Redeliver the same inbound email and the agent sees it once. Every outbound send carries a stable request id, so a retry after a timeout is recognisably the same email, not a second one.

How the pieces fit

someone@example.com
          │
          ▼
  your inbound mail receiver ──signed POST──▶ Ragwalla agent
                                                   │
  recipient's inbox ◀── your mail provider ◀──signed POST── (reply)

You run two small pieces: something that forwards received mail to Ragwalla, and a webhook that sends what the agent writes. Both are signed with HMAC-SHA256 so each side can verify the other. The rest of this post builds both.

1. Create the channel

bash
curl -X POST https://myendpoint.ai.ragwalla.com/v1/agents/$AGENT_ID/channels \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel_type": "email",
    "session_scope": "per_conversation",
    "config": {
      "addresses": ["support@yourcompany.com"],
      "inbound": { "mode": "raw" },
      "outbound": {
        "provider": "webhook",
        "url": "https://mail.yourcompany.com/ragwalla/send",
        "from": { "email": "support@yourcompany.com", "name": "Acme Support" }
      },
      "security": {
        "senderAllowlist": ["@yourcompany.com", "@bigcustomer.com"],
        "recipientPolicy": "participants_and_allowlist",
        "recipientAllowlist": ["@yourcompany.com", "@bigcustomer.com"]
      }
    }
  }'

The response includes everything the next two steps need:

json
{
  "id": "ch_...",
  "channel_type": "email",
  "inbound_url": "https://myendpoint.ai.ragwalla.com/channels/email/ch_.../inbound",
  "inbound_json_url": "https://myendpoint.ai.ragwalla.com/channels/email/ch_.../inbound/json",
  "inbound_hmac_secret": "9f2c...",
  "outbound_webhook_secret": "41ab...",
  "setup_instructions": "..."
}

Both secrets are shown once. Put them in your secret store now. If you would rather choose them yourself, pass config.inbound.verify.hmacSecret and config.outbound.credentials.webhookSecret (32 to 256 characters) when you create the channel.

A channel can hold up to 50 addresses, and from.email has to be one of them. If the request comes back with Channel type 'email' is not enabled, the channel is not switched on for your organization yet; get in touch and we will enable it.

2. Forward inbound mail

Ragwalla accepts inbound mail two ways:

  • Raw (inbound_url): the original RFC 822 bytes, exactly as received. Use this when you can. Ragwalla parses the MIME itself and can read the message's own Authentication-Results header.
  • JSON (inbound_json_url): a normalized object with from, to, subject, text, html, messageId, inReplyTo, references and so on. Use this if your provider only hands you parsed mail.

Every request carries two headers:

  • X-Ragwalla-Timestamp: Unix seconds. Requests more than five minutes off are rejected.
  • X-Ragwalla-Signature: hex HMAC-SHA256, using inbound_hmac_secret, of {timestamp}.{envelope}.{body bytes}.

On the raw route, {envelope} is the value of an X-Ragwalla-Envelope header: base64url-encoded JSON describing the SMTP envelope. On the JSON route the envelope fields travel in the body, and the middle segment is empty ({timestamp}..{body}).

Here is a complete forwarder as a Cloudflare Email Worker. Any program that receives mail can do the same thing.

```ts
interface Env {
  RAGWALLA_INBOUND_URL: string;
  RAGWALLA_INBOUND_SECRET: string;
}

const encoder = new TextEncoder();

function base64url(text: string): string {
  const bytes = encoder.encode(text);
  let binary = '';
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

async function sign(secret: string, prefix: string, body: Uint8Array) {
  const key = await crypto.subtle.importKey(
    'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
  );
  const head = encoder.encode(prefix);
  const material = new Uint8Array(head.length + body.length);
  material.set(head);
  material.set(body, head.length);
  const sig = await crypto.subtle.sign('HMAC', key, material);
  return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
}

export default {
  async email(message: ForwardableEmailMessage, env: Env) {
    const raw = new Uint8Array(await new Response(message.raw).arrayBuffer());
    const envelope = base64url(JSON.stringify({ to: [message.to], from: message.from }));
    const timestamp = String(Math.floor(Date.now() / 1000));
    const signature = await sign(env.RAGWALLA_INBOUND_SECRET, `${timestamp}.${envelope}.`, raw);

    const res = await fetch(env.RAGWALLA_INBOUND_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'message/rfc822',
        'X-Ragwalla-Timestamp': timestamp,
        'X-Ragwalla-Envelope': envelope,
        'X-Ragwalla-Signature': signature,
      },
      body: raw,
    });
    if (!res.ok) throw new Error(`Ragwalla rejected inbound email: ${res.status}`);
  },
};
```

Three things to know about the envelope:

Include the SMTP sender (from). It is the reverse path. Mail without one, which is how bounces arrive, never gets a reply.

Tell Ragwalla who authenticated the message. With requireDmarcPass on (the default), mail without a trusted DMARC pass is dropped. Either set config.inbound.trustedAuthservId to the authserv-id your receiving server stamps on its Authentication-Results header, and Ragwalla will read the verdict from the message, or put your own verdicts in the signed envelope:

json
{
  "to": ["support@yourcompany.com"],
  "from": "dana@bigcustomer.com",
  "verdicts": { "dmarc": "pass", "spf": "pass", "dkim": "pass", "headerFrom": "bigcustomer.com" }
}

A DMARC verdict needs headerFrom, the domain it was evaluated for, and it only counts when that matches the domain in the message's From.

Add a sourceEventId if your system has one. It is your id for this inbound event, used to recognise redeliveries. Keep it the same when you retry an event and never reuse it for a different email. Without it, Ragwalla falls back to the Message-ID and a hash of the content.

HTML-only messages are converted to Markdown before the agent sees them. The agent is told the names and types of attachments; attachment contents are not passed to it. The raw route accepts messages up to 25 MB.

3. Send what the agent writes

When the agent replies, Ragwalla POSTs JSON to your outbound.url:

json
{
  "from": { "email": "support@yourcompany.com", "name": "Acme Support" },
  "to": ["dana@bigcustomer.com"],
  "subject": "Re: Invoice 4471 looks wrong",
  "text": "Hi Dana, you're right, the second line item ...",
  "html": "(the same body rendered as HTML)",
  "inReplyTo": "<2f9c41d7.CAF3x@mail.gmail.com>",
  "references": ["<2f9c41d7.CAF3x@mail.gmail.com>"],
  "headers": { "Auto-Submitted": "auto-replied" }
}

cc, replyTo, inReplyTo, references and headers appear when they apply. inReplyTo and references are ready to drop into headers as-is. Automatic replies carry Auto-Submitted: auto-replied, and an email the agent sends from a scheduled job, with nobody watching, carries Auto-Submitted: auto-generated. Pass headers through so the recipient's autoresponder knows not to answer.

The request is signed like the inbound one, with the request id as the middle segment:

  • X-Ragwalla-Timestamp
  • X-Ragwalla-Request-Id
  • X-Ragwalla-Signature: hex HMAC-SHA256, using outbound_webhook_secret, of {timestamp}.{request id}.{body bytes}

Answer with the Message-ID the email actually went out with. That is how Ragwalla links the recipient's eventual reply back to this conversation.

json
{ "messageId": "<1d6e2b90-7c1a@mail.yourcompany.com>" }

The one rule: one request id, one email

If your webhook times out or the connection drops, Ragwalla retries with the same X-Ragwalla-Request-Id. From where Ragwalla sits, a timeout could mean the email never left or that it left and the answer got lost. The request id is how your webhook tells it which.

So your webhook should:

  1. Claim the request id atomically before sending.
  2. If the send fails, release the claim so the retry can send it.
  3. If the id was already sent, return the first acknowledgement again and send nothing.
  4. If the id is mid-send, return 409. Ragwalla treats that as "possibly sent" rather than retrying into a duplicate.

Here is a receiver in Node with Hono and Nodemailer. The sends table stands in for whatever database you already have; what matters is that claim is atomic, for example an INSERT that fails on a duplicate primary key.

```ts
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { Hono } from 'hono';
import nodemailer from 'nodemailer';
import { sends } from './db'; // claim / complete / release / get, keyed on request id

const secret = process.env.RAGWALLA_OUTBOUND_SECRET!;
const transport = nodemailer.createTransport(process.env.SMTP_URL);
const app = new Hono();

function verify(timestamp: string, requestId: string, body: Buffer, signature: string): boolean {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${requestId}.`)
    .update(body)
    .digest('hex');
  return expected.length === signature.length
    && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

app.post('/ragwalla/send', async (c) => {
  const body = Buffer.from(await c.req.arrayBuffer());
  const timestamp = c.req.header('X-Ragwalla-Timestamp') ?? '';
  const requestId = c.req.header('X-Ragwalla-Request-Id') ?? '';
  const signature = c.req.header('X-Ragwalla-Signature') ?? '';
  if (!verify(timestamp, requestId, body, signature)) return c.text('bad signature', 401);

  if (!(await sends.claim(requestId))) {
    const previous = await sends.get(requestId);
    return previous?.messageId
      ? c.json({ messageId: previous.messageId })
      : c.text('send in progress', 409);
  }

  const email = JSON.parse(body.toString('utf8'));
  const messageId = `<${randomUUID()}@mail.yourcompany.com>`;
  try {
    await transport.sendMail({
      messageId,
      from: email.from,
      to: email.to,
      cc: email.cc,
      replyTo: email.replyTo,
      subject: email.subject,
      text: email.text,
      html: email.html,
      inReplyTo: email.inReplyTo,
      references: email.references,
      headers: email.headers,
    });
  } catch (err) {
    await sends.release(requestId);
    throw err;
  }
  await sends.complete(requestId, messageId);
  return c.json({ messageId });
});

export default app;
```

Swap Nodemailer for your provider's SDK if you prefer. Just make sure the Message-ID you return is the one recipients actually see. Some providers set their own, in which case return theirs.

At this point the channel works. Send an email to support@yourcompany.com and the agent answers it.

4. Let the agent write first

Answering mail is half of it. The other half is asking the agent to go and email someone. Enable the email tools on the agent:

bash
curl -X POST https://myendpoint.ai.ragwalla.com/v1/agents/$AGENT_ID/tools/bulk-enable-system \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "toolIds": ["email_send", "email_reply", "email_list_threads"] }'

They also appear under Email in the agent's tool settings in the dashboard.

Tool Arguments What it does
email_send to, subject, text Starts a new conversation with one recipient. Returns the new threadId.
email_reply threadId, text Follows up in an existing conversation, threaded under its latest message.
email_list_threads limit (optional, up to 25) Lists recent conversations: correspondent, subject, last activity, message count.

text can be plain text or Markdown. Now you can tell the agent things like:

Email dana@bigcustomer.com and ask whether the corrected invoice for 4471 arrived. Keep it to two sentences.

Check our open email conversations and follow up with anyone who hasn't heard from us in three days.

The second one works especially well as a scheduled task. When Dana answers, her reply arrives as an inbound email in the conversation email_send started, and the agent picks it up with the history attached.

Emails the agent sends with these tools appear in the thread's message list with row_kind: "email_sent", so your UI can show them as sent mail rather than ordinary chat replies.

Who can use them

These tools act across every conversation the agent has and can email new people, so they are for you, not your correspondents. They are available when someone is using the agent through the dashboard or the API, or in a schedule set up there. Anyone who can chat with the agent that way can use them, so treat that access accordingly.

A turn started by an inbound email never gets them. Its output is the reply to that email and nothing else. Someone emailing your support address cannot talk the agent into emailing a third party or reading out another customer's thread. Likewise, from inside one channel conversation, email_reply can only answer that conversation, and email_list_threads refuses to run.

Tool sends are capped at maxAgentSendsPerDay (50 by default) in any 24 hours. Automatic replies to inbound mail do not count toward it.

Settings worth a minute

All of these live under config.security. The defaults are cautious; loosen them deliberately.

senderAllowlist: addresses or @domain entries allowed to reach the agent. Empty means anyone. For an internal tool or a single-customer deployment, list the domains and everything else is dropped without a reply.

requireDmarcPass (default true): drop mail without a trusted DMARC pass. Leave it on. Without it, the From address proves nothing and anyone can claim to be anyone.

recipientPolicy: any (default) or participants_and_allowlist. With the latter, the agent can only send to people already in the conversation or on recipientAllowlist. If the agent is meant to talk to one company, this guarantees it cannot email anyone else, whatever it is told.

maxAutoRepliesPerSenderPerHour (default 5): inbound messages per sender per hour before the rest are dropped. A cheap guard against loops and floods.

threadJoin: participant (default) or never. By default a reply joins its earlier conversation only if the sender has a DMARC pass for their domain and is already in that conversation. Set never to make every email start fresh, which is the right choice when you do not want one message to see another's history at all.

One caveat about participant: DMARC proves a domain, not a mailbox. If alice@bigcustomer.com is in a conversation, anyone who can send authenticated mail as bigcustomer.com can technically join it. For most deployments that is the right trust boundary. If it is not right for yours, use threadJoin: "never" or restrict senderAllowlist to exact addresses.

Bring your own delivery, for now

Today the email channel sends through your webhook. That is more setup than pasting an SMTP password, but it means Ragwalla never holds your mail credentials, your existing domain reputation and suppression lists keep working, and every email the agent writes passes through code you control before it leaves.

The receiver is about 50 lines. Once it is running, your agent has an inbox, and the people who were never going to open a chat widget can just email it.

Build your AI knowledge base today

Start creating intelligent AI assistants that understand your business, your documentation, and your customers.

Get started for free