Events
The server is a Node.js EventEmitter. Its events tell a test what the client is doing, so the test can wait for "the client is idling" or "the client selected INBOX" and then act, instead of sleeping or polling. Tests in other languages get the same events from the REST event stream.
| Event | Arguments | When |
|---|---|---|
session | { type, session, command? } | a session opens, logs in, selects, unselects, logs out, waits or closes |
command | { session, tag, command, status, user } | the tagged response of a command goes out |
mailbox | { type, path, oldPath, mailbox, origin, created? } | a mailbox is created, deleted, renamed, subscribed or unsubscribed |
expunge | (mailbox, messages, origin) | messages are removed |
flags | (mailbox, messages, origin) | the control API changed flags |
acl | (mailbox, previousAcl) | an ACL changed (ACL plugin) |
script | { rule, event, session, tag, command } | a script rule fired |
reset | none | control.reset() restored the server |
Listeners run synchronously, while the server is in the middle of the change. Calling the control API from a listener is fine, its changes have no session as their origin like any other control API call.
session
{ type, session }, where session is the session as sessions() describes it, at the moment of the event. type is one of:
type | When |
|---|---|
open | a client connected |
login | a command authenticated the session (LOGIN, AUTHENTICATE), right after the command event of that command |
select | SELECT or EXAMINE opened a mailbox, also when the same mailbox is selected again |
unselect | the selected mailbox was closed: CLOSE, UNSELECT, a failed SELECT, or a BYE from the server |
logout | the session went back to Not Authenticated (UNAUTHENTICATE) |
waiting | a command waits for client input after its continuation, the event has command too, e.g. IDLE after + idling, or AUTHENTICATE |
close | the connection closed |
A real sequence, from a client that logs in as alice, selects INBOX and starts IDLE:
{"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"}}
{"type":"login","session":{"session":1,"user":"alice","state":"Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}}
{"type":"select","session":{"session":1,"user":"alice","state":"Selected","mailbox":"INBOX","readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}}
{"type":"waiting","session":{"session":1,"user":"alice","state":"Selected","mailbox":"INBOX","readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"},"command":"IDLE"}
In the close event remoteAddress is null, as the socket is gone. After LOGOUT or a BYE, state is Logout.
command
{ session, tag, command, status, user } when the tagged response of a command is about to be written:
| Field | Description |
|---|---|
session | session number |
tag | the command tag |
command | the command name in upper case, UID FETCH for UID commands |
status | OK, NO or BAD |
user | the logged in user after the command, null before login |
{"session":1,"tag":"A1","command":"LOGIN","status":"OK","user":"alice"}
{"session":1,"tag":"A2","command":"SELECT","status":"OK","user":"alice"}
The event fires when the server sends the response, the client may not have read it yet. A command that a script rule answered instead of the command handler has no command event, the script event reports it instead.
mailbox
{ type, path, oldPath, mailbox, origin } for a mailbox change, from a command or the control API:
| Field | Description |
|---|---|
type | create, delete, rename, subscribe or unsubscribe |
path | storage name of the mailbox, the new name for rename |
oldPath | the old name for rename, otherwise null |
mailbox | the removed mailbox object for delete, otherwise null |
origin | the IMAPConnection whose command made the change, null for the control API (and SMTP) |
created | create only: every mailbox the create made, superior levels first |
subscribe and unsubscribe fire only when the subscription changed. Renaming INBOX is a rename from INBOX to the new name.
server.on('mailbox', event => console.log(event.type, event.path, event.created));
server.control.createMailbox('Archive/2024');
// create Archive/2024 [ 'Archive', 'Archive/2024' ]
expunge
(mailbox, messages, origin): the mailbox object, the removed message objects and the session that caused it (null for the control API). It fires for every removal, from EXPUNGE, CLOSE, MOVE, UID EXPUNGE and the control API, before the sessions get their EXPUNGE or VANISHED responses.
server.on('expunge', (mailbox, messages, origin) => {
console.log(
mailbox.path,
messages.map(message => message.uid),
origin && origin.sessionNumber
);
});
flags
(mailbox, messages, origin) for flag changes of control.setFlags(), with only the messages whose flags changed. STORE commands of the sessions do not emit it, watch the command event for them.
acl
(mailbox, previousAcl) when an ACL changes, from SETACL, DELETEACL or the control API, with the ACL plugin loaded. previousAcl is a Map of identifier to a Set of rights. Read the new ACL with control.getAcl(mailbox.path).
script
{ rule, event, session, tag, command } when a script rule fires: the rule as it was added, the event it watched (greeting, command, input, response ...), the session number, and the tag and command name of the command it belongs to. See Scripted faults.
reset
No arguments. Emitted by control.reset() after the storage and users are restored. Plugins listen to it to restore their own state, and a long running test harness can use it to clear what it tracks.
Recipes
Wait for one event
events.once() from Node.js resolves with the arguments of the next event. Add a filter for a particular event:
import { once } from 'node:events';
function waitFor(server, name, predicate = () => true) {
return new Promise(resolve => {
const listener = (...args) => {
if (predicate(...args)) {
server.removeListener(name, listener);
resolve(args[0]);
}
};
server.on(name, listener);
});
}
const [opened] = await once(server, 'session'); // the next session event, whatever it is
Create the promise before you let the client act, so the event can not fire before the listener is in place.
Deliver a message while the client idles
const server = imapkit({ plugins: ['IDLE'] });
const port = await server.start();
const idling = waitFor(server, 'session', event => event.type === 'waiting' && event.command === 'IDLE');
// ... start the client, let it select INBOX and IDLE
await idling;
server.control.addMessage('INBOX', { raw: 'Subject: hello\r\n\r\nHi!\r\n' });
// the idling client gets * 1 EXISTS right away
Wait for the client to select a mailbox
const selected = waitFor(server, 'session', event => event.type === 'select' && event.session.mailbox === 'INBOX');
// ... let the client open INBOX
const { session } = await selected;
server.control.expungeMessages('INBOX', [1]); // or anything the client must cope with while INBOX is open
Wait for a command to finish
const stored = waitFor(server, 'command', event => event.command === 'UID STORE' && event.status === 'OK');
// ... let the client mark a message as read
await stored;
assert.deepStrictEqual(server.control.getMessage('INBOX', 1, { raw: false }).flags, ['\\Seen']);
Wait for a session to go away
const closed = waitFor(server, 'session', event => event.type === 'close' && event.session.session === 1);
server.control.disconnect(1, { reset: true });
await closed;