diff --git a/mail/components/extensions/ExtensionMessages.sys.mjs b/mail/components/extensions/ExtensionMessages.sys.mjs index 37a2c5bbb4..889eb4abb6 100644 --- a/mail/components/extensions/ExtensionMessages.sys.mjs +++ b/mail/components/extensions/ExtensionMessages.sys.mjs @@ -9,11 +9,23 @@ import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { folderPathToURI } from "resource:///modules/ExtensionAccounts.sys.mjs"; + var { ExtensionError } = ExtensionUtils; var { MailServices } = ChromeUtils.import( "resource:///modules/MailServices.jsm" ); +ChromeUtils.defineModuleGetter( + lazy, + "MsgHdrToMimeMessage", + "resource:///modules/gloda/MimeMessage.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "jsmime", + "resource:///modules/jsmime.jsm" +); XPCOMUtils.defineLazyPreferenceGetter( lazy, "gJunkThreshold", @@ -47,6 +59,294 @@ export function getMsgStreamUrl(msgHdr) { return url.toString(); } +/** + * @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} + */ +export 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} + */ +export 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 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 MailServices.messageServiceFromURI("news:").messageURIToMsgHdr( + newsUrl + ); + } + + // 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} - Binary string of the raw message. + */ +export 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), + "" + ); + } + + let msgUri = getMsgStreamUrl(msgHdr); + 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} + */ +export 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 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 . + 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; + } + + try { + let mimeMsg = await new Promise((resolve, reject) => { + lazy.MsgHdrToMimeMessage( + msgHdr, + null, + (_msgHdr, mimeMsg) => { + if (!mimeMsg) { + reject(); + } else { + mimeMsg.attachments = mimeMsg.allInlineAttachments; + resolve(mimeMsg); + } + }, + true, + { examineEncryptedParts: true } + ); + }); + return partName + ? mimeMsg.attachments.find(a => a.partName == partName) + : mimeMsg; + } catch (ex) { + // Something went wrong. Return null, which will inform the user that the + // message could not be read. + console.warn(ex); + return null; + } +} + /** * Class for cached message headers to reduce XPCOM requests and to cache msgHdr * of file and attachment messages. @@ -868,3 +1168,445 @@ export class MessageManager { return this._messageListTracker.startList(messageList, this.extension); } } + +/** + * Convenience class to keep track of a search. + */ +export class MessageQuery { + /** + * @param {object} queryInfo + * @param {MessageListTracker} messageListTracker + * @param {ExtensionData} extension + * + * @see /mail/components/extensions/schemas/messages.json + */ + constructor(queryInfo, messageListTracker, extension) { + this.extension = extension; + this.queryInfo = queryInfo; + this.messageListTracker = messageListTracker; + this.messageList = this.messageListTracker.createList(this.extension); + + this.composeFields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + + // Prepare case insensitive me filtering. + this.identities = null; + if (this.queryInfo.toMe !== null || this.queryInfo.fromMe !== null) { + this.identities = MailServices.accounts.allIdentities.map(i => + i.email.toLocaleLowerCase() + ); + } + } + + /** + * Initiates the search. + * + * @returns {Promise } A Promise for the first page with search + * results. + */ + async startSearch() { + // Prepare tag filtering. + this.requiredTags = null; + this.forbiddenTags = null; + if (this.queryInfo.tags) { + let availableTags = MailServices.tags.getAllTags(); + this.requiredTags = availableTags.filter( + tag => + tag.key in this.queryInfo.tags.tags && + this.queryInfo.tags.tags[tag.key] + ); + this.forbiddenTags = availableTags.filter( + tag => + tag.key in this.queryInfo.tags.tags && + !this.queryInfo.tags.tags[tag.key] + ); + // If non-existing tags have been required, return immediately with + // an empty message list. + if ( + this.requiredTags.length === 0 && + Object.values(this.queryInfo.tags.tags).filter(v => v).length > 0 + ) { + return this.messageListTracker.startList([], this.extension); + } + this.requiredTags = this.requiredTags.map(tag => tag.key); + this.forbiddenTags = this.forbiddenTags.map(tag => tag.key); + } + + // Limit search to a given folder, or search all folders. + let folders = []; + let includeSubFolders = false; + if (this.queryInfo.folder) { + includeSubFolders = !!this.queryInfo.includeSubFolders; + if (!this.extension.hasPermission("accountsRead")) { + throw new ExtensionError( + 'Querying by folder requires the "accountsRead" permission' + ); + } + let folder = MailServices.folderLookup.getFolderForURL( + folderPathToURI( + this.queryInfo.folder.accountId, + this.queryInfo.folder.path + ) + ); + if (!folder) { + throw new ExtensionError( + `Folder not found: ${this.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. + this.searchFolders(folders, includeSubFolders); + return this.messageListTracker.getNextPage(this.messageList); + } + + async checkSearchCriteria(folder, msg) { + // Check date ranges. + if ( + this.queryInfo.fromDate !== null && + msg.dateInSeconds * 1000 < this.queryInfo.fromDate.getTime() + ) { + return false; + } + if ( + this.queryInfo.toDate !== null && + msg.dateInSeconds * 1000 > this.queryInfo.toDate.getTime() + ) { + return false; + } + + // Check headerMessageId. + if ( + this.queryInfo.headerMessageId && + msg.messageId != this.queryInfo.headerMessageId + ) { + return false; + } + + // Check unread. + if ( + this.queryInfo.unread !== null && + msg.isRead != !this.queryInfo.unread + ) { + return false; + } + + // Check flagged. + if ( + this.queryInfo.flagged !== null && + msg.isFlagged != this.queryInfo.flagged + ) { + return false; + } + + // Check subject (substring match). + if ( + this.queryInfo.subject && + !msg.mime2DecodedSubject.includes(this.queryInfo.subject) + ) { + return false; + } + + // Check tags. + if (this.requiredTags || this.forbiddenTags) { + let messageTags = msg.getStringProperty("keywords").split(" "); + if (this.requiredTags.length > 0) { + if ( + this.queryInfo.tags.mode == "all" && + !this.requiredTags.every(tag => messageTags.includes(tag)) + ) { + return false; + } + if ( + this.queryInfo.tags.mode == "any" && + !this.requiredTags.some(tag => messageTags.includes(tag)) + ) { + return false; + } + } + if (this.forbiddenTags.length > 0) { + if ( + this.queryInfo.tags.mode == "all" && + this.forbiddenTags.every(tag => messageTags.includes(tag)) + ) { + return false; + } + if ( + this.queryInfo.tags.mode == "any" && + this.forbiddenTags.some(tag => messageTags.includes(tag)) + ) { + return false; + } + } + } + + // Check toMe (case insensitive email address match). + if (this.queryInfo.toMe !== null) { + let recipients = [].concat( + this.composeFields.splitRecipients(msg.recipients, true), + this.composeFields.splitRecipients(msg.ccList, true), + this.composeFields.splitRecipients(msg.bccList, true) + ); + + if ( + this.queryInfo.toMe != + recipients.some(email => + this.identities.includes(email.toLocaleLowerCase()) + ) + ) { + return false; + } + } + + // Check fromMe (case insensitive email address match). + if (this.queryInfo.fromMe !== null) { + let authors = this.composeFields.splitRecipients( + msg.mime2DecodedAuthor, + true + ); + if ( + this.queryInfo.fromMe != + authors.some(email => + this.identities.includes(email.toLocaleLowerCase()) + ) + ) { + return false; + } + } + + // Check author. + if ( + this.queryInfo.author && + !isAddressMatch(this.queryInfo.author, [ + { addr: msg.mime2DecodedAuthor, doRfc2047: false }, + ]) + ) { + return false; + } + + // Check recipients. + if ( + this.queryInfo.recipients && + !isAddressMatch(this.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 (this.queryInfo.fullText) { + let subjectMatches = msg.mime2DecodedSubject.includes( + this.queryInfo.fullText + ); + let authorMatches = msg.mime2DecodedAuthor.includes( + this.queryInfo.fullText + ); + fullTextBodySearchNeeded = !(subjectMatches || authorMatches); + } + + // Check body. + if (this.queryInfo.body || fullTextBodySearchNeeded) { + let mimeMsg = await getMimeMessage(msg); + if ( + this.queryInfo.body && + !includesContent(folder, [mimeMsg], this.queryInfo.body) + ) { + return false; + } + if ( + fullTextBodySearchNeeded && + !includesContent(folder, [mimeMsg], this.queryInfo.fullText) + ) { + return false; + } + } + + // Check attachments. + if (this.queryInfo.attachment != null) { + let attachments = await getAttachments( + msg, + true // includeNestedAttachments + ); + return !!attachments.length == this.queryInfo.attachment; + } + + return true; + } + + async searchMessages(folder, 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 (this.messageList.isDone) { + return; + } + if (await this.checkSearchCriteria(folder, msg)) { + this.messageList.addMessage(msg); + } + } + } + + if (includeSubFolders) { + for (let subFolder of folder.subFolders) { + if (this.messageList.isDone) { + return; + } + await this.searchMessages(subFolder, true); + } + } + } + + async searchFolders(folders, includeSubFolders = false) { + for (let folder of folders) { + if (this.messageList.isDone) { + return; + } + await this.searchMessages(folder, includeSubFolders); + } + this.messageList.done(); + } +} + +function 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; +} + +function 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. + * + * @param {string} displayAddr - Full mail address with (potentially) name and + * email. + */ +function 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. + */ +function 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. + */ +function 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 + ? lazy.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; +} diff --git a/mail/components/extensions/parent/ext-messages.js b/mail/components/extensions/parent/ext-messages.js index 0476c11a5f..e81ea47038 100644 --- a/mail/components/extensions/parent/ext-messages.js +++ b/mail/components/extensions/parent/ext-messages.js @@ -6,9 +6,16 @@ ChromeUtils.defineESModuleGetters(this, { AttachmentInfo: "resource:///modules/AttachmentInfo.sys.mjs", }); -var { CachedMsgHeader, getMsgStreamUrl } = ChromeUtils.importESModule( - "resource:///modules/ExtensionMessages.sys.mjs" -); +var { + CachedMsgHeader, + MessageQuery, + getAttachment, + getAttachments, + getMimeMessage, + getMsgStreamUrl, + getRawMessage, +} = ChromeUtils.importESModule("resource:///modules/ExtensionMessages.sys.mjs"); + var { folderPathToURI } = ChromeUtils.importESModule( "resource:///modules/ExtensionAccounts.sys.mjs" ); @@ -31,21 +38,11 @@ ChromeUtils.defineModuleGetter( "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" @@ -54,8 +51,6 @@ XPCOMUtils.defineLazyGlobalGetters(this, ["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. @@ -121,292 +116,6 @@ async function convertAttachment(attachment, extension) { 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} - */ -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} - */ -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 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); - } - - // 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} - 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), - "" - ); - } - - let msgUri = getMsgStreamUrl(msgHdr); - 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} - */ -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 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 . - 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; - } - - try { - let mimeMsg = await new Promise((resolve, reject) => { - MsgHdrToMimeMessage( - msgHdr, - null, - (_msgHdr, mimeMsg) => { - if (!mimeMsg) { - reject(); - } else { - mimeMsg.attachments = mimeMsg.allInlineAttachments; - resolve(mimeMsg); - } - }, - true, - { examineEncryptedParts: true } - ); - }); - return partName - ? mimeMsg.attachments.find(a => a.partName == partName) - : mimeMsg; - } catch (ex) { - // Something went wrong. Return null, which will inform the user that the - // message could not be read. - console.warn(ex); - return null; - } -} - this.messages = class extends ExtensionAPIPersistent { PERSISTENT_EVENTS = { // For primed persistent events (deactivated background), the context is only @@ -866,426 +575,12 @@ this.messages = class extends ExtensionAPIPersistent { } }, 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 (messageList.isDone) { - return; - } - if (await checkSearchCriteria(folder, msg)) { - messageList.addMessage(msg); - } - } - } - - if (includeSubFolders) { - for (let subFolder of folder.subFolders) { - if (messageList.isDone) { - return; - } - await searchMessages(subFolder, messageList, true); - } - } - }; - - const searchFolders = async ( - folders, - messageList, - includeSubFolders = false - ) => { - for (let folder of folders) { - if (messageList.isDone) { - return; - } - await searchMessages(folder, messageList, includeSubFolders); - } - 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); + let messageQuery = new MessageQuery( + queryInfo, + messageListTracker, + context.extension + ); + return messageQuery.startSearch(); }, async update(messageId, newProperties) { try {