GhostPaw

@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.

Wireframe envelopes drawn in cyan light streaming into a hexagonal vault, a ghost paw on its lid, one envelope glowing violet.

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).

  1. 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 -> populateFts
    Message-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 October

    One message in the cache, uid 1. Its body stays in SQLite next to the headers.

  2. 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.example

    The 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.

  3. 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 1

    Two threads, computed from References and In-Reply-To.

  4. 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.

  5. 05 Read the thread as an agent

    The mail_read tool, 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.

  6. 06 Ask for a sync without a server

    mail_sync with 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: false

    Refused 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

A diagonal chain of wireframe envelopes branching like a conversation, cyan lines between them and one reply glowing violet.

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:zlib and node:crypto, in one bundled file.
  • The MCP server reads EMAIL_IMAP_HOST, EMAIL_SMTP_HOST, EMAIL_USER and EMAIL_PASS or EMAIL_ACCESS_TOKEN from your environment. Cached reads need no credentials.
  • Cached mail lives in ~/.ghostpaw/email.db unless GHOSTPAW_EMAIL_DB or --db says 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.