summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/imap/src/ImapClient.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mailnews/imap/src/ImapClient.jsm')
-rw-r--r--comm/mailnews/imap/src/ImapClient.jsm1895
1 files changed, 1895 insertions, 0 deletions
diff --git a/comm/mailnews/imap/src/ImapClient.jsm b/comm/mailnews/imap/src/ImapClient.jsm
new file mode 100644
index 0000000000..319a03b958
--- /dev/null
+++ b/comm/mailnews/imap/src/ImapClient.jsm
@@ -0,0 +1,1895 @@
+/* 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 = ["ImapClient"];
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { clearTimeout, setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+var { MailStringUtils } = ChromeUtils.import(
+ "resource:///modules/MailStringUtils.jsm"
+);
+var { ImapAuthenticator } = ChromeUtils.import(
+ "resource:///modules/MailAuthenticator.jsm"
+);
+var { ImapResponse } = ChromeUtils.import(
+ "resource:///modules/ImapResponse.jsm"
+);
+var { ImapUtils } = ChromeUtils.import("resource:///modules/ImapUtils.jsm");
+
+// There can be multiple ImapClient running concurrently, assign each logger a
+// unique prefix.
+let loggerInstanceId = 0;
+
+const PR_UINT32_MAX = 0xffffffff;
+
+/**
+ * A class to interact with IMAP server.
+ */
+class ImapClient {
+ _logger = console.createInstance({
+ prefix: `mailnews.imap.${loggerInstanceId++}`,
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mailnews.imap.loglevel",
+ });
+
+ /**
+ * @param {nsIImapIncomingServer} server - The associated server instance.
+ */
+ constructor(server) {
+ this._server = server.QueryInterface(Ci.nsIMsgIncomingServer);
+ this._serverSink = this._server.QueryInterface(Ci.nsIImapServerSink);
+ this._authenticator = new ImapAuthenticator(server);
+
+ // Auth methods detected from the CAPABILITY response.
+ this._supportedAuthMethods = [];
+ // Subset of _supportedAuthMethods that are allowed by user preference.
+ this._possibleAuthMethods = [];
+ // Auth methods set by user preference.
+ this._preferredAuthMethods =
+ {
+ [Ci.nsMsgAuthMethod.passwordCleartext]: ["PLAIN", "LOGIN"],
+ [Ci.nsMsgAuthMethod.passwordEncrypted]: ["CRAM-MD5"],
+ [Ci.nsMsgAuthMethod.GSSAPI]: ["GSSAPI"],
+ [Ci.nsMsgAuthMethod.NTLM]: ["NTLM"],
+ [Ci.nsMsgAuthMethod.OAuth2]: ["XOAUTH2"],
+ [Ci.nsMsgAuthMethod.External]: ["EXTERNAL"],
+ }[server.authMethod] || [];
+ // The next auth method to try if the current failed.
+ this._nextAuthMethod = null;
+
+ this._tag = Math.floor(100 * Math.random());
+ this._charsetManager = Cc[
+ "@mozilla.org/charset-converter-manager;1"
+ ].getService(Ci.nsICharsetConverterManager);
+
+ this._messageUids = [];
+ this._messages = new Map();
+
+ this._loadPrefs();
+ }
+
+ /**
+ * @type {boolean} - Whether the socket is open.
+ */
+ get isOnline() {
+ return this._socket?.readyState == "open";
+ }
+
+ /**
+ * Load imap related preferences, many behaviors depend on these pref values.
+ */
+ _loadPrefs() {
+ this._prefs = {
+ tcpTimeout: Services.prefs.getIntPref("mailnews.tcptimeout"),
+ };
+ }
+
+ /**
+ * Reset some internal states to be safely reused.
+ */
+ _reset() {
+ this.onData = () => {};
+ this.onDone = () => {};
+
+ this._actionAfterDiscoverAllFolders = null;
+ this.channel = null;
+ this._urlListener = null;
+ this._msgWindow = null;
+ this._authenticating = false;
+ this.verifyLogon = false;
+ this._idling = false;
+ if (this._idleTimer) {
+ clearTimeout(this._idleTimer);
+ this._idleTimer = null;
+ }
+ }
+
+ /**
+ * Initiate a connection to the server
+ */
+ connect() {
+ if (this.isOnline) {
+ // Reuse the connection.
+ this.onReady();
+ this._setSocketTimeout(this._prefs.tcpTimeout);
+ } else {
+ let hostname = this._server.hostName.toLowerCase();
+ this._logger.debug(`Connecting to ${hostname}:${this._server.port}`);
+ this._greeted = false;
+ this._capabilities = null;
+ this._secureTransport = this._server.socketType == Ci.nsMsgSocketType.SSL;
+ this._socket = new TCPSocket(hostname, this._server.port, {
+ binaryType: "arraybuffer",
+ useSecureTransport: this._secureTransport,
+ });
+ this._socket.onopen = this._onOpen;
+ this._socket.onerror = this._onError;
+ }
+ }
+
+ /**
+ * Set socket timeout in seconds.
+ *
+ * @param {number} timeout - The timeout in seconds.
+ */
+ _setSocketTimeout(timeout) {
+ this._socket.transport?.setTimeout(
+ Ci.nsISocketTransport.TIMEOUT_READ_WRITE,
+ timeout
+ );
+ }
+
+ /**
+ * Construct an nsIMsgMailNewsUrl instance, setup urlListener to notify when
+ * the current request is finished.
+ *
+ * @param {nsIUrlListener} urlListener - Callback for the request.
+ * @param {nsIMsgWindow} msgWindow - The associated msg window.
+ * @param {nsIMsgMailNewsUrl} [runningUri] - The url to run, if provided.
+ * @returns {nsIMsgMailNewsUrl}
+ */
+ startRunningUrl(urlListener, msgWindow, runningUri) {
+ this._urlListener = urlListener;
+ this._msgWindow = msgWindow;
+ this.runningUri = runningUri;
+ if (!this.runningUri) {
+ this.runningUri = Services.io.newURI(
+ `imap://${this._server.hostName}:${this._server.port}`
+ );
+ }
+ this._urlListener?.OnStartRunningUrl(this.runningUri, Cr.NS_OK);
+ this.runningUri
+ .QueryInterface(Ci.nsIMsgMailNewsUrl)
+ .SetUrlState(true, Cr.NS_OK);
+ return this.runningUri;
+ }
+
+ /**
+ * Discover all folders if the current server hasn't already discovered.
+ */
+ _discoverAllFoldersIfNecessary = () => {
+ if (this._server.hasDiscoveredFolders) {
+ this.onReady();
+ return;
+ }
+ this._actionAfterDiscoverAllFolders = this.onReady;
+ this.discoverAllFolders(this._server.rootFolder);
+ };
+
+ /**
+ * Discover all folders.
+ *
+ * @param {nsIMsgFolder} folder - The associated folder.
+ */
+ discoverAllFolders(folder) {
+ this._logger.debug("discoverAllFolders", folder.URI);
+
+ let handleListResponse = res => {
+ this._hasTrash = res.mailboxes.some(
+ mailbox => mailbox.flags & ImapUtils.FLAG_IMAP_TRASH
+ );
+ if (!this._hasTrash) {
+ let trashFolderName = this._server.trashFolderName.toLowerCase();
+ let trashMailbox = res.mailboxes.find(
+ mailbox => mailbox.name.toLowerCase() == trashFolderName
+ );
+ if (trashMailbox) {
+ this._hasTrash = true;
+ trashMailbox.flags |= ImapUtils.FLAG_IMAP_TRASH;
+ }
+ }
+ for (let mailbox of res.mailboxes) {
+ this._serverSink.possibleImapMailbox(
+ mailbox.name.replaceAll(mailbox.delimiter, "/"),
+ mailbox.delimiter,
+ mailbox.flags
+ );
+ }
+ };
+
+ if (this._capabilities.includes("LIST-EXTENDED")) {
+ this._nextAction = res => {
+ handleListResponse(res);
+ this._actionFinishFolderDiscovery();
+ };
+ let command = 'LIST (SUBSCRIBED) "" "*"';
+ if (this._capabilities.includes("SPECIAL-USE")) {
+ command += " RETURN (SPECIAL-USE)"; // rfc6154
+ }
+ this._sendTagged(command);
+ return;
+ }
+
+ this._nextAction = res => {
+ this._nextAction = res2 => {
+ // Per rfc3501#section-6.3.9, if LSUB returns different flags from LIST,
+ // use the LIST responses.
+ for (let mailbox of res2.mailboxes) {
+ let mailboxFromList = res.mailboxes.find(x => x.name == mailbox.name);
+ if (
+ mailboxFromList?.flags &&
+ mailboxFromList?.flags != mailbox.flags
+ ) {
+ mailbox.flags = mailboxFromList.flags;
+ }
+ }
+ handleListResponse(res2);
+ this._actionFinishFolderDiscovery();
+ };
+ this._sendTagged('LSUB "" "*"');
+ };
+ let command = 'LIST "" "*"';
+ if (this._capabilities.includes("SPECIAL-USE")) {
+ command += " RETURN (SPECIAL-USE)"; // rfc6154
+ }
+ this._sendTagged(command);
+ }
+
+ /**
+ * Discover all folders for the subscribe dialog.
+ *
+ * @param {nsIMsgFolder} folder - The associated folder.
+ */
+ discoverAllAndSubscribedFolders(folder) {
+ this._logger.debug("discoverAllAndSubscribedFolders", folder.URI);
+ let handleListResponse = res => {
+ for (let mailbox of res.mailboxes) {
+ this._serverSink.possibleImapMailbox(
+ mailbox.name.replaceAll(mailbox.delimiter, "/"),
+ mailbox.delimiter,
+ mailbox.flags
+ );
+ }
+ };
+
+ this._nextAction = res => {
+ handleListResponse(res);
+ this._server.doingLsub = false;
+ this._nextAction = res2 => {
+ // Per rfc3501#section-6.3.9, if LSUB returns different flags from LIST,
+ // use the LIST responses.
+ for (let mailbox of res2.mailboxes) {
+ let mailboxFromList = res.mailboxes.find(x => x.name == mailbox.name);
+ if (
+ mailboxFromList?.flags &&
+ mailboxFromList?.flags != mailbox.flags
+ ) {
+ mailbox.flags = mailboxFromList.flags;
+ }
+ }
+ handleListResponse(res2);
+ this._actionDone();
+ };
+ this._sendTagged('LIST "" "*"');
+ };
+ this._sendTagged('LSUB "" "*"');
+ this._server.doingLsub = true;
+ }
+
+ /**
+ * Select a folder.
+ *
+ * @param {nsIMsgFolder} folder - The folder to select.
+ */
+ selectFolder(folder) {
+ this._logger.debug("selectFolder", folder.URI);
+ if (this.folder == folder) {
+ this._actionNoop();
+ return;
+ }
+ this._actionAfterSelectFolder = this._actionUidFetch;
+ this._nextAction = this._actionSelectResponse(folder);
+ this._sendTagged(`SELECT "${this._getServerFolderName(folder)}"`);
+ }
+
+ /**
+ * Rename a folder.
+ *
+ * @param {nsIMsgFolder} folder - The folder to rename.
+ * @param {string} newName - The new folder name.
+ */
+ renameFolder(folder, newName) {
+ this._logger.debug("renameFolder", folder.URI, newName);
+ let delimiter =
+ folder.QueryInterface(Ci.nsIMsgImapMailFolder).hierarchyDelimiter || "/";
+ let names = this._getAncestorFolderNames(folder);
+ let oldName = this._getServerFolderName(folder);
+ newName = this._encodeMailboxName([...names, newName].join(delimiter));
+
+ this._nextAction = this._actionRenameResponse(oldName, newName);
+ this._sendTagged(`RENAME "${oldName}" "${newName}"`);
+ }
+
+ /**
+ * Move a source folder to be a child of another folder.
+ *
+ * @param {nsIMsgFolder} srcFolder - The source folder to move.
+ * @param {nsIMsgFolder} dstFolder - The target parent folder.
+ */
+ moveFolder(srcFolder, dstFolder) {
+ this._logger.debug("moveFolder", srcFolder.URI, dstFolder.URI);
+ let oldName = this._getServerFolderName(srcFolder);
+ let newName = this._getServerSubFolderName(dstFolder, srcFolder.name);
+ this._nextAction = this._actionRenameResponse(oldName, newName, true);
+ this._sendTagged(`RENAME "${oldName}" "${newName}"`);
+ }
+
+ /**
+ * Send LIST command for a folder.
+ *
+ * @param {nsIMsgFolder} folder - The folder to list.
+ */
+ listFolder(folder) {
+ this._logger.debug("listFolder", folder.URI);
+ this._actionList(this._getServerFolderName(folder), () => {
+ this._actionDone();
+ });
+ }
+
+ /**
+ * Send DELETE command for a folder and all subfolders.
+ *
+ * @param {nsIMsgFolder} folder - The folder to delete.
+ */
+ deleteFolder(folder) {
+ this._logger.debug("deleteFolder", folder.URI);
+ this._nextAction = res => {
+ // Leaves have longer names than parent mailbox, sort them by the name
+ // length, so that leaf mailbox will be deleted first.
+ let mailboxes = res.mailboxes.sort(
+ (x, y) => y.name.length - x.name.length
+ );
+ let selfName = this._getServerFolderName(folder);
+ let selfIncluded = false;
+ this._nextAction = () => {
+ let mailbox = mailboxes.shift();
+ if (mailbox) {
+ this._sendTagged(`DELETE "${mailbox.name}"`);
+ if (!selfIncluded && selfName == mailbox.name) {
+ selfIncluded = true;
+ }
+ } else if (!selfIncluded) {
+ this._nextAction = () => this._actionDone();
+ this._sendTagged(`DELETE "${this._getServerFolderName(folder)}"`);
+ } else {
+ this._actionDone();
+ }
+ };
+ this._nextAction();
+ };
+ this._sendTagged(`LIST "" "${this._getServerFolderName(folder)}"`);
+ }
+
+ /**
+ * Ensure a folder exists on the server. Create one if not already exists.
+ *
+ * @param {nsIMsgFolder} parent - The parent folder to check.
+ * @param {string} folderName - The folder name.
+ */
+ ensureFolderExists(parent, folderName) {
+ this._logger.debug("ensureFolderExists", parent.URI, folderName);
+ let mailboxName = this._getServerSubFolderName(parent, folderName);
+ this._nextAction = res => {
+ if (res.mailboxes.length) {
+ // Already exists.
+ this._actionDone();
+ return;
+ }
+ // Create one and subscribe to it.
+ this._actionCreateAndSubscribe(mailboxName, res => {
+ this._actionList(mailboxName, () => this._actionDone());
+ });
+ };
+ this._sendTagged(`LIST "" "${mailboxName}"`);
+ }
+
+ /**
+ * Create a folder on the server.
+ *
+ * @param {nsIMsgFolder} parent - The parent folder to check.
+ * @param {string} folderName - The folder name.
+ */
+ createFolder(parent, folderName) {
+ this._logger.debug("createFolder", parent.URI, folderName);
+ let mailboxName = this._getServerSubFolderName(parent, folderName);
+ this._actionCreateAndSubscribe(mailboxName, res => {
+ this._actionList(mailboxName, () => this._actionDone());
+ });
+ }
+
+ /**
+ * Subscribe a folder.
+ *
+ * @param {nsIMsgFolder} folder - The folder to subscribe.
+ * @param {string} folderName - The folder name.
+ */
+ subscribeFolder(folder, folderName) {
+ this._logger.debug("subscribeFolder", folder.URI, folderName);
+ this._nextAction = () => this._server.performExpand();
+ this._sendTagged(`SUBSCRIBE "${folderName}"`);
+ }
+
+ /**
+ * Unsubscribe a folder.
+ *
+ * @param {nsIMsgFolder} folder - The folder to unsubscribe.
+ * @param {string} folderName - The folder name.
+ */
+ unsubscribeFolder(folder, folderName) {
+ this._logger.debug("unsubscribeFolder", folder.URI, folderName);
+ this._nextAction = () => this._server.performExpand();
+ this._sendTagged(`UNSUBSCRIBE "${folderName}"`);
+ }
+
+ /**
+ * Fetch the attribute of messages.
+ *
+ * @param {nsIMsgFolder} folder - The folder to check.
+ * @param {string} uids - The message uids.
+ * @param {string} attribute - The message attribute to fetch
+ */
+ fetchMsgAttribute(folder, uids, attribute) {
+ this._logger.debug("fetchMsgAttribute", folder.URI, uids, attribute);
+ this._nextAction = res => {
+ if (res.done) {
+ let resultAttributes = res.messages
+ .map(m => m.customAttributes[attribute])
+ .flat();
+ this.runningUri.QueryInterface(Ci.nsIImapUrl).customAttributeResult =
+ resultAttributes.length > 1
+ ? `(${resultAttributes.join(" ")})`
+ : resultAttributes[0];
+ this._actionDone();
+ }
+ };
+ this._sendTagged(`UID FETCH ${uids} (${attribute})`);
+ }
+
+ /**
+ * Delete all the messages in a folder.
+ *
+ * @param {nsIMsgFolder} folder - The folder to delete messages.
+ */
+ deleteAllMessages(folder) {
+ this._logger.debug("deleteAllMessages", folder.URI);
+ this._actionInFolder(folder, () => {
+ if (!this._messages.size) {
+ this._actionDone();
+ return;
+ }
+
+ this._nextAction = () => this.expunge(folder);
+ this._sendTagged("UID STORE 1:* +FLAGS.SILENT (\\Deleted)");
+ });
+ }
+
+ /**
+ * Search in a folder.
+ *
+ * @param {nsIMsgFolder} folder - The folder to delete messages.
+ * @param {string} searchCommand - The SEARCH command together with the search
+ * criteria.
+ */
+ search(folder, searchCommand) {
+ this._logger.debug("search", folder.URI);
+ this._actionInFolder(folder, () => {
+ this._nextAction = res => {
+ this.onData(res.search);
+ this._actionDone();
+ };
+ this._sendTagged(`UID ${searchCommand}`);
+ });
+ }
+
+ /**
+ * Get the names of all ancestor folders. For example,
+ * folder a/b/c will return ['a', 'b'].
+ *
+ * @param {nsIMsgFolder} folder - The input folder.
+ * @returns {string[]}
+ */
+ _getAncestorFolderNames(folder) {
+ let matches = /imap:\/\/[^/]+\/(.+)/.exec(folder.URI);
+ return matches[1].split("/").slice(0, -1);
+ }
+
+ /**
+ * When UTF8 is enabled, use the name directly. Otherwise, encode to mUTF-7.
+ *
+ * @param {string} name - The mailbox name.
+ */
+ _encodeMailboxName(name) {
+ return this._utf8Enabled ? name : this._charsetManager.unicodeToMutf7(name);
+ }
+
+ /**
+ * Get the server name of a msg folder.
+ *
+ * @param {nsIMsgFolder} folder - The input folder.
+ * @returns {string}
+ */
+ _getServerFolderName(folder) {
+ if (folder.isServer) {
+ return "";
+ }
+
+ if (folder.onlineName) {
+ return folder.onlineName.replaceAll('"', '\\"');
+ }
+ let delimiter =
+ folder.QueryInterface(Ci.nsIMsgImapMailFolder).hierarchyDelimiter || "/";
+ let names = this._getAncestorFolderNames(folder);
+ return this._encodeMailboxName(
+ [...names, folder.name].join(delimiter)
+ ).replaceAll('"', '\\"');
+ }
+
+ /**
+ * Get the server name of a sub folder. The sub folder may or may not exist on
+ * the server.
+ *
+ * @param {nsIMsgFolder} parent - The parent folder.
+ * @param {string} folderName - The sub folder name.
+ * @returns {string}
+ */
+ _getServerSubFolderName(parent, folderName) {
+ folderName = this._encodeMailboxName(folderName);
+ let mailboxName = this._getServerFolderName(parent);
+ if (mailboxName) {
+ let delimiter = parent.QueryInterface(
+ Ci.nsIMsgImapMailFolder
+ ).hierarchyDelimiter;
+ // @see nsImapCore.h.
+ const ONLINE_HIERARCHY_SEPARATOR_UNKNOWN = "^";
+ if (!delimiter || delimiter == ONLINE_HIERARCHY_SEPARATOR_UNKNOWN) {
+ delimiter = "/";
+ }
+ return mailboxName + delimiter + folderName;
+ }
+ return folderName;
+ }
+
+ /**
+ * Fetch the full content of a message by UID.
+ *
+ * @param {nsIMsgFolder} folder - The associated folder.
+ * @param {number} uid - The message uid.
+ * @param {number} [size] - The body size to fetch.
+ */
+ fetchMessage(folder, uid, size) {
+ this._logger.debug(`fetchMessage folder=${folder.name} uid=${uid}`);
+ if (folder.hasMsgOffline(uid, null, 10)) {
+ this.onDone = () => {};
+ this.channel?.readFromLocalCache();
+ this._actionDone();
+ return;
+ }
+ this._actionInFolder(folder, () => {
+ this._nextAction = this._actionUidFetchBodyResponse;
+ let command;
+ if (size) {
+ command = `UID FETCH ${uid} (UID RFC822.SIZE FLAGS BODY.PEEK[HEADER.FIELDS (Content-Type Content-Transfer-Encoding)] BODY.PEEK[TEXT]<0.${size}>)`;
+ } else {
+ command = `UID FETCH ${uid} (UID RFC822.SIZE FLAGS BODY.PEEK[])`;
+ }
+ this._sendTagged(command);
+ });
+ }
+
+ /**
+ * Add, remove or replace flags of specified messages.
+ *
+ * @param {string} action - "+" means add, "-" means remove, "" means replace.
+ * @param {nsIMsgFolder} folder - The target folder.
+ * @param {string} messageIds - Message UIDs, e.g. "23,30:33".
+ * @param {number} flags - The internal flags number to update.
+ */
+ updateMessageFlags(action, folder, messageIds, flags) {
+ this._actionInFolder(folder, () => {
+ this._nextAction = () => this._actionDone();
+ // _supportedFlags is available after _actionSelectResponse.
+ let flagsStr = ImapUtils.flagsToString(flags, this._supportedFlags);
+ this._sendTagged(`UID STORE ${messageIds} ${action}FLAGS (${flagsStr})`);
+ });
+ }
+
+ /**
+ * Send EXPUNGE command to a folder.
+ *
+ * @param {nsIMsgFolder} folder - The associated folder.
+ */
+ expunge(folder) {
+ this._actionInFolder(folder, () => {
+ this._nextAction = () => this._actionDone();
+ this._sendTagged("EXPUNGE");
+ });
+ }
+
+ /**
+ * Move or copy messages from a folder to another folder.
+ *
+ * @param {nsIMsgFolder} folder - The source folder.
+ * @param {nsIMsgFolder} folder - The target folder.
+ * @param {string} messageIds - The message identifiers.
+ * @param {boolean} idsAreUids - If true messageIds are UIDs, otherwise,
+ * messageIds are sequences.
+ * @param {boolean} isMove - If true, use MOVE command when supported.
+ */
+ copy(folder, dstFolder, messageIds, idsAreUids, isMove) {
+ let command = idsAreUids ? "UID " : "";
+ command +=
+ isMove && this._capabilities.includes("MOVE")
+ ? "MOVE " // rfc6851
+ : "COPY ";
+ command += messageIds + ` "${this._getServerFolderName(dstFolder)}"`;
+ this._actionInFolder(folder, () => {
+ this._nextAction = this._actionNoopResponse;
+ this._sendTagged(command);
+ });
+ }
+
+ /**
+ * Upload a message file to a folder.
+ *
+ * @param {nsIFile} file - The message file to upload.
+ * @param {nsIMsgFolder} dstFolder - The target folder.
+ * @param {nsImapMailCopyState} copyState - A state used by nsImapMailFolder.
+ * @param {boolean} isDraft - Is the uploaded file a draft.
+ */
+ async uploadMessageFromFile(file, dstFolder, copyState, isDraft) {
+ this._logger.debug("uploadMessageFromFile", file.path, dstFolder.URI);
+ let mailbox = this._getServerFolderName(dstFolder);
+ let content = MailStringUtils.uint8ArrayToByteString(
+ await IOUtils.read(file.path)
+ );
+ this._nextAction = res => {
+ if (res.tag != "+") {
+ this._actionDone(Cr.NS_ERROR_FAILURE);
+ return;
+ }
+ this._nextAction = res => {
+ this._folderSink = dstFolder.QueryInterface(Ci.nsIImapMailFolderSink);
+ if (
+ // See rfc4315.
+ this._capabilities.includes("UIDPLUS") &&
+ res.attributes.appenduid
+ ) {
+ // The response is like `<tag> OK [APPENDUID <uidvalidity> <uid>]`.
+ this._folderSink.setAppendMsgUid(
+ res.attributes.appenduid[1],
+ this.runningUri
+ );
+ }
+ this._actionDone();
+ if (res.exists) {
+ // FIXME: _actionNoopResponse should be enough here, but it breaks
+ // test_imapAttachmentSaves.js.
+ this.folder = null;
+ }
+ try {
+ this._folderSink.copyNextStreamMessage(true, copyState);
+ } catch (e) {
+ this._logger.warn("copyNextStreamMessage failed", e);
+ }
+ };
+ this._send(content + (this._utf8Enabled ? ")" : ""));
+ };
+ let outKeywords = {};
+ let flags = dstFolder
+ .QueryInterface(Ci.nsIImapMessageSink)
+ .getCurMoveCopyMessageInfo(this.runningUri, {}, outKeywords);
+ let flagString = ImapUtils.flagsToString(flags, this._supportedFlags);
+ if (isDraft && !/\b\Draft\b/.test(flagString)) {
+ flagString += " \\Draft";
+ }
+ if (outKeywords.value) {
+ flagString += " " + outKeywords.value;
+ }
+ let open = this._utf8Enabled ? "UTF8 (~{" : "{";
+ let command = `APPEND "${mailbox}" (${flagString.trim()}) ${open}${
+ content.length
+ }}`;
+ this._sendTagged(command);
+ }
+
+ /**
+ * Check the status of a folder.
+ *
+ * @param {nsIMsgFolder} folder - The folder to check.
+ */
+ updateFolderStatus(folder) {
+ this._logger.debug("updateFolderStatus", folder.URI);
+ if (this._folder == folder) {
+ // According to rfc3501, "the STATUS command SHOULD NOT be used on the
+ // currently selected mailbox", so use NOOP instead.
+ this._actionNoop();
+ return;
+ }
+
+ this._nextAction = res => {
+ if (res.status == "OK") {
+ folder
+ .QueryInterface(Ci.nsIImapMailFolderSink)
+ .UpdateImapMailboxStatus(this, {
+ QueryInterface: ChromeUtils.generateQI(["nsIMailboxSpec"]),
+ nextUID: res.attributes.uidnext,
+ numMessages: res.attributes.messages.length,
+ numUnseenMessages: res.attributes.unseen,
+ });
+ folder.msgDatabase = null;
+ }
+ this._actionDone();
+ };
+ this._sendTagged(
+ `STATUS "${this._getServerFolderName(folder)}" (UIDNEXT MESSAGES UNSEEN)`
+ );
+ }
+
+ /**
+ * Update message flags.
+ *
+ * @param {nsIMsgFolder} folder - The associated folder.
+ * @param {string} flagsToAdd - The flags to add.
+ * @param {string} flagsToSubtract - The flags to subtract.
+ * @param {string} uids - The message uids.
+ */
+ storeCustomKeywords(folder, flagsToAdd, flagsToSubtract, uids) {
+ this._logger.debug(
+ "storeCustomKeywords",
+ folder.URI,
+ flagsToAdd,
+ flagsToSubtract,
+ uids
+ );
+ let subtractFlags = () => {
+ if (flagsToSubtract) {
+ this._nextAction = () => {
+ this._actionDone();
+ };
+ this._sendTagged(`UID STORE ${uids} -FLAGS (${flagsToSubtract})`);
+ } else {
+ this._actionDone();
+ }
+ };
+ this._actionInFolder(folder, () => {
+ if (flagsToAdd) {
+ this._nextAction = () => {
+ subtractFlags();
+ };
+ this._sendTagged(`UID STORE ${uids} +FLAGS (${flagsToAdd})`);
+ } else {
+ subtractFlags();
+ }
+ });
+ }
+
+ /**
+ * Get message headers by the specified uids.
+ *
+ * @param {nsIMsgFolder} folder - The folder of the messages.
+ * @param {string[]} uids - The message uids.
+ */
+ getHeaders(folder, uids) {
+ this._logger.debug("getHeaders", folder.URI, uids);
+ this._actionInFolder(folder, () => {
+ this._nextAction = this._actionUidFetchHeaderResponse;
+ let extraItems = "";
+ if (this._server.isGMailServer) {
+ extraItems += "X-GM-MSGID X-GM-THRID X-GM-LABELS ";
+ }
+ this._sendTagged(
+ `UID FETCH ${uids} (UID ${extraItems}RFC822.SIZE FLAGS BODY.PEEK[HEADER])`
+ );
+ });
+ }
+
+ /**
+ * Send IDLE command to the server.
+ */
+ idle() {
+ if (!this.folder) {
+ this._actionDone();
+ return;
+ }
+ this._nextAction = res => {
+ if (res.tag == "*") {
+ this.folder.performingBiff = true;
+ this._actionNoopResponse(res);
+ }
+ };
+ this._sendTagged("IDLE");
+ this._setSocketTimeout(PR_UINT32_MAX);
+ this._idling = true;
+ this._idleTimer = setTimeout(() => {
+ this.endIdle(() => {
+ this._actionNoop();
+ });
+ // Per rfc2177, should terminate the IDLE and re-issue it at least every
+ // 29 minutes. But in practice many servers timeout before that. A noop
+ // every 5min is better than timeout.
+ }, 5 * 60 * 1000);
+ this._logger.debug(`Idling in ${this.folder.URI}`);
+ }
+
+ /**
+ * Send DONE to end the IDLE command.
+ *
+ * @param {Function} nextAction - Callback function after IDLE is ended.
+ */
+ endIdle(nextAction) {
+ this._nextAction = res => {
+ if (res.status == "OK") {
+ nextAction();
+ }
+ };
+ this._send("DONE");
+ this._idling = false;
+ this.busy = true;
+ clearTimeout(this._idleTimer);
+ this._idleTimer = null;
+ }
+
+ /**
+ * Send LOGOUT and close the socket.
+ */
+ logout() {
+ this._sendTagged("LOGOUT");
+ this._socket.close();
+ }
+
+ /**
+ * The open event handler.
+ */
+ _onOpen = () => {
+ this._logger.debug("Connected");
+ this._socket.ondata = this._onData;
+ this._socket.onclose = this._onClose;
+ this._nextAction = res => {
+ this._greeted = true;
+ this._actionCapabilityResponse(res);
+ };
+
+ this._setSocketTimeout(this._prefs.tcpTimeout);
+ };
+
+ /**
+ * The data event handler.
+ *
+ * @param {TCPSocketEvent} event - The data event.
+ */
+ _onData = async event => {
+ // Without this, some tests are blocked waiting for response from Maild.jsm.
+ // Don't know the real cause, but possibly because ImapClient and Maild runs
+ // on the same process. We also have this in Pop3Client.
+ await new Promise(resolve => setTimeout(resolve));
+
+ let stringPayload = this._utf8Enabled
+ ? new TextDecoder().decode(event.data)
+ : MailStringUtils.uint8ArrayToByteString(new Uint8Array(event.data));
+ this._logger.debug(`S: ${stringPayload}`);
+ if (!this._response || this._idling || this._response.done) {
+ this._response = new ImapResponse();
+ this._response.onMessage = this._onMessage;
+ }
+ this._response.parse(stringPayload);
+ if (
+ !this._authenticating &&
+ this._response.done &&
+ this._response.status &&
+ this._response.tag != "+" &&
+ !["OK", "+"].includes(this._response.status)
+ ) {
+ this._actionDone(ImapUtils.NS_MSG_ERROR_IMAP_COMMAND_FAILED);
+ return;
+ }
+ if (!this._greeted || this._idling || this._response.done) {
+ this._nextAction?.(this._response);
+ }
+ };
+
+ /**
+ * The error event handler.
+ *
+ * @param {TCPSocketErrorEvent} event - The error event.
+ */
+ _onError = async event => {
+ this._logger.error(`${event.name}: a ${event.message} error occurred`);
+ if (event.errorCode == Cr.NS_ERROR_NET_TIMEOUT) {
+ this._actionError("imapNetTimeoutError");
+ this._actionDone(event.errorCode);
+ return;
+ }
+
+ let secInfo =
+ await event.target.transport?.tlsSocketControl?.asyncGetSecurityInfo();
+ if (secInfo) {
+ this._logger.error(`SecurityError info: ${secInfo.errorCodeString}`);
+ if (secInfo.failedCertChain.length) {
+ let chain = secInfo.failedCertChain.map(c => {
+ return c.commonName + "; serial# " + c.serialNumber;
+ });
+ this._logger.error(`SecurityError cert chain: ${chain.join(" <- ")}`);
+ }
+ this.runningUri.failedSecInfo = secInfo;
+ this._server.closeCachedConnections();
+ } else {
+ this.logout();
+ }
+
+ this._actionDone(event.errorCode);
+ };
+
+ /**
+ * The close event handler.
+ */
+ _onClose = () => {
+ this._logger.debug("Connection closed.");
+ this.folder = null;
+ };
+
+ /**
+ * Send a command to the server.
+ *
+ * @param {string} str - The command string to send.
+ * @param {boolean} [suppressLogging=false] - Whether to suppress logging the str.
+ */
+ _send(str, suppressLogging) {
+ if (suppressLogging && AppConstants.MOZ_UPDATE_CHANNEL != "default") {
+ this._logger.debug(
+ "C: Logging suppressed (it probably contained auth information)"
+ );
+ } else {
+ // Do not suppress for non-release builds, so that debugging auth problems
+ // is easier.
+ this._logger.debug(`C: ${str}`);
+ }
+
+ if (!this.isOnline) {
+ if (!str.includes("LOGOUT")) {
+ this._logger.warn(
+ `Failed to send because socket state is ${this._socket?.readyState}`
+ );
+ }
+ return;
+ }
+
+ let encode = this._utf8Enabled
+ ? x => new TextEncoder().encode(x)
+ : MailStringUtils.byteStringToUint8Array;
+ this._socket.send(encode(str + "\r\n").buffer);
+ }
+
+ /**
+ * Same as _send, but prepend a tag to the command.
+ */
+ _sendTagged(str, suppressLogging) {
+ if (this._idling) {
+ let nextAction = this._nextAction;
+ this.endIdle(() => {
+ this._nextAction = nextAction;
+ this._sendTagged(str, suppressLogging);
+ });
+ } else {
+ this._send(`${this._getNextTag()} ${str}`, suppressLogging);
+ }
+ }
+
+ /**
+ * Get the next command tag.
+ *
+ * @returns {number}
+ */
+ _getNextTag() {
+ this._tag = (this._tag + 1) % 100;
+ return this._tag;
+ }
+
+ /**
+ * Send CAPABILITY command to the server.
+ */
+ _actionCapability() {
+ this._nextAction = this._actionCapabilityResponse;
+ this._sendTagged("CAPABILITY");
+ }
+
+ /**
+ * Handle the capability response.
+ *
+ * @param {ImapResponse} res - Response received from the server.
+ */
+ _actionCapabilityResponse = res => {
+ if (res.capabilities) {
+ this._capabilities = res.capabilities;
+ this._server.wrappedJSObject.capabilities = res.capabilities;
+ if (this._capabilities.includes("X-GM-EXT-1")) {
+ this._server.isGMailServer = true;
+ }
+
+ this._supportedAuthMethods = res.authMethods;
+ this._actionChooseFirstAuthMethod();
+ } else {
+ this._actionCapability();
+ }
+ };
+
+ /**
+ * Decide the first auth method to try.
+ */
+ _actionChooseFirstAuthMethod = () => {
+ if (
+ [
+ Ci.nsMsgSocketType.trySTARTTLS,
+ Ci.nsMsgSocketType.alwaysSTARTTLS,
+ ].includes(this._server.socketType) &&
+ !this._secureTransport
+ ) {
+ if (this._capabilities.includes("STARTTLS")) {
+ // Init STARTTLS negotiation if required by user pref and supported.
+ this._nextAction = this._actionStarttlsResponse;
+ this._sendTagged("STARTTLS");
+ } else {
+ // Abort if not supported.
+ this._logger.error("Server doesn't support STARTTLS. Aborting.");
+ this._actionError("imapServerDisconnected");
+ this._actionDone(Cr.NS_ERROR_FAILURE);
+ }
+ return;
+ }
+
+ this._possibleAuthMethods = this._preferredAuthMethods.filter(x =>
+ this._supportedAuthMethods.includes(x)
+ );
+ if (
+ !this._possibleAuthMethods.length &&
+ this._server.authMethod == Ci.nsMsgAuthMethod.passwordCleartext &&
+ !this._capabilities.includes("LOGINDISABLED")
+ ) {
+ this._possibleAuthMethods = ["OLDLOGIN"];
+ }
+ this._logger.debug(`Possible auth methods: ${this._possibleAuthMethods}`);
+ this._nextAuthMethod = this._possibleAuthMethods[0];
+ if (this._capabilities.includes("CLIENTID") && this._server.clientid) {
+ this._nextAction = res => {
+ if (res.status == "OK") {
+ this._actionAuth();
+ } else {
+ this._actionDone(Cr.NS_ERROR_FAILURE);
+ }
+ };
+ this._sendTagged(`CLIENTID UUID ${this._server.clientid}`);
+ } else {
+ this._actionAuth();
+ }
+ };
+
+ /**
+ * Handle the STARTTLS response.
+ *
+ * @param {ImapResponse} res - The server response.
+ */
+ _actionStarttlsResponse(res) {
+ if (!res.status == "OK") {
+ this._actionDone(Cr.NS_ERROR_FAILURE);
+ return;
+ }
+ this._socket.upgradeToSecure();
+ this._secureTransport = true;
+ this._actionCapability();
+ }
+
+ /**
+ * Init authentication depending on server capabilities and user prefs.
+ */
+ _actionAuth = async () => {
+ if (!this._nextAuthMethod) {
+ this._socket.close();
+ this._actionDone(Cr.NS_ERROR_FAILURE);
+ return;
+ }
+
+ this._authenticating = true;
+
+ this._currentAuthMethod = this._nextAuthMethod;
+ this._nextAuthMethod =
+ this._possibleAuthMethods[
+ this._possibleAuthMethods.indexOf(this._currentAuthMethod) + 1
+ ];
+
+ switch (this._currentAuthMethod) {
+ case "OLDLOGIN":
+ this._nextAction = this._actionAuthResponse;
+ let password = await this._getPassword();
+ this._sendTagged(
+ `LOGIN ${this._authenticator.username} ${password}`,
+ true
+ );
+ break;
+ case "PLAIN":
+ this._nextAction = this._actionAuthPlain;
+ this._sendTagged("AUTHENTICATE PLAIN");
+ break;
+ case "LOGIN":
+ this._nextAction = this._actionAuthLoginUser;
+ this._sendTagged("AUTHENTICATE LOGIN");
+ break;
+ case "CRAM-MD5":
+ this._nextAction = this._actionAuthCramMd5;
+ this._sendTagged("AUTHENTICATE CRAM-MD5");
+ break;
+ case "GSSAPI": {
+ this._nextAction = this._actionAuthGssapi;
+ this._authenticator.initGssapiAuth("imap");
+ let token;
+ try {
+ token = this._authenticator.getNextGssapiToken("");
+ } catch (e) {
+ this._logger.error(e);
+ this._actionDone(Cr.NS_ERROR_FAILURE);
+ return;
+ }
+ this._sendTagged(`AUTHENTICATE GSSAPI ${token}`, true);
+ break;
+ }
+ case "NTLM": {
+ this._nextAction = this._actionAuthNtlm;
+ this._authenticator.initNtlmAuth("imap");
+ let token;
+ try {
+ token = this._authenticator.getNextNtlmToken("");
+ } catch (e) {
+ this._logger.error(e);
+ this._actionDone(Cr.NS_ERROR_FAILURE);
+ return;
+ }
+ this._sendTagged(`AUTHENTICATE NTLM ${token}`, true);
+ break;
+ }
+ case "XOAUTH2":
+ this._nextAction = this._actionAuthResponse;
+ let token = await this._authenticator.getOAuthToken();
+ this._sendTagged(`AUTHENTICATE XOAUTH2 ${token}`, true);
+ break;
+ case "EXTERNAL":
+ this._nextAction = this._actionAuthResponse;
+ this._sendTagged(
+ `AUTHENTICATE EXTERNAL ${this._authenticator.username}`
+ );
+ break;
+ default:
+ this._actionDone();
+ }
+ };
+
+ /**
+ * @param {ImapResponse} res - Response received from the server.
+ */
+ _actionAuthResponse = res => {
+ this._authenticating = false;
+
+ if (this.verifyLogon) {
+ this._actionDone(res.status == "OK" ? Cr.NS_OK : Cr.NS_ERROR_FAILURE);
+ return;
+ }
+ if (res.status == "OK") {
+ this._serverSink.userAuthenticated = true;
+ if (res.capabilities) {
+ this._capabilities = res.capabilities;
+ this._server.wrappedJSObject.capabilities = res.capabilities;
+ this._actionId();
+ } else {
+ this._nextAction = res => {
+ this._capabilities = res.capabilities;
+ this._server.wrappedJSObject.capabilities = res.capabilities;
+ this._actionId();
+ };
+ this._sendTagged("CAPABILITY");
+ }
+ return;
+ }
+ if (
+ ["OLDLOGIN", "PLAIN", "LOGIN", "CRAM-MD5"].includes(
+ this._currentAuthMethod
+ )
+ ) {
+ // Ask user what to do.
+ let action = this._authenticator.promptAuthFailed();
+ if (action == 1) {
+ // Cancel button pressed.
+ this._socket.close();
+ this._actionDone(Cr.NS_ERROR_FAILURE);
+ return;
+ }
+ if (action == 2) {
+ // 'New password' button pressed.
+ this._authenticator.forgetPassword();
+ }
+
+ // Retry.
+ this._nextAuthMethod = this._possibleAuthMethods[0];
+ this._actionAuth();
+ return;
+ }
+ this._logger.error("Authentication failed.");
+ this._actionDone(Cr.NS_ERROR_FAILURE);
+ };
+
+ /**
+ * Returns the saved/cached server password, or show a password dialog. If the
+ * user cancels the dialog, stop the process.
+ *
+ * @returns {string} The server password.
+ */
+ async _getPassword() {
+ try {
+ let password = await this._authenticator.getPassword();
+ return password;
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_ABORT) {
+ this._actionDone(e.result);
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * The second step of PLAIN auth. Send the auth token to the server.
+ *
+ * @param {ImapResponse} res - Response received from the server.
+ */
+ _actionAuthPlain = async res => {
+ this._nextAction = this._actionAuthResponse;
+ this._send(await this._authenticator.getPlainToken(), true);
+ };
+
+ /**
+ * The second step of LOGIN auth. Send the username to the server.
+ *
+ * @param {ImapResponse} res - The server response.
+ */
+ _actionAuthLoginUser = res => {
+ this._nextAction = this._actionAuthLoginPass;
+ this._send(btoa(this._authenticator.username), true);
+ };
+
+ /**
+ * The third step of LOGIN auth. Send the password to the server.
+ *
+ * @param {ImapResponse} res - The server response.
+ */
+ _actionAuthLoginPass = async res => {
+ this._nextAction = this._actionAuthResponse;
+ let password = MailStringUtils.stringToByteString(
+ await this._getPassword()
+ );
+ this._send(btoa(password), true);
+ };
+
+ /**
+ * The second step of CRAM-MD5 auth, send a HMAC-MD5 signature to the server.
+ *
+ * @param {ImapResponse} res - The server response.
+ */
+ _actionAuthCramMd5 = async res => {
+ this._nextAction = this._actionAuthResponse;
+ let password = await this._getPassword();
+ this._send(
+ this._authenticator.getCramMd5Token(password, res.statusText),
+ true
+ );
+ };
+
+ /**
+ * The second and next step of GSSAPI auth.
+ *
+ * @param {ImapResponse} res - The server response.
+ */
+ _actionAuthGssapi = res => {
+ if (res.tag != "+") {
+ this._actionAuthResponse(res);
+ return;
+ }
+
+ // Server returns a challenge, we send a new token. Can happen multiple times.
+ let token;
+ try {
+ token = this._authenticator.getNextGssapiToken(res.statusText);
+ } catch (e) {
+ this._logger.error(e);
+ this._actionAuthResponse(res);
+ return;
+ }
+ this._send(token, true);
+ };
+
+ /**
+ * The second and next step of NTLM auth.
+ *
+ * @param {ImapResponse} res - The server response.
+ */
+ _actionAuthNtlm = res => {
+ if (res.tag != "+") {
+ this._actionAuthResponse(res);
+ return;
+ }
+
+ // Server returns a challenge, we send a new token. Can happen multiple times.
+ let token;
+ try {
+ token = this._authenticator.getNextNtlmToken(res.statusText);
+ } catch (e) {
+ this._logger.error(e);
+ this._actionAuthResponse(res);
+ return;
+ }
+ this._send(token, true);
+ };
+
+ /**
+ * Send ID command to the server.
+ *
+ * @param {Function} [actionAfter] - A callback after processing ID command.
+ */
+ _actionId = (actionAfter = this._actionEnableUtf8) => {
+ if (this._capabilities.includes("ID") && Services.appinfo.name) {
+ this._nextAction = res => {
+ this._server.serverIDPref = res.id;
+ actionAfter();
+ };
+ this._sendTagged(
+ `ID ("name" "${Services.appinfo.name}" "version" "${Services.appinfo.version}")`
+ );
+ } else {
+ actionAfter();
+ }
+ };
+
+ /**
+ * Enable UTF8 if supported by the server.
+ *
+ * @param {Function} [actionAfter] - A callback after processing ENABLE UTF8.
+ */
+ _actionEnableUtf8 = (actionAfter = this._discoverAllFoldersIfNecessary) => {
+ if (
+ this._server.allowUTF8Accept &&
+ (this._capabilities.includes("UTF8=ACCEPT") ||
+ this._capabilities.includes("UTF8=ONLY"))
+ ) {
+ this._nextAction = res => {
+ this._utf8Enabled = res.status == "OK";
+ this._server.utf8AcceptEnabled = this._utf8Enabled;
+ actionAfter();
+ };
+ this._sendTagged("ENABLE UTF8=ACCEPT");
+ } else {
+ this._utf8Enabled = false;
+ actionAfter();
+ }
+ };
+
+ /**
+ * Execute an action with a folder selected.
+ *
+ * @param {nsIMsgFolder} folder - The folder to select.
+ * @param {Function} actionInFolder - The action to execute.
+ */
+ _actionInFolder(folder, actionInFolder) {
+ if (this.folder == folder) {
+ // If already in the folder, execute the action now.
+ actionInFolder();
+ } else {
+ // Send the SELECT command and queue the action.
+ this._actionAfterSelectFolder = actionInFolder;
+ this._nextAction = this._actionSelectResponse(folder);
+ this._sendTagged(`SELECT "${this._getServerFolderName(folder)}"`);
+ }
+ }
+
+ /**
+ * Send LSUB or LIST command depending on the server capabilities.
+ *
+ * @param {string} [mailbox="*"] - The mailbox to list, default to list all.
+ */
+ _actionListOrLsub(mailbox = "*") {
+ this._nextAction = this._actionListResponse();
+ let command = this._capabilities.includes("LIST-EXTENDED")
+ ? "LIST (SUBSCRIBED)" // rfc5258
+ : "LSUB";
+ command += ` "" "${mailbox}"`;
+ if (this._capabilities.includes("SPECIAL-USE")) {
+ command += " RETURN (SPECIAL-USE)"; // rfc6154
+ }
+ this._sendTagged(command);
+ this._listInboxSent = false;
+ }
+
+ /**
+ * Handle LIST response.
+ *
+ * @param {Function} actionAfterResponse - A callback after handling the response.
+ * @param {ImapResponse} res - Response received from the server.
+ */
+ _actionListResponse =
+ (actionAfterResponse = this._actionFinishFolderDiscovery) =>
+ res => {
+ if (!this._hasInbox) {
+ this._hasInbox = res.mailboxes.some(
+ mailbox => mailbox.flags & ImapUtils.FLAG_IMAP_INBOX
+ );
+ }
+ for (let mailbox of res.mailboxes) {
+ this._serverSink.possibleImapMailbox(
+ mailbox.name.replaceAll(mailbox.delimiter, "/"),
+ mailbox.delimiter,
+ mailbox.flags
+ );
+ }
+
+ actionAfterResponse(res);
+ };
+
+ /**
+ * Send LIST command.
+ *
+ * @param {string} folderName - The name of the folder to list.
+ * @param {Function} actionAfterResponse - A callback after handling the response.
+ */
+ _actionList(folderName, actionAfterResponse) {
+ this._nextAction = this._actionListResponse(actionAfterResponse);
+ this._sendTagged(`LIST "" "${folderName}"`);
+ }
+
+ /**
+ * Finish folder discovery after checking Inbox and Trash folders.
+ */
+ _actionFinishFolderDiscovery = () => {
+ if (!this._hasInbox && !this._listInboxSent) {
+ this._actionList("INBOX");
+ this._listInboxSent = true;
+ return;
+ }
+ if (!this._hasTrash && !this._listTrashSent) {
+ this._actionCreateTrashFolderIfNeeded();
+ return;
+ }
+ this._serverSink.discoveryDone();
+ this._actionAfterDiscoverAllFolders
+ ? this._actionAfterDiscoverAllFolders()
+ : this._actionDone();
+ };
+
+ /**
+ * If Trash folder is not found on server, create one and subscribe to it.
+ */
+ _actionCreateTrashFolderIfNeeded() {
+ let trashFolderName = this._server.trashFolderName;
+ this._actionList(trashFolderName, res => {
+ this._hasTrash = res.mailboxes.length > 0;
+ if (this._hasTrash) {
+ // Trash folder exists.
+ this._actionFinishFolderDiscovery();
+ } else {
+ // Trash folder doesn't exist, create one and subscribe to it.
+ this._nextAction = res => {
+ this._actionList(trashFolderName, () => {
+ // After subscribing, finish folder discovery.
+ this._nextAction = this._actionFinishFolderDiscovery;
+ this._sendTagged(`SUBSCRIBE "${trashFolderName}"`);
+ });
+ };
+ this._sendTagged(`CREATE "${trashFolderName}"`);
+ }
+ });
+ this._listTrashSent = true;
+ }
+
+ /**
+ * Create and subscribe to a folder.
+ *
+ * @param {string} folderName - The folder name.
+ * @param {Function} callbackAfterSubscribe - The action after the subscribe
+ * command.
+ */
+ _actionCreateAndSubscribe(folderName, callbackAfterSubscribe) {
+ this._nextAction = res => {
+ this._nextAction = callbackAfterSubscribe;
+ this._sendTagged(`SUBSCRIBE "${folderName}"`);
+ };
+ this._sendTagged(`CREATE "${folderName}"`);
+ }
+
+ /**
+ * Handle SELECT response.
+ */
+ _actionSelectResponse = folder => res => {
+ if (folder) {
+ this.folder = folder;
+ }
+ this._supportedFlags = res.permanentflags || res.flags;
+ this._folderState = res;
+ if (this._capabilities.includes("QUOTA")) {
+ this._actionGetQuotaData();
+ } else {
+ this._actionAfterSelectFolder();
+ }
+ };
+
+ /**
+ * Send GETQUOTAROOT command and handle the response.
+ */
+ _actionGetQuotaData() {
+ this._folderSink = this.folder.QueryInterface(Ci.nsIImapMailFolderSink);
+ this._nextAction = res => {
+ const INVALIDATE_QUOTA = 0;
+ const STORE_QUOTA = 1;
+ const VALIDATE_QUOTA = 2;
+ for (let root of res.quotaRoots || []) {
+ this._folderSink.setFolderQuotaData(INVALIDATE_QUOTA, root, 0, 0);
+ }
+ for (let [mailbox, resource, usage, limit] of res.quotas || []) {
+ this._folderSink.setFolderQuotaData(
+ STORE_QUOTA,
+ mailbox ? `${mailbox} / ${resource}` : resource,
+ usage,
+ limit
+ );
+ }
+ this._folderSink.setFolderQuotaData(VALIDATE_QUOTA, "", 0, 0);
+ this._actionAfterSelectFolder();
+ };
+ this._sendTagged(
+ `GETQUOTAROOT "${this._getServerFolderName(this.folder)}"`
+ );
+ this._folderSink.folderQuotaCommandIssued = true;
+ }
+
+ /**
+ * Handle RENAME response. Three steps are involved.
+ *
+ * @param {string} oldName - The old folder name.
+ * @param {string} newName - The new folder name.
+ * @param {boolean} [isMove] - Is it response to MOVE command.
+ * @param {ImapResponse} res - The server response.
+ */
+ _actionRenameResponse = (oldName, newName, isMove) => res => {
+ // Step 3: Rename the local folder and send LIST command to re-sync folders.
+ let actionAfterUnsubscribe = () => {
+ this._serverSink.onlineFolderRename(this._msgWindow, oldName, newName);
+ if (isMove) {
+ this._actionDone();
+ } else {
+ this._actionListOrLsub(newName);
+ }
+ };
+ // Step 2: unsubscribe to the oldName.
+ this._nextAction = () => {
+ this._nextAction = actionAfterUnsubscribe;
+ this._sendTagged(`UNSUBSCRIBE "${oldName}"`);
+ };
+ // Step 1: subscribe to the newName.
+ this._sendTagged(`SUBSCRIBE "${newName}"`);
+ };
+
+ /**
+ * Send UID FETCH request to the server.
+ */
+ _actionUidFetch() {
+ if (this.runningUri.imapAction == Ci.nsIImapUrl.nsImapLiteSelectFolder) {
+ this._nextAction = () => this._actionDone();
+ } else {
+ this._nextAction = this._actionUidFetchResponse;
+ }
+ this._sendTagged("UID FETCH 1:* (FLAGS)");
+ }
+
+ /**
+ * Handle UID FETCH response.
+ *
+ * @param {ImapResponse} res - Response received from the server.
+ */
+ _actionUidFetchResponse(res) {
+ let outFolderInfo = {};
+ this.folder.getDBFolderInfoAndDB(outFolderInfo);
+ let highestUid = outFolderInfo.value.getUint32Property(
+ "highestRecordedUID",
+ 0
+ );
+ this._folderSink = this.folder.QueryInterface(Ci.nsIImapMailFolderSink);
+ this._folderSink.UpdateImapMailboxInfo(this, this._getMailboxSpec());
+ let latestUid = this._messageUids.at(-1);
+ if (latestUid > highestUid) {
+ let extraItems = "";
+ if (this._server.isGMailServer) {
+ extraItems += "X-GM-MSGID X-GM-THRID X-GM-LABELS ";
+ }
+ this._nextAction = this._actionUidFetchHeaderResponse;
+ this._sendTagged(
+ `UID FETCH ${
+ highestUid + 1
+ }:${latestUid} (UID ${extraItems}RFC822.SIZE FLAGS BODY.PEEK[HEADER])`
+ );
+ } else {
+ this._folderSink.headerFetchCompleted(this);
+ if (this._bodysToDownload.length) {
+ let uids = this._bodysToDownload.join(",");
+ this._nextAction = this._actionUidFetchBodyResponse;
+ this._sendTagged(
+ `UID FETCH ${uids} (UID RFC822.SIZE FLAGS BODY.PEEK[])`
+ );
+ return;
+ }
+ this._actionDone();
+ }
+ }
+
+ /**
+ * Make an nsIMailboxSpec instance to interact with nsIImapMailFolderSink.
+ *
+ * @returns {nsIMailboxSpec}
+ */
+ _getMailboxSpec() {
+ return {
+ QueryInterface: ChromeUtils.generateQI(["nsIMailboxSpec"]),
+ folder_UIDVALIDITY: this._folderState.uidvalidity,
+ box_flags: this._folderState.flags,
+ supportedUserFlags: this._folderState.supportedUserFlags,
+ nextUID: this._folderState.attributes.uidnext,
+ numMessages: this._messages.size,
+ numUnseenMessages: this._folderState.attributes.unseen,
+ flagState: this.flagAndUidState,
+ };
+ }
+
+ /**
+ * Handle UID FETCH BODY.PEEK[HEADER] response.
+ *
+ * @param {ImapResponse} res - Response received from the server.
+ */
+ _actionUidFetchHeaderResponse(res) {
+ this.folder
+ .QueryInterface(Ci.nsIImapMailFolderSink)
+ .headerFetchCompleted(this);
+ if (this._bodysToDownload.length) {
+ // nsImapMailFolder decides to fetch the full body by calling
+ // NotifyBodysToDownload.
+ let uids = this._bodysToDownload.join(",");
+ this._nextAction = this._actionUidFetchBodyResponse;
+ this._sendTagged(`UID FETCH ${uids} (UID RFC822.SIZE FLAGS BODY.PEEK[])`);
+ return;
+ }
+ this._actionDone();
+ }
+
+ /**
+ * Handle UID FETCH BODY response.
+ *
+ * @param {ImapResponse} res - Response received from the server.
+ */
+ _actionUidFetchBodyResponse(res) {
+ this._actionDone();
+ }
+
+ /**
+ * Handle a single message data response.
+ *
+ * @param {MessageData} msg - Message data parsed in ImapResponse.
+ */
+ _onMessage = msg => {
+ this._msgSink = this.folder.QueryInterface(Ci.nsIImapMessageSink);
+ this._folderSink = this.folder.QueryInterface(Ci.nsIImapMailFolderSink);
+
+ // Handle message flags.
+ if ((msg.uid || msg.sequence) && msg.flags != undefined) {
+ let uid = msg.uid;
+ if (uid && msg.sequence) {
+ this._messageUids[msg.sequence] = uid;
+ this._messages.set(uid, msg);
+ } else if (msg.sequence) {
+ uid = this._messageUids[msg.sequence];
+ }
+ if (uid) {
+ this.folder
+ .QueryInterface(Ci.nsIImapMessageSink)
+ .notifyMessageFlags(
+ msg.flags,
+ msg.keywords,
+ uid,
+ this._folderState.highestmodseq
+ );
+ }
+ }
+
+ if (msg.body) {
+ if (!msg.body.endsWith("\r\n")) {
+ msg.body += "\r\n";
+ }
+ if (msg.bodySection.length == 1 && msg.bodySection[0] == "HEADER") {
+ // Handle message headers.
+ this._messageUids[msg.sequence] = msg.uid;
+ this._messages.set(msg.uid, msg);
+ this._folderSink.StartMessage(this.runningUri);
+ let hdrXferInfo = {
+ numHeaders: 1,
+ getHeader() {
+ return {
+ msgUid: msg.uid,
+ msgSize: msg.size,
+ get msgHdrs() {
+ let sepIndex = msg.body.indexOf("\r\n\r\n");
+ return sepIndex == -1
+ ? msg.body + "\r\n"
+ : msg.body.slice(0, sepIndex + 2);
+ },
+ };
+ },
+ };
+ this._folderSink.parseMsgHdrs(this, hdrXferInfo);
+ } else {
+ // Handle message body.
+ let shouldStoreMsgOffline = false;
+ try {
+ shouldStoreMsgOffline = this.folder.shouldStoreMsgOffline(msg.uid);
+ } catch (e) {}
+ if (
+ (shouldStoreMsgOffline ||
+ this.runningUri.QueryInterface(Ci.nsIImapUrl)
+ .storeResultsOffline) &&
+ msg.body
+ ) {
+ this._folderSink.StartMessage(this.runningUri);
+ this._msgSink.parseAdoptedMsgLine(msg.body, msg.uid, this.runningUri);
+ this._msgSink.normalEndMsgWriteStream(
+ msg.uid,
+ true,
+ this.runningUri,
+ msg.body.length
+ );
+ this._folderSink.EndMessage(this.runningUri, msg.uid);
+ }
+
+ this.onData?.(msg.body);
+ // Release some memory.
+ msg.body = "";
+ }
+ }
+ };
+
+ /**
+ * Send NOOP command.
+ */
+ _actionNoop() {
+ this._nextAction = this._actionNoopResponse;
+ this._sendTagged("NOOP");
+ }
+
+ /**
+ * Handle NOOP response.
+ *
+ * @param {ImapResponse} res - Response received from the server.
+ */
+ _actionNoopResponse(res) {
+ if (
+ (res.exists && res.exists != this._folderState.exists) ||
+ res.expunged.length
+ ) {
+ // Handle messages number changes, re-sync the folder.
+ this._folderState.exists = res.exists;
+ this._actionAfterSelectFolder = this._actionUidFetch;
+ this._nextAction = this._actionSelectResponse();
+ if (res.expunged.length) {
+ this._messageUids = [];
+ this._messages.clear();
+ }
+ let folder = this.folder;
+ this.folder = null;
+ this.selectFolder(folder);
+ } else if (res.messages.length || res.exists) {
+ let outFolderInfo = {};
+ this.folder.getDBFolderInfoAndDB(outFolderInfo);
+ let highestUid = outFolderInfo.value.getUint32Property(
+ "highestRecordedUID",
+ 0
+ );
+ this._nextAction = this._actionUidFetchResponse;
+ this._sendTagged(`UID FETCH ${highestUid + 1}:* (FLAGS)`);
+ } else {
+ if (res.exists == 0) {
+ this._messageUids = [];
+ this._messages.clear();
+ this.folder
+ .QueryInterface(Ci.nsIImapMailFolderSink)
+ .UpdateImapMailboxInfo(this, this._getMailboxSpec());
+ }
+ if (!this._idling) {
+ this._actionDone();
+ }
+ }
+ }
+
+ /**
+ * Show an error prompt.
+ *
+ * @param {string} errorName - An error name corresponds to an entry of
+ * imapMsgs.properties.
+ */
+ _actionError(errorName) {
+ if (!this._msgWindow) {
+ return;
+ }
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/imapMsgs.properties"
+ );
+ let errorMsg = bundle.formatStringFromName(errorName, [
+ this._server.hostName,
+ ]);
+ Services.prompt.alert(this._msgWindow.domWindow, null, errorMsg);
+ }
+
+ /**
+ * Finish a request and do necessary cleanup.
+ */
+ _actionDone = (status = Cr.NS_OK) => {
+ this._logger.debug(`Done with status=${status}`);
+ this._nextAction = null;
+ this._urlListener?.OnStopRunningUrl(this.runningUri, status);
+ this.runningUri.SetUrlState(false, status);
+ this.onDone?.(status);
+ this._reset();
+ // Tell ImapIncomingServer this client can be reused now.
+ this.onFree?.();
+ };
+
+ /** @see nsIImapProtocol */
+ NotifyBodysToDownload(keys) {
+ this._logger.debug("NotifyBodysToDownload", keys);
+ this._bodysToDownload = keys;
+ }
+
+ GetRunningUrl() {
+ this._logger.debug("GetRunningUrl");
+ }
+
+ get flagAndUidState() {
+ // The server sequence is 1 based, nsIImapFlagAndUidState sequence is 0 based.
+ let getUidOfMessage = index => this._messageUids[index + 1];
+ let getMessageFlagsByUid = uid => this._messages.get(uid)?.flags;
+
+ return {
+ QueryInterface: ChromeUtils.generateQI(["nsIImapFlagAndUidState"]),
+ numberOfMessages: this._messages.size,
+ getUidOfMessage,
+ getMessageFlags: index => getMessageFlagsByUid(getUidOfMessage(index)),
+ hasMessage: uid => this._messages.has(uid),
+ getMessageFlagsByUid,
+ getCustomFlags: uid => this._messages.get(uid)?.keywords,
+ getCustomAttribute: (uid, name) => {
+ let value = this._messages.get(uid)?.customAttributes[name];
+ return Array.isArray(value) ? value.join(" ") : value;
+ },
+ };
+ }
+}