Back to Projects

Email Query MCP

An MCP server giving AI coding sessions searchable access to a decade of Office 365 email. Six versions of lock fighting ended when one daemon owned the database.

2026Live
email-query
[10:24:12] launcher: daemon not running, spawning
[10:24:18] launcher: daemon is up (loopback)
[10:24:18] bridging stdio <-> http
[10:24:19] session A: query_emails domain=canadabeef.ca
[10:24:19] session B: get_conversation_by_id ...
→ same daemon, same DB, zero eviction
[10:29:00] background re-sync (last-sync stale, 0 in flight)
── 8 tools │ N sessions │ 1 database owner ──

Results

8
MCP Tools
Query, threads, imports, attachments, client mapping
N → 1
Sessions per DB owner
Any number of AI sessions, one process touching SQLite
0
Eviction fights since v1.6
The daemon replaced last-writer-wins takeover entirely
6 days
Longest orphan survived
The incident that forced the redesign

Changelog

v1.0 (2026-02): Query, import, and thread tools over a synced copy of the email-triage database. One database, finally searchable.

v1.3 (2026-04): First corruption incident. Raw SCP of SQLite binaries and a copyFileSync fallback both turned out to be corruption vectors on Windows. Dump+rebuild sync replaced them. One database, rebuilt instead of copied.

v1.4 (2026-04): A 6-day-old orphaned process held the WAL lock and every fresh launch corrupted the DB trying to swap it. PID+heartbeat lockfile, stdin-EOF death detection, skip-if-recent sync. One database, guarded by a lock.

v1.5 (2026-05): The lock broke reconnects in a new way: zombie parents kept orphans heartbeating for days. Cooperative handoff, parent-PID polling, Windows tasklist fallback. One database, guarded by a smarter lock.

v1.6 (2026-08): The redesign. One shared daemon owns the database; sessions bridge to it over loopback HTTP. The lock, the handoff, the eviction, all made unnecessary rather than more clever. One database, one owner, any number of sessions.

The Problem

My email lives in Office 365. My AI coding sessions live in terminals. The gap between them meant every "what did the client actually agree to?" question ended in manual inbox archaeology.

Email Query MCP closes that gap: 8 tools that let any Claude Code session search a decade of mail by sender, domain, client, or keyword, pull full conversation threads, and download attachments. Read-only against the mailbox. The interesting part was never the queries.

The interesting part was one SQLite file on Windows.

A Taxonomy of Ways One File Can Ruin Your Month

The server keeps a local SQLite copy of the mail index, synced over SSH from a home server. Simple. Except:

FailureCauseLesson
SQLITE_CORRUPT on a byte-identical fileRaw SCP of SQLite binaries, Linux → WindowsChecksums lie. Dump SQL, rebuild locally.
Live DB truncated to garbagecopyFileSync fallback "succeeding" against an open fileA fallback that can't fail safely isn't a fallback
Every fresh launch corrupts the DB6-day orphaned process holding the WAL lockProcesses outlive the sessions that spawned them
New sessions refuse to startThe lock working exactly as designedFixing corruption created an availability problem
Sessions randomly "not connected"Each new session evicting the previous one's serverThe lock's final form: last-writer-wins musical chairs
Flaky handshakes since day oneThe logger writing info lines to stdout, the MCP protocol channelFound six months later, while fixing something else

Each fix was correct. Each fix was also a patch on the same underlying mistake: every session spawned its own process, and every process thought it owned the database.

The Fix That Deleted the Problem

v1.6 stops making the lock smarter and removes the contention instead. One long-lived daemon owns the SQLite file, the sync, and the writes, serving MCP over loopback HTTP, stateless per request, so sessions share it freely and a daemon restart is invisible. Each session spawns only a tiny stdio launcher: health-check the daemon, start it if absent, bridge stdio to HTTP.

The port bind is the whole singleton mechanism now. Two daemons can't bind the same port; the loser exits. No heartbeats, no handoff timeouts, no SIGKILL takeovers.

Three design details carry the weight:

  • Bind first, sync later. The daemon accepts connections immediately and initializes the database in the background; tool calls await readiness. MCP startup timeouts stopped being possible.
  • Stateless per request. No session state in the daemon means a crash-and-respawn mid-conversation loses nothing. The launcher retries once and the session never notices.
  • Sync only when idle. The fragile dump+rebuild swap runs when data is stale and no request is in flight, the two conditions under which it can't hurt anyone.

Results

  • 8 MCP tools available to every AI session on the machine, concurrently
  • N sessions, 1 database owner: the entire lock/handoff/eviction apparatus reduced to a port bind
  • Zero eviction fights since v1.6: the "isn't connected in this session" error class is structurally gone
  • The mailbox stays read-only: all writes are to the local cache, all owned by one process

Five versions made the lock smarter. The sixth asked why there was contention at all, and the best concurrency control turned out to be having nothing to contend for.