Unsolicited Responses

Copy Markdown View Source

IMAP servers can send untagged responses at any time, not just in reply to your commands. These unsolicited responses notify the client of changes to the mailbox: new messages arriving, messages being expunged by another session, or flag changes made elsewhere. The :on_unsolicited_response callback gives your application visibility into these notifications.

What are unsolicited responses?

RFC 9051 Section 5.2 requires servers to send mailbox size updates automatically and recommends sending flag updates as well. The three most important unsolicited responses for mailbox state are:

  • EXISTS — the mailbox message count changed (usually a new message arrived). Example: * 42 EXISTS
  • EXPUNGE — a message was removed from the mailbox (by another client or session). Example: * 7 EXPUNGE
  • FETCH — flags on a message changed (e.g., marked as read by another device). Example: * 5 FETCH (FLAGS (\Seen))

These are the ones most applications need to react to in real time.

However, the spec permits any untagged response to arrive unsolicited (RFC 9051 Section 5.3). Other examples include:

  • LIST — another session created, deleted, or renamed a mailbox
  • FLAGS — the set of available flags on the mailbox changed
  • OK/BAD/BYE — status responses with response codes (e.g., ALERT codes, impending shutdown)
  • Extension-defined responses

All untagged responses can arrive during any command, or even between commands if the server has pending notifications.

How Plover handles them

By default, Plover accumulates unsolicited responses on the pending command's response list. They're included in the command result alongside expected responses. When you're in IDLE mode, the IDLE callback handles EXISTS, EXPUNGE, and FETCH instead.

The :on_unsolicited_response option adds a callback that fires for all untagged responses outside of IDLE, giving you a single place to react to server-initiated notifications regardless of which command triggered them. Responses still accumulate on pending commands as before — the callback is additive.

Setting up the callback

Pass a function to Plover.connect/2 or Plover.connect/3:

{:ok, conn} =
  Plover.connect("imap.example.com",
    on_unsolicited_response: fn response ->
      IO.inspect(response, label: "unsolicited")
    end
  )

The callback receives a struct for every untagged response Plover parses. All response types are documented in Plover.Types.untagged_response. Always include a catch-all clause for forward compatibility:

on_unsolicited_response: fn
  %Plover.Response.Mailbox.Exists{count: count} ->
    Logger.info("Mailbox now has #{count} messages")

  %Plover.Response.Message.Expunge{seq: seq} ->
    Logger.info("Message #{seq} was expunged")

  %Plover.Response.Message.Fetch{seq: seq, attrs: attrs} ->
    Logger.info("Message #{seq} flags changed: #{inspect(attrs.flags)}")

  _other ->
    :ok
end

What the callback receives

The callback is invoked for every untagged response outside of IDLE. The most important ones to handle:

ResponseStructKey fieldsWhen it fires
EXISTS%Mailbox.Exists{}count — total messages in mailboxA new message arrived
EXPUNGE%Message.Expunge{}seq — sequence number of removed messageA message was permanently removed
FETCH%Message.Fetch{}seq, attrs (map with :flags etc.)Flags changed on a message

Other responses you may see:

ResponseStructKey fields
FLAGS%Mailbox.Flags{}flags — available flags on the mailbox
LIST%Mailbox.List{}name, flags, delimiter
STATUS%Mailbox.Status{}name, messages, unseen, etc.
CAPABILITY%Capability{}capabilities — list of capability strings
OK/NO/BAD%Condition{}status, code (response code), text
BYE%Condition{}status: :bye, text
ENABLED%Enabled{}capabilities — list of enabled extensions
(unknown)%Unhandled{}tokens — raw token list

Common patterns

Tracking mailbox state

{:ok, agent} = Agent.start_link(fn -> %{count: 0} end)

{:ok, conn} =
  Plover.connect("imap.example.com",
    on_unsolicited_response: fn
      %Plover.Response.Mailbox.Exists{count: count} ->
        Agent.update(agent, &Map.put(&1, :count, count))

      %Plover.Response.Message.Expunge{} ->
        Agent.update(agent, &Map.update!(&1, :count, fn c -> max(c - 1, 0) end))

      _ ->
        :ok
    end
  )

Sending to a process

If you need to do more than lightweight bookkeeping, forward notifications to a dedicated process to keep the callback fast:

{:ok, conn} =
  Plover.connect("imap.example.com",
    on_unsolicited_response: fn response ->
      send(MyApp.MailboxWatcher, {:imap_notification, response})
    end
  )

The callback runs inside the Plover.Connection GenServer process, so it should return quickly to avoid blocking IMAP command processing.

Relationship with IDLE

When the connection is in IDLE mode, EXISTS, EXPUNGE, and FETCH notifications are handled by the IDLE callback passed to Plover.idle/2. The :on_unsolicited_response callback is not invoked for those three during IDLE — only the IDLE callback fires. Other untagged responses (FLAGS, LIST, etc.) are not delivered during IDLE.

Once you call Plover.idle_done/1 and resume normal commands, the :on_unsolicited_response callback takes over again.

# During normal commands: on_unsolicited_response fires
{:ok, _} = Plover.noop(conn)

# During IDLE: idle callback fires, on_unsolicited_response does not
:ok = Plover.idle(conn, fn response -> handle_idle(response) end)

Further reading