summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/extensions/parent/ext-messages.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/extensions/parent/ext-messages.js')
-rw-r--r--comm/mail/components/extensions/parent/ext-messages.js1563
1 files changed, 1563 insertions, 0 deletions
diff --git a/comm/mail/components/extensions/parent/ext-messages.js b/comm/mail/components/extensions/parent/ext-messages.js
new file mode 100644
index 0000000000..7d03b3fa62
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-messages.js
@@ -0,0 +1,1563 @@
+/* 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.defineESModuleGetters(this, {
+ AttachmentInfo: "resource:///modules/AttachmentInfo.sys.mjs",
+});
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MessageArchiver",
+ "resource:///modules/MessageArchiver.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MimeParser",
+ "resource:///modules/mimeParser.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MsgHdrToMimeMessage",
+ "resource:///modules/gloda/MimeMessage.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "jsmime",
+ "resource:///modules/jsmime.jsm"
+);
+
+var { MailStringUtils } = ChromeUtils.import(
+ "resource:///modules/MailStringUtils.jsm"
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["File", "IOUtils", "PathUtils"]);
+
+var { DefaultMap } = ExtensionUtils;
+
+let messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+/**
+ * Takes a part of a MIME message (as retrieved with MsgHdrToMimeMessage) and
+ * filters out the properties we don't want to send to extensions.
+ */
+function convertMessagePart(part) {
+ let partObject = {};
+ for (let key of ["body", "contentType", "name", "partName", "size"]) {
+ if (key in part) {
+ partObject[key] = part[key];
+ }
+ }
+
+ // Decode headers. This also takes care of headers, which still include
+ // encoded words and need to be RFC 2047 decoded.
+ if ("headers" in part) {
+ partObject.headers = {};
+ for (let header of Object.keys(part.headers)) {
+ partObject.headers[header] = part.headers[header].map(h =>
+ MailServices.mimeConverter.decodeMimeHeader(
+ h,
+ null,
+ false /* override_charset */,
+ true /* eatContinuations */
+ )
+ );
+ }
+ }
+
+ if ("parts" in part && Array.isArray(part.parts) && part.parts.length > 0) {
+ partObject.parts = part.parts.map(convertMessagePart);
+ }
+ return partObject;
+}
+
+async function convertAttachment(attachment) {
+ let rv = {
+ contentType: attachment.contentType,
+ name: attachment.name,
+ size: attachment.size,
+ partName: attachment.partName,
+ };
+
+ if (attachment.contentType.startsWith("message/")) {
+ // The attached message may not have been seen/opened yet, create a dummy
+ // msgHdr.
+ let attachedMsgHdr = new nsDummyMsgHeader();
+
+ attachedMsgHdr.setStringProperty("dummyMsgUrl", attachment.url);
+ attachedMsgHdr.recipients = attachment.headers.to;
+ attachedMsgHdr.ccList = attachment.headers.cc;
+ attachedMsgHdr.bccList = attachment.headers.bcc;
+ attachedMsgHdr.author = attachment.headers.from?.[0] || "";
+ attachedMsgHdr.subject = attachment.headers.subject?.[0] || "";
+
+ let hdrDate = attachment.headers.date?.[0];
+ attachedMsgHdr.date = hdrDate ? Date.parse(hdrDate) * 1000 : 0;
+
+ let hdrId = attachment.headers["message-id"]?.[0];
+ attachedMsgHdr.messageId = hdrId ? hdrId.replace(/^<|>$/g, "") : "";
+
+ rv.message = convertMessage(attachedMsgHdr);
+ }
+
+ return rv;
+}
+
+/**
+ * @typedef MimeMessagePart
+ * @property {MimeMessagePart[]} [attachments] - flat list of attachment parts
+ * found in any of the nested mime parts
+ * @property {string} [body] - the body of the part
+ * @property {Uint8Array} [raw] - the raw binary content of the part
+ * @property {string} [contentType]
+ * @property {string} headers - key-value object with key being a header name
+ * and value an array with all header values found
+ * @property {string} [name] - filename, if part is an attachment
+ * @property {string} partName - name of the mime part (e.g: "1.2")
+ * @property {MimeMessagePart[]} [parts] - nested mime parts
+ * @property {string} [size] - size of the part
+ * @property {string} [url] - message url
+ */
+
+/**
+ * Returns attachments found in the message belonging to the given nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {boolean} includeNestedAttachments - Whether to return all attachments,
+ * including attachments from nested mime parts.
+ * @returns {Promise<MimeMessagePart[]>}
+ */
+async function getAttachments(msgHdr, includeNestedAttachments = false) {
+ let mimeMsg = await getMimeMessage(msgHdr);
+ if (!mimeMsg) {
+ return null;
+ }
+
+ // Reduce returned attachments according to includeNestedAttachments.
+ let level = mimeMsg.partName ? mimeMsg.partName.split(".").length : 0;
+ return mimeMsg.attachments.filter(
+ a => includeNestedAttachments || a.partName.split(".").length == level + 2
+ );
+}
+
+/**
+ * Returns the attachment identified by the provided partName.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {string} partName
+ * @param {object} [options={}] - If the includeRaw property is truthy the raw
+ * attachment contents are included.
+ * @returns {Promise<MimeMessagePart>}
+ */
+async function getAttachment(msgHdr, partName, options = {}) {
+ // It's not ideal to have to call MsgHdrToMimeMessage here again, but we need
+ // the name of the attached file, plus this also gives us the URI without having
+ // to jump through a lot of hoops.
+ let attachment = await getMimeMessage(msgHdr, partName);
+ if (!attachment) {
+ return null;
+ }
+
+ if (options.includeRaw) {
+ let channel = Services.io.newChannelFromURI(
+ Services.io.newURI(attachment.url),
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+
+ attachment.raw = await new Promise((resolve, reject) => {
+ let listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+ listener.init({
+ onStreamComplete(loader, context, status, resultLength, result) {
+ if (Components.isSuccessCode(status)) {
+ resolve(Uint8Array.from(result));
+ } else {
+ reject(
+ new ExtensionError(
+ `Failed to read attachment ${attachment.url} content: ${status}`
+ )
+ );
+ }
+ },
+ });
+ channel.asyncOpen(listener, null);
+ });
+ }
+
+ return attachment;
+}
+
+/**
+ * Returns the <part> parameter of the dummyMsgUrl of the provided nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @returns {string}
+ */
+function getSubMessagePartName(msgHdr) {
+ if (msgHdr.folder || !msgHdr.getStringProperty("dummyMsgUrl")) {
+ return "";
+ }
+
+ return new URL(msgHdr.getStringProperty("dummyMsgUrl")).searchParams.get(
+ "part"
+ );
+}
+
+/**
+ * Returns the nsIMsgHdr of the outer message, if the provided nsIMsgHdr belongs
+ * to a message which is actually an attachment of another message. Returns null
+ * otherwise.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @returns {nsIMsgHdr}
+ */
+function getParentMsgHdr(msgHdr) {
+ if (msgHdr.folder || !msgHdr.getStringProperty("dummyMsgUrl")) {
+ return null;
+ }
+
+ let url = new URL(msgHdr.getStringProperty("dummyMsgUrl"));
+
+ if (url.protocol == "news:") {
+ let newsUrl = `news-message://${url.hostname}/${url.searchParams.get(
+ "group"
+ )}#${url.searchParams.get("key")}`;
+ return messenger.msgHdrFromURI(newsUrl);
+ }
+
+ if (url.protocol == "mailbox:") {
+ // This could be a sub-message of a message opened from file.
+ let fileUrl = `file://${url.pathname}`;
+ let parentMsgHdr = messageTracker._dummyMessageHeaders.get(fileUrl);
+ if (parentMsgHdr) {
+ return parentMsgHdr;
+ }
+ }
+ // Everything else should be a mailbox:// or an imap:// url.
+ let params = Array.from(url.searchParams, p => p[0]).filter(
+ p => !["number"].includes(p)
+ );
+ for (let param of params) {
+ url.searchParams.delete(param);
+ }
+ return Services.io.newURI(url.href).QueryInterface(Ci.nsIMsgMessageUrl)
+ .messageHeader;
+}
+
+/**
+ * Get the raw message for a given nsIMsgHdr.
+ *
+ * @param aMsgHdr - The message header to retrieve the raw message for.
+ * @returns {Promise<string>} - Binary string of the raw message.
+ */
+async function getRawMessage(msgHdr) {
+ // If this message is a sub-message (an attachment of another message), get it
+ // as an attachment from the parent message and return its raw content.
+ let subMsgPartName = getSubMessagePartName(msgHdr);
+ if (subMsgPartName) {
+ let parentMsgHdr = getParentMsgHdr(msgHdr);
+ let attachment = await getAttachment(parentMsgHdr, subMsgPartName, {
+ includeRaw: true,
+ });
+ return attachment.raw.reduce(
+ (prev, curr) => prev + String.fromCharCode(curr),
+ ""
+ );
+ }
+
+ // Messages opened from file do not have a folder property, but
+ // have their url stored as a string property.
+ let msgUri = msgHdr.folder
+ ? msgHdr.folder.generateMessageURI(msgHdr.messageKey)
+ : msgHdr.getStringProperty("dummyMsgUrl");
+
+ let service = MailServices.messageServiceFromURI(msgUri);
+ return new Promise((resolve, reject) => {
+ let streamlistener = {
+ _data: [],
+ _stream: null,
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ if (!this._stream) {
+ this._stream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ this._stream.init(aInputStream);
+ }
+ this._data.push(this._stream.read(aCount));
+ },
+ onStartRequest() {},
+ onStopRequest(request, status) {
+ if (Components.isSuccessCode(status)) {
+ resolve(this._data.join(""));
+ } else {
+ reject(
+ new ExtensionError(
+ `Error while streaming message <${msgUri}>: ${status}`
+ )
+ );
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIRequestObserver",
+ ]),
+ };
+
+ // This is not using aConvertData and therefore works for news:// messages.
+ service.streamMessage(
+ msgUri,
+ streamlistener,
+ null, // aMsgWindow
+ null, // aUrlListener
+ false, // aConvertData
+ "" //aAdditionalHeader
+ );
+ });
+}
+
+/**
+ * Returns MIME parts found in the message identified by the given nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {string} partName - Return only a specific mime part.
+ * @returns {Promise<MimeMessagePart>}
+ */
+async function getMimeMessage(msgHdr, partName = "") {
+ // If this message is a sub-message (an attachment of another message), get the
+ // mime parts of the parent message and return the part of the sub-message.
+ let subMsgPartName = getSubMessagePartName(msgHdr);
+ if (subMsgPartName) {
+ let parentMsgHdr = getParentMsgHdr(msgHdr);
+ if (!parentMsgHdr) {
+ return null;
+ }
+
+ let mimeMsg = await getMimeMessage(parentMsgHdr, partName);
+ if (!mimeMsg) {
+ return null;
+ }
+
+ // If <partName> was specified, the returned mime message is just that part,
+ // no further processing needed. But prevent x-ray vision into the parent.
+ if (partName) {
+ if (partName.split(".").length > subMsgPartName.split(".").length) {
+ return mimeMsg;
+ }
+ return null;
+ }
+
+ // Limit mimeMsg and attachments to the requested <subMessagePart>.
+ let findSubPart = (parts, partName) => {
+ let match = parts.find(a => partName.startsWith(a.partName));
+ if (!match) {
+ throw new ExtensionError(
+ `Unexpected Error: Part ${partName} not found.`
+ );
+ }
+ return match.partName == partName
+ ? match
+ : findSubPart(match.parts, partName);
+ };
+ let subMimeMsg = findSubPart(mimeMsg.parts, subMsgPartName);
+
+ if (mimeMsg.attachments) {
+ subMimeMsg.attachments = mimeMsg.attachments.filter(
+ a =>
+ a.partName != subMsgPartName && a.partName.startsWith(subMsgPartName)
+ );
+ }
+ return subMimeMsg;
+ }
+
+ let mimeMsg = await new Promise(resolve => {
+ MsgHdrToMimeMessage(
+ msgHdr,
+ null,
+ (_msgHdr, mimeMsg) => {
+ mimeMsg.attachments = mimeMsg.allInlineAttachments;
+ resolve(mimeMsg);
+ },
+ true,
+ { examineEncryptedParts: true }
+ );
+ });
+
+ return partName
+ ? mimeMsg.attachments.find(a => a.partName == partName)
+ : mimeMsg;
+}
+
+this.messages = 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).
+
+ onNewMailReceived({ context, fire }) {
+ let listener = async (event, folder, newMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert it early.
+ let page = await messageListTracker.startList(newMessages, extension);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(convertFolder(folder), page);
+ };
+ messageTracker.on("messages-received", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-received", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onUpdated({ context, fire }) {
+ let listener = async (event, message, properties) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert it early.
+ let convertedMessage = convertMessage(message, extension);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(convertedMessage, properties);
+ };
+ messageTracker.on("message-updated", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("message-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMoved({ context, fire }) {
+ let listener = async (event, srcMessages, dstMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let srcPage = await messageListTracker.startList(
+ srcMessages,
+ extension
+ );
+ let dstPage = await messageListTracker.startList(
+ dstMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcPage, dstPage);
+ };
+ messageTracker.on("messages-moved", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-moved", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onCopied({ context, fire }) {
+ let listener = async (event, srcMessages, dstMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let srcPage = await messageListTracker.startList(
+ srcMessages,
+ extension
+ );
+ let dstPage = await messageListTracker.startList(
+ dstMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcPage, dstPage);
+ };
+ messageTracker.on("messages-copied", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-copied", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ let listener = async (event, deletedMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let deletedPage = await messageListTracker.startList(
+ deletedMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(deletedPage);
+ };
+ messageTracker.on("messages-deleted", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ const { extension } = this;
+ const { tabManager } = extension;
+
+ function collectMessagesInFolders(messageIds) {
+ let folderMap = new DefaultMap(() => new Set());
+
+ for (let messageId of messageIds) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+
+ let msgHeaderSet = folderMap.get(msgHdr.folder);
+ msgHeaderSet.add(msgHdr);
+ }
+
+ return folderMap;
+ }
+
+ async function createTempFileMessage(msgHdr) {
+ let rawBinaryString = await getRawMessage(msgHdr);
+ let pathEmlFile = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ encodeURIComponent(msgHdr.messageId).replaceAll(/[/:*?\"<>|]/g, "_") +
+ ".eml",
+ 0o600
+ );
+
+ let emlFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ emlFile.initWithPath(pathEmlFile);
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(emlFile);
+
+ let buffer = MailStringUtils.byteStringToUint8Array(rawBinaryString);
+ await IOUtils.write(pathEmlFile, buffer);
+ return emlFile;
+ }
+
+ async function moveOrCopyMessages(messageIds, { accountId, path }, isMove) {
+ if (
+ !context.extension.hasPermission("accountsRead") ||
+ !context.extension.hasPermission("messagesMove")
+ ) {
+ throw new ExtensionError(
+ `Using messages.${
+ isMove ? "move" : "copy"
+ }() requires the "accountsRead" and the "messagesMove" permission`
+ );
+ }
+ let destinationURI = folderPathToURI(accountId, path);
+ let destinationFolder =
+ MailServices.folderLookup.getFolderForURL(destinationURI);
+ try {
+ let promises = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (sourceFolder == destinationFolder) {
+ continue;
+ }
+ let msgHeaders = [...msgHeaderSet];
+
+ // Special handling for external messages.
+ if (!sourceFolder) {
+ if (isMove) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+
+ for (let msgHdr of msgHeaders) {
+ let file;
+ let fileUrl = msgHdr.getStringProperty("dummyMsgUrl");
+ if (fileUrl.startsWith("file://")) {
+ file = Services.io
+ .newURI(fileUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ } else {
+ file = await createTempFileMessage(msgHdr);
+ }
+
+ promises.push(
+ new Promise((resolve, reject) => {
+ MailServices.copy.copyFileMessage(
+ file,
+ destinationFolder,
+ /* msgToReplace */ null,
+ /* isDraftOrTemplate */ false,
+ /* aMsgFlags */ Ci.nsMsgMessageFlags.Read,
+ /* aMsgKeywords */ "",
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null
+ );
+ })
+ );
+ }
+ continue;
+ }
+
+ // Since the archiver falls back to copy if delete is not supported,
+ // lets do that here as well.
+ promises.push(
+ new Promise((resolve, reject) => {
+ MailServices.copy.copyMessages(
+ sourceFolder,
+ msgHeaders,
+ destinationFolder,
+ isMove && sourceFolder.canDeleteMessages,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null,
+ /* allowUndo */ true
+ );
+ })
+ );
+ }
+ await Promise.all(promises);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(
+ `Error ${isMove ? "moving" : "copying"} message: ${ex.message}`
+ );
+ }
+ }
+
+ return {
+ messages: {
+ onNewMailReceived: new EventManager({
+ context,
+ module: "messages",
+ event: "onNewMailReceived",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "messages",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ onMoved: new EventManager({
+ context,
+ module: "messages",
+ event: "onMoved",
+ extensionApi: this,
+ }).api(),
+ onCopied: new EventManager({
+ context,
+ module: "messages",
+ event: "onCopied",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "messages",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ async list({ accountId, path }) {
+ let uri = folderPathToURI(accountId, path);
+ let folder = MailServices.folderLookup.getFolderForURL(uri);
+
+ if (!folder) {
+ throw new ExtensionError(`Folder not found: ${path}`);
+ }
+
+ return messageListTracker.startList(
+ folder.messages,
+ context.extension
+ );
+ },
+ async continueList(messageListId) {
+ let messageList = messageListTracker.getList(
+ messageListId,
+ context.extension
+ );
+ return messageListTracker.getNextPage(messageList);
+ },
+ async get(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let messageHeader = convertMessage(msgHdr, context.extension);
+ if (messageHeader.id != messageId) {
+ throw new ExtensionError(
+ "Unexpected Error: Returned message does not equal requested message."
+ );
+ }
+ return messageHeader;
+ },
+ async getFull(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let mimeMsg = await getMimeMessage(msgHdr);
+ if (!mimeMsg) {
+ throw new ExtensionError(`Error reading message ${messageId}`);
+ }
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.Partial) {
+ // Do not include fake body.
+ mimeMsg.parts = [];
+ }
+ return convertMessagePart(mimeMsg);
+ },
+ async getRaw(messageId, options) {
+ let data_format = options?.data_format;
+ if (!["File", "BinaryString"].includes(data_format)) {
+ data_format =
+ extension.manifestVersion < 3 ? "BinaryString" : "File";
+ }
+
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ try {
+ let raw = await getRawMessage(msgHdr);
+ if (data_format == "File") {
+ // Convert binary string to Uint8Array and return a File.
+ let bytes = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) {
+ bytes[i] = raw.charCodeAt(i) & 0xff;
+ }
+ return new File([bytes], `message-${messageId}.eml`, {
+ type: "message/rfc822",
+ });
+ }
+ return raw;
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error reading message ${messageId}`);
+ }
+ },
+ async listAttachments(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachments = await getAttachments(msgHdr);
+ for (let i = 0; i < attachments.length; i++) {
+ attachments[i] = await convertAttachment(attachments[i]);
+ }
+ return attachments;
+ },
+ async getAttachmentFile(messageId, partName) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachment = await getAttachment(msgHdr, partName, {
+ includeRaw: true,
+ });
+ if (!attachment) {
+ throw new ExtensionError(
+ `Part ${partName} not found in message ${messageId}.`
+ );
+ }
+ return new File([attachment.raw], attachment.name, {
+ type: attachment.contentType,
+ });
+ },
+ async openAttachment(messageId, partName, tabId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachment = await getAttachment(msgHdr, partName);
+ if (!attachment) {
+ throw new ExtensionError(
+ `Part ${partName} not found in message ${messageId}.`
+ );
+ }
+ let attachmentInfo = new AttachmentInfo({
+ contentType: attachment.contentType,
+ url: attachment.url,
+ name: attachment.name,
+ uri: msgHdr.folder.getUriForMsg(msgHdr),
+ isExternalAttachment: attachment.isExternal,
+ message: msgHdr,
+ });
+ let tab = tabManager.get(tabId);
+ try {
+ // Content tabs or content windows use browser, while mail and message
+ // tabs use chromeBrowser.
+ let browser = tab.nativeTab.chromeBrowser || tab.nativeTab.browser;
+ await attachmentInfo.open(browser.browsingContext);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Part ${partName} could not be opened: ${ex}.`
+ );
+ }
+ },
+ async query(queryInfo) {
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ const includesContent = (folder, parts, searchTerm) => {
+ if (!parts || parts.length == 0) {
+ return false;
+ }
+ for (let part of parts) {
+ if (
+ coerceBodyToPlaintext(folder, part).includes(searchTerm) ||
+ includesContent(folder, part.parts, searchTerm)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ const coerceBodyToPlaintext = (folder, part) => {
+ if (!part || !part.body) {
+ return "";
+ }
+ if (part.contentType == "text/plain") {
+ return part.body;
+ }
+ // text/enriched gets transformed into HTML by libmime
+ if (
+ part.contentType == "text/html" ||
+ part.contentType == "text/enriched"
+ ) {
+ return folder.convertMsgSnippetToPlainText(part.body);
+ }
+ return "";
+ };
+
+ /**
+ * Prepare name and email properties of the address object returned by
+ * MailServices.headerParser.makeFromDisplayAddress() to be lower case.
+ * Also fix the name being wrongly returned in the email property, if
+ * the address was just a single name.
+ */
+ const prepareAddress = displayAddr => {
+ let email = displayAddr.email?.toLocaleLowerCase();
+ let name = displayAddr.name?.toLocaleLowerCase();
+ if (email && !name && !email.includes("@")) {
+ name = email;
+ email = null;
+ }
+ return { name, email };
+ };
+
+ /**
+ * Check multiple addresses if they match the provided search address.
+ *
+ * @returns A boolean indicating if search was successful.
+ */
+ const searchInMultipleAddresses = (searchAddress, addresses) => {
+ // Return on first positive match.
+ for (let address of addresses) {
+ let nameMatched =
+ searchAddress.name &&
+ address.name &&
+ address.name.includes(searchAddress.name);
+
+ // Check for email match. Name match being required on top, if
+ // specified.
+ if (
+ (nameMatched || !searchAddress.name) &&
+ searchAddress.email &&
+ address.email &&
+ address.email == searchAddress.email
+ ) {
+ return true;
+ }
+
+ // If address match failed, name match may only be true if no
+ // email has been specified.
+ if (!searchAddress.email && nameMatched) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ /**
+ * Substring match on name and exact match on email. If searchTerm
+ * includes multiple addresses, all of them must match.
+ *
+ * @returns A boolean indicating if search was successful.
+ */
+ const isAddressMatch = (searchTerm, addressObjects) => {
+ let searchAddresses =
+ MailServices.headerParser.makeFromDisplayAddress(searchTerm);
+ if (!searchAddresses || searchAddresses.length == 0) {
+ return false;
+ }
+
+ // Prepare addresses.
+ let addresses = [];
+ for (let addressObject of addressObjects) {
+ let decodedAddressString = addressObject.doRfc2047
+ ? jsmime.headerparser.decodeRFC2047Words(addressObject.addr)
+ : addressObject.addr;
+ for (let address of MailServices.headerParser.makeFromDisplayAddress(
+ decodedAddressString
+ )) {
+ addresses.push(prepareAddress(address));
+ }
+ }
+ if (addresses.length == 0) {
+ return false;
+ }
+
+ let success = false;
+ for (let searchAddress of searchAddresses) {
+ // Exit early if this search was not successfully, but all search
+ // addresses have to be matched.
+ if (
+ !searchInMultipleAddresses(
+ prepareAddress(searchAddress),
+ addresses
+ )
+ ) {
+ return false;
+ }
+ success = true;
+ }
+
+ return success;
+ };
+
+ const checkSearchCriteria = async (folder, msg) => {
+ // Check date ranges.
+ if (
+ queryInfo.fromDate !== null &&
+ msg.dateInSeconds * 1000 < queryInfo.fromDate.getTime()
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.toDate !== null &&
+ msg.dateInSeconds * 1000 > queryInfo.toDate.getTime()
+ ) {
+ return false;
+ }
+
+ // Check headerMessageId.
+ if (
+ queryInfo.headerMessageId &&
+ msg.messageId != queryInfo.headerMessageId
+ ) {
+ return false;
+ }
+
+ // Check unread.
+ if (queryInfo.unread !== null && msg.isRead != !queryInfo.unread) {
+ return false;
+ }
+
+ // Check flagged.
+ if (
+ queryInfo.flagged !== null &&
+ msg.isFlagged != queryInfo.flagged
+ ) {
+ return false;
+ }
+
+ // Check subject (substring match).
+ if (
+ queryInfo.subject &&
+ !msg.mime2DecodedSubject.includes(queryInfo.subject)
+ ) {
+ return false;
+ }
+
+ // Check tags.
+ if (requiredTags || forbiddenTags) {
+ let messageTags = msg.getStringProperty("keywords").split(" ");
+ if (requiredTags.length > 0) {
+ if (
+ queryInfo.tags.mode == "all" &&
+ !requiredTags.every(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.tags.mode == "any" &&
+ !requiredTags.some(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ }
+ if (forbiddenTags.length > 0) {
+ if (
+ queryInfo.tags.mode == "all" &&
+ forbiddenTags.every(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.tags.mode == "any" &&
+ forbiddenTags.some(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ }
+ }
+
+ // Check toMe (case insensitive email address match).
+ if (queryInfo.toMe !== null) {
+ let recipients = [].concat(
+ composeFields.splitRecipients(msg.recipients, true),
+ composeFields.splitRecipients(msg.ccList, true),
+ composeFields.splitRecipients(msg.bccList, true)
+ );
+
+ if (
+ queryInfo.toMe !=
+ recipients.some(email =>
+ identities.includes(email.toLocaleLowerCase())
+ )
+ ) {
+ return false;
+ }
+ }
+
+ // Check fromMe (case insensitive email address match).
+ if (queryInfo.fromMe !== null) {
+ let authors = composeFields.splitRecipients(
+ msg.mime2DecodedAuthor,
+ true
+ );
+ if (
+ queryInfo.fromMe !=
+ authors.some(email =>
+ identities.includes(email.toLocaleLowerCase())
+ )
+ ) {
+ return false;
+ }
+ }
+
+ // Check author.
+ if (
+ queryInfo.author &&
+ !isAddressMatch(queryInfo.author, [
+ { addr: msg.mime2DecodedAuthor, doRfc2047: false },
+ ])
+ ) {
+ return false;
+ }
+
+ // Check recipients.
+ if (
+ queryInfo.recipients &&
+ !isAddressMatch(queryInfo.recipients, [
+ { addr: msg.mime2DecodedRecipients, doRfc2047: false },
+ { addr: msg.ccList, doRfc2047: true },
+ { addr: msg.bccList, doRfc2047: true },
+ ])
+ ) {
+ return false;
+ }
+
+ // Check if fullText is already partially fulfilled.
+ let fullTextBodySearchNeeded = false;
+ if (queryInfo.fullText) {
+ let subjectMatches = msg.mime2DecodedSubject.includes(
+ queryInfo.fullText
+ );
+ let authorMatches = msg.mime2DecodedAuthor.includes(
+ queryInfo.fullText
+ );
+ fullTextBodySearchNeeded = !(subjectMatches || authorMatches);
+ }
+
+ // Check body.
+ if (queryInfo.body || fullTextBodySearchNeeded) {
+ let mimeMsg = await getMimeMessage(msg);
+ if (
+ queryInfo.body &&
+ !includesContent(folder, [mimeMsg], queryInfo.body)
+ ) {
+ return false;
+ }
+ if (
+ fullTextBodySearchNeeded &&
+ !includesContent(folder, [mimeMsg], queryInfo.fullText)
+ ) {
+ return false;
+ }
+ }
+
+ // Check attachments.
+ if (queryInfo.attachment != null) {
+ let attachments = await getAttachments(
+ msg,
+ /* includeNestedAttachments */ true
+ );
+ return !!attachments.length == queryInfo.attachment;
+ }
+
+ return true;
+ };
+
+ const searchMessages = async (
+ folder,
+ messageList,
+ includeSubFolders = false
+ ) => {
+ let messages = null;
+ try {
+ messages = folder.messages;
+ } catch (e) {
+ /* Some folders fail on message query, instead of returning empty */
+ }
+
+ if (messages) {
+ for (let msg of [...messages]) {
+ if (await checkSearchCriteria(folder, msg)) {
+ messageList.add(msg);
+ }
+ }
+ }
+
+ if (includeSubFolders) {
+ for (let subFolder of folder.subFolders) {
+ await searchMessages(subFolder, messageList, true);
+ }
+ }
+ };
+
+ const searchFolders = async (
+ folders,
+ messageList,
+ includeSubFolders = false
+ ) => {
+ for (let folder of folders) {
+ await searchMessages(folder, messageList, includeSubFolders);
+ }
+ return messageList.done();
+ };
+
+ // Prepare case insensitive me filtering.
+ let identities;
+ if (queryInfo.toMe !== null || queryInfo.fromMe !== null) {
+ identities = MailServices.accounts.allIdentities.map(i =>
+ i.email.toLocaleLowerCase()
+ );
+ }
+
+ // Prepare tag filtering.
+ let requiredTags;
+ let forbiddenTags;
+ if (queryInfo.tags) {
+ let availableTags = MailServices.tags.getAllTags();
+ requiredTags = availableTags.filter(
+ tag =>
+ tag.key in queryInfo.tags.tags && queryInfo.tags.tags[tag.key]
+ );
+ forbiddenTags = availableTags.filter(
+ tag =>
+ tag.key in queryInfo.tags.tags && !queryInfo.tags.tags[tag.key]
+ );
+ // If non-existing tags have been required, return immediately with
+ // an empty message list.
+ if (
+ requiredTags.length === 0 &&
+ Object.values(queryInfo.tags.tags).filter(v => v).length > 0
+ ) {
+ return messageListTracker.startList([], context.extension);
+ }
+ requiredTags = requiredTags.map(tag => tag.key);
+ forbiddenTags = forbiddenTags.map(tag => tag.key);
+ }
+
+ // Limit search to a given folder, or search all folders.
+ let folders = [];
+ let includeSubFolders = false;
+ if (queryInfo.folder) {
+ includeSubFolders = !!queryInfo.includeSubFolders;
+ if (!context.extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Querying by folder requires the "accountsRead" permission'
+ );
+ }
+ let folder = MailServices.folderLookup.getFolderForURL(
+ folderPathToURI(queryInfo.folder.accountId, queryInfo.folder.path)
+ );
+ if (!folder) {
+ throw new ExtensionError(
+ `Folder not found: ${queryInfo.folder.path}`
+ );
+ }
+ folders.push(folder);
+ } else {
+ includeSubFolders = true;
+ for (let account of MailServices.accounts.accounts) {
+ folders.push(account.incomingServer.rootFolder);
+ }
+ }
+
+ // The searchFolders() function searches the provided folders for
+ // messages matching the query and adds results to the messageList. It
+ // is an asynchronous function, but it is not awaited here. Instead,
+ // messageListTracker.getNextPage() returns a Promise, which will
+ // fulfill after enough messages for a full page have been added.
+ let messageList = messageListTracker.createList(context.extension);
+ searchFolders(folders, messageList, includeSubFolders);
+ return messageListTracker.getNextPage(messageList);
+ },
+ async update(messageId, newProperties) {
+ try {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ if (!msgHdr.folder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+
+ let msgs = [msgHdr];
+ if (newProperties.read !== null) {
+ msgHdr.folder.markMessagesRead(msgs, newProperties.read);
+ }
+ if (newProperties.flagged !== null) {
+ msgHdr.folder.markMessagesFlagged(msgs, newProperties.flagged);
+ }
+ if (newProperties.junk !== null) {
+ let score = newProperties.junk
+ ? Ci.nsIJunkMailPlugin.IS_SPAM_SCORE
+ : Ci.nsIJunkMailPlugin.IS_HAM_SCORE;
+ msgHdr.folder.setJunkScoreForMessages(msgs, score);
+ // nsIFolderListener::OnFolderEvent is notified about changes through
+ // setJunkScoreForMessages(), but does not provide the actual message.
+ // nsIMsgFolderListener::msgsJunkStatusChanged is notified only by
+ // nsMsgDBView::ApplyCommandToIndices(). Since it only works on
+ // selected messages, we cannot use it here.
+ // Notify msgsJunkStatusChanged() manually.
+ MailServices.mfn.notifyMsgsJunkStatusChanged(msgs);
+ }
+ if (Array.isArray(newProperties.tags)) {
+ let currentTags = msgHdr.getStringProperty("keywords").split(" ");
+
+ for (let { key: tagKey } of MailServices.tags.getAllTags()) {
+ if (newProperties.tags.includes(tagKey)) {
+ if (!currentTags.includes(tagKey)) {
+ msgHdr.folder.addKeywordsToMessages(msgs, tagKey);
+ }
+ } else if (currentTags.includes(tagKey)) {
+ msgHdr.folder.removeKeywordsFromMessages(msgs, tagKey);
+ }
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error updating message: ${ex.message}`);
+ }
+ },
+ async move(messageIds, destination) {
+ return moveOrCopyMessages(messageIds, destination, true);
+ },
+ async copy(messageIds, destination) {
+ return moveOrCopyMessages(messageIds, destination, false);
+ },
+ async delete(messageIds, skipTrash) {
+ try {
+ let promises = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (!sourceFolder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+ if (!sourceFolder.canDeleteMessages) {
+ throw new ExtensionError(
+ `Messages in "${sourceFolder.prettyName}" cannot be deleted`
+ );
+ }
+ promises.push(
+ new Promise((resolve, reject) => {
+ sourceFolder.deleteMessages(
+ [...msgHeaderSet],
+ /* msgWindow */ null,
+ /* deleteStorage */ skipTrash,
+ /* isMove */ false,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* allowUndo */ true
+ );
+ })
+ );
+ }
+ await Promise.all(promises);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error deleting message: ${ex.message}`);
+ }
+ },
+ async import(file, { accountId, path }, properties) {
+ if (
+ !context.extension.hasPermission("accountsRead") ||
+ !context.extension.hasPermission("messagesImport")
+ ) {
+ throw new ExtensionError(
+ `Using messages.import() requires the "accountsRead" and the "messagesImport" permission`
+ );
+ }
+ let destinationURI = folderPathToURI(accountId, path);
+ let destinationFolder =
+ MailServices.folderLookup.getFolderForURL(destinationURI);
+ if (!destinationFolder) {
+ throw new ExtensionError(`Folder not found: ${path}`);
+ }
+ if (!["none", "pop3"].includes(destinationFolder.server.type)) {
+ throw new ExtensionError(
+ `browser.messenger.import() is not supported for ${destinationFolder.server.type} accounts`
+ );
+ }
+ try {
+ let tempFile = await getRealFileForFile(file);
+ let msgHeader = await new Promise((resolve, reject) => {
+ let newKey = null;
+ let msgHdrs = new Map();
+
+ let folderListener = {
+ onMessageAdded(parentItem, msgHdr) {
+ if (destinationFolder.URI != msgHdr.folder.URI) {
+ return;
+ }
+ let key = msgHdr.messageKey;
+ msgHdrs.set(key, msgHdr);
+ if (msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ },
+ onFolderAdded(parent, child) {},
+ };
+
+ // Note: Currently this API is not supported for IMAP. Once this gets added (Bug 1787104),
+ // please note that the MailServices.mfn.addListener will fire only when the IMAP message
+ // is visibly shown in the UI, while MailServices.mailSession.AddFolderListener fires as
+ // soon as it has been added to the database .
+ MailServices.mailSession.AddFolderListener(
+ folderListener,
+ Ci.nsIFolderListener.added
+ );
+
+ let finish = msgHdr => {
+ MailServices.mailSession.RemoveFolderListener(folderListener);
+ resolve(msgHdr);
+ };
+
+ let tags = "";
+ let flags = 0;
+ if (properties) {
+ if (properties.tags) {
+ let knownTags = MailServices.tags
+ .getAllTags()
+ .map(tag => tag.key);
+ tags = properties.tags
+ .filter(tag => knownTags.includes(tag))
+ .join(" ");
+ }
+ flags |= properties.new ? Ci.nsMsgMessageFlags.New : 0;
+ flags |= properties.read ? Ci.nsMsgMessageFlags.Read : 0;
+ flags |= properties.flagged ? Ci.nsMsgMessageFlags.Marked : 0;
+ }
+ MailServices.copy.copyFileMessage(
+ tempFile,
+ destinationFolder,
+ /* msgToReplace */ null,
+ /* isDraftOrTemplate */ false,
+ /* aMsgFlags */ flags,
+ /* aMsgKeywords */ tags,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(aKey) {
+ /* Note: Not fired for offline IMAP. Add missing
+ * if (aCopyState) {
+ * ((nsImapMailCopyState*)aCopyState)->m_listener->SetMessageKey(fakeKey);
+ * }
+ * before firing the OnStopRunningUrl listener in
+ * nsImapService::OfflineAppendFromFile
+ */
+ newKey = aKey;
+ if (msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ },
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ if (newKey && msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null
+ );
+ });
+
+ // Do not wait till the temp file is removed on app shutdown. However, skip deletion if
+ // the provided DOM File was already linked to a real file.
+ if (!file.mozFullPath) {
+ await IOUtils.remove(tempFile.path);
+ }
+ return convertMessage(msgHeader, context.extension);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error importing message: ${ex.message}`);
+ }
+ },
+ async archive(messageIds) {
+ try {
+ let messages = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (!sourceFolder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+ messages.push(...msgHeaderSet);
+ }
+ await new Promise(resolve => {
+ let archiver = new MessageArchiver();
+ archiver.oncomplete = resolve;
+ archiver.archiveMessages(messages);
+ });
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error archiving message: ${ex.message}`);
+ }
+ },
+ async listTags() {
+ return MailServices.tags
+ .getAllTags()
+ .map(({ key, tag, color, ordinal }) => {
+ return {
+ key,
+ tag,
+ color,
+ ordinal,
+ };
+ });
+ },
+ async createTag(key, tag, color) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ if (tags.find(t => t.key == key)) {
+ throw new ExtensionError(`Specified key already exists: ${key}`);
+ }
+ if (tags.find(t => t.tag == tag)) {
+ throw new ExtensionError(`Specified tag already exists: ${tag}`);
+ }
+ MailServices.tags.addTagForKey(key, tag, color, "");
+ },
+ async updateTag(key, updateProperties) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ let tag = tags.find(t => t.key == key);
+ if (!tag) {
+ throw new ExtensionError(`Specified key does not exist: ${key}`);
+ }
+ if (updateProperties.color && tag.color != updateProperties.color) {
+ MailServices.tags.setColorForKey(key, updateProperties.color);
+ }
+ if (updateProperties.tag && tag.tag != updateProperties.tag) {
+ // Don't let the user edit a tag to the name of another existing tag.
+ if (tags.find(t => t.tag == updateProperties.tag)) {
+ throw new ExtensionError(
+ `Specified tag already exists: ${updateProperties.tag}`
+ );
+ }
+ MailServices.tags.setTagForKey(key, updateProperties.tag);
+ }
+ },
+ async deleteTag(key) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ if (!tags.find(t => t.key == key)) {
+ throw new ExtensionError(`Specified key does not exist: ${key}`);
+ }
+ MailServices.tags.deleteKey(key);
+ },
+ },
+ };
+ }
+};