summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/imap/src/ImapResponse.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mailnews/imap/src/ImapResponse.jsm')
-rw-r--r--comm/mailnews/imap/src/ImapResponse.jsm479
1 files changed, 479 insertions, 0 deletions
diff --git a/comm/mailnews/imap/src/ImapResponse.jsm b/comm/mailnews/imap/src/ImapResponse.jsm
new file mode 100644
index 0000000000..08d1c25916
--- /dev/null
+++ b/comm/mailnews/imap/src/ImapResponse.jsm
@@ -0,0 +1,479 @@
+/* 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<string|string[]>} 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<string|string[]>}
+ */
+ _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<string|string[]>} 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('\\"', '"');
+}