Answer email with your agent, in the thread of each mail, from a mailbox over IMAP or a Mailgun, Postmark or SendGrid inbound webhook.
Setup
The channel sends with nodemailer and reads mail one of two ways:
- IMAP. It reads unread mail of a mailbox with
imapflowand parses it withpostal-mime. Nothing needs a public URL, so this is the easy way to start. Any mailbox with IMAP works: Gmail and Outlook take an app password once 2-step verification is on. - A provider's inbound webhook. Mailgun, Postmark or SendGrid receive mail for a domain and post it to the channel, which needs a URL they can reach. Their webhooks report the sender's DKIM and SPF results, see Security.
pnpm add @cogitator-ai/channels nodemailer imapflow postal-mimeimapflow and postal-mime are only needed for IMAP (and for a webhook that posts raw MIME). All three are optional peer dependencies, loaded when they are first used.
Give the bot a mailbox of its own, such as assistant@example.com: it reads every unread mail there and marks it read.
Usage
import { Agent, Cogitator } from '@cogitator-ai/core';
import { Gateway, emailChannel } from '@cogitator-ai/channels';
const email = emailChannel({
address: 'assistant@example.com',
name: 'Acme Assistant',
allowFrom: ['example.com', 'ann@partner.org'],
imap: {
host: 'imap.gmail.com',
auth: { user: 'assistant@example.com', pass: process.env.EMAIL_IMAP_PASSWORD! },
},
smtp: {
host: 'smtp.gmail.com',
port: 587,
auth: { user: 'assistant@example.com', pass: process.env.EMAIL_SMTP_PASSWORD! },
},
});
const cogitator = new Cogitator({
llm: { providers: { google: { apiKey: process.env.GOOGLE_API_KEY! } } },
memory: { adapter: 'memory' },
});
const memory = await cogitator.getMemory();
const gateway = new Gateway({
agent: new Agent({
name: 'email-assistant',
model: 'google/gemini-3.5-flash-lite',
instructions: 'You answer email for a small team. Be friendly and brief.',
}),
cogitator,
channels: [email],
...(memory && { memory }),
});
await gateway.start();start() connects to the IMAP server (and rejects when the first connection fails), opens the webhook server when webhook.port is set, and warns when allowFrom is empty, since the bot then answers nobody.
Messages and Threads
ChannelMessage | From the mail |
|---|---|
id | Message-ID, with its angle brackets |
channelId | the sender's address, in lower case |
userId | the sender's address, in lower case |
userName | the sender's display name |
replyTo | In-Reply-To |
topicId | the thread's root: the first id of References, else In-Reply-To, else id |
text | the plain text part, or the HTML as text, without quoted history and signature |
attachments | the attachments, up to maxAttachmentBytes (10 MB) each, inline images left out |
The Gateway keeps one conversation per sender and thread, email:<address>:<root>:<address>, so each email thread is a conversation of its own, whatever its subject turns into. Only the first mail of a thread (one without In-Reply-To and References) starts with its subject (Subject: ...), which often holds the question, and includeSubject: false leaves it out. A mail whose text is a command such as /status goes without the subject, so owner commands and /pair still see it, and a mail with an empty text and a command as its subject is that command.
The text is cut where the quoted history starts: an On ... wrote: line and its translations (German, French, Spanish, Portuguese, Dutch, Italian, and the <address>: lines Gmail and Yandex write in Russian), an Outlook From: and Sent: block or -----Original Message-----, and a trailing block of > quotes. A signature after a -- line and a Sent from my ... line go too. Quotes the writer answers between are kept, since they say what each answer is about. HTML mail becomes text with its paragraphs, lists, links as text (url) and quotes as > lines.
Answers
sendText(address, text, options) sends a mail to the address. With the replyTo of a mail the channel saw it answers that mail: the subject gets Re: , In-Reply-To names the mail and References the thread (the first id and the latest 19), so mail clients keep it in the conversation. With only a topicId, as a scheduled reminder has, it answers the latest mail of that thread. A reply that names only In-Reply-To belongs to the thread of the mail it answers. Otherwise it starts a new thread, with the subject subject (default Message from <name>). It resolves with the new mail's Message-ID.
The channel remembers the subject and references of the latest 1000 mails in memory. After a restart an answer to an older mail still sets In-Reply-To and References from the ids it has, with the subject of the channel and no Re:, since the thread's own subject is not known any more.
- Format. The agent writes Markdown, sent as HTML (headings, lists, tables, code, links to
http,httpsandmailtoonly, inline styles, raw HTML escaped) with a plain text alternative.format: 'plain'sends only text,format: 'html'sends HTML as given with its text. - Editing. Sent mail cannot be changed, so the channel says
editable: falseand the Gateway sends each answer once it is finished.editTextrejects. - Length. No limit (
maxMessageCharsisInfinity): an answer is one mail. - Files.
sendFilesends a mail with the file attached and its caption as the text, from abufferor anhttp(s)urlthat nodemailer downloads. - Typing and reactions. Email has neither.
sendTypingdoes nothing. - Headers. Every mail carries
X-Cogitator: emailandAuto-Submitted: auto-replied(auto-generatedfor a new thread), so other bots and auto-responders can tell it is automatic.
Transports
smtp takes a server, sent through with nodemailer: host, port (default 587, or 465 when secure), secure (TLS from the start, default on port 465, STARTTLS otherwise), auth and further nodemailer options. Or it takes a transport, anything with sendMail(mail) such as a nodemailer transporter for Amazon SES or a test transport. Mailgun, Postmark and SendGrid all take SMTP, so their SMTP relays work as the server.
import nodemailer from 'nodemailer';
const email = emailChannel({
address: 'assistant@example.com',
smtp: { transport: nodemailer.createTransport({ host: 'smtp.mailgun.org', port: 587, auth }) },
webhook: { provider: 'mailgun', signingKey: process.env.EMAIL_MAILGUN_SIGNING_KEY!, port: 8788 },
allowFrom: ['example.com'],
});IMAP
The channel opens the mailbox (mailbox, default INBOX) and reads its unread mail of the last lookbackDays (default 7), so a first start does not answer a whole inbox. New mail is reported at once through IMAP IDLE, and a poll every pollInterval (default 60 s) catches what IDLE missed. Mail is fetched batchSize (20) at a time and parsed concurrency (4) at once, and reaches the agent in order. Its size is asked first: mail over maxBytes (25 MB) is not fetched, but logged and marked read.
A mail is flagged \Seen only once the Gateway took it, which queues it, so a restart reads only what was not taken yet, and the UIDs taken are remembered in case flagging failed. A stopping Gateway refuses new mail, which stays unread, and mail it took but drops unanswered at stop() (still queued after drainTimeout) is marked unread again through redeliver, so the next start reads it (once the connection is open again, when it was closed). Mail whose answer started going out is never read again, so an aborted run's tools may run twice but the sender gets one answer. A mail with more than one From header has no sender and is skipped. A mail that cannot be parsed is logged and marked read, but a missing postal-mime leaves mail unread, and start() fails without it, as it does without nodemailer for an SMTP server. stop() waits up to 5 seconds for a read in progress, then closes the connection. When the connection drops, the channel connects again after 1 s, doubling the wait up to 60 s while it keeps failing. poll() looks for new mail at once.
emailChannel({
address: 'assistant@example.com',
imap: {
host: 'imap.example.com',
port: 993, // default
secure: true, // default, false for STARTTLS
auth: { user: 'assistant@example.com', pass: process.env.EMAIL_IMAP_PASSWORD! }, // or accessToken for OAuth2
mailbox: 'Assistant',
pollInterval: 60_000,
lookbackDays: 7,
clientOptions: { tls: { rejectUnauthorized: true } }, // passed to ImapFlow
},
smtp: { host: 'smtp.example.com', auth: { user: 'assistant@example.com', pass: '...' } },
allowFrom: ['example.com'],
});Webhooks
With webhook, the channel takes mail a provider posts. It runs a server of its own on port (at /email/webhook unless path says otherwise, bodies up to maxBody, 10 MB, 413 beyond), or you mount handleWebhook or webhookListener() in your server with the raw body. A request is checked, its mail handed to the Gateway, which queues it, and answered 200, while the agent answers after. When the Gateway does not take the mail (it is stopping), or once stop() was called, the request is answered 503, so the provider sends it again later. A Gateway that answers before it takes the next message (queueMode: 'parallel') gets 5 seconds, then the request is answered 200.
import express from 'express';
const app = express();
app.post('/email/webhook', express.raw({ type: '*/*', limit: '10mb' }), async (req, res) => {
const response = await email.handleWebhook({
method: req.method,
query: req.query as Record<string, string>,
headers: req.headers,
rawBody: req.body,
});
res.status(response.status).send(response.body);
});| Provider | How it is checked | What it posts | Set it up |
|---|---|---|---|
| Mailgun | HMAC SHA-256 of timestamp and token with the webhook signing key, at most maxAge (300 s) old | a form (parsed, or raw in body-mime) or JSON signed over the whole body | a route with forward("https://<host>/email/webhook") |
| Postmark | basic auth in the URL, or ?token= | the inbound JSON (FromFull, TextBody, HtmlBody, Headers, Attachments) | the inbound webhook URL https://user:password@<host>/email/webhook |
| SendGrid | ?token= in the URL, or basic auth | Inbound Parse multipart (headers, text, html, attachment1...), or raw | Inbound Parse to https://<host>/email/webhook?token=..., raw MIME preferred |
- Mailgun. The signing key is the HTTP webhook signing key of the account. A request without a valid signature, or older than
maxAge, is refused with401, and a signature seen before is answered200and not handled again, so a replayed request does nothing. Signatures are remembered as long as their timestamp is withinmaxAge, up to 100 000. The signature is checked before the mail is read. - Postmark and SendGrid do not sign inbound mail, so the URL carries a secret:
basicAuth({ username, password }, which Postmark sends from theuser:password@of the URL) or atokenin the query (?token=, or anX-Webhook-Tokenheader). At least one is required, both are compared in constant time, and a request without them gets401. - SendGrid sends the parsed fields in their own character sets and names them in
charsets, and the channel decodes each field by it. With "POST the raw, full MIME message" it parses the raw message withpostal-mime. - Postmark gives each mail an id of its own. The channel uses the
Message-IDheader when it is among the headers, and<id@postmark.invalid>otherwise. The sender is the address Postmark parsed (FromFull.Email), never one from the display name.
What Reaches the Agent
- Its own mail. Mail from
addressor one ofaliases, and mail with theX-Cogitatorheader that every Cogitator email channel puts on what it sends, so two bots cannot answer each other forever. - Automatic mail is skipped:
Auto-Submittedother thanno,Precedence: bulk,junk,listorauto_reply,X-Autoreplyand similar, delivery reports (multipart/report,X-Failed-Recipients, an emptyReturn-Path), and senders such asmailer-daemon@,postmaster@andno-reply@. - Each mail once, by its
Message-ID(the latest 1000, in memory), whichever request or mailbox brings it. - Mail with nothing in it (no text, no subject, no attachment) is skipped.
Security
Anyone can send mail, and the From address is easy to forge. Treat what arrives as untrusted input:
- Who may write.
allowFromlists addresses (ann@example.com), domains (example.com, without subdomains), subdomain wildcards (*.example.com) or*for everyone. Without it the bot answers nobody. The address is the one outside the quoted strings and comments ofFrom, so"ceo@example.com" <attacker@evil.example>isattacker@evil.example. - Owners.
owners(theownerIdsofcogitator.yml) are allowed too, and the Gateway gives them owner rights, so their mail reaches the agent only when the sender authentication passes for their domain. Mail claiming an owner address without a pass is dropped.senderAuth: 'off'turns this off too, for a setup that checks senders some other way. - Sender authentication. The receiving side checks DKIM, SPF and DMARC, and the channel uses what it reports for the
Fromdomain. A DKIM signature, an SPF pass for the envelope sender or a DMARC pass passes only when its domain is theFromdomain or a subdomain of it, so a spoofer who signs with a domain of its own fails. Within one report any pass passes (forwarded mail breaks SPF but keeps DKIM), and across reports a fail wins.senderAuth: 'when-reported'(default) drops mail whose results fail,'required'also drops mail without results,'off'does not look. A sender can add any header, so only results the receiving side wrote count:- Mailgun adds
X-Mailgun-Spf(for the envelope sender, compared with theFromdomain) andX-Mailgun-Dkim-Check-Result, which does not say which signature it checked: a DKIM pass counts when the mail carries exactly one signature, for theFromdomain. A mail with two copies of either fails, since one came from the sender. - SendGrid posts
dkim({@example.com : pass}, one entry per signature) andSPFfor the envelope sender as fields of the request, which a sender cannot write. - Postmark adds SpamAssassin's
X-Spam-Tests, whereDKIM_VALID_AUis a valid signature of the author's domain. SpamAssassin removes the copies a sender brings, and a mail with more than one fails. It counts only on the Postmark webhook. - IMAP has only the headers of the mail, so it uses the
Authentication-ResultsoftrustedAuthservIds: the authserv-id, the first word of the header, of the server that received the mail (mx.google.comfor Gmail). The topmost of those counts, since the receiving server puts its own on top. WithouttrustedAuthservIdsno result over IMAP is trusted: mail counts as not reported,start()warns, andsenderAuth: 'required'is refused.X-Spam-TestsandReceived-SPFnever count over IMAP.
- Mailgun adds
- Prefer verified webhooks. A provider that receives the mail and reports DKIM and SPF, behind a signed (Mailgun) or secret (Postmark, SendGrid) webhook, is the strongest setup. With IMAP, use a mailbox whose server adds
Authentication-Results. - Prompt injection. A mail can tell the agent to ignore its instructions, to forward a secret or to call a tool. Give the agent only the tools it needs, ask approval for tools that write, send or delete, and keep secrets out of what it can read.
- Credentials. Use an app password or a mailbox of the bot's own, never your personal password, and keep them in the environment.
Configuration
interface EmailChannelConfig {
address: string;
name?: string;
aliases?: string[];
smtp:
| {
host: string;
port?: number;
secure?: boolean;
auth?: { user: string; pass: string };
options?: Record<string, unknown>;
}
| { transport: EmailTransport };
imap?: {
host: string;
port?: number; // default 993
secure?: boolean; // default true
auth: { user: string; pass?: string; accessToken?: string };
mailbox?: string; // default 'INBOX'
pollInterval?: number; // default 60 000 ms
lookbackDays?: number; // default 7
batchSize?: number; // default 20
concurrency?: number; // default 4
maxBytes?: number; // default 25 MB
clientOptions?: Record<string, unknown>;
};
webhook?: (
| { provider: 'mailgun'; signingKey: string; maxAge?: number }
| {
provider: 'postmark' | 'sendgrid';
basicAuth?: { username: string; password: string };
token?: string;
}
) & { port?: number; path?: string; host?: string; maxBody?: number };
allowFrom?: string[]; // default nobody
owners?: string[]; // allowed, with a passing sender authentication
senderAuth?: 'required' | 'when-reported' | 'off'; // default 'when-reported'
trustedAuthservIds?: string[];
includeSubject?: boolean; // default true
subject?: string; // of mail that starts a thread
maxAttachmentBytes?: number; // default 10 MB
}In cogitator.yml, channels.email takes ownerIds (addresses, allowed whatever allowFrom says, with a passing sender authentication), address, name, aliases, allowFrom, senderAuth, trustedAuthservIds, subject, imap (host, port, secure, mailbox, pollInterval, lookbackDays), smtp (host, port, secure) and webhook (provider, port, default 8788, and path, starting with /). Hosts, ports and the address may come from the environment instead, and the secrets always do:
| Variable | For |
|---|---|
EMAIL_ADDRESS | the bot's address, when address is not set |
EMAIL_IMAP_HOST, EMAIL_IMAP_PORT | the IMAP server, EMAIL_IMAP_HOST alone turns IMAP on |
EMAIL_IMAP_USER, EMAIL_IMAP_PASSWORD | IMAP login, the user defaulting to the address |
EMAIL_SMTP_HOST, EMAIL_SMTP_PORT | the SMTP server |
EMAIL_SMTP_USER, EMAIL_SMTP_PASSWORD | SMTP login, the user defaulting to the address |
EMAIL_MAILGUN_SIGNING_KEY | webhook.provider: mailgun |
EMAIL_WEBHOOK_TOKEN | webhook.provider: postmark or sendgrid, as ?token= |
EMAIL_WEBHOOK_USER, EMAIL_WEBHOOK_PASSWORD | the same, as basic auth instead |
channels:
email:
ownerIds: [me@example.com]
address: assistant@example.com
allowFrom: [example.com]
trustedAuthservIds: [mx.google.com]
imap:
host: imap.gmail.com
smtp:
host: smtp.gmail.comA channel without what it needs is skipped with a warning. The scaffolder makes a bot that reads a mailbox over IMAP with npx create-cogitator-app my-bot --preset channels --channels email, and cogitator init and cogitator wizard offer it too. See examples/channels/07-email-bot.ts.