@ghostpaw/email 0.4.0
Your agent can read the inbox. Sending is a separate tool.
email speaks IMAP and SMTP from inside your Node.js process, caches mail in a SQLite file with threads and FTS5 search, and splits what an agent can do into five tools. Give an agent mail_read and mail_search, leave out mail_compose, and it can read and search but not send.
As a library
As a local MCP server
Replay the run Read the source on GitHub
Free and MIT licensed for Node.js 24 or newer, with zero runtime dependencies. The cache is a SQLite file on your disk; credentials stay in your environment.

0runtime dependencies
Three messages, six calls, no server
Everything below ran on one machine with no IMAP server contacted.
We built the messages with the library's own MIME composer, parsed them with its parser and cached them through the store functions its sync uses, all against email 0.4.0 on 2026-10-24 with the clock fixed. Run the steps in order.
Mailbox
- Messages0
- Threads0
- Last search—
- Tool result—
- IMAP sessionnone
An empty SQLite cache after initSchema(db).
-
01 An invoice arrives
Composed by the library and cached the way sync caches it.
const raw = mime.composeMessage({ from: ops, to: [mika], subject: 'Invoice 4471 for October', text: 'Hi Mika, invoice 4471 for October: 1,280 EUR, due 7 November.', }); cache(raw); // parseMessage -> store.insertMessage -> upsertBody -> populateFtsMessage-ID: <1792660320000.90c76e10…@vendor.example> Date: Thu, 22 Oct 2026 09:12:00 +0000 MIME-Version: 1.0 From: Ops <ops@vendor.example> To: Mika <mika@example.com> Subject: Invoice 4471 for OctoberOne message in the cache, uid 1. Its body stays in SQLite next to the headers.
-
02 Reply to it
composeReply builds the reply from the cached original.
const reply = mime.composeReply(original, { text: 'Thanks. Is the 1,280 EUR before or after the volume discount?', }, [mika]); cache(mime.composeMessage(reply), ['\\Seen']);From: Mika <mika@example.com> To: Ops <ops@vendor.example> Subject: Re: Invoice 4471 for October In-Reply-To: 1792660320000.90c76e10…@vendor.example References: 1792660320000.90c76e10…@vendor.exampleThe reply points back at the original Message-ID. This only builds the message, like a draft that nobody has sent; sending it would take SMTP.
-
03 A note from a friend, then threads
A third message with no headers in common.
cache(mime.composeMessage({ from: ren, to: [mika], subject: 'Climbing on Sunday?', text: 'Gym opens at ten. …' })); store.computeThreads(db, inbox.id); read.createReadSurface(db, account.id).threads('INBOX');// Climbing on Sunday? 1 message, 1 unread // uid 3 Ren depth 0 // Invoice 4471 for October 2 messages, 1 unread // uid 1 Ops depth 0 // uid 2 Mika depth 1Two threads, computed from References and In-Reply-To.
-
04 Search for the invoice
Local search over the cache.
rs.search('invoice'); rs.search('climbing');// invoice -> uid 1 Ops "Invoice 4471 for October" // uid 2 Mika "Re: Invoice 4471 for October" // climbing -> uid 3 Ren "Climbing on Sunday?"Both invoice messages, and only Ren's for climbing. No server was asked.
-
05 Read the thread as an agent
The
mail_readtool, with no write or network surface handed in.getEmailToolByName('mail_read').handler( { read: rs, write: null, network: null }, { view: 'thread', threadId }, );// { ok: true, outcome: 'success', // summary: 'Thread "Invoice 4471 for October" has 2 message(s).', // next: [{ kind: 'use_tool', // message: 'Use view: "message" to read individual messages in the thread.' }] }Reading worked with
write: null. The tool never needed the sending side. -
06 Ask for a sync without a server
mail_syncwith a network surface that has no session.getEmailToolByName('mail_sync').handler( { read: rs, write: null, network: createNetworkSurface(db, { accountId, session: null }) }, { action: 'sync' }, );// { ok: false, outcome: 'error', // summary: 'mail_sync failed: Use Mailbox.connect() — network surface // requires an active IMAP session' } // underlying: EmailUnsupportedError, code 'unsupported', retriable: falseRefused with a typed error. The page stops here because the next step needs a real mailbox.
cache() is ten lines of our own glue calling mime.parseMessage, store.insertMessage, store.upsertBody and sync.populateFts. In a real setup mailbox.network.sync() fills the cache from IMAP.
Search here matched subjects. We do not claim body search for 0.4.0; in our run, changing a cached message row cleared its body from the search index.
Five tools, and what each one may do
The tools follow what an operator wants done, not IMAP commands. Which ones an agent gets is up to your harness.
mail_read- Folder lists, message queues, threads, bodies, attachments or raw EML, from the cache.
mail_search- FTS5 local search, or IMAP SEARCH on the server.
mail_compose- Send, reply, forward, and save, update or send drafts.
mail_organize- Flags, labels, copy, move, archive, trash, junk and folder management.
mail_sync- Connect, disconnect, reconnect, sync, refresh folders and watch with IDLE.
- Thread
- A JWZ conversation tree computed from References and In-Reply-To at sync time.
- Body
- Fetched on demand. The first sync pulls headers and BODYSTRUCTURE only.
Each tool returns the same shape: ok, outcome, summary, next and data. The email CLI returns it too with --json.
If an email package is the dependency you fear most
An email MCP package once added a single BCC line in a patch release. email cannot promise you anything about other packages, but it keeps its own surface small.
- Zero runtime dependencies. It uses only
node:sqlite,node:tls,node:net,node:zlibandnode:crypto, in one bundled file. - The MCP server reads
EMAIL_IMAP_HOST,EMAIL_SMTP_HOST,EMAIL_USERandEMAIL_PASSorEMAIL_ACCESS_TOKENfrom your environment. Cached reads need no credentials. - Cached mail lives in
~/.ghostpaw/email.dbunlessGHOSTPAW_EMAIL_DBor--dbsays otherwise. - IMAP with IDLE, CONDSTORE and QRESYNC, COMPRESS=DEFLATE and XOAUTH2 or OAUTHBEARER; SMTP over TLS or STARTTLS, with a copy appended to Sent.
- Node.js 24 or newer, because it uses the built-in
node:sqlite.
What it costs to add
- Runtime dependencies
- 0
- Node.js
- 24 or newer (node:sqlite)
- Size of dist/index.js, gzip
- 47.5 KB
- Import time
- about 12.3 ms
- Tests
- 553 passing
- Line coverage
- 94.0%
- Agent tools
- 5
Measured 2026-10-24 on the maintainer's machine with Node.js 24.14.0 against email 0.4.0: size of the published dist/index.js at gzip -9, median of 11 cold imports, tests and coverage from the v0.4.0 tag.
Start from the README
import { Mailbox } from '@ghostpaw/email';
const mailbox = new Mailbox({
imap: { host: 'imap.example.com', port: 993, tls: true },
smtp: { host: 'smtp.example.com', port: 465, tls: true },
auth: { user: 'user@example.com', pass: process.env.EMAIL_PASS },
storage: 'mail.db',
});
await mailbox.connect();
await mailbox.network.sync();
const messages = mailbox.read.messages('INBOX', { limit: 20 });
await mailbox.disconnect();
To run it as an MCP server, add npx -y @ghostpaw/email mcp to your client.
All seven GhostPaw librariescodex, for memoryquestlog, for tasksaffinity, for contactssouls, for identitygrimoire, for skillstelegram, for chats