/* 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, CREATE a
CREATE a/b
fails where * CREATE a/
CREATE a/b
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; }