diff options
Diffstat (limited to 'comm/mail/components/extensions/parent/ext-folders.js')
-rw-r--r-- | comm/mail/components/extensions/parent/ext-folders.js | 675 |
1 files changed, 675 insertions, 0 deletions
diff --git a/comm/mail/components/extensions/parent/ext-folders.js b/comm/mail/components/extensions/parent/ext-folders.js new file mode 100644 index 0000000000..63704b9dd7 --- /dev/null +++ b/comm/mail/components/extensions/parent/ext-folders.js @@ -0,0 +1,675 @@ +/* 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/. */ + +ChromeUtils.defineModuleGetter( + this, + "MailServices", + "resource:///modules/MailServices.jsm" +); +ChromeUtils.defineESModuleGetters(this, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", +}); + +/** + * Tracks folder events. + * + * @implements {nsIMsgFolderListener} + */ +var folderTracker = new (class extends EventEmitter { + constructor() { + super(); + this.listenerCount = 0; + this.pendingInfoNotifications = new ExtensionUtils.DefaultMap( + () => new Map() + ); + this.deferredInfoNotifications = new ExtensionUtils.DefaultMap( + folder => + new DeferredTask( + () => this.emitPendingInfoNotification(folder), + NOTIFICATION_COLLAPSE_TIME + ) + ); + } + + on(...args) { + super.on(...args); + this.incrementListeners(); + } + + off(...args) { + super.off(...args); + this.decrementListeners(); + } + + incrementListeners() { + this.listenerCount++; + if (this.listenerCount == 1) { + // nsIMsgFolderListener + const flags = + MailServices.mfn.folderAdded | + MailServices.mfn.folderDeleted | + MailServices.mfn.folderMoveCopyCompleted | + MailServices.mfn.folderRenamed; + MailServices.mfn.addListener(this, flags); + // nsIFolderListener + MailServices.mailSession.AddFolderListener( + this, + Ci.nsIFolderListener.intPropertyChanged + ); + } + } + decrementListeners() { + this.listenerCount--; + if (this.listenerCount == 0) { + MailServices.mfn.removeListener(this); + MailServices.mailSession.RemoveFolderListener(this); + } + } + + // nsIFolderListener + + onFolderIntPropertyChanged(item, property, oldValue, newValue) { + if (!(item instanceof Ci.nsIMsgFolder)) { + return; + } + + switch (property) { + case "FolderFlag": + if ( + (oldValue & Ci.nsMsgFolderFlags.Favorite) != + (newValue & Ci.nsMsgFolderFlags.Favorite) + ) { + this.addPendingInfoNotification( + item, + "favorite", + !!(newValue & Ci.nsMsgFolderFlags.Favorite) + ); + } + break; + case "TotalMessages": + this.addPendingInfoNotification(item, "totalMessageCount", newValue); + break; + case "TotalUnreadMessages": + this.addPendingInfoNotification(item, "unreadMessageCount", newValue); + break; + } + } + + addPendingInfoNotification(folder, key, value) { + // If there is already a notification entry, decide if it must be emitted, + // or if it can be collapsed: Message count changes can be collapsed. + // This also collapses multiple different notifications types into a + // single event. + if ( + ["favorite"].includes(key) && + this.deferredInfoNotifications.has(folder) && + this.pendingInfoNotifications.get(folder).has(key) + ) { + this.deferredInfoNotifications.get(folder).disarm(); + this.emitPendingInfoNotification(folder); + } + + this.pendingInfoNotifications.get(folder).set(key, value); + this.deferredInfoNotifications.get(folder).disarm(); + this.deferredInfoNotifications.get(folder).arm(); + } + + emitPendingInfoNotification(folder) { + let folderInfo = this.pendingInfoNotifications.get(folder); + if (folderInfo.size > 0) { + this.emit( + "folder-info-changed", + convertFolder(folder), + Object.fromEntries(folderInfo) + ); + this.pendingInfoNotifications.delete(folder); + } + } + + // nsIMsgFolderListener + + folderAdded(childFolder) { + this.emit("folder-created", convertFolder(childFolder)); + } + folderDeleted(oldFolder) { + // Deleting an account, will trigger delete notifications for its folders, + // but the account lookup fails, so skip them. + let server = oldFolder.server; + let account = MailServices.accounts.FindAccountForServer(server); + if (account) { + this.emit("folder-deleted", convertFolder(oldFolder, account.key)); + } + } + folderMoveCopyCompleted(move, srcFolder, targetFolder) { + // targetFolder is not the copied/moved folder, but its parent. Find the + // actual folder by its name (which is unique). + let dstFolder = null; + if (targetFolder && targetFolder.hasSubFolders) { + dstFolder = targetFolder.subFolders.find( + f => f.prettyName == srcFolder.prettyName + ); + } + + if (move) { + this.emit( + "folder-moved", + convertFolder(srcFolder), + convertFolder(dstFolder) + ); + } else { + this.emit( + "folder-copied", + convertFolder(srcFolder), + convertFolder(dstFolder) + ); + } + } + folderRenamed(oldFolder, newFolder) { + this.emit( + "folder-renamed", + convertFolder(oldFolder), + convertFolder(newFolder) + ); + } +})(); + +/** + * Accepts a MailFolder or a MailAccount and returns the actual folder and its + * accountId. Throws if the requested folder does not exist. + */ +function getFolder({ accountId, path, id }) { + if (id && !path && !accountId) { + accountId = id; + path = "/"; + } + + let uri = folderPathToURI(accountId, path); + let folder = MailServices.folderLookup.getFolderForURL(uri); + if (!folder) { + throw new ExtensionError(`Folder not found: ${path}`); + } + return { folder, accountId }; +} + +/** + * Copy or Move a folder. + */ +async function doMoveCopyOperation(source, destination, isMove) { + // The schema file allows destination to be either a MailFolder or a + // MailAccount. + let srcFolder = getFolder(source); + let dstFolder = getFolder(destination); + + if ( + srcFolder.folder.server.type == "nntp" || + dstFolder.folder.server.type == "nntp" + ) { + throw new ExtensionError( + `folders.${isMove ? "move" : "copy"}() is not supported in news accounts` + ); + } + + if ( + dstFolder.folder.hasSubFolders && + dstFolder.folder.subFolders.find( + f => f.prettyName == srcFolder.folder.prettyName + ) + ) { + throw new ExtensionError( + `folders.${isMove ? "move" : "copy"}() failed, because ${ + srcFolder.folder.prettyName + } already exists in ${folderURIToPath( + dstFolder.accountId, + dstFolder.folder.URI + )}` + ); + } + + let rv = await new Promise(resolve => { + let _destination = null; + const listener = { + folderMoveCopyCompleted(_isMove, _srcFolder, _dstFolder) { + if ( + _destination != null || + _isMove != isMove || + _srcFolder.URI != srcFolder.folder.URI || + _dstFolder.URI != dstFolder.folder.URI + ) { + return; + } + + // The targetFolder is not the copied/moved folder, but its parent. + // Find the actual folder by its name (which is unique). + if (_dstFolder && _dstFolder.hasSubFolders) { + _destination = _dstFolder.subFolders.find( + f => f.prettyName == _srcFolder.prettyName + ); + } + }, + }; + MailServices.mfn.addListener( + listener, + MailServices.mfn.folderMoveCopyCompleted + ); + MailServices.copy.copyFolder( + srcFolder.folder, + dstFolder.folder, + isMove, + { + OnStartCopy() {}, + OnProgress() {}, + SetMessageKey() {}, + GetMessageId() {}, + OnStopCopy(status) { + MailServices.mfn.removeListener(listener); + resolve({ + status, + folder: _destination, + }); + }, + }, + null + ); + }); + + if (!Components.isSuccessCode(rv.status)) { + throw new ExtensionError( + `folders.${isMove ? "move" : "copy"}() failed for unknown reasons` + ); + } + + return convertFolder(rv.folder, dstFolder.accountId); +} + +/** + * Wait for a folder operation. + */ +function waitForOperation(flags, uri) { + return new Promise(resolve => { + MailServices.mfn.addListener( + { + folderAdded(childFolder) { + if (childFolder.parent.URI != uri) { + return; + } + + MailServices.mfn.removeListener(this); + resolve(childFolder); + }, + folderDeleted(oldFolder) { + if (oldFolder.URI != uri) { + return; + } + + MailServices.mfn.removeListener(this); + resolve(); + }, + folderMoveCopyCompleted(move, srcFolder, destFolder) { + if (srcFolder.URI != uri) { + return; + } + + MailServices.mfn.removeListener(this); + resolve(destFolder); + }, + folderRenamed(oldFolder, newFolder) { + if (oldFolder.URI != uri) { + return; + } + + MailServices.mfn.removeListener(this); + resolve(newFolder); + }, + }, + flags + ); + }); +} + +this.folders = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + // For primed persistent events (deactivated background), the context is only + // available after fire.wakeup() has fulfilled (ensuring the convert() function + // has been called). + + onCreated({ context, fire }) { + async function listener(event, createdMailFolder) { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.async(createdMailFolder); + } + folderTracker.on("folder-created", listener); + return { + unregister: () => { + folderTracker.off("folder-created", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onRenamed({ context, fire }) { + async function listener(event, originalMailFolder, renamedMailFolder) { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.async(originalMailFolder, renamedMailFolder); + } + folderTracker.on("folder-renamed", listener); + return { + unregister: () => { + folderTracker.off("folder-renamed", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onMoved({ context, fire }) { + async function listener(event, srcMailFolder, dstMailFolder) { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.async(srcMailFolder, dstMailFolder); + } + folderTracker.on("folder-moved", listener); + return { + unregister: () => { + folderTracker.off("folder-moved", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onCopied({ context, fire }) { + async function listener(event, srcMailFolder, dstMailFolder) { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.async(srcMailFolder, dstMailFolder); + } + folderTracker.on("folder-copied", listener); + return { + unregister: () => { + folderTracker.off("folder-copied", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onDeleted({ context, fire }) { + async function listener(event, deletedMailFolder) { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.async(deletedMailFolder); + } + folderTracker.on("folder-deleted", listener); + return { + unregister: () => { + folderTracker.off("folder-deleted", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onFolderInfoChanged({ context, fire }) { + async function listener(event, changedMailFolder, mailFolderInfo) { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.async(changedMailFolder, mailFolderInfo); + } + folderTracker.on("folder-info-changed", listener); + return { + unregister: () => { + folderTracker.off("folder-info-changed", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + }; + + getAPI(context) { + return { + folders: { + onCreated: new EventManager({ + context, + module: "folders", + event: "onCreated", + extensionApi: this, + }).api(), + onRenamed: new EventManager({ + context, + module: "folders", + event: "onRenamed", + extensionApi: this, + }).api(), + onMoved: new EventManager({ + context, + module: "folders", + event: "onMoved", + extensionApi: this, + }).api(), + onCopied: new EventManager({ + context, + module: "folders", + event: "onCopied", + extensionApi: this, + }).api(), + onDeleted: new EventManager({ + context, + module: "folders", + event: "onDeleted", + extensionApi: this, + }).api(), + onFolderInfoChanged: new EventManager({ + context, + module: "folders", + event: "onFolderInfoChanged", + extensionApi: this, + }).api(), + async create(parent, childName) { + // The schema file allows parent to be either a MailFolder or a + // MailAccount. + let { folder: parentFolder, accountId } = getFolder(parent); + + if ( + parentFolder.hasSubFolders && + parentFolder.subFolders.find(f => f.prettyName == childName) + ) { + throw new ExtensionError( + `folders.create() failed, because ${childName} already exists in ${folderURIToPath( + accountId, + parentFolder.URI + )}` + ); + } + + let childFolderPromise = waitForOperation( + MailServices.mfn.folderAdded, + parentFolder.URI + ); + parentFolder.createSubfolder(childName, null); + + let childFolder = await childFolderPromise; + return convertFolder(childFolder, accountId); + }, + async rename({ accountId, path }, newName) { + let { folder } = getFolder({ accountId, path }); + + if (!folder.parent) { + throw new ExtensionError( + `folders.rename() failed, because it cannot rename the root of the account` + ); + } + if (folder.server.type == "nntp") { + throw new ExtensionError( + `folders.rename() is not supported in news accounts` + ); + } + + if (folder.parent.subFolders.find(f => f.prettyName == newName)) { + throw new ExtensionError( + `folders.rename() failed, because ${newName} already exists in ${folderURIToPath( + accountId, + folder.parent.URI + )}` + ); + } + + let newFolderPromise = waitForOperation( + MailServices.mfn.folderRenamed, + folder.URI + ); + folder.rename(newName, null); + + let newFolder = await newFolderPromise; + return convertFolder(newFolder, accountId); + }, + async move(source, destination) { + return doMoveCopyOperation(source, destination, true /* isMove */); + }, + async copy(source, destination) { + return doMoveCopyOperation(source, destination, false /* isMove */); + }, + async delete({ accountId, path }) { + if ( + !context.extension.hasPermission("accountsFolders") || + !context.extension.hasPermission("messagesDelete") + ) { + throw new ExtensionError( + 'Using folders.delete() requires the "accountsFolders" and the "messagesDelete" permission' + ); + } + + let { folder } = getFolder({ accountId, path }); + if (folder.server.type == "nntp") { + throw new ExtensionError( + `folders.delete() is not supported in news accounts` + ); + } + + if (folder.server.type == "imap") { + let inTrash = false; + let parent = folder.parent; + while (!inTrash && parent) { + inTrash = parent.flags & Ci.nsMsgFolderFlags.Trash; + parent = parent.parent; + } + if (inTrash) { + // FixMe: The UI is not updated, the folder is still shown, only after + // a restart it is removed from trash. + let deletedPromise = new Promise(resolve => { + MailServices.imap.deleteFolder( + folder, + { + OnStartRunningUrl() {}, + OnStopRunningUrl(url, status) { + resolve(status); + }, + }, + null + ); + }); + let status = await deletedPromise; + if (!Components.isSuccessCode(status)) { + throw new ExtensionError( + `folders.delete() failed for unknown reasons` + ); + } + } else { + // FixMe: Accounts could have their trash folder outside of their + // own folder structure. + let trash = folder.server.rootFolder.getFolderWithFlags( + Ci.nsMsgFolderFlags.Trash + ); + let deletedPromise = new Promise(resolve => { + MailServices.imap.moveFolder( + folder, + trash, + { + OnStartRunningUrl() {}, + OnStopRunningUrl(url, status) { + resolve(status); + }, + }, + null + ); + }); + let status = await deletedPromise; + if (!Components.isSuccessCode(status)) { + throw new ExtensionError( + `folders.delete() failed for unknown reasons` + ); + } + } + } else { + let deletedPromise = waitForOperation( + MailServices.mfn.folderDeleted | + MailServices.mfn.folderMoveCopyCompleted, + folder.URI + ); + folder.deleteSelf(null); + await deletedPromise; + } + }, + async getFolderInfo({ accountId, path }) { + let { folder } = getFolder({ accountId, path }); + + let mailFolderInfo = { + favorite: folder.getFlag(Ci.nsMsgFolderFlags.Favorite), + totalMessageCount: folder.getTotalMessages(false), + unreadMessageCount: folder.getNumUnread(false), + }; + + return mailFolderInfo; + }, + async getParentFolders({ accountId, path }, includeFolders) { + let { folder } = getFolder({ accountId, path }); + let parentFolders = []; + // We do not consider the absolute root ("/") as a root folder, but + // the first real folders (all folders returned in MailAccount.folders + // are considered root folders). + while (folder.parent != null && folder.parent.parent != null) { + folder = folder.parent; + + if (includeFolders) { + parentFolders.push(traverseSubfolders(folder, accountId)); + } else { + parentFolders.push(convertFolder(folder, accountId)); + } + } + return parentFolders; + }, + async getSubFolders(accountOrFolder, includeFolders) { + let { folder, accountId } = getFolder(accountOrFolder); + let subFolders = []; + if (folder.hasSubFolders) { + for (let subFolder of folder.subFolders) { + if (includeFolders) { + subFolders.push(traverseSubfolders(subFolder, accountId)); + } else { + subFolders.push(convertFolder(subFolder, accountId)); + } + } + } + return subFolders; + }, + }, + }; + } +}; |