Bug 1858721 - Introduce the MessagesQuery class for code related to messages.query(). r=mkmelin

Differential Revision: https://phabricator.services.mozilla.com/D190818

--HG--
extra : moz-landing-system : lando
This commit is contained in:
John Bieling 2023-10-20 22:16:55 +00:00
Родитель c1bcc2a80c
Коммит 0c6fdc2704
2 изменённых файлов: 758 добавлений и 721 удалений

Просмотреть файл

@ -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<MimeMessagePart[]>}
*/
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<MimeMessagePart>}
*/
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 <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 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<string>} - 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<MimeMessagePart>}
*/
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 <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;
}
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 <MessageList>} 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;
}

Просмотреть файл

@ -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<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);
}
// 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),
""
);
}
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<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;
}
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 {