diff options
Diffstat (limited to 'comm/mailnews/test/fakeserver/Imapd.jsm')
-rw-r--r-- | comm/mailnews/test/fakeserver/Imapd.jsm | 2544 |
1 files changed, 2544 insertions, 0 deletions
diff --git a/comm/mailnews/test/fakeserver/Imapd.jsm b/comm/mailnews/test/fakeserver/Imapd.jsm new file mode 100644 index 0000000000..6023bf7b90 --- /dev/null +++ b/comm/mailnews/test/fakeserver/Imapd.jsm @@ -0,0 +1,2544 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// This file implements test IMAP servers + +var EXPORTED_SYMBOLS = [ + "ImapDaemon", + "ImapMailbox", + "ImapMessage", + "IMAP_RFC3501_handler", + "configurations", + "mixinExtension", + "IMAP_GMAIL_extension", + "IMAP_MOVE_extension", + "IMAP_CUSTOM_extension", + "IMAP_RFC2197_extension", + "IMAP_RFC2342_extension", + "IMAP_RFC3348_extension", + "IMAP_RFC4315_extension", + "IMAP_RFC5258_extension", + "IMAP_RFC2195_extension", +]; + +// IMAP DAEMON ORGANIZATION +// ------------------------ +// The large numbers of RFCs all induce some implicit assumptions as to the +// organization of an IMAP server. Ideally, we'd like to be as inclusive as +// possible so that we can guarantee that it works for every type of server. +// Unfortunately, such all-accepting setups make generic algorithms hard to +// use; given their difficulty in a generic framework, it seems unlikely that +// a server would implement such characteristics. It also seems likely that +// if mailnews had a problem with the implementation, then most clients would +// see similar problems, so as to make the server widely unusable. In any +// case, if someone complains about not working on bugzilla, it can be added +// to the test suite. +// So, with that in mind, this is the basic layout of the daemon: +// DAEMON +// + Namespaces: parentless mailboxes whose names are the namespace name. The +// type of the namespace is specified by the type attribute. +// + Mailboxes: ImapMailbox objects with several properties. If a mailbox +// | | property begins with a '_', then it should not be serialized because +// | | it can be discovered from other means; in particular, a '_' does not +// | | necessarily mean that it is a private property that should not be +// | | accessed. The parent of a top-level mailbox is null, not "". +// | + I18N names: RFC 3501 specifies a modified UTF-7 form for names. +// | | However, a draft RFC makes the names UTF-8; it is expected to be +// | | completed and implemented "soon". Therefore, the correct usage is +// | | to specify the mailbox names as one normally does in JS and the +// | | protocol will take care of conversion itself. +// | + Case-sensitivity: RFC 3501 takes no position on this issue, only that +// | | a case-insensitive server must treat the base-64 parts of mailbox +// | | names as case-sensitive. The draft UTF8 RFC says nothing on this +// | | topic, but Crispin recommends using Unicode case-insensitivity. We +// | | therefore treat names in such manner (if the case-insensitive flag +// | | is set), in technical violation of RFC 3501. +// | + Flags: Flags are (as confirmed by Crispin) case-insensitive. Internal +// | flag equality, though, uses case-sensitive checks. Therefore they +// | should be normalized to a title-case form (e.g., \Noselect). +// + Synchronization: On certain synchronizing commands, the daemon will call +// | a synchronizing function to allow manipulating code the chance to +// | perform various (potentially expensive) actions. +// + Messages: A message is represented internally as an annotated URI. + +const { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm"); +var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import( + "resource://testing-common/mailnews/Auth.jsm" +); + +class ImapDaemon { + constructor(flags, syncFunc) { + this._flags = flags; + + this.namespaces = []; + this.idResponse = "NIL"; + this.root = new ImapMailbox("", null, { type: IMAP_NAMESPACE_PERSONAL }); + this.uidvalidity = Math.round(Date.now() / 1000); + this.inbox = new ImapMailbox("INBOX", null, this.uidvalidity++); + this.root.addMailbox(this.inbox); + this.namespaces.push(this.root); + this.syncFunc = syncFunc; + // This can be used to cause the artificial failure of any given command. + this.commandToFail = ""; + // This can be used to simulate timeouts on large copies + this.copySleep = 0; + } + synchronize(mailbox, update) { + if (this.syncFunc) { + this.syncFunc.call(null, this); + } + if (update) { + for (var message of mailbox._messages) { + message.recent = false; + } + } + } + getNamespace(name) { + for (var namespace of this.namespaces) { + if ( + name.indexOf(namespace.name) == 0 && + name[namespace.name.length] == namespace.delimiter + ) { + return namespace; + } + } + return this.root; + } + createNamespace(name, type) { + var newbox = this.createMailbox(name, { type }); + this.namespaces.push(newbox); + } + getMailbox(name) { + if (name == "") { + return this.root; + } + // INBOX is case-insensitive, no matter what + if (name.toUpperCase().startsWith("INBOX")) { + name = "INBOX" + name.substr(5); + } + // We want to find a child who has the same name, but we don't quite know + // what the delimiter is. The convention is that different namespaces use a + // name starting with '#', so that's how we'll work it out. + let mailbox; + if (name.startsWith("#")) { + for (mailbox of this.root._children) { + if ( + mailbox.name.indexOf(name) == 0 && + name[mailbox.name.length] == mailbox.delimiter + ) { + break; + } + } + if (!mailbox) { + return null; + } + + // Now we continue like normal + let names = name.split(mailbox.delimiter); + names.splice(0, 1); + for (let part of names) { + mailbox = mailbox.getChild(part); + if (!mailbox || mailbox.nonExistent) { + return null; + } + } + } else { + // This is easy, just split it up using the inbox's delimiter + let names = name.split(this.inbox.delimiter); + mailbox = this.root; + + for (let part of names) { + mailbox = mailbox.getChild(part); + if (!mailbox || mailbox.nonExistent) { + return null; + } + } + } + return mailbox; + } + createMailbox(name, oldBox) { + var namespace = this.getNamespace(name); + if (namespace.name != "") { + name = name.substring(namespace.name.length + 1); + } + var prefixes = name.split(namespace.delimiter); + var subName; + if (prefixes[prefixes.length - 1] == "") { + subName = prefixes.splice(prefixes.length - 2, 2)[0]; + } else { + subName = prefixes.splice(prefixes.length - 1, 1)[0]; + } + var box = namespace; + for (var component of prefixes) { + box = box.getChild(component); + // Yes, we won't autocreate intermediary boxes + if (box == null || box.flags.includes("\\NoInferiors")) { + return false; + } + } + // If this is an ImapMailbox... + if (oldBox && oldBox._children) { + // Only delete now so we don't screw ourselves up if creation fails + this.deleteMailbox(oldBox); + oldBox._parent = box == this.root ? null : box; + let newBox = new ImapMailbox(subName, box, this.uidvalidity++); + newBox._messages = oldBox._messages; + box.addMailbox(newBox); + + // And if oldBox is an INBOX, we need to recreate that + if (oldBox.name == "INBOX") { + this.inbox = new ImapMailbox("INBOX", null, this.uidvalidity++); + this.root.addMailbox(this.inbox); + } + oldBox.name = subName; + } else if (oldBox) { + // oldBox is a regular {} object, so it contains mailbox data but is not + // a mailbox itself. Pass it into the constructor and let that deal with + // it... + let childBox = new ImapMailbox( + subName, + box == this.root ? null : box, + oldBox + ); + box.addMailbox(childBox); + // And return the new mailbox, since this is being used by people setting + // up the daemon. + return childBox; + } else { + var creatable = hasFlag(this._flags, IMAP_FLAG_NEEDS_DELIMITER) + ? name[name.length - 1] == namespace.delimiter + : true; + let childBox = new ImapMailbox(subName, box == this.root ? null : box, { + flags: creatable ? [] : ["\\NoInferiors"], + uidvalidity: this.uidvalidity++, + }); + box.addMailbox(childBox); + } + return true; + } + deleteMailbox(mailbox) { + if (mailbox._children.length == 0) { + // We don't preserve the subscribed state for deleted mailboxes + var parentBox = mailbox._parent == null ? this.root : mailbox._parent; + parentBox._children.splice(parentBox._children.indexOf(mailbox), 1); + } else { + // clear mailbox + mailbox._messages = []; + mailbox.flags.push("\\Noselect"); + } + } +} + +class ImapMailbox { + constructor(name, parent, state) { + this.name = name; + this._parent = parent; + this._children = []; + this._messages = []; + this._updates = []; + + // Shorthand for uidvalidity + if (typeof state == "number") { + this.uidvalidity = state; + state = {}; + } + + if (!state) { + state = {}; + } + + for (var prop in state) { + this[prop] = state[prop]; + } + + this.setDefault("subscribed", false); + this.setDefault("nonExistent", false); + this.setDefault("delimiter", "/"); + this.setDefault("flags", []); + this.setDefault("specialUseFlag", ""); + this.setDefault("uidnext", 1); + this.setDefault("msgflags", [ + "\\Seen", + "\\Answered", + "\\Flagged", + "\\Deleted", + "\\Draft", + ]); + this.setDefault("permflags", [ + "\\Seen", + "\\Answered", + "\\Flagged", + "\\Deleted", + "\\Draft", + "\\*", + ]); + } + setDefault(prop, def) { + this[prop] = prop in this ? this[prop] : def; + } + addMailbox(mailbox) { + this._children.push(mailbox); + } + getChild(name) { + for (var mailbox of this._children) { + if (name == mailbox.name) { + return mailbox; + } + } + return null; + } + matchKids(pattern) { + if (pattern == "") { + return this._parent ? this._parent.matchKids("") : [this]; + } + + var portions = pattern.split(this.delimiter); + var matching = [this]; + for (var folder of portions) { + if (folder.length == 0) { + continue; + } + + let generator = folder.includes("*") ? "allChildren" : "_children"; + let possible = matching.reduce(function (arr, elem) { + return arr.concat(elem[generator]); + }, []); + + if (folder == "*" || folder == "%") { + matching = possible; + continue; + } + + let parts = folder.split(/[*%]/).filter(function (str) { + return str.length > 0; + }); + matching = possible.filter(function (mailbox) { + let index = 0, + name = mailbox.fullName; + for (var part of parts) { + index = name.indexOf(part, index); + if (index == -1) { + return false; + } + } + return true; + }); + } + return matching; + } + get fullName() { + return ( + (this._parent ? this._parent.fullName + this.delimiter : "") + this.name + ); + } + get displayName() { + let manager = Cc["@mozilla.org/charset-converter-manager;1"].getService( + Ci.nsICharsetConverterManager + ); + // Escape backslash and double-quote with another backslash before encoding. + return manager.unicodeToMutf7(this.fullName.replace(/([\\"])/g, "\\$1")); + } + get allChildren() { + return this._children.reduce(function (arr, elem) { + return arr.concat(elem._allChildrenInternal); + }, []); + } + get _allChildrenInternal() { + return this._children.reduce( + function (arr, elem) { + return arr.concat(elem._allChildrenInternal); + }, + [this] + ); + } + addMessage(message) { + this._messages.push(message); + if (message.uid >= this.uidnext) { + this.uidnext = message.uid + 1; + } + if (!this._updates.includes("EXISTS")) { + this._updates.push("EXISTS"); + } + if ("__highestuid" in this && message.uid > this.__highestuid) { + this.__highestuid = message.uid; + } + } + get _highestuid() { + if ("__highestuid" in this) { + return this.__highestuid; + } + var highest = 0; + for (var message of this._messages) { + if (message.uid > highest) { + highest = message.uid; + } + } + this.__highestuid = highest; + return highest; + } + expunge() { + var response = ""; + for (var i = 0; i < this._messages.length; i++) { + if (this._messages[i].flags.includes("\\Deleted")) { + response += "* " + (i + 1) + " EXPUNGE\0"; + this._messages.splice(i--, 1); + } + } + if (response.length > 0) { + delete this.__highestuid; + } + return response; + } +} + +class ImapMessage { + constructor(URI, uid, flags) { + this._URI = URI; + this.uid = uid; + this.size = 0; + this.flags = []; + for (let flag in flags) { + this.flags.push(flag); + } + this.recent = false; + } + get channel() { + return Services.io.newChannel( + this._URI, + null, + null, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + } + setFlag(flag) { + if (!this.flags.includes(flag)) { + this.flags.push(flag); + } + } + // This allows us to simulate servers that approximate the rfc822 size. + setSize(size) { + this.size = size; + } + clearFlag(flag) { + let index = this.flags.indexOf(flag); + if (index != -1) { + this.flags.splice(index, 1); + } + } + getText(start, length) { + if (!start) { + start = 0; + } + if (!length) { + length = -1; + } + var channel = this.channel; + var istream = channel.open(); + var bstream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + bstream.setInputStream(istream); + var str = bstream.readBytes(start); + if (str.length != start) { + throw new Error("Erm, we didn't just pass through 8-bit"); + } + length = length == -1 ? istream.available() : length; + if (length > istream.available()) { + length = istream.available(); + } + str = bstream.readBytes(length); + return str; + } + + get _partMap() { + if (this.__partMap) { + return this.__partMap; + } + var partMap = {}; + var emitter = { + startPart(partNum, headers) { + var imapPartNum = partNum.replace("$", ""); + // If there are multiple imap parts that this represents, we'll + // overwrite with the latest. This is what we want (most deeply nested). + partMap[imapPartNum] = [partNum, headers]; + }, + }; + MimeParser.parseSync(this.getText(), emitter, { + bodyformat: "none", + stripcontinuations: false, + }); + return (this.__partMap = partMap); + } + getPartHeaders(partNum) { + return this._partMap[partNum][1]; + } + getPartBody(partNum) { + var body = ""; + var emitter = { + deliverPartData(partNum, data) { + body += data; + }, + }; + var mimePartNum = this._partMap[partNum][0]; + MimeParser.parseSync(this.getText(), emitter, { + pruneat: mimePartNum, + bodyformat: "raw", + }); + return body; + } +} + +// IMAP FLAGS +// If you don't specify any flag, no flags are set. + +/** + * This flag represents whether or not CREATE hierarchies need a delimiter. + * + * If this flag is off, <tt>CREATE a<br />CREATE a/b</tt> fails where + * <tt>CREATE a/<br />CREATE a/b</tt> would succeed (assuming the delimiter is + * '/'). + */ +var IMAP_FLAG_NEEDS_DELIMITER = 2; + +function hasFlag(flags, flag) { + return (flags & flag) == flag; +} + +// IMAP Namespaces +var IMAP_NAMESPACE_PERSONAL = 0; +// var IMAP_NAMESPACE_OTHER_USERS = 1; +// var IMAP_NAMESPACE_SHARED = 2; + +// IMAP server helpers +var IMAP_STATE_NOT_AUTHED = 0; +var IMAP_STATE_AUTHED = 1; +var IMAP_STATE_SELECTED = 2; + +function parseCommand(text, partial) { + var args = []; + var current = args; + var stack = []; + if (partial) { + args = partial.args; + current = partial.current; + stack = partial.stack; + current.push(partial.text); + } + var atom = ""; + while (text.length > 0) { + let c = text[0]; + + if (c == '"') { + let index = 1; + let s = ""; + while (index < text.length && text[index] != '"') { + if (text[index] == "\\") { + index++; + if (text[index] != '"' && text[index] != "\\") { + throw new Error("Expected quoted character"); + } + } + s += text[index++]; + } + if (index == text.length) { + throw new Error("Expected DQUOTE"); + } + current.push(s); + text = text.substring(index + 1); + continue; + } else if (c == "{") { + let end = text.indexOf("}"); + if (end == -1) { + throw new Error("Expected CLOSE_BRACKET"); + } + if (end + 1 != text.length) { + throw new Error("Expected CRLF"); + } + let length = parseInt(text.substring(1, end)); + // Usable state + // eslint-disable-next-line no-throw-literal + throw { length, current, args, stack, text: "" }; + } else if (c == "(") { + stack.push(current); + current = []; + } else if (c == ")") { + if (atom.length > 0) { + current.push(atom); + atom = ""; + } + let hold = current; + current = stack.pop(); + if (current == undefined) { + throw new Error("Unexpected CLOSE_PAREN"); + } + current.push(hold); + } else if (c == " ") { + if (atom.length > 0) { + current.push(atom); + atom = ""; + } + } else if ( + text.toUpperCase().startsWith("NIL") && + (text.length == 3 || text[3] == " ") + ) { + current.push(null); + text = text.substring(4); + continue; + } else { + atom += c; + } + text = text.substring(1); + } + if (stack.length != 0) { + throw new Error("Expected CLOSE_PAREN!"); + } + if (atom.length > 0) { + args.push(atom); + } + return args; +} + +function formatArg(argument, spec) { + // Get NILs out of the way quickly + var nilAccepted = false; + if (spec.startsWith("n") && spec[1] != "u") { + spec = spec.substring(1); + nilAccepted = true; + } + if (argument == null) { + if (!nilAccepted) { + throw new Error("Unexpected NIL!"); + } + + return null; + } + + // array! + if (spec.startsWith("(")) { + // typeof array is object. Don't ask me why. + if (!Array.isArray(argument)) { + throw new Error("Expected list!"); + } + // Strip the '(' and ')'... + spec = spec.substring(1, spec.length - 1); + // ... and apply to the rest + return argument.map(function (item) { + return formatArg(item, spec); + }); + } + + // or! + var pipe = spec.indexOf("|"); + if (pipe > 0) { + var first = spec.substring(0, pipe); + try { + return formatArg(argument, first); + } catch (e) { + return formatArg(argument, spec.substring(pipe + 1)); + } + } + + // By now, we know that the input should be generated from an atom or string. + if (typeof argument != "string") { + throw new Error("Expected argument of type " + spec + "!"); + } + + if (spec == "atom") { + argument = argument.toUpperCase(); + } else if (spec == "mailbox") { + let manager = Cc["@mozilla.org/charset-converter-manager;1"].getService( + Ci.nsICharsetConverterManager + ); + argument = manager.mutf7ToUnicode(argument); + } else if (spec == "string") { + // Do nothing + } else if (spec == "flag") { + argument = argument.toLowerCase(); + if ( + !("a" <= argument[0] && argument[0] <= "z") && + !("A" <= argument[0] && argument[0] <= "Z") + ) { + argument = argument[0] + argument[1].toUpperCase() + argument.substr(2); + } else { + argument = argument[0].toUpperCase() + argument.substr(1); + } + } else if (spec == "number") { + if (argument == parseInt(argument)) { + argument = parseInt(argument); + } + } else if (spec == "date") { + if ( + !/^\d{1,2}-[A-Z][a-z]{2}-\d{4}( \d{2}(:\d{2}){2} [+-]\d{4})?$/.test( + argument + ) + ) { + throw new Error("Expected date!"); + } + argument = new Date(Date.parse(argument.replace(/-(?!\d{4}$)/g, " "))); + } else { + throw new Error("Unknown spec " + spec); + } + + return argument; +} + +// IMAP TEST SERVERS +// ----------------- +// Because of IMAP and the LEMONADE RFCs, we have a myriad of different +// server configurations that we should ideally be supporting. We handle them +// by defining a core RFC 3501 implementation and then have different server +// extensions subclass the server through functions below. However, we also +// provide standard configurations for best handling. +// Configurations: +// * Barebones RFC 3501 +// * Cyrus +// * UW IMAP +// * Courier +// * Exchange +// * Dovecot +// * Zimbra +// * GMail +// KNOWN DEVIATIONS FROM RFC 3501: +// + The autologout timer is 3 minutes, not 30 minutes. A test with a logout +// of 30 minutes would take a very long time if it failed. +// + SEARCH (except for UNDELETED) and STARTTLS are not supported, +// nor is all of FETCH. +// + Concurrent mailbox access is probably compliant with a rather liberal +// implementation of RFC 3501, although probably not what one would expect, +// and certainly not what the Dovecot IMAP server tests expect. + +/* IMAP Fakeserver operates in a different manner than the rest of fakeserver + * because of some differences in the protocol. Commands are dispatched through + * onError, which parses the message into components. Like other fakeserver + * implementations, the command property will be called, but this time with an + * argument that is an array of data items instead of a string representing the + * rest of the line. + */ +class IMAP_RFC3501_handler { + constructor(daemon) { + this.kUsername = "user"; + this.kPassword = "password"; + this.kAuthSchemes = []; // Added by RFC2195 extension. Test may modify as needed. + this.kCapabilities = [ + /* "LOGINDISABLED", "STARTTLS", */ + "CLIENTID", + ]; // Test may modify as needed. + this.kUidCommands = ["FETCH", "STORE", "SEARCH", "COPY"]; + + this._daemon = daemon; + this.closing = false; + this.dropOnStartTLS = false; + // map: property = auth scheme {String}, value = start function on this obj + this._kAuthSchemeStartFunction = {}; + + this._enabledCommands = { + // IMAP_STATE_NOT_AUTHED + 0: [ + "CAPABILITY", + "NOOP", + "LOGOUT", + "STARTTLS", + "CLIENTID", + "AUTHENTICATE", + "LOGIN", + ], + // IMAP_STATE_AUTHED + 1: [ + "CAPABILITY", + "NOOP", + "LOGOUT", + "SELECT", + "EXAMINE", + "CREATE", + "DELETE", + "RENAME", + "SUBSCRIBE", + "UNSUBSCRIBE", + "LIST", + "LSUB", + "STATUS", + "APPEND", + ], + // IMAP_STATE_SELECTED + 2: [ + "CAPABILITY", + "NOOP", + "LOGOUT", + "SELECT", + "EXAMINE", + "CREATE", + "DELETE", + "RENAME", + "SUBSCRIBE", + "UNSUBSCRIBE", + "LIST", + "LSUB", + "STATUS", + "APPEND", + "CHECK", + "CLOSE", + "EXPUNGE", + "SEARCH", + "FETCH", + "STORE", + "COPY", + "UID", + ], + }; + // Format explanation: + // atom -> UPPERCASE + // string -> don't touch! + // mailbox -> Apply ->UTF16 transformation with case-insensitivity stuff + // flag -> Titlecase (or \Titlecase, $Titlecase, etc.) + // date -> Make it a JSDate object + // number -> Make it a number, if possible + // ( ) -> list, apply flags as specified + // [ ] -> optional argument. + // x|y -> either x or y format. + // ... -> variable args, don't parse + this._argFormat = { + CAPABILITY: [], + NOOP: [], + LOGOUT: [], + STARTTLS: [], + CLIENTID: ["string", "string"], + AUTHENTICATE: ["atom", "..."], + LOGIN: ["string", "string"], + SELECT: ["mailbox"], + EXAMINE: ["mailbox"], + CREATE: ["mailbox"], + DELETE: ["mailbox"], + RENAME: ["mailbox", "mailbox"], + SUBSCRIBE: ["mailbox"], + UNSUBSCRIBE: ["mailbox"], + LIST: ["mailbox", "mailbox"], + LSUB: ["mailbox", "mailbox"], + STATUS: ["mailbox", "(atom)"], + APPEND: ["mailbox", "[(flag)]", "[date]", "string"], + CHECK: [], + CLOSE: [], + EXPUNGE: [], + SEARCH: ["atom", "..."], + FETCH: ["number", "atom|(atom|(atom))"], + STORE: ["number", "atom", "flag|(flag)"], + COPY: ["number", "mailbox"], + UID: ["atom", "..."], + }; + + this.resetTest(); + } + resetTest() { + this._state = IMAP_STATE_NOT_AUTHED; + this._multiline = false; + this._nextAuthFunction = undefined; // should be in RFC2195_ext, but too lazy + } + onStartup() { + this._state = IMAP_STATE_NOT_AUTHED; + return "* OK IMAP4rev1 Fakeserver started up"; + } + + // CENTRALIZED DISPATCH FUNCTIONS + + // IMAP sends commands in the form of "tag command args", but fakeserver + // parsing tries to call the tag, which doesn't exist. Instead, we use this + // error method to do the actual command dispatch. Mailnews uses numbers for + // tags, which won't impede on actual commands. + onError(tag, realLine) { + this._tag = tag; + var space = realLine.indexOf(" "); + var command = space == -1 ? realLine : realLine.substring(0, space); + realLine = space == -1 ? "" : realLine.substring(space + 1); + + // Now parse realLine into an array of atoms, etc. + try { + var args = parseCommand(realLine); + } catch (state) { + if (typeof state == "object") { + this._partial = state; + this._partial.command = command; + this._multiline = true; + return "+ More!"; + } + + return this._tag + " BAD " + state; + } + + // If we're here, we have a command with arguments. Dispatch! + return this._dispatchCommand(command, args); + } + onMultiline(line) { + // A multiline arising form a literal being passed + if (this._partial) { + // There are two cases to be concerned with: + // 1. The CRLF is internal or end (we want more) + // 1a. The next line is the actual command stuff! + // 2. The CRLF is in the middle (rest of the line is args) + if (this._partial.length >= line.length + 2) { + // Case 1 + this._partial.text += line + "\r\n"; + this._partial.length -= line.length + 2; + return undefined; + } else if (this._partial.length != 0) { + this._partial.text += line.substring(0, this._partial.length); + line = line.substring(this._partial.length); + } + var command = this._partial.command; + var args; + try { + args = parseCommand(line, this._partial); + } catch (state) { + if (typeof state == "object") { + // Yet another literal coming around... + this._partial = state; + this._partial.command = command; + return "+ I'll be needing more text"; + } + + this._multiline = false; + return this.tag + " BAD parse error: " + state; + } + + this._partial = undefined; + this._multiline = false; + return this._dispatchCommand(command, args); + } + + if (this._nextAuthFunction) { + var func = this._nextAuthFunction; + this._multiline = false; + this._nextAuthFunction = undefined; + if (line == "*") { + return this._tag + " BAD Okay, as you wish. Chicken"; + } + if (!func || typeof func != "function") { + return this._tag + " BAD I'm lost. Internal server error during auth"; + } + try { + return this._tag + " " + func.call(this, line); + } catch (e) { + return this._tag + " BAD " + e; + } + } + return undefined; + } + _dispatchCommand(command, args) { + this.sendingLiteral = false; + command = command.toUpperCase(); + if (command == this._daemon.commandToFail.toUpperCase()) { + return this._tag + " NO " + command + " failed"; + } + var response; + if (command in this) { + this._lastCommand = command; + // Are we allowed to execute this command? + if (!this._enabledCommands[this._state].includes(command)) { + return ( + this._tag + " BAD illegal command for current state " + this._state + ); + } + + try { + // Format the arguments nicely + args = this._treatArgs(args, command); + + // UID command by itself is not useful for PerformTest + if (command == "UID") { + this._lastCommand += " " + args[0]; + } + + // Finally, run the thing + response = this[command](args); + } catch (e) { + if (typeof e == "string") { + response = e; + } else { + throw e; + } + } + } else { + response = "BAD " + command + " not implemented"; + } + + // Add status updates + if (this._selectedMailbox) { + for (var update of this._selectedMailbox._updates) { + let line; + switch (update) { + case "EXISTS": + line = "* " + this._selectedMailbox._messages.length + " EXISTS"; + break; + } + response = line + "\0" + response; + } + } + + var lines = response.split("\0"); + response = ""; + for (let line of lines) { + if (!line.startsWith("+") && !line.startsWith("*")) { + response += this._tag + " "; + } + response += line + "\r\n"; + } + return response; + } + _treatArgs(args, command) { + var format = this._argFormat[command]; + var treatedArgs = []; + for (var i = 0; i < format.length; i++) { + var spec = format[i]; + + if (spec == "...") { + treatedArgs = treatedArgs.concat(args); + args = []; + break; + } + + if (args.length == 0) { + if (spec.startsWith("[")) { + // == optional arg + continue; + } else { + throw new Error("BAD not enough arguments"); + } + } + + if (spec.startsWith("[")) { + // We have an optional argument. See if the format matches and move on + // if it doesn't. Ideally, we'd rethink our decision if a later + // application turns out to be wrong, but that's ugly to do + // iteratively. Should any IMAP extension require it, we'll have to + // come back and change this assumption, though. + spec = spec.substr(1, spec.length - 2); + try { + var out = formatArg(args[0], spec); + } catch (e) { + continue; + } + treatedArgs.push(out); + args.shift(); + continue; + } + try { + treatedArgs.push(formatArg(args.shift(), spec)); + } catch (e) { + throw new Error("BAD " + e); + } + } + if (args.length != 0) { + throw new Error("BAD Too many arguments"); + } + return treatedArgs; + } + + // PROTOCOL COMMANDS (ordered as in spec) + + CAPABILITY(args) { + var capa = "* CAPABILITY IMAP4rev1 " + this.kCapabilities.join(" "); + if (this.kAuthSchemes.length > 0) { + capa += " AUTH=" + this.kAuthSchemes.join(" AUTH="); + } + capa += "\0OK CAPABILITY completed"; + return capa; + } + CLIENTID(args) { + return "OK Recognized a valid CLIENTID command, used for authentication methods"; + } + LOGOUT(args) { + this.closing = true; + if (this._selectedMailbox) { + this._daemon.synchronize(this._selectedMailbox, !this._readOnly); + } + this._state = IMAP_STATE_NOT_AUTHED; + return "* BYE IMAP4rev1 Logging out\0OK LOGOUT completed"; + } + NOOP(args) { + return "OK NOOP completed"; + } + STARTTLS(args) { + // simulate annoying server that drops connection on STARTTLS + if (this.dropOnStartTLS) { + this.closing = true; + return ""; + } + return "BAD maild doesn't support TLS ATM"; + } + _nextAuthFunction = undefined; + AUTHENTICATE(args) { + var scheme = args[0]; // already uppercased by type "atom" + // |scheme| contained in |kAuthSchemes|? + if ( + !this.kAuthSchemes.some(function (s) { + return s == scheme; + }) + ) { + return "-ERR AUTH " + scheme + " not supported"; + } + + var func = this._kAuthSchemeStartFunction[scheme]; + if (!func || typeof func != "function") { + return ( + "BAD I just pretended to implement AUTH " + scheme + ", but I don't" + ); + } + return func.apply(this, args.slice(1)); + } + LOGIN(args) { + if ( + this.kCapabilities.some(function (c) { + return c == "LOGINDISABLED"; + }) + ) { + return "BAD old-style LOGIN is disabled, use AUTHENTICATE"; + } + if (args[0] == this.kUsername && args[1] == this.kPassword) { + this._state = IMAP_STATE_AUTHED; + return "OK authenticated"; + } + return "BAD invalid password, I won't authenticate you"; + } + SELECT(args) { + var box = this._daemon.getMailbox(args[0]); + if (!box) { + return "NO no such mailbox"; + } + + if (this._selectedMailbox) { + this._daemon.synchronize(this._selectedMailbox, !this._readOnly); + } + this._state = IMAP_STATE_SELECTED; + this._selectedMailbox = box; + this._readOnly = false; + + var response = "* FLAGS (" + box.msgflags.join(" ") + ")\0"; + response += "* " + box._messages.length + " EXISTS\0* "; + response += box._messages.reduce(function (count, message) { + return count + (message.recent ? 1 : 0); + }, 0); + response += " RECENT\0"; + for (var i = 0; i < box._messages.length; i++) { + if (!box._messages[i].flags.includes("\\Seen")) { + response += "* OK [UNSEEN " + (i + 1) + "]\0"; + break; + } + } + response += "* OK [PERMANENTFLAGS (" + box.permflags.join(" ") + ")]\0"; + response += "* OK [UIDNEXT " + box.uidnext + "]\0"; + if ("uidvalidity" in box) { + response += "* OK [UIDVALIDITY " + box.uidvalidity + "]\0"; + } + return response + "OK [READ-WRITE] SELECT completed"; + } + EXAMINE(args) { + var box = this._daemon.getMailbox(args[0]); + if (!box) { + return "NO no such mailbox"; + } + + if (this._selectedMailbox) { + this._daemon.synchronize(this._selectedMailbox, !this._readOnly); + } + this._state = IMAP_STATE_SELECTED; + this._selectedMailbox = box; + this._readOnly = true; + + var response = "* FLAGS (" + box.msgflags.join(" ") + ")\0"; + response += "* " + box._messages.length + " EXISTS\0* "; + response += box._messages.reduce(function (count, message) { + return count + (message.recent ? 1 : 0); + }, 0); + response += " RECENT\0"; + for (var i = 0; i < box._messages.length; i++) { + if (!box._messages[i].flags.includes("\\Seen")) { + response += "* OK [UNSEEN " + (i + 1) + "]\0"; + break; + } + } + response += "* OK [PERMANENTFLAGS (" + box.permflags.join(" ") + ")]\0"; + response += "* OK [UIDNEXT " + box.uidnext + "]\0"; + response += "* OK [UIDVALIDITY " + box.uidvalidity + "]\0"; + return response + "OK [READ-ONLY] EXAMINE completed"; + } + CREATE(args) { + if (this._daemon.getMailbox(args[0])) { + return "NO mailbox already exists"; + } + if (!this._daemon.createMailbox(args[0])) { + return "NO cannot create mailbox"; + } + return "OK CREATE completed"; + } + DELETE(args) { + var mbox = this._daemon.getMailbox(args[0]); + if (!mbox || mbox.name == "") { + return "NO no such mailbox"; + } + if (mbox._children.length > 0) { + for (let i = 0; i < mbox.flags.length; i++) { + if (mbox.flags[i] == "\\Noselect") { + return "NO cannot delete mailbox"; + } + } + } + this._daemon.deleteMailbox(mbox); + return "OK DELETE completed"; + } + RENAME(args) { + var mbox = this._daemon.getMailbox(args[0]); + if (!mbox || mbox.name == "") { + return "NO no such mailbox"; + } + if (!this._daemon.createMailbox(args[1], mbox)) { + return "NO cannot rename mailbox"; + } + return "OK RENAME completed"; + } + SUBSCRIBE(args) { + var mailbox = this._daemon.getMailbox(args[0]); + if (!mailbox) { + return "NO error in subscribing"; + } + mailbox.subscribed = true; + return "OK SUBSCRIBE completed"; + } + UNSUBSCRIBE(args) { + var mailbox = this._daemon.getMailbox(args[0]); + if (mailbox) { + mailbox.subscribed = false; + } + return "OK UNSUBSCRIBE completed"; + } + LIST(args) { + // even though this is the LIST function for RFC 3501, code for + // LIST-EXTENDED (RFC 5258) is included here to keep things simple and + // avoid duplication. We can get away with this because the _treatArgs + // function filters out invalid args for servers that don't support + // LIST-EXTENDED before they even get here. + + let listFunctionName = "_LIST"; + // check for optional list selection options argument used by LIST-EXTENDED + // and other related RFCs + if (args.length == 3 || (args.length > 3 && args[3] == "RETURN")) { + let selectionOptions = args.shift(); + selectionOptions = selectionOptions.toString().split(" "); + selectionOptions.sort(); + for (let option of selectionOptions) { + listFunctionName += "_" + option.replace(/-/g, "_"); + } + } + // check for optional list return options argument used by LIST-EXTENDED + // and other related RFCs + if ( + (args.length > 2 && args[2] == "RETURN") || + this.kCapabilities.includes("CHILDREN") + ) { + listFunctionName += "_RETURN"; + let returnOptions = args[3] ? args[3].toString().split(" ") : []; + if ( + this.kCapabilities.includes("CHILDREN") && + !returnOptions.includes("CHILDREN") + ) { + returnOptions.push("CHILDREN"); + } + returnOptions.sort(); + for (let option of returnOptions) { + listFunctionName += "_" + option.replace(/-/g, "_"); + } + } + if (!this[listFunctionName]) { + return "BAD unknown LIST request options"; + } + + let base = this._daemon.getMailbox(args[0]); + if (!base) { + return "NO no such mailbox"; + } + let requestedBoxes; + // check for multiple mailbox patterns used by LIST-EXTENDED + // and other related RFCs + if (args[1].startsWith("(")) { + requestedBoxes = parseCommand(args[1])[0]; + } else { + requestedBoxes = [args[1]]; + } + let response = ""; + for (let requestedBox of requestedBoxes) { + let people = base.matchKids(requestedBox); + for (let box of people) { + response += this[listFunctionName](box); + } + } + return response + "OK LIST completed"; + } + // _LIST is the standard LIST command response + _LIST(aBox) { + if (aBox.nonExistent) { + return ""; + } + return ( + "* LIST (" + + aBox.flags.join(" ") + + ') "' + + aBox.delimiter + + '" "' + + aBox.displayName + + '"\0' + ); + } + LSUB(args) { + var base = this._daemon.getMailbox(args[0]); + if (!base) { + return "NO no such mailbox"; + } + var people = base.matchKids(args[1]); + var response = ""; + for (var box of people) { + if (box.subscribed) { + response += + '* LSUB () "' + box.delimiter + '" "' + box.displayName + '"\0'; + } + } + return response + "OK LSUB completed"; + } + STATUS(args) { + var box = this._daemon.getMailbox(args[0]); + if (!box) { + return "NO no such mailbox exists"; + } + for (let i = 0; i < box.flags.length; i++) { + if (box.flags[i] == "\\Noselect") { + return "NO STATUS not allowed on Noselect folder"; + } + } + var parts = []; + for (var status of args[1]) { + var line = status + " "; + switch (status) { + case "MESSAGES": + line += box._messages.length; + break; + case "RECENT": + line += box._messages.reduce(function (count, message) { + return count + (message.recent ? 1 : 0); + }, 0); + break; + case "UIDNEXT": + line += box.uidnext; + break; + case "UIDVALIDITY": + line += box.uidvalidity; + break; + case "UNSEEN": + line += box._messages.reduce(function (count, message) { + return count + (message.flags.includes("\\Seen") ? 0 : 1); + }, 0); + break; + default: + return "BAD unknown status flag: " + status; + } + parts.push(line); + } + return ( + '* STATUS "' + + args[0] + + '" (' + + parts.join(" ") + + ")\0OK STATUS completed" + ); + } + APPEND(args) { + var mailbox = this._daemon.getMailbox(args[0]); + if (!mailbox) { + return "NO [TRYCREATE] no such mailbox"; + } + var flags, date, text; + if (args.length == 3) { + if (args[1] instanceof Date) { + flags = []; + date = args[1]; + } else { + flags = args[1]; + date = Date.now(); + } + text = args[2]; + } else if (args.length == 4) { + flags = args[1]; + date = args[2]; + text = args[3]; + } else { + flags = []; + date = Date.now(); + text = args[1]; + } + var msg = new ImapMessage( + "data:text/plain," + encodeURI(text), + mailbox.uidnext++, + flags + ); + msg.recent = true; + msg.date = date; + mailbox.addMessage(msg); + return "OK APPEND complete"; + } + CHECK(args) { + this._daemon.synchronize(this._selectedMailbox, false); + return "OK CHECK completed"; + } + CLOSE(args) { + this._selectedMailbox.expunge(); + this._daemon.synchronize(this._selectedMailbox, !this._readOnly); + this._selectedMailbox = null; + this._state = IMAP_STATE_AUTHED; + return "OK CLOSE completed"; + } + EXPUNGE(args) { + // Will be either empty or LF-terminated already + var response = this._selectedMailbox.expunge(); + this._daemon.synchronize(this._selectedMailbox); + return response + "OK EXPUNGE completed"; + } + SEARCH(args, uid) { + if (args[0] == "UNDELETED") { + let response = "* SEARCH"; + let messages = this._selectedMailbox._messages; + for (let i = 0; i < messages.length; i++) { + if (!messages[i].flags.includes("\\Deleted")) { + response += " " + messages[i].uid; + } + } + response += "\0"; + return response + "OK SEARCH COMPLETED\0"; + } + return "BAD not here yet"; + } + FETCH(args, uid) { + // Step 1: Get the messages to fetch + var ids = []; + var messages = this._parseSequenceSet(args[0], uid, ids); + + // Step 2: Ensure that the fetching items are in a neat format + if (typeof args[1] == "string") { + if (args[1] in this.fetchMacroExpansions) { + args[1] = this.fetchMacroExpansions[args[1]]; + } else { + args[1] = [args[1]]; + } + } + if (uid && !args[1].includes("UID")) { + args[1].push("UID"); + } + + // Step 2.1: Preprocess the item fetch stack + var items = [], + prefix = undefined; + for (let item of args[1]) { + if (item.indexOf("[") > 0 && !item.includes("]")) { + // We want to append everything into an item until we find a ']' + prefix = item + " "; + continue; + } + if (prefix !== undefined) { + if (typeof item != "string" || !item.includes("]")) { + prefix += + (typeof item == "string" ? item : "(" + item.join(" ") + ")") + " "; + continue; + } + // Replace superfluous space with a ']'. + prefix = prefix.substr(0, prefix.length - 1) + "]"; + item = prefix; + prefix = undefined; + } + item = item.toUpperCase(); + if (!items.includes(item)) { + items.push(item); + } + } + + // Step 3: Fetch time! + var response = ""; + for (var i = 0; i < messages.length; i++) { + response += "* " + ids[i] + " FETCH ("; + var parts = []; + const flagsBefore = messages[i].flags.slice(); + for (let item of items) { + // Brief explanation: an item like BODY[]<> can't be hardcoded easily, + // so we go for the initial alphanumeric substring, passing in the + // actual string as an optional second part. + var front = item.split(/[^A-Z0-9-]/, 1)[0]; + var functionName = "_FETCH_" + front.replace(/-/g, "_"); + + if (!(functionName in this)) { + return "BAD can't fetch " + front; + } + try { + parts.push(this[functionName](messages[i], item)); + } catch (ex) { + return "BAD error in fetching: " + ex; + } + } + const flagsAfter = messages[i].flags; + if ( + !items.includes("FLAGS") && + (flagsAfter.length != flagsBefore.length || + flagsAfter.some((f, i) => f != flagsBefore[i])) + ) { + // Flags changed, send them too, even though they weren't requested. + parts.push(this._FETCH_FLAGS(messages[i], "FLAGS")); + } + response += parts.join(" ") + ")\0"; + } + return response + "OK FETCH completed"; + } + STORE(args, uid) { + var ids = []; + var messages = this._parseSequenceSet(args[0], uid, ids); + + args[1] = args[1].toUpperCase(); + var silent = args[1].includes(".SILENT", 1); + if (silent) { + args[1] = args[1].substring(0, args[1].indexOf(".")); + } + + if (typeof args[2] != "object") { + args[2] = [args[2]]; + } + + var response = ""; + for (var i = 0; i < messages.length; i++) { + var message = messages[i]; + switch (args[1]) { + case "FLAGS": + message.flags = args[2]; + break; + case "+FLAGS": + for (let flag of args[2]) { + message.setFlag(flag); + } + break; + case "-FLAGS": + for (let flag of args[2]) { + var index; + if ((index = message.flags.indexOf(flag)) != -1) { + message.flags.splice(index, 1); + } + } + break; + default: + return "BAD change what now?"; + } + response += "* " + ids[i] + " FETCH (FLAGS ("; + response += message.flags.join(" "); + response += "))\0"; + } + if (silent) { + response = ""; + } + return response + "OK STORE completed"; + } + COPY(args, uid) { + var messages = this._parseSequenceSet(args[0], uid); + + var dest = this._daemon.getMailbox(args[1]); + if (!dest) { + return "NO [TRYCREATE] what mailbox?"; + } + + for (var message of messages) { + let newMessage = new ImapMessage( + message._URI, + dest.uidnext++, + message.flags + ); + newMessage.recent = false; + dest.addMessage(newMessage); + } + if (this._daemon.copySleep > 0) { + // spin rudely for copyTimeout milliseconds. + let now = new Date(); + let alarm; + let startingMSeconds = now.getTime(); + while (true) { + alarm = new Date(); + if (alarm.getTime() - startingMSeconds > this._daemon.copySleep) { + break; + } + } + } + return "OK COPY completed"; + } + UID(args) { + var name = args.shift(); + if (!this.kUidCommands.includes(name)) { + return "BAD illegal command " + name; + } + + args = this._treatArgs(args, name); + return this[name](args, true); + } + + postCommand(reader) { + if (this.closing) { + this.closing = false; + reader.closeSocket(); + } + if (this.sendingLiteral) { + reader.preventLFMunge(); + } + reader.setMultiline(this._multiline); + if (this._lastCommand == reader.watchWord) { + reader.stopTest(); + } + } + onServerFault(e) { + return ( + ("_tag" in this ? this._tag : "*") + " BAD Internal server error: " + e + ); + } + + // FETCH sub commands and helpers + + fetchMacroExpansions = { + ALL: ["FLAGS", "INTERNALDATE", "RFC822.SIZE" /* , "ENVELOPE" */], + FAST: ["FLAGS", "INTERNALDATE", "RFC822.SIZE"], + FULL: ["FLAGS", "INTERNALDATE", "RFC822.SIZE" /* , "ENVELOPE", "BODY" */], + }; + _parseSequenceSet(set, uid, ids /* optional */) { + if (typeof set == "number") { + if (uid) { + for (let i = 0; i < this._selectedMailbox._messages.length; i++) { + var message = this._selectedMailbox._messages[i]; + if (message.uid == set) { + if (ids) { + ids.push(i + 1); + } + return [message]; + } + } + return []; + } + if (!(set - 1 in this._selectedMailbox._messages)) { + return []; + } + if (ids) { + ids.push(set); + } + return [this._selectedMailbox._messages[set - 1]]; + } + + var daemon = this; + function part2num(part) { + if (part == "*") { + if (uid) { + return daemon._selectedMailbox._highestuid; + } + return daemon._selectedMailbox._messages.length; + } + let re = /[0-9]/g; + let num = part.match(re); + if (!num || num.length != part.length) { + throw new Error("BAD invalid UID " + part); + } + return parseInt(part); + } + + var elements = set.split(/,/); + set = []; + for (var part of elements) { + if (!part.includes(":")) { + set.push(part2num(part)); + } else { + var range = part.split(/:/); + range[0] = part2num(range[0]); + range[1] = part2num(range[1]); + if (range[0] > range[1]) { + let temp = range[1]; + range[1] = range[0]; + range[0] = temp; + } + for (let i = range[0]; i <= range[1]; i++) { + set.push(i); + } + } + } + set.sort(); + for (let i = set.length - 1; i > 0; i--) { + if (set[i] == set[i - 1]) { + set.splice(i, 0); + } + } + + if (!ids) { + ids = []; + } + var messages; + if (uid) { + messages = this._selectedMailbox._messages.filter(function (msg, i) { + if (!set.includes(msg.uid)) { + return false; + } + ids.push(i + 1); + return true; + }); + } else { + messages = []; + for (var id of set) { + if (id - 1 in this._selectedMailbox._messages) { + ids.push(id); + messages.push(this._selectedMailbox._messages[id - 1]); + } + } + } + return messages; + } + _FETCH_BODY(message, query) { + if (query == "BODY") { + return "BODYSTRUCTURE " + bodystructure(message.getText(), false); + } + // parts = [ name, section, empty, {, partial, empty } ] + var parts = query.split(/[[\]<>]/); + + if (parts[0] != "BODY.PEEK" && !this._readOnly) { + message.setFlag("\\Seen"); + } + + if (parts[3]) { + parts[3] = parts[3].split(/\./).map(function (e) { + return parseInt(e); + }); + } + + if (parts[1].length == 0) { + // Easy case: we have BODY[], just send the message... + let response = "BODY[]"; + var text; + if (parts[3]) { + response += "<" + parts[3][0] + ">"; + text = message.getText(parts[3][0], parts[3][1]); + } else { + text = message.getText(); + } + response += " {" + text.length + "}\r\n"; + response += text; + return response; + } + + // What's inside the command? + var data = /((?:\d+\.)*\d+)(?:\.([^ ]+))?/.exec(parts[1]); + var partNum; + if (data) { + partNum = data[1]; + query = data[2]; + } else { + partNum = ""; + if (parts[1].includes(" ", 1)) { + query = parts[1].substring(0, parts[1].indexOf(" ")); + } else { + query = parts[1]; + } + } + var queryArgs; + if (parts[1].includes(" ", 1)) { + queryArgs = parseCommand(parts[1].substr(parts[1].indexOf(" ")))[0]; + } else { + queryArgs = []; + } + + // Now we have three parameters representing the part number (empty for top- + // level), the subportion representing what we want to find (empty for the + // body), and an array of arguments if we have a subquery. If we made an + // error here, it will pop until it gets to FETCH, which will just pop at a + // BAD response, which is what should happen if the query is malformed. + // Now we dump it all off onto ImapMessage to mess with. + + // Start off the response + let response = "BODY[" + parts[1] + "]"; + if (parts[3]) { + response += "<" + parts[3][0] + ">"; + } + response += " "; + + data = ""; + switch (query) { + case "": + case "TEXT": + data += message.getPartBody(partNum); + break; + case "HEADER": // I believe this specifies mime for an RFC822 message only + data += message.getPartHeaders(partNum).rawHeaderText + "\r\n"; + break; + case "MIME": + data += message.getPartHeaders(partNum).rawHeaderText + "\r\n\r\n"; + break; + case "HEADER.FIELDS": { + let joinList = []; + let headers = message.getPartHeaders(partNum); + for (let header of queryArgs) { + header = header.toLowerCase(); + if (headers.has(header)) { + joinList.push( + headers + .getRawHeader(header) + .map(value => `${header}: ${value}`) + .join("\r\n") + ); + } + } + data += joinList.join("\r\n") + "\r\n"; + break; + } + case "HEADER.FIELDS.NOT": { + let joinList = []; + let headers = message.getPartHeaders(partNum); + for (let header of headers) { + if (!(header in queryArgs)) { + joinList.push( + headers + .getRawHeader(header) + .map(value => `${header}: ${value}`) + .join("\r\n") + ); + } + } + data += joinList.join("\r\n") + "\r\n"; + break; + } + default: + data += message.getPartBody(partNum); + } + + this.sendingLiteral = true; + response += "{" + data.length + "}\r\n"; + response += data; + return response; + } + _FETCH_BODYSTRUCTURE(message, query) { + return "BODYSTRUCTURE " + bodystructure(message.getText(), true); + } + // _FETCH_ENVELOPE, + _FETCH_FLAGS(message) { + var response = "FLAGS ("; + response += message.flags.join(" "); + if (message.recent) { + response += " \\Recent"; + } + response += ")"; + return response; + } + _FETCH_INTERNALDATE(message) { + let date = message.date; + // Format timestamp as: "%d-%b-%Y %H:%M:%S %z" (%b in English). + let year = date.getFullYear().toString(); + let month = date.toLocaleDateString("en-US", { month: "short" }); + let day = date.getDate().toString(); + let hours = date.getHours().toString().padStart(2, "0"); + let minutes = date.getMinutes().toString().padStart(2, "0"); + let seconds = date.getSeconds().toString().padStart(2, "0"); + let offset = date.getTimezoneOffset(); + let tzoff = + Math.floor(Math.abs(offset) / 60) * 100 + (Math.abs(offset) % 60); + let timeZone = (offset < 0 ? "+" : "-") + tzoff.toString().padStart(4, "0"); + + let response = 'INTERNALDATE "'; + response += `${day}-${month}-${year} ${hours}:${minutes}:${seconds} ${timeZone}`; + response += '"'; + return response; + } + _FETCH_RFC822(message, query) { + if (query == "RFC822") { + return this._FETCH_BODY(message, "BODY[]").replace("BODY[]", "RFC822"); + } + if (query == "RFC822.HEADER") { + return this._FETCH_BODY(message, "BODY.PEEK[HEADER]").replace( + "BODY[HEADER]", + "RFC822.HEADER" + ); + } + if (query == "RFC822.TEXT") { + return this._FETCH_BODY(message, "BODY[TEXT]").replace( + "BODY[TEXT]", + "RFC822.TEXT" + ); + } + + if (query == "RFC822.SIZE") { + var channel = message.channel; + var length = message.size ? message.size : channel.contentLength; + if (length == -1) { + var inputStream = channel.open(); + length = inputStream.available(); + inputStream.close(); + } + return "RFC822.SIZE " + length; + } + throw new Error("Unknown item " + query); + } + _FETCH_UID(message) { + return "UID " + message.uid; + } +} + +// IMAP4 RFC extensions +// -------------------- +// Since there are so many extensions to IMAP, and since these extensions are +// not strictly hierarchical (e.g., an RFC 2342-compliant server can also be +// RFC 3516-compliant, but a server might only implement one of them), they +// must be handled differently from other fakeserver implementations. +// An extension is defined as follows: it is an object (not a function and +// prototype pair!). This object is "mixed" into the handler via the helper +// function mixinExtension, which applies appropriate magic to make the +// handler compliant to the extension. Functions are added untransformed, but +// both arrays and objects are handled by appending the values onto the +// original state of the handler. Semantics apply as for the base itself. + +// Note that UIDPLUS (RFC4315) should be mixed in last (or at least after the +// MOVE extension) because it changes behavior of that extension. +var configurations = { + Cyrus: ["RFC2342", "RFC2195", "RFC5258"], + UW: ["RFC2342", "RFC2195"], + Dovecot: ["RFC2195", "RFC5258"], + Zimbra: ["RFC2197", "RFC2342", "RFC2195", "RFC5258"], + Exchange: ["RFC2342", "RFC2195"], + LEMONADE: ["RFC2342", "RFC2195"], + CUSTOM1: ["MOVE", "RFC4315", "CUSTOM"], + GMail: ["GMAIL", "RFC2197", "RFC2342", "RFC3348", "RFC4315"], +}; + +function mixinExtension(handler, extension) { + if (extension.preload) { + extension.preload(handler); + } + + for (var property in extension) { + if (property == "preload") { + continue; + } + if (typeof extension[property] == "function") { + // This is a function, so we add it to the handler + handler[property] = extension[property]; + } else if (extension[property] instanceof Array) { + // This is an array, so we append the values + if (!(property in handler)) { + handler[property] = []; + } + handler[property] = handler[property].concat(extension[property]); + } else if (property in handler) { + // This is an object, so we add in the values + // Hack to make arrays et al. work recursively + mixinExtension(handler[property], extension[property]); + } else { + handler[property] = extension[property]; + } + } +} + +// Support for Gmail extensions: XLIST and X-GM-EXT-1 +var IMAP_GMAIL_extension = { + preload(toBeThis) { + toBeThis._preGMAIL_STORE = toBeThis.STORE; + toBeThis._preGMAIL_STORE_argFormat = toBeThis._argFormat.STORE; + toBeThis._argFormat.STORE = ["number", "atom", "..."]; + toBeThis._DEFAULT_LIST = toBeThis.LIST; + }, + XLIST(args) { + // XLIST is really just SPECIAL-USE that does not conform to RFC 6154 + return this.LIST(args); + }, + LIST(args) { + // XLIST was deprecated, LIST implies SPECIAL-USE for Gmail. + args.push("RETURN"); + args.push("SPECIAL-USE"); + return this._DEFAULT_LIST(args); + }, + _LIST_RETURN_CHILDREN(aBox) { + return IMAP_RFC5258_extension._LIST_RETURN_CHILDREN(aBox); + }, + _LIST_RETURN_CHILDREN_SPECIAL_USE(aBox) { + if (aBox.nonExistent) { + return ""; + } + + let result = "* LIST (" + aBox.flags.join(" "); + if (aBox._children.length > 0) { + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\HasChildren"; + } else if (!aBox.flags.includes("\\NoInferiors")) { + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\HasNoChildren"; + } + if (aBox.specialUseFlag && aBox.specialUseFlag.length > 0) { + result += " " + aBox.specialUseFlag; + } + result += ') "' + aBox.delimiter + '" "' + aBox.displayName + '"\0'; + return result; + }, + STORE(args, uid) { + let regex = /[+-]?FLAGS.*/; + if (regex.test(args[1])) { + // if we are storing flags, use the method that was overridden + this._argFormat = this._preGMAIL_STORE_argFormat; + args = this._treatArgs(args, "STORE"); + return this._preGMAIL_STORE(args, uid); + } + // otherwise, handle gmail specific cases + let ids = []; + let messages = this._parseSequenceSet(args[0], uid, ids); + args[2] = formatArg(args[2], "string|(string)"); + for (let i = 0; i < args[2].length; i++) { + if (args[2][i].includes(" ")) { + args[2][i] = '"' + args[2][i] + '"'; + } + } + let response = ""; + for (let i = 0; i < messages.length; i++) { + let message = messages[i]; + switch (args[1]) { + case "X-GM-LABELS": + if (message.xGmLabels) { + message.xGmLabels = args[2]; + } else { + return "BAD can't store X-GM-LABELS"; + } + break; + case "+X-GM-LABELS": + if (message.xGmLabels) { + message.xGmLabels = message.xGmLabels.concat(args[2]); + } else { + return "BAD can't store X-GM-LABELS"; + } + break; + case "-X-GM-LABELS": + if (message.xGmLabels) { + for (let i = 0; i < args[2].length; i++) { + let idx = message.xGmLabels.indexOf(args[2][i]); + if (idx != -1) { + message.xGmLabels.splice(idx, 1); + } + } + } else { + return "BAD can't store X-GM-LABELS"; + } + break; + default: + return "BAD change what now?"; + } + response += "* " + ids[i] + " FETCH (X-GM-LABELS ("; + response += message.xGmLabels.join(" "); + response += "))\0"; + } + return response + "OK STORE completed"; + }, + _FETCH_X_GM_MSGID(message) { + if (message.xGmMsgid) { + return "X-GM-MSGID " + message.xGmMsgid; + } + return "BAD can't fetch X-GM-MSGID"; + }, + _FETCH_X_GM_THRID(message) { + if (message.xGmThrid) { + return "X-GM-THRID " + message.xGmThrid; + } + return "BAD can't fetch X-GM-THRID"; + }, + _FETCH_X_GM_LABELS(message) { + if (message.xGmLabels) { + return "X-GM-LABELS " + message.xGmLabels; + } + return "BAD can't fetch X-GM-LABELS"; + }, + kCapabilities: ["XLIST", "X-GM-EXT-1"], + _argFormat: { XLIST: ["mailbox", "mailbox"] }, + // Enabled in AUTHED and SELECTED states + _enabledCommands: { 1: ["XLIST"], 2: ["XLIST"] }, +}; + +var IMAP_MOVE_extension = { + MOVE(args, uid) { + let messages = this._parseSequenceSet(args[0], uid); + + let dest = this._daemon.getMailbox(args[1]); + if (!dest) { + return "NO [TRYCREATE] what mailbox?"; + } + + for (var message of messages) { + let newMessage = new ImapMessage( + message._URI, + dest.uidnext++, + message.flags + ); + newMessage.recent = false; + dest.addMessage(newMessage); + } + let mailbox = this._selectedMailbox; + let response = ""; + for (let i = messages.length - 1; i >= 0; i--) { + let msgIndex = mailbox._messages.indexOf(messages[i]); + if (msgIndex != -1) { + response += "* " + (msgIndex + 1) + " EXPUNGE\0"; + mailbox._messages.splice(msgIndex, 1); + } + } + if (response.length > 0) { + delete mailbox.__highestuid; + } + + return response + "OK MOVE completed"; + }, + kCapabilities: ["MOVE"], + kUidCommands: ["MOVE"], + _argFormat: { MOVE: ["number", "mailbox"] }, + // Enabled in SELECTED state + _enabledCommands: { 2: ["MOVE"] }, +}; + +// Provides methods for testing fetchCustomAttribute and issueCustomCommand +var IMAP_CUSTOM_extension = { + preload(toBeThis) { + toBeThis._preCUSTOM_STORE = toBeThis.STORE; + toBeThis._preCUSTOM_STORE_argFormat = toBeThis._argFormat.STORE; + toBeThis._argFormat.STORE = ["number", "atom", "..."]; + }, + STORE(args, uid) { + let regex = /[+-]?FLAGS.*/; + if (regex.test(args[1])) { + // if we are storing flags, use the method that was overridden + this._argFormat = this._preCUSTOM_STORE_argFormat; + args = this._treatArgs(args, "STORE"); + return this._preCUSTOM_STORE(args, uid); + } + // otherwise, handle custom attribute + let ids = []; + let messages = this._parseSequenceSet(args[0], uid, ids); + args[2] = formatArg(args[2], "string|(string)"); + for (let i = 0; i < args[2].length; i++) { + if (args[2][i].includes(" ")) { + args[2][i] = '"' + args[2][i] + '"'; + } + } + let response = ""; + for (let i = 0; i < messages.length; i++) { + let message = messages[i]; + switch (args[1]) { + case "X-CUSTOM-VALUE": + if (message.xCustomValue && args[2].length == 1) { + message.xCustomValue = args[2][0]; + } else { + return "BAD can't store X-CUSTOM-VALUE"; + } + break; + case "X-CUSTOM-LIST": + if (message.xCustomList) { + message.xCustomList = args[2]; + } else { + return "BAD can't store X-CUSTOM-LIST"; + } + break; + case "+X-CUSTOM-LIST": + if (message.xCustomList) { + message.xCustomList = message.xCustomList.concat(args[2]); + } else { + return "BAD can't store X-CUSTOM-LIST"; + } + break; + case "-X-CUSTOM-LIST": + if (message.xCustomList) { + for (let i = 0; i < args[2].length; i++) { + let idx = message.xCustomList.indexOf(args[2][i]); + if (idx != -1) { + message.xCustomList.splice(idx, 1); + } + } + } else { + return "BAD can't store X-CUSTOM-LIST"; + } + break; + default: + return "BAD change what now?"; + } + response += "* " + ids[i] + " FETCH (X-CUSTOM-LIST ("; + response += message.xCustomList.join(" "); + response += "))\0"; + } + return response + "OK STORE completed"; + }, + _FETCH_X_CUSTOM_VALUE(message) { + if (message.xCustomValue) { + return "X-CUSTOM-VALUE " + message.xCustomValue; + } + return "BAD can't fetch X-CUSTOM-VALUE"; + }, + _FETCH_X_CUSTOM_LIST(message) { + if (message.xCustomList) { + return "X-CUSTOM-LIST (" + message.xCustomList.join(" ") + ")"; + } + return "BAD can't fetch X-CUSTOM-LIST"; + }, + kCapabilities: ["X-CUSTOM1"], +}; + +// RFC 2197: ID +var IMAP_RFC2197_extension = { + ID(args) { + let clientID = "("; + for (let i of args) { + clientID += '"' + i + '"'; + } + + clientID += ")"; + let clientStrings = clientID.split(","); + clientID = ""; + for (let i of clientStrings) { + clientID += '"' + i + '" '; + } + clientID = clientID.slice(1, clientID.length - 3); + clientID += ")"; + this._daemon.clientID = clientID; + return "* ID " + this._daemon.idResponse + "\0OK Success"; + }, + kCapabilities: ["ID"], + _argFormat: { ID: ["(string)"] }, + _enabledCommands: { 1: ["ID"], 2: ["ID"] }, +}; + +// RFC 2342: IMAP4 Namespace (NAMESPACE) +var IMAP_RFC2342_extension = { + NAMESPACE(args) { + var namespaces = [[], [], []]; + for (let namespace of this._daemon.namespaces) { + namespaces[namespace.type].push(namespace); + } + + var response = "* NAMESPACE"; + for (var type of namespaces) { + if (type.length == 0) { + response += " NIL"; + continue; + } + response += " ("; + for (let namespace of type) { + response += '("'; + response += namespace.displayName; + response += '" "'; + response += namespace.delimiter; + response += '")'; + } + response += ")"; + } + response += "\0OK NAMESPACE command completed"; + return response; + }, + kCapabilities: ["NAMESPACE"], + _argFormat: { NAMESPACE: [] }, + // Enabled in AUTHED and SELECTED states + _enabledCommands: { 1: ["NAMESPACE"], 2: ["NAMESPACE"] }, +}; + +// RFC 3348 Child Mailbox (CHILDREN) +var IMAP_RFC3348_extension = { + kCapabilities: ["CHILDREN"], +}; + +// RFC 4315: UIDPLUS +var IMAP_RFC4315_extension = { + preload(toBeThis) { + toBeThis._preRFC4315UID = toBeThis.UID; + toBeThis._preRFC4315APPEND = toBeThis.APPEND; + toBeThis._preRFC4315COPY = toBeThis.COPY; + toBeThis._preRFC4315MOVE = toBeThis.MOVE; + }, + UID(args) { + // XXX: UID EXPUNGE is not supported. + return this._preRFC4315UID(args); + }, + APPEND(args) { + let response = this._preRFC4315APPEND(args); + if (response.indexOf("OK") == 0) { + let mailbox = this._daemon.getMailbox(args[0]); + let uid = mailbox.uidnext - 1; + response = + "OK [APPENDUID " + + mailbox.uidvalidity + + " " + + uid + + "]" + + response.substring(2); + } + return response; + }, + COPY(args) { + let mailbox = this._daemon.getMailbox(args[0]); + if (mailbox) { + var first = mailbox.uidnext; + } + let response = this._preRFC4315COPY(args); + if (response.indexOf("OK") == 0) { + let last = mailbox.uidnext - 1; + response = + "OK [COPYUID " + + this._selectedMailbox.uidvalidity + + " " + + args[0] + + " " + + first + + ":" + + last + + "]" + + response.substring(2); + } + return response; + }, + MOVE(args) { + let mailbox = this._daemon.getMailbox(args[1]); + if (mailbox) { + var first = mailbox.uidnext; + } + let response = this._preRFC4315MOVE(args); + if (response.includes("OK MOVE")) { + let last = mailbox.uidnext - 1; + response = response.replace( + "OK MOVE", + "OK [COPYUID " + + this._selectedMailbox.uidvalidity + + " " + + args[0] + + " " + + first + + ":" + + last + + "]" + ); + } + return response; + }, + kCapabilities: ["UIDPLUS"], +}; + +// RFC 5258: LIST-EXTENDED +var IMAP_RFC5258_extension = { + preload(toBeThis) { + toBeThis._argFormat.LIST = [ + "[(atom)]", + "mailbox", + "mailbox|(mailbox)", + "[atom]", + "[(atom)]", + ]; + }, + _LIST_SUBSCRIBED(aBox) { + if (!aBox.subscribed) { + return ""; + } + + let result = "* LIST (" + aBox.flags.join(" "); + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\Subscribed"; + if (aBox.nonExistent) { + result += " \\NonExistent"; + } + result += ') "' + aBox.delimiter + '" "' + aBox.displayName + '"\0'; + return result; + }, + _LIST_RETURN_CHILDREN(aBox) { + if (aBox.nonExistent) { + return ""; + } + + let result = "* LIST (" + aBox.flags.join(" "); + if (aBox._children.length > 0) { + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\HasChildren"; + } else if (!aBox.flags.includes("\\NoInferiors")) { + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\HasNoChildren"; + } + result += ') "' + aBox.delimiter + '" "' + aBox.displayName + '"\0'; + return result; + }, + _LIST_RETURN_SUBSCRIBED(aBox) { + if (aBox.nonExistent) { + return ""; + } + + let result = "* LIST (" + aBox.flags.join(" "); + if (aBox.subscribed) { + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\Subscribed"; + } + result += ') "' + aBox.delimiter + '" "' + aBox.displayName + '"\0'; + return result; + }, + // TODO implement _LIST_REMOTE, _LIST_RECURSIVEMATCH, _LIST_RETURN_SUBSCRIBED + // and all valid combinations thereof. Currently, nsImapServerResponseParser + // does not support any of these responses anyway. + + kCapabilities: ["LIST-EXTENDED"], +}; + +/** + * This implements AUTH schemes. Could be moved into RFC3501 actually. + * The test can en-/disable auth schemes by modifying kAuthSchemes. + */ +var IMAP_RFC2195_extension = { + kAuthSchemes: ["CRAM-MD5", "PLAIN", "LOGIN"], + + preload(handler) { + handler._kAuthSchemeStartFunction["CRAM-MD5"] = this.authCRAMStart; + handler._kAuthSchemeStartFunction.PLAIN = this.authPLAINStart; + handler._kAuthSchemeStartFunction.LOGIN = this.authLOGINStart; + }, + + authPLAINStart(lineRest) { + this._nextAuthFunction = this.authPLAINCred; + this._multiline = true; + + return "+"; + }, + authPLAINCred(line) { + var req = AuthPLAIN.decodeLine(line); + if (req.username == this.kUsername && req.password == this.kPassword) { + this._state = IMAP_STATE_AUTHED; + return "OK Hello friend! Friends give friends good advice: Next time, use CRAM-MD5"; + } + return "BAD Wrong username or password, crook!"; + }, + + authCRAMStart(lineRest) { + this._nextAuthFunction = this.authCRAMDigest; + this._multiline = true; + + this._usedCRAMMD5Challenge = AuthCRAM.createChallenge("localhost"); + return "+ " + this._usedCRAMMD5Challenge; + }, + authCRAMDigest(line) { + var req = AuthCRAM.decodeLine(line); + var expectedDigest = AuthCRAM.encodeCRAMMD5( + this._usedCRAMMD5Challenge, + this.kPassword + ); + if (req.username == this.kUsername && req.digest == expectedDigest) { + this._state = IMAP_STATE_AUTHED; + return "OK Hello friend!"; + } + return "BAD Wrong username or password, crook!"; + }, + + authLOGINStart(lineRest) { + this._nextAuthFunction = this.authLOGINUsername; + this._multiline = true; + + return "+ " + btoa("Username:"); + }, + authLOGINUsername(line) { + var req = AuthLOGIN.decodeLine(line); + if (req == this.kUsername) { + this._nextAuthFunction = this.authLOGINPassword; + } else { + // Don't return error yet, to not reveal valid usernames + this._nextAuthFunction = this.authLOGINBadUsername; + } + this._multiline = true; + return "+ " + btoa("Password:"); + }, + authLOGINBadUsername(line) { + return "BAD Wrong username or password, crook!"; + }, + authLOGINPassword(line) { + var req = AuthLOGIN.decodeLine(line); + if (req == this.kPassword) { + this._state = IMAP_STATE_AUTHED; + return "OK Hello friend! Where did you pull out this old auth scheme?"; + } + return "BAD Wrong username or password, crook!"; + }, +}; + +// FETCH BODYSTRUCTURE +function bodystructure(msg, extension) { + if (!msg || msg == "") { + return ""; + } + + // Use the mime parser emitter to generate body structure data. Most of the + // string will be built as we exit a part. Currently not working: + // 1. Some of the fields return NIL instead of trying to calculate them. + // 2. MESSAGE is missing the ENVELOPE and the lines at the end. + var bodystruct = ""; + function paramToString(params) { + let paramList = []; + for (let [param, value] of params) { + paramList.push('"' + param.toUpperCase() + '" "' + value + '"'); + } + return paramList.length == 0 ? "NIL" : "(" + paramList.join(" ") + ")"; + } + var headerStack = []; + var BodyStructureEmitter = { + startPart(partNum, headers) { + bodystruct += "("; + headerStack.push(headers); + this.numLines = 0; + this.length = 0; + }, + deliverPartData(partNum, data) { + this.length += data.length; + this.numLines += Array.from(data).filter(x => x == "\n").length; + }, + endPart(partNum) { + // Grab the headers from before + let headers = headerStack.pop(); + let contentType = headers.contentType; + if (contentType.mediatype == "multipart") { + bodystruct += ' "' + contentType.subtype.toUpperCase() + '"'; + if (extension) { + bodystruct += " " + paramToString(contentType); + // XXX: implement the rest + bodystruct += " NIL NIL NIL"; + } + } else { + bodystruct += + '"' + + contentType.mediatype.toUpperCase() + + '" "' + + contentType.subtype.toUpperCase() + + '"'; + bodystruct += " " + paramToString(contentType); + + // XXX: Content ID, Content description + bodystruct += " NIL NIL"; + + let cte = headers.has("content-transfer-encoding") + ? headers.get("content-transfer-encoding") + : "7BIT"; + bodystruct += ' "' + cte + '"'; + + bodystruct += " " + this.length; + if (contentType.mediatype == "text") { + bodystruct += " " + this.numLines; + } + + // XXX: I don't want to implement these yet + if (extension) { + bodystruct += " NIL NIL NIL NIL"; + } + } + bodystruct += ")"; + }, + }; + MimeParser.parseSync(msg, BodyStructureEmitter, {}); + return bodystruct; +} |