Skip to main content

Event stream

GET /v1/events streams the server events as Server-Sent Events. A test in any language can open the stream, let the client act, and wait for "the client is idling" or "a script rule fired" without polling.

curl -N 'http://127.0.0.1:8143/v1/events?types=session'
: connected

event: session
data: {"type":"open","session":{"session":1,"user":null,"state":"Not Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}}

event: session
data: {"type":"login","session":{"session":1,"user":"testuser","state":"Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}}

The stream starts with the comment : connected as soon as the server listens for events, so a test can wait for it before it lets the client act. Each event is an event: line with the type and a data: line with JSON, followed by an empty line. A : ping comment every 15 seconds keeps proxies from closing an idle stream. The stream stays open until the client closes it or the server stops, a shutdown (graceful or not) ends it. A graceful shutdown leaves open streams open, and they keep the imapkit command running until their clients close them. Events that happen while no stream is open are not kept.

Choosing event types​

?types= takes a comma separated list. Without it the stream has every type:

TypeWhen
sessiona session opens, logs in, selects, unselects, logs out, waits or closes
commandthe tagged response of a command goes out
mailboxa mailbox is created, deleted, renamed, subscribed or unsubscribed
expungemessages are removed
flagsthe control API or the REST API changed flags
aclan ACL changed (ACL plugin)
scripta script rule fired
resetthe server was reset

An unknown type is refused before the stream starts:

curl -s 'http://127.0.0.1:8143/v1/events?types=session,foo'
# 400 {"error":{"code":"INVALID","message":"Unknown event type foo, expected session, command, mailbox, expunge, flags, acl, script, reset"}}

Payloads​

The data is the JSON form of the server event: mailboxes are storage names, messages are UIDs and sessions are session numbers (origin is null for changes of the control and REST APIs). Below is an excerpt from a real run, with an IMAP client that logs in, selects INBOX, runs a NOOP that a script rule answers, creates a mailbox, stores a flag and logs out, followed by REST calls:

TypeData
session{ type, session, command? }, see session events
command{ session, tag, command, status, user }
mailbox{ type, path, oldPath, origin }
expunge{ path, uids, origin }
flags{ path, messages: [{ uid, flags }], origin }
acl{ path }
script{ rule, event, session, tag, command }
reset{}
event: acl
data: {"path":"INBOX"}

event: session
data: {"type":"open","session":{"session":2,"user":null,"state":"Not Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}}

event: command
data: {"session":2,"tag":"CHEN1","command":"LOGIN","status":"OK","user":"testuser"}

event: session
data: {"type":"login","session":{"session":2,"user":"testuser","state":"Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}}

event: session
data: {"type":"select","session":{"session":2,"user":"testuser","state":"Selected","mailbox":"INBOX","readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}}

event: command
data: {"session":2,"tag":"CHEN3","command":"SELECT","status":"OK","user":"testuser"}

event: script
data: {"rule":{"on":"command","command":"NOOP","times":1,"send":"$TAG NO Scripted\r\n"},"event":"command","session":2,"tag":"CHEN4","command":"NOOP"}

event: mailbox
data: {"type":"create","path":"Projects","oldPath":null,"origin":2}

event: command
data: {"session":2,"tag":"CHEN6","command":"UID STORE","status":"OK","user":"testuser"}

event: session
data: {"type":"close","session":{"session":2,"user":"testuser","state":"Logout","mailbox":"INBOX","readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":null}}

event: flags
data: {"path":"INBOX","messages":[{"uid":105,"flags":["\\Seen"]}],"origin":null}

event: expunge
data: {"path":"INBOX","uids":[105],"origin":null}

event: mailbox
data: {"type":"rename","path":"Done","oldPath":"Projects","origin":null}

event: reset
data: {}

Some things to note in this run:

  • The NOOP that the script rule answered has a script event and no command event.
  • The client's UID STORE has a command event and no flags event. flags is only for flag changes of the control and REST APIs.
  • acl events carry only the path. Read the ACL with GET /v1/mailboxes/{path}/acl.
  • A rule that was added from JavaScript with a RegExp in match shows {} in its place, as JSON has no form for it. Rules added over REST keep the string they were given.

Waiting for an event from Python​

The standard library is enough. Start the server with imapkit -p 1143 --plugin=IDLE --rest-port=8143:

import json
import urllib.request


def events(base, types):
"""Yields (type, data) for every event of the stream"""
response = urllib.request.urlopen(base + "/v1/events?types=" + ",".join(types))
event_type, data = None, []
for raw in response:
line = raw.decode("utf-8").rstrip("\r\n")
if line.startswith("event:"):
event_type = line[6:].strip()
elif line.startswith("data:"):
data.append(line[5:].strip())
elif line == "" and event_type:
yield event_type, json.loads("\n".join(data))
event_type, data = None, []


for event_type, data in events("http://127.0.0.1:8143", ["session"]):
print(event_type, data["type"], data["session"]["mailbox"])
if data["type"] == "select" and data["session"]["mailbox"] == "INBOX":
print("session", data["session"]["session"], "selected INBOX")
break

Output while a client logs in and selects INBOX:

session open None
session login None
session select INBOX
session 1 selected INBOX

In a test, run the generator in a thread (or open the stream before the client starts and read it after), so that the stream is open before the client acts.

Waiting for an event from JavaScript​

Node.js 20 and newer have fetch(), no packages needed. This waits until a client idles and then delivers a message, which the idling client sees as * 1 EXISTS right away:

async function waitForEvent(base, types, predicate) {
const response = await fetch(`${base}/v1/events?types=${types.join(',')}`);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) {
throw new Error('Event stream closed');
}
buffer += value;
let end;
while ((end = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
const type = /^event: (.*)$/m.exec(block)?.[1];
const data = /^data: (.*)$/m.exec(block)?.[1];
if (type && data && predicate(type, JSON.parse(data))) {
await reader.cancel();
return JSON.parse(data);
}
}
}
}

const base = 'http://127.0.0.1:8143';
const event = await waitForEvent(base, ['session'], (type, data) => data.type === 'waiting' && data.command === 'IDLE');
console.log('session %d is idling', event.session.session);
const response = await fetch(`${base}/v1/mailboxes/INBOX/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ raw: 'Subject: new\r\n\r\nHello\r\n' })
});
console.log(response.status, await response.json());
session 1 is idling
201 { uid: 1, uidvalidity: 1 }

Any Server-Sent Events client library works as well, the stream is plain SSE. A test that runs in Node.js against an in-process server can listen to the server events directly instead.

With a token, send Authorization: Bearer <token> on the stream request like on any other.