deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

One-word IMAP bug silently marked 9,133 Gmail messages as read

A developer's mail bot marked 9,133 of 9,143 Gmail messages as read because an IMAP fetch sets the \Seen flag by design — and fixing that side effect broke the poller that depended on it.

One-word IMAP bug silently marked 9,133 Gmail messages as read

A bot that read the mail too well

A personal automation bot spent months marking almost every message in its owner's Gmail inbox as read, seconds after each one arrived, before anyone noticed. According to a postmortem published on dev.to, the problem surfaced as a complaint the author effectively filed against themselves: email seemed broken and the filtering looked wrong. The filtering was fine. What had actually happened was that a mail bot had flagged 9,133 of the 9,143 messages in the inbox as read.

The adapter polled the inbox every 15 seconds and fetched each new message using IMAP's RFC822 fetch item. As the postmortem explains, RFC 3501, the IMAP specification, defines that fetch so it sets the \Seen flag as a side effect. Retrieving the full body counts as reading, and the server records it. Nothing was malfunctioning — the protocol behaved exactly as written.

Before the fix, the author measured 9,143 total messages, 9,133 flagged as seen, 10 unseen, and 29 of the 30 newest already read. The repair itself is a single token: request BODY.PEEK[] instead, which returns the same bytes without the flag write.

Why the filter took the blame

The author finds the misdirection more interesting than the flag itself. The adapter had a sender allowlist that worked, plus filters for automated mail keyed on headers such as List-Unsubscribe, Precedence: bulk and Auto-Submitted — all functioning correctly. But filtering ran at dispatch time, after the fetch. For a message the bot wanted nothing to do with, the sequence was: fetch it, which set \Seen; decide the sender wasn't allowed; discard it. Mail the system deliberately ignored still got marked read.

Since nearly everything passed through the fetch, tighter filtering meant more silent damage. And the visible symptom — mail the author cared about arriving pre-read — pointed at the filtering configuration, the one subsystem behaving correctly. The general lesson the author draws: when a symptom names a component, check whether that component sits downstream of something with side effects. Read paths go unaudited because reading isn't supposed to write.

The second bug arrives with the fix

Switching to BODY.PEEK[] on its own introduces a new failure. The poller located new messages by searching for UNSEEN — a scheme that only worked because the old fetch was setting \Seen on everything it touched. Remove the side effect and UNSEEN stops meaning "not yet processed" and starts meaning "not read by a human", leaving the poller to refetch a permanently growing backlog every 15 seconds.

The replacement is an explicit UID high-water mark: track the highest UID processed and search from UID max+1:* onward. Two details tripped the author here. First, UID n:* always returns at least one result — IMAP hands back the mailbox's highest UID even when nothing newer exists — so the code's own comparison against the stored maximum is the real filter. Second, the mark must be seeded from the existing mailbox at startup, or a restart re-delivers everything; an earlier _seen_uids set could not serve this purpose because it was capped at 2,000 entries and trimmed over time.

Verify by effect, not by diff

The author did not declare the bug fixed because the code looked right. The verification procedure: select the mailbox in read-only mode so the watcher cannot contaminate what it measures, snapshot the counts for ALL and UNSEEN searches, poll until the message count rises, then assert that the new UID is still unseen. A fresh message arrived and stayed unread. The postmortem argues that is the only evidence worth anything, because you cannot prove the absence of a side effect by re-reading the code that caused it.

Why it matters

Most automation that touches shared state contains reads that quietly write, and IMAP's \Seen flag is a textbook case. Anyone pointing a bot at a mailbox they care about should check whether they fetch RFC822 or BODY.PEEK[], and should filter before fetching rather than after, since anything downstream of the fetch is too late to prevent side effects. The story also illustrates two transferable debugging rules: removing a side effect will break whatever silently depended on it, so ask what else was reading that state; and prove fixes by observed behaviour rather than by how the diff reads. The author, who documents similar misdiagnosed homelab failures, spent the first stretch of debugging on the component the symptom named — the one that was working perfectly all along.

  • #imap
  • #email
  • #debugging
  • #gmail
  • #postmortem

Related posts