Skip to content

[2.x] Add typed mailbox events for IDLE - #187

Merged
stevebauman merged 8 commits into
v2.0from
feature/v2-idle-events
Sep 28, 2026
Merged

stevebauman merged 8 commits into
v2.0from
feature/v2-idle-events

Conversation

@stevebauman

@stevebauman stevebauman commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

IMAP IDLE reports mailbox changes, not individual new messages. Treating each EXISTS response as a single message to fetch can miss arrivals when several messages arrive together.

This PR separates mailbox events from message delivery and moves the IDLE protocol exchange into a dedicated session.

Mailbox events

Folder::events($callback, $timeout = 300, ...$options) exposes typed mailbox events without fetching messages:

  • Folder selection, message counts, FETCH updates, EXPUNGE, and VANISHED.
  • Concrete classes under Idle\Events, sharing an EventInterface for callback type hints and instanceof checks.
  • FolderSelected on initial selection and after reconnecting so applications can reconcile saved checkpoints.
  • Original server responses, with sequence numbers kept separate from UIDs. Unrecognized responses remain available through UnknownEvent.

The watcher uses its own connection. Returning false stops it, callback exceptions propagate, and the dedicated connection closes on exit.

use DirectoryTree\ImapEngine\Idle\Events\EventInterface;
use DirectoryTree\ImapEngine\Idle\Events\FolderSelected;
use DirectoryTree\ImapEngine\Idle\Events\MessagesVanished;

$folder->events(function (EventInterface $event) {
    if ($event instanceof FolderSelected) {
        reconcileFolder($event->folder(), $event->selection());
    } elseif ($event instanceof MessagesVanished) {
        removeMessages($event->folder(), $event->uids());
    } else {
        scheduleFolderSync($event->folder());
    }
}, timeout: 300);

The functions above represent application code. Applications using this API own message retrieval, checkpoint storage, and reconciliation. EXISTS contains the current mailbox count, not the UID of a newly arrived message.

Message delivery

Folder::idle($callback, $query = null, $timeout = 300, ...$options) continues to deliver messages and retains its optional query callback. Folder::poll($callback, $query = null, $frequency = 60) provides the same message-focused API through periodic checks.

use DirectoryTree\ImapEngine\MessageData;
use DirectoryTree\ImapEngine\MessageInterface;
use DirectoryTree\ImapEngine\MessageQuery;

$folder->idle(
    callback: function (MessageInterface $message) {
        processMessage($message);
    },
    query: fn (MessageQuery $query) => $query->with(MessageData::flags()),
    timeout: 300,
);

Both methods track arrivals in ascending UID order, exclude existing messages at startup, preserve the cursor across reconnects with the same UID validity, and establish a new baseline when UID validity changes. Returning false stops delivery, and callback exceptions propagate.

Message queries expose cursor($chunkSize = 10) for bounded fetching. The cursor loads matching UIDs once and retrieves messages in batches; IDLE and polling use this instead of fetching every arrival at once. Query pagination does not apply to the cursor.

IDLE retrieves messages through the original folder connection while Watch listens on a separate connection. Reconnects do not provide durable synchronization guarantees; applications needing those should use events() and maintain checkpoints.

Session handling

IdleSession owns the continuation handshake, buffered updates, renewal deadline, and DONE exchange. Updates received before the continuation or while finishing IDLE are preserved.

Connection idle() returns an IdleSession. Consume updates through responses($timeout) and finish through finish(), which returns remaining updates and replaces connection done().

The connection blocks normal commands while IDLE is active and invalidates the session when disconnected. The watcher uses the mailbox's selected-folder state. Selection options passed to events() or idle() apply to the dedicated watching connection.

Renewal defaults to 300 seconds and honors the developer's chosen interval without an upper cap. Incoming updates do not extend the deadline. Polling frequency controls the delay between checks instead.

Connection read() and write() expose parsed/logged protocol IO, while stream() provides low-level transport access. Direct stream access bypasses connection bookkeeping.

Fakes

FakeFolder::setIdleEvents(array $events) supplies events to events() after its initial selection event. Fake idle() and poll() deliver configured messages and support query callbacks and stopping.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

FakeFolder::idle() incorrectly rejects valid non-closure callable timeout values.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Adds typed mailbox events and a dedicated, reconnectable IMAP IDLE session.

Changes:

  • Introduces typed selection, count, FETCH, EXPUNGE, VANISHED, and unknown events.
  • Moves IDLE lifecycle and buffering into IdleSession.
  • Updates folder APIs, fakes, and tests for event-driven watching.
File Description
tests/​Unit/​IdleTest.php Tests watcher lifecycle and renewal.
tests/​Unit/​IdleEventTest.php Tests event parsing and fake delivery.
tests/​Unit/​Connection/​ImapConnectionTest.php Tests connection IDLE and I/O APIs.
tests/​Unit/​Connection/​IdleTest.php Tests IDLE session protocol handling.
src/​Testing/​FakeFolder.php Adds configurable fake IDLE events.
src/​Idle/​Events/​UnknownEvent.php Represents unmodeled responses.
src/​Idle/​Events/​ResponseEvent.php Provides shared response event behavior.
src/​Idle/​Events/​MessagesVanished.php Exposes vanished UIDs.
src/​Idle/​Events/​MessagesExist.php Exposes mailbox message counts.
src/​Idle/​Events/​MessageFetched.php Exposes FETCH changes.
src/​Idle/​Events/​MessageExpunged.php Exposes expunged sequence numbers.
src/​Idle/​Events/​FolderSelected.php Exposes selection metadata.
src/​Idle/​Events/​EventInterface.php Defines the event contract.
src/​Idle/​EventFactory.php Maps responses to event classes.
src/​Idle.php Implements event-based watching and reconnects.
src/​FolderInterface.php Updates the public IDLE API.
src/​Folder.php Uses a dedicated event watcher connection.
src/​Connection/​ImapConnection.php Integrates sessions and public protocol I/O.
src/​Connection/​IdleSession.php Manages the IDLE protocol lifecycle.
src/​Connection/​ConnectionInterface.php Exposes the revised connection APIs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Testing/FakeFolder.php Outdated
…-Closure callable timeouts'

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The protocol lifecycle and broad v2 API changes warrant final human validation despite substantial unit coverage.

Review effort: Balanced
Findings: None

Resolved since last review (1)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The implemented Folder::idle() signature and behavior contradict the documented V2 event API.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Comment thread src/Folder.php
@stevebauman
stevebauman merged commit 833b7d0 into v2.0 Sep 28, 2026
14 checks passed
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.

2 participants