Mailgrep-web: Regex-Powered Bulk Email Cleanup Over IMAP
I had an old email account sitting at around 40,000 messages. A decade of newsletters, promotional garbage, alerts from services I'd forgotten I signed up for, and roughly one useful email per hundred. Every inbox UI I tried — webmail, desktop clients, mobile apps — was built for managing a normal inbox, not for surgically destroying most of one.
The usual workflow: search for a sender, select all, delete, repeat. Except "select all" caps out at whatever the client lets you batch, pagination makes the process tedious, and none of them give you regex. If you want to nuke everything from *@*.ru, or every subject matching a pattern across all senders, you're doing it one page at a time.
So I built a small web app that connects to any IMAP inbox, pulls the full message list, groups by sender or subject, and lets you search with regex before bulk-deleting. It runs locally, takes a few minutes to fetch a large mailbox, and then the whole cleanup happens in a browser tab.
The Stack
- Node.js + Express — backend, REST API, static file serving
- imap-simple — IMAP connectivity; wraps
node-imapwith promise support - mailparser — parses raw RFC822 email bytes into structured objects
- Server-Sent Events (SSE) — live progress updates from backend to browser during long fetches
- Vanilla HTML/JS — single
public/index.htmlfile, no framework, no build step - Docker + Compose — the entire deployment is
docker compose up -d --build
The project is intentionally small:
mailgrep-web/
├── Dockerfile
├── docker-compose.yml
├── server.js # Express + IMAP backend (~410 lines)
├── package.json
└── public/
└── index.html # Full UI (inline CSS + JS)
No database. No persistent state. Emails are held in memory on the server for the duration of the session and cleared on disconnect.
Fetching 40,000 Emails Without Breaking IMAP
The naive approach — fetch everything in one IMAP request — fails on large mailboxes. IMAP servers return errors like UID FETCH: Too long argument when the sequence range or arguments get too long, and some older servers have their own quirks on top of that.
The fetch logic pages through the mailbox in chunks of 200 messages using sequence numbers:
const PAGE = 200;
for (let start = 1; start <= total; start += PAGE) {
const end = Math.min(start + PAGE - 1, total);
const items = await seqFetchRangeRobust(imap, start, end, bodies);
// parse and accumulate...
}
Each chunk uses a robust fetcher that splits the range recursively on failure. If fetching 1:200 fails, it tries 1:100 and 101:200 independently, then splits again if needed. In practice this rarely triggers, but it means the tool doesn't bail on a weird message or a server hiccup.
There's also a body-spec detection step before fetching begins. IMAP servers don't all accept the same BODY[] syntax — some want RFC822, some want BODY[], some BODY.PEEK[]. The tool probes the newest message with each option in order and caches the one that works:
const optionsToTry = [
['RFC822'],
['BODY[]'],
['BODY.PEEK[]'],
[''] // node-imap shortcut for whole message
];
for (const bodies of optionsToTry) {
// try a single known-good seq number
// if it returns items, this spec works — cache and use for all subsequent fetches
}
This one-time probe at startup means the actual page fetches use whatever the server accepts without retrying each chunk.
Live Progress via SSE
Fetching 40,000 emails takes a few minutes. The frontend connects to /api/progress as an SSE stream before triggering the fetch, and the backend pushes progress events as it works:
sendProgress({
stage: 'downloading',
message: `Downloaded ${processed} of ${total} emails...`,
total,
current: processed
});
SSE is the right tool here — one-directional, text-based, trivially simple compared to WebSockets, and it works fine through the nginx container without any special proxy config. The frontend renders a progress bar from the current and total fields.
Grouping and Regex Search
Once the emails are loaded, the UI groups them into "folders" — either one folder per sender address or one per subject string. Each folder shows a count, a checkbox, and a "drill in" link.
The search bar supports three modes:
- Plain text — case-insensitive substring match
- Regex literal — anything typed as
/pattern/flagsis treated as a regex - Glob mode — with "Regex" toggle enabled, glob-like patterns (
*.com,*@*.de) are translated to proper regex before matching
Search scope is selectable: all fields, email address only, subject only, or body. For cleanup purposes the most useful combination is regex on email address. A few patterns that worked well on my inbox:
-- Non-standard TLDs (not .com / .net / .org / .edu / .ca / .com.au)
^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.(?!com$|com\.au$|ca$|net$|org$|edu$)[A-Za-z0-9-]+$
-- High-risk country TLDs
^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.(ru|su|tr|pl)$
-- Everything from a specific domain
^[A-Za-z0-9._%+-]+@amazon\.de$
The glob translation is handy for quick one-offs. Typing *@*.ru with Regex mode on gets converted to the proper regex internally — you don't have to remember the escape syntax every time.
Deletion
Deletion goes through the IMAP EXPUNGE flow: open the inbox read-write, flag the target UIDs as \Deleted in conservative chunks of 300, then expunge. The in-memory cache is updated immediately so deleted emails disappear from the UI without a refetch.
const CHUNK_SIZE = 300;
const chunks = chunkArray(uids, CHUNK_SIZE);
for (const chunk of chunks) {
await imapConnection.addFlags(chunk, '\\Deleted');
}
await expungeAsync(imapConnection.imap);
This is a hard delete. There's no trash stage, no undo. Depending on the provider, EXPUNGE is final. The README is pretty clear about this, but it's worth repeating: test on a throwaway account before running it on anything you care about.
What Went Wrong
The BODY section mismatch ate a lot of time. imap-simple defaults to requesting BODY[], but older servers and some hosted providers (including a couple I tested against) reject this with Invalid BODY section or just silently return empty results. The symptom is a successful connect and fetch that returns zero emails. The body-spec detection loop was added after running into this — now it probes the server once before committing to a fetch strategy.
Chunking alone doesn't prevent all argument-length errors. Some servers impose limits on UID lists in a UID STORE call, not just in FETCH. The deletion side has the same conservative chunking as the fetch side (300 UIDs per call) specifically because a first version that sent all UIDs at once failed on a mailbox with 8,000 messages flagged for deletion.
TLS certificate rejection. The IMAP config has tlsOptions: { rejectUnauthorized: false }. This was necessary for a self-hosted mail server I was testing against that had a self-signed cert. It's the wrong default for anything you're connecting to over the internet — you're trusting that the server you're talking to is who it says it is. For a locally-run tool pointed at imap.gmail.com it's low risk in practice, but it's not the right call and I'd fix it in any version intended to run beyond localhost.
No auth layer around the web UI. The app listens on port 3000. Anyone who can reach that port can point it at any IMAP server they have credentials for. This is fine for local use. If you expose it outside localhost — even on a private network — there's nothing stopping someone from using it as an IMAP relay. Run it locally, keep it local.
Memory pressure on very large inboxes. All emails are held in memory as parsed objects. The parsed representation is smaller than raw RFC822, but at 40,000 messages with up to 500 characters of body text per email, you're looking at meaningful heap usage. It worked fine on the test inbox, but I'd expect problems with mailboxes in the hundreds of thousands. There's no pagination of the in-memory store — it's all loaded or nothing.
Running It
git clone https://github.com/grellis00/mailgrep-web.git
cd mailgrep-web
docker compose up -d --build
Open http://localhost:3000. Fill in your IMAP host, port (993 for SSL), email, and password — use an app password if your provider supports it, not your main account password. Click Connect, then Load Emails. For a large mailbox, get a coffee.
Backend logs:
docker logs mailgrep-web -f
The Result
Got that 40,000-message inbox down to under 200 useful emails in about an hour. Most of it was selecting folders by regex, checking a sample, and hitting delete. The remainder was drilling into specific senders and picking through individual messages.
The tool is about 410 lines of backend JavaScript and a single HTML file. It's not polished — there are rough edges, the error messages aren't always helpful, and the UI is purely functional. But it does the one thing inbox clients don't: let you treat email like a dataset you can grep, filter, and bulk-delete with real pattern matching.
Source is at github.com/grellis00/mailgrep-web.