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:
- Turn on 2FA: https://myaccount.google.com/security
- Generate an App Password: https://myaccount.google.com/apppasswords
- Select "Mail" + "Other (Custom name)" → name it
theo-email-bot.
- Select "Mail" + "Other (Custom name)" → name it
- Copy the 16-character password.
- Copy
.env.exampleto.envand paste:[email protected]EMAIL_PASSWORD=<16-char app password>(no spaces)
- 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
| Provider | IMAP | SMTP |
|---|---|---|
| Gmail | imap.gmail.com:993 | smtp.gmail.com:587 |
| Outlook | outlook.office365.com:993 | smtp.office365.com:587 |
| Yahoo | imap.mail.yahoo.com:993 | smtp.mail.yahoo.com:587 |
| Fastmail | imap.fastmail.com:993 | smtp.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 withAuto-Submitted: auto-*,Precedence: bulk|list, orX-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:
[email protected],"Bob" <[email protected]>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
/**
* 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