Skip to content

Honor FT_PEEK at the protocol level instead of reverting \Seen with STORE - #625

Open
ralt wants to merge 1 commit into
Webklex:masterfrom
ralt:fix-ft-peek-wire-level
Open

Honor FT_PEEK at the protocol level instead of reverting \Seen with STORE#625
ralt wants to merge 1 commit into
Webklex:masterfrom
ralt:fix-ft-peek-wire-level

Conversation

@ralt

@ralt ralt commented Jul 28, 2026

Copy link
Copy Markdown

Related PRs

I only spotted these after opening this PR — flagging the overlap upfront:

  • Fix leaveUnread by using PEEK for body fetch #616 (@braito4) proposes the same wire-level approach for the fetch side: optional $peek parameter on content(), BODY.PEEK[TEXT], FT_PEEK for imap_fetchbody(), and BODY.PEEK-aware response matching. On top of that, this PR also removes the now-pointless revert STORE -FLAGS \Seen in Message::peek() — without that, every unseen message still costs the two extra round trips and the "flag could not be removed" failure mode remains — and adds live regression tests plus a changelog entry.
  • Low-level IMAP protocol fixes for RFC compliance & Dovecot. #617 (@jdwx) independently contains the BODY.PEEK[...]BODY[...] response-matching normalization (with mocked-protocol unit tests), alongside unrelated RFC-compliance fixes.

Happy to rebase this on top of either if you'd rather land those first — the Message::peek() change and the live tests here are complementary to both.

Problem

IMAP::FT_PEEK (the default options.fetch) is documented as "Fetch the message without setting the 'seen' flag", but it is currently implemented as fetch-then-revert: bodies are fetched with RFC822.TEXT, which implicitly sets \Seen (RFC 3501 §6.4.5), and Message::peek() then sends a STORE -FLAGS \Seen per unseen message to undo it.

This has three user-visible consequences:

  1. Messages flicker read → unread in other clients connected to the same mailbox (e.g. the Gmail web UI updates live while a php-imap sync is running).
  2. Messages end up permanently marked as read whenever the revert doesn't happen: the STORE fails (MessageFlagException "flag could not be removed", e.g. on folders where flag writes are refused) or the process dies between the FETCH and the STORE.
  3. Two extra round trips per unseen message (the STORE itself plus the flag re-fetch inside unsetFlag()).

Fix

Make FT_PEEK peek at the wire level:

  • ImapProtocol::content() fetches with BODY.PEEK[TEXT] when peeking (new optional bool $peek = false parameter; BODY.PEEK[TEXT] returns the same payload as RFC822.TEXT/BODY[TEXT] per RFC 3501, there is just no PEEK variant of the RFC822.TEXT syntax).
  • ImapProtocol::fetch() now matches response items against the non-PEEK item name, because servers answer a BODY.PEEK[...] request with an untagged BODY[...] response.
  • LegacyProtocol::content() passes FT_PEEK to imap_fetchbody().
  • Query::fetch() and Message::parseBody() pass their existing fetch option down to content().
  • Message::peek() no longer issues the reconciliation STORE in FT_PEEK mode — the flag was never set, so there is nothing to revert. The non-peek branch (mark as seen) is unchanged.

History

This restores the wire behavior the library had before 2.0: in the ext-imap era, FT_PEEK was passed straight to imap_fetchbody(), and c-client fetches with BODY.PEEK[...] on the wire. The socket-protocol rewrite (f83ccda, Sept 2020) hardcoded RFC822.TEXT and dropped the peek option; the revert-STORE was then added as a fix for the resulting "messages get marked as read" reports (0c61e09, #33, d00b041). BODY.PEEK itself is core IMAP4 (RFC 1730/3501, no capability negotiation needed), so every server that worked with the pre-2.0 peek mode already handled it.

Backwards compatibility

  • ProtocolInterface::content() gains an optional bool $peek = false parameter — anyone implementing the interface with a custom protocol class needs to add it. Callers are unaffected.
  • Fetched payloads are unchanged (RFC822.TEXTBODY[TEXT] per RFC 3501).
  • Observable flag behavior only changes in that FT_PEEK now works as documented on servers/folders where the revert STORE used to fail, and in that other clients no longer see the transient \Seen.

Tests

Two live tests added to tests/live/MessageTest.php:

  • testFetchingBodyLeavesMessageUnseen — fetches a body with the default FT_PEEK and asserts, via a fresh fetch from the server, that the body is returned and \Seen is still unset. On current master this only passes because of the revert STORE; with the patch no \Seen/STORE traffic happens at all.
  • testFetchingBodyWithoutPeekMarksMessageSeen — asserts the non-peek path still ends with the message seen.

Full suite run locally against a dovecot configured like .github/docker/dovecot_setup.sh: the set of failing tests is byte-for-byte identical before and after the patch (pre-existing, environment-related), and the new tests pass.

…TORE

Fetching a body with RFC822.TEXT implicitly sets \Seen (rfc3501#section-6.4.5),
so FT_PEEK was implemented as fetch-then-revert: a STORE -FLAGS \Seen per
unseen message. That flickers messages read/unread in other connected clients,
leaves them permanently marked read when the revert fails ("flag could not be
removed") or the process dies mid-way, and costs two extra round trips per
unseen message.

Instead, fetch with BODY.PEEK[TEXT] (imap_fetchbody FT_PEEK for the legacy
protocol), which never sets \Seen, and drop the reconciliation STORE. Servers
answer a BODY.PEEK[...] request with an untagged BODY[...] response, so
ImapProtocol::fetch() now matches response items against the non-PEEK name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant