Skip to content
Building & Breaking

I Gave an LLM My Inbox

It’s past midnight. I’m building an MCP server in Go that lets an AI agent read and manage my email through Protonmail Bridge. This is either a terrible idea or a great one — I’ll know by the end of this post.

The Setup

Protonmail Bridge runs a local IMAP and SMTP server on 127.0.0.1 — port 1143 for IMAP, 1025 for SMTP, both with STARTTLS and a self-signed cert. It’s how you get standard mail clients talking to Proton’s encrypted backend. The Bridge decrypts messages on-the-fly as your client fetches them.

I wanted my AI agent (running locally through OpenCode) to list folders and read messages, search across headers and body, pull full message content including attachments, send mail via SMTP, and move, mark, delete, and organize messages. Basically everything a mail client does, minus the client.

So I built flimap — a Go MCP server using the official modelcontextprotocol/go-sdk, emersion/go-imap/v2 for IMAP, and jhillyerd/enmime/v2 for MIME parsing. Twelve tools exposed over stdio. The agent calls them like any other tool.

The Library Question

I started by evaluating IMAP libraries. The obvious choice was emersion/go-imap/v2 — the de facto Go IMAP client, well-maintained, proper IMAP4rev2 support. But I also looked at BrianLeishman/go-imap, which has a much nicer high-level API. You can fetch unread emails and get back pre-parsed Email structs with .Text, .HTML, .Attachments — all the MIME parsing handled for you.

One problem: it doesn’t support STARTTLS. It hard-codes tls.Dial (implicit TLS). My Bridge is configured for STARTTLS on port 1143. I’d have to either switch Bridge to SSL mode or fork the library.

I went with emersion for the IMAP client and borrowed BrianLeishman’s trick of using enmime for MIME parsing. The useful part was taking both: solid STARTTLS IMAP client + effortless env.Text, env.HTML, env.Attachments from raw RFC822.

The Protonmail Bridge Discovery

I have multiple email addresses tied to one Proton account — my primary Protonmail address, and ben@b7r.dev as a custom domain address. In the Protonmail web app, everything shows up in one unified inbox.

Bridge has two modes for multi-address accounts: combined, where every address’s mail lands in one shared set of IMAP folders, and split, where each address gets its own isolated set. Mine was in split mode. So logging in with my primary Protonmail address showed 3,041 INBOX messages. Logging in with ben@b7r.dev showed zero. Completely different mailboxes, same underlying account.

I switched to combined mode and suddenly the agent could see everything — 754 messages in INBOX, 3,776 in All Mail, across both addresses.

The Folder Tree Problem

But there was another wrinkle. I asked the agent to find an email with the subject “Changes to the SourceHut terms of service to limit LLM use.” It searched INBOX, Starred, Archive, Spam, Trash — nothing. The message only showed up in All Mail.

It turns out Protonmail’s custom folders and labels are exposed through Bridge, but they’re nested under container folders with a / delimiter: Folders/ben@b7r.dev, Folders/JOB SCAN, Labels/personal, Labels/insurance. My Protonmail filters were routing mail to Folders/ben@b7r.dev, which is why it was invisible to a flat folder listing.

The original list_folders implementation used the IMAP LIST "" "%" wildcard, which only returns one level deep. I switched to LIST "" "*" for recursive listing, and suddenly all 17 mailboxes appeared — including the nested subfolders with their message counts.

Container folders like Folders and Labels carry the \Noselect attribute — they can’t hold messages directly, only their children can. The FolderInfo struct now includes selectable, delimiter, and attrs so the agent knows the tree structure and which folders it can actually query.

Virtual Folders

Before I discovered the nested real folders, I built a virtual folder system. The idea: configure email addresses as virtual folders via an env var, and when the agent selects one, flimap searches All Mail filtered by the To: header. UIDs in All Mail are stable, so all mutations (mark read, move, delete) work by operating on All Mail with that UID.

It turned out I didn’t need it — the real Folders/ben@b7r.dev mailbox was there all along, just hidden by the flat listing. But I kept the virtual folder code. It’s a clean abstraction that could be useful for other filtering patterns, and it doesn’t hurt to have it available programmatically.

What the Agent Can Do

At this point, flimap exposes twelve tools:

Tool What it does
list_folders Recursive folder tree with counts and metadata
list_messages Recent messages by folder (envelopes only)
search_messages Keyword search across headers and body
get_message Full message: body, HTML, headers, attachment list
send_message Compose and send via SMTP with STARTTLS
move_message Move between folders by UID
mark_read / mark_unread Toggle \Seen flag
delete_message Move to Trash, or permanent delete if already there
create_folder / rename_folder / delete_folder Mailbox management

Each tool call opens a fresh IMAP connection (dial → STARTTLS → login → operate → logout). On localhost this adds maybe 50ms. The alternative — persistent connections with state management and reconnect logic — isn’t worth the complexity for an on-demand tool server.

The Late-Night Part

The debugging loop was my favorite part of building this. I’d build, kill the OpenCode backend process, wait for it to auto-restart, then call tools through the MCP protocol to see if they worked. Each restart cycle was about 30 seconds — enough time to doubt every decision I’d made.

The first time list_messages hit an empty INBOX, it crashed with imap: BAD no such message because it tried to build a sequence range from 1 to 0. Fixed with a three-line guard. The first time I searched for “ATOM surges 17.5% on bank tokenization push,” IMAP returned nothing — Protonmail Bridge’s body search is limited by decryption overhead. But searching for “Kraken Daily” (the From header) worked fine. IMAP TEXT search hits headers and body, but with Bridge, header matches are more reliable.

Why This Matters

Email is the last data silo most people never think to pry open. Receipts, notifications, newsletters, and actual human conversations all live there — encrypted, behind a bridge, in folders that may or may not be exposed to standard protocols.

Giving an AI agent read/write access to your inbox is a trust decision, and it doesn’t get smaller just because the agent runs on localhost instead of someone else’s cloud. The agent can read your mail. It can also move it, delete it, and send it on your behalf.

The agent can read your mail. It can also delete it. The only thing standing between those two facts is which tools you decided to expose.

flimap runs as a subprocess — no cloud, no third-party API, nothing leaving localhost. That’s the boundary that made this worth building instead of worth worrying about. But localhost is still a boundary you drew, not one that drew itself, and the twelve tools on the other side of it are only as safe as the judgment behind exposing them.

The practical effect, though, is immediate. “What’s in my inbox?” becomes a tool call instead of a tab switch. “Find that email about SourceHut’s TOS changes” becomes a search instead of scrolling. “Move all the Kraken Daily newsletters to a folder” becomes a sequence of moves instead of drag-and-drop.

The Day After

Yesterday, for the first time since I created the account, the inbox was at zero. Not triaged-to-feel-like-it — zero, with the folder headers agreeing.

Proton Mail's 'no messages'  screen: 'Seems like you are all caught up for now' on a dark charcoal background.
I'm not an email workflow purist, but this feels good, man.

“Seems like you are all caught up for now” … What? This is a state I didn’t feel like I deserved to achieve. The overwhelm of the deep inbox had been weighing on me for years. I also found a notification about an old pet insurance policy from early 2025 that I owed a nominal payment for so it wouldn’t hit my credit report. With the noise gone, I’d taken care of it in five minutes.

Today it got routine. I handed a session the mail-check skill, and it did what a mail client does but never asks about: looked at the unread set, sorted it into tiers I’d written down, asked before moving anything, and put noise where noise goes. One Jomashop promo to bulk. Nine Proton promos from 2019–2023 that had been sitting flagged-but-unread in Starred since before half this post’s tooling existed — archived, marked read, out of the triage set.

The rest was Archive — ten thousand, eight hundred-some messages that hadn’t accumulated so much as never been read, invisible behind the same folder-tree problem from earlier. The agent wrote a loop and let it run: fifty messages marked read, forty-five seconds, report, repeat. Unattended, all the way down to zero.

That’s the part I keep turning over. Twelve tools were a promise; what the session added was the discipline around them — which tiers mean what, confirm before you move anything, send and delete stay off the table — and a place for the state that discipline needs: a mailbox map, known senders, a log of what’s been done. The tools stayed the same size. The work around them is what got real.

So: terrible idea, or great one? Both, probably, in the proportions these things usually come in. The code is about 1,100 lines of Go across five files. It talks to a local mail server over IMAP and SMTP. It exposes twelve tools to an LLM, and every one of them runs on localhost, under a boundary I drew myself and no one else. That’s not nothing. It’s also not nothing to hand over.

Judge for yourself — the code’s on GitHub.