Theo Docs
TheoKitTheoKit-SDKTheoUITheo PaaS
Cookbook

email-bot

Minimal example of an email-driven agent using `@theokit/gateway-email`.

email-bot

Minimal example of an email-driven agent using @theokit/gateway-email.

The agent listens on IMAP IDLE (or polls if the server doesn't advertise IDLE), forwards every inbound message to the SDK, and replies via SMTP with RFC 5322 threading headers preserved (so the conversation stays in a single thread in the user's inbox).

Quick start (Gmail)

Gmail dropped basic-auth IMAP/SMTP in 2022. You MUST use an App Password:

  1. Turn on 2FA: https://myaccount.google.com/security
  2. Generate an App Password: https://myaccount.google.com/apppasswords
    • Select "Mail" + "Other (Custom name)" → name it theo-email-bot.
  3. Copy the 16-character password.
  4. Copy .env.example to .env and paste:
  5. Add your provider key: OPENROUTER_API_KEY=sk-or-v1-...

Then:

pnpm install
pnpm smoke   # one-shot: connects, verifies IMAP+SMTP, disconnects
pnpm run     # starts the live bot (Ctrl-C to stop)

Send an email to your address — the bot replies within ~5 seconds.

Other providers

ProviderIMAPSMTP
Gmailimap.gmail.com:993smtp.gmail.com:587
Outlookoutlook.office365.com:993smtp.office365.com:587
Yahooimap.mail.yahoo.com:993smtp.mail.yahoo.com:587
Fastmailimap.fastmail.com:993smtp.fastmail.com:587

Most providers require an App Password (not your account password). Check the provider's docs.

Filtering

By default the adapter drops:

  • Own-address loopback (EC-1 CRITICAL): messages where From: == your bot address. Prevents infinite reply loops when the bot CCs itself.
  • Automated senders (D332): noreply@, postmaster@, mailer-daemon@, bounce@, notifications@, and any message with Auto-Submitted: auto-*, Precedence: bulk|list, or X-Auto-Response-Suppress: all.

To opt INTO automated senders (rarely a good idea): set allowAutomated: true in EmailAdapter options.

To restrict to specific senders, set EMAIL_ALLOWED_SENDERS to a comma-separated list. EC-3 lets you mix bracketed and plain forms:

Troubleshooting

"Authentication unsuccessful" / EAUTH

Gmail: confirm you're using an App Password, not your account password. Outlook: ensure SMTP AUTH is enabled on the tenant (Microsoft disabled it by default in 2023). For Microsoft 365 personal accounts, modern auth/OAuth2 is strongly recommended over basic auth.

Bot replies but the user's reply lands in a NEW thread

Some clients reject In-Reply-To headers that point to a non-existent Message-ID in their archive. The bot keeps an in-memory ThreadStore capped at 1000 entries (FIFO eviction). For threads older than that, the bot will start a new thread on reply. This is documented behavior; persistence across restarts is deferred.

[email-bot] dropped own-address loopback (uid=N)

EC-1 working as intended — the bot rejected a message from its own address. Usually triggered by:

  • The bot being CC'd on its own reply (don't CC your own address).
  • Mail rules / automation that forward to the bot from itself.

Body looks truncated with [truncated — full body in event.email.raw]

EC-2 working as intended — bodies over 50000 chars get capped to keep token costs predictable. To raise the cap, pass maxBodyChars: 200_000 to the EmailAdapter constructor. The full body is always available via event.email.raw for advanced consumers.

IMAP IDLE keeps disconnecting

Some servers drop IDLE after ~29 minutes (per RFC 2177). imapflow refreshes automatically. If your server doesn't advertise IDLE at all, the adapter falls back to polling — set EMAIL_POLL_INTERVAL_MS=30000 to slow it down.

See also

  • packages/gateway-email/ — adapter source
  • ADRs D327-D339 — design rationale
  • .claude/knowledge-base/plans/gateway-email-plan.md — full implementation plan

Code

run.ts
/**
 * Email bot example — built on `@theokit/gateway-email`.
 *
 * Listens on IMAP IDLE (or polls when IDLE is unavailable), forwards inbound
 * messages to an SDK agent, and replies via SMTP with RFC 5322 threading
 * headers preserved (D337).
 *
 * Real-LLM validation: requires a provider key (default: OpenRouter).
 * Per `.claude/rules/real-llm-validation.md`, this example MUST be exercised
 * against a real LLM before declaring "validated".
 */

import {
  EmailAdapter,
  type EmailMessageEvent,
} from "@theokit/gateway-email";
import { Agent } from "@theokit/sdk";

function required(name: string): string {
  const v = process.env[name];
  if (v === undefined || v.length === 0) {
    console.error(`[email-bot] missing env var: ${name}`);
    process.exit(1);
  }
  return v;
}

const ADDRESS = required("EMAIL_ADDRESS");
const PASSWORD = required("EMAIL_PASSWORD");
const IMAP_HOST = required("EMAIL_IMAP_HOST");
const SMTP_HOST = required("EMAIL_SMTP_HOST");
const IMAP_PORT = Number(process.env.EMAIL_IMAP_PORT ?? 993);
const SMTP_PORT = Number(process.env.EMAIL_SMTP_PORT ?? 587);
const FROM_NAME = process.env.EMAIL_FROM_NAME;
const POLL_INTERVAL_MS = process.env.EMAIL_POLL_INTERVAL_MS !== undefined
  ? Number(process.env.EMAIL_POLL_INTERVAL_MS)
  : undefined;
const ALLOWED_SENDERS =
  process.env.EMAIL_ALLOWED_SENDERS !== undefined
    && process.env.EMAIL_ALLOWED_SENDERS.length > 0
    ? process.env.EMAIL_ALLOWED_SENDERS.split(",").map((s) => s.trim())
    : undefined;
const PROVIDER_KEY = required("OPENROUTER_API_KEY");

const adapter = new EmailAdapter({
  address: ADDRESS,
  password: PASSWORD,
  imapHost: IMAP_HOST,
  imapPort: IMAP_PORT,
  smtpHost: SMTP_HOST,
  smtpPort: SMTP_PORT,
  ...(FROM_NAME !== undefined ? { fromName: FROM_NAME } : {}),
  ...(ALLOWED_SENDERS !== undefined ? { allowedSenders: ALLOWED_SENDERS } : {}),
  ...(POLL_INTERVAL_MS !== undefined ? { pollIntervalMs: POLL_INTERVAL_MS } : {}),
});

adapter.onInbound(async (event) => {
  if (event.platform !== "email") return;
  const em = event as EmailMessageEvent;
  console.log(
    `[email-bot] inbound from=${em.email.fromAddress} subject="${em.email.subject}" attachments=${em.email.attachmentCount}`,
  );
  const agent = await Agent.create({
    apiKey: PROVIDER_KEY,
    model: { id: "openai/gpt-4o-mini" },
    name: `email-${em.email.fromAddress}`,
    systemPrompt:
      "You are a concise email assistant. Reply in one short paragraph. Plain text only — no markdown headers, no bullet lists.",
  });
  try {
    const run = await agent.send(em.text);
    const result = await run.wait();
    const reply = result.result ?? "(no reply)";
    const send = await adapter.sendMessage({
      channel: em.channel,
      text: reply,
    });
    if (send.ok) {
      console.log(`[email-bot] replied messageId=${send.messageId ?? "(unknown)"}`);
    } else if (send.error !== undefined) {
      console.error(`[email-bot] send failed: ${send.error.code} — ${send.error.message}`);
    } else {
      console.error("[email-bot] send failed: (no error payload)");
    }
  } finally {
    await agent.dispose();
  }
});

const ok = await adapter.connect();
if (!ok) {
  console.error("[email-bot] FAILED to connect — check IMAP/SMTP credentials.");
  process.exit(1);
}
console.log(`[email-bot] connected as ${ADDRESS} (IMAP ${IMAP_HOST}:${IMAP_PORT}, SMTP ${SMTP_HOST}:${SMTP_PORT})`);
console.log("[email-bot] listening for inbound mail. Send an email to test.");

process.on("SIGINT", async () => {
  console.log("\n[email-bot] shutting down");
  await adapter.disconnect();
  process.exit(0);
});

Run

cd examples/email-bot
cp .env.example .env  # fill in keys
pnpm install
pnpm run run

Repository

examples/email-bot

On this page