diff options
Diffstat (limited to '')
-rw-r--r-- | comm/mail/base/content/msgHdrView.js | 4501 |
1 files changed, 4501 insertions, 0 deletions
diff --git a/comm/mail/base/content/msgHdrView.js b/comm/mail/base/content/msgHdrView.js new file mode 100644 index 0000000000..1579cddbfe --- /dev/null +++ b/comm/mail/base/content/msgHdrView.js @@ -0,0 +1,4501 @@ +/* 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/. */ + +/** + * Functions related to displaying the headers for a selected message in the + * message pane. + */ + +/* import-globals-from ../../../../toolkit/content/contentAreaUtils.js */ +/* import-globals-from ../../../calendar/base/content/imip-bar.js */ +/* import-globals-from ../../../mailnews/extensions/newsblog/newsblogOverlay.js */ +/* import-globals-from ../../extensions/smime/content/msgHdrViewSMIMEOverlay.js */ +/* import-globals-from aboutMessage.js */ +/* import-globals-from editContactPanel.js */ +/* import-globals-from globalOverlay.js */ +/* import-globals-from mailContext.js */ +/* import-globals-from mail-offline.js */ +/* import-globals-from mailCore.js */ +/* import-globals-from msgSecurityPane.js */ + +/* globals MozElements */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +ChromeUtils.defineESModuleGetters(this, { + AttachmentInfo: "resource:///modules/AttachmentInfo.sys.mjs", + PluralForm: "resource://gre/modules/PluralForm.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + calendarDeactivator: + "resource:///modules/calendar/calCalendarDeactivator.jsm", + Gloda: "resource:///modules/gloda/GlodaPublic.jsm", + GlodaUtils: "resource:///modules/gloda/GlodaUtils.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + MessageArchiver: "resource:///modules/MessageArchiver.jsm", + PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gDbService", + "@mozilla.org/msgDatabase/msgDBService;1", + "nsIMsgDBService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "gHandlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "gEncryptedSMIMEURIsService", + "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1", + Ci.nsIEncryptedSMIMEURIsService +); + +// Warning: It's critical that the code in here for displaying the message +// headers for a selected message remain as fast as possible. In particular, +// right now, we only introduce one reflow per message. i.e. if you click on +// a message in the thread pane, we batch up all the changes for displaying +// the header pane (to, cc, attachments button, etc.) and we make a single +// pass to display them. It's critical that we maintain this one reflow per +// message view in the message header pane. + +var gViewAllHeaders = false; +var gMinNumberOfHeaders = 0; +var gDummyHeaderIdIndex = 0; +var gBuildAttachmentsForCurrentMsg = false; +var gBuiltExpandedView = false; +var gHeadersShowReferences = false; + +/** + * Show the friendly display names for people I know, + * instead of the name + email address. + */ +var gShowCondensedEmailAddresses; + +/** + * Other components may listen to on start header & on end header notifications + * for each message we display: to do that you need to add yourself to our + * gMessageListeners array with an object that supports the three properties: + * onStartHeaders, onEndHeaders and onEndAttachments. + * + * Additionally, if your object has an onBeforeShowHeaderPane() method, it will + * be called at the appropriate time. This is designed to give add-ons a + * chance to examine and modify the currentHeaderData array before it gets + * displayed. + */ +var gMessageListeners = []; + +/** + * List fo common headers that need to be populated. + * + * For every possible "view" in the message pane, you need to define the header + * names you want to see in that view. In addition, include information + * describing how you want that header field to be presented. We'll then use + * this static table to dynamically generate header view entries which + * manipulate the UI. + * + * @param {string} name - The name of the header. i.e. "to", "subject". This + * must be in lower case and the name of the header is used to help + * dynamically generate ids for objects in the document. + * @param {Function} outputFunction - This is a method which takes a headerEntry + * (see the definition below) and a header value. This allows to provide a + * unique methods for determining how the header value is displayed. Defaults + * to updateHeaderValue which just sets the header value on the text node. + */ +const gExpandedHeaderList = [ + { name: "subject" }, + { name: "from", outputFunction: outputEmailAddresses }, + { name: "reply-to", outputFunction: outputEmailAddresses }, + { name: "to", outputFunction: outputEmailAddresses }, + { name: "cc", outputFunction: outputEmailAddresses }, + { name: "bcc", outputFunction: outputEmailAddresses }, + { name: "newsgroups", outputFunction: outputNewsgroups }, + { name: "references", outputFunction: outputMessageIds }, + { name: "followup-to", outputFunction: outputNewsgroups }, + { name: "content-base" }, + { name: "tags", outputFunction: outputTags }, +]; + +/** + * These are all the items that use a multi-recipient-row widget and + * therefore may require updating if the address book changes. + */ +var gEmailAddressHeaderNames = [ + "from", + "reply-to", + "to", + "cc", + "bcc", + "toCcBcc", +]; + +/** + * Now, for each view the message pane can generate, we need a global table of + * headerEntries. These header entry objects are generated dynamically based on + * the static data in the header lists (see above) and elements we find in the + * DOM based on properties in the header lists. + */ +var gExpandedHeaderView = {}; + +/** + * This is an array of header name and value pairs for the currently displayed + * message. It's purely a data object and has no view information. View + * information is contained in the view objects. + * For a given entry in this array you can ask for: + * .headerName name of the header (i.e. 'to'). Always stored in lower case + * .headerValue value of the header "johndoe@example.com" + */ +var currentHeaderData = {}; + +/** + * CurrentAttachments is an array of AttachmentInfo objects. + */ +var currentAttachments = []; + +/** + * The character set of the message, according to the MIME parser. + */ +var currentCharacterSet = ""; + +/** + * Folder database listener object. This is used alongside the + * nsIDBChangeListener implementation in order to listen for the changes of the + * messages' flags that don't trigger a messageHeaderSink.processHeaders(). + * For now, it's used only for the flagged/marked/starred flag, but it could be + * extended to handle other flags changes and remove the full header reload. + */ +var gFolderDBListener = null; + +// Timer to mark read, if the user has configured the app to mark a message as +// read if it is viewed for more than n seconds. +var gMarkViewedMessageAsReadTimer = null; + +// Per message header flags to keep track of whether the user is allowing remote +// content for a particular message. +// if you change or add more values to these constants, be sure to modify +// the corresponding definitions in nsMsgContentPolicy.cpp +var kNoRemoteContentPolicy = 0; +var kBlockRemoteContent = 1; +var kAllowRemoteContent = 2; + +class FolderDBListener { + constructor(folder) { + // Keep a record of the currently selected folder to check when the + // selection changes to avoid initializing the DBListener in case the same + // folder is selected. + this.selectedFolder = folder; + this.isRegistered = false; + } + + register() { + gDbService.registerPendingListener(this.selectedFolder, this); + this.isRegistered = true; + } + + unregister() { + gDbService.unregisterPendingListener(this); + this.isRegistered = false; + } + + /** @implements {nsIDBChangeListener} */ + onHdrFlagsChanged(hdrChanged, oldFlags, newFlags, instigator) { + // Bail out if the changed message isn't the one currently displayed. + if (hdrChanged != gMessage) { + return; + } + + // Check if the flagged/marked/starred state was changed. + if ( + newFlags & Ci.nsMsgMessageFlags.Marked || + oldFlags & Ci.nsMsgMessageFlags.Marked + ) { + updateStarButton(); + } + } + onHdrDeleted(hdrChanged, parentKey, flags, instigator) {} + onHdrAdded(hdrChanged, parentKey, flags, instigator) {} + onParentChanged(keyChanged, oldParent, newParent, instigator) {} + onAnnouncerGoingAway(instigator) {} + onReadChanged(instigator) {} + onJunkScoreChanged(instigator) {} + onHdrPropertyChanged(hdrToChange, property, preChange, status, instigator) { + // Not interested before a change, or if the message isn't the one displayed, + // or an .eml file from disk or an attachment. + if (preChange || gMessage != hdrToChange) { + return; + } + switch (property) { + case "keywords": + OnTagsChange(); + break; + case "junkscore": + HandleJunkStatusChanged(hdrToChange); + break; + } + } + onEvent(db, event) {} +} + +/** + * Initialize the nsIDBChangeListener when a new folder is selected in order to + * listen for any flags change happening in the currently displayed messages. + */ +function initFolderDBListener() { + // Bail out if we don't have a selected message, or we already have a + // DBListener initialized and the folder didn't change. + if ( + !gFolder || + (gFolderDBListener?.isRegistered && + gFolderDBListener.selectedFolder == gFolder) + ) { + return; + } + + // Clearly we are viewing a different message in a different folder, so clear + // any remaining of the old DBListener. + clearFolderDBListener(); + + gFolderDBListener = new FolderDBListener(gFolder); + gFolderDBListener.register(); +} + +/** + * Unregister the listener and clear the object if we already have one, meaning + * the user just changed folder or deselected all messages. + */ +function clearFolderDBListener() { + if (gFolderDBListener?.isRegistered) { + gFolderDBListener.unregister(); + gFolderDBListener = null; + } +} + +/** + * Our class constructor method which creates a header Entry based on an entry + * in one of the header lists. A header entry is different from a header list. + * A header list just describes how you want a particular header to be + * presented. The header entry actually has knowledge about the DOM + * and the actual DOM elements associated with the header. + * + * @param prefix the name of the view (e.g. "expanded") + * @param headerListInfo entry from a header list. + */ +class MsgHeaderEntry { + constructor(prefix, headerListInfo) { + this.enclosingBox = document.getElementById( + `${prefix}${headerListInfo.name}Box` + ); + this.enclosingRow = this.enclosingBox.closest(".message-header-row"); + this.isNewHeader = false; + this.valid = false; + this.outputFunction = headerListInfo.outputFunction || updateHeaderValue; + } +} + +function initializeHeaderViewTables() { + // Iterate over each header in our header list arrays and create header entries + // for each one. These header entries are then stored in the appropriate header + // table. + for (let header of gExpandedHeaderList) { + gExpandedHeaderView[header.name] = new MsgHeaderEntry("expanded", header); + } + + let extraHeaders = Services.prefs + .getCharPref("mailnews.headers.extraExpandedHeaders") + .split(" "); + for (let extraHeaderName of extraHeaders) { + if (!extraHeaderName.trim()) { + continue; + } + gExpandedHeaderView[extraHeaderName.toLowerCase()] = new HeaderView( + extraHeaderName, + extraHeaderName + ); + } + + let otherHeaders = Services.prefs + .getCharPref("mail.compose.other.header", "") + .split(",") + .map(h => h.trim()) + .filter(Boolean); + + for (let otherHeaderName of otherHeaders) { + gExpandedHeaderView[otherHeaderName.toLowerCase()] = new HeaderView( + otherHeaderName, + otherHeaderName + ); + } + + if (Services.prefs.getBoolPref("mailnews.headers.showOrganization")) { + var organizationEntry = { + name: "organization", + outputFunction: updateHeaderValue, + }; + gExpandedHeaderView[organizationEntry.name] = new MsgHeaderEntry( + "expanded", + organizationEntry + ); + } + + if (Services.prefs.getBoolPref("mailnews.headers.showUserAgent")) { + var userAgentEntry = { + name: "user-agent", + outputFunction: updateHeaderValue, + }; + gExpandedHeaderView[userAgentEntry.name] = new MsgHeaderEntry( + "expanded", + userAgentEntry + ); + } + + if (Services.prefs.getBoolPref("mailnews.headers.showMessageId")) { + var messageIdEntry = { + name: "message-id", + outputFunction: outputMessageIds, + }; + gExpandedHeaderView[messageIdEntry.name] = new MsgHeaderEntry( + "expanded", + messageIdEntry + ); + } + + if (Services.prefs.getBoolPref("mailnews.headers.showSender")) { + let senderEntry = { + name: "sender", + outputFunction: outputEmailAddresses, + }; + gExpandedHeaderView[senderEntry.name] = new MsgHeaderEntry( + "expanded", + senderEntry + ); + } +} + +async function OnLoadMsgHeaderPane() { + // Load any preferences that at are global with regards to + // displaying a message... + gMinNumberOfHeaders = Services.prefs.getIntPref( + "mailnews.headers.minNumHeaders" + ); + gShowCondensedEmailAddresses = Services.prefs.getBoolPref( + "mail.showCondensedAddresses" + ); + gHeadersShowReferences = Services.prefs.getBoolPref( + "mailnews.headers.showReferences" + ); + + Services.obs.addObserver(MsgHdrViewObserver, "remote-content-blocked"); + Services.prefs.addObserver("mail.showCondensedAddresses", MsgHdrViewObserver); + Services.prefs.addObserver( + "mailnews.headers.showReferences", + MsgHdrViewObserver + ); + + initializeHeaderViewTables(); + + // Add the keyboard shortcut event listener for the message header. + // Ctrl+Alt+S / Cmd+Control+S. We don't use the Alt/Option key on macOS + // because it alters the pressed key to an ASCII character. See bug 1692263. + let shortcut = await document.l10n.formatValue( + "message-header-show-security-info-key" + ); + document.addEventListener("keypress", event => { + if ( + event.ctrlKey && + (event.altKey || event.metaKey) && + event.key.toLowerCase() == shortcut.toLowerCase() + ) { + showMessageReadSecurityInfo(); + } + }); + + headerToolbarNavigation.init(); + + // Set up event listeners for the encryption technology button and panel. + document + .getElementById("encryptionTechBtn") + .addEventListener("click", showMessageReadSecurityInfo); + let panel = document.getElementById("messageSecurityPanel"); + panel.addEventListener("popuphidden", onMessageSecurityPopupHidden); + + // Set the flag/star button on click listener. + document + .getElementById("starMessageButton") + .addEventListener("click", MsgMarkAsFlagged); + + // Dispatch an event letting any listeners know that we have loaded + // the message pane. + let headerViewElement = document.getElementById("msgHeaderView"); + headerViewElement.loaded = true; + headerViewElement.dispatchEvent( + new Event("messagepane-loaded", { bubbles: false, cancelable: true }) + ); + + getMessagePaneBrowser().addProgressListener( + messageProgressListener, + Ci.nsIWebProgress.NOTIFY_STATE_ALL + ); + + gHeaderCustomize.init(); +} + +function OnUnloadMsgHeaderPane() { + let headerViewElement = document.getElementById("msgHeaderView"); + if (!headerViewElement.loaded) { + // We're unloading, but we never loaded. + return; + } + + Services.obs.removeObserver(MsgHdrViewObserver, "remote-content-blocked"); + Services.prefs.removeObserver( + "mail.showCondensedAddresses", + MsgHdrViewObserver + ); + Services.prefs.removeObserver( + "mailnews.headers.showReferences", + MsgHdrViewObserver + ); + + clearFolderDBListener(); + + // Dispatch an event letting any listeners know that we have unloaded + // the message pane. + headerViewElement.dispatchEvent( + new Event("messagepane-unloaded", { bubbles: false, cancelable: true }) + ); +} + +var MsgHdrViewObserver = { + observe(subject, topic, data) { + // verify that we're changing the mail pane config pref + if (topic == "nsPref:changed") { + // We don't need to call ReloadMessage() in either of these conditions + // because a preference observer for these preferences already does it. + if (data == "mail.showCondensedAddresses") { + gShowCondensedEmailAddresses = Services.prefs.getBoolPref( + "mail.showCondensedAddresses" + ); + } else if (data == "mailnews.headers.showReferences") { + gHeadersShowReferences = Services.prefs.getBoolPref( + "mailnews.headers.showReferences" + ); + } + } else if (topic == "remote-content-blocked") { + let browser = getMessagePaneBrowser(); + if ( + browser.browsingContext.id == data || + browser.browsingContext == BrowsingContext.get(data)?.top + ) { + gMessageNotificationBar.setRemoteContentMsg( + null, + subject, + !gEncryptedSMIMEURIsService.isEncrypted(browser.currentURI.spec) + ); + } + } + }, +}; + +/** + * Receives a message's headers as we display the message through our mime converter. + * + * @see {nsIMailChannel} + * @implements {nsIMailProgressListener} + * @implements {nsIWebProgressListener} + * @implements {nsISupportsWeakReference} + */ +var messageProgressListener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIMailProgressListener", + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + /** + * Step 1: A message has started loading (if the flags include STATE_START). + * + * @param {nsIWebProgress} webProgress + * @param {nsIRequest} request + * @param {integer} stateFlags + * @param {nsresult} status + * @see {nsIWebProgressListener} + */ + onStateChange(webProgress, request, stateFlags, status) { + if ( + !(request instanceof Ci.nsIMailChannel) || + !(stateFlags & Ci.nsIWebProgressListener.STATE_START) + ) { + return; + } + + // Clear the previously displayed message. + const previousDocElement = + getMessagePaneBrowser().contentDocument?.documentElement; + if (previousDocElement) { + previousDocElement.style.display = "none"; + } + ClearAttachmentList(); + gMessageNotificationBar.clearMsgNotifications(); + + request.listener = this; + request.smimeHeaderSink = smimeHeaderSink; + this.onStartHeaders(); + }, + + /** + * Step 2: The message headers are available on the channel. + * + * @param {nsIMailChannel} mailChannel + * @see {nsIMailProgressListener} + */ + onHeadersComplete(mailChannel) { + const domWindow = getMessagePaneBrowser().docShell.DOMWindow; + domWindow.addEventListener( + "DOMContentLoaded", + event => this.onDOMContentLoaded(event), + { once: true } + ); + this.processHeaders(mailChannel.headerNames, mailChannel.headerValues); + }, + + /** + * Step 3: The parser has finished reading the body of the message. + * + * @param {nsIMailChannel} mailChannel + * @see {nsIMailProgressListener} + */ + onBodyComplete(mailChannel) { + autoMarkAsRead(); + }, + + /** + * Step 4: The attachment information is available on the channel. + * + * @param {nsIMailChannel} mailChannel + * @see {nsIMailProgressListener} + */ + onAttachmentsComplete(mailChannel) { + for (const attachment of mailChannel.attachments) { + this.handleAttachment( + attachment.getProperty("contentType"), + attachment.getProperty("url"), + attachment.getProperty("displayName"), + attachment.getProperty("uri"), + attachment.getProperty("notDownloaded") + ); + for (const key of [ + "X-Mozilla-PartURL", + "X-Mozilla-PartSize", + "X-Mozilla-PartDownloaded", + "Content-Description", + "Content-Type", + "Content-Encoding", + ]) { + if (attachment.hasKey(key)) { + this.addAttachmentField(key, attachment.getProperty(key)); + } + } + } + }, + + /** + * Step 5: The message HTML is complete, but external resources such as may + * not have loaded yet. The docShell will handle them – for our purposes, + * message loading has finished. + */ + onDOMContentLoaded(event) { + const { docShell } = event.target.ownerGlobal; + if (!docShell.isTopLevelContentDocShell) { + return; + } + + const channel = docShell.currentDocumentChannel; + channel.QueryInterface(Ci.nsIMailChannel); + currentCharacterSet = channel.mailCharacterSet; + channel.smimeHeaderSink = null; + if (channel.imipItem) { + calImipBar.showImipBar(channel.imipItem, channel.imipMethod); + } + this.onEndAllAttachments(); + const uri = channel.URI.QueryInterface(Ci.nsIMsgMailNewsUrl); + this.onEndMsgHeaders(uri); + this.onEndMsgDownload(uri); + }, + + onStartHeaders() { + // Every time we start to redisplay a message, check the view all headers + // pref... + let showAllHeadersPref = Services.prefs.getIntPref("mail.show_headers"); + if (showAllHeadersPref == 2) { + // eslint-disable-next-line no-global-assign + gViewAllHeaders = true; + } else { + if (gViewAllHeaders) { + // If we currently are in view all header mode, rebuild our header + // view so we remove most of the header data. + hideHeaderView(gExpandedHeaderView); + RemoveNewHeaderViews(gExpandedHeaderView); + gDummyHeaderIdIndex = 0; + // eslint-disable-next-line no-global-assign + gExpandedHeaderView = {}; + initializeHeaderViewTables(); + } + + // eslint-disable-next-line no-global-assign + gViewAllHeaders = false; + } + + document.title = ""; + ClearCurrentHeaders(); + gBuiltExpandedView = false; + gBuildAttachmentsForCurrentMsg = false; + ClearAttachmentList(); + gMessageNotificationBar.clearMsgNotifications(); + + // Reset the blocked hosts so we can populate it again for this message. + document.getElementById("remoteContentOptions").value = ""; + + for (let listener of gMessageListeners) { + listener.onStartHeaders(); + } + }, + + onEndHeaders() { + if (!gViewWrapper || !gMessage) { + // The view wrapper and/or message went away before we finished loading + // the message. Bail out. + return; + } + + // Give add-ons a chance to modify currentHeaderData before it actually + // gets displayed. + for (let listener of gMessageListeners) { + if ("onBeforeShowHeaderPane" in listener) { + listener.onBeforeShowHeaderPane(); + } + } + + // Load feed web page if so configured. This entry point works for + // messagepane loads in 3pane folder tab, 3pane message tab, and the + // standalone message window. + if (!FeedMessageHandler.shouldShowSummary(gMessage, false)) { + FeedMessageHandler.setContent(gMessage, false); + } + + ShowMessageHeaderPane(); + // WARNING: This is the ONLY routine inside of the message Header Sink + // that should trigger a reflow! + ClearHeaderView(gExpandedHeaderView); + + // Make sure there is a subject even if it's empty so we'll show the + // subject and the twisty. + EnsureSubjectValue(); + + // Make sure there is a from value even if empty so the header toolbar + // will show up. + EnsureFromValue(); + + // Only update the expanded view if it's actually selected and needs updating. + if (!gBuiltExpandedView) { + UpdateExpandedMessageHeaders(); + } + + gMessageNotificationBar.setDraftEditMessage(); + updateHeaderToolbarButtons(); + + for (let listener of gMessageListeners) { + listener.onEndHeaders(); + } + }, + + processHeaders(headerNames, headerValues) { + const kMailboxSeparator = ", "; + var index = 0; + for (let i = 0; i < headerNames.length; i++) { + let header = { + headerName: headerNames[i], + headerValue: headerValues[i], + }; + + // For consistency's sake, let us force all header names to be lower + // case so we don't have to worry about looking for: Cc and CC, etc. + var lowerCaseHeaderName = header.headerName.toLowerCase(); + + // If we have an x-mailer, x-mimeole, or x-newsreader string, + // put it in the user-agent slot which we know how to handle already. + if (/^x-(mailer|mimeole|newsreader)$/.test(lowerCaseHeaderName)) { + lowerCaseHeaderName = "user-agent"; + } + + // According to RFC 2822, certain headers can occur "unlimited" times. + if (lowerCaseHeaderName in currentHeaderData) { + // Sometimes, you can have multiple To or Cc lines.... + // In this case, we want to append these headers into one. + if (lowerCaseHeaderName == "to" || lowerCaseHeaderName == "cc") { + currentHeaderData[lowerCaseHeaderName].headerValue = + currentHeaderData[lowerCaseHeaderName].headerValue + + "," + + header.headerValue; + } else { + // Use the index to create a unique header name like: + // received5, received6, etc + currentHeaderData[lowerCaseHeaderName + index++] = header; + } + } else { + currentHeaderData[lowerCaseHeaderName] = header; + } + + // See RFC 5322 section 3.6 for min-max number for given header. + // If multiple headers exist we need to make sure to use the first one. + if (lowerCaseHeaderName == "subject" && !document.title) { + let fullSubject = ""; + // Use the subject from the database, which may have been put there in + // decrypted form. + if (gMessage?.subject) { + if (gMessage.flags & Ci.nsMsgMessageFlags.HasRe) { + fullSubject = "Re: "; + } + fullSubject += gMessage.mime2DecodedSubject; + } + document.title = fullSubject || header.headerValue; + currentHeaderData.subject.headerValue = document.title; + } + } // while we have more headers to parse + + // Process message tags as if they were headers in the message. + gMessageHeader.setTags(); + updateStarButton(); + + if ("from" in currentHeaderData && "sender" in currentHeaderData) { + let senderMailbox = + kMailboxSeparator + + MailServices.headerParser.extractHeaderAddressMailboxes( + currentHeaderData.sender.headerValue + ) + + kMailboxSeparator; + let fromMailboxes = + kMailboxSeparator + + MailServices.headerParser.extractHeaderAddressMailboxes( + currentHeaderData.from.headerValue + ) + + kMailboxSeparator; + if (fromMailboxes.includes(senderMailbox)) { + delete currentHeaderData.sender; + } + } + + // We don't need to show the reply-to header if its value is either + // the From field (totally pointless) or the To field (common for + // mailing lists, but not that useful). + if ( + "from" in currentHeaderData && + "to" in currentHeaderData && + "reply-to" in currentHeaderData + ) { + let replyToMailbox = + MailServices.headerParser.extractHeaderAddressMailboxes( + currentHeaderData["reply-to"].headerValue + ); + let fromMailboxes = + MailServices.headerParser.extractHeaderAddressMailboxes( + currentHeaderData.from.headerValue + ); + let toMailboxes = MailServices.headerParser.extractHeaderAddressMailboxes( + currentHeaderData.to.headerValue + ); + + if (replyToMailbox == fromMailboxes || replyToMailbox == toMailboxes) { + delete currentHeaderData["reply-to"]; + } + } + + // For content-base urls stored uri encoded, we want to decode for + // display (and encode for external link open). + if ("content-base" in currentHeaderData) { + currentHeaderData["content-base"].headerValue = decodeURI( + currentHeaderData["content-base"].headerValue + ); + } + + let expandedfromLabel = document.getElementById("expandedfromLabel"); + if (FeedUtils.isFeedMessage(gMessage)) { + expandedfromLabel.value = expandedfromLabel.getAttribute("valueAuthor"); + } else { + expandedfromLabel.value = expandedfromLabel.getAttribute("valueFrom"); + } + + this.onEndHeaders(); + }, + + handleAttachment(contentType, url, displayName, uri, isExternalAttachment) { + let newAttachment = new AttachmentInfo({ + contentType, + url, + name: displayName, + uri, + isExternalAttachment, + message: gMessage, + updateAttachmentsDisplayFn: updateAttachmentsDisplay, + }); + currentAttachments.push(newAttachment); + + if (contentType == "application/pgp-keys" || displayName.endsWith(".asc")) { + Enigmail.msg.autoProcessPgpKeyAttachment(newAttachment); + } + }, + + addAttachmentField(field, value) { + let last = currentAttachments[currentAttachments.length - 1]; + if ( + field == "X-Mozilla-PartSize" && + !last.isFileAttachment && + !last.isDeleted + ) { + let size = parseInt(value); + + if (last.isLinkAttachment) { + // Check if an external link attachment's reported size is sane. + // A size of < 2 isn't sensical so ignore such placeholder values. + // Don't accept a size with any non numerics. Also cap the number. + // We want the size to be checked again, upon user action, to make + // sure size is updated with an accurate value, so |sizeResolved| + // remains false. + if (isNaN(size) || size.toString().length != value.length || size < 2) { + last.size = -1; + } else if (size > Number.MAX_SAFE_INTEGER) { + last.size = Number.MAX_SAFE_INTEGER; + } else { + last.size = size; + } + } else { + // For internal or file (detached) attachments, save the size. + last.size = size; + // For external file attachments, we won't have a valid size. + if (!last.isFileAttachment && size > -1) { + last.sizeResolved = true; + } + } + } else if (field == "X-Mozilla-PartDownloaded" && value == "0") { + // We haven't downloaded the attachment, so any size we get from + // libmime is almost certainly inaccurate. Just get rid of it. (Note: + // this relies on the fact that PartDownloaded comes after PartSize from + // the MIME emitter.) + // Note: for imap parts_on_demand, a small size consisting of the part + // headers would have been returned above. + last.size = -1; + last.sizeResolved = false; + } + }, + + onEndAllAttachments() { + Enigmail.msg.notifyEndAllAttachments(); + + displayAttachmentsForExpandedView(); + + for (let listener of gMessageListeners) { + if ("onEndAttachments" in listener) { + listener.onEndAttachments(); + } + } + }, + + /** + * This event is generated by nsMsgStatusFeedback when it gets an + * OnStateChange event for STATE_STOP. This is the same event that + * generates the "msgLoaded" property flag change event. This best + * corresponds to the end of the streaming process. + */ + onEndMsgDownload(url) { + let browser = getMessagePaneBrowser(); + + // If we have no attachments, we hide the attachment icon in the message + // tree. + // PGP key attachments do not count as attachments for the purposes of the + // message tree, even though we still show them in the attachment list. + // Otherwise the attachment icon becomes less useful when someone receives + // lots of signed messages. + // We do the same if we only have text/vcard attachments because we + // *assume* the vcard attachment is a personal vcard (rather than an + // addressbook, or a shared contact) that is attached to every message. + // NOTE: There would be some obvious give-aways in the vcard content that + // this personal vcard assumption is incorrect (multiple contacts, or a + // contact with an address that is different from the sender address) but we + // do not have easy access to the attachment content here, so we just stick + // to the assumption. + // NOTE: If the message contains two vcard attachments (or more) then this + // would hint that one of the vcards is not personal, but we won't make an + // exception here to keep the implementation simple. + gMessage?.markHasAttachments( + currentAttachments.some( + att => + att.contentType != "text/vcard" && + att.contentType != "text/x-vcard" && + att.contentType != "application/pgp-keys" + ) + ); + + if ( + currentAttachments.length && + Services.prefs.getBoolPref("mail.inline_attachments") && + FeedUtils.isFeedMessage(gMessage) && + browser && + browser.contentDocument && + browser.contentDocument.body + ) { + for (let img of browser.contentDocument.body.getElementsByClassName( + "moz-attached-image" + )) { + for (let attachment of currentAttachments) { + let partID = img.src.split("&part=")[1]; + partID = partID ? partID.split("&")[0] : null; + if (attachment.partID && partID == attachment.partID) { + img.src = attachment.url; + break; + } + } + + img.addEventListener("load", function (event) { + if (this.clientWidth > this.parentNode.clientWidth) { + img.setAttribute("overflowing", "true"); + img.setAttribute("shrinktofit", "true"); + } + }); + } + } + + OnMsgParsed(url); + }, + + onEndMsgHeaders(url) { + if (!url.errorCode) { + // Should not mark a message as read if failed to load. + OnMsgLoaded(url); + } + }, +}; + +/** + * Update the flagged (starred) state of the currently selected message. + */ +function updateStarButton() { + if (!gMessage || !gFolder) { + // No msgHdr to update, or we're dealing with an .eml. + document.getElementById("starMessageButton").hidden = true; + return; + } + + let flagButton = document.getElementById("starMessageButton"); + flagButton.hidden = false; + + let isFlagged = gMessage.isFlagged; + flagButton.classList.toggle("flagged", isFlagged); + flagButton.setAttribute("aria-checked", isFlagged); +} + +function EnsureSubjectValue() { + if (!("subject" in currentHeaderData)) { + let foo = {}; + foo.headerValue = ""; + foo.headerName = "subject"; + currentHeaderData[foo.headerName] = foo; + } +} + +function EnsureFromValue() { + if (!("from" in currentHeaderData)) { + let foo = {}; + foo.headerValue = ""; + foo.headerName = "from"; + currentHeaderData[foo.headerName] = foo; + } +} + +function OnTagsChange() { + // rebuild the tag headers + gMessageHeader.setTags(); + + // Now update the expanded header view to rebuild the tags, + // and then show or hide the tag header box. + if (gBuiltExpandedView) { + let headerEntry = gExpandedHeaderView.tags; + if (headerEntry) { + headerEntry.valid = "tags" in currentHeaderData; + if (headerEntry.valid) { + headerEntry.outputFunction( + headerEntry, + currentHeaderData.tags.headerValue + ); + } + + // we may need to collapse or show the tag header row... + headerEntry.enclosingRow.hidden = !headerEntry.valid; + // ... and ensure that all headers remain correctly aligned + gMessageHeader.syncLabelsColumnWidths(); + } + } +} + +/** + * Flush out any local state being held by a header entry for a given table. + * + * @param aHeaderTable Table of header entries + */ +function ClearHeaderView(aHeaderTable) { + for (let name in aHeaderTable) { + let headerEntry = aHeaderTable[name]; + headerEntry.enclosingBox.clearHeaderValues?.(); + headerEntry.enclosingBox.clear?.(); + + headerEntry.valid = false; + } +} + +/** + * Make sure that any valid header entry in the table is collapsed. + * + * @param aHeaderTable Table of header entries + */ +function hideHeaderView(aHeaderTable) { + for (let name in aHeaderTable) { + let headerEntry = aHeaderTable[name]; + headerEntry.enclosingRow.hidden = true; + } +} + +/** + * Make sure that any valid header entry in the table specified is visible. + * + * @param aHeaderTable Table of header entries + */ +function showHeaderView(aHeaderTable) { + for (let name in aHeaderTable) { + let headerEntry = aHeaderTable[name]; + headerEntry.enclosingRow.hidden = !headerEntry.valid; + + // If we're hiding the To field, we need to hide the date inline and show + // the duplicate on the subject line. + if (headerEntry.enclosingRow.id == "expandedtoRow") { + let dateLabel = document.getElementById("dateLabel"); + let dateLabelSubject = document.getElementById("dateLabelSubject"); + if (!headerEntry.valid) { + dateLabelSubject.setAttribute( + "datetime", + dateLabel.getAttribute("datetime") + ); + dateLabelSubject.textContent = dateLabel.textContent; + dateLabelSubject.hidden = false; + } else { + dateLabelSubject.removeAttribute("datetime"); + dateLabelSubject.textContent = ""; + dateLabelSubject.hidden = true; + } + } + } +} + +/** + * Enumerate through the list of headers and find the number that are visible + * add empty entries if we don't have the minimum number of rows. + */ +function EnsureMinimumNumberOfHeaders(headerTable) { + // 0 means we don't have a minimum... do nothing special + if (!gMinNumberOfHeaders) { + return; + } + + var numVisibleHeaders = 0; + for (let name in headerTable) { + let headerEntry = headerTable[name]; + if (headerEntry.valid) { + numVisibleHeaders++; + } + } + + if (numVisibleHeaders < gMinNumberOfHeaders) { + // How many empty headers do we need to add? + var numEmptyHeaders = gMinNumberOfHeaders - numVisibleHeaders; + + // We may have already dynamically created our empty rows and we just need + // to make them visible. + for (let index in headerTable) { + let headerEntry = headerTable[index]; + if (index.startsWith("Dummy-Header") && numEmptyHeaders) { + headerEntry.valid = true; + numEmptyHeaders--; + } + } + + // Ok, now if we have any extra dummy headers we need to add, create a new + // header widget for them. + while (numEmptyHeaders) { + var dummyHeaderId = "Dummy-Header" + gDummyHeaderIdIndex; + gExpandedHeaderView[dummyHeaderId] = new HeaderView(dummyHeaderId, ""); + gExpandedHeaderView[dummyHeaderId].valid = true; + + gDummyHeaderIdIndex++; + numEmptyHeaders--; + } + } +} + +/** + * Make sure the appropriate fields in the expanded header view are collapsed + * or visible... + */ +function updateExpandedView() { + if (gMinNumberOfHeaders) { + EnsureMinimumNumberOfHeaders(gExpandedHeaderView); + } + showHeaderView(gExpandedHeaderView); + + // Now that we have all the headers, ensure that the name columns of both + // grids are the same size so that they don't look weird. + gMessageHeader.syncLabelsColumnWidths(); + + UpdateReplyButtons(); + updateHeaderToolbarButtons(); + updateComposeButtons(); + displayAttachmentsForExpandedView(); + + try { + AdjustHeaderView(Services.prefs.getIntPref("mail.show_headers")); + } catch (e) { + console.error(e); + } +} + +/** + * Default method for updating a header value into a header entry + * + * @param aHeaderEntry A single header from currentHeaderData + * @param aHeaderValue The new value for headerEntry + */ +function updateHeaderValue(aHeaderEntry, aHeaderValue) { + aHeaderEntry.enclosingBox.headerValue = aHeaderValue; +} + +/** + * Create the DOM nodes (aka "View") for a non-standard header and insert them + * into the grid. Create and return the corresponding headerEntry object. + * + * @param {string} headerName - name of the header we're adding, used to + * construct the element IDs (in lower case) + * @param {string} label - name of the header as displayed in the UI + */ +class HeaderView { + constructor(headerName, label) { + headerName = headerName.toLowerCase(); + let rowId = "expanded" + headerName + "Row"; + let idName = "expanded" + headerName + "Box"; + let newHeaderNode; + // If a row for this header already exists, do not create another one. + let newRowNode = document.getElementById(rowId); + if (!newRowNode) { + // Create new collapsed row. + newRowNode = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "div" + ); + newRowNode.setAttribute("id", rowId); + newRowNode.classList.add("message-header-row"); + newRowNode.hidden = true; + + // Create and append the label which contains the header name. + let newLabelNode = document.createXULElement("label"); + newLabelNode.setAttribute("id", "expanded" + headerName + "Label"); + newLabelNode.setAttribute("value", label); + newLabelNode.setAttribute("class", "message-header-label"); + + newRowNode.appendChild(newLabelNode); + + // Create and append the new header value. + newHeaderNode = document.createElement("div", { + is: "simple-header-row", + }); + newHeaderNode.setAttribute("id", idName); + newHeaderNode.dataset.prettyHeaderName = label; + newHeaderNode.dataset.headerName = headerName; + newRowNode.appendChild(newHeaderNode); + + // Add the new row to the extra headers container. + document.getElementById("extraHeadersArea").appendChild(newRowNode); + this.isNewHeader = true; + } else { + newRowNode.hidden = true; + newHeaderNode = document.getElementById(idName); + this.isNewHeader = false; + } + + this.enclosingBox = newHeaderNode; + this.enclosingRow = newRowNode; + this.valid = false; + this.outputFunction = updateHeaderValue; + } +} + +/** + * Removes all non-predefined header nodes from the view. + * + * @param aHeaderTable Table of header entries. + */ +function RemoveNewHeaderViews(aHeaderTable) { + for (let name in aHeaderTable) { + let headerEntry = aHeaderTable[name]; + if (headerEntry.isNewHeader) { + headerEntry.enclosingRow.remove(); + } + } +} + +/** + * UpdateExpandedMessageHeaders: Iterate through all the current header data + * we received from mime for this message for the expanded header entry table, + * and see if we have a corresponding entry for that header (i.e. + * whether the expanded header view cares about this header value) + * If so, then call updateHeaderEntry + */ +function UpdateExpandedMessageHeaders() { + // Iterate over each header we received and see if we have a matching entry + // in each header view table... + var headerName; + + // Remove the height attr so that it redraws correctly. Works around a problem + // that attachment-splitter causes if it's moved high enough to affect + // the header box: + document.getElementById("msgHeaderView").removeAttribute("height"); + // This height attribute may be set by toggleWrap() if the user clicked + // the "more" button" in the header. + // Remove it so that the height is determined automatically. + + for (headerName in currentHeaderData) { + var headerField = currentHeaderData[headerName]; + var headerEntry = null; + + if (headerName in gExpandedHeaderView) { + headerEntry = gExpandedHeaderView[headerName]; + } + + if (!headerEntry && gViewAllHeaders) { + // For view all headers, if we don't have a header field for this + // value, cheat and create one then fill in a headerEntry. + if (headerName == "message-id" || headerName == "in-reply-to") { + var messageIdEntry = { + name: headerName, + outputFunction: outputMessageIds, + }; + gExpandedHeaderView[headerName] = new MsgHeaderEntry( + "expanded", + messageIdEntry + ); + } else if (headerName != "x-mozilla-localizeddate") { + // Don't bother showing X-Mozilla-LocalizedDate, since that value is + // displayed below the message header toolbar. + gExpandedHeaderView[headerName] = new HeaderView( + headerName, + currentHeaderData[headerName].headerName + ); + } + + headerEntry = gExpandedHeaderView[headerName]; + } + + if (headerEntry) { + if ( + headerName == "references" && + !( + gViewAllHeaders || + gHeadersShowReferences || + gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false) + ) + ) { + // Hide references header if view all headers mode isn't selected, the + // pref show references is deactivated and the currently displayed + // message isn't a newsgroup posting. + headerEntry.valid = false; + } else { + // Set the row element visible before populating the field with addresses. + headerEntry.enclosingRow.hidden = false; + headerEntry.outputFunction(headerEntry, headerField.headerValue); + headerEntry.valid = true; + } + } + } + + let otherHeaders = Services.prefs + .getCharPref("mail.compose.other.header", "") + .split(",") + .map(h => h.trim()) + .filter(Boolean); + + for (let otherHeaderName of otherHeaders) { + let toLowerCaseHeaderName = otherHeaderName.toLowerCase(); + let headerEntry = gExpandedHeaderView[toLowerCaseHeaderName]; + let headerData = currentHeaderData[toLowerCaseHeaderName]; + + if (headerEntry && headerData) { + headerEntry.outputFunction(headerEntry, headerData.headerValue); + headerEntry.valid = true; + } + } + + let dateLabel = document.getElementById("dateLabel"); + dateLabel.hidden = true; + if ( + "x-mozilla-localizeddate" in currentHeaderData && + currentHeaderData["x-mozilla-localizeddate"].headerValue + ) { + dateLabel.textContent = + currentHeaderData["x-mozilla-localizeddate"].headerValue; + let date = new Date(currentHeaderData.date.headerValue); + if (!isNaN(date)) { + dateLabel.setAttribute("datetime", date.toISOString()); + dateLabel.hidden = false; + } + } + + gBuiltExpandedView = true; + + // Now update the view to make sure the right elements are visible. + updateExpandedView(); +} + +function ClearCurrentHeaders() { + gSecureMsgProbe = {}; + // eslint-disable-next-line no-global-assign + currentHeaderData = {}; + // eslint-disable-next-line no-global-assign + currentAttachments = []; + currentCharacterSet = ""; +} + +function ShowMessageHeaderPane() { + document.getElementById("msgHeaderView").collapsed = false; + document.getElementById("mail-notification-top").collapsed = false; + + // Initialize the DBListener if we don't have one. This might happen when the + // message pane is hidden or no message was selected before, which caused the + // clearing of the the DBListener. + initFolderDBListener(); +} + +function HideMessageHeaderPane() { + let header = document.getElementById("msgHeaderView"); + header.collapsed = true; + document.getElementById("mail-notification-top").collapsed = true; + + // Disable the attachment box. + document.getElementById("attachmentView").collapsed = true; + document.getElementById("attachment-splitter").collapsed = true; + + gMessageNotificationBar.clearMsgNotifications(); + // Clear the DBListener since we don't have any visible UI to update. + clearFolderDBListener(); + + // Now let interested listeners know the pane has been hidden. + header.dispatchEvent(new Event("message-header-pane-hidden")); +} + +/** + * Take a string of newsgroups separated by commas, split it into newsgroups and + * add them to the corresponding header-newsgroups-row element. + * + * @param {MsgHeaderEntry} headerEntry - The data structure for this header. + * @param {string} headerValue - The string of newsgroups from the message. + */ +function outputNewsgroups(headerEntry, headerValue) { + headerValue + .split(",") + .forEach(newsgroup => headerEntry.enclosingBox.addNewsgroup(newsgroup)); + headerEntry.enclosingBox.buildView(); +} + +/** + * Take a string of tags separated by space, split them and add them to the + * corresponding header-tags-row element. + * + * @param {MsgHeaderEntry} headerEntry - The data structure for this header. + * @param {string} headerValue - The string of tags from the message. + */ +function outputTags(headerEntry, headerValue) { + headerEntry.enclosingBox.buildTags(headerValue.split(" ")); +} + +/** + * Take a string of message-ids separated by whitespace, split it and send them + * to the corresponding header-message-ids-row element. + * + * @param {MsgHeaderEntry} headerEntry - The data structure for this header. + * @param {string} headerValue - The string of message IDs from the message. + */ +function outputMessageIds(headerEntry, headerValue) { + headerEntry.enclosingBox.clear(); + + for (let id of headerValue.split(/\s+/)) { + headerEntry.enclosingBox.addId(id); + } + + headerEntry.enclosingBox.buildView(); +} + +/** + * Take a string of addresses separated by commas, split it into separated + * recipient objects and add them to the related parent container row. + * + * @param {MsgHeaderEntry} headerEntry - The data structure for this header. + * @param {string} emailAddresses - The string of addresses from the message. + */ +function outputEmailAddresses(headerEntry, emailAddresses) { + if (!emailAddresses) { + return; + } + + // The email addresses are still RFC2047 encoded but libmime has already + // converted from "raw UTF-8" to "wide" (UTF-16) characters. + let addresses = MailServices.headerParser.parseEncodedHeaderW(emailAddresses); + + // Make sure we start clean. + headerEntry.enclosingBox.clear(); + + // No addresses and a colon, so an empty group like "undisclosed-recipients: ;". + // Add group name so at least something displays. + if (!addresses.length && emailAddresses.includes(":")) { + let address = { displayName: emailAddresses }; + headerEntry.enclosingBox.addRecipient(address); + } + + for (let addr of addresses) { + // If we want to include short/long toggle views and we have a long view, + // always add it. If we aren't including a short/long view OR if we are and + // we haven't parsed enough addresses to reach the cutoff valve yet then add + // it to the default (short) div. + let address = {}; + address.emailAddress = addr.email; + address.fullAddress = addr.toString(); + address.displayName = addr.name; + headerEntry.enclosingBox.addRecipient(address); + } + + headerEntry.enclosingBox.buildView(); +} + +/** + * Return true if possible attachments in the currently loaded message can be + * deleted/detached. + */ +function CanDetachAttachments() { + var canDetach = + !gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false) && + (!gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.ImapBox, false) || + MailOfflineMgr.isOnline()) && + gFolder; // We can't detach from loaded eml files yet. + if (canDetach && "content-type" in currentHeaderData) { + canDetach = !ContentTypeIsSMIME( + currentHeaderData["content-type"].headerValue + ); + } + if (canDetach) { + canDetach = Enigmail.hdrView.enigCanDetachAttachments(); + } + + return canDetach; +} + +/** + * Return true if the content type is an S/MIME one. + */ +function ContentTypeIsSMIME(contentType) { + // S/MIME is application/pkcs7-mime and application/pkcs7-signature + // - also match application/x-pkcs7-mime and application/x-pkcs7-signature. + return /application\/(x-)?pkcs7-(mime|signature)/.test(contentType); +} + +function onShowAttachmentToolbarContextMenu() { + let expandBar = document.getElementById("context-expandAttachmentBar"); + let expanded = Services.prefs.getBoolPref( + "mailnews.attachments.display.start_expanded" + ); + expandBar.setAttribute("checked", expanded); +} + +/** + * Set up the attachment item context menu, showing or hiding the appropriate + * menu items. + */ +function onShowAttachmentItemContextMenu() { + let attachmentList = document.getElementById("attachmentList"); + let attachmentInfo = document.getElementById("attachmentInfo"); + let attachmentName = document.getElementById("attachmentName"); + let contextMenu = document.getElementById("attachmentItemContext"); + let openMenu = document.getElementById("context-openAttachment"); + let saveMenu = document.getElementById("context-saveAttachment"); + let detachMenu = document.getElementById("context-detachAttachment"); + let deleteMenu = document.getElementById("context-deleteAttachment"); + let copyUrlMenuSep = document.getElementById( + "context-menu-copyurl-separator" + ); + let copyUrlMenu = document.getElementById("context-copyAttachmentUrl"); + let openFolderMenu = document.getElementById("context-openFolder"); + + // If we opened the context menu from the attachment info area (the paperclip, + // "1 attachment" label, filename, or file size, just grab the first (and + // only) attachment as our "selected" attachments. + var selectedAttachments; + if ( + contextMenu.triggerNode == attachmentInfo || + contextMenu.triggerNode.parentNode == attachmentInfo + ) { + selectedAttachments = [attachmentList.getItemAtIndex(0).attachment]; + if (contextMenu.triggerNode == attachmentName) { + attachmentName.setAttribute("selected", true); + } + } else { + selectedAttachments = [...attachmentList.selectedItems].map( + item => item.attachment + ); + } + contextMenu.attachments = selectedAttachments; + + var allSelectedDetached = selectedAttachments.every(function (attachment) { + return attachment.isExternalAttachment; + }); + var allSelectedDeleted = selectedAttachments.every(function (attachment) { + return !attachment.hasFile; + }); + var canDetachSelected = + CanDetachAttachments() && !allSelectedDetached && !allSelectedDeleted; + let allSelectedHttp = selectedAttachments.every(function (attachment) { + return attachment.isLinkAttachment; + }); + let allSelectedFile = selectedAttachments.every(function (attachment) { + return attachment.isFileAttachment; + }); + + openMenu.disabled = allSelectedDeleted; + saveMenu.disabled = allSelectedDeleted; + detachMenu.disabled = !canDetachSelected; + deleteMenu.disabled = !canDetachSelected; + copyUrlMenuSep.hidden = copyUrlMenu.hidden = !( + allSelectedHttp || allSelectedFile + ); + openFolderMenu.hidden = !allSelectedFile; + openFolderMenu.disabled = allSelectedDeleted; + + Enigmail.hdrView.onShowAttachmentContextMenu(); +} + +/** + * Close the attachment item context menu, performing any cleanup as necessary. + */ +function onHideAttachmentItemContextMenu() { + let attachmentName = document.getElementById("attachmentName"); + let contextMenu = document.getElementById("attachmentItemContext"); + + // If we opened the context menu from the attachmentName label, we need to + // get rid of the "selected" attribute. + if (contextMenu.triggerNode == attachmentName) { + attachmentName.removeAttribute("selected"); + } +} + +/** + * Enable/disable menu items as appropriate for the single-attachment save all + * toolbar button. + */ +function onShowSaveAttachmentMenuSingle() { + let openItem = document.getElementById("button-openAttachment"); + let saveItem = document.getElementById("button-saveAttachment"); + let detachItem = document.getElementById("button-detachAttachment"); + let deleteItem = document.getElementById("button-deleteAttachment"); + + let detached = currentAttachments[0].isExternalAttachment; + let deleted = !currentAttachments[0].hasFile; + let canDetach = CanDetachAttachments() && !deleted && !detached; + + openItem.disabled = deleted; + saveItem.disabled = deleted; + detachItem.disabled = !canDetach; + deleteItem.disabled = !canDetach; +} + +/** + * Enable/disable menu items as appropriate for the multiple-attachment save all + * toolbar button. + */ +function onShowSaveAttachmentMenuMultiple() { + let openAllItem = document.getElementById("button-openAllAttachments"); + let saveAllItem = document.getElementById("button-saveAllAttachments"); + let detachAllItem = document.getElementById("button-detachAllAttachments"); + let deleteAllItem = document.getElementById("button-deleteAllAttachments"); + + let allDetached = currentAttachments.every(function (attachment) { + return attachment.isExternalAttachment; + }); + let allDeleted = currentAttachments.every(function (attachment) { + return !attachment.hasFile; + }); + let canDetach = CanDetachAttachments() && !allDeleted && !allDetached; + + openAllItem.disabled = allDeleted; + saveAllItem.disabled = allDeleted; + detachAllItem.disabled = !canDetach; + deleteAllItem.disabled = !canDetach; +} + +/** + * This is our oncommand handler for the attachment list items. A double click + * or enter press in an attachmentitem simulates "opening" the attachment. + * + * @param event the event object + */ +function attachmentItemCommand(event) { + HandleSelectedAttachments("open"); +} + +var AttachmentListController = { + supportsCommand(command) { + switch (command) { + case "cmd_selectAll": + case "cmd_delete": + case "cmd_shiftDelete": + case "cmd_saveAsFile": + return true; + default: + return false; + } + }, + + isCommandEnabled(command) { + switch (command) { + case "cmd_selectAll": + case "cmd_delete": + case "cmd_shiftDelete": + case "cmd_saveAsFile": + return true; + default: + return false; + } + }, + + doCommand(command) { + // If the user invoked a key short cut then it is possible that we got here + // for a command which is really disabled. kick out if the command should + // be disabled. + if (!this.isCommandEnabled(command)) { + return; + } + + var attachmentList = document.getElementById("attachmentList"); + + switch (command) { + case "cmd_selectAll": + attachmentList.selectAll(); + return; + case "cmd_delete": + case "cmd_shiftDelete": + HandleSelectedAttachments("delete"); + return; + case "cmd_saveAsFile": + HandleSelectedAttachments("saveAs"); + } + }, + + onEvent(event) {}, +}; + +var AttachmentMenuController = { + canDetachFiles() { + let someNotDetached = currentAttachments.some(function (aAttachment) { + return !aAttachment.isExternalAttachment; + }); + + return ( + CanDetachAttachments() && someNotDetached && this.someFilesAvailable() + ); + }, + + someFilesAvailable() { + return currentAttachments.some(function (aAttachment) { + return aAttachment.hasFile; + }); + }, + + supportsCommand(aCommand) { + return aCommand in this.commands; + }, +}; + +function goUpdateAttachmentCommands() { + for (let action of ["open", "save", "detach", "delete"]) { + goUpdateCommand(`cmd_${action}AllAttachments`); + } +} + +async function displayAttachmentsForExpandedView() { + var bundle = document.getElementById("bundle_messenger"); + var numAttachments = currentAttachments.length; + var attachmentView = document.getElementById("attachmentView"); + var attachmentSplitter = document.getElementById("attachment-splitter"); + document + .getElementById("attachmentIcon") + .setAttribute("src", "chrome://messenger/skin/icons/attach.svg"); + + if (numAttachments <= 0) { + attachmentView.collapsed = true; + attachmentSplitter.collapsed = true; + } else if (!gBuildAttachmentsForCurrentMsg) { + attachmentView.collapsed = false; + + var attachmentList = document.getElementById("attachmentList"); + + attachmentList.controllers.appendController(AttachmentListController); + + toggleAttachmentList(false); + + for (let attachment of currentAttachments) { + // Create a new attachment widget + var displayName = SanitizeAttachmentDisplayName(attachment); + var item = attachmentList.appendItem(attachment, displayName); + item.setAttribute("tooltiptext", attachment.name); + item.addEventListener("command", attachmentItemCommand); + + // Get a detached file's size. For link attachments, the user must always + // initiate the fetch for privacy reasons. + if (attachment.isFileAttachment) { + await attachment.isEmpty(); + } + } + + if ( + Services.prefs.getBoolPref("mailnews.attachments.display.start_expanded") + ) { + toggleAttachmentList(true); + } + + let attachmentInfo = document.getElementById("attachmentInfo"); + let attachmentCount = document.getElementById("attachmentCount"); + let attachmentName = document.getElementById("attachmentName"); + let attachmentSize = document.getElementById("attachmentSize"); + + if (numAttachments == 1) { + let count = bundle.getString("attachmentCountSingle"); + let name = SanitizeAttachmentDisplayName(currentAttachments[0]); + + attachmentInfo.setAttribute("contextmenu", "attachmentItemContext"); + attachmentCount.setAttribute("value", count); + attachmentName.hidden = false; + attachmentName.setAttribute("value", name); + } else { + let words = bundle.getString("attachmentCount"); + let count = PluralForm.get(currentAttachments.length, words).replace( + "#1", + currentAttachments.length + ); + + attachmentInfo.setAttribute("contextmenu", "attachmentListContext"); + attachmentCount.setAttribute("value", count); + attachmentName.hidden = true; + } + + attachmentSize.value = getAttachmentsTotalSizeStr(); + + // Extra candy for external attachments. + displayAttachmentsForExpandedViewExternal(); + + // Show the appropriate toolbar button and label based on the number of + // attachments. + updateSaveAllAttachmentsButton(); + + gBuildAttachmentsForCurrentMsg = true; + } +} + +function displayAttachmentsForExpandedViewExternal() { + let bundleMessenger = document.getElementById("bundle_messenger"); + let attachmentName = document.getElementById("attachmentName"); + let attachmentList = document.getElementById("attachmentList"); + + // Attachment bar single. + let firstAttachment = attachmentList.firstElementChild.attachment; + let isExternalAttachment = firstAttachment.isExternalAttachment; + let displayUrl = isExternalAttachment ? firstAttachment.displayUrl : ""; + let tooltiptext = + isExternalAttachment || firstAttachment.isDeleted + ? "" + : attachmentName.getAttribute("tooltiptextopen"); + let externalAttachmentNotFound = bundleMessenger.getString( + "externalAttachmentNotFound" + ); + + attachmentName.textContent = displayUrl; + attachmentName.tooltipText = tooltiptext; + attachmentName.setAttribute( + "tooltiptextexternalnotfound", + externalAttachmentNotFound + ); + attachmentName.addEventListener("mouseover", () => + top.MsgStatusFeedback.setOverLink(displayUrl) + ); + attachmentName.addEventListener("mouseout", () => + top.MsgStatusFeedback.setOverLink("") + ); + attachmentName.addEventListener("focus", () => + top.MsgStatusFeedback.setOverLink(displayUrl) + ); + attachmentName.addEventListener("blur", () => + top.MsgStatusFeedback.setOverLink("") + ); + attachmentName.classList.remove("text-link"); + attachmentName.classList.remove("notfound"); + + if (firstAttachment.isDeleted) { + attachmentName.classList.add("notfound"); + } + + if (isExternalAttachment) { + attachmentName.classList.add("text-link"); + + if (!firstAttachment.hasFile) { + attachmentName.setAttribute("tooltiptext", externalAttachmentNotFound); + attachmentName.classList.add("notfound"); + } + } + + // Expanded attachment list. + let index = 0; + for (let attachmentitem of attachmentList.children) { + let attachment = attachmentitem.attachment; + if (attachment.isDeleted) { + attachmentitem.classList.add("notfound"); + } + + if (attachment.isExternalAttachment) { + displayUrl = attachment.displayUrl; + attachmentitem.setAttribute("tooltiptext", ""); + attachmentitem.addEventListener("mouseover", () => + top.MsgStatusFeedback.setOverLink(displayUrl) + ); + attachmentitem.addEventListener("mouseout", () => + top.MsgStatusFeedback.setOverLink("") + ); + attachmentitem.addEventListener("focus", () => + top.MsgStatusFeedback.setOverLink(displayUrl) + ); + attachmentitem.addEventListener("blur", () => + top.MsgStatusFeedback.setOverLink("") + ); + + attachmentitem + .querySelector(".attachmentcell-name") + .classList.add("text-link"); + attachmentitem + .querySelector(".attachmentcell-extension") + .classList.add("text-link"); + + if (attachment.isLinkAttachment) { + if (index == 0) { + attachment.size = currentAttachments[index].size; + } + } + + if (!attachment.hasFile) { + attachmentitem.setAttribute("tooltiptext", externalAttachmentNotFound); + attachmentitem.classList.add("notfound"); + } + } + + index++; + } +} + +/** + * Update the "save all attachments" button in the attachment pane, showing + * the proper button and enabling/disabling it as appropriate. + */ +function updateSaveAllAttachmentsButton() { + let saveAllSingle = document.getElementById("attachmentSaveAllSingle"); + let saveAllMultiple = document.getElementById("attachmentSaveAllMultiple"); + + // If we can't find the buttons, they're not on the toolbar, so bail out! + if (!saveAllSingle || !saveAllMultiple) { + return; + } + + let allDeleted = currentAttachments.every(function (attachment) { + return !attachment.hasFile; + }); + let single = currentAttachments.length == 1; + + saveAllSingle.hidden = !single; + saveAllMultiple.hidden = single; + saveAllSingle.disabled = saveAllMultiple.disabled = allDeleted; +} + +/** + * Update the attachments display info after a particular attachment's + * existence has been verified. + * + * @param {AttachmentInfo} attachmentInfo + * @param {boolean} isFetching + */ +function updateAttachmentsDisplay(attachmentInfo, isFetching) { + if (attachmentInfo.isExternalAttachment) { + let attachmentList = document.getElementById("attachmentList"); + let attachmentIcon = document.getElementById("attachmentIcon"); + let attachmentName = document.getElementById("attachmentName"); + let attachmentSize = document.getElementById("attachmentSize"); + let attachmentItem = attachmentList.findItemForAttachment(attachmentInfo); + let index = attachmentList.getIndexOfItem(attachmentItem); + + if (isFetching) { + // Set elements busy to show the user this is potentially a long network + // fetch for the link attachment. + attachmentList.setAttachmentLoaded(attachmentItem, false); + return; + } + + if (attachmentInfo.message != gMessage) { + // The user changed messages while fetching, reset the bar and exit; + // the listitems are torn down/rebuilt on each message load. + attachmentIcon.setAttribute( + "src", + "chrome://messenger/skin/icons/attach.svg" + ); + return; + } + + if (index == -1) { + // The user changed messages while fetching, then came back to the same + // message. The reset of busy state has already happened and anyway the + // item has already been torn down so the index will be invalid; exit. + return; + } + + currentAttachments[index].size = attachmentInfo.size; + let tooltiptextExternalNotFound = attachmentName.getAttribute( + "tooltiptextexternalnotfound" + ); + + let sizeStr; + let bundle = document.getElementById("bundle_messenger"); + if (attachmentInfo.size < 1) { + sizeStr = bundle.getString("attachmentSizeUnknown"); + } else { + sizeStr = top.messenger.formatFileSize(attachmentInfo.size); + } + + // The attachment listitem. + attachmentList.setAttachmentLoaded(attachmentItem, true); + attachmentList.setAttachmentSize( + attachmentItem, + attachmentInfo.hasFile ? sizeStr : "" + ); + + // FIXME: The UI logic for this should be moved to the attachment list or + // item itself. + if (attachmentInfo.hasFile) { + attachmentItem.removeAttribute("tooltiptext"); + attachmentItem.classList.remove("notfound"); + } else { + attachmentItem.setAttribute("tooltiptext", tooltiptextExternalNotFound); + attachmentItem.classList.add("notfound"); + } + + // The attachmentbar. + updateSaveAllAttachmentsButton(); + attachmentSize.value = getAttachmentsTotalSizeStr(); + if (attachmentList.isLoaded()) { + attachmentIcon.setAttribute( + "src", + "chrome://messenger/skin/icons/attach.svg" + ); + } + + // If it's the first one (and there's only one). + if (index == 0) { + if (attachmentInfo.hasFile) { + attachmentName.removeAttribute("tooltiptext"); + attachmentName.classList.remove("notfound"); + } else { + attachmentName.setAttribute("tooltiptext", tooltiptextExternalNotFound); + attachmentName.classList.add("notfound"); + } + } + + // Reset widths since size may have changed; ensure no false cropping of + // the attachment item name. + attachmentList.setOptimumWidth(); + } +} + +/** + * Calculate the total size of all attachments in the message as emitted to + * |currentAttachments| and return a pretty string. + * + * @returns {string} - Description of the attachment size (e.g. 123 KB or 3.1MB) + */ +function getAttachmentsTotalSizeStr() { + let bundle = document.getElementById("bundle_messenger"); + let totalSize = 0; + let lastPartID; + let unknownSize = false; + for (let attachment of currentAttachments) { + // Check if this attachment's part ID is a child of the last attachment + // we counted. If so, skip it, since we already accounted for its size + // from its parent. + if (!lastPartID || attachment.partID.indexOf(lastPartID) != 0) { + lastPartID = attachment.partID; + if (attachment.size != -1) { + totalSize += Number(attachment.size); + } else if (!attachment.isDeleted) { + unknownSize = true; + } + } + } + + let sizeStr = top.messenger.formatFileSize(totalSize); + if (unknownSize) { + if (totalSize == 0) { + sizeStr = bundle.getString("attachmentSizeUnknown"); + } else { + sizeStr = bundle.getFormattedString("attachmentSizeAtLeast", [sizeStr]); + } + } + + return sizeStr; +} + +/** + * Expand/collapse the attachment list. When expanding it, automatically resize + * it to an appropriate height (1/4 the message pane or smaller). + * + * @param expanded True if the attachment list should be expanded, false + * otherwise. If |expanded| is not specified, toggle the state. + * @param updateFocus (optional) True if the focus should be updated, focusing + * on the attachmentList when expanding, or the messagepane + * when collapsing (but only when the attachmentList was + * originally focused). + */ +function toggleAttachmentList(expanded, updateFocus) { + var attachmentView = document.getElementById("attachmentView"); + var attachmentBar = document.getElementById("attachmentBar"); + var attachmentToggle = document.getElementById("attachmentToggle"); + var attachmentList = document.getElementById("attachmentList"); + var attachmentSplitter = document.getElementById("attachment-splitter"); + var bundle = document.getElementById("bundle_messenger"); + + if (expanded === undefined) { + expanded = !attachmentToggle.checked; + } + + attachmentToggle.checked = expanded; + + if (expanded) { + attachmentList.collapsed = false; + if (!attachmentView.collapsed) { + attachmentSplitter.collapsed = false; + } + attachmentBar.setAttribute( + "tooltiptext", + bundle.getString("collapseAttachmentPaneTooltip") + ); + + attachmentList.setOptimumWidth(); + + // By design, attachmentView should not take up more than 1/4 of the message + // pane space + attachmentView.setAttribute( + "height", + Math.min( + attachmentList.preferredHeight, + document.getElementById("messagepanebox").getBoundingClientRect() + .height / 4 + ) + ); + + if (updateFocus) { + attachmentList.focus(); + } + } else { + attachmentList.collapsed = true; + attachmentSplitter.collapsed = true; + attachmentBar.setAttribute( + "tooltiptext", + bundle.getString("expandAttachmentPaneTooltip") + ); + attachmentView.removeAttribute("height"); + + if (updateFocus && document.activeElement == attachmentList) { + // TODO + } + } +} + +/** + * Open an attachment from the attachment bar. + * + * @param event the event that triggered this action + */ +function OpenAttachmentFromBar(event) { + if (event.button == 0) { + // Only open on the first click; ignore double-clicks so that the user + // doesn't end up with the attachment opened multiple times. + if (event.detail == 1) { + TryHandleAllAttachments("open"); + } + event.stopPropagation(); + } +} + +/** + * Handle all the attachments in this message (save them, open them, etc). + * + * @param action one of "open", "save", "saveAs", "detach", or "delete" + */ +function HandleAllAttachments(action) { + HandleMultipleAttachments(currentAttachments, action); +} + +/** + * Try to handle all the attachments in this message (save them, open them, + * etc). If the action fails for whatever reason, catch the error and report it. + * + * @param action one of "open", "save", "saveAs", "detach", or "delete" + */ +function TryHandleAllAttachments(action) { + try { + HandleAllAttachments(action); + } catch (e) { + console.error(e); + } +} + +/** + * Handle the currently-selected attachments in this message (save them, open + * them, etc). + * + * @param action one of "open", "save", "saveAs", "detach", or "delete" + */ +function HandleSelectedAttachments(action) { + let attachmentList = document.getElementById("attachmentList"); + let selectedAttachments = []; + for (let item of attachmentList.selectedItems) { + selectedAttachments.push(item.attachment); + } + + HandleMultipleAttachments(selectedAttachments, action); +} + +/** + * Perform an action on multiple attachments (e.g. open or save) + * + * @param attachments an array of AttachmentInfo objects to work with + * @param action one of "open", "save", "saveAs", "detach", or "delete" + */ +function HandleMultipleAttachments(attachments, action) { + // Feed message link attachments save handling. + if ( + FeedUtils.isFeedMessage(gMessage) && + (action == "save" || action == "saveAs") + ) { + saveLinkAttachmentsToFile(attachments); + return; + } + + // convert our attachment data into some c++ friendly structs + var attachmentContentTypeArray = []; + var attachmentUrlArray = []; + var attachmentDisplayUrlArray = []; + var attachmentDisplayNameArray = []; + var attachmentMessageUriArray = []; + + // populate these arrays.. + var actionIndex = 0; + for (let attachment of attachments) { + // Exclude attachment which are 1) deleted, or 2) detached with missing + // external files, unless copying urls. + if (!attachment.hasFile && action != "copyUrl") { + continue; + } + + attachmentContentTypeArray[actionIndex] = attachment.contentType; + attachmentUrlArray[actionIndex] = attachment.url; + attachmentDisplayUrlArray[actionIndex] = attachment.displayUrl; + attachmentDisplayNameArray[actionIndex] = encodeURI(attachment.name); + attachmentMessageUriArray[actionIndex] = attachment.uri; + ++actionIndex; + } + + // The list has been built. Now call our action code... + switch (action) { + case "save": + top.messenger.saveAllAttachments( + attachmentContentTypeArray, + attachmentUrlArray, + attachmentDisplayNameArray, + attachmentMessageUriArray + ); + return; + case "detach": + // "detach" on a multiple selection of attachments is so far not really + // supported. As a workaround, resort to normal detach-"all". See also + // the comment on 'detaching a multiple selection of attachments' below. + if (attachments.length == 1) { + attachments[0].detach(top.messenger, true); + } else { + top.messenger.detachAllAttachments( + attachmentContentTypeArray, + attachmentUrlArray, + attachmentDisplayNameArray, + attachmentMessageUriArray, + true // save + ); + } + return; + case "delete": + top.messenger.detachAllAttachments( + attachmentContentTypeArray, + attachmentUrlArray, + attachmentDisplayNameArray, + attachmentMessageUriArray, + false // don't save + ); + return; + case "open": + // XXX hack alert. If we sit in tight loop and open multiple + // attachments, we get chrome errors in layout as we start loading the + // first helper app dialog then before it loads, we kick off the next + // one and the next one. Subsequent helper app dialogs were failing + // because we were still loading the chrome files for the first attempt + // (error about the xul cache being empty). For now, work around this by + // doing the first helper app dialog right away, then waiting a bit + // before we launch the rest. + let actionFunction = function (aAttachment) { + aAttachment.open(getMessagePaneBrowser().browsingContext); + }; + + for (let i = 0; i < attachments.length; i++) { + if (i == 0) { + actionFunction(attachments[i]); + } else { + setTimeout(actionFunction, 100, attachments[i]); + } + } + return; + case "saveAs": + // Show one save dialog at a time, which allows to adjust the file name + // and folder path for each attachment. For added convenience, we remember + // the folder path of each file for the save dialog of the next one. + let saveAttachments = function (attachments) { + if (attachments.length > 0) { + attachments[0].save(top.messenger).then(function () { + saveAttachments(attachments.slice(1)); + }); + } + }; + + saveAttachments(attachments); + return; + case "copyUrl": + // Copy external http url(s) to clipboard. The menuitem is hidden unless + // all selected attachment urls are http. + navigator.clipboard.writeText(attachmentDisplayUrlArray.join("\n")); + return; + case "openFolder": + for (let attachment of attachments) { + setTimeout(() => attachment.openFolder()); + } + return; + default: + throw new Error("unknown HandleMultipleAttachments action: " + action); + } +} + +/** + * Link attachments are passed as an array of AttachmentInfo objects. This + * is meant to download http link content using the browser method. + * + * @param {AttachmentInfo[]} aAttachmentInfoArray - Array of attachmentInfo. + */ +async function saveLinkAttachmentsToFile(aAttachmentInfoArray) { + for (let attachment of aAttachmentInfoArray) { + if (!attachment.hasFile || attachment.message != gMessage) { + continue; + } + + let empty = await attachment.isEmpty(); + if (empty) { + continue; + } + + // internalSave() is part of saveURL() internals... + internalSave( + attachment.url, // aURL, + null, // aOriginalUrl, + undefined, // aDocument, + attachment.name, // aDefaultFileName, + undefined, // aContentDisposition, + undefined, // aContentType, + undefined, // aShouldBypassCache, + undefined, // aFilePickerTitleKey, + undefined, // aChosenData, + undefined, // aReferrer, + undefined, // aCookieJarSettings, + document, // aInitiatingDocument, + undefined, // aSkipPrompt, + undefined, // aCacheKey, + undefined // aIsContentWindowPrivate + ); + } +} + +function ClearAttachmentList() { + // clear selection + var list = document.getElementById("attachmentList"); + list.clearSelection(); + + while (list.hasChildNodes()) { + list.lastChild.remove(); + } +} + +// See attachmentBucketDNDObserver, which should have the same logic. +let attachmentListDNDObserver = { + onDragStart(event) { + // NOTE: Starting a drag on an attachment item will normally also select + // the attachment item before this method is called. But this is not + // necessarily the case. E.g. holding Shift when starting the drag + // operation. When it isn't selected, we just don't transfer. + if (event.target.matches(".attachmentItem[selected]")) { + // Also transfer other selected attachment items. + let attachments = Array.from( + document.querySelectorAll("#attachmentList .attachmentItem[selected]"), + item => item.attachment + ); + setupDataTransfer(event, attachments); + } + event.stopPropagation(); + }, +}; + +let attachmentNameDNDObserver = { + onDragStart(event) { + let attachmentList = document.getElementById("attachmentList"); + setupDataTransfer(event, [attachmentList.getItemAtIndex(0).attachment]); + event.stopPropagation(); + }, +}; + +function onShowOtherActionsPopup() { + // Enable/disable the Open Conversation button. + let glodaEnabled = Services.prefs.getBoolPref( + "mailnews.database.global.indexer.enabled" + ); + + let openConversation = document.getElementById( + "otherActionsOpenConversation" + ); + // Check because this menuitem element is not present in messageWindow.xhtml. + if (openConversation) { + openConversation.disabled = !( + glodaEnabled && Gloda.isMessageIndexed(gMessage) + ); + } + + let isDummyMessage = !gViewWrapper.isSynthetic && !gMessage.folder; + let tagsItem = document.getElementById("otherActionsTag"); + let markAsReadItem = document.getElementById("markAsReadMenuItem"); + let markAsUnreadItem = document.getElementById("markAsUnreadMenuItem"); + + if (isDummyMessage) { + tagsItem.disabled = true; + markAsReadItem.disabled = true; + markAsReadItem.removeAttribute("hidden"); + markAsUnreadItem.setAttribute("hidden", true); + } else { + tagsItem.disabled = false; + markAsReadItem.disabled = false; + if (SelectedMessagesAreRead()) { + markAsReadItem.setAttribute("hidden", true); + markAsUnreadItem.removeAttribute("hidden"); + } else { + markAsReadItem.removeAttribute("hidden"); + markAsUnreadItem.setAttribute("hidden", true); + } + } + + document.getElementById("otherActions-calendar-convert-menu").hidden = + isDummyMessage || !calendarDeactivator.isCalendarActivated; + + // Check if the current message is feed or not. + let isFeed = FeedUtils.isFeedMessage(gMessage); + document.getElementById("otherActionsMessageBodyAs").hidden = isFeed; + document.getElementById("otherActionsFeedBodyAs").hidden = !isFeed; +} + +function InitOtherActionsViewBodyMenu() { + let html_as = Services.prefs.getIntPref("mailnews.display.html_as"); + let prefer_plaintext = Services.prefs.getBoolPref( + "mailnews.display.prefer_plaintext" + ); + let disallow_classes = Services.prefs.getIntPref( + "mailnews.display.disallow_mime_handlers" + ); + let isFeed = false; // TODO + const kDefaultIDs = [ + "otherActionsMenu_bodyAllowHTML", + "otherActionsMenu_bodySanitized", + "otherActionsMenu_bodyAsPlaintext", + "otherActionsMenu_bodyAllParts", + ]; + const kRssIDs = [ + "otherActionsMenu_bodyFeedSummaryAllowHTML", + "otherActionsMenu_bodyFeedSummarySanitized", + "otherActionsMenu_bodyFeedSummaryAsPlaintext", + ]; + let menuIDs = isFeed ? kRssIDs : kDefaultIDs; + + if (disallow_classes > 0) { + window.top.gDisallow_classes_no_html = disallow_classes; + } + // else gDisallow_classes_no_html keeps its initial value (see top) + + let AllowHTML_menuitem = document.getElementById(menuIDs[0]); + let Sanitized_menuitem = document.getElementById(menuIDs[1]); + let AsPlaintext_menuitem = document.getElementById(menuIDs[2]); + let AllBodyParts_menuitem = menuIDs[3] + ? document.getElementById(menuIDs[3]) + : null; + + document.getElementById("otherActionsMenu_bodyAllParts").hidden = + !Services.prefs.getBoolPref("mailnews.display.show_all_body_parts_menu"); + + // Clear all checkmarks. + AllowHTML_menuitem.removeAttribute("checked"); + Sanitized_menuitem.removeAttribute("checked"); + AsPlaintext_menuitem.removeAttribute("checked"); + if (AllBodyParts_menuitem) { + AllBodyParts_menuitem.removeAttribute("checked"); + } + + if ( + !prefer_plaintext && + !html_as && + !disallow_classes && + AllowHTML_menuitem + ) { + AllowHTML_menuitem.setAttribute("checked", true); + } else if ( + !prefer_plaintext && + html_as == 3 && + disallow_classes > 0 && + Sanitized_menuitem + ) { + Sanitized_menuitem.setAttribute("checked", true); + } else if ( + prefer_plaintext && + html_as == 1 && + disallow_classes > 0 && + AsPlaintext_menuitem + ) { + AsPlaintext_menuitem.setAttribute("checked", true); + } else if ( + !prefer_plaintext && + html_as == 4 && + !disallow_classes && + AllBodyParts_menuitem + ) { + AllBodyParts_menuitem.setAttribute("checked", true); + } + // else (the user edited prefs/user.js) check none of the radio menu items + + if (isFeed) { + AllowHTML_menuitem.hidden = !gShowFeedSummary; + Sanitized_menuitem.hidden = !gShowFeedSummary; + AsPlaintext_menuitem.hidden = !gShowFeedSummary; + document.getElementById( + "otherActionsMenu_viewFeedSummarySeparator" + ).hidden = !gShowFeedSummary; + } +} + +/** + * Object literal to handle a few simple customization options for the message + * header. + */ +const gHeaderCustomize = { + docURL: "chrome://messenger/content/messenger.xhtml", + /** + * The DOM element panel collecting all customization options. + * + * @type {XULElement} + */ + customizePanel: null, + /** + * The object storing all saved customization options. + * + * @note Any keys added to this object should also be added to the telemetry + * scalar tb.ui.configuration.message_header. + * + * @type {object} + * @property {boolean} showAvatar - If the profile picture of the sender + * should be shown. + * @property {boolean} showBigAvatar - If a big profile picture of the sender + * should be shown. + * @property {boolean} showFullAddress - If the sender should always be + * shown with the full name and email address. + * @property {boolean} hideLabels - If the labels column should be hidden. + * @property {boolean} subjectLarge - If the font size of the subject line + * should be increased. + * @property {string} buttonStyle - The style in which the buttons should be + * rendered: + * - "default" = icons+text + * - "only-icons" = only icons + * - "only-text" = only text + */ + customizeData: { + showAvatar: true, + showBigAvatar: false, + showFullAddress: true, + hideLabels: true, + subjectLarge: true, + buttonStyle: "default", + }, + + /** + * Initialize the customizer. + */ + init() { + this.customizePanel = document.getElementById( + "messageHeaderCustomizationPanel" + ); + + if (Services.xulStore.hasValue(this.docURL, "messageHeader", "layout")) { + this.customizeData = JSON.parse( + Services.xulStore.getValue(this.docURL, "messageHeader", "layout") + ); + this.updateLayout(); + } + }, + + /** + * Reset and update the customized style of the message header. + */ + updateLayout() { + let header = document.getElementById("messageHeader"); + // Always clear existing styles to avoid visual issues. + header.classList.remove( + "message-header-large-subject", + "message-header-buttons-only-icons", + "message-header-buttons-only-text", + "message-header-hide-label-column" + ); + + // Bail out if we don't have anything to customize. + if (!Object.keys(this.customizeData).length) { + header.classList.add( + "message-header-large-subject", + "message-header-show-recipient-avatar", + "message-header-show-sender-full-address", + "message-header-hide-label-column" + ); + return; + } + + header.classList.toggle( + "message-header-large-subject", + this.customizeData.subjectLarge || false + ); + + header.classList.toggle( + "message-header-hide-label-column", + this.customizeData.hideLabels || false + ); + + header.classList.toggle( + "message-header-show-recipient-avatar", + this.customizeData.showAvatar || false + ); + + header.classList.toggle( + "message-header-show-big-avatar", + this.customizeData.showBigAvatar || false + ); + + header.classList.toggle( + "message-header-show-sender-full-address", + this.customizeData.showFullAddress || false + ); + + switch (this.customizeData.buttonStyle) { + case "only-icons": + case "only-text": + header.classList.add( + `message-header-buttons-${this.customizeData.buttonStyle}` + ); + break; + + case "default": + default: + header.classList.remove( + "message-header-buttons-only-icons", + "message-header-buttons-only-text" + ); + break; + } + + gMessageHeader.syncLabelsColumnWidths(); + }, + + /** + * Show the customization panel for the message header. + */ + showPanel() { + this.customizePanel.openPopup( + document.getElementById("otherActionsButton"), + "after_end", + 6, + 6, + false + ); + }, + + /** + * Update the panel's elements to reflect the users' customization. + */ + onPanelShowing() { + document.getElementById("headerButtonStyle").value = + this.customizeData.buttonStyle || "default"; + + document.getElementById("headerShowAvatar").checked = + this.customizeData.showAvatar || false; + + document.getElementById("headerShowBigAvatar").checked = + this.customizeData.showBigAvatar || false; + + document.getElementById("headerShowFullAddress").checked = + this.customizeData.showFullAddress || false; + + document.getElementById("headerHideLabels").checked = + this.customizeData.hideLabels || false; + + document.getElementById("headerSubjectLarge").checked = + this.customizeData.subjectLarge || false; + + let type = Ci.nsMimeHeaderDisplayTypes; + let pref = Services.prefs.getIntPref("mail.show_headers"); + + document.getElementById("headerViewAllHeaders").checked = + type.AllHeaders == pref; + }, + + /** + * Update the buttons style when the menuitem value is changed. + * + * @param {Event} event - The menuitem command event. + */ + updateButtonStyle(event) { + this.customizeData.buttonStyle = event.target.value; + this.updateLayout(); + }, + + /** + * Show or hide the profile picture of the sender recipient. + * + * @param {Event} event - The checkbox command event. + */ + toggleAvatar(event) { + const isChecked = event.target.checked; + this.customizeData.showAvatar = isChecked; + document.getElementById("headerShowBigAvatar").disabled = !isChecked; + this.updateLayout(); + }, + + /** + * Show big or small profile picture of the sender recipient. + * + * @param {Event} event - The checkbox command event. + */ + toggleBigAvatar(event) { + this.customizeData.showBigAvatar = event.target.checked; + this.updateLayout(); + }, + + /** + * Show or hide the sender's full address, which will show the display name + * and the email address on two different lines. + * + * @param {Event} event - The checkbox command event. + */ + toggleSenderAddress(event) { + this.customizeData.showFullAddress = event.target.checked; + this.updateLayout(); + }, + + /** + * Show or hide the labels column. + * + * @param {Event} event - The checkbox command event. + */ + toggleLabelColumn(event) { + this.customizeData.hideLabels = event.target.checked; + this.updateLayout(); + }, + + /** + * Update the subject style when the checkbox is clicked. + * + * @param {Event} event - The checkbox command event. + */ + updateSubjectStyle(event) { + this.customizeData.subjectLarge = event.target.checked; + this.updateLayout(); + }, + + /** + * Show or hide all the headers of a message. + * + * @param {Event} event - The checkbox command event. + */ + toggleAllHeaders(event) { + let mode = event.target.checked + ? Ci.nsMimeHeaderDisplayTypes.AllHeaders + : Ci.nsMimeHeaderDisplayTypes.NormalHeaders; + Services.prefs.setIntPref("mail.show_headers", mode); + AdjustHeaderView(mode); + ReloadMessage(); + }, + + /** + * Close the customize panel. + */ + closePanel() { + this.customizePanel.hidePopup(); + }, + + /** + * Update the xulStore only when the panel is closed. + */ + onPanelHidden() { + Services.xulStore.setValue( + this.docURL, + "messageHeader", + "layout", + JSON.stringify(this.customizeData) + ); + }, +}; + +/** + * Object to handle the creation, destruction, and update of all recipient + * fields that will be showed in the message header. + */ +const gMessageHeader = { + /** + * Get the newsgroup server corresponding to the currently selected message. + * + * @returns {?nsISubscribableServer} The server for the newsgroup, or null. + */ + get newsgroupServer() { + if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) { + return gFolder.server?.QueryInterface(Ci.nsISubscribableServer); + } + + return null; + }, + + /** + * Toggle the scrollable style of the message header area. + * + * @param {boolean} showAllHeaders - True if we need to show all header fields + * and ignore the space limit for multi recipients row. + */ + toggleScrollableHeader(showAllHeaders) { + document + .getElementById("messageHeader") + .classList.toggle("scrollable", showAllHeaders); + }, + + /** + * Ensure that the all visible labels have the same size. + */ + syncLabelsColumnWidths() { + let allHeaderLabels = document.querySelectorAll( + ".message-header-row:not([hidden]) .message-header-label" + ); + + // Clear existing style. + for (let label of allHeaderLabels) { + label.style.minWidth = null; + } + + let minWidth = Math.max(...Array.from(allHeaderLabels, i => i.clientWidth)); + for (let label of allHeaderLabels) { + label.style.minWidth = `${minWidth}px`; + } + }, + + openCopyPopup(event, element) { + document.getElementById("copyCreateFilterFrom").disabled = + !gFolder?.server.canHaveFilters; + + let popup = document.getElementById( + element.matches(`:scope[is="url-header-row"]`) + ? "copyUrlPopup" + : "copyPopup" + ); + popup.headerField = element; + popup.openPopupAtScreen(event.screenX, event.screenY, true); + }, + + async openEmailAddressPopup(event, element) { + // Bail out if we don't have an email address. + if (!element.emailAddress) { + return; + } + + document + .getElementById("emailAddressPlaceHolder") + .setAttribute("label", element.emailAddress); + + document.getElementById("addToAddressBookItem").hidden = + element.cardDetails.card; + document.getElementById("editContactItem").hidden = + !element.cardDetails.card || element.cardDetails.book?.readOnly; + document.getElementById("viewContactItem").hidden = + !element.cardDetails.card || !element.cardDetails.book?.readOnly; + + let discoverKeyMenuItem = document.getElementById("searchKeysOpenPGP"); + if (discoverKeyMenuItem) { + let hidden = await PgpSqliteDb2.hasAnyPositivelyAcceptedKeyForEmail( + element.emailAddress + ); + discoverKeyMenuItem.hidden = hidden; + discoverKeyMenuItem.nextElementSibling.hidden = hidden; // Hide separator. + } + + document.getElementById("createFilterFrom").disabled = + !gFolder?.server.canHaveFilters; + + let popup = document.getElementById("emailAddressPopup"); + popup.headerField = element; + + if (!event.screenX) { + popup.openPopup(event.target, "after_start", 0, 0, true); + return; + } + + popup.openPopupAtScreen(event.screenX, event.screenY, true); + }, + + openNewsgroupPopup(event, element) { + document + .getElementById("newsgroupPlaceHolder") + .setAttribute("label", element.textContent); + + let subscribed = this.newsgroupServer + ?.QueryInterface(Ci.nsINntpIncomingServer) + .containsNewsgroup(element.textContent); + document.getElementById("subscribeToNewsgroupItem").hidden = subscribed; + document.getElementById("subscribeToNewsgroupSeparator").hidden = + subscribed; + + let popup = document.getElementById("newsgroupPopup"); + popup.headerField = element; + + if (!event.screenX) { + popup.openPopup(event.target, "after_start", 0, 0, true); + return; + } + + popup.openPopupAtScreen(event.screenX, event.screenY, true); + }, + + openMessageIdPopup(event, element) { + document + .getElementById("messageIdContext-messageIdTarget") + .setAttribute("label", element.id); + + // We don't want to show "Open Message For ID" for the same message + // we're viewing. + document.getElementById("messageIdContext-openMessageForMsgId").hidden = + `<${gMessage.messageId}>` == element.id; + + // We don't want to show "Open Browser With Message-ID" for non-nntp + // messages. + document.getElementById("messageIdContext-openBrowserWithMsgId").hidden = + !gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false); + + let popup = document.getElementById("messageIdContext"); + popup.headerField = element; + + if (!event.screenX) { + popup.openPopup(event.target, "after_start", 0, 0, true); + return; + } + + popup.openPopupAtScreen(event.screenX, event.screenY, true); + }, + + /** + * Add a contact to the address book. + * + * @param {Event} event - The DOM Event. + */ + addContact(event) { + event.currentTarget.parentNode.headerField.addToAddressBook(); + }, + + /** + * Show the edit card popup panel. + * + * @param {Event} event - The DOM Event. + */ + showContactEdit(event) { + this.editContact(event.currentTarget.parentNode.headerField); + }, + + /** + * Trigger a new message compose window. + * + * @param {Event} event - The click DOMEvent. + */ + composeMessage(event) { + let recipient = event.currentTarget.parentNode.headerField; + + let fields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + + if (recipient.classList.contains("header-newsgroup")) { + fields.newsgroups = recipient.textContent; + } + + if (recipient.fullAddress) { + let addresses = MailServices.headerParser.makeFromDisplayAddress( + recipient.fullAddress + ); + if (addresses.length) { + fields.to = MailServices.headerParser.makeMimeHeader([addresses[0]]); + } + } + + let params = Cc[ + "@mozilla.org/messengercompose/composeparams;1" + ].createInstance(Ci.nsIMsgComposeParams); + params.type = Ci.nsIMsgCompType.New; + + // If the Shift key was pressed toggle the composition format + // (HTML vs. plaintext). + params.format = event.shiftKey + ? Ci.nsIMsgCompFormat.OppositeOfDefault + : Ci.nsIMsgCompFormat.Default; + + if (gFolder) { + params.identity = MailServices.accounts.getFirstIdentityForServer( + gFolder.server + ); + } + params.composeFields = fields; + MailServices.compose.OpenComposeWindowWithParams(null, params); + }, + + /** + * Copy the email address, as well as the name if wanted, in the clipboard. + * + * @param {Event} event - The DOM Event. + * @param {boolean} withName - True if we need to copy also the name. + */ + copyAddress(event, withName = false) { + let recipient = event.currentTarget.parentNode.headerField; + let address; + if (recipient.classList.contains("header-newsgroup")) { + address = recipient.textContent; + } else { + address = withName ? recipient.fullAddress : recipient.emailAddress; + } + navigator.clipboard.writeText(address); + }, + + copyNewsgroupURL(event) { + let server = this.newsgroupServer; + if (!server) { + return; + } + + let newsgroup = event.currentTarget.parentNode.headerField.textContent; + + let url; + if (server.socketType != Ci.nsMsgSocketType.SSL) { + url = "news://" + server.hostName; + if (server.port != Ci.nsINntpUrl.DEFAULT_NNTP_PORT) { + url += ":" + server.port; + } + url += "/" + newsgroup; + } else { + url = "snews://" + server.hostName; + if (server.port != Ci.nsINntpUrl.DEFAULT_NNTPS_PORT) { + url += ":" + server.port; + } + url += "/" + newsgroup; + } + + try { + let uri = Services.io.newURI(url); + navigator.clipboard.writeText(decodeURI(uri.spec)); + } catch (e) { + console.error("Invalid URL: " + url); + } + }, + + /** + * Subscribe to a newsgroup. + * + * @param {Event} event - The DOM Event. + */ + subscribeToNewsgroup(event) { + let server = this.newsgroupServer; + if (server) { + let newsgroup = event.currentTarget.parentNode.headerField.textContent; + server.subscribe(newsgroup); + server.commitSubscribeChanges(); + } + }, + + /** + * Copy the text value of an header field. + * + * @param {Event} event - The DOM Event. + */ + copyString(event) { + // This method is used inside the copyPopup menupopup, which is triggered by + // both HTML headers fields and XUL labels. We need to account for those + // different widgets in order to properly copy the text. + let target = + event.currentTarget.parentNode.triggerNode || + event.currentTarget.parentNode.headerField; + navigator.clipboard.writeText( + window.getSelection().isCollapsed + ? target.textContent + : window.getSelection().toString() + ); + }, + + /** + * Open the message filter dialog prefilled with available data. + * + * @param {Event} event - The DOM Event. + */ + createFilter(event) { + let element = event.currentTarget.parentNode.headerField; + top.MsgFilters( + element.emailAddress || element.value.textContent, + gFolder, + element.dataset.headerName + ); + }, + + /** + * Show the edit contact popup panel. + * + * @param {HTMLLIElement} element - The recipient element. + */ + editContact(element) { + editContactInlineUI.showEditContactPanel(element.cardDetails, element); + }, + + /** + * Set the tags to the message header tag element. + */ + setTags() { + // Bail out if we don't have a message selected. + if (!gMessage || !gFolder) { + return; + } + + // Extract the tag keys from the message header. + let msgKeyArray = gMessage.getStringProperty("keywords").split(" "); + + // Get the list of known tags. + let tagsArray = MailServices.tags.getAllTags().filter(t => t.tag); + let tagKeys = {}; + for (let tagInfo of tagsArray) { + tagKeys[tagInfo.key] = true; + } + // Only use tags that match our saved tags. + let msgKeys = msgKeyArray.filter(k => k in tagKeys); + + if (msgKeys.length) { + currentHeaderData.tags = { + headerName: "tags", + headerValue: msgKeys.join(" "), + }; + return; + } + + // No more tags, so clear out the header field. + delete currentHeaderData.tags; + }, + + onMessageIdClick(event) { + let id = event.currentTarget.closest(".header-message-id").id; + if (event.button == 0) { + // Remove the < and > symbols. + OpenMessageForMessageId(id.substring(1, id.length - 1)); + } + }, + + openMessage(event) { + let id = event.currentTarget.parentNode.headerField.id; + // Remove the < and > symbols. + OpenMessageForMessageId(id.substring(1, id.length - 1)); + }, + + openBrowser(event) { + let id = event.currentTarget.parentNode.headerField.id; + // Remove the < and > symbols. + OpenBrowserWithMessageId(id.substring(1, id.length - 1)); + }, + + copyMessageId(event) { + navigator.clipboard.writeText( + event.currentTarget.parentNode.headerField.id + ); + }, + + copyWebsiteUrl(event) { + navigator.clipboard.writeText( + event.currentTarget.parentNode.headerField.value.textContent + ); + }, +}; + +function MarkSelectedMessagesRead(markRead) { + ClearPendingReadTimer(); + gDBView.doCommand( + markRead + ? Ci.nsMsgViewCommandType.markMessagesRead + : Ci.nsMsgViewCommandType.markMessagesUnread + ); + if (markRead) { + reportMsgRead({ isNewRead: true }); + } +} + +function MarkSelectedMessagesFlagged(markFlagged) { + gDBView.doCommand( + markFlagged + ? Ci.nsMsgViewCommandType.flagMessages + : Ci.nsMsgViewCommandType.unflagMessages + ); +} + +/** + * Take the message id from the messageIdNode and use the url defined in the + * hidden pref "mailnews.messageid_browser.url" to open it in a browser window + * (%mid is replaced by the message id). + * @param {string} messageId - The message id to open. + */ +function OpenBrowserWithMessageId(messageId) { + var browserURL = Services.prefs.getComplexValue( + "mailnews.messageid_browser.url", + Ci.nsIPrefLocalizedString + ).data; + browserURL = browserURL.replace(/%mid/, messageId); + try { + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(Services.io.newURI(browserURL)); + } catch (ex) { + console.error( + "Failed to open message-id in browser; browserURL=" + browserURL + ); + } +} + +/** + * Take the message id from the messageIdNode, search for the corresponding + * message in all folders starting with the current selected folder, then the + * current account followed by the other accounts and open corresponding + * message if found. + * @param {string} messageId - The message id to open. + */ +function OpenMessageForMessageId(messageId) { + let startServer = gFolder?.server; + + window.setCursor("wait"); + let msgHdr = MailUtils.getMsgHdrForMsgId(messageId, startServer); + window.setCursor("auto"); + + // If message was found open corresponding message. + if (msgHdr) { + if (parent.location == "about:3pane") { + // Message in 3pane. + parent.selectMessage(msgHdr); + } else { + // Message in tab, standalone message window. + let uri = msgHdr.folder.getUriForMsg(msgHdr); + window.displayMessage(uri); + } + return; + } + let messageIdStr = "<" + messageId + ">"; + let bundle = document.getElementById("bundle_messenger"); + let errorTitle = bundle.getString("errorOpenMessageForMessageIdTitle"); + let errorMessage = bundle.getFormattedString( + "errorOpenMessageForMessageIdMessage", + [messageIdStr] + ); + Services.prompt.alert(window, errorTitle, errorMessage); +} + +/** + * @param headermode {Ci.nsMimeHeaderDisplayTypes} + */ +function AdjustHeaderView(headermode) { + const all = Ci.nsMimeHeaderDisplayTypes.AllHeaders; + document + .getElementById("messageHeader") + .setAttribute("show_header_mode", headermode == all ? "all" : "normal"); +} + +/** + * Should the reply command/button be enabled? + * + * @return whether the reply command/button should be enabled. + */ +function IsReplyEnabled() { + // If we're in an rss item, we never want to Reply, because there's + // usually no-one useful to reply to. + return !FeedUtils.isFeedMessage(gMessage); +} + +/** + * Should the reply-all command/button be enabled? + * + * @return whether the reply-all command/button should be enabled. + */ +function IsReplyAllEnabled() { + if (gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) { + // If we're in a news item, we always want ReplyAll, because we can + // reply to the sender and the newsgroup. + return true; + } + if (FeedUtils.isFeedMessage(gMessage)) { + // If we're in an rss item, we never want to ReplyAll, because there's + // usually no-one useful to reply to. + return false; + } + + let addresses = + gMessage.author + "," + gMessage.recipients + "," + gMessage.ccList; + + // If we've got any BCCed addresses (because we sent the message), add + // them as well. + if ("bcc" in currentHeaderData) { + addresses += currentHeaderData.bcc.headerValue; + } + + // Check to see if my email address is in the list of addresses. + let [myIdentity] = MailUtils.getIdentityForHeader(gMessage); + let myEmail = myIdentity ? myIdentity.email : null; + // We aren't guaranteed to have an email address, so guard against that. + let imInAddresses = + myEmail && addresses.toLowerCase().includes(myEmail.toLowerCase()); + + // Now, let's get the number of unique addresses. + let uniqueAddresses = MailServices.headerParser.removeDuplicateAddresses( + addresses, + "" + ); + let numAddresses = + MailServices.headerParser.parseEncodedHeader(uniqueAddresses).length; + + // I don't want to count my address in the number of addresses to reply + // to, since I won't be emailing myself. + if (imInAddresses) { + numAddresses--; + } + + // ReplyAll is enabled if there is more than 1 person to reply to. + return numAddresses > 1; +} + +/** + * Should the reply-list command/button be enabled? + * + * @return whether the reply-list command/button should be enabled. + */ +function IsReplyListEnabled() { + // ReplyToList is enabled if there is a List-Post header + // with the correct format. + let listPost = currentHeaderData["list-post"]; + if (!listPost) { + return false; + } + + // XXX: Once Bug 496914 provides a parser, we should use that instead. + // Until then, we need to keep the following regex in sync with the + // listPost parsing in nsMsgCompose.cpp's + // QuotingOutputStreamListener::OnStopRequest. + return /<mailto:.+>/.test(listPost.headerValue); +} + +/** + * Update the enabled/disabled states of the Reply, Reply-All, and + * Reply-List buttons. (After this function runs, one of the buttons + * should be shown, and the others should be hidden.) + */ +function UpdateReplyButtons() { + // If we have no message, because we're being called from + // MailToolboxCustomizeDone before someone selected a message, then just + // return. + if (!gMessage) { + return; + } + + let buttonToShow; + if (gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) { + // News messages always default to the "followup" dual-button. + buttonToShow = "followup"; + } else if (FeedUtils.isFeedMessage(gMessage)) { + // RSS items hide all the reply buttons. + buttonToShow = null; + } else if (IsReplyListEnabled()) { + // Mail messages show the "reply" button (not the dual-button) and + // possibly the "reply all" and "reply list" buttons. + buttonToShow = "replyList"; + } else if (IsReplyAllEnabled()) { + buttonToShow = "replyAll"; + } else { + buttonToShow = "reply"; + } + + let smartReplyButton = document.getElementById("hdrSmartReplyButton"); + if (smartReplyButton) { + let replyButton = document.getElementById("hdrReplyButton"); + let replyAllButton = document.getElementById("hdrReplyAllButton"); + let replyListButton = document.getElementById("hdrReplyListButton"); + let followupButton = document.getElementById("hdrFollowupButton"); + + replyButton.hidden = buttonToShow != "reply"; + replyAllButton.hidden = buttonToShow != "replyAll"; + replyListButton.hidden = buttonToShow != "replyList"; + followupButton.hidden = buttonToShow != "followup"; + } + + let replyToSenderButton = document.getElementById("hdrReplyToSenderButton"); + if (replyToSenderButton) { + if (FeedUtils.isFeedMessage(gMessage)) { + replyToSenderButton.hidden = true; + } else if (smartReplyButton) { + replyToSenderButton.hidden = buttonToShow == "reply"; + } else { + replyToSenderButton.hidden = false; + } + } + + // Run this method only after all the header toolbar buttons have been updated + // so we deal with the actual state. + headerToolbarNavigation.updateRovingTab(); +} + +/** + * Update the enabled/disabled states of the Reply, Reply-All, Reply-List, + * Followup, and Forward buttons based on the number of identities. + * If there are no identities, all of these buttons should be disabled. + */ +function updateComposeButtons() { + const hasIdentities = MailServices.accounts.allIdentities.length; + for (let id of [ + "hdrReplyButton", + "hdrReplyAllButton", + "hdrReplyListButton", + "hdrFollowupButton", + "hdrForwardButton", + "hdrReplyToSenderButton", + ]) { + document.getElementById(id).disabled = !hasIdentities; + } +} + +function SelectedMessagesAreJunk() { + try { + let junkScore = gMessage.getStringProperty("junkscore"); + return junkScore != "" && junkScore != "0"; + } catch (ex) { + return false; + } +} + +function SelectedMessagesAreRead() { + return gMessage?.isRead; +} + +function SelectedMessagesAreFlagged() { + return gMessage?.isFlagged; +} + +function MsgReplyMessage(event) { + if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) { + MsgReplyGroup(event); + } else { + MsgReplySender(event); + } +} + +function MsgReplySender(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToSender, event); +} + +function MsgReplyGroup(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToGroup, event); +} + +function MsgReplyToAllMessage(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyAll, event); +} + +function MsgReplyToListMessage(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToList, event); +} + +function MsgForwardMessage(event) { + var forwardType = Services.prefs.getIntPref("mail.forward_message_mode", 0); + + // mail.forward_message_mode could be 1, if the user migrated from 4.x + // 1 (forward as quoted) is obsolete, so we treat is as forward inline + // since that is more like forward as quoted then forward as attachment + if (forwardType == 0) { + MsgForwardAsAttachment(event); + } else { + MsgForwardAsInline(event); + } +} + +function MsgForwardAsAttachment(event) { + commandController._composeMsgByType( + Ci.nsIMsgCompType.ForwardAsAttachment, + event + ); +} + +function MsgForwardAsInline(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.ForwardInline, event); +} + +function MsgRedirectMessage(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.Redirect, event); +} + +function MsgEditMessageAsNew(aEvent) { + commandController._composeMsgByType(Ci.nsIMsgCompType.EditAsNew, aEvent); +} + +function MsgEditDraftMessage(aEvent) { + commandController._composeMsgByType(Ci.nsIMsgCompType.Draft, aEvent); +} + +function MsgNewMessageFromTemplate(aEvent) { + commandController._composeMsgByType(Ci.nsIMsgCompType.Template, aEvent); +} + +function MsgEditTemplateMessage(aEvent) { + commandController._composeMsgByType(Ci.nsIMsgCompType.EditTemplate, aEvent); +} + +function MsgComposeDraftMessage() { + top.ComposeMessage( + Ci.nsIMsgCompType.Draft, + Ci.nsIMsgCompFormat.Default, + gFolder, + [gMessageURI] + ); +} + +/** + * Update the "archive", "junk" and "delete" buttons in the message header area. + */ +function updateHeaderToolbarButtons() { + let isDummyMessage = !gViewWrapper.isSynthetic && !gMessage.folder; + let archiveButton = document.getElementById("hdrArchiveButton"); + let junkButton = document.getElementById("hdrJunkButton"); + let trashButton = document.getElementById("hdrTrashButton"); + + if (isDummyMessage) { + archiveButton.disabled = true; + junkButton.disabled = true; + trashButton.disabled = true; + return; + } + + archiveButton.disabled = !MessageArchiver.canArchive([gMessage]); + let junkScore = gMessage.getStringProperty("junkscore"); + let hideJunk = junkScore == Ci.nsIJunkMailPlugin.IS_SPAM_SCORE; + if (!commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.junk)) { + hideJunk = true; + } + junkButton.disabled = hideJunk; + trashButton.disabled = false; +} + +/** + * Checks if the selected messages can be marked as read or unread + * + * @param markingRead true if trying to mark messages as read, false otherwise + * @return true if the chosen operation can be performed + */ +function CanMarkMsgAsRead(markingRead) { + return gMessage && SelectedMessagesAreRead() != markingRead; +} + +/** + * Marks the selected messages as read or unread + * + * @param read true if trying to mark messages as read, false if marking unread, + * undefined if toggling the read status + */ +function MsgMarkMsgAsRead(read) { + if (read == undefined) { + read = !gMessage.isRead; + } + MarkSelectedMessagesRead(read); +} + +function MsgMarkAsFlagged() { + MarkSelectedMessagesFlagged(!SelectedMessagesAreFlagged()); +} + +/** + * Extract email data and prefill the event/task dialog with that data. + */ +function convertToEventOrTask(isTask = false) { + window.top.calendarExtract.extractFromEmail(gMessage, isTask); +} + +/** + * Triggered by the onHdrPropertyChanged notification for a single message being + * displayed. We handle updating the message display if our displayed message + * might have had its junk status change. This primarily entails updating the + * notification bar (that thing that appears above the message and says "this + * message might be junk") and (potentially) reloading the message because junk + * status affects the form of HTML display used (sanitized vs not). + * When our tab implementation is no longer multiplexed (reusing the same + * display widget), this must be moved into the MessageDisplayWidget or + * otherwise be scoped to the tab. + * + * @param {nsIMsgHdr} msgHdr - The nsIMsgHdr of the message with a junk status change. + */ +function HandleJunkStatusChanged(msgHdr) { + if (!msgHdr || !msgHdr.folder) { + return; + } + + let junkBarStatus = gMessageNotificationBar.checkJunkMsgStatus(msgHdr); + + // Only reload message if junk bar display state is changing and only if the + // reload is really needed. + if (junkBarStatus != 0) { + // We may be forcing junk mail to be rendered with sanitized html. + // In that scenario, we want to reload the message if the status has just + // changed to not junk. + var sanitizeJunkMail = Services.prefs.getBoolPref( + "mail.spam.display.sanitize" + ); + + // Only bother doing this if we are modifying the html for junk mail.... + if (sanitizeJunkMail) { + let junkScore = msgHdr.getStringProperty("junkscore"); + let isJunk = junkScore == Ci.nsIJunkMailPlugin.IS_SPAM_SCORE; + + // If the current row isn't going to change, reload to show sanitized or + // unsanitized. Otherwise we wouldn't see the reloaded version anyway. + // 1) When marking as non-junk from the Junk folder, the msg would move + // back to the Inbox -> no reload needed + // When marking as non-junk from a folder other than the Junk folder, + // the message isn't moved back to Inbox -> reload needed + // (see nsMsgDBView::DetermineActionsForJunkChange) + // 2) When marking as junk, the msg will move or delete, if manualMark is set. + // 3) Marking as junk in the junk folder just changes the junk status. + if ( + (!isJunk && !msgHdr.folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk)) || + (isJunk && !msgHdr.folder.server.spamSettings.manualMark) || + (isJunk && msgHdr.folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk)) + ) { + ReloadMessage(); + return; + } + } + } + + gMessageNotificationBar.setJunkMsg(msgHdr); +} + +/** + * Object to handle message related notifications that are showing in a + * notificationbox above the message content. + */ +var gMessageNotificationBar = { + get stringBundle() { + delete this.stringBundle; + return (this.stringBundle = document.getElementById("bundle_messenger")); + }, + + get brandBundle() { + delete this.brandBundle; + return (this.brandBundle = document.getElementById("bundle_brand")); + }, + + get msgNotificationBar() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "top"); + document.getElementById("mail-notification-top").append(element); + }); + } + return this._notificationBox; + }, + + /** + * Check if the current status of the junk notification is correct or not. + * + * @param {nsIMsgDBHdr} aMsgHdr - Information about the message + * @returns {integer} Tri-state status information. + * 1: notification is missing + * 0: notification is correct + * -1: notification must be removed + */ + checkJunkMsgStatus(aMsgHdr) { + let junkScore = aMsgHdr ? aMsgHdr.getStringProperty("junkscore") : ""; + let junkStatus = this.isShowingJunkNotification(); + + if (junkScore == "" || junkScore == Ci.nsIJunkMailPlugin.IS_HAM_SCORE) { + // This is not junk. The notification should not be shown. + return junkStatus ? -1 : 0; + } + + // This is junk. The notification should be shown. + return junkStatus ? 0 : 1; + }, + + setJunkMsg(aMsgHdr) { + goUpdateCommand("cmd_junk"); + + let junkBarStatus = this.checkJunkMsgStatus(aMsgHdr); + if (junkBarStatus == -1) { + this.msgNotificationBar.removeNotification( + this.msgNotificationBar.getNotificationWithValue("junkContent"), + true + ); + } else if (junkBarStatus == 1) { + let brandName = this.brandBundle.getString("brandShortName"); + let junkBarMsg = this.stringBundle.getFormattedString("junkBarMessage", [ + brandName, + ]); + + let buttons = [ + { + label: this.stringBundle.getString("junkBarInfoButton"), + accessKey: this.stringBundle.getString("junkBarInfoButtonKey"), + popup: null, + callback(aNotification, aButton) { + // TODO: This doesn't work in a message window. + top.openContentTab( + "https://support.mozilla.org/kb/thunderbird-and-junk-spam-messages" + ); + return true; // keep notification open + }, + }, + { + label: this.stringBundle.getString("junkBarButton"), + accessKey: this.stringBundle.getString("junkBarButtonKey"), + popup: null, + callback(aNotification, aButton) { + commandController.doCommand("cmd_markAsNotJunk"); + // Return true (=don't close) since changing junk status will fire a + // JunkStatusChanged notification which will make the junk bar go away + // for this message -> no notification to close anymore -> trying to + // close would just fail. + return true; + }, + }, + ]; + + this.msgNotificationBar.appendNotification( + "junkContent", + { + label: junkBarMsg, + image: "chrome://messenger/skin/icons/junk.svg", + priority: this.msgNotificationBar.PRIORITY_WARNING_HIGH, + }, + buttons + ); + } + }, + + isShowingJunkNotification() { + return !!this.msgNotificationBar.getNotificationWithValue("junkContent"); + }, + + setRemoteContentMsg(aMsgHdr, aContentURI, aCanOverride) { + // update the allow remote content for sender string + let brandName = this.brandBundle.getString("brandShortName"); + let remoteContentMsg = this.stringBundle.getFormattedString( + "remoteContentBarMessage", + [brandName] + ); + + let buttonLabel = this.stringBundle.getString( + AppConstants.platform == "win" + ? "remoteContentPrefLabel" + : "remoteContentPrefLabelUnix" + ); + let buttonAccesskey = this.stringBundle.getString( + AppConstants.platform == "win" + ? "remoteContentPrefAccesskey" + : "remoteContentPrefAccesskeyUnix" + ); + + let buttons = [ + { + label: buttonLabel, + accessKey: buttonAccesskey, + popup: "remoteContentOptions", + callback() {}, + }, + ]; + + // The popup value is a space separated list of all the blocked origins. + let popup = document.getElementById("remoteContentOptions"); + let principal = Services.scriptSecurityManager.createContentPrincipal( + aContentURI, + {} + ); + let origins = popup.value ? popup.value.split(" ") : []; + if (!origins.includes(principal.origin)) { + origins.push(principal.origin); + } + popup.value = origins.join(" "); + + if (!this.isShowingRemoteContentNotification()) { + let notification = this.msgNotificationBar.appendNotification( + "remoteContent", + { + label: remoteContentMsg, + image: "chrome://messenger/skin/icons/remote-blocked.svg", + priority: this.msgNotificationBar.PRIORITY_WARNING_MEDIUM, + }, + aCanOverride ? buttons : [] + ); + + notification.buttonContainer.firstElementChild.classList.add( + "button-menu-list" + ); + } + }, + + isShowingRemoteContentNotification() { + return !!this.msgNotificationBar.getNotificationWithValue("remoteContent"); + }, + + setPhishingMsg() { + let phishingMsgNote = this.stringBundle.getString("phishingBarMessage"); + + let buttonLabel = this.stringBundle.getString( + AppConstants.platform == "win" + ? "phishingBarPrefLabel" + : "phishingBarPrefLabelUnix" + ); + let buttonAccesskey = this.stringBundle.getString( + AppConstants.platform == "win" + ? "phishingBarPrefAccesskey" + : "phishingBarPrefAccesskeyUnix" + ); + + let buttons = [ + { + label: buttonLabel, + accessKey: buttonAccesskey, + popup: "phishingOptions", + callback(aNotification, aButton) {}, + }, + ]; + + if (!this.isShowingPhishingNotification()) { + let notification = this.msgNotificationBar.appendNotification( + "maybeScam", + { + label: phishingMsgNote, + image: "chrome://messenger/skin/icons/phishing.svg", + priority: this.msgNotificationBar.PRIORITY_CRITICAL_MEDIUM, + }, + buttons + ); + + notification.buttonContainer.firstElementChild.classList.add( + "button-menu-list" + ); + } + }, + + isShowingPhishingNotification() { + return !!this.msgNotificationBar.getNotificationWithValue("maybeScam"); + }, + + setMDNMsg(aMdnGenerator, aMsgHeader, aMimeHdr) { + this.mdnGenerator = aMdnGenerator; + // Return receipts can be RFC 3798 or not. + let mdnHdr = + aMimeHdr.extractHeader("Disposition-Notification-To", false) || + aMimeHdr.extractHeader("Return-Receipt-To", false); // not + let fromHdr = aMimeHdr.extractHeader("From", false); + + let mdnAddr = + MailServices.headerParser.extractHeaderAddressMailboxes(mdnHdr); + let fromAddr = + MailServices.headerParser.extractHeaderAddressMailboxes(fromHdr); + + let authorName = + MailServices.headerParser.extractFirstName( + aMsgHeader.mime2DecodedAuthor + ) || aMsgHeader.author; + + // If the return receipt doesn't go to the sender address, note that in the + // notification. + let mdnBarMsg = + mdnAddr != fromAddr + ? this.stringBundle.getFormattedString("mdnBarMessageAddressDiffers", [ + authorName, + mdnAddr, + ]) + : this.stringBundle.getFormattedString("mdnBarMessageNormal", [ + authorName, + ]); + + let buttons = [ + { + label: this.stringBundle.getString("mdnBarSendReqButton"), + accessKey: this.stringBundle.getString("mdnBarSendReqButtonKey"), + popup: null, + callback(aNotification, aButton) { + SendMDNResponse(); + return false; // close notification + }, + }, + { + label: this.stringBundle.getString("mdnBarIgnoreButton"), + accessKey: this.stringBundle.getString("mdnBarIgnoreButtonKey"), + popup: null, + callback(aNotification, aButton) { + IgnoreMDNResponse(); + return false; // close notification + }, + }, + ]; + + this.msgNotificationBar.appendNotification( + "mdnRequested", + { + label: mdnBarMsg, + priority: this.msgNotificationBar.PRIORITY_INFO_MEDIUM, + }, + buttons + ); + }, + + setDraftEditMessage() { + if (!gMessage || !gFolder) { + return; + } + + if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Drafts, true)) { + let draftMsgNote = this.stringBundle.getString("draftMessageMsg"); + + let buttons = [ + { + label: this.stringBundle.getString("draftMessageButton"), + accessKey: this.stringBundle.getString("draftMessageButtonKey"), + popup: null, + callback(aNotification, aButton) { + MsgComposeDraftMessage(); + return true; // keep notification open + }, + }, + ]; + + this.msgNotificationBar.appendNotification( + "draftMsgContent", + { + label: draftMsgNote, + priority: this.msgNotificationBar.PRIORITY_INFO_HIGH, + }, + buttons + ); + } + }, + + clearMsgNotifications() { + this.msgNotificationBar.removeAllNotifications(true); + }, +}; + +/** + * LoadMsgWithRemoteContent + * Reload the current message, allowing remote content + */ +function LoadMsgWithRemoteContent() { + // we want to get the msg hdr for the currently selected message + // change the "remoteContentBar" property on it + // then reload the message + + setMsgHdrPropertyAndReload("remoteContentPolicy", kAllowRemoteContent); + window.content?.focus(); +} + +/** + * Populate the remote content options for the current message. + */ +function onRemoteContentOptionsShowing(aEvent) { + let origins = aEvent.target.value ? aEvent.target.value.split(" ") : []; + + let addresses = MailServices.headerParser.parseEncodedHeader(gMessage.author); + addresses = addresses.slice(0, 1); + // If there is an author's email, put it also in the menu. + let adrCount = addresses.length; + if (adrCount > 0) { + let authorEmailAddress = addresses[0].email; + let authorEmailAddressURI = Services.io.newURI( + "chrome://messenger/content/email=" + authorEmailAddress + ); + let mailPrincipal = Services.scriptSecurityManager.createContentPrincipal( + authorEmailAddressURI, + {} + ); + origins.push(mailPrincipal.origin); + } + + let messengerBundle = document.getElementById("bundle_messenger"); + + // Out with the old... + let children = aEvent.target.children; + for (let i = children.length - 1; i >= 0; i--) { + if (children[i].getAttribute("class") == "allow-remote-uri") { + children[i].remove(); + } + } + + let urlSepar = document.getElementById("remoteContentAllMenuSeparator"); + + // ... and in with the new. + for (let origin of origins) { + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute( + "label", + messengerBundle.getFormattedString("remoteAllowResource", [ + origin.replace("chrome://messenger/content/email=", ""), + ]) + ); + menuitem.setAttribute("value", origin); + menuitem.setAttribute("class", "allow-remote-uri"); + menuitem.setAttribute("oncommand", "allowRemoteContentForURI(this.value);"); + if (origin.startsWith("chrome://messenger/content/email=")) { + aEvent.target.appendChild(menuitem); + } else { + aEvent.target.insertBefore(menuitem, urlSepar); + } + } + + let URLcount = origins.length - adrCount; + let allowAllItem = document.getElementById("remoteContentOptionAllowAll"); + let allURLLabel = messengerBundle.getString("remoteAllowAll"); + allowAllItem.label = PluralForm.get(URLcount, allURLLabel).replace( + "#1", + URLcount + ); + + allowAllItem.collapsed = URLcount < 2; + document.getElementById("remoteContentOriginsMenuSeparator").collapsed = + urlSepar.collapsed = allowAllItem.collapsed && adrCount == 0; +} + +/** + * Add privileges to display remote content for the given uri. + * + * @param aUriSpec |String| uri for the site to add permissions for. + * @param aReload Reload the message display after allowing the URI. + */ +function allowRemoteContentForURI(aUriSpec, aReload = true) { + let uri = Services.io.newURI(aUriSpec); + Services.perms.addFromPrincipal( + Services.scriptSecurityManager.createContentPrincipal(uri, {}), + "image", + Services.perms.ALLOW_ACTION + ); + if (aReload) { + ReloadMessage(); + } +} + +/** + * Add privileges to display remote content for the given uri. + * + * @param aListNode The menulist element containing the URIs to allow. + */ +function allowRemoteContentForAll(aListNode) { + let uriNodes = aListNode.querySelectorAll(".allow-remote-uri"); + for (let uriNode of uriNodes) { + if (!uriNode.value.startsWith("chrome://messenger/content/email=")) { + allowRemoteContentForURI(uriNode.value, false); + } + } + ReloadMessage(); +} + +/** + * Displays fine-grained, per-site preferences for remote content. + */ +function editRemoteContentSettings() { + top.openOptionsDialog("panePrivacy", "privacyCategory"); +} + +/** + * Set the msg hdr flag to ignore the phishing warning and reload the message. + */ +function IgnorePhishingWarning() { + // This property should really be called skipPhishingWarning or something + // like that, but it's too late to change that now. + // This property is used to suppress the phishing bar for the message. + setMsgHdrPropertyAndReload("notAPhishMessage", 1); +} + +/** + * Open the preferences dialog to allow disabling the scam feature. + */ +function OpenPhishingSettings() { + top.openOptionsDialog("panePrivacy", "privacySecurityCategory"); +} + +function setMsgHdrPropertyAndReload(aProperty, aValue) { + // we want to get the msg hdr for the currently selected message + // change the appropriate property on it then reload the message + if (gMessage) { + gMessage.setUint32Property(aProperty, aValue); + ReloadMessage(); + } +} + +/** + * Mark a specified message as read. + * @param msgHdr header (nsIMsgDBHdr) of the message to mark as read + */ +function MarkMessageAsRead(msgHdr) { + ClearPendingReadTimer(); + msgHdr.folder.markMessagesRead([msgHdr], true); + reportMsgRead({ isNewRead: true }); +} + +function ClearPendingReadTimer() { + if (gMarkViewedMessageAsReadTimer) { + clearTimeout(gMarkViewedMessageAsReadTimer); + gMarkViewedMessageAsReadTimer = null; + } +} + +// this is called when layout is actually finished rendering a +// mail message. OnMsgLoaded is called when libmime is done parsing the message +function OnMsgParsed(aUrl) { + // browser doesn't do this, but I thought it could be a useful thing to test out... + // If the find bar is visible and we just loaded a new message, re-run + // the find command. This means the new message will get highlighted and + // we'll scroll to the first word in the message that matches the find text. + var findBar = document.getElementById("FindToolbar"); + if (!findBar.hidden) { + findBar.onFindAgainCommand(false); + } + + let browser = getMessagePaneBrowser(); + // Run the phishing detector on the message if it hasn't been marked as not + // a scam already. + if ( + gMessage && + !gMessage.getUint32Property("notAPhishMessage") && + PhishingDetector.analyzeMsgForPhishingURLs(aUrl, browser) + ) { + gMessageNotificationBar.setPhishingMsg(); + } + + // Notify anyone (e.g., extensions) who's interested in when a message is loaded. + Services.obs.notifyObservers(null, "MsgMsgDisplayed", gMessageURI); + + let doc = browser && browser.contentDocument ? browser.contentDocument : null; + + // Rewrite any anchor elements' href attribute to reflect that the loaded + // document is a mailnews url. This will cause docShell to scroll to the + // element in the document rather than opening the link externally. + let links = doc && doc.links ? doc.links : []; + for (let linkNode of links) { + if (!linkNode.hash) { + continue; + } + + // We have a ref fragment which may reference a node in this document. + // Ensure html in mail anchors work as expected. + let anchorId = linkNode.hash.replace("#", ""); + // Continue if an id (html5) or name attribute value for the ref is not + // found in this document. + let selector = "#" + anchorId + ", [name='" + anchorId + "']"; + try { + if (!linkNode.ownerDocument.querySelector(selector)) { + continue; + } + } catch (ex) { + continue; + } + + // Then check if the href url matches the document baseURL. + if ( + makeURI(linkNode.href).specIgnoringRef != + makeURI(linkNode.baseURI).specIgnoringRef + ) { + continue; + } + + // Finally, if the document url is a message url, and the anchor href is + // http, it needs to be adjusted so docShell finds the node. + let messageURI = makeURI(linkNode.ownerDocument.URL); + if ( + messageURI instanceof Ci.nsIMsgMailNewsUrl && + linkNode.href.startsWith("http") + ) { + linkNode.href = messageURI.specIgnoringRef + linkNode.hash; + } + } + + // Scale any overflowing images, exclude http content. + let imgs = doc && !doc.URL.startsWith("http") ? doc.images : []; + for (let img of imgs) { + if ( + img.clientWidth - doc.body.offsetWidth >= 0 && + (img.clientWidth <= img.naturalWidth || !img.naturalWidth) + ) { + img.setAttribute("overflowing", "true"); + } + + // This is the default case for images when a message is loaded. + img.setAttribute("shrinktofit", "true"); + } +} + +function OnMsgLoaded(aUrl) { + if (!aUrl) { + return; + } + + window.msgLoaded = true; + window.dispatchEvent( + new CustomEvent("MsgLoaded", { detail: gMessage, bubbles: true }) + ); + window.dispatchEvent( + new CustomEvent("MsgsLoaded", { detail: [gMessage], bubbles: true }) + ); + + if (!gFolder) { + return; + } + + gMessageNotificationBar.setJunkMsg(gMessage); + + // See if MDN was requested but has not been sent. + HandleMDNResponse(aUrl); +} + +/** + * Marks the message as read, optionally after a delay, if the preferences say + * we should do so. + */ +function autoMarkAsRead() { + if (!gMessage?.folder) { + // The message can't be marked read or unread. + return; + } + + if (document.hidden) { + // We're in an inactive docShell (probably a background tab). Wait until + // it becomes active before marking the message as read. + document.addEventListener("visibilitychange", () => autoMarkAsRead(), { + once: true, + }); + return; + } + + let markReadAutoMode = Services.prefs.getBoolPref( + "mailnews.mark_message_read.auto" + ); + + // We just finished loading a message. If messages are to be marked as read + // automatically, set a timer to mark the message is read after n seconds + // where n can be configured by the user. + if (!gMessage.isRead && markReadAutoMode) { + let markReadOnADelay = Services.prefs.getBoolPref( + "mailnews.mark_message_read.delay" + ); + + let winType = top.document.documentElement.getAttribute("windowtype"); + // Only use the timer if viewing using the 3-pane preview pane and the + // user has set the pref. + if (markReadOnADelay && winType == "mail:3pane") { + // 3-pane window + ClearPendingReadTimer(); + let markReadDelayTime = Services.prefs.getIntPref( + "mailnews.mark_message_read.delay.interval" + ); + if (markReadDelayTime == 0) { + MarkMessageAsRead(gMessage); + } else { + gMarkViewedMessageAsReadTimer = setTimeout( + MarkMessageAsRead, + markReadDelayTime * 1000, + gMessage + ); + } + } else { + // standalone msg window + MarkMessageAsRead(gMessage); + } + } +} + +/** + * This function handles all mdn response generation (ie, imap and pop). + * For pop the msg uid can be 0 (ie, 1st msg in a local folder) so no + * need to check uid here. No one seems to set mimeHeaders to null so + * no need to check it either. + */ +function HandleMDNResponse(aUrl) { + if (!aUrl) { + return; + } + + var msgFolder = aUrl.folder; + if ( + !msgFolder || + !gMessage || + gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false) + ) { + return; + } + + // if the message is marked as junk, do NOT attempt to process a return receipt + // in order to better protect the user + if (SelectedMessagesAreJunk()) { + return; + } + + var mimeHdr; + + try { + mimeHdr = aUrl.mimeHeaders; + } catch (ex) { + return; + } + + // If we didn't get the message id when we downloaded the message header, + // we cons up an md5: message id. If we've done that, we'll try to extract + // the message id out of the mime headers for the whole message. + let msgId = gMessage.messageId; + if (msgId.startsWith("md5:")) { + var mimeMsgId = mimeHdr.extractHeader("Message-Id", false); + if (mimeMsgId) { + gMessage.messageId = mimeMsgId; + } + } + + // After a msg is downloaded it's already marked READ at this point so we must check if + // the msg has a "Disposition-Notification-To" header and no MDN report has been sent yet. + if (gMessage.flags & Ci.nsMsgMessageFlags.MDNReportSent) { + return; + } + + var DNTHeader = mimeHdr.extractHeader("Disposition-Notification-To", false); + var oldDNTHeader = mimeHdr.extractHeader("Return-Receipt-To", false); + if (!DNTHeader && !oldDNTHeader) { + return; + } + + // Everything looks good so far, let's generate the MDN response. + var mdnGenerator = Cc[ + "@mozilla.org/messenger-mdn/generator;1" + ].createInstance(Ci.nsIMsgMdnGenerator); + const MDN_DISPOSE_TYPE_DISPLAYED = 0; + let askUser = mdnGenerator.process( + MDN_DISPOSE_TYPE_DISPLAYED, + top.msgWindow, + msgFolder, + gMessage.messageKey, + mimeHdr, + false + ); + if (askUser) { + gMessageNotificationBar.setMDNMsg(mdnGenerator, gMessage, mimeHdr); + } +} + +function SendMDNResponse() { + gMessageNotificationBar.mdnGenerator.userAgreed(); +} + +function IgnoreMDNResponse() { + gMessageNotificationBar.mdnGenerator.userDeclined(); +} + +// An object to help collecting reading statistics of secure emails. +var gSecureMsgProbe = {}; + +/** + * Update gSecureMsgProbe and report to telemetry if necessary. + */ +function reportMsgRead({ isNewRead = false, key = null }) { + if (isNewRead) { + gSecureMsgProbe.isNewRead = true; + } + if (key) { + gSecureMsgProbe.key = key; + } + if (gSecureMsgProbe.key && gSecureMsgProbe.isNewRead) { + Services.telemetry.keyedScalarAdd( + "tb.mails.read_secure", + gSecureMsgProbe.key, + 1 + ); + } +} + +window.addEventListener("secureMsgLoaded", event => { + reportMsgRead({ key: event.detail.key }); +}); + +/** + * Roving tab navigation for the header buttons. + */ +var headerToolbarNavigation = { + /** + * Get all currently visible buttons of the message header toolbar. + * + * @returns {Array} An array of buttons. + */ + get headerButtons() { + return this.headerToolbar.querySelectorAll( + `toolbarbutton:not([hidden="true"],[is="toolbarbutton-menu-button"]),toolbaritem[id="hdrSmartReplyButton"]>toolbarbutton:not([hidden="true"])>dropmarker, button:not([hidden])` + ); + }, + + init() { + this.headerToolbar = document.getElementById("header-view-toolbar"); + this.headerToolbar.addEventListener("keypress", event => { + this.triggerMessageHeaderRovingTab(event); + }); + }, + + /** + * Update the `tabindex` attribute of the currently visible buttons. + */ + updateRovingTab() { + for (let button of this.headerButtons) { + button.tabIndex = -1; + } + // Allow focus on the first available button. + // We use `setAttribute` to guarantee compatibility with XUL toolbarbuttons. + this.headerButtons[0].setAttribute("tabindex", "0"); + }, + + /** + * Handles the keypress event on the message header toolbar. + * + * @param {Event} event - The keypress DOMEvent. + */ + triggerMessageHeaderRovingTab(event) { + // Expected keyboard actions are Left, Right, Home, End, Space, and Enter. + if ( + !["ArrowRight", "ArrowLeft", "Home", "End", " ", "Enter"].includes( + event.key + ) + ) { + return; + } + + const headerButtons = [...this.headerButtons]; + let focusableButton = headerButtons.find(b => b.tabIndex != -1); + let elementIndex = headerButtons.indexOf(focusableButton); + + // TODO: Remove once the buttons are updated to not be XUL + // NOTE: Normally a button click handler would cover Enter and Space key + // events. However, we need to prevent the default behavior and explicitly + // trigger the button click because the XUL toolbarbuttons do not work when + // the Enter key is pressed. They do work when the Space key is pressed. + // However, if the toolbarbutton is a dropdown menu, the Space key + // does not open the menu. + if ( + event.key == "Enter" || + (event.key == " " && event.target.hasAttribute("type")) + ) { + if ( + event.target.getAttribute("class") == + "toolbarbutton-menubutton-dropmarker" + ) { + event.preventDefault(); + event.target.parentNode + .querySelector("menupopup") + .openPopup(event.target.parentNode, "after_end", { + triggerEvent: event, + }); + } else { + event.preventDefault(); + event.target.click(); + return; + } + } + + // Find the adjacent focusable element based on the pressed key. + if ( + (document.dir == "rtl" && event.key == "ArrowLeft") || + (document.dir == "ltr" && event.key == "ArrowRight") + ) { + elementIndex++; + if (elementIndex > headerButtons.length - 1) { + elementIndex = 0; + } + } else if ( + (document.dir == "ltr" && event.key == "ArrowLeft") || + (document.dir == "rtl" && event.key == "ArrowRight") + ) { + elementIndex--; + if (elementIndex == -1) { + elementIndex = headerButtons.length - 1; + } + } + + // Move the focus to a new toolbar button and update the tabindex attribute. + let newFocusableButton = headerButtons[elementIndex]; + if (newFocusableButton) { + focusableButton.tabIndex = -1; + newFocusableButton.setAttribute("tabindex", "0"); + newFocusableButton.focus(); + } + }, +}; |