ImapKit is a scriptable, in-memory IMAP server for testing IMAP clients. It implements IMAP4rev1 (RFC 3501) and, as an optional plugin, IMAP4rev2 (RFC 9051), with more than 50 extensions that can be turned on and off per server instance. Nothing is ever written to disk: the mailbox tree comes from a JSON object, so every new server starts from the same known state.
ImapKit is strict on purpose: it answers client input that breaks the RFCs with BAD or NO, so client bugs show up in your test suite instead of in production (see Strict by design).
Homepage: imapkit.com. ImapKit requires Node.js 20 or newer.
ImapKit is maintained by the team behind EmailEngine, a self-hosted email API that turns Gmail, Microsoft 365, and IMAP accounts into REST endpoints, with managed OAuth2 and webhooks for incoming mail. If you need a production email integration rather than a mock IMAP server for tests, start there.
Formerly Hoodiecrow. ImapKit was published as
hoodiecrow-imapup to version 3.3.1. To migrate, installimapkitinstead and userequire('imapkit'). The command is nowimapkitand its environment variables start withIMAPKIT_instead ofHOODIECROW_. The API, plugins and storage format are unchanged.
Install ImapKit globally with npm and run it:
npm install -g imapkit
imapkit -p 1143Point your IMAP client to localhost:1143 and log in with user name testuser and password testpass. Without -p the server listens on port 143 (993 with --secure), which usually needs root privileges.
imapkit --smtpPort=1025 also starts an SMTP server that appends every message it receives to INBOX. SMTP needs the optional smtp-server package, which is not installed with ImapKit: npm install -g smtp-server (or npm install smtp-server next to a local install).
Run imapkit --help to see all command line options, the environment variables (IMAPKIT_PORT, IMAPKIT_PLUGINS, ...) and sample configuration data. For example, imapkit -p 1143 --plugin=IDLE,MOVE,CONDSTORE --storage=storage.json loads three plugins and the mailboxes of storage.json.
Add imapkit dependency
npm install imapkitCreate and start an IMAP server
import imapkit from 'imapkit';
// or with CommonJS: const imapkit = require('imapkit');
const server = imapkit(options);
server.listen(1143);ImapKit is written in TypeScript and ships both ES modules and CommonJS, each with type declarations. The package exports the imapkit(options) factory as its default export, the IMAPServer and IMAPConnection classes, and types such as IMAPServerOptions, Plugin, CommandHandler, Mailbox and Message for writing plugins:
import imapkit, { type IMAPServerOptions, type Plugin } from 'imapkit';ImapKit needs Node.js 20 or newer. It also runs on the latest Bun and Deno releases (npm:imapkit in Deno), CI runs the whole test suite on both.
See complete.js for an example, control-api.js for a client test that changes the server state with the control API, and rest-api.py for the REST API from Python.
ImapKit is a single user, multiple connection IMAP server. Changes made over IMAP live only in the memory of that server instance, so start a new server for every test.
Several clients can connect to the server simultaneously but all the clients share the same user account, even if login credentials are different. The ACL plugin can limit what users other than the owner can do (see ACL).
ImapKit is extendable: any command can be overridden and plugins can be added (see Creating custom plugins, and src/commands and src/plugins for the built-in commands and plugins). To test a client against a broken or unusual server, script rules make the server misbehave at chosen points.
ImapKit is meant for developing standards compliant IMAP clients, so it follows the RFCs strictly instead of accepting whatever clients send. Most production servers are lenient, which hides client bugs until the client meets a stricter server. ImapKit answers these with BAD (or NO where the RFC requires it):
- commands sent in the wrong state (RFC 3501 section 3), for example
FETCHbeforeSELECTorLOGINandAUTHENTICATE(with any mechanism, known or not) after login - arguments to commands that take none (
NOOP x,CLOSE x), missing or extra arguments, and values that break the RFC 3501 grammar - command lines that end with a bare LF instead of CRLF
- literal data sent before the server's
+continuation request (RFC 3501 section 4.3);{n+}is only accepted when LITERAL+ or LITERAL- is enabled, and with LITERAL- only up to 4096 octets, a larger one is answered withBAD [TOOBIG](RFC 7888 section 5) - literals for unknown commands, or for commands that can not run in the current state, are refused without a continuation request
- mailbox names that are not valid modified UTF-7 (RFC 3501 section 5.1.3), including 8-bit names, and CREATE or RENAME to names with an empty hierarchy level (
foo//bar,/foo,foo//), answered withNO [CANNOT](RFC 5530 section 3) - invalid sequence sets (
0,abc), message sequence numbers greater than the number of messages in FETCH, STORE, COPY and MOVE, also*in an empty mailbox (RFC 3501 section 9, seq-number; UID sets and SEARCH keys can point past the end), flags that are not atoms,\Recentin STORE or APPEND, invalid dates - 8-bit SEARCH strings without
CHARSET UTF-8, invalid UTF-8, unsupported charsets (NO [BADCHARSET]) - SORT and THREAD (RFC 5256 section 5) with a charset that is not an atom or a quoted string, an empty sort criteria list,
REVERSEthat is not followed by a sort key (REVERSE REVERSE DATE), or a threading algorithm that is not an atom - invalid base64 in SASL exchanges, and anything other than
DONEwhile IDLE - 8-bit user names or passwords in
LOGIN(RFC 9755 section 5: UTF-8 user names needAUTHENTICATE), and invalid UTF-8 in anAUTHENTICATE PLAINmessage (RFC 4616 section 2). User names are unicode strings everywhere: the keys ofusers, SASL user names and ACL identifiers - OAUTHBEARER client responses that break the RFC 7628 or GS2 (RFC 5801) grammar, and anything other than a single
%x01after an OAUTHBEARER error result STARTTLSandCOMPRESSwith commands pipelined after them (RFC 9051 section 6.2.1, RFC 4978 section 3), TLS or compression is then not started and the pipelined commands are refused withBADwithout running, andCOMPRESSwhile compression is active (BAD [COMPRESSIONACTIVE])- pipelined commands that RFC 3501 section 5.5 calls ambiguous, for example
CHECKfollowed byFETCHwithout waiting for theCHECKresult ENABLEafterSELECTorEXAMINE(RFC 5161 section 3.1), andIDlists that break the RFC 2971 limits- the QRESYNC
SELECTparameter or theVANISHEDmodifier withoutENABLE QRESYNC,VANISHEDwithFETCHor withoutCHANGEDSINCE, and QRESYNC values that break the RFC 7162 grammar (UIDVALIDITY or mod-sequence0,*in the UID sets, sequence match sets that are not ascending or not of the same size) - unknown
SEARCH RETURNoptions orRETURNafterCHARSET(RFC 4466 section 2.6.1),$combined with numbers, andSEARCH MODSEQvalues or entry names that break the RFC 7162 grammar - extended LIST commands (RFC 5258) with unknown options,
RECURSIVEMATCHwithout a base option likeSUBSCRIBED(also(SPECIAL-USE RECURSIVEMATCH), RFC 6154 section 6), an empty pattern list, options with values they do not take, a repeatedSTATUSreturn option with different items, and invalidSTATUSitems (RFC 5819) CREATE ... (USE (...))(CREATE-SPECIAL-USE) with entries that are not an atom starting with a backslash, likeNIL, quoted strings orSent(RFC 6154 section 6:use-attr-ext = "\" atom); an attribute the server does not support getsNO [USEATTR](section 3)- METADATA entry names that break RFC 5464 section 3.2 (
//, a trailing/,*,%, 8-bit or control characters, a scope other than/privateor/shared), values that are atoms or use bare CR or LF as line ends, empty entry or option lists, and GETMETADATA options after the mailbox name (errata 2785) - unknown or uppercase ACL rights, and empty identifiers or identifiers with control characters or invalid UTF-8 (RFC 4314 section 3)
- more than one message in
APPENDwithout MULTIAPPEND, and with MULTIAPPEND a zero-length message literal cancels the wholeAPPENDwithNO(RFC 3502) - CATENATE URLs that are not absolute-path references (
/INBOX/;UID=1), including relative-path references like;UID=1that RFC 5092 section 7.2 forbids, and URLs of message parts that do not exist (NO [BADURL ...]) - with UIDONLY (RFC 9586): every command that takes message sequence numbers, and sequence sets in search criteria, answered with
BAD [UIDREQUIRED] UIDAFTERandUIDBEFORE(MESSAGELIMIT, RFC 9738 section 3.2) with anything but a single UID- with UTF8=ACCEPT (RFC 9755): invalid UTF-8 in quoted strings,
SEARCH CHARSETafterENABLE UTF8=ACCEPT, mailbox names with control characters (UTF-8, or encoded in modified UTF-7 like&AA0-), U+2028, U+2029, a leading BOM, unassigned code points or a name that is not in Unicode Normalization Form C, andNOforAPPENDof a message with an 8-bit header beforeENABLE UTF8=ACCEPT(section 4) - literal8 (
~{n}) anywhere but in an APPEND or REPLACE message with BINARY, or a SETMETADATA value with METADATA, refused without a continuation request; NUL octets in a normal literal; a literal8TEXTpart in CATENATE - with BINARY:
BINARY[],BINARYof multipart or message/rfc822 parts (RFC 9051 section 6.4.5 allows leaf body parts only),HEADER,TEXTorMIMEsections, and a partial range onBINARY.SIZE - with NOTIFY (RFC 5465): MessageNew without MessageExpunge or the other way round, FlagChange without both (section 5), mailbox events or two selected filters with
selected/selected-delayed(section 6.1), fetch attributes outside the selected filters, empty event or mailbox lists,NOTIFY SETwithout event groups; unknown events getNO [BADEVENT (...)]listing the supported ones (section 3.1) - after
ENABLE IMAP4rev2(RFC 9051):CHECK,LSUB, theRFC822,RFC822.HEADERandRFC822.TEXTFETCH items, theNEW,OLDandRECENTSEARCH keys and theRECENTSTATUS item, none of which are in the RFC 9051 grammar (Appendix E), numbers above 63 bits in LARGER and SMALLER, and invalid UTF-8 or mailbox names that are not Net-Unicode (section 5.1); before it, 8-bit quoted strings (Appendix A) and partial ranges, LARGER and SMALLER values above 32 bits (RFC 3501 section 9 number)
Responses follow the grammar strictly too: strings that can not be quoted are sent as literals. Failures carry the RFC 5530 response codes that RFC 9051 section 7.1 lists, in both protocol revisions: AUTHENTICATIONFAILED and AUTHORIZATIONFAILED for logins, ALREADYEXISTS, NONEXISTENT, CANNOT, HASCHILDREN and NOPERM for mailbox operations, TRYCREATE when the target of APPEND, COPY or MOVE does not exist or is a \Noselect name, CLIENTBUG for STATUS on the selected mailbox and for STORE, EXPUNGE, UID EXPUNGE, MOVE and REPLACE in a mailbox selected read-only, and EXPUNGEISSUED when FETCH, STORE, SEARCH, SORT or THREAD (or UID SEARCH, UID SORT or UID THREAD with message numbers in the criteria) completes while the EXPUNGE of another session can not be reported yet.
Some client side recommendations of RFC 2683 are checked too: STATUS on the selected mailbox gets CLIENTBUG (section 3.1.1), mailbox names must be valid modified UTF-7 (section 3.4.2), and EXPUNGE or STORE after EXAMINE, which answered [READ-ONLY], get NO (section 3.3.2). Command lines are not limited to the 1000 octets that section 3.2.1.5 suggests for clients, the server accepts up to 1 MiB (the section asks servers for at least 8000 octets) and answers a longer line with BAD.
ImapKit follows these of the strategies that RFC 2180 (IMAP4 Multi-Accessed Mailbox Practice) allows, so a client can be tested against one consistent behavior:
- a session that has not been told about the EXPUNGE of another session yet keeps its message numbers. FETCH still returns the expunged messages (section 4.1.1) and SEARCH still finds them (section 4.3), both end with
OK [EXPUNGEISSUED] - STORE does not change expunged messages: with
.SILENTit ends withOK(section 4.2.1), otherwise the other messages are stored and get their FETCH responses, and the tagged response isNO [EXPUNGEISSUED](sections 4.2.2 and 4.2.3; with CONDSTORENO [MODIFIED ...]when that applies, RFC 7162 section 3.1.3) - COPY and MOVE of a set that includes an expunged message copy nothing and return the pending EXPUNGE responses with
NO [EXPUNGEISSUED](section 4.4.1) - UID commands report the pending EXPUNGE responses before they run (RFC 3501 section 7.4.1), the UIDs of the expunged messages then no longer exist and are ignored (RFC 3501 section 6.4.8). UID SEARCH (UID SORT, UID THREAD) with message numbers in its criteria still uses the old numbers and ends with
OK [EXPUNGEISSUED] - a command that refers to messages (FETCH, STORE, SEARCH, COPY, MOVE, SORT, THREAD and the UID commands) first reports the new messages (EXISTS) and flag changes of other sessions, so every message number in its responses is one the client was told about (RFC 3501 section 5.2: "A server MUST send mailbox size updates automatically if a mailbox size change is observed during the processing of a command"). While an EXPUNGE is pending, only what was queued before it goes out, a message that arrived after it is announced after the EXPUNGE, and until then its number is answered with BAD (RFC 3501 section 9, seq-number)
- the EXISTS responses of new messages are followed by
* n RECENTwith the number of\Recentmessages of the session (RFC 3501 section 7.3.2), also for its own APPEND, COPY and MOVE into the selected mailbox, but not afterENABLE IMAP4rev2 - DELETE of a mailbox that other sessions have selected disconnects them with
* BYE(section 3.3) - RENAME keeps the messages of the mailbox under the new name, sessions that have it selected keep working, the old name no longer exists (section 3.4)
- a session that ends without LOGOUT or CLOSE does not expunge anything (RFC 2683 section 3.1.2), and there is no inactivity timeout
By default the only account is user name "testuser" with password "testpass" (and access token "testtoken" for XOAUTH2 and OAUTHBEARER). The users option replaces the default account list, for example { "testuser": { "password": "testpass" }, "otheruser": { "password": "secret" } }, and server.control.addUser() adds users at runtime (see Control API). All users share the same mailbox tree.
All RFC 3501 commands are supported. Some choices that the RFCs leave to the server:
- The subscription list holds names, not mailboxes (RFC 3501 section 6.3.6). DELETE does not unsubscribe, so LSUB and
LIST (SUBSCRIBED)keep listing the name (as\NonExistentin extended LIST) until UNSUBSCRIBE, and a mailbox created again under that name is subscribed. RENAME leaves the subscription with the old name (RFC 9051 section 6.3.6). A mailbox from the storage object is subscribed unless it has"subscribed": false, a new mailbox is not. SUBSCRIBE refuses names that are not mailboxes, UNSUBSCRIBE accepts any name - CREATE
a/balso createsaas a normal mailbox if it does not exist (RFC 3501 section 6.3.3, Dovecot creates a\Noselectlevel instead). An existing\Noselectlevel stays\Noselect - DELETE of a mailbox with children leaves a
\Noselectlevel that keeps nothing but the children, CREATE of that name makes a new mailbox with a new UIDVALIDITY - A keyword stays in the FLAGS and PERMANENTFLAGS of a mailbox once a message in it had the keyword, also after that message is expunged (RFC 3501 section 7.2.6, like Dovecot). In a mailbox with
"allowPermanentFlags": falseSTORE accepts exactly the flags PERMANENTFLAGS lists (permanentFlagsand the flags its messages have or had) and ignores the others, APPEND and COPY leave them out (RFC 3501 section 7.1) - An obsolete source route in an address (
<@route.example:a@b.c>, RFC 5322 section 4.4) goes to the at-domain-list field of the ENVELOPE address ((NIL "@route.example" "a" "b.c")), the mailbox name is the local part only (RFC 9051 section 7.5.2), like Dovecot sends it
Plugins can be enabled when starting the server but can not be unloaded or loaded when the server is already running.
All plugins are self contained and not tied to core. If you do not enable a plugin, no trace of it is left
to the system. For example, if you do not enable CONDSTORE, messages do not have a MODSEQ value set.
Plugin names are case insensitive and capability spellings like LITERAL+ or AUTH=PLAIN are accepted too.
An unknown plugin name throws an error, and a plugin listed more than once is loaded only once.
- ACL Adds ACL [RFC4314] capability with
RIGHTS=texk(SETACL, DELETEACL, GETACL, LISTRIGHTS and MYRIGHTS), and LIST-MYRIGHTS [RFC8440] when LIST-EXTENDED is loaded. See ACL below - APPENDLIMIT Adds APPENDLIMIT [RFC7889] capability. The server option
appendLimit(octets) sets the limit for every mailbox and is advertised asAPPENDLIMIT=<n>. A mailbox in the storage can set its ownappendLimit(a number, ornullfor no limit), then the capability is advertised without a value and clients read the limits withSTATUS (APPENDLIMIT). Larger messages in APPEND and REPLACE fail withNO [TOOBIG], synchronizing literals are refused before the client sends them - AUTH-PLAIN Adds AUTH=PLAIN capability. Supports SASL-IR [RFC4959] as well
- BINARY Adds BINARY [RFC3516] support:
BINARY[<part>]<<partial>>,BINARY.PEEKandBINARY.SIZEFETCH items that remove base64 and quoted-printable encodings (NO [UNKNOWN-CTE]for other encodings), and APPEND, MULTIAPPEND and REPLACE with literal8 messages (~{n},~{n+}with LITERAL+ or LITERAL-). Decoded data is sent as a literal8 only when it contains NUL. Binary parts of an appended message, and parts with NUL octets, are stored base64 encoded, soBODY[]stays valid IMAP4rev1 - COMPRESS Adds COMPRESS=DEFLATE [RFC4978] capability. Raw DEFLATE in both directions after the tagged OK, every burst of responses ends with a sync flush
- CATENATE Adds CATENATE [RFC4469] and URL-PARTIAL [RFC5550] capabilities. APPEND (and REPLACE) can build a message from literals and IMAP URLs of messages or message parts on the server. Only absolute-path URLs are accepted, for example
/INBOX;UIDVALIDITY=1/;UID=2/;SECTION=1.MIME/;PARTIAL=0.100, other URLs and URLs that do not resolve fail withNO [BADURL ...]. A message over the literal size limit fails withNO [TOOBIG]. Plugins can refuse URLs of a mailbox throughserver.urlAccessChecks - CONDSTORE Adds CONDSTORE [RFC7162] support, including the
SEARCH MODSEQsearch key and theCLOSEDresponse code - CONTEXT=SEARCH Adds CONTEXT=SEARCH [RFC5267] capability, also loads ESEARCH: the
UPDATE,CONTEXTandPARTIALresult options of SEARCH and UID SEARCH, and the CANCELUPDATE command. WithUPDATEthe session getsADDTOandREMOVEFROMESEARCH updates as messages start or stop matching, whether this or another session changed them (REMOVEFROM comes before the EXPUNGE response, ADDTO after EXISTS and FETCH). Updates end with CANCELUPDATE or when the mailbox is closed. Server optionmaxSearchContexts(default 10) limits the updating searches of a session, above it the server answers withNO [NOUPDATE "tag"].CONTEXTis accepted as a hint and ignored. Message numbers in the search program are taken as they were when the search ran (RFC 5267 section 4.3) - CONTEXT=SORT Adds CONTEXT=SORT [RFC5267] capability, also loads ESORT and CONTEXT=SEARCH:
UPDATE,CONTEXTandPARTIALfor SORT and UID SORT, the updates carry context positions in sort order - CREATE-SPECIAL-USE Enables CREATE-SPECIAL-USE [RFC6154] capability. Allowed special flags can be set with server option
"special-use" - ESEARCH Adds ESEARCH [RFC4731] capability:
SEARCH RETURN (MIN MAX ALL COUNT)andUID SEARCH RETURN (...)answer with an ESEARCH response. With CONDSTORE the response includesMODSEQfor aMODSEQsearch - ESORT Adds ESORT [RFC5267] capability, also loads SORT and ESEARCH:
SORT RETURN (MIN MAX ALL COUNT)andUID SORT RETURN (...)answer with an ESEARCH response in sort order. With SEARCHRES,SAVEworks for SORT as well - ENABLE Adds ENABLE capability [RFC5161]. Can be loaded in any order with the plugins it enables (eg. CONDSTORE). Capability names are matched case-insensitively, the ENABLED response lists them as the server advertises them (
IMAP4rev2,UTF8=ACCEPT) - ID Adds ID [RFC2971] capability
- IDLE Adds IDLE [RFC2177] capability
- IMAP4rev2 Adds IMAP4rev2 [RFC9051], advertised next to IMAP4rev1 (Appendix A). Loads the extensions that IMAP4rev2 folds in (ENABLE, NAMESPACE, UNSELECT, UIDPLUS, ESEARCH, SEARCHRES, IDLE, SASL-IR, LIST-EXTENDED, LIST-STATUS, MOVE, BINARY, SPECIAL-USE, STATUS=SIZE, AUTH=PLAIN, and LITERAL- unless LITERAL+ is loaded), and keeps the
$Forwarded,$MDNSent,$Junk,$NotJunkand$Phishingkeywords also in mailboxes that do not allow new keywords. Every session starts as IMAP4rev1; afterENABLE IMAP4rev2it follows RFC 9051:STATUS DELETEDis allowed,SEARCHanswers withESEARCH,SELECTandEXAMINEsend an untaggedLISTresponse for the mailbox and* OK [CLOSED]when they close one, but noRECENTresponse,[UNSEEN]code or\Recentflag, mailbox names and quoted strings are UTF-8, SEARCH assumes UTF-8 when noCHARSETis given, a message with 8-bit headers can be appended, message/global parts are described and numbered like message/rfc822, and the items that RFC 9051 removed are BAD (see Strict by design). STARTTLS and LOGINDISABLED (RFC 9051 section 6.1.1) are not loaded, as they change how clients log in; load them when needed. Partial FETCH ranges and the LARGER and SMALLER search keys take 63-bit numbers (number64) after ENABLE, 32-bit ones before it (partial ranges up to 2^53 - 1, the largest exact JavaScript number). A server that advertises only IMAP4rev2, UTF-8 in response text, and OLDNAME are not implemented - LIST-EXTENDED Adds LIST-EXTENDED [RFC5258]: selection options
SUBSCRIBED,REMOTE(there are no remote mailboxes) andRECURSIVEMATCH, return optionsSUBSCRIBEDandCHILDREN, multiple mailbox patterns and theCHILDINFOextended data item.\Noselectmailboxes are listed as\NonExistentin extended LIST responses. With SPECIAL-USE loaded, theSPECIAL-USEselection and return options [RFC6154] combine with the other options. The plain RFC 3501 LIST is not changed - LIST-STATUS Adds LIST-STATUS [RFC5819], the
STATUSreturn option of LIST. Loads LIST-EXTENDED as well - LITERALMINUS Enables LITERAL- [RFC7888] capability: non-synchronizing literals up to 4096 octets. A larger one is read and dropped, and the command is answered with
BAD [TOOBIG]. Can not be loaded together with LITERALPLUS - LITERALPLUS Enables LITERAL+ [RFC7888] capability. Can not be loaded together with LITERALMINUS, but replaces the LITERAL- that IMAP4rev2 loads
- LOGINDISABLED Disables LOGIN support for unencrypted connections
- MESSAGELIMIT Adds MESSAGELIMIT [RFC9738] capability, advertised as
MESSAGELIMIT=<n>, where the server optionmessageLimitsets n (default 1000, any positive number is accepted so that small test mailboxes can hit it). FETCH, STORE, SEARCH, MOVE, UID EXPUNGE and their UID variants only work on the n messages with the highest UIDs (UID EXPUNGE counts the\Deletedones) and add[MESSAGELIMIT n uid]with the lowest processed UID to the tagged OK, or send it in an untaggedNOwhen the tagged OK already has a response code (likeHIGHESTMODSEQorMODIFIED). SEARCH counts the searched messages, which its top level sequence set,UID,UIDAFTERandUIDBEFOREkeys narrow down. COPY, APPEND (MULTIAPPEND), SORT and THREAD of more messages, and a FETCHPARTIALrange (PARTIAL plugin) of more messages, fail withNO [MESSAGELIMIT ...]. EXPUNGE, CLOSE and STATUS are not limited. Adds theUIDAFTERandUIDBEFOREsearch keys. Can not be loaded together with SAVELIMIT - METADATA Adds METADATA [RFC5464] capability (GETMETADATA and SETMETADATA) for server and mailbox annotations, and loads ENABLE, which RFC 5464 section 4.1 requires for unsolicited METADATA responses. Values can be binary: SETMETADATA takes a literal8 (
~{n}), and values with NUL are sent back as a literal8. Initial mailbox entries come from ametadataobject on the mailbox in storage ("INBOX": { "metadata": { "/private/comment": "My comment" } }), server entries from themetadataoption. Server optionsmetadataMaxSize(largest value in octets, default 65536),metadataMaxEntries(entries per mailbox and for the server, default 100) andmetadataPrivate: false(refuse/privateentries with[METADATA NOPRIVATE]) let you test the client's error handling./shared/adminon the server is read-only. Annotations move with RENAME (renaming INBOX copies them), DELETE removes them. AfterENABLE METADATA(needs the ENABLE plugin), changes made by other sessions are announced with unsolicitedMETADATAresponses. With SPECIAL-USE loaded, the read-only/private/specialuseentry shows the special-use attributes of a mailbox (RFC 6154 section 4) - MULTISEARCH Adds MULTISEARCH [RFC7377] capability, also loads ESEARCH: the ESEARCH command, also in the authenticated state.
ESEARCH IN (mailboxes "a" subtree "b" subtree-one "c" personal subscribed inboxes selected) RETURN (...) criteriasends one ESEARCH response with UIDs and theTAG,MAILBOXandUIDVALIDITYcorrelators for every mailbox with matches. Mailboxes that do not exist or are\Noselectare skipped (with ACL also those without therright, and withoutlunless named undermailboxesor as a subtree root), a mailbox named twice is searched once, andinboxesis INBOX.SAVEis only allowed when the selected mailbox is the only one searched,UPDATE(with CONTEXT=SEARCH) only applies to the selected mailbox - METADATA-SERVER Same as METADATA (also loads ENABLE), but only for server annotations (mailbox name
"") - MOVE Adds MOVE [RFC6851] capability (MOVE and UID MOVE commands)
- MULTIAPPEND Adds MULTIAPPEND [RFC3502] capability. APPEND takes several messages and appends all or none of them. With UIDPLUS, APPENDUID lists the UIDs as a UID set
- NAMESPACE Adds NAMESPACE [RFC2342] capability
- NOTIFY Adds NOTIFY [RFC5465] capability:
NOTIFY SET [STATUS] (filter events) ...andNOTIFY NONEwith theselected,selected-delayed,inboxes(same aspersonal),personal,subscribed,subtreeandmailboxesfilters and the MessageNew (with fetch attributes for the selected mailbox), MessageExpunge, FlagChange, MailboxName (LIST withOLDNAMEfor RENAME) and SubscriptionChange events, plus MailboxMetadataChange and ServerMetadataChange with METADATA. Events are sent as soon as they happen, also between commands, while a command runs they wait for its tagged response, and EXPUNGE (or VANISHED) waits longer withselected-delayedand during FETCH, STORE and SEARCH. For a new message in the selected mailbox an IMAP4rev1 session gets EXISTS, the requested FETCH and then RECENT. After the first NOTIFY a session only hears about the events it asked for, also for the selected mailbox; changes made by the session itself are not reported. Other mailboxes are reported with STATUS (UNSEEN when the\Seencount changed, HIGHESTMODSEQ when CONDSTORE is enabled), with ACL only mailboxes with thelandrrights, and granting or revokinglcounts as MailboxName. Fetch attributes never set\Seen.server.notifyOverflow([connection])sends* OK [NOTIFICATIONOVERFLOW]and turns NOTIFY off. AnnotationChange (no ANNOTATE support) is refused withNO [BADEVENT], the fetch attributes of the CONTEXT=SEARCHUPDATEoption (RFC 5465 section 7) are not supported - OAUTHBEARER Adds AUTH=OAUTHBEARER [RFC7628] capability, with or without SASL-IR. Uses the same credentials as XOAUTH2: access token
"testtoken", the authzid in the GS2 header (n,a=testuser,) is optional. A failed login gets the JSON error result as a continuation request (invalid_tokenorinvalid_request), the client must answer it withAQ==(a single%x01) - OBJECTID Adds OBJECTID [RFC8474] capability:
MAILBOXIDfor CREATE, SELECT, EXAMINE and STATUS,EMAILIDandTHREADIDfor FETCH and SEARCH. Ids are generated (F1,M1,T1, ...) unless the storage sets aMAILBOXIDfor a mailbox or anEMAILID/THREADIDfor a message. COPY, MOVE and RENAME INBOX keep the EMAILID and THREADID of a message. Messages are threaded by theirMessage-ID,In-Reply-ToandReferencesheaders across all mailboxes, a message joins the thread of the nearest known parent when it is added - PARTIAL Adds PARTIAL [RFC9394] capability, also loads ESEARCH: the
PARTIALresult option of SEARCH (RETURN (PARTIAL 1:100),RETURN (PARTIAL -1:-100)counts from the last result) and of SORT with ESORT, and thePARTIALmodifier of FETCH and UID FETCH (UID FETCH 1:* (FLAGS) (PARTIAL -1:-50)), which combines with CHANGEDSINCE. With PARTIAL loaded a command takes only one PARTIAL or ALL result option - PREVIEW Adds PREVIEW [RFC8970] capability (the PREVIEW FETCH data item with the LAZY modifier). Previews are generated from the first text/plain or text/html part (text/plain preferred in multipart/alternative, attachments, attached messages and encrypted content are skipped): transfer encoding and charset are decoded, HTML markup and quoted text are removed, whitespace is collapsed and the result is cut to 200 characters. A message in storage can set its own
"preview"string instead.PREVIEW (LAZY)returns NIL until the preview of the message has been generated by a FETCH without LAZY, or comes from storage - QUOTA Adds QUOTA [RFC9208] capability with
GETQUOTA,GETQUOTAROOT,SETQUOTA(QUOTASET), theSTORAGE,MESSAGEandMAILBOXresources and theDELETEDandDELETED-STORAGESTATUS items. INBOX and the personal namespaces share one quota root, other namespaces have none. Configure it with thequotaserver option, eg.{ "root": "User quota", "STORAGE": 10240, "MESSAGE": 1000, "MAILBOX": 100, "soft": false }(STORAGE is in units of 1024 octets, a missing resource is not limited). APPEND, COPY and MOVE (from outside the quota root) fail withNO [OVERQUOTA]when they would go over a limit, and CREATE or RENAME INBOX when they would go over the MAILBOX limit. With"soft": truethey succeed with an untaggedNO [OVERQUOTA]warning instead.SETQUOTAchanges the limits at runtime - REPLACE Adds REPLACE [RFC8508] capability (REPLACE and UID REPLACE commands). With UIDPLUS, APPENDUID is sent in an untagged OK before the EXPUNGE. With QUOTA only the net usage counts (RFC 8508 section 3.4)
- SASL-IR Enables SASL-IR [RFC4959] capability
- QRESYNC Adds QRESYNC [RFC7162] capability, also loads CONDSTORE and ENABLE. After
ENABLE QRESYNC:SELECT/EXAMINEwith(QRESYNC (uidvalidity modseq [known-uids] [seq-match-data]))reportsVANISHED (EARLIER)and the changed flags,UID FETCH ... (CHANGEDSINCE n VANISHED)works, and expunges (EXPUNGE, UID EXPUNGE, MOVE, other sessions, IDLE) are reported withVANISHEDinstead ofEXPUNGE. Expunged UIDs are remembered with their mod-sequence; UIDs missing from the initial storage count as expunged before the server started - SAVELIMIT Adds SAVELIMIT [RFC9738] capability, advertised as
SAVELIMIT=<n>(server optionmessageLimit, default 1000): only COPY and APPEND (MULTIAPPEND) of more messages fail withNO [MESSAGELIMIT ...]. Can not be loaded together with MESSAGELIMIT - SAVEDATE Adds SAVEDATE [RFC8514] capability: the
SAVEDATEFETCH item and theSAVEDBEFORE,SAVEDON,SAVEDSINCEandSAVEDATESUPPORTEDSEARCH keys. APPEND, COPY and MOVE set the save date to the current time, messages in storage can set it with aSAVEDATEvalue (a date-time string or a Date) and get the the time the storage was loaded otherwise (the current time, or thenowoption). A mailbox with"SAVEDATE": falsein storage does not support save dates: FETCH returns NIL and the SEARCH keys use the internal date - SEARCHRES Adds SEARCHRES [RFC5182] capability, also loads ESEARCH:
SEARCH RETURN (SAVE)stores the result and$refers to it in FETCH, STORE, COPY, MOVE, UID EXPUNGE, SEARCH and their UID variants.$must be used alone, not combined with numbers like1,$ - SORT Adds SORT [RFC5256] capability (SORT and UID SORT with all RFC 5256 sort keys). Strings are compared with the i;unicode-casemap collation (RFC 5051), base subjects follow RFC 5256 section 2.1 and sent dates section 2.2. With CONDSTORE, a MODSEQ search key appends the highest mod-sequence (RFC 7162 section 3.1.9). I18NLEVEL=1 is not advertised, as SEARCH matches strings with ASCII case folding only
- SORT=DISPLAY Adds SORT=DISPLAY [RFC5957] capability (DISPLAYFROM and DISPLAYTO sort keys), also loads SORT
- SPECIAL-USE Enables SPECIAL-USE [RFC6154] capability Mailboxes need to have a "special-use" property (String or Array) that will be used as extra flag for LIST and LSUB responses
- STARTTLS Adds STARTTLS command
- STATUS=SIZE Adds STATUS=SIZE [RFC8438], the
SIZEstatus item (also with LIST-STATUS). The plugin file isstatus-size - THREAD=ORDEREDSUBJECT Adds THREAD=ORDEREDSUBJECT [RFC5256] capability (THREAD and UID THREAD)
- THREAD=REFERENCES Adds THREAD=REFERENCES [RFC5256] capability (THREAD and UID THREAD), the full REFERENCES algorithm of RFC 5256 section 3. Load both THREAD plugins to support both algorithms
- UIDONLY Adds UIDONLY [RFC9586] capability and loads ENABLE. After
ENABLE UIDONLY, FETCH, STORE, SEARCH, COPY, MOVE, SORT, THREAD and REPLACE, message numbers in the criteria of UID SEARCH, UID SORT, UID THREAD and the ESEARCH command (MULTISEARCH), and the QRESYNC message sequence match data are refused withBAD [UIDREQUIRED](a synchronizing literal of such a command is refused before it is sent). Every FETCH response becomes a* <uid> UIDFETCH (...)response (theUIDitem is only included when UID FETCH asks for it), expunges are reported withVANISHED, and SELECT does not send[UNSEEN n]. EXISTS and RECENT are not changed. Load UIDPLUS for UID EXPUNGE and COPYUID - UIDPLUS Adds UIDPLUS [RFC4315] capability (APPENDUID, COPYUID and UID EXPUNGE)
- UNAUTHENTICATE Adds UNAUTHENTICATE [RFC8437] capability. Returns to the Not Authenticated state and resets the session: the selected mailbox is closed without expunging, ENABLEd extensions and CONDSTORE are turned off, and COMPRESS ends after the tagged OK. TLS stays
- UNSELECT Adds UNSELECT [RFC3691] capability
- UTF8=ACCEPT Adds UTF8=ACCEPT [RFC9755] capability and loads ENABLE. After
ENABLE UTF8=ACCEPTmailbox names are UTF-8 in both directions (storage keeps modified UTF-7 names, so&is an ordinary character), strings that are valid UTF-8 are sent quoted, and SEARCH strings are UTF-8 withoutCHARSET. UTF8=ONLY, the obsoleteAPPEND ... UTF8 (...)data item of RFC 6855 and downgrading of 8-bit headers for clients that did not enable UTF-8 (RFC 9755 section 8) are not implemented - X-GM-EXT-1 Adds Gmail specific extensions.
X-GM-MSGIDandX-GM-THRIDwork with FETCH and SEARCH (every message is its own thread unless the storage sets anX-GM-THRIDvalue for it; with OBJECTID loaded, the messages of aTHREADIDshare theX-GM-THRIDof the first message of that thread, so both thread ids group the same messages).X-GM-LABELSworks with FETCH, STORE (+,-,.SILENT) and SEARCH: system labels are atoms that start with\(\Inboxfor INBOX, the special-use attribute for special-use mailboxes), other labels are mailbox names, sent and read in the form the session uses for mailbox names (modified UTF-7, or UTF-8 afterENABLE UTF8=ACCEPT) and quoted when they are not atoms. In SEARCH a label that starts with\is a system label. Setting a label does not change message behavior, for example the message does not get copied to another mailbox.X-GM-RAWsupports a subset of the Gmail search syntax: words and"phrases"(TEXT),-term,OR,( ),{ },from:,to:,cc:,bcc:,subject:,label:,in:(inbox,sent,drafts,trash,spam,anywhereor a label),is:(read,unread,starred,important),larger:andsmaller:(withkorm),after:andbefore:(YYYY/MM/DD) andrfc822msgid:. Other Gmail operators (has:,older_than:...) are answered with NO - XOAUTH2 Gmail XOAUTH2 login. Needs SASL-IR (load the SASL-IR plugin too), Gmail itself does not. Use
"testuser"as the user name and"testtoken"as the access token to log in.
All users share the same mailbox tree. With the ACL plugin, the owner (server option aclOwner, "testuser" by default) has every right on every mailbox, and every other user only gets the rights that the ACL of a mailbox grants to their user name or to anyone, minus the negative rights of -username and -anyone (RFC 4314 section 2). ACLs come from the acl property of a mailbox in the storage, or from SETACL:
{
"INBOX": { "acl": { "otheruser": "lrs", "anyone": "l" } },
"": { "folders": { "Shared": { "acl": { "otheruser": "lrswikte", "-otheruser": "t" } } } }
}The rights of other users are enforced as RFC 4314 section 4 describes:
- LIST and LSUB leave out mailboxes without
l. SELECT, EXAMINE and STATUS needr, SUBSCRIBE needsl - a mailbox is opened READ-ONLY without any of
i,e,s,wandt, and PERMANENTFLAGS only lists the flags the user can change - STORE changes only the flags the user has rights for (
sfor\Seen,tfor\Deleted,wfor the others) and answersNO [NOPERM]if it could change none of them; a FETCH withoutsdoes not set\Seen - APPEND and COPY need
ion the target and keep only the flags the user has rights for. MOVE also needstandeon the source (RFC 6851 section 4.2), and so does REPLACE (RFC 8508 section 4.1). APPEND and REPLACE are refused before the message literal is sent, a target the user can not see like a missing one - EXPUNGE needs
e, CLOSE withoutecloses the mailbox without expunging - CREATE needs
kon the nearest existing parent (so other users can not create top level mailboxes), DELETE needsx, RENAME needsxon the mailbox andkon the new parent - GETACL, SETACL, DELETEACL and LISTRIGHTS need
a, MYRIGHTS needs any ofl,r,i,k,x,a - with LIST-STATUS, mailboxes without
rget no STATUS response and are listed with\Noselect(RFC 5819 section 2) - with METADATA, GETMETADATA and SETMETADATA on a mailbox need
land any ofr,s,w,i,p(RFC 5464 section 3.3), and unsolicited METADATA responses only go to sessions with these rights - with QUOTA, GETQUOTAROOT only lists the MAILBOX resource without
ron the mailbox, and SETQUOTA needsaon every mailbox of the quota root (RFC 9208 section 6)
Missing rights are answered with NO [NOPERM], or with the same error as for a mailbox that does not exist when the user does not have l either, so the existence of the mailbox is not disclosed (RFC 4314 section 6). The rights on the selected mailbox are taken when it is selected. A new mailbox inherits the ACL of its parent and DELETE removes the ACL. The obsolete c and d rights are accepted as kx and et and are added to ACL and MYRIGHTS responses (RFC 4314 section 2.1.1). The rights of the owner can not be changed.
ImapKit 5.0.0 has two breaking changes:
- SMTP (
--smtpPort, thesmtpoption) needs thesmtp-serverpackage, it is an optional peer dependency now:npm install smtp-server. - The XTOYBIRD plugin was removed, the IMAP port carries IMAP traffic only. Loading
XTOYBIRDthrows an error that points here.
The XTOYBIRD commands map to the control API:
| XTOYBIRD command | Control API |
|---|---|
XTOYBIRD STORAGE |
server.control.snapshot() |
XTOYBIRD SERVER |
server.control.listMailboxes(), server.control.listUsers() |
XTOYBIRD CONNECTION |
server.control.sessions() |
XTOYBIRD USERADD "user" "password" |
server.control.addUser('user', { password }), or updateUser() for an existing user |
XTOYBIRD USERDEL "user" |
server.control.deleteUser('user') |
XTOYBIRD SHUTDOWN |
await server.control.shutdown(), stops accepting connections and waits for the last client |
- All messages have MODSEQ value
- CONDSTORE can be ENABLEd
- SELECT/EXAMINE show HIGHESTMODSEQ
- SELECT/EXAMINE support (CONDSTORE) option
- Updating flags increments MODSEQ value
- FETCH (MODSEQ) works
- FETCH (CHANGEDSINCE modseq) works
- STORE (UNCHANGEDSINCE modseq) works, messages changed since then are reported with
[MODIFIED ...] - SEARCH MODSEQ works, the entry name and type are checked but ignored since MODSEQ is not stored per flag
- Flag changes made by other sessions include MODSEQ once CONDSTORE is enabled
- SELECT/EXAMINE send
* OK [CLOSED]when they close the selected mailbox
- anonymous namespaces are not supported
- LIST does not insert a hierarchy delimiter between a reference without one and the mailbox name (RFC 2683 section 3.4.9 recommends it), the two are concatenated as RFC 9051 section 6.3.9 describes, like Dovecot does. A pattern that starts with the
#break out character ignores the reference - CHARSET values other than US-ASCII and UTF-8 are not supported
The source is TypeScript in src/, npm run build compiles it into dist/esm (ES modules) and dist/cjs (CommonJS). Tests use the built-in Node.js test runner (TypeScript test files run through tsx), linting uses ESLint and the TypeScript compiler, and formatting uses Prettier.
npm install
npm test # lint, type check, build and all tests
npm run test:unit # tests only
npm run test:coverage # tests with coverage (Node.js 22.8 or newer)
npm run test:bun # tests under Bun
npm run test:deno # tests under Deno
node --import tsx --test test/fetch.test.ts # a single test file
npm run format # apply Prettier formatting
compare/ holds a development tool that replays the same IMAP commands against ImapKit and a Dovecot server running in Docker and shows where the responses differ (npm run dovecot:start, then npm run compare -- compare/scenarios/fetch.txt). See CLAUDE.md for details.
The storage option (or --storage=<path> for the command) describes the mailbox tree. The keys are namespaces.
storage.json:
{
"INBOX": {},
"INBOX.": {},
"user.": {
"type": "user"
},
"": {
"type": "shared"
}
}storage.json:
{
"INBOX": {},
"": {
"separator": "/",
"folders": {
"[Gmail]": {
"flags": ["\\Noselect"],
"folders": {
"All Mail": {
"special-use": "\\All"
},
"Drafts": {
"special-use": "\\Drafts"
},
"Important": {
"special-use": "\\Important"
},
"Sent Mail": {
"special-use": "\\Sent"
},
"Spam": {
"special-use": "\\Junk"
},
"Starred": {
"special-use": "\\Flagged"
},
"Trash": {
"special-use": "\\Trash"
}
}
}
}
}
}Creating your tests in Node.js is a piece of cake, you do not even need to run the imapkit command. Here is a sample test using the built-in Node.js test runner.
import { describe, it, beforeEach, afterEach } from 'node:test';
import imapkit from 'imapkit';
import myIMAPClient from '../my-imap-client.js';
describe('IMAP tests', () => {
let server;
// Executed before every test, creates a new blank IMAP server
// on a random free port
beforeEach(
() =>
new Promise(resolve => {
server = imapkit();
server.listen(0, resolve);
})
);
// Executed after every test, closes the IMAP server created for the test
afterEach(() => new Promise(resolve => server.close(resolve)));
// A new IMAP client is instantiated that tries to connect to the
// IMAP server. If the client is connected the test is considered as passed.
it('Connect to the server', (t, done) => {
const client = myIMAPClient.connect('localhost', server.address().port);
client.on('ready', () => {
client.disconnect();
done();
});
});
});server.control changes and inspects the server from your test, without an IMAP session. Every change reaches the connected sessions the way a change by another session would: a selected session gets EXISTS (and RECENT in IMAP4rev1) for a new message, EXPUNGE (or VANISHED after ENABLE QRESYNC) for a removed one, an unsolicited FETCH with the UID and the new flags (with MODSEQ after ENABLE CONDSTORE), and BYE when its mailbox is deleted. NOTIFY and CONTEXT=SEARCH sessions get their updates too. ACL does not apply to the control API, but every argument is checked.
const server = imapkit({ plugins: ['IDLE', 'CONDSTORE'] });
const port = await server.start(); // a free port, server.start(1143, '127.0.0.1') for a fixed one
const { uid } = server.control.addMessage('INBOX', { raw: 'Subject: hello\r\n\r\nHi!\r\n', flags: ['\\Seen'] });
server.control.setFlags('INBOX', [uid], ['\\Flagged'], 'add');
server.control.expungeMessages('INBOX', [uid]);
await server.stop();Mailboxes are addressed by their storage name (modified UTF-7, the name a client uses in LIST), messages by mailbox and UID. The methods return plain data and throw an ImapKitError (exported by the package) whose code is NONEXISTENT, ALREADYEXISTS, INVALID, or the RFC 5530 code of a failed mailbox operation (CANNOT, HASCHILDREN ...).
| Method | What it does |
|---|---|
snapshot() |
The storage as JSON in the shape of the storage option, imapkit({ storage: server.control.snapshot() }) starts from the same state |
listMailboxes(), getMailbox(path) |
Mailboxes with path, delimiter, flags, selectable, subscribed, messages, unseen, uidnext, uidvalidity, permanentFlags (and highestModseq with CONDSTORE) |
listMessages(path, { uids, raw }), getMessage(path, uid) |
Messages with uid, flags, internaldate, size (and modseq with CONDSTORE), the source as a Buffer in raw |
sessions() |
Connected sessions: session number, user, state, selected mailbox, readOnly, enabled, secure, compressed, remoteAddress |
addMessage(path, { raw, flags, internaldate }, { checks }) |
Adds a message like a delivery, returns { uid, uidvalidity }. A string raw is encoded as UTF-8. checks: true refuses the message like APPEND would (QUOTA OVERQUOTA, APPENDLIMIT TOOBIG) |
setFlags(path, uids, flags, mode) |
mode is set (default), add or remove |
expungeMessages(path, uids) |
Removes messages |
copyMessages(path, uids, target), moveMessages(...) |
Returns { uidvalidity, uids: [{ uid, targetUid }] } |
replaceMessage(path, uid, { raw, flags, internaldate }) |
Adds the new message and expunges the old one. The content of a UID never changes (RFC 9051 section 2.3.1.1), so the new message gets a new UID |
createMailbox(path, { subscribed }), deleteMailbox(path), renameMailbox(path, newPath) |
Like CREATE, DELETE and RENAME |
resetUidValidity(path, { uidvalidity, uids, offset, seed }) |
Gives the mailbox a new, greater UIDVALIDITY. uids is keep (default), renumber (1 to n), shuffle (1 to n in a random order, repeatable with seed, so an old UID points to another message) or offset (every UID moves above the old UIDNEXT, plus offset, so old UIDs find nothing). A UID must not change during a session (RFC 9051 section 2.3.1.1), so sessions that have the mailbox selected get BYE. Returns { uidvalidity, uidnext, uids: [{ uid, newUid }] } |
subscribe(path), unsubscribe(path) |
Return true if the subscription changed |
listUsers(), addUser(name, { password, xoauth2 }), updateUser(name, {...}), deleteUser(name) |
xoauth2 is { accessToken, sessionTimeout }, sessionTimeout is deprecated and ignored (access tokens do not expire). Deleting a user disconnects its sessions, { disconnect: false } keeps them. No credentials in listUsers() |
disconnect(session or { user }, { text, reset }) |
Disconnects sessions with an untagged BYE, or resets the TCP connection |
inject(session, data) |
Writes bytes to a session as they are, e.g. * OK [ALERT] ... between commands |
reset() |
Restores the mailboxes and users of the server options and disconnects every session with BYE, so a long running server can be reused between tests. Script rules stay, with their hits counts |
shutdown({ graceful }) |
Stops accepting connections and resolves once the last client is gone, graceful: false closes the sessions |
Plugins add operations for their own data, they exist only when the plugin is loaded:
| Plugin | Methods |
|---|---|
| ACL | getAcl(path), setAcl(path, identifier, rights) (+rights adds, -rights removes, empty rights remove the identifier), deleteAcl(path, identifier), ACLs are { identifier: rights } |
| QUOTA | getQuota() ({ root, limits, usage }, STORAGE in units of 1024 octets), setQuota({ STORAGE, MESSAGE, MAILBOX }) replaces the limits |
| METADATA, METADATA-SERVER | getMetadata(path), setMetadata(path, { entry: value }) ("" is the server, null removes an entry, also the read-only /shared/admin). Sessions after ENABLE METADATA get unsolicited METADATA |
| SPECIAL-USE | setSpecialUse(path, ['\\Sent']) (RFC 6154 attributes and \Important), getMailbox() has specialUse |
| OBJECTID | getMailbox() has mailboxId, getMessage() and listMessages() have emailId and threadId |
A plugin of your own adds operations with server.control.register(name, fn, routes), the routes go to the REST API.
server.start(port, host) resolves with the port, server.stop() closes the server and every session. listen() and close() still work. With the smtp option (imapkit({ smtp: { port, host } })) start() also starts an SMTP server that appends every message it receives to INBOX, server.smtpServer is that server. It needs the optional smtp-server package (npm install smtp-server), start() rejects with an error that says so when it is missing.
Tests can wait for events instead of polling:
session:{ type, session },typeisopen,login,select(also when the same mailbox is selected again),unselect(CLOSE, UNSELECT, a failed SELECT, BYE),logout(UNAUTHENTICATE),waiting(a command waits for client input, withcommand, e.g.IDLEafter its continuation) orclose,sessionis the session assessions()describes it.command:{ session, tag, command, status, user }when the tagged response of a command goes out,statusisOK,NOorBAD. A line that is refused before it runs (it does not parse, or it is pipelined ambiguously) and a command that a script rule answers have nocommandevent.mailbox:{ type, path, oldPath, mailbox, origin }for CREATE, DELETE, RENAME, SUBSCRIBE and UNSUBSCRIBE,originis null for the control API.expunge:(mailbox, messages, origin)before the sessions are told, andflags:(mailbox, messages, origin)for flag changes of the control API.acl:(mailbox, previousAcl)when the ACL of a mailbox changes (ACL plugin),script:{ rule, event, session, tag, command }when a script rule fires,reset: aftercontrol.reset().
const idling = new Promise(resolve => server.on('session', event => event.type === 'waiting' && event.command === 'IDLE' && resolve(event)));
// ... let the client start IDLE ...
await idling;
server.control.addMessage('INBOX', { raw: message }); // the idling client gets * n EXISTS right awayThe REST API is the control API over HTTP, for test suites in any language. It is off unless the rest option (imapkit({ rest: { port, host, token } }) with server.start()) or --rest-port turns it on:
imapkit -p 1143 --rest-port=8143
curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/messages \
-H 'Content-Type: application/json' \
-d '{"raw": "Subject: hello\r\n\r\nHi!\r\n", "flags": ["\\Seen"]}'The REST API controls the whole server, including the mail of every user, so it is careful by default. It listens on 127.0.0.1 unless host (--rest-host) says otherwise, and any other address than a loopback one needs a bearer token (token, --rest-token, IMAPKIT_REST_TOKEN) that every request sends as Authorization: Bearer <token>. Without a token it only answers requests for a loopback host name. It sends no CORS headers and takes JSON bodies only (Content-Type: application/json, at most 64 MiB), so a web page can not call it. Do not expose it outside a test environment.
Requests and responses are JSON, and every POST needs Content-Type: application/json, also without a body (HTTP 415 otherwise). Errors are { "error": { "code", "message" } } with HTTP status 404 for NONEXISTENT, 409 for ALREADYEXISTS and the response codes of a failed mailbox operation (CANNOT, HASCHILDREN ...), 400 for INVALID, 401 for a missing or wrong token, 404 NOTFOUND and 405 METHOD for an unknown route or method, 413 TOOBIG for a body over 64 MiB (and addMessage checks refused by APPENDLIMIT), 500 for a server error. A mailbox is its storage name (modified UTF-7) in the URL, URL encoded with a / in the name as %2F (/v1/mailboxes/Work%2FProjects). Message sources are base64 in responses ("encoding": "base64"), a request sends raw as text or as base64 with "encoding": "base64". GET /v1/openapi.json describes every endpoint.
GET /v1/events streams the events as Server-Sent Events (event: <type> with JSON data), so a test in another language can wait for "the client selected INBOX" or "a script rule fired" without polling. ?types=session,command chooses the types (session, command, mailbox, expunge, flags, acl, script, reset), mailboxes are paths, messages UIDs and sessions numbers in the data:
curl -N 'http://127.0.0.1:8143/v1/events?types=session'
# event: session
# data: {"type":"select","session":{"session":1,"user":"testuser","state":"Selected","mailbox":"INBOX",...}}| Endpoint | Control API |
|---|---|
GET /v1/snapshot |
snapshot() |
POST /v1/reset |
reset() |
POST /v1/shutdown ({ graceful }) |
shutdown() |
GET /v1/sessions, DELETE /v1/sessions/{n} ({ text, reset }) |
sessions(), disconnect() |
POST /v1/sessions/{n}/inject ({ data, encoding }) |
inject() |
GET, POST /v1/users, PUT, DELETE /v1/users/{name} |
listUsers(), addUser(), updateUser(), deleteUser() |
GET, POST /v1/mailboxes, GET, DELETE /v1/mailboxes/{path} |
listMailboxes(), createMailbox(), getMailbox(), deleteMailbox() |
POST /v1/mailboxes/{path}/rename ({ newPath }) |
renameMailbox() |
PUT, DELETE /v1/mailboxes/{path}/subscription |
subscribe(), unsubscribe() |
POST /v1/mailboxes/{path}/uidvalidity |
resetUidValidity() |
GET /v1/mailboxes/{path}/messages (?uids=1,2&raw=true), POST ({ raw, flags }) |
listMessages(), addMessage() |
GET, DELETE /v1/mailboxes/{path}/messages/{uid} |
getMessage(), expungeMessages() |
POST /v1/mailboxes/{path}/messages/flags, .../expunge, .../copy, .../move |
setFlags(), expungeMessages(), copyMessages(), moveMessages() |
GET /v1/mailboxes/{path}/acl, PUT, DELETE /v1/mailboxes/{path}/acl/{identifier} ({ rights }) |
getAcl(), setAcl(), deleteAcl() (ACL) |
GET, PUT /v1/quota |
getQuota(), setQuota() (QUOTA) |
GET, PUT /v1/metadata, GET, PUT /v1/mailboxes/{path}/metadata |
getMetadata(), setMetadata() (METADATA) |
PUT /v1/mailboxes/{path}/special-use ({ specialUse }) |
setSpecialUse() (SPECIAL-USE) |
GET, POST, DELETE /v1/script/rules, DELETE /v1/script/rules/{id} |
script rules in their JSON form, so runtime faults need no restart |
ImapKit is strict and correct by default. To test how a client copes with a server that is not, script rules make the server deviate from the protocol at chosen points: answer a command with a canned response, send a literal where a quoted string is expected, cut a response in the middle of a literal, delay or split output, or drop the connection. Rules come from the script server option (a rule or a list of rules), or are added at runtime with server.script.add():
const server = imapkit({
plugins: ['IDLE'],
script: [
// the first SELECT gets NO, the next ones run as usual
{ on: 'command', command: 'SELECT', times: 1, send: '$TAG NO [UNAVAILABLE] Try again later\r\n' },
// the body of message 1 is cut short and the connection dropped
{ on: 'response', command: 'FETCH', match: /^\* 1 FETCH .*BODY\[\]/, truncate: 40 }
]
});
// rules added later return handles
const [literals, late] = server.script.add([
// every string the grammar allows is sent as a literal, valid IMAP that a client must handle
{ on: 'response', untagged: true, literals: true },
// the FETCH response of UID 2 arrives after the tagged OK, like some servers do now and then
{ on: 'response', command: 'UID FETCH', untagged: true, match: /UID 2\b/, times: 1, defer: 'tagged' }
]);
// ... run the client
assert.strictEqual(late.hits, 1);
literals.remove();Every rule watches one event (on):
greeting: the* OKgreeting of a new connectioncommand: a complete command line (with its literals) from the client. The rule acts instead of the parser and the command handler, so it also matches lines that do not parse and commands that do not exist. The rule is chosen when the line arrives (matchers likestatesee the session at that moment), and acts in the order of the commands, so pipelined responses stay in orderinput: a line read by a command that takes over the input, likeDONEof IDLE or a SASL response of AUTHENTICATEresponse: every response the server sends withconnection.send(), tagged and untagged, as the exact bytes that are about to go out, after every plugin and the core changed the responsecontinuation: a+continuation request (literals, IDLE, AUTHENTICATE)quiet: the session had no input and no output forquietFormilliseconds (required for this event). The output of the rule starts the next quiet time. During IDLE the event belongs to the IDLE command, so{ on: 'quiet', command: 'IDLE', quietFor: 1800000, send: '* BYE Autologout; idle for too long\r\n', close: true }is an autologout, and{ on: 'quiet', state: 'Selected', quietFor: 500, times: 1, send: '* OK [ALERT] System shutdown in 10 minutes\r\n' }an ALERT between commands. A quiet rule cansendandclose
The matchers of a rule all have to match. Rules are checked in the order they were added, the first rule that matches and is not used up handles the event, so a later rule can handle what an earlier one leaves alone.
| Matcher | Events | Matches |
|---|---|---|
command |
all but greeting | the command name, or a list of names, case-insensitive ('UID FETCH'). An unsolicited response belongs to the command that runs, or that reads input (IDLE) |
tag |
all but greeting | the command tag, a string or a RegExp |
description |
response, continuation | the description passed to connection.send(), or a list of them. Continuation requests are LITERAL, IDLE, AUTHENTICATE PLAIN and AUTHENTICATE OAUTHBEARER |
untagged |
response | true for untagged responses only, false for tagged ones |
session |
all | the number of the connection, or a list of numbers, 1 for the first connection the server accepted |
state, user, mailbox |
all | the session state ('Not Authenticated', 'Authenticated', 'Selected'), the authenticated user, the path of the selected mailbox |
match |
all | a RegExp, or a string with a regular expression, tested against the command line or the output bytes (a binary string) |
when |
all | a function that gets the event context and returns true to match |
nth |
all | the rule fires from the nth matching event on (default 1) |
times |
all | the rule fires this many times at most, then lets later rules handle the event |
chance |
all | the rule fires on a matching event with this probability (0 to 1). The random numbers come from the scriptSeed option, so a run with the same seed and the same client is repeated exactly |
The actions say what happens instead of the usual behavior. Strings are sent as they are (binary strings, one character per octet, or UTF-8 when they have characters above U+00FF) without an added CRLF, and $TAG in a string is replaced with the tag of the command. A Buffer is sent as it is, a function gets the event context and returns a string or a Buffer.
| Action | Events | Effect |
|---|---|---|
send |
all | output events: bytes sent instead of the output. command and input: bytes sent instead of processing the line |
run |
command, input | process the line as usual after send (to add output before the real response) |
drop |
all but quiet | output events: send nothing. command and input: ignore the line, the client gets no answer |
mutate |
response, continuation | (response, context) gets a copy of the response object before it is compiled and changes it, or returns another one. Continuation requests have a response object only when a plugin sends them with connection.send() (the error challenges of XOAUTH2 and OAUTHBEARER), other events ignore mutate. A tagged response that does not compile then is sent as NO [SERVERBUG], an untagged one is dropped. Use send for output that is not valid IMAP |
literals |
response | sends every string of the response that the grammar allows (string, nstring, astring) as a literal. The positions that take only a quoted string stay quoted: the hierarchy delimiter of LIST, LSUB and NAMESPACE, CHILDINFO values, INTERNALDATE and SAVEDATE, and the media types "TEXT" and "MESSAGE" "RFC822" in a body structure (RFC 9051 section 9, RFC 5258 section 6). Atoms, response codes and text stay as they are. Works together with mutate, after it |
defer |
response | holds an untagged response back: 'tagged' sends it right after the tagged response of its command, 'next' with the answer to the next command, before its first response. Needs untagged: true. send, before and after change the held output, drop, delay, chunk, truncate and close can not be combined with it. A response that does not belong to a command (one that arrives in IDLE belongs to IDLE) is held for the next tagged response or command. Held responses are dropped when the connection closes |
before, after |
output events | bytes sent before or after the output, e.g. an unsolicited response |
delay |
all but input, quiet | milliseconds to wait before the output goes out, all later output waits behind it. For a command, the wait before the rule acts or the command runs, later commands wait too |
chunk, chunkDelay |
all | write the bytes in pieces of chunk octets, chunkDelay milliseconds apart (default 10). chunkDelay: 0 or 'tick' sends each piece after the earlier one was handed to the system, on the next event loop turn, so the pieces leave as separate TCP segments without a wall clock delay (on Deno a zero timer, about 2 ms a piece) |
truncate |
all | send only this many octets of the bytes, then close the connection |
close |
all | close the connection after the bytes are sent, 'reset' destroys the socket instead (a TCP RST where the runtime supports it), 20 ms after the bytes so that the RST does not overtake them. Input that arrives meanwhile is not processed |
A rule needs at least one action, and for command and input events chunk and truncate need send. A rule with only delay (and run) delays the line and then processes it as usual. Rules are checked when they are added: an unknown option, an option that does not apply to the event, or an invalid value throws a TypeError, so a typo can not turn into a rule that never fires.
The event context, which when, mutate and send functions get, has event, connection, session, state, user, mailbox, tag, command, data (the command line or the output bytes, as a binary string), quiet (milliseconds without input or output, quiet events), and for responses description and response.
server.script.add(rule) returns a handle with rule, matched (events that matched the rule, also before nth), hits (events the rule handled) and remove(), server.script.add([rules]) returns a list of handles. server.script.rules lists the handles in order, server.script.clear() removes every rule. The server emits a script event { rule, event, session, tag, command } every time a rule fires.
The imapkit command takes the rules as JSON with --script=<path> (or IMAPKIT_SCRIPT), or as script in the --config file. JSON rules use strings for match and send, functions (when, mutate, function values of send) work only from JavaScript:
[
{ "on": "greeting", "send": "* BYE Too many connections\r\n", "close": true, "times": 1 },
{ "on": "response", "command": "FETCH", "untagged": true, "send": "* 1 FETCH (BODY[] {100}\r\nshort", "close": true }
]Faults change only the output and the handling of the lines a rule matches, the state of the server stays consistent: a LOGIN answered by a rule with OK does not log the session in, and a dropped EXPUNGE response still removes the message. COMPRESS works with delayed output, as the output keeps the compression layer it was sent with. Script rules are for tests only, a rule can send anything.
The quirks option (--quirk for the imapkit command) turns on named presets that make the server behave like a known real server, so a client test reproduces that server's bug in every run, without the server itself. A preset is a set of script rules, after the rules of the script option, and plugins it leaves out. The presets are exported as data (import { quirks } from 'imapkit'), copy one into your own script rules to adjust it.
| Quirk | Behavior |
|---|---|
james-fetchgroup |
Apache James FetchGroup: only the first section asked for a part in one FETCH is answered, later ones for the same part are empty (BODY[2.MIME] BODY[2] gives a zero-length body) |
james-late-fetch |
Apache James: 1 in 4 FETCH responses come after the tagged OK of their command |
yahoo-quoted-sections |
Yahoo: short body sections (up to 100 octets without line breaks) are quoted strings instead of literals |
m365-throttle |
Microsoft 365: 1 in 10 commands (not LOGOUT) is refused with BAD Request is throttled. Suggested Backoff Time: 1000 milliseconds |
no-uidplus, no-move |
servers without UIDPLUS or MOVE, the plugins are not loaded even when plugins lists them, and with IMAP4rev2 (which requires both, RFC 9051 Appendix E) the server constructor throws |
const server = imapkit({ plugins: ['IDLE', 'MOVE'], quirks: ['james-fetchgroup', 'm365-throttle'], scriptSeed: 42 });scriptSeed: the seed of the random numbers of script rules withchanceand the quirk presets that use them (--script-seed).now: the time the server uses for the dates it sets itself, the INTERNALDATE of a message without one and SAVEDATE: a Date, a timestamp, or a function that returns one. The dates are formatted in the time zone of the process.resetUidValidity(path, { uids: 'shuffle', seed })of the control API.
The storage option is checked when the server is built: a key that looks like a typo of a known one (message for messages, uidValidity) or a wrong type fails with the path of the problem, e.g. Invalid storage at "INBOX".messages[2]: unknown key "flag", did you mean "flags"?. A message internaldate (or SAVEDATE) must be an RFC 3501 date-time string like "14-Sep-2013 21:22:28 -0300" or a valid Date, a Date header value like "Thu, 1 Jan 2026 10:00:00 +0000" is refused. Plugins keep their own data on mailboxes and messages, so other keys are allowed. The package exports the check as validateStorage(storage) and the shape as a JSON Schema, storageSchema, for editors and fixture tooling. server.control.snapshot() returns the same shape.
A plugin can be a string as a pointer to a built in plugin or a function. Plugin function is run when the server is created and gets server instance object as an argument.
imapkit({
// Add two plugins, built in "IDLE" and custom function
plugins: ['IDLE', myAwesomePlugin]
});
// Plugin handler
function myAwesomePlugin(server) {
// Add a string to the capability listing
server.registerCapability('XSUM');
/**
* Add a new command XSUM
* If client runs this command, the response is a sum of all
* numeric arguments provided
*
* A1 XSUM 1 2 3 4 5
* * XSUM 15
* A1 OK SUM completed
*
* @param {Object} connection - Session instance
* @param {Object} parsed - Input from the client in structured form
* @param {String} data - Input command as a binary string
* @param {Function} callback - callback function to run
*/
server.setCommandHandler('XSUM', function (connection, parsed, data, callback) {
// Send untagged XSUM response
connection.send(
{
tag: '*',
command: 'XSUM',
attributes: [
[].concat(parsed.attributes || []).reduce(function (prev, cur) {
return prev + Number(cur.value);
}, 0)
]
},
'XSUM',
parsed,
data
);
// Send tagged OK response
connection.send(
{
tag: parsed.tag,
command: 'OK',
attributes: [
// TEXT allows to send unquoted
{ type: 'TEXT', value: 'XSUM completed' }
]
},
'XSUM',
parsed,
data
);
callback();
});
}server.registerCapability(name[, availability])
Where
- name a string displayed in the capability response
- availability a function which returns boolean value. Executed before displaying the capability response. If the function returns true, the capability is displayed, if false then not.
Example
// Display in CAPABILITY only in Not Authenticated state
server.registerCapability('XAUTH', function (connection) {
return connection.state === 'Not Authenticated';
});server.setCommandHandler(name, handler[, options])
Where
-
name is the command name
-
handler (connection, parsed, data, callback) is the handler function for the command
-
options is an optional object, checked by the server before the handler runs:
- states lists the connection states the command is valid in (
'Not Authenticated','Authenticated','Selected'), any state if not set. A plain list is read as the states - noArguments if true, the command is refused when it has arguments
- mailboxArguments lists the positions of arguments that are mailbox names, these must be valid modified UTF-7 (RFC 3501 section 5.1.3)
- astringArguments lists the positions of other astring arguments (user names, identifiers). In these, in mailbox names and in search criteria an atom
NILreaches the handler as an atom, not asnull - searchCriteria is the position where SEARCH style criteria start, sequenceSet the position of an argument with message sequence numbers. Both are used for the RFC 3501 section 5.5 pipelining check
- noExpunge if true, EXPUNGE responses are held back while the command runs (like FETCH, STORE and SEARCH, RFC 3501 section 7.4.1), the notifications queued before them are still sent
- literal8 if true (or the name of the capability that allows it), the command accepts
~{n}literals (RFC 3516) - noPipelining if true, the command is refused with BAD when the client sent more input after it (STARTTLS, COMPRESS), and so are the commands sent with it
- appendMessage if true, the command takes a message after its mailbox argument like APPEND (REPLACE), so a message literal to a missing mailbox is refused with
NO [TRYCREATE]before it is sent
Without options, a command that already exists (such as a built-in one that the handler wraps) keeps its settings.
- states lists the connection states the command is valid in (
server.getCommandOptions(name) -> Object
server.getCommandStates(name) -> Array|false
getCommandOptions returns the options of a command (see above) with every key set, getCommandStates only the connection states it is valid in (false for a command of a plugin that did not limit them).
A plugin that wraps commands or handlers of other plugins, whatever the load order, does it once all plugins are loaded:
server.once('pluginsLoaded', function () {
const move = server.getCommandHandler('MOVE');
// ...
});Handler arguments
- connection - Session instance
- parsed - Input from the client in structured form (see imap-handler for reference)
- data - Input command as a binary string
- callback - callback function to run (does not take any arguments)
The command should send data to the client with connection.send()
connection.send(response, description, parsed, data, /* any additional data */)
Where
- response is a imap-handler compatible object. To get the correct tag for the OK, NO or BAD response, look into
parsed.tag - description is a string identifying the response to be used by other plugins
- parsed is the
parsedargument passed to the handler - data is the
dataargument passed to the handler - additional arguments can be used to provide input for other plugins
To override existing commands you should first cache the existing command, so you can use it in your own command handler.
server.getCommandHandler(name) -> Function
Where
- name is the function name
Example
const list = server.getCommandHandler('LIST');
server.setCommandHandler('LIST', function (connection, parsed, data, callback) {
// do something
console.log('Received LIST request');
// run the cached command
list(connection, parsed, data, callback);
});If your plugin needs to get direct input from the client, you can reroute the incoming data by defining a connection.inputHandler function. The function gets input data as complete lines (without the linebreaks). Once you want to reroute the input back to the command handler, just clear the function.
connection.inputHandler = function(line){
console.log(line);
connection.inputHandler = false;
}
See idle.ts for an example
Raw output, such as a + continuation request, goes through connection.write(data), and connection.end() closes the connection once all output is written. Do not use connection.socket for this, a COMPRESS layer (connection.transport) sits between the protocol and the socket.
UNAUTHENTICATE (RFC 8437) returns a connection to the Not Authenticated state. A plugin that keeps per-session state on the connection object adds a handler that clears it:
server.resetHandlers.push(function (connection) {
connection.mySessionState = false;
});Any response sent to the client can be overridden or cancelled by other handlers. You should append your handler to server.outputHandlers array. If something is being sent to the client, the response object is passed through all handlers in this array.
Output handlers are meant for extensions that change valid responses. They run before the core finishes the response (response codes like EXPUNGEISSUED, mailbox names, the 7-bit status text) and before the compiler, which refuses output that is not valid IMAP. To make the server send something wrong on purpose, use script rules instead, they see the final bytes.
server.outputHandlers.push(function(connection, /* arguments from connection.send */){})
response arguments from connection.send is an object and thus any modifications will be passed on. If skipResponse property is added to the response object, the data is not sent to the client.
// All untagged responses are ignored and not passed to the client
server.outputHandlers.push(function (connection, response, description) {
if (response.tag === '*') {
response.skipResponse = true;
console.log('Ignoring untagged response for %s', description);
}
});server.messageHandlersandserver.mailboxHandlersrun on every message and mailbox when it is loaded from storage or created,(server, message, mailbox)and(server, mailbox)server.statusHandlers[ITEM]returns the value of a STATUS item that a plugin adds toserver.allowedStatus,(connection, mailbox, status)wherestatusholds the counters ofserver.getStatus()server.appendChecksare consulted before APPEND, COPY and MOVE add messages to a mailbox,(connection, mailbox, messages, options). A check returns nothing to allow it, or{ code, text }to fail the command with a taggedNO [code] text({ code, text, soft: true }only sends an untaggedNOwarning)server.closedChecksare consulted when SELECT or EXAMINE closes the selected mailbox,(connection). If any returns true,* OK [CLOSED]marks where the responses for the new mailbox startserver.copyHandlersrun when COPY, MOVE or RENAME INBOX copies a message,(server, source, properties, mailbox). Properties set onpropertiesare given to the copy before the message handlers run
It is possible to append messages to a mailbox; create, delete and rename mailboxes; change authentication state and so on through the server and connection methods and properties. See existing command handlers and plugins for examples.
Copyright (c) 2013-2026 Postal Systems OÜ
Licensed under the MIT license.