/* 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/. */ const EXPORTED_SYMBOLS = ["ImapResponse"]; var { ImapUtils } = ChromeUtils.import("resource:///modules/ImapUtils.jsm"); /** * A structure to represent a server response. */ class ImapResponse { constructor() { // @type {MailboxData[]} The mailbox-data in this response. this.mailboxes = []; // @type {MessageData[]} The message-data in this response. this.messages = []; // A holder for attributes. this.attributes = {}; // Expunged message sequences. this.expunged = []; // The remaining string to parse. this._response = ""; this.onMessage = () => {}; } /** * A server response can span multiple chunks, this function parses one chunk. * * @param {string} str - A chunk of server response. */ parse(str) { this._response += str; if (this._pendingMessage) { // We have an unfinished message in the last chunk. let remaining = this._pendingMessage.bodySize - this._pendingMessage.body.length; if (remaining + ")\r\n".length <= this._response.length) { // Consume the message together with the ending ")\r\n". this._pendingMessage.body += this._response.slice(0, remaining); this.onMessage(this._pendingMessage); this._pendingMessage = null; this._advance(remaining + ")\r\n".length); } else { this.done = false; return; } } this._parse(); } /** * Drop n characters from _response. * * @param {number} n - The number of characters to drop. */ _advance(n) { this._response = this._response.slice(n); } /** * Parse the response line by line. Because a single response can contain * multiple types of data, update the corresponding properties after parsing * a line, e.g. this.capabilities, this.flags, this.messages. */ _parse() { if (!this._response && this.tag != "*") { // Nothing more to parse. this.done = true; return; } let index = this._response.indexOf("\r\n"); if (index == -1) { // Expect more string in the next chunk. this.done = false; return; } let line = this._response.slice(0, index); this._advance(index + 2); // Consume the line and "\r\n". let tokens = this._parseLine(line); this.tag = tokens[0]; this.status = tokens[1]; if (this.tag == "+") { this.statusText = tokens.slice(1).join(" "); if (!this._response) { this.done = true; return; } } let parsed; if (this.tag == "*") { parsed = true; switch (tokens[1].toUpperCase()) { case "CAPABILITY": // * CAPABILITY IMAP4rev1 IDLE STARTTLS AUTH=LOGIN AUTH=PLAIN let { capabilities, authMethods } = new CapabilityData( tokens.slice(2) ); this.capabilities = capabilities; this.authMethods = authMethods; break; case "FLAGS": // * FLAGS (\Seen \Draft $Forwarded) this.flags = ImapUtils.stringsToFlags(tokens[2]); if (tokens[2].includes("\\*")) { this.supportedUserFlags = ImapUtils.FLAG_LABEL | ImapUtils.FLAG_MDN_SENT | ImapUtils.FLAG_SUPPORT_FORWARDED_FLAG | ImapUtils.FLAG_SUPPORT_USER_FLAG; } break; case "ID": // * ID ("name" "imap" "vendor" "Example, Inc.") this.id = line.slice("* ID ".length); break; case "LIST": case "LSUB": // * LIST (\Subscribed \NoInferiors \UnMarked \Sent) "/" Sent this.mailboxes.push(new MailboxData(tokens)); break; case "QUOTAROOT": // * QUOTAROOT Sent INBOX this.quotaRoots = tokens.slice(3); break; case "QUOTA": // S: * QUOTA INBOX (STORAGE 95295 97656832) if (!this.quotas) { this.quotas = []; } this.quotas.push([tokens[2], ...tokens[3]]); break; case "SEARCH": // * SEARCH 1 4 9 this.search = tokens.slice(2).map(x => Number(x)); break; case "STATUS": // * STATUS \"folder 2\" (UIDNEXT 2 MESSAGES 1 UNSEEN 1) this.attributes = new StatusData(tokens).attributes; break; default: if (Number.isInteger(+tokens[1])) { this._parseNumbered(tokens); } else { parsed = false; } break; } } if (!parsed && Array.isArray(tokens[2])) { let type = tokens[2][0].toUpperCase(); let data = tokens[2].slice(1); switch (type) { case "CAPABILITY": // 32 OK [CAPABILITY IMAP4rev1 IDLE STARTTLS AUTH=LOGIN AUTH=PLAIN] let { capabilities, authMethods } = new CapabilityData(data); this.capabilities = capabilities; this.authMethods = authMethods; break; case "PERMANENTFLAGS": // * OK [PERMANENTFLAGS (\\Seen \\Draft $Forwarded \\*)] this.permanentflags = ImapUtils.stringsToFlags(tokens[2][1]); if (tokens[2][1].includes("\\*")) { this.supportedUserFlags = ImapUtils.FLAG_LABEL | ImapUtils.FLAG_MDN_SENT | ImapUtils.FLAG_SUPPORT_FORWARDED_FLAG | ImapUtils.FLAG_SUPPORT_USER_FLAG; } break; default: let field = type.toLowerCase(); if (tokens[2].length == 1) { // A boolean attribute, e.g. 12 OK [READ-WRITE] this[field] = true; } else if (tokens[2].length == 2) { // An attribute/value pair, e.g. 12 OK [UIDNEXT 600] this[field] = tokens[2][1]; } else { // Hold other attributes. this.attributes[field] = data; } } } this._parse(); } /** * Handle the tokens of a line in the form of "* NUM TYPE". * * @params {Array} tokens - The tokens of the line. */ _parseNumbered(tokens) { let intValue = +tokens[1]; let type = tokens[2].toUpperCase(); switch (type) { case "FETCH": // * 1 FETCH (UID 5 FLAGS (\SEEN) BODY[HEADER.FIELDS (FROM TO)] {12} let message = new MessageData(intValue, tokens[3]); this.messages.push(message); if (message.bodySize) { if (message.bodySize + ")\r\n".length <= this._response.length) { // Consume the message together with the ending ")\r\n". message.body = this._response.slice(0, message.bodySize); this.onMessage(message); } else { message.body = this._response; this._pendingMessage = message; this.done = false; } this._advance(message.bodySize + ")\r\n".length); } else { this.onMessage(message); } break; case "EXISTS": // * 6 EXISTS this.exists = intValue; break; case "EXPUNGE": // * 2 EXPUNGE this.expunged.push(intValue); break; case "RECENT": // Deprecated in rfc9051. break; default: throw Components.Exception( `Unrecognized response: ${tokens.join(" ")}`, Cr.NS_ERROR_ILLEGAL_VALUE ); } } /** * Break a line into flat tokens array. For example, * "(UID 24 FLAGS (NonJunk))" will be tokenized to * ["(", "UID", "24", "FLAGS", "(", "NonJunk", ")", ")"]. * * @param {string} line - A single line of string. * @returns {string[]} */ _tokenize(line) { const SEPARATORS = /[()\[\]" ]/; let tokens = []; while (line) { // Find the first separator. let index = line.search(SEPARATORS); if (index == -1) { tokens.push(line); break; } let sep = line[index]; let token = line.slice(0, index); if (token) { tokens.push(token); } if (sep == '"') { // Parse the whole string as a token. line = line.slice(index + 1); let str = sep; while (true) { index = line.indexOf('"'); if (line[index - 1] == "\\") { // Not the ending quote. str += line.slice(0, index + 1); line = line.slice(index + 1); continue; } else { // The ending quote. str += line.slice(0, index + 1); tokens.push(str); line = line.slice(index + 1); break; } } continue; } else if (sep != " ") { tokens.push(sep); } line = line.slice(index + 1); } return tokens; } /** * Parse a line into nested tokens array. For example, * "(UID 24 FLAGS (NonJunk))" will be parsed to * ["UID", "24", "FLAGS", ["NonJunk"]]. * * @param {string} line - A single line of string. * @returns {Array} */ _parseLine(line) { let tokens = []; let arrayDepth = 0; for (let token of this._tokenize(line)) { let depth = arrayDepth; let arr = tokens; while (depth-- > 0) { arr = arr.at(-1); } switch (token) { case "(": case "[": arr.push([]); arrayDepth++; break; case ")": case "]": arrayDepth--; break; default: arr.push(token); } } return tokens; } } /** * A structure to represent capability-data. */ class CapabilityData { /** * @param {string[]} tokens - An array like: ["IMAP4rev1", "IDLE", "STARTTLS", * "AUTH=LOGIN", "AUTH=PLAIN"]. */ constructor(tokens) { this.capabilities = []; this.authMethods = []; for (let cap of tokens) { cap = cap.toUpperCase(); if (cap.startsWith("AUTH=")) { this.authMethods.push(cap.slice(5)); } else { this.capabilities.push(cap); } } } } /** * A structure to represent message-data. */ class MessageData { /** * @param {number} sequence - The sequence number of this message. * @param {string[]} tokens - An array like: ["UID", "24", "FLAGS", ["\Seen"]]. */ constructor(sequence, tokens) { this.sequence = sequence; this.customAttributes = {}; for (let i = 0; i < tokens.length; i += 2) { let name = tokens[i].toUpperCase(); switch (name) { case "UID": this.uid = +tokens[i + 1]; break; case "FLAGS": this.flags = ImapUtils.stringsToFlags(tokens[i + 1]); this.keywords = tokens[i + 1] .filter(x => !x.startsWith("\\")) .join(" "); break; case "BODY": { // bodySection is the part between [ and ]. this.bodySection = tokens[i + 1]; i++; // {123} means the following 123 bytes are the body. let matches = tokens[i + 1].match(/{(\d+)}/); if (matches) { this.bodySize = +matches[1]; this.body = ""; } break; } case "RFC822.SIZE": { this.size = +tokens[i + 1]; break; } default: this.customAttributes[tokens[i]] = tokens[i + 1]; break; } } } } /** * A structure to represent mailbox-data. */ class MailboxData { constructor(tokens) { let [, , attributes, delimiter, name] = tokens; this.flags = this._stringsToFlags(attributes); this.delimiter = unwrapString(delimiter); this.name = unwrapString(name); } /** * Convert an array of flag string to an internal flag number. * * @param {string[]} arr - An array of flag string. * @returns {number} An internal flag number. */ _stringsToFlags(arr) { let stringToFlag = { "\\MARKED": ImapUtils.FLAG_MARKED, "\\UNMARKED": ImapUtils.FLAG_UNMARKED, "\\NOINFERIORS": // RFC 5258 \NoInferiors implies \HasNoChildren ImapUtils.FLAG_NO_INFERIORS | ImapUtils.FLAG_HAS_NO_CHILDREN, "\\NOSELECT": ImapUtils.FLAG_NO_SELECT, "\\TRASH": ImapUtils.FLAG_IMAP_TRASH | ImapUtils.FLAG_IMAP_XLIST_TRASH, "\\SENT": ImapUtils.FLAG_IMAP_SENT, "\\DRAFTS": ImapUtils.FLAG_IMAP_DRAFTS, "\\SPAM": ImapUtils.FLAG_IMAP_SPAM, "\\JUNK": ImapUtils.FLAG_IMAP_SPAM, "\\ARCHIVE": ImapUtils.FLAG_IMAP_ARCHIVE, "\\ALL": ImapUtils.FLAG_IMAP_ALL_MAIL, "\\ALLMAIL": ImapUtils.FLAG_IMAP_ALL_MAIL, "\\INBOX": ImapUtils.FLAG_IMAP_INBOX, "\\NONEXISTENT": // RFC 5258 \NonExistent implies \NoSelect ImapUtils.FLAG_NON_EXISTENT | ImapUtils.FLAG_NO_SELECT, "\\SUBSCRIBED": ImapUtils.FLAG_SUBSCRIBED, "\\REMOTE": ImapUtils.FLAG_REMOTE, "\\HASCHILDREN": ImapUtils.FLAG_HAS_CHILDREN, "\\HASNOCHILDREN": ImapUtils.FLAG_HAS_NO_CHILDREN, }; let flags = 0; for (let str of arr) { flags |= stringToFlag[str.toUpperCase()] || 0; } return flags; } } /** * A structure to represent STATUS data. * STATUS \"folder 2\" (UIDNEXT 2 MESSAGES 1 UNSEEN 1) */ class StatusData { /** * @params {Array} tokens - The tokens of the line. */ constructor(tokens) { this.attributes = {}; // The first two tokens are ["*", "STATUS"], the last token is the attribute // list, the middle part is the mailbox name. this.attributes.mailbox = unwrapString(tokens[2]); let attributes = tokens.at(-1); for (let i = 0; i < attributes.length; i += 2) { let type = attributes[i].toLowerCase(); this.attributes[type] = attributes[i + 1]; } } } /** * Following rfc3501 section-5.1 and section-9, this function does two things: * 1. Remove the wrapping DQUOTE. * 2. Unesacpe QUOTED-CHAR. * * @params {string} name - E.g. `"a \"b\" c"` will become `a "b" c`. */ function unwrapString(name) { return name.replace(/(^"|"$)/g, "").replaceAll('\\"', '"'); }