/* 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/. */ const EXPORTED_SYMBOLS = ["MailNotificationManager"]; const { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); const { MailServices } = ChromeUtils.import( "resource:///modules/MailServices.jsm" ); const { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); const lazy = {}; XPCOMUtils.defineLazyModuleGetters(lazy, { MailUtils: "resource:///modules/MailUtils.jsm", WinUnreadBadge: "resource:///modules/WinUnreadBadge.jsm", }); XPCOMUtils.defineLazyGetter( lazy, "l10n", () => new Localization(["messenger/messenger.ftl"]) ); /** * A module that listens to folder change events, and show notifications for new * mails if necessary. */ class MailNotificationManager { QueryInterface = ChromeUtils.generateQI([ "nsIObserver", "nsIFolderListener", "mozINewMailListener", ]); constructor() { this._systemAlertAvailable = true; this._unreadChatCount = 0; this._unreadMailCount = 0; // @type {Map} - A map of folder and its last biff time. this._folderBiffTime = new Map(); // @type {Set} - A set of folders to show alert for. this._pendingFolders = new Set(); this._logger = console.createInstance({ prefix: "mail.notification", maxLogLevel: "Warn", maxLogLevelPref: "mail.notification.loglevel", }); this._bundle = Services.strings.createBundle( "chrome://messenger/locale/messenger.properties" ); MailServices.mailSession.AddFolderListener( this, Ci.nsIFolderListener.intPropertyChanged ); // Ensure that OS integration is defined before we attempt to initialize the // system tray icon. XPCOMUtils.defineLazyGetter(this, "_osIntegration", () => { try { return Cc["@mozilla.org/messenger/osintegration;1"].getService( Ci.nsIMessengerOSIntegration ); } catch (e) { // We don't have OS integration on all platforms. return null; } }); if (["macosx", "win"].includes(AppConstants.platform)) { // We don't have indicator for unread count on Linux yet. Cc["@mozilla.org/newMailNotificationService;1"] .getService(Ci.mozINewMailNotificationService) .addListener(this, Ci.mozINewMailNotificationService.count); Services.obs.addObserver(this, "unread-im-count-changed"); Services.obs.addObserver(this, "profile-before-change"); } if (AppConstants.platform == "macosx") { Services.obs.addObserver(this, "new-directed-incoming-message"); } if (AppConstants.platform == "win") { Services.obs.addObserver(this, "windows-refresh-badge-tray"); Services.prefs.addObserver("mail.biff.show_badge", this); Services.prefs.addObserver("mail.biff.show_tray_icon_always", this); } } observe(subject, topic, data) { switch (topic) { case "alertclickcallback": // Display the associated message when an alert is clicked. let msgHdr = Cc["@mozilla.org/messenger;1"] .getService(Ci.nsIMessenger) .msgHdrFromURI(data); lazy.MailUtils.displayMessageInFolderTab(msgHdr, true); return; case "unread-im-count-changed": this._logger.log( `Unread chat count changed to ${this._unreadChatCount}` ); this._unreadChatCount = parseInt(data, 10) || 0; this._updateUnreadCount(); return; case "new-directed-incoming-messenger": this._animateDockIcon(); return; case "windows-refresh-badge-tray": this._updateUnreadCount(); return; case "profile-before-change": this._osIntegration?.onExit(); return; case "newmailalert-closed": // newmailalert.xhtml is closed, try to show the next queued folder. this._customizedAlertShown = false; this._showCustomizedAlert(); return; case "nsPref:changed": if ( data == "mail.biff.show_badge" || data == "mail.biff.show_tray_icon_always" ) { this._updateUnreadCount(); } } } /** * Following are nsIFolderListener interfaces. Do nothing about them. */ onFolderAdded() {} onMessageAdded() {} onFolderRemoved() {} onMessageRemoved() {} onFolderPropertyChanged() {} /** * The only nsIFolderListener interface we care about. * * @see nsIFolderListener */ onFolderIntPropertyChanged(folder, property, oldValue, newValue) { if (!Services.prefs.getBoolPref("mail.biff.show_alert")) { return; } this._logger.debug( `onFolderIntPropertyChanged; property=${property}: ${oldValue} => ${newValue}, folder.URI=${folder.URI}` ); switch (property) { case "BiffState": if (newValue == Ci.nsIMsgFolder.nsMsgBiffState_NewMail) { // The folder argument is a root folder. this._fillAlertInfo(folder); } break; case "NewMailReceived": // The folder argument is a real folder. this._fillAlertInfo(folder); break; } } onFolderBoolPropertyChanged() {} onFolderUnicharPropertyChanged() {} onFolderPropertyFlagChanged() {} onFolderEvent() {} /** * @see mozINewMailNotificationService */ onCountChanged(count) { this._logger.log(`Unread mail count changed to ${count}`); this._unreadMailCount = count; this._updateUnreadCount(); } /** * Show an alert according to the changed folder. * * @param {nsIMsgFolder} changedFolder - The folder that emitted the change * event, can be a root folder or a real folder. */ async _fillAlertInfo(changedFolder) { let folder = this._getFirstRealFolderWithNewMail(changedFolder); if (!folder) { return; } let newMsgKeys = this._getNewMsgKeysNotNotified(folder); let numNewMessages = newMsgKeys.length; if (!numNewMessages) { return; } this._logger.debug( `Filling alert info; folder.URI=${folder.URI}, numNewMessages=${numNewMessages}` ); let firstNewMsgHdr = folder.msgDatabase.getMsgHdrForKey(newMsgKeys[0]); let title = this._getAlertTitle(folder, numNewMessages); let body; try { body = await this._getAlertBody(folder, firstNewMsgHdr); } catch (e) { this._logger.error(e); } if (!title || !body) { return; } this._showAlert(firstNewMsgHdr, title, body); this._animateDockIcon(); } /** * Iterate the subfolders of changedFolder, return the first real folder with * new mail. * * @param {nsIMsgFolder} changedFolder - The folder that emitted the change event. * @returns {nsIMsgFolder} The first real folder. */ _getFirstRealFolderWithNewMail(changedFolder) { let folders = changedFolder.descendants; folders.unshift(changedFolder); for (let folder of folders) { let flags = folder.flags; if ( !(flags & Ci.nsMsgFolderFlags.Inbox) && flags & (Ci.nsMsgFolderFlags.SpecialUse | Ci.nsMsgFolderFlags.Virtual) ) { // Do not notify if the folder is not Inbox but one of // Drafts|Trash|SentMail|Templates|Junk|Archive|Queue or Virtual. continue; } if (folder.getNumNewMessages(false) > 0) { return folder; } } return null; } /** * Get the title for the alert. * * @param {nsIMsgFolder} folder - The changed folder. * @param {number} numNewMessages - The count of new messages. * @returns {string} The alert title. */ _getAlertTitle(folder, numNewMessages) { return this._bundle.formatStringFromName( numNewMessages == 1 ? "newMailNotification_message" : "newMailNotification_messages", [folder.server.prettyName, numNewMessages.toString()] ); } /** * Get the body for the alert. * * @param {nsIMsgFolder} folder - The changed folder. * @param {nsIMsgHdr} msgHdr - The nsIMsgHdr of the first new messages. * @returns {string} The alert body. */ async _getAlertBody(folder, msgHdr) { await new Promise((resolve, reject) => { let isAsync = folder.fetchMsgPreviewText([msgHdr.messageKey], { OnStartRunningUrl() {}, // @see nsIUrlListener OnStopRunningUrl(url, exitCode) { Components.isSuccessCode(exitCode) ? resolve() : reject(); }, }); if (!isAsync) { resolve(); } }); let alertBody = ""; let subject = Services.prefs.getBoolPref("mail.biff.alert.show_subject") ? msgHdr.mime2DecodedSubject : ""; let author = ""; if (Services.prefs.getBoolPref("mail.biff.alert.show_sender")) { let addressObjects = MailServices.headerParser.makeFromDisplayAddress( msgHdr.mime2DecodedAuthor ); let { name, email } = addressObjects[0] || {}; author = name || email; } if (subject && author) { alertBody += this._bundle.formatStringFromName( "newMailNotification_messagetitle", [subject, author] ); } else if (subject) { alertBody += subject; } else if (author) { alertBody += author; } let showPreview = Services.prefs.getBoolPref( "mail.biff.alert.show_preview" ); if (showPreview) { let previewLength = Services.prefs.getIntPref( "mail.biff.alert.preview_length", 40 ); let preview = msgHdr.getStringProperty("preview").slice(0, previewLength); if (preview) { alertBody += (alertBody ? "\n" : "") + preview; } } return alertBody; } /** * Show the alert. * * @param {nsIMsgHdr} msgHdr - The nsIMsgHdr of the first new messages. * @param {string} title - The alert title. * @param {string} body - The alert body. */ _showAlert(msgHdr, title, body) { let folder = msgHdr.folder; // Try to use system alert first. if ( Services.prefs.getBoolPref("mail.biff.use_system_alert", true) && this._systemAlertAvailable ) { let alertsService = Cc["@mozilla.org/system-alerts-service;1"].getService( Ci.nsIAlertsService ); let cookie = folder.generateMessageURI(msgHdr.messageKey); try { let alert = Cc["@mozilla.org/alert-notification;1"].createInstance( Ci.nsIAlertNotification ); alert.init( cookie, // name "chrome://messenger/skin/icons/new-mail-alert.png", title, body, true, // clickable cookie ); alertsService.showAlert(alert, this); return; } catch (e) { this._logger.error(e); this._systemAlertAvailable = false; } } // The use_system_alert pref is false or showAlert somehow failed, use the // customized alert window. this._showCustomizedAlert(folder); } /** * Show a customized alert window (newmailalert.xhtml), if there is already * one showing, do not show another one, because the newer one will block the * older one. Instead, save the folder and newMsgKeys to this._pendingFolders. * * @param {nsIMsgFolder} [folder] - The folder containing new messages. */ _showCustomizedAlert(folder) { if (this._customizedAlertShown) { // Queue the folder. this._pendingFolders.add(folder); return; } if (!folder) { // Get the next folder from the queue. folder = this._pendingFolders.keys().next().value; if (!folder) { return; } this._pendingFolders.delete(folder); } let newMsgKeys = this._getNewMsgKeysNotNotified(folder); if (!newMsgKeys.length) { // No NEW message in the current folder, try the next queued folder. this._showCustomizedAlert(); return; } let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); args.appendElement(folder); args.appendElement({ wrappedJSObject: newMsgKeys, }); args.appendElement(this); Services.ww.openWindow( null, "chrome://messenger/content/newmailalert.xhtml", "_blank", "chrome,dialog=yes,titlebar=no,popup=yes", args ); this._customizedAlertShown = true; this._folderBiffTime.set(folder, Date.now()); } /** * Get all NEW messages from a folder that we received after last biff time. * * @param {nsIMsgFolder} folder - The message folder to check. * @returns {number[]} An array of message keys. */ _getNewMsgKeysNotNotified(folder) { let msgDb = folder.msgDatabase; let lastBiffTime = this._folderBiffTime.get(folder) || 0; return msgDb .getNewList() .slice(-folder.getNumNewMessages(false)) .filter(key => { let msgHdr = msgDb.getMsgHdrForKey(key); return msgHdr.dateInSeconds * 1000 > lastBiffTime; }); } async _updateUnreadCount() { if (this._updatingUnreadCount) { // _updateUnreadCount can be triggered faster than we finish rendering the // badge. When that happens, set a flag and return. this._pendingUpdate = true; return; } this._updatingUnreadCount = true; this._logger.debug( `Update unreadMailCount=${this._unreadMailCount}, unreadChatCount=${this._unreadChatCount}` ); let count = this._unreadMailCount + this._unreadChatCount; let tooltip = ""; if (AppConstants.platform == "win") { if (!Services.prefs.getBoolPref("mail.biff.show_badge", true)) { count = 0; } if (count > 0) { tooltip = await lazy.l10n.formatValue("unread-messages-os-tooltip", { count, }); } await lazy.WinUnreadBadge.updateUnreadCount(count, tooltip); } this._osIntegration?.updateUnreadCount(count, tooltip); this._updatingUnreadCount = false; if (this._pendingUpdate) { // There was at least one _updateUnreadCount call while we were rendering // the badge. Render one more time will ensure the badge reflects the // current state. this._pendingUpdate = false; this._updateUnreadCount(); } } _animateDockIcon() { if (Services.prefs.getBoolPref("mail.biff.animate_dock_icon", false)) { Services.wm.getMostRecentWindow("mail:3pane")?.getAttention(); } } }