diff options
Diffstat (limited to 'comm/mail/components/compose/content/MsgComposeCommands.js')
-rw-r--r-- | comm/mail/components/compose/content/MsgComposeCommands.js | 11654 |
1 files changed, 11654 insertions, 0 deletions
diff --git a/comm/mail/components/compose/content/MsgComposeCommands.js b/comm/mail/components/compose/content/MsgComposeCommands.js new file mode 100644 index 0000000000..6a0045b58d --- /dev/null +++ b/comm/mail/components/compose/content/MsgComposeCommands.js @@ -0,0 +1,11654 @@ +/* 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/. */ + +/* import-globals-from ../../../../../toolkit/content/contentAreaUtils.js */ +/* import-globals-from ../../../../mailnews/addrbook/content/abDragDrop.js */ +/* import-globals-from ../../../../mailnews/base/prefs/content/accountUtils.js */ +/* import-globals-from ../../../base/content/contentAreaClick.js */ +/* import-globals-from ../../../base/content/mailCore.js */ +/* import-globals-from ../../../base/content/messenger-customization.js */ +/* import-globals-from ../../../base/content/toolbarIconColor.js */ +/* import-globals-from ../../../base/content/utilityOverlay.js */ +/* import-globals-from ../../../base/content/viewZoomOverlay.js */ +/* import-globals-from ../../../base/content/widgets/browserPopups.js */ +/* import-globals-from ../../../extensions/openpgp/content/ui/keyAssistant.js */ +/* import-globals-from addressingWidgetOverlay.js */ +/* import-globals-from cloudAttachmentLinkManager.js */ +/* import-globals-from ComposerCommands.js */ +/* import-globals-from editor.js */ +/* import-globals-from editorUtilities.js */ + +/** + * Commands for the message composition window. + */ + +// Ensure the activity modules are loaded for this window. +ChromeUtils.import("resource:///modules/activity/activityModules.jsm"); +var { AttachmentChecker } = ChromeUtils.import( + "resource:///modules/AttachmentChecker.jsm" +); +var { cloudFileAccounts } = ChromeUtils.import( + "resource:///modules/cloudFileAccounts.jsm" +); +var { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm"); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + FolderUtils: "resource:///modules/FolderUtils.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm", + BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm", + UIFontSize: "resource:///modules/UIFontSize.jsm", + UIDensity: "resource:///modules/UIDensity.jsm", +}); + +XPCOMUtils.defineLazyGetter( + this, + "l10nCompose", + () => + new Localization([ + "branding/brand.ftl", + "messenger/messengercompose/messengercompose.ftl", + ]) +); + +XPCOMUtils.defineLazyGetter( + this, + "l10nComposeSync", + () => + new Localization( + ["branding/brand.ftl", "messenger/messengercompose/messengercompose.ftl"], + true + ) +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); + +XPCOMUtils.defineLazyScriptGetter( + this, + "PrintUtils", + "chrome://messenger/content/printUtils.js" +); + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + MailStringUtils: "resource:///modules/MailStringUtils.jsm", +}); + +/** + * Global message window object. This is used by mail-offline.js and therefore + * should not be renamed. We need to avoid doing this kind of cross file global + * stuff in the future and instead pass this object as parameter when needed by + * functions in the other js file. + */ +var msgWindow; + +var gMessenger; + +/** + * Global variables, need to be re-initialized every time mostly because + * we need to release them when the window closes. + */ +var gMsgCompose; +var gOriginalMsgURI; +var gWindowLocked; +var gSendLocked; +var gContentChanged; +var gSubjectChanged; +var gAutoSaving; +var gCurrentIdentity; +var defaultSaveOperation; +var gSendOperationInProgress; +var gSaveOperationInProgress; +var gCloseWindowAfterSave; +var gSavedSendNowKey; +var gContextMenu; +var gLastFocusElement = null; +var gLoadingComplete = false; + +var gAttachmentBucket; +var gAttachmentCounter; +/** + * typedef {Object} FocusArea + * + * @property {Element} root - The root of a given area of the UI. + * @property {moveFocusWithin} focus - A method to move the focus within the + * root. + */ +/** + * @callback moveFocusWithin + * + * @param {Element} root - The element to move the focus within. + * + * @returns {boolean} - Whether the focus was successfully moved to within the + * given element. + */ +/** + * An ordered list of non-intersecting areas we want to jump focus between. + * Ordering should be in the same order as tab focus. See + * {@link moveFocusToNeighbouringArea}. + * + * @type {FocusArea[]} + */ +var gFocusAreas; +// TODO: Maybe the following two variables can be combined. +var gManualAttachmentReminder; +var gDisableAttachmentReminder; +var gComposeType; +var gLanguageObserver; +var gRecipientObserver; +var gWantCannotEncryptBCCNotification = true; +var gRecipientKeysObserver; +var gCheckPublicRecipientsTimer; +var gBodyFromArgs; + +// gSMFields is the nsIMsgComposeSecure instance for S/MIME. +// gMsgCompose.compFields.composeSecure is set to this instance most of +// the time. Because the S/MIME code has no knowledge of the OpenPGP +// implementation, gMsgCompose.compFields.composeSecure is set to an +// instance of PgpMimeEncrypt only temporarily. Keeping variable +// gSMFields separate allows switching as needed. +var gSMFields = null; + +var gSMPendingCertLookupSet = new Set(); +var gSMCertsAlreadyLookedUpInLDAP = new Set(); + +var gSelectedTechnologyIsPGP = false; + +// The initial flags store the value we used at composer open time. +// Some flags might be automatically changed as a consequence of other +// changes. When reverting automatic actions, the initial flags help +// us know what value we should use for restoring. + +var gSendSigned = false; + +var gAttachMyPublicPGPKey = false; + +var gSendEncrypted = false; + +// gEncryptSubject contains the preference for subject encryption, +// considered only if encryption is enabled and the technology allows it. +// In other words, gEncryptSubject might be set to true, but if +// encryption is disabled, or if S/MIME is used, +// gEncryptSubject==true is ignored. +var gEncryptSubject = false; + +var gUserTouchedSendEncrypted = false; +var gUserTouchedSendSigned = false; +var gUserTouchedAttachMyPubKey = false; +var gUserTouchedEncryptSubject = false; + +var gIsRelatedToEncryptedOriginal = false; + +var gOpened = Date.now(); + +var gEncryptedURIService = Cc[ + "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1" +].getService(Ci.nsIEncryptedSMIMEURIsService); + +try { + var gDragService = Cc["@mozilla.org/widget/dragservice;1"].getService( + Ci.nsIDragService + ); +} catch (e) {} + +/** + * Boolean variable to keep track of the dragging action of files above the + * compose window. + * + * @type {boolean} + */ +var gIsDraggingAttachments; + +/** + * Boolean variable to allow showing the attach inline overlay when dragging + * links that otherwise would only trigger the add as attachment overlay. + * + * @type {boolean} + */ +var gIsValidInline; + +// i18n globals +var _gComposeBundle; +function getComposeBundle() { + // That one has to be lazy. Getting a reference to an element with a XBL + // binding attached will cause the XBL constructors to fire if they haven't + // already. If we get a reference to the compose bundle at script load-time, + // this will cause the XBL constructor that's responsible for the personas to + // fire up, thus executing the personas code while the DOM is not fully built. + // Since this <script> comes before the <statusbar>, the Personas code will + // fail. + if (!_gComposeBundle) { + _gComposeBundle = document.getElementById("bundle_composeMsgs"); + } + return _gComposeBundle; +} + +var gLastWindowToHaveFocus; +var gLastKnownComposeStates; +var gReceiptOptionChanged; +var gDSNOptionChanged; +var gAttachVCardOptionChanged; + +var gAutoSaveInterval; +var gAutoSaveTimeout; +var gAutoSaveKickedIn; +var gEditingDraft; +var gNumUploadingAttachments; + +// From the user's point-of-view, is spell checking enabled? This value only +// changes if the user makes the change, it's not affected by the process of +// sending or saving the message or any other reason the actual state of the +// spellchecker might change. +var gSpellCheckingEnabled; + +var kComposeAttachDirPrefName = "mail.compose.attach.dir"; + +window.addEventListener("unload", event => { + ComposeUnload(); +}); +window.addEventListener("load", event => { + ComposeLoad(); +}); +window.addEventListener("close", event => { + if (!ComposeCanClose()) { + event.preventDefault(); + } +}); +window.addEventListener("focus", event => { + EditorOnFocus(); +}); +window.addEventListener("click", event => { + composeWindowOnClick(event); +}); + +document.addEventListener("focusin", event => { + // Listen for focusin event in composition. gLastFocusElement might well be + // null, e.g. when focusin enters a different document like contacts sidebar. + gLastFocusElement = event.relatedTarget; +}); + +// For WebExtensions. +this.__defineGetter__("browser", GetCurrentEditorElement); + +/** + * @implements {nsIXULBrowserWindow} + */ +var XULBrowserWindow = { + // Used to show the link-being-hovered-over in the status bar. Do nothing here. + setOverLink(url, anchorElt) {}, + + // Called before links are navigated to to allow us to retarget them if needed. + onBeforeLinkTraversal(originalTarget, linkURI, linkNode, isAppTab) { + return originalTarget; + }, + + // Called by BrowserParent::RecvShowTooltip. + showTooltip(xDevPix, yDevPix, tooltip, direction, browser) { + if ( + Cc["@mozilla.org/widget/dragservice;1"] + .getService(Ci.nsIDragService) + .getCurrentSession() + ) { + return; + } + + let elt = document.getElementById("remoteBrowserTooltip"); + elt.label = tooltip; + elt.style.direction = direction; + elt.openPopupAtScreen( + xDevPix / window.devicePixelRatio, + yDevPix / window.devicePixelRatio, + false, + null + ); + }, + + // Called by BrowserParent::RecvHideTooltip. + hideTooltip() { + let elt = document.getElementById("remoteBrowserTooltip"); + elt.hidePopup(); + }, + + getTabCount() { + return 1; + }, +}; +window + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem) + .treeOwner.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).XULBrowserWindow = window.XULBrowserWindow; + +// Observer for the autocomplete input. +const inputObserver = { + observe: (subject, topic, data) => { + if (topic == "autocomplete-did-enter-text") { + let input = subject.QueryInterface( + Ci.nsIAutoCompleteInput + ).wrappedJSObject; + + // Interrupt if there's no input proxy, or the input doesn't have an ID, + // the latter meaning that the autocomplete event was triggered within an + // already existing pill, so we don't want to create a new pill. + if (!input || !input.id) { + return; + } + + // Trigger the pill creation. + recipientAddPills(document.getElementById(input.id)); + } + }, +}; + +const keyObserver = { + observe: async (subject, topic, data) => { + switch (topic) { + case "openpgp-key-change": + EnigmailKeyRing.clearCache(); + // fall through + case "openpgp-acceptance-change": + checkEncryptionState(topic); + gKeyAssistant.onExternalKeyChange(); + break; + default: + break; + } + }, +}; + +// Non translatable international shortcuts. +var SHOW_TO_KEY = "T"; +var SHOW_CC_KEY = "C"; +var SHOW_BCC_KEY = "B"; + +function InitializeGlobalVariables() { + gMessenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger); + + gMsgCompose = null; + gOriginalMsgURI = null; + gWindowLocked = false; + gContentChanged = false; + gSubjectChanged = false; + gCurrentIdentity = null; + defaultSaveOperation = "draft"; + gSendOperationInProgress = false; + gSaveOperationInProgress = false; + gAutoSaving = false; + gCloseWindowAfterSave = false; + gSavedSendNowKey = null; + gManualAttachmentReminder = false; + gDisableAttachmentReminder = false; + gLanguageObserver = null; + gRecipientObserver = null; + + gLastWindowToHaveFocus = null; + gLastKnownComposeStates = {}; + gReceiptOptionChanged = false; + gDSNOptionChanged = false; + gAttachVCardOptionChanged = false; + gNumUploadingAttachments = 0; + // eslint-disable-next-line no-global-assign + msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance( + Ci.nsIMsgWindow + ); + MailServices.mailSession.AddMsgWindow(msgWindow); + + // Add the observer. + Services.obs.addObserver(inputObserver, "autocomplete-did-enter-text"); + Services.obs.addObserver(keyObserver, "openpgp-key-change"); + Services.obs.addObserver(keyObserver, "openpgp-acceptance-change"); +} +InitializeGlobalVariables(); + +function ReleaseGlobalVariables() { + gCurrentIdentity = null; + gMsgCompose = null; + gOriginalMsgURI = null; + gMessenger = null; + gRecipientObserver = null; + gDisableAttachmentReminder = false; + _gComposeBundle = null; + MailServices.mailSession.RemoveMsgWindow(msgWindow); + // eslint-disable-next-line no-global-assign + msgWindow = null; + + gLastKnownComposeStates = null; + + // Remove the observers. + Services.obs.removeObserver(inputObserver, "autocomplete-did-enter-text"); + Services.obs.removeObserver(keyObserver, "openpgp-key-change"); + Services.obs.removeObserver(keyObserver, "openpgp-acceptance-change"); +} + +// Notification box shown at the bottom of the window. +XPCOMUtils.defineLazyGetter(this, "gComposeNotification", () => { + return new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "bottom"); + document.getElementById("compose-notification-bottom").append(element); + }); +}); + +/** + * Get the first next sibling element matching the selector (if specified). + * + * @param {HTMLElement} element - The source element whose sibling to look for. + * @param {string} [selector] - The CSS query selector to match. + * + * @returns {(HTMLElement|null)} - The first matching sibling element, or null. + */ +function getNextSibling(element, selector) { + let sibling = element.nextElementSibling; + if (!selector) { + // If there's no selector, return the first next sibling. + return sibling; + } + while (sibling) { + if (sibling.matches(selector)) { + // Return the current sibling if it matches the selector. + return sibling; + } + // Otherwise, continue the loop with the following next sibling. + sibling = sibling.nextElementSibling; + } + return null; +} + +/** + * Get the first previous sibling element matching the selector (if specified). + * + * @param {HTMLElement} element - The source element whose sibling to look for. + * @param {string} [selector] - The CSS query selector to match. + * + * @returns {(HTMLElement|null)} - The first matching sibling element, or null. + */ +function getPreviousSibling(element, selector) { + let sibling = element.previousElementSibling; + if (!selector) { + // If there's no selector, return the first previous sibling. + return sibling; + } + while (sibling) { + if (sibling.matches(selector)) { + // Return the current sibling if it matches the selector. + return sibling; + } + // Otherwise, continue the loop with the preceding previous sibling. + sibling = sibling.previousElementSibling; + } + return null; +} + +/** + * Get a pretty, human-readable shortcut key string from a given <key> id. + * + * @param aKeyId the ID of a <key> element + * @returns string pretty, human-readable shortcut key string from the <key> + */ +function getPrettyKey(aKeyId) { + return ShortcutUtils.prettifyShortcut(document.getElementById(aKeyId)); +} + +/** + * Disables or enables editable elements in the window. + * The elements to operate on are marked with the "disableonsend" attribute. + * This includes elements like the address list, attachment list, subject + * and message body. + * + * @param aDisable true = disable items. false = enable items. + */ +function updateEditableFields(aDisable) { + if (!gMsgCompose) { + return; + } + + if (aDisable) { + gMsgCompose.editor.flags |= Ci.nsIEditor.eEditorReadonlyMask; + } else { + gMsgCompose.editor.flags &= ~Ci.nsIEditor.eEditorReadonlyMask; + + try { + let checker = GetCurrentEditor().getInlineSpellChecker(true); + checker.enableRealTimeSpell = gSpellCheckingEnabled; + } catch (ex) { + // An error will be thrown if there are no dictionaries. Just ignore it. + } + } + + // Disable all the input fields and labels. + for (let element of document.querySelectorAll('[disableonsend="true"]')) { + element.disabled = aDisable; + } + + // Update the UI of the addressing rows. + for (let row of document.querySelectorAll(".address-container")) { + row.classList.toggle("disable-container", aDisable); + } + + // Prevent any interaction with the addressing pills. + for (let pill of document.querySelectorAll("mail-address-pill")) { + pill.toggleAttribute("disabled", aDisable); + } +} + +/** + * Small helper function to check whether the node passed in is a signature. + * Note that a text node is not a DOM element, hence .localName can't be used. + */ +function isSignature(aNode) { + return ( + ["DIV", "PRE"].includes(aNode.nodeName) && + aNode.classList.contains("moz-signature") + ); +} + +var stateListener = { + NotifyComposeFieldsReady() { + ComposeFieldsReady(); + updateSendCommands(true); + }, + + NotifyComposeBodyReady() { + // Look all the possible compose types (nsIMsgComposeParams.idl): + switch (gComposeType) { + case Ci.nsIMsgCompType.MailToUrl: + gBodyFromArgs = true; + // Falls through + case Ci.nsIMsgCompType.New: + case Ci.nsIMsgCompType.NewsPost: + case Ci.nsIMsgCompType.ForwardAsAttachment: + this.NotifyComposeBodyReadyNew(); + break; + + case Ci.nsIMsgCompType.Reply: + case Ci.nsIMsgCompType.ReplyAll: + case Ci.nsIMsgCompType.ReplyToSender: + case Ci.nsIMsgCompType.ReplyToGroup: + case Ci.nsIMsgCompType.ReplyToSenderAndGroup: + case Ci.nsIMsgCompType.ReplyWithTemplate: + case Ci.nsIMsgCompType.ReplyToList: + this.NotifyComposeBodyReadyReply(); + break; + + case Ci.nsIMsgCompType.Redirect: + case Ci.nsIMsgCompType.ForwardInline: + this.NotifyComposeBodyReadyForwardInline(); + break; + + case Ci.nsIMsgCompType.EditTemplate: + defaultSaveOperation = "template"; + break; + case Ci.nsIMsgCompType.Draft: + case Ci.nsIMsgCompType.Template: + case Ci.nsIMsgCompType.EditAsNew: + break; + + default: + dump( + "Unexpected nsIMsgCompType in NotifyComposeBodyReady (" + + gComposeType + + ")\n" + ); + } + + // Setting the selected item in the identity list will cause an + // identity/signature switch. This can only be done once the message + // body has already been assembled with the signature we need to switch. + if (gMsgCompose.identity != gCurrentIdentity) { + let identityList = document.getElementById("msgIdentity"); + identityList.selectedItem = identityList.getElementsByAttribute( + "identitykey", + gMsgCompose.identity.key + )[0]; + LoadIdentity(false); + } + if (gMsgCompose.composeHTML) { + loadHTMLMsgPrefs(); + } + AdjustFocus(); + }, + + NotifyComposeBodyReadyNew() { + let useParagraph = Services.prefs.getBoolPref( + "mail.compose.default_to_paragraph" + ); + let insertParagraph = gMsgCompose.composeHTML && useParagraph; + + let mailBody = getBrowser().contentDocument.querySelector("body"); + if (insertParagraph && gBodyFromArgs) { + // Check for "empty" body before allowing paragraph to be inserted. + // Non-empty bodies in a new message can occur when clicking on a + // mailto link or when using the command line option -compose. + // An "empty" body can be one of these three cases: + // 1) <br> and nothing follows (no next sibling) + // 2) <div/pre class="moz-signature"> + // 3) No elements, just text + // Note that <br><div/pre class="moz-signature"> doesn't happen in + // paragraph mode. + let firstChild = mailBody.firstChild; + let firstElementChild = mailBody.firstElementChild; + if (firstElementChild) { + if ( + (firstElementChild.nodeName != "BR" || + firstElementChild.nextElementSibling) && + !isSignature(firstElementChild) + ) { + insertParagraph = false; + } + } else if (firstChild && firstChild.nodeType == Node.TEXT_NODE) { + insertParagraph = false; + } + } + + // Control insertion of line breaks. + if (insertParagraph) { + let editor = GetCurrentEditor(); + editor.enableUndo(false); + + editor.selection.collapse(mailBody, 0); + let pElement = editor.createElementWithDefaults("p"); + pElement.appendChild(editor.createElementWithDefaults("br")); + editor.insertElementAtSelection(pElement, false); + + document.getElementById("cmd_paragraphState").setAttribute("state", "p"); + + editor.beginningOfDocument(); + editor.enableUndo(true); + editor.resetModificationCount(); + } else { + document.getElementById("cmd_paragraphState").setAttribute("state", ""); + } + onParagraphFormatChange(); + }, + + NotifyComposeBodyReadyReply() { + // Control insertion of line breaks. + let useParagraph = Services.prefs.getBoolPref( + "mail.compose.default_to_paragraph" + ); + if (gMsgCompose.composeHTML && useParagraph) { + let mailBody = getBrowser().contentDocument.querySelector("body"); + let editor = GetCurrentEditor(); + let selection = editor.selection; + + // Make sure the selection isn't inside the signature. + if (isSignature(mailBody.firstElementChild)) { + selection.collapse(mailBody, 0); + } + + let range = selection.getRangeAt(0); + let start = range.startOffset; + + if (start != range.endOffset) { + // The selection is not collapsed, most likely due to the + // "select the quote" option. In this case we do nothing. + return; + } + + if (range.startContainer != mailBody) { + dump("Unexpected selection in NotifyComposeBodyReadyReply\n"); + return; + } + + editor.enableUndo(false); + + let pElement = editor.createElementWithDefaults("p"); + pElement.appendChild(editor.createElementWithDefaults("br")); + editor.insertElementAtSelection(pElement, false); + + // Position into the paragraph. + selection.collapse(pElement, 0); + + document.getElementById("cmd_paragraphState").setAttribute("state", "p"); + + editor.enableUndo(true); + editor.resetModificationCount(); + } else { + document.getElementById("cmd_paragraphState").setAttribute("state", ""); + } + onParagraphFormatChange(); + }, + + NotifyComposeBodyReadyForwardInline() { + let mailBody = getBrowser().contentDocument.querySelector("body"); + let editor = GetCurrentEditor(); + let selection = editor.selection; + + editor.enableUndo(false); + + // Control insertion of line breaks. + selection.collapse(mailBody, 0); + let useParagraph = Services.prefs.getBoolPref( + "mail.compose.default_to_paragraph" + ); + if (gMsgCompose.composeHTML && useParagraph) { + let pElement = editor.createElementWithDefaults("p"); + let brElement = editor.createElementWithDefaults("br"); + pElement.appendChild(brElement); + editor.insertElementAtSelection(pElement, false); + document.getElementById("cmd_paragraphState").setAttribute("state", "p"); + } else { + // insertLineBreak() has been observed to insert two <br> elements + // instead of one before a <div>, so we'll do it ourselves here. + let brElement = editor.createElementWithDefaults("br"); + editor.insertElementAtSelection(brElement, false); + document.getElementById("cmd_paragraphState").setAttribute("state", ""); + } + + onParagraphFormatChange(); + editor.beginningOfDocument(); + editor.enableUndo(true); + editor.resetModificationCount(); + }, + + ComposeProcessDone(aResult) { + ToggleWindowLock(false); + + if (aResult == Cr.NS_OK) { + if (!gAutoSaving) { + SetContentAndBodyAsUnmodified(); + } + + if (gCloseWindowAfterSave) { + // Notify the SendListener that Send has been aborted and Stopped + if (gMsgCompose) { + gMsgCompose.onSendNotPerformed(null, Cr.NS_ERROR_ABORT); + } + + MsgComposeCloseWindow(); + } + } else if (gAutoSaving) { + // If we failed to save, and we're autosaving, need to re-mark the editor + // as changed, so that we won't lose the changes. + gMsgCompose.bodyModified = true; + gContentChanged = true; + } + gAutoSaving = false; + gCloseWindowAfterSave = false; + }, + + SaveInFolderDone(folderURI) { + DisplaySaveFolderDlg(folderURI); + }, +}; + +var gSendListener = { + // nsIMsgSendListener + onStartSending(aMsgID, aMsgSize) {}, + onProgress(aMsgID, aProgress, aProgressMax) {}, + onStatus(aMsgID, aMsg) {}, + onStopSending(aMsgID, aStatus, aMsg, aReturnFile) { + if (Components.isSuccessCode(aStatus)) { + Services.obs.notifyObservers(null, "mail:composeSendSucceeded", aMsgID); + } + }, + onGetDraftFolderURI(aMsgID, aFolderURI) {}, + onSendNotPerformed(aMsgID, aStatus) {}, + onTransportSecurityError(msgID, status, secInfo, location) { + // We're only interested in Bad Cert errors here. + let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService( + Ci.nsINSSErrorsService + ); + let errorClass = nssErrorsService.getErrorClass(status); + if (errorClass != Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) { + return; + } + + // Give the user the option of adding an exception for the bad cert. + let params = { + exceptionAdded: false, + securityInfo: secInfo, + prefetchCert: true, + location, + }; + window.openDialog( + "chrome://pippki/content/exceptionDialog.xhtml", + "", + "chrome,centerscreen,modal", + params + ); + // params.exceptionAdded will be set if the user added an exception. + }, +}; + +// all progress notifications are done through the nsIWebProgressListener implementation... +var progressListener = { + onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { + let progressMeter = document.getElementById("compose-progressmeter"); + if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) { + progressMeter.hidden = false; + progressMeter.removeAttribute("value"); + } + + if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + gSendOperationInProgress = false; + gSaveOperationInProgress = false; + progressMeter.hidden = true; + progressMeter.value = 0; + document.getElementById("statusText").textContent = ""; + Services.obs.notifyObservers( + { composeWindow: window }, + "mail:composeSendProgressStop" + ); + } + }, + + onProgressChange( + aWebProgress, + aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress + ) { + // Calculate percentage. + var percent; + if (aMaxTotalProgress > 0) { + percent = Math.round((aCurTotalProgress * 100) / aMaxTotalProgress); + if (percent > 100) { + percent = 100; + } + + // Advance progress meter. + document.getElementById("compose-progressmeter").value = percent; + } else { + // Progress meter should be barber-pole in this case. + document.getElementById("compose-progressmeter").removeAttribute("value"); + } + }, + + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + // we can ignore this notification + }, + + onStatusChange(aWebProgress, aRequest, aStatus, aMessage) { + // Looks like it's possible that we get call while the document has been already delete! + // therefore we need to protect ourself by using try/catch + try { + let statusText = document.getElementById("statusText"); + if (statusText) { + statusText.textContent = aMessage; + } + } catch (ex) {} + }, + + onSecurityChange(aWebProgress, aRequest, state) { + // we can ignore this notification + }, + + onContentBlockingEvent(aWebProgress, aRequest, aEvent) { + // we can ignore this notification + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), +}; + +var defaultController = { + commands: { + cmd_attachFile: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + AttachFile(); + }, + }, + + cmd_attachCloud: { + isEnabled() { + // Hide the command entirely if there are no cloud accounts or + // the feature is disabled. + let cmd = document.getElementById("cmd_attachCloud"); + cmd.hidden = + !Services.prefs.getBoolPref("mail.cloud_files.enabled") || + cloudFileAccounts.configuredAccounts.length == 0 || + Services.io.offline; + return !cmd.hidden && !gWindowLocked; + }, + doCommand() { + // We should never actually call this, since the <command> node calls + // a different function. + }, + }, + + cmd_attachPage: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + gMsgCompose.allowRemoteContent = true; + AttachPage(); + }, + }, + + cmd_attachVCard: { + isEnabled() { + let cmd = document.getElementById("cmd_attachVCard"); + cmd.setAttribute("checked", gMsgCompose.compFields.attachVCard); + return !!gCurrentIdentity?.escapedVCard; + }, + doCommand() {}, + }, + + cmd_attachPublicKey: { + isEnabled() { + let cmd = document.getElementById("cmd_attachPublicKey"); + cmd.setAttribute("checked", gAttachMyPublicPGPKey); + return isPgpConfigured(); + }, + doCommand() {}, + }, + + cmd_toggleAttachmentPane: { + isEnabled() { + return !gWindowLocked && gAttachmentBucket.itemCount; + }, + doCommand() { + toggleAttachmentPane("toggle"); + }, + }, + + cmd_reorderAttachments: { + isEnabled() { + if (!gAttachmentBucket.itemCount) { + let reorderAttachmentsPanel = document.getElementById( + "reorderAttachmentsPanel" + ); + if (reorderAttachmentsPanel.state == "open") { + // When the panel is open and all attachments get deleted, + // we get notified here and want to close the panel. + reorderAttachmentsPanel.hidePopup(); + } + } + return gAttachmentBucket.itemCount > 1; + }, + doCommand() { + showReorderAttachmentsPanel(); + }, + }, + + cmd_removeAllAttachments: { + isEnabled() { + return !gWindowLocked && gAttachmentBucket.itemCount; + }, + doCommand() { + RemoveAllAttachments(); + }, + }, + + cmd_close: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + if (ComposeCanClose()) { + window.close(); + } + }, + }, + + cmd_saveDefault: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + Save(); + }, + }, + + cmd_saveAsFile: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + SaveAsFile(true); + }, + }, + + cmd_saveAsDraft: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + SaveAsDraft(); + }, + }, + + cmd_saveAsTemplate: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + SaveAsTemplate(); + }, + }, + + cmd_sendButton: { + isEnabled() { + return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked; + }, + doCommand() { + if (Services.io.offline) { + SendMessageLater(); + } else { + SendMessage(); + } + }, + }, + + cmd_sendNow: { + isEnabled() { + return ( + !gWindowLocked && + !Services.io.offline && + !gSendLocked && + !gNumUploadingAttachments + ); + }, + doCommand() { + SendMessage(); + }, + }, + + cmd_sendLater: { + isEnabled() { + return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked; + }, + doCommand() { + SendMessageLater(); + }, + }, + + cmd_sendWithCheck: { + isEnabled() { + return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked; + }, + doCommand() { + SendMessageWithCheck(); + }, + }, + + cmd_print: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + DoCommandPrint(); + }, + }, + + cmd_delete: { + isEnabled() { + let cmdDelete = document.getElementById("cmd_delete"); + let textValue = cmdDelete.getAttribute("valueDefault"); + let accesskeyValue = cmdDelete.getAttribute("valueDefaultAccessKey"); + + cmdDelete.setAttribute("label", textValue); + cmdDelete.setAttribute("accesskey", accesskeyValue); + + return false; + }, + doCommand() {}, + }, + + cmd_account: { + isEnabled() { + return true; + }, + doCommand() { + let currentAccountKey = getCurrentAccountKey(); + let account = MailServices.accounts.getAccount(currentAccountKey); + MsgAccountManager(null, account.incomingServer); + }, + }, + + cmd_showFormatToolbar: { + isEnabled() { + return gMsgCompose && gMsgCompose.composeHTML; + }, + doCommand() { + goToggleToolbar("FormatToolbar", "menu_showFormatToolbar"); + }, + }, + + cmd_quoteMessage: { + isEnabled() { + let selectedURIs = GetSelectedMessages(); + return selectedURIs && selectedURIs.length > 0; + }, + doCommand() { + QuoteSelectedMessage(); + }, + }, + + cmd_toggleReturnReceipt: { + isEnabled() { + if (!gMsgCompose) { + return false; + } + return !gWindowLocked; + }, + doCommand() { + ToggleReturnReceipt(); + }, + }, + + cmd_fullZoomReduce: { + isEnabled() { + return true; + }, + doCommand() { + ZoomManager.reduce(); + }, + }, + + cmd_fullZoomEnlarge: { + isEnabled() { + return true; + }, + doCommand() { + ZoomManager.enlarge(); + }, + }, + + cmd_fullZoomReset: { + isEnabled() { + return true; + }, + doCommand() { + ZoomManager.reset(); + }, + }, + + cmd_spelling: { + isEnabled() { + return true; + }, + doCommand() { + window.cancelSendMessage = false; + var skipBlockQuotes = + window.document.documentElement.getAttribute("windowtype") == + "msgcompose"; + window.openDialog( + "chrome://messenger/content/messengercompose/EdSpellCheck.xhtml", + "_blank", + "dialog,close,titlebar,modal,resizable", + false, + skipBlockQuotes, + true + ); + }, + }, + + cmd_fullZoomToggle: { + isEnabled() { + return true; + }, + doCommand() { + ZoomManager.toggleZoom(); + }, + }, + }, + + supportsCommand(aCommand) { + return aCommand in this.commands; + }, + + isCommandEnabled(aCommand) { + if (!this.supportsCommand(aCommand)) { + return false; + } + return this.commands[aCommand].isEnabled(); + }, + + doCommand(aCommand) { + if (!this.supportsCommand(aCommand)) { + return; + } + var cmd = this.commands[aCommand]; + if (!cmd.isEnabled()) { + return; + } + cmd.doCommand(); + }, + + onEvent(event) {}, +}; + +var attachmentBucketController = { + commands: { + cmd_selectAll: { + isEnabled() { + return true; + }, + doCommand() { + gAttachmentBucket.selectAll(); + }, + }, + + cmd_delete: { + isEnabled() { + let cmdDelete = document.getElementById("cmd_delete"); + let textValue = getComposeBundle().getString("removeAttachmentMsgs"); + textValue = PluralForm.get(gAttachmentBucket.selectedCount, textValue); + let accesskeyValue = cmdDelete.getAttribute( + "valueRemoveAttachmentAccessKey" + ); + cmdDelete.setAttribute("label", textValue); + cmdDelete.setAttribute("accesskey", accesskeyValue); + + return gAttachmentBucket.selectedCount; + }, + doCommand() { + RemoveSelectedAttachment(); + }, + }, + + cmd_openAttachment: { + isEnabled() { + return gAttachmentBucket.selectedCount == 1; + }, + doCommand() { + OpenSelectedAttachment(); + }, + }, + + cmd_renameAttachment: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount == 1 && + !gAttachmentBucket.selectedItem.uploading + ); + }, + doCommand() { + RenameSelectedAttachment(); + }, + }, + + cmd_moveAttachmentLeft: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount && !attachmentsSelectionIsBlock("top") + ); + }, + doCommand() { + moveSelectedAttachments("left"); + }, + }, + + cmd_moveAttachmentRight: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount && + !attachmentsSelectionIsBlock("bottom") + ); + }, + doCommand() { + moveSelectedAttachments("right"); + }, + }, + + cmd_moveAttachmentBundleUp: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount > 1 && !attachmentsSelectionIsBlock() + ); + }, + doCommand() { + moveSelectedAttachments("bundleUp"); + }, + }, + + cmd_moveAttachmentBundleDown: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount > 1 && !attachmentsSelectionIsBlock() + ); + }, + doCommand() { + moveSelectedAttachments("bundleDown"); + }, + }, + + cmd_moveAttachmentTop: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount && !attachmentsSelectionIsBlock("top") + ); + }, + doCommand() { + moveSelectedAttachments("top"); + }, + }, + + cmd_moveAttachmentBottom: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount && + !attachmentsSelectionIsBlock("bottom") + ); + }, + doCommand() { + moveSelectedAttachments("bottom"); + }, + }, + + cmd_sortAttachmentsToggle: { + isEnabled() { + let sortSelection; + let currSortOrder; + let isBlock; + let btnAscending; + let toggleCmd = document.getElementById("cmd_sortAttachmentsToggle"); + let toggleBtn = document.getElementById("btn_sortAttachmentsToggle"); + let sortDirection; + let btnLabelAttr; + + if ( + gAttachmentBucket.selectedCount > 1 && + gAttachmentBucket.selectedCount < gAttachmentBucket.itemCount + ) { + // Sort selected attachments only, which needs at least 2 of them, + // but not all. + sortSelection = true; + currSortOrder = attachmentsSelectionGetSortOrder(); + isBlock = attachmentsSelectionIsBlock(); + // If current sorting is ascending AND it's a block; OR + // if current sorting is descending AND it's NOT a block yet: + // Offer toggle button face to sort descending. + // In all other cases, offer toggle button face to sort ascending. + btnAscending = !( + (currSortOrder == "ascending" && isBlock) || + (currSortOrder == "descending" && !isBlock) + ); + // Set sortDirection for toggleCmd, and respective button face. + if (btnAscending) { + sortDirection = "ascending"; + btnLabelAttr = "label-selection-AZ"; + } else { + sortDirection = "descending"; + btnLabelAttr = "label-selection-ZA"; + } + } else { + // gAttachmentBucket.selectedCount <= 1 or all attachments are selected. + // Sort all attachments. + sortSelection = false; + currSortOrder = attachmentsGetSortOrder(); + btnAscending = !(currSortOrder == "ascending"); + // Set sortDirection for toggleCmd, and respective button face. + if (btnAscending) { + sortDirection = "ascending"; + btnLabelAttr = "label-AZ"; + } else { + sortDirection = "descending"; + btnLabelAttr = "label-ZA"; + } + } + + // Set the sort direction for toggleCmd. + toggleCmd.setAttribute("sortdirection", sortDirection); + // The button's icon is set dynamically via CSS involving the button's + // sortdirection attribute, which is forwarded by the command. + toggleBtn.setAttribute("label", toggleBtn.getAttribute(btnLabelAttr)); + + return sortSelection + ? !(currSortOrder == "equivalent" && isBlock) + : !(currSortOrder == "equivalent"); + }, + doCommand() { + moveSelectedAttachments("toggleSort"); + }, + }, + + cmd_convertCloud: { + isEnabled() { + // Hide the command entirely if Filelink is disabled, or if there are + // no cloud accounts. + let cmd = document.getElementById("cmd_convertCloud"); + + cmd.hidden = + !Services.prefs.getBoolPref("mail.cloud_files.enabled") || + cloudFileAccounts.configuredAccounts.length == 0 || + Services.io.offline; + if (cmd.hidden) { + return false; + } + + for (let item of gAttachmentBucket.selectedItems) { + if (item.uploading) { + return false; + } + } + return true; + }, + doCommand() { + // We should never actually call this, since the <command> node calls + // a different function. + }, + }, + + cmd_convertAttachment: { + isEnabled() { + if (!Services.prefs.getBoolPref("mail.cloud_files.enabled")) { + return false; + } + + for (let item of gAttachmentBucket.selectedItems) { + if (item.uploading) { + return false; + } + } + return true; + }, + doCommand() { + convertSelectedToRegularAttachment(); + }, + }, + + cmd_cancelUpload: { + isEnabled() { + let cmd = document.getElementById( + "composeAttachmentContext_cancelUploadItem" + ); + + // If Filelink is disabled, hide this menuitem and bailout. + if (!Services.prefs.getBoolPref("mail.cloud_files.enabled")) { + cmd.hidden = true; + return false; + } + + for (let item of gAttachmentBucket.selectedItems) { + if (item && item.uploading) { + cmd.hidden = false; + return true; + } + } + + // Hide the command entirely if the selected attachments aren't cloud + // files. + // For some reason, the hidden property isn't propagating from the cmd + // to the menuitem. + cmd.hidden = true; + return false; + }, + doCommand() { + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + + for (let item of gAttachmentBucket.selectedItems) { + if (item && item.uploading) { + let file = fileHandler.getFileFromURLSpec(item.attachment.url); + item.uploading.cancelFileUpload(window, file); + } + } + }, + }, + }, + + supportsCommand(aCommand) { + return aCommand in this.commands; + }, + + isCommandEnabled(aCommand) { + if (!this.supportsCommand(aCommand)) { + return false; + } + return this.commands[aCommand].isEnabled(); + }, + + doCommand(aCommand) { + if (!this.supportsCommand(aCommand)) { + return; + } + var cmd = this.commands[aCommand]; + if (!cmd.isEnabled()) { + return; + } + cmd.doCommand(); + }, + + onEvent(event) {}, +}; + +/** + * Start composing a new message. + */ +function goOpenNewMessage(aEvent) { + // If aEvent is passed, check if Shift key was pressed for composition in + // non-default format (HTML vs. plaintext). + let msgCompFormat = + aEvent && aEvent.shiftKey + ? Ci.nsIMsgCompFormat.OppositeOfDefault + : Ci.nsIMsgCompFormat.Default; + + MailServices.compose.OpenComposeWindow( + null, + null, + null, + Ci.nsIMsgCompType.New, + msgCompFormat, + gCurrentIdentity, + null, + null + ); +} + +function QuoteSelectedMessage() { + var selectedURIs = GetSelectedMessages(); + if (selectedURIs) { + gMsgCompose.allowRemoteContent = false; + for (let i = 0; i < selectedURIs.length; i++) { + gMsgCompose.quoteMessage(selectedURIs[i]); + } + } +} + +function GetSelectedMessages() { + let mailWindow = Services.wm.getMostRecentWindow("mail:3pane"); + if (!mailWindow) { + return null; + } + let tab = mailWindow.document.getElementById("tabmail").currentTabInfo; + if (tab.mode.name == "mail3PaneTab" && tab.message) { + return tab.chromeBrowser.contentWindow?.gDBView?.getURIsForSelection(); + } else if (tab.mode.name == "mailMessageTab") { + return [tab.messageURI]; + } + return null; +} + +function SetupCommandUpdateHandlers() { + top.controllers.appendController(defaultController); + gAttachmentBucket.controllers.appendController(attachmentBucketController); + + document + .getElementById("optionsMenuPopup") + .addEventListener("popupshowing", updateOptionItems, true); +} + +function UnloadCommandUpdateHandlers() { + document + .getElementById("optionsMenuPopup") + .removeEventListener("popupshowing", updateOptionItems, true); + + gAttachmentBucket.controllers.removeController(attachmentBucketController); + top.controllers.removeController(defaultController); +} + +function CommandUpdate_MsgCompose() { + var focusedWindow = top.document.commandDispatcher.focusedWindow; + + // we're just setting focus to where it was before + if (focusedWindow == gLastWindowToHaveFocus) { + return; + } + + gLastWindowToHaveFocus = focusedWindow; + updateComposeItems(); +} + +function findbarFindReplace() { + focusMsgBody(); + let findbar = document.getElementById("FindToolbar"); + findbar.close(); + goDoCommand("cmd_findReplace"); + findbar.open(); +} + +function updateComposeItems() { + try { + // Edit Menu + goUpdateCommand("cmd_rewrap"); + + // Insert Menu + if (gMsgCompose && gMsgCompose.composeHTML) { + goUpdateCommand("cmd_renderedHTMLEnabler"); + goUpdateCommand("cmd_fontColor"); + goUpdateCommand("cmd_backgroundColor"); + goUpdateCommand("cmd_decreaseFontStep"); + goUpdateCommand("cmd_increaseFontStep"); + goUpdateCommand("cmd_bold"); + goUpdateCommand("cmd_italic"); + goUpdateCommand("cmd_underline"); + goUpdateCommand("cmd_removeStyles"); + goUpdateCommand("cmd_ul"); + goUpdateCommand("cmd_ol"); + goUpdateCommand("cmd_indent"); + goUpdateCommand("cmd_outdent"); + goUpdateCommand("cmd_align"); + goUpdateCommand("cmd_smiley"); + } + + // Options Menu + goUpdateCommand("cmd_spelling"); + + // Workaround to update 'Quote' toolbar button. (See bug 609926.) + goUpdateCommand("cmd_quoteMessage"); + goUpdateCommand("cmd_toggleReturnReceipt"); + } catch (e) {} +} + +/** + * Disables or restores all toolbar items (menus/buttons) in the window. + * + * @param {boolean} disable - Meaning true = disable all items, false = restore + * items to the state stored before disabling them. + */ +function updateAllItems(disable) { + for (let item of document.querySelectorAll( + "menu, toolbarbutton, [command], [oncommand]" + )) { + if (disable) { + // Disable all items + item.setAttribute("stateBeforeSend", item.getAttribute("disabled")); + item.setAttribute("disabled", "disabled"); + } else { + // Restore initial state + let stateBeforeSend = item.getAttribute("stateBeforeSend"); + if (stateBeforeSend == "disabled" || stateBeforeSend == "true") { + item.setAttribute("disabled", stateBeforeSend); + } else { + item.removeAttribute("disabled"); + } + item.removeAttribute("stateBeforeSend"); + } + } +} + +function InitFileSaveAsMenu() { + document + .getElementById("cmd_saveAsFile") + .setAttribute("checked", defaultSaveOperation == "file"); + document + .getElementById("cmd_saveAsDraft") + .setAttribute("checked", defaultSaveOperation == "draft"); + document + .getElementById("cmd_saveAsTemplate") + .setAttribute("checked", defaultSaveOperation == "template"); +} + +function isSmimeSigningConfigured() { + return !!gCurrentIdentity?.getUnicharAttribute("signing_cert_name"); +} + +function isSmimeEncryptionConfigured() { + return !!gCurrentIdentity?.getUnicharAttribute("encryption_cert_name"); +} + +function isPgpConfigured() { + return !!gCurrentIdentity?.getUnicharAttribute("openpgp_key_id"); +} + +function toggleGlobalSignMessage() { + gSendSigned = !gSendSigned; + gUserTouchedSendSigned = true; + + updateAttachMyPubKey(); + showSendEncryptedAndSigned(); +} + +function updateAttachMyPubKey() { + if (!gUserTouchedAttachMyPubKey) { + if (gSendSigned) { + gAttachMyPublicPGPKey = gCurrentIdentity.attachPgpKey; + } else { + gAttachMyPublicPGPKey = false; + } + } +} + +function removeAutoDisableNotification() { + let notification = gComposeNotification.getNotificationWithValue( + "e2eeDisableNotification" + ); + if (notification) { + gComposeNotification.removeNotification(notification); + } +} + +function toggleEncryptMessage() { + gSendEncrypted = !gSendEncrypted; + + if (gSendEncrypted) { + removeAutoDisableNotification(); + } + + gUserTouchedSendEncrypted = true; + checkEncryptionState(); +} + +function toggleAttachMyPublicKey(target) { + gAttachMyPublicPGPKey = target.getAttribute("checked") != "true"; + target.setAttribute("checked", gAttachMyPublicPGPKey); + gUserTouchedAttachMyPubKey = true; +} + +function updateEncryptedSubject() { + let warnSubjectUnencrypted = + (!gSelectedTechnologyIsPGP && gSendEncrypted) || + (isPgpConfigured() && + gSelectedTechnologyIsPGP && + gSendEncrypted && + !gEncryptSubject); + + document + .getElementById("msgSubject") + .classList.toggle("with-icon", warnSubjectUnencrypted); + document.getElementById("msgEncryptedSubjectIcon").hidden = + !warnSubjectUnencrypted; +} + +function toggleEncryptedSubject() { + gEncryptSubject = !gEncryptSubject; + gUserTouchedEncryptSubject = true; + updateEncryptedSubject(); +} + +/** + * Update user interface elements + * + * @param {string} menu_id - suffix of the menu ID of the menu to update + */ +function setSecuritySettings(menu_id) { + let encItem = document.getElementById("menu_securityEncrypt" + menu_id); + encItem.setAttribute("checked", gSendEncrypted); + + let disableSig = false; + let disableEnc = false; + + if (gSelectedTechnologyIsPGP) { + if (!isPgpConfigured()) { + disableSig = true; + disableEnc = true; + } + } else { + if (!isSmimeSigningConfigured()) { + disableSig = true; + } + if (!isSmimeEncryptionConfigured()) { + disableEnc = true; + } + } + + let sigItem = document.getElementById("menu_securitySign" + menu_id); + sigItem.setAttribute("checked", gSendSigned && !disableSig); + + // The radio button to disable encryption is always active. + // This is necessary, even if the current identity doesn't have + // e2ee configured. If the user switches the sender identity of an + // email, we might keep encryption enabled, to not surprise the user. + // This means, we must always allow the user to disable encryption. + encItem.disabled = disableEnc && !gSendEncrypted; + + sigItem.disabled = disableSig; + + let pgpItem = document.getElementById("encTech_OpenPGP" + menu_id); + let smimeItem = document.getElementById("encTech_SMIME" + menu_id); + + smimeItem.disabled = + !isSmimeSigningConfigured() && !isSmimeEncryptionConfigured(); + + let encryptSubjectItem = document.getElementById( + `menu_securityEncryptSubject${menu_id}` + ); + + pgpItem.setAttribute("checked", gSelectedTechnologyIsPGP); + smimeItem.setAttribute("checked", !gSelectedTechnologyIsPGP); + encryptSubjectItem.setAttribute( + "checked", + !disableEnc && gSelectedTechnologyIsPGP && gSendEncrypted && gEncryptSubject + ); + encryptSubjectItem.setAttribute( + "disabled", + disableEnc || !gSelectedTechnologyIsPGP || !gSendEncrypted + ); + + document.getElementById("menu_recipientStatus" + menu_id).disabled = + disableEnc; + let manager = document.getElementById("menu_openManager" + menu_id); + manager.disabled = disableEnc; + manager.hidden = !gSelectedTechnologyIsPGP; +} + +/** + * Show the message security status based on the selected encryption technology. + * + * @param {boolean} [isSending=false] - If the key assistant was triggered + * during a sending attempt. + */ +function showMessageComposeSecurityStatus(isSending = false) { + if (gSelectedTechnologyIsPGP) { + if ( + Services.prefs.getBoolPref("mail.openpgp.key_assistant.enable", false) + ) { + gKeyAssistant.show(getEncryptionCompatibleRecipients(), isSending); + } else { + Recipients2CompFields(gMsgCompose.compFields); + window.openDialog( + "chrome://openpgp/content/ui/composeKeyStatus.xhtml", + "", + "chrome,modal,resizable,centerscreen", + { + compFields: gMsgCompose.compFields, + currentIdentity: gCurrentIdentity, + } + ); + checkEncryptionState(); + } + } else { + Recipients2CompFields(gMsgCompose.compFields); + // Copy current flags to S/MIME composeSecure object. + gMsgCompose.compFields.composeSecure.requireEncryptMessage = gSendEncrypted; + gMsgCompose.compFields.composeSecure.signMessage = gSendSigned; + window.openDialog( + "chrome://messenger-smime/content/msgCompSecurityInfo.xhtml", + "", + "chrome,modal,resizable,centerscreen", + { + compFields: gMsgCompose.compFields, + subject: document.getElementById("msgSubject").value, + isSigningCertAvailable: + gCurrentIdentity.getUnicharAttribute("signing_cert_name") != "", + isEncryptionCertAvailable: + gCurrentIdentity.getUnicharAttribute("encryption_cert_name") != "", + currentIdentity: gCurrentIdentity, + recipients: getEncryptionCompatibleRecipients(), + } + ); + } +} + +function msgComposeContextOnShowing(event) { + if (event.target.id != "msgComposeContext") { + return; + } + + // gSpellChecker handles all spell checking related to the context menu, + // except whether or not spell checking is enabled. We need the editor's + // spell checker for that. + gSpellChecker.initFromRemote( + nsContextMenu.contentData.spellInfo, + nsContextMenu.contentData.actor.manager + ); + + let canSpell = gSpellChecker.canSpellCheck; + let showDictionaries = canSpell && gSpellChecker.enabled; + let onMisspelling = gSpellChecker.overMisspelling; + let showUndo = canSpell && gSpellChecker.canUndo(); + + document.getElementById("spellCheckSeparator").hidden = !canSpell; + document.getElementById("spellCheckEnable").hidden = !canSpell; + document + .getElementById("spellCheckEnable") + .setAttribute("checked", canSpell && gSpellCheckingEnabled); + + document.getElementById("spellCheckAddToDictionary").hidden = !onMisspelling; + document.getElementById("spellCheckUndoAddToDictionary").hidden = !showUndo; + document.getElementById("spellCheckIgnoreWord").hidden = !onMisspelling; + + // Suggestion list. + document.getElementById("spellCheckSuggestionsSeparator").hidden = + !onMisspelling && !showUndo; + let separator = document.getElementById("spellCheckAddSep"); + separator.hidden = !onMisspelling; + if (onMisspelling) { + let addMenuItem = document.getElementById("spellCheckAddToDictionary"); + let suggestionCount = gSpellChecker.addSuggestionsToMenu( + addMenuItem.parentNode, + separator, + nsContextMenu.contentData.spellInfo.spellSuggestions + ); + document.getElementById("spellCheckNoSuggestions").hidden = + !suggestionCount == 0; + } else { + document.getElementById("spellCheckNoSuggestions").hidden = !false; + } + + // Dictionary list. + document.getElementById("spellCheckDictionaries").hidden = !showDictionaries; + if (canSpell) { + let dictMenu = document.getElementById("spellCheckDictionariesMenu"); + let dictSep = document.getElementById("spellCheckLanguageSeparator"); + let count = gSpellChecker.addDictionaryListToMenu(dictMenu, dictSep); + dictSep.hidden = count == 0; + document.getElementById("spellCheckAddDictionariesMain").hidden = !false; + } else if (this.onSpellcheckable) { + // when there is no spellchecker but we might be able to spellcheck + // add the add to dictionaries item. This will ensure that people + // with no dictionaries will be able to download them + document.getElementById("spellCheckLanguageSeparator").hidden = + !showDictionaries; + document.getElementById("spellCheckAddDictionariesMain").hidden = + !showDictionaries; + } else { + document.getElementById("spellCheckAddDictionariesMain").hidden = !false; + } + + updateEditItems(); + + // The rest of this block sends menu information to WebExtensions. + + let editor = GetCurrentEditorElement(); + let target = editor.contentDocument.elementFromPoint( + editor._contextX, + editor._contextY + ); + + let selectionInfo = SelectionUtils.getSelectionDetails(window); + let isContentSelected = !selectionInfo.docSelectionIsCollapsed; + let textSelected = selectionInfo.text; + let isTextSelected = !!textSelected.length; + + // Set up early the right flags for editable / not editable. + let editFlags = SpellCheckHelper.isEditable(target, window); + let onTextInput = (editFlags & SpellCheckHelper.TEXTINPUT) !== 0; + let onEditable = + (editFlags & + (SpellCheckHelper.EDITABLE | SpellCheckHelper.CONTENTEDITABLE)) !== + 0; + + let onImage = false; + let srcUrl = undefined; + + if (target.nodeType == Node.ELEMENT_NODE) { + if (target instanceof Ci.nsIImageLoadingContent && target.currentURI) { + onImage = true; + srcUrl = target.currentURI.spec; + } + } + + let onLink = false; + let linkText = undefined; + let linkUrl = undefined; + + let link = target.closest("a"); + if (link) { + onLink = true; + linkText = + link.textContent || + link.getAttribute("title") || + link.getAttribute("a") || + link.href || + ""; + linkUrl = link.href; + } + + let subject = { + menu: event.target, + tab: window, + isContentSelected, + isTextSelected, + onTextInput, + onLink, + onImage, + onEditable, + srcUrl, + linkText, + linkUrl, + selectionText: isTextSelected ? selectionInfo.fullText : undefined, + pageUrl: target.ownerGlobal.top.location.href, + onComposeBody: true, + }; + subject.context = subject; + subject.wrappedJSObject = subject; + + Services.obs.notifyObservers(subject, "on-prepare-contextmenu"); + Services.obs.notifyObservers(subject, "on-build-contextmenu"); +} + +function msgComposeContextOnHiding(event) { + if (event.target.id != "msgComposeContext") { + return; + } + + if (nsContextMenu.contentData.actor) { + nsContextMenu.contentData.actor.hiding(); + } + + nsContextMenu.contentData = null; + gSpellChecker.clearSuggestionsFromMenu(); + gSpellChecker.clearDictionaryListFromMenu(); + gSpellChecker.uninit(); +} + +function updateEditItems() { + goUpdateCommand("cmd_paste"); + goUpdateCommand("cmd_pasteNoFormatting"); + goUpdateCommand("cmd_pasteQuote"); + goUpdateCommand("cmd_delete"); + goUpdateCommand("cmd_renameAttachment"); + goUpdateCommand("cmd_reorderAttachments"); + goUpdateCommand("cmd_selectAll"); + goUpdateCommand("cmd_openAttachment"); + goUpdateCommand("cmd_findReplace"); + goUpdateCommand("cmd_find"); + goUpdateCommand("cmd_findNext"); + goUpdateCommand("cmd_findPrev"); +} + +function updateViewItems() { + goUpdateCommand("cmd_toggleAttachmentPane"); +} + +function updateOptionItems() { + goUpdateCommand("cmd_quoteMessage"); + goUpdateCommand("cmd_toggleReturnReceipt"); +} + +function updateAttachmentItems() { + goUpdateCommand("cmd_toggleAttachmentPane"); + goUpdateCommand("cmd_attachCloud"); + goUpdateCommand("cmd_convertCloud"); + goUpdateCommand("cmd_convertAttachment"); + goUpdateCommand("cmd_cancelUpload"); + goUpdateCommand("cmd_delete"); + goUpdateCommand("cmd_removeAllAttachments"); + goUpdateCommand("cmd_renameAttachment"); + updateReorderAttachmentsItems(); + goUpdateCommand("cmd_selectAll"); + goUpdateCommand("cmd_openAttachment"); + goUpdateCommand("cmd_attachVCard"); + goUpdateCommand("cmd_attachPublicKey"); +} + +function updateReorderAttachmentsItems() { + goUpdateCommand("cmd_reorderAttachments"); + goUpdateCommand("cmd_moveAttachmentLeft"); + goUpdateCommand("cmd_moveAttachmentRight"); + goUpdateCommand("cmd_moveAttachmentBundleUp"); + goUpdateCommand("cmd_moveAttachmentBundleDown"); + goUpdateCommand("cmd_moveAttachmentTop"); + goUpdateCommand("cmd_moveAttachmentBottom"); + goUpdateCommand("cmd_sortAttachmentsToggle"); +} + +/** + * Update all the commands for sending a message to reflect their current state. + */ +function updateSendCommands(aHaveController) { + updateSendLock(); + if (aHaveController) { + goUpdateCommand("cmd_sendButton"); + goUpdateCommand("cmd_sendNow"); + goUpdateCommand("cmd_sendLater"); + goUpdateCommand("cmd_sendWithCheck"); + } else { + goSetCommandEnabled( + "cmd_sendButton", + defaultController.isCommandEnabled("cmd_sendButton") + ); + goSetCommandEnabled( + "cmd_sendNow", + defaultController.isCommandEnabled("cmd_sendNow") + ); + goSetCommandEnabled( + "cmd_sendLater", + defaultController.isCommandEnabled("cmd_sendLater") + ); + goSetCommandEnabled( + "cmd_sendWithCheck", + defaultController.isCommandEnabled("cmd_sendWithCheck") + ); + } + + let changed = false; + let currentStates = {}; + let changedStates = {}; + for (let state of ["cmd_sendNow", "cmd_sendLater"]) { + currentStates[state] = defaultController.isCommandEnabled(state); + if ( + !gLastKnownComposeStates.hasOwnProperty(state) || + gLastKnownComposeStates[state] != currentStates[state] + ) { + gLastKnownComposeStates[state] = currentStates[state]; + changedStates[state] = currentStates[state]; + changed = true; + } + } + if (changed) { + window.dispatchEvent( + new CustomEvent("compose-state-changed", { detail: changedStates }) + ); + } +} + +function addAttachCloudMenuItems(aParentMenu) { + while (aParentMenu.hasChildNodes()) { + aParentMenu.lastChild.remove(); + } + + for (let account of cloudFileAccounts.configuredAccounts) { + if ( + aParentMenu.lastElementChild && + aParentMenu.lastElementChild.cloudFileUpload + ) { + aParentMenu.appendChild(document.createXULElement("menuseparator")); + } + + let item = document.createXULElement("menuitem"); + let iconURL = account.iconURL; + item.cloudFileAccount = account; + item.setAttribute( + "label", + cloudFileAccounts.getDisplayName(account) + "\u2026" + ); + if (iconURL) { + item.setAttribute("class", `${item.localName}-iconic`); + item.setAttribute("image", iconURL); + } + aParentMenu.appendChild(item); + + let previousUploads = account.getPreviousUploads(); + let addedFiles = []; + for (let upload of previousUploads) { + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(upload.path); + + // TODO: Figure out how to handle files that no longer exist on the filesystem. + if (!file.exists()) { + continue; + } + if (!addedFiles.find(f => f.name == upload.name || f.url == upload.url)) { + let fileItem = document.createXULElement("menuitem"); + fileItem.cloudFileUpload = upload; + fileItem.cloudFileAccount = account; + fileItem.setAttribute("label", upload.name); + fileItem.setAttribute("class", "menuitem-iconic"); + fileItem.setAttribute("image", "moz-icon://" + upload.name); + aParentMenu.appendChild(fileItem); + addedFiles.push({ name: upload.name, url: upload.url }); + } + } + } +} + +function addConvertCloudMenuItems(aParentMenu, aAfterNodeId, aRadioGroup) { + let afterNode = document.getElementById(aAfterNodeId); + while (afterNode.nextElementSibling) { + afterNode.nextElementSibling.remove(); + } + + if (!gAttachmentBucket.selectedItem.sendViaCloud) { + let item = document.getElementById( + "convertCloudMenuItems_popup_convertAttachment" + ); + item.setAttribute("checked", "true"); + } + + for (let account of cloudFileAccounts.configuredAccounts) { + let item = document.createXULElement("menuitem"); + let iconURL = account.iconURL; + item.cloudFileAccount = account; + item.setAttribute("label", cloudFileAccounts.getDisplayName(account)); + item.setAttribute("type", "radio"); + item.setAttribute("name", aRadioGroup); + + if ( + gAttachmentBucket.selectedItem.cloudFileAccount && + gAttachmentBucket.selectedItem.cloudFileAccount.accountKey == + account.accountKey + ) { + item.setAttribute("checked", "true"); + } else if (iconURL) { + item.setAttribute("class", "menu-iconic"); + item.setAttribute("image", iconURL); + } + + aParentMenu.appendChild(item); + } + + // Check if the cloudFile has an invalid account and deselect the default + // option, allowing to convert it back to a regular file. + if ( + gAttachmentBucket.selectedItem.attachment.sendViaCloud && + !gAttachmentBucket.selectedItem.cloudFileAccount + ) { + let regularItem = document.getElementById( + "convertCloudMenuItems_popup_convertAttachment" + ); + regularItem.removeAttribute("checked"); + } +} + +async function updateAttachmentItemProperties(attachmentItem) { + // FIXME: The UI logic should be handled by the attachment list or item + // itself. + if (attachmentItem.uploading) { + // uploading/renaming + attachmentItem.setAttribute( + "tooltiptext", + getComposeBundle().getFormattedString("cloudFileUploadingTooltip", [ + cloudFileAccounts.getDisplayName(attachmentItem.uploading), + ]) + ); + gAttachmentBucket.setCloudIcon(attachmentItem, ""); + } else if (attachmentItem.attachment.sendViaCloud) { + let [tooltipUnknownAccountText, introText, titleText] = + await document.l10n.formatValues([ + "cloud-file-unknown-account-tooltip", + { + id: "cloud-file-placeholder-intro", + args: { filename: attachmentItem.attachment.name }, + }, + { + id: "cloud-file-placeholder-title", + args: { filename: attachmentItem.attachment.name }, + }, + ]); + + // uploaded + let tooltiptext; + if (attachmentItem.cloudFileAccount) { + tooltiptext = getComposeBundle().getFormattedString( + "cloudFileUploadedTooltip", + [cloudFileAccounts.getDisplayName(attachmentItem.cloudFileAccount)] + ); + } else { + tooltiptext = tooltipUnknownAccountText; + } + attachmentItem.setAttribute("tooltiptext", tooltiptext); + + gAttachmentBucket.setAttachmentName( + attachmentItem, + attachmentItem.attachment.name + ); + gAttachmentBucket.setCloudIcon( + attachmentItem, + attachmentItem.cloudFileUpload.serviceIcon + ); + + // Update the CloudPartHeaderData, if there is a valid cloudFileUpload. + if (attachmentItem.cloudFileUpload) { + let json = JSON.stringify(attachmentItem.cloudFileUpload); + // Convert 16bit JavaScript string to a byteString, to make it work with + // btoa(). + attachmentItem.attachment.cloudPartHeaderData = btoa( + MailStringUtils.stringToByteString(json) + ); + } + + // Update the cloudFile placeholder file. + attachmentItem.attachment.htmlAnnotation = `<!DOCTYPE html> +<html> + <head> + <title>${titleText}</title> + <meta charset="utf-8" /> + </head> + <body> + <div style="padding: 15px; font-family: Calibri, sans-serif;"> + <div style="margin-bottom: 15px;" id="cloudAttachmentListHeader">${introText}</div> + <ul>${ + ( + await gCloudAttachmentLinkManager._createNode( + document, + attachmentItem.cloudFileUpload, + true + ) + ).outerHTML + }</ul> + </div> + </body> +</html>`; + + // Calculate size of placeholder attachment. + attachmentItem.cloudHtmlFileSize = new TextEncoder().encode( + attachmentItem.attachment.htmlAnnotation + ).length; + } else { + // local + attachmentItem.setAttribute("tooltiptext", attachmentItem.attachment.url); + gAttachmentBucket.setAttachmentName( + attachmentItem, + attachmentItem.attachment.name + ); + gAttachmentBucket.setCloudIcon(attachmentItem, ""); + + // Remove placeholder file size information. + delete attachmentItem.cloudHtmlFileSize; + } + updateAttachmentPane(); +} + +async function showLocalizedCloudFileAlert( + ex, + provider = ex.cloudProvider, + filename = ex.cloudFileName +) { + let bundle = getComposeBundle(); + let localizedTitle, localizedMessage; + + switch (ex.result) { + case cloudFileAccounts.constants.uploadCancelled: + // No alerts for cancelled uploads. + return; + case cloudFileAccounts.constants.deleteErr: + localizedTitle = bundle.getString("errorCloudFileDeletion.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileDeletion.message", + [provider, filename] + ); + break; + case cloudFileAccounts.constants.offlineErr: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-connection-error-title" + ); + localizedMessage = await l10nCompose.formatValue( + "cloud-file-connection-error", + { + provider, + } + ); + break; + case cloudFileAccounts.constants.authErr: + localizedTitle = bundle.getString("errorCloudFileAuth.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileAuth.message", + [provider] + ); + break; + case cloudFileAccounts.constants.uploadErrWithCustomMessage: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-upload-error-with-custom-message-title", + { + provider, + filename, + } + ); + localizedMessage = ex.message; + break; + case cloudFileAccounts.constants.uploadErr: + localizedTitle = bundle.getString("errorCloudFileUpload.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileUpload.message", + [provider, filename] + ); + break; + case cloudFileAccounts.constants.uploadWouldExceedQuota: + localizedTitle = bundle.getString("errorCloudFileQuota.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileQuota.message", + [provider, filename] + ); + break; + case cloudFileAccounts.constants.uploadExceedsFileLimit: + localizedTitle = bundle.getString("errorCloudFileLimit.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileLimit.message", + [provider, filename] + ); + break; + case cloudFileAccounts.constants.renameNotSupported: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-rename-error-title" + ); + localizedMessage = await l10nCompose.formatValue( + "cloud-file-rename-not-supported", + { + provider, + } + ); + break; + case cloudFileAccounts.constants.renameErrWithCustomMessage: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-rename-error-with-custom-message-title", + { + provider, + filename, + } + ); + localizedMessage = ex.message; + break; + case cloudFileAccounts.constants.renameErr: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-rename-error-title" + ); + localizedMessage = await l10nCompose.formatValue( + "cloud-file-rename-error", + { + provider, + filename, + } + ); + break; + case cloudFileAccounts.constants.attachmentErr: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-attachment-error-title" + ); + localizedMessage = await l10nCompose.formatValue( + "cloud-file-attachment-error", + { + filename, + } + ); + break; + case cloudFileAccounts.constants.accountErr: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-account-error-title" + ); + localizedMessage = await l10nCompose.formatValue( + "cloud-file-account-error", + { + filename, + } + ); + break; + default: + localizedTitle = bundle.getString("errorCloudFileOther.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileOther.message", + [provider] + ); + } + + Services.prompt.alert(window, localizedTitle, localizedMessage); +} + +/** + * @typedef UpdateSettings + * @property {CloudFileAccount} [cloudFileAccount] - cloud file account to store + * the attachment + * @property {CloudFileUpload} [relatedCloudFileUpload] - information about an + * already uploaded file this upload is related to, e.g. renaming a repeatedly + * used cloud file or updating the content of a cloud file + * @property {nsIFile} [file] - file to replace the current attachments content + * @property {string} [name] - name to replace the current attachments name + */ + +/** + * Update the name and or the content of an attachment, as well as its local/cloud + * state. + * + * @param {DOMNode} attachmentItem - the existing attachmentItem + * @param {UpdateSettings} [updateSettings] - object defining how to update the + * attachment + */ +async function UpdateAttachment(attachmentItem, updateSettings = {}) { + if (!attachmentItem || !attachmentItem.attachment) { + throw new Error("Unexpected: Invalid attachment item."); + } + + let originalAttachment = Object.assign({}, attachmentItem.attachment); + let eventOnDone = false; + + // Ignore empty or falsy names. + let name = updateSettings.name || attachmentItem.attachment.name; + + let destCloudFileAccount = updateSettings.hasOwnProperty("cloudFileAccount") + ? updateSettings.cloudFileAccount + : attachmentItem.cloudFileAccount; + + try { + if ( + // Bypass upload and set provided relatedCloudFileUpload. + updateSettings.relatedCloudFileUpload && + updateSettings.cloudFileAccount && + updateSettings.cloudFileAccount.reuseUploads && + !updateSettings.file && + !updateSettings.name + ) { + attachmentItem.attachment.sendViaCloud = true; + attachmentItem.attachment.contentLocation = + updateSettings.relatedCloudFileUpload.url; + attachmentItem.attachment.cloudFileAccountKey = + updateSettings.cloudFileAccount.accountKey; + + attachmentItem.cloudFileAccount = updateSettings.cloudFileAccount; + attachmentItem.cloudFileUpload = updateSettings.relatedCloudFileUpload; + gAttachmentBucket.setCloudIcon( + attachmentItem, + updateSettings.relatedCloudFileUpload.serviceIcon + ); + + eventOnDone = new CustomEvent("attachment-uploaded", { + bubbles: true, + cancelable: true, + }); + } else if ( + // Handle a local -> local replace/rename. + !attachmentItem.attachment.sendViaCloud && + !updateSettings.hasOwnProperty("cloudFileAccount") + ) { + // Both modes - rename and replace - require the same UI handling. + eventOnDone = new CustomEvent("attachment-renamed", { + bubbles: true, + cancelable: true, + detail: originalAttachment, + }); + } else if ( + // Handle a cloud -> local conversion. + attachmentItem.attachment.sendViaCloud && + updateSettings.cloudFileAccount === null + ) { + // Throw if the linked local file does not exists (i.e. invalid draft). + if (!(await IOUtils.exists(attachmentItem.cloudFileUpload.path))) { + throw Components.Exception( + `CloudFile Error: Attachment file not found: ${attachmentItem.cloudFileUpload.path}`, + cloudFileAccounts.constants.attachmentErr + ); + } + + if (attachmentItem.cloudFileAccount) { + // A cloud delete error is not considered to be a fatal error. It is + // not preventing the attachment from being removed from the composer. + attachmentItem.cloudFileAccount + .deleteFile(window, attachmentItem.cloudFileUpload.id) + .catch(ex => console.warn(ex.message)); + } + // Clean up attachment from cloud bits. + attachmentItem.attachment.sendViaCloud = false; + attachmentItem.attachment.htmlAnnotation = ""; + attachmentItem.attachment.contentLocation = ""; + attachmentItem.attachment.cloudFileAccountKey = ""; + attachmentItem.attachment.cloudPartHeaderData = ""; + delete attachmentItem.cloudFileAccount; + delete attachmentItem.cloudFileUpload; + + eventOnDone = new CustomEvent("attachment-converted-to-regular", { + bubbles: true, + cancelable: true, + detail: originalAttachment, + }); + } else if ( + // Exit early if offline. + Services.io.offline + ) { + throw Components.Exception( + "Connection error: Offline", + cloudFileAccounts.constants.offlineErr + ); + } else { + // Handle a cloud -> cloud move/rename or a local -> cloud upload. + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + + let mode = "upload"; + if (attachmentItem.attachment.sendViaCloud) { + // Throw if the used cloudFile account does not exists (invalid draft, + // disabled add-on, removed account). + if ( + !destCloudFileAccount || + !cloudFileAccounts.getAccount(destCloudFileAccount.accountKey) + ) { + throw Components.Exception( + `CloudFile Error: Account not found: ${destCloudFileAccount?.accountKey}`, + cloudFileAccounts.constants.accountErr + ); + } + + if ( + attachmentItem.cloudFileUpload && + attachmentItem.cloudFileAccount == destCloudFileAccount && + !updateSettings.file && + !destCloudFileAccount.isReusedUpload(attachmentItem.cloudFileUpload) + ) { + mode = "rename"; + } else { + mode = "move"; + // Throw if the linked local file does not exists (invalid draft, removed + // local file). + if ( + !fileHandler + .getFileFromURLSpec(attachmentItem.attachment.url) + .exists() + ) { + throw Components.Exception( + `CloudFile Error: Attachment file not found: ${ + fileHandler.getFileFromURLSpec(attachmentItem.attachment.url) + .path + }`, + cloudFileAccounts.constants.attachmentErr + ); + } + if (!(await IOUtils.exists(attachmentItem.cloudFileUpload.path))) { + throw Components.Exception( + `CloudFile Error: Attachment file not found: ${attachmentItem.cloudFileUpload.path}`, + cloudFileAccounts.constants.attachmentErr + ); + } + } + } + + // Notify the UI that we're starting the upload process: disable send commands + // and show a "connecting" icon for the attachment. + gNumUploadingAttachments++; + updateSendCommands(true); + + attachmentItem.uploading = destCloudFileAccount; + await updateAttachmentItemProperties(attachmentItem); + + const eventsOnStart = { + upload: "attachment-uploading", + move: "attachment-moving", + }; + if (eventsOnStart[mode]) { + attachmentItem.dispatchEvent( + new CustomEvent(eventsOnStart[mode], { + bubbles: true, + cancelable: true, + detail: attachmentItem.attachment, + }) + ); + } + + try { + let upload; + if (mode == "rename") { + upload = await destCloudFileAccount.renameFile( + window, + attachmentItem.cloudFileUpload.id, + name + ); + } else { + let file = + updateSettings.file || + fileHandler.getFileFromURLSpec(attachmentItem.attachment.url); + + upload = await destCloudFileAccount.uploadFile( + window, + file, + name, + updateSettings.relatedCloudFileUpload + ); + + attachmentItem.cloudFileAccount = destCloudFileAccount; + attachmentItem.attachment.sendViaCloud = true; + attachmentItem.attachment.cloudFileAccountKey = + destCloudFileAccount.accountKey; + + Services.telemetry.keyedScalarAdd( + "tb.filelink.uploaded_size", + destCloudFileAccount.type, + file.fileSize + ); + } + + attachmentItem.cloudFileUpload = upload; + attachmentItem.attachment.contentLocation = upload.url; + + const eventsOnSuccess = { + upload: "attachment-uploaded", + move: "attachment-moved", + rename: "attachment-renamed", + }; + if (eventsOnSuccess[mode]) { + eventOnDone = new CustomEvent(eventsOnSuccess[mode], { + bubbles: true, + cancelable: true, + detail: originalAttachment, + }); + } + } catch (ex) { + const eventsOnFailure = { + upload: "attachment-upload-failed", + move: "attachment-move-failed", + }; + if (eventsOnFailure[mode]) { + eventOnDone = new CustomEvent(eventsOnFailure[mode], { + bubbles: true, + cancelable: true, + detail: ex.result, + }); + } + throw ex; + } finally { + attachmentItem.uploading = false; + gNumUploadingAttachments--; + updateSendCommands(true); + } + } + + // Update the local attachment. + if (updateSettings.file) { + let attachment = FileToAttachment(updateSettings.file); + attachmentItem.attachment.size = attachment.size; + attachmentItem.attachment.url = attachment.url; + } + attachmentItem.attachment.name = name; + + AttachmentsChanged(); + // Update cmd_sortAttachmentsToggle because replacing/renaming may change the + // current sort order. + goUpdateCommand("cmd_sortAttachmentsToggle"); + } catch (ex) { + // Attach provider and fileName to the Exception, so showLocalizedCloudFileAlert() + // can display the proper alert message. + ex.cloudProvider = destCloudFileAccount + ? cloudFileAccounts.getDisplayName(destCloudFileAccount) + : ""; + ex.cloudFileName = originalAttachment?.name || name; + throw ex; + } finally { + await updateAttachmentItemProperties(attachmentItem); + if (eventOnDone) { + attachmentItem.dispatchEvent(eventOnDone); + } + } +} + +function attachToCloud(event) { + gMsgCompose.allowRemoteContent = true; + if (event.target.cloudFileUpload) { + attachToCloudRepeat( + event.target.cloudFileUpload, + event.target.cloudFileAccount + ); + } else { + attachToCloudNew(event.target.cloudFileAccount); + } + event.stopPropagation(); +} + +/** + * Attach a file that has already been uploaded to a cloud provider. + * + * @param {object} upload - the cloudFileUpload of the already uploaded file + * @param {object} account - the cloudFileAccount of the already uploaded file + */ +async function attachToCloudRepeat(upload, account) { + gMsgCompose.allowRemoteContent = true; + let file = FileUtils.File(upload.path); + let attachment = FileToAttachment(file); + attachment.name = upload.name; + + let addedAttachmentItems = await AddAttachments([attachment]); + if (addedAttachmentItems.length > 0) { + try { + await UpdateAttachment(addedAttachmentItems[0], { + cloudFileAccount: account, + relatedCloudFileUpload: upload, + }); + } catch (ex) { + showLocalizedCloudFileAlert(ex); + } + } +} + +/** + * Prompt the user for a list of files to attach via a cloud provider. + * + * @param aAccount the cloud provider to upload the files to + */ +async function attachToCloudNew(aAccount) { + // We need to let the user pick local file(s) to upload to the cloud and + // gather url(s) to those files. + var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init( + window, + getComposeBundle().getFormattedString("chooseFileToAttachViaCloud", [ + cloudFileAccounts.getDisplayName(aAccount), + ]), + Ci.nsIFilePicker.modeOpenMultiple + ); + + var lastDirectory = GetLastAttachDirectory(); + if (lastDirectory) { + fp.displayDirectory = lastDirectory; + } + + fp.appendFilters(Ci.nsIFilePicker.filterAll); + + let rv = await new Promise(resolve => fp.open(resolve)); + if (rv != Ci.nsIFilePicker.returnOK || !fp.files) { + return; + } + + let files = [...fp.files]; + let attachments = files.map(f => FileToAttachment(f)); + let addedAttachmentItems = await AddAttachments(attachments); + SetLastAttachDirectory(files[files.length - 1]); + + let promises = []; + for (let attachmentItem of addedAttachmentItems) { + promises.push( + UpdateAttachment(attachmentItem, { cloudFileAccount: aAccount }).catch( + ex => { + RemoveAttachments([attachmentItem]); + showLocalizedCloudFileAlert(ex); + } + ) + ); + } + + await Promise.all(promises); +} + +/** + * Convert an array of attachments to cloud attachments. + * + * @param aItems an array of <attachmentitem>s containing the attachments in + * question + * @param aAccount the cloud account to upload the files to + */ +async function convertListItemsToCloudAttachment(aItems, aAccount) { + gMsgCompose.allowRemoteContent = true; + let promises = []; + for (let item of aItems) { + // Bail out, if we would convert to the current account. + if ( + item.attachment.sendViaCloud && + item.cloudFileAccount && + item.cloudFileAccount == aAccount + ) { + continue; + } + promises.push( + UpdateAttachment(item, { cloudFileAccount: aAccount }).catch( + showLocalizedCloudFileAlert + ) + ); + } + await Promise.all(promises); +} + +/** + * Convert the selected attachments to cloud attachments. + * + * @param aAccount the cloud account to upload the files to + */ +function convertSelectedToCloudAttachment(aAccount) { + convertListItemsToCloudAttachment( + [...gAttachmentBucket.selectedItems], + aAccount + ); +} + +/** + * Convert an array of nsIMsgAttachments to cloud attachments. + * + * @param aAttachments an array of nsIMsgAttachments + * @param aAccount the cloud account to upload the files to + */ +function convertToCloudAttachment(aAttachments, aAccount) { + let items = []; + for (let attachment of aAttachments) { + let item = gAttachmentBucket.findItemForAttachment(attachment); + if (item) { + items.push(item); + } + } + + convertListItemsToCloudAttachment(items, aAccount); +} + +/** + * Convert an array of attachments to regular (non-cloud) attachments. + * + * @param aItems an array of <attachmentitem>s containing the attachments in + * question + */ +async function convertListItemsToRegularAttachment(aItems) { + let promises = []; + for (let item of aItems) { + if (!item.attachment.sendViaCloud) { + continue; + } + promises.push( + UpdateAttachment(item, { cloudFileAccount: null }).catch( + showLocalizedCloudFileAlert + ) + ); + } + await Promise.all(promises); +} + +/** + * Convert the selected attachments to regular (non-cloud) attachments. + */ +function convertSelectedToRegularAttachment() { + return convertListItemsToRegularAttachment([ + ...gAttachmentBucket.selectedItems, + ]); +} + +/** + * Convert an array of nsIMsgAttachments to regular (non-cloud) attachments. + * + * @param aAttachments an array of nsIMsgAttachments + */ +function convertToRegularAttachment(aAttachments) { + let items = []; + for (let attachment of aAttachments) { + let item = gAttachmentBucket.findItemForAttachment(attachment); + if (item) { + items.push(item); + } + } + + return convertListItemsToRegularAttachment(items); +} + +/* messageComposeOfflineQuitObserver is notified whenever the network + * connection status has switched to offline, or when the application + * has received a request to quit. + */ +var messageComposeOfflineQuitObserver = { + observe(aSubject, aTopic, aData) { + // sanity checks + if (aTopic == "network:offline-status-changed") { + MessageComposeOfflineStateChanged(Services.io.offline); + } else if ( + aTopic == "quit-application-requested" && + aSubject instanceof Ci.nsISupportsPRBool && + !aSubject.data + ) { + // Check whether to veto the quit request + // (unless another observer already did). + aSubject.data = !ComposeCanClose(); + } + }, +}; + +function AddMessageComposeOfflineQuitObserver() { + Services.obs.addObserver( + messageComposeOfflineQuitObserver, + "network:offline-status-changed" + ); + Services.obs.addObserver( + messageComposeOfflineQuitObserver, + "quit-application-requested" + ); + + // set the initial state of the send button + MessageComposeOfflineStateChanged(Services.io.offline); +} + +function RemoveMessageComposeOfflineQuitObserver() { + Services.obs.removeObserver( + messageComposeOfflineQuitObserver, + "network:offline-status-changed" + ); + Services.obs.removeObserver( + messageComposeOfflineQuitObserver, + "quit-application-requested" + ); +} + +function MessageComposeOfflineStateChanged(goingOffline) { + try { + var sendButton = document.getElementById("button-send"); + var sendNowMenuItem = document.getElementById("menu-item-send-now"); + + if (!gSavedSendNowKey) { + gSavedSendNowKey = sendNowMenuItem.getAttribute("key"); + } + + // don't use goUpdateCommand here ... the defaultController might not be installed yet + updateSendCommands(false); + + if (goingOffline) { + sendButton.label = sendButton.getAttribute("later_label"); + sendButton.setAttribute( + "tooltiptext", + sendButton.getAttribute("later_tooltiptext") + ); + sendNowMenuItem.removeAttribute("key"); + } else { + sendButton.label = sendButton.getAttribute("now_label"); + sendButton.setAttribute( + "tooltiptext", + sendButton.getAttribute("now_tooltiptext") + ); + if (gSavedSendNowKey) { + sendNowMenuItem.setAttribute("key", gSavedSendNowKey); + } + } + } catch (e) {} +} + +function DoCommandPrint() { + let browser = GetCurrentEditorElement(); + browser.contentDocument.title = + document.getElementById("msgSubject").value.trim() || + getComposeBundle().getString("defaultSubject"); + PrintUtils.startPrintWindow(browser.browsingContext, {}); +} + +/** + * Locks/Unlocks the window widgets while a message is being saved/sent. + * Locking means to disable all possible items in the window so that + * the user can't click/activate anything. + * + * @param aDisable true = lock the window. false = unlock the window. + */ +function ToggleWindowLock(aDisable) { + if (aDisable) { + // Save the active element so we can focus it again. + ToggleWindowLock.activeElement = document.activeElement; + } + gWindowLocked = aDisable; + updateAllItems(aDisable); + updateEditableFields(aDisable); + if (!aDisable) { + updateComposeItems(); + // Refocus what had focus when the lock began. + ToggleWindowLock.activeElement?.focus(); + } +} + +/* This function will go away soon as now arguments are passed to the window using a object of type nsMsgComposeParams instead of a string */ +function GetArgs(originalData) { + var args = {}; + + if (originalData == "") { + return null; + } + + var data = ""; + var separator = String.fromCharCode(1); + + var quoteChar = ""; + var prevChar = ""; + var nextChar = ""; + for (let i = 0; i < originalData.length; i++, prevChar = aChar) { + var aChar = originalData.charAt(i); + var aCharCode = originalData.charCodeAt(i); + if (i < originalData.length - 1) { + nextChar = originalData.charAt(i + 1); + } else { + nextChar = ""; + } + + if (aChar == quoteChar && (nextChar == "," || nextChar == "")) { + quoteChar = ""; + data += aChar; + } else if ((aCharCode == 39 || aCharCode == 34) && prevChar == "=") { + // quote or double quote + if (quoteChar == "") { + quoteChar = aChar; + } + data += aChar; + } else if (aChar == ",") { + if (quoteChar == "") { + data += separator; + } else { + data += aChar; + } + } else { + data += aChar; + } + } + + var pairs = data.split(separator); + // dump("Compose: argument: {" + data + "}\n"); + + for (let i = pairs.length - 1; i >= 0; i--) { + var pos = pairs[i].indexOf("="); + if (pos == -1) { + continue; + } + var argname = pairs[i].substring(0, pos); + var argvalue = pairs[i].substring(pos + 1); + if (argvalue.startsWith("'") && argvalue.endsWith("'")) { + args[argname] = argvalue.substring(1, argvalue.length - 1); + } else { + try { + args[argname] = decodeURIComponent(argvalue); + } catch (e) { + args[argname] = argvalue; + } + } + // dump("[" + argname + "=" + args[argname] + "]\n"); + } + return args; +} + +function ComposeFieldsReady() { + // If we are in plain text, we need to set the wrap column + if (!gMsgCompose.composeHTML) { + try { + gMsgCompose.editor.wrapWidth = gMsgCompose.wrapLength; + } catch (e) { + dump("### textEditor.wrapWidth exception text: " + e + " - failed\n"); + } + } + + CompFields2Recipients(gMsgCompose.compFields); + SetComposeWindowTitle(); + updateEditableFields(false); + gLoadingComplete = true; + + // Set up observers to recheck limit and encyption on recipients change. + observeRecipientsChange(); + + // Perform the initial checks. + checkPublicRecipientsLimit(); + checkEncryptionState(); +} + +/** + * Set up observers to recheck limit and encyption on recipients change. + */ +function observeRecipientsChange() { + // Observe childList changes of `To` and `Cc` address rows to check if we need + // to show the public bulk recipients notification according to the threshold. + // So far we're only counting recipient pills, not plain text addresses. + gRecipientObserver = new MutationObserver(function (mutations) { + if (mutations.some(m => m.type == "childList")) { + checkPublicRecipientsLimit(); + } + }); + gRecipientObserver.observe(document.getElementById("toAddrContainer"), { + childList: true, + }); + gRecipientObserver.observe(document.getElementById("ccAddrContainer"), { + childList: true, + }); + + function callCheckEncryptionState() { + // We must not pass the parameters that we get from observing. + checkEncryptionState(); + } + + gRecipientKeysObserver = new MutationObserver(callCheckEncryptionState); + gRecipientKeysObserver.observe(document.getElementById("toAddrContainer"), { + childList: true, + }); + gRecipientKeysObserver.observe(document.getElementById("ccAddrContainer"), { + childList: true, + }); + gRecipientKeysObserver.observe(document.getElementById("bccAddrContainer"), { + childList: true, + }); +} + +// checks if the passed in string is a mailto url, if it is, generates nsIMsgComposeParams +// for the url and returns them. +function handleMailtoArgs(mailtoUrl) { + // see if the string is a mailto url....do this by checking the first 7 characters of the string + if (mailtoUrl.toLowerCase().startsWith("mailto:")) { + // if it is a mailto url, turn the mailto url into a MsgComposeParams object.... + let uri = Services.io.newURI(mailtoUrl); + + if (uri) { + return MailServices.compose.getParamsForMailto(uri); + } + } + + return null; +} + +/** + * Handle ESC keypress from composition window for + * notifications with close button in the + * attachmentNotificationBox. + */ +function handleEsc() { + let activeElement = document.activeElement; + + if (activeElement.id == "messageEditor") { + // Focus within the message body. + let findbar = document.getElementById("FindToolbar"); + if (!findbar.hidden) { + // If findbar is visible hide it. + // Focus on the findbar is handled by findbar itself. + findbar.close(); + } else { + // Close the most recently shown notification. + gComposeNotification.currentNotification?.close(); + } + return; + } + + // If focus is within a notification, close the corresponding notification. + for (let notification of gComposeNotification.allNotifications) { + if (notification.contains(activeElement)) { + notification.close(); + return; + } + } +} + +/** + * This state machine manages all showing and hiding of the attachment + * notification bar. It is only called if any change happened so that + * recalculating of the notification is needed: + * - keywords changed + * - manual reminder was toggled + * - attachments changed + * - manual reminder is disabled + * + * It does not track whether the notification is still up when it should be. + * That allows the user to close it any time without this function showing + * it again. + * We ensure notification is only shown on right events, e.g. only when we have + * keywords and attachments were removed (but not when we have keywords and + * manual reminder was just turned off). We always show the notification + * again if keywords change (if no attachments and no manual reminder). + * + * @param aForce If set to true, notification will be shown immediately if + * there are any keywords. If set to false, it is shown only when + * they have changed. + */ +function manageAttachmentNotification(aForce = false) { + let keywords; + let keywordsCount = 0; + + // First see if the notification is to be hidden due to reasons other than + // not having keywords. + let removeNotification = attachmentNotificationSupressed(); + + // If that is not true, we need to look at the state of keywords. + if (!removeNotification) { + if (attachmentWorker.lastMessage) { + // We know the state of keywords, so process them. + if (attachmentWorker.lastMessage.length) { + keywords = attachmentWorker.lastMessage.join(", "); + keywordsCount = attachmentWorker.lastMessage.length; + } + removeNotification = keywordsCount == 0; + } else { + // We don't know keywords, so get them first. + // If aForce was true, and some keywords are found, we get to run again from + // attachmentWorker.onmessage(). + gAttachmentNotifier.redetectKeywords(aForce); + return; + } + } + + let notification = + gComposeNotification.getNotificationWithValue("attachmentReminder"); + if (removeNotification) { + if (notification) { + gComposeNotification.removeNotification(notification); + } + return; + } + + // We have some keywords, however only pop up the notification if requested + // to do so. + if (!aForce) { + return; + } + + let textValue = getComposeBundle().getString( + "attachmentReminderKeywordsMsgs" + ); + textValue = PluralForm.get(keywordsCount, textValue).replace( + "#1", + keywordsCount + ); + // If the notification already exists, we simply add the new attachment + // specific keywords to the existing notification instead of creating it + // from scratch. + if (notification) { + let msgContainer = notification.messageText.querySelector( + "#attachmentReminderText" + ); + msgContainer.textContent = textValue; + let keywordsContainer = notification.messageText.querySelector( + "#attachmentKeywords" + ); + keywordsContainer.textContent = keywords; + return; + } + + // Construct the notification as we don't have one. + let msg = document.createElement("div"); + msg.onclick = function (event) { + openOptionsDialog("paneCompose", "compositionAttachmentsCategory", { + subdialog: "attachment_reminder_button", + }); + }; + + let msgText = document.createElement("span"); + msg.appendChild(msgText); + msgText.id = "attachmentReminderText"; + msgText.textContent = textValue; + let msgKeywords = document.createElement("span"); + msg.appendChild(msgKeywords); + msgKeywords.id = "attachmentKeywords"; + msgKeywords.textContent = keywords; + let addButton = { + "l10n-id": "add-attachment-notification-reminder2", + callback(aNotificationBar, aButton) { + goDoCommand("cmd_attachFile"); + return true; // keep notification open (the state machine will decide on it later) + }, + }; + + let remindLaterMenuPopup = document.createXULElement("menupopup"); + remindLaterMenuPopup.id = "reminderBarPopup"; + let disableAttachmentReminder = document.createXULElement("menuitem"); + disableAttachmentReminder.id = "disableReminder"; + disableAttachmentReminder.setAttribute( + "label", + getComposeBundle().getString("disableAttachmentReminderButton") + ); + disableAttachmentReminder.addEventListener("command", event => { + gDisableAttachmentReminder = true; + toggleAttachmentReminder(false); + event.stopPropagation(); + }); + remindLaterMenuPopup.appendChild(disableAttachmentReminder); + + // The notification code only deals with buttons but we need a toolbarbutton, + // so we construct it and add it ourselves. + let remindButton = document.createXULElement("toolbarbutton", { + is: "toolbarbutton-menu-button", + }); + remindButton.classList.add("notification-button", "small-button"); + remindButton.setAttribute( + "accessKey", + getComposeBundle().getString("remindLaterButton.accesskey") + ); + remindButton.setAttribute( + "label", + getComposeBundle().getString("remindLaterButton") + ); + remindButton.addEventListener("command", function (event) { + toggleAttachmentReminder(true); + }); + remindButton.appendChild(remindLaterMenuPopup); + + notification = gComposeNotification.appendNotification( + "attachmentReminder", + { + label: "", + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + }, + [addButton] + ); + notification.setAttribute("id", "attachmentNotificationBox"); + + notification.messageText.appendChild(msg); + notification.buttonContainer.appendChild(remindButton); +} + +function clearRecipPillKeyIssues() { + for (let pill of document.querySelectorAll("mail-address-pill.key-issue")) { + pill.classList.remove("key-issue"); + } +} + +/** + * @returns {string[]} - All current recipient email addresses, lowercase. + */ +function getEncryptionCompatibleRecipients() { + let recipientPills = [ + ...document.querySelectorAll( + "#toAddrContainer > mail-address-pill, #ccAddrContainer > mail-address-pill, #bccAddrContainer > mail-address-pill" + ), + ]; + let recipients = [ + ...new Set(recipientPills.map(pill => pill.emailAddress.toLowerCase())), + ]; + return recipients; +} + +const PRErrorCodeSuccess = 0; +const certificateUsageEmailRecipient = 0x0020; + +var gEmailsWithMissingKeys = null; +var gEmailsWithMissingCerts = null; + +/** + * @returns {boolean} true if checking openpgp keys is necessary + */ +function mustCheckRecipientKeys() { + let remindOpenPGP = Services.prefs.getBoolPref( + "mail.openpgp.remind_encryption_possible" + ); + + let autoEnablePref = Services.prefs.getBoolPref( + "mail.e2ee.auto_enable", + false + ); + + return ( + isPgpConfigured() && (gSendEncrypted || remindOpenPGP || autoEnablePref) + ); +} + +/** + * Check available OpenPGP public encryption keys for the given email + * addresses. (This function assumes the caller has already called + * mustCheckRecipientKeys() and the result was true.) + * + * gEmailsWithMissingKeys will be set to an array of email addresses + * (a subset of the input) that do NOT have a usable + * (valid + accepted) key. + * + * @param {string[]} recipients - The addresses to lookup. + */ +async function checkRecipientKeys(recipients) { + gEmailsWithMissingKeys = []; + + for (let addr of recipients) { + let keyMetas = await EnigmailKeyRing.getEncryptionKeyMeta(addr); + + if (keyMetas.length == 1 && keyMetas[0].readiness == "alias") { + // Skip if this is an alias email. + continue; + } + + if (!keyMetas.some(k => k.readiness == "accepted")) { + gEmailsWithMissingKeys.push(addr); + continue; + } + } +} + +/** + * @returns {boolean} true if checking s/mime certificates is necessary + */ +function mustCheckRecipientCerts() { + let remindSMime = Services.prefs.getBoolPref( + "mail.smime.remind_encryption_possible" + ); + + let autoEnablePref = Services.prefs.getBoolPref( + "mail.e2ee.auto_enable", + false + ); + + return ( + isSmimeEncryptionConfigured() && + (gSendEncrypted || remindSMime || autoEnablePref) + ); +} + +/** + * Check available S/MIME encryption certificates for the given email + * addresses. (This function assumes the caller has already called + * mustCheckRecipientCerts() and the result was true.) + * + * gEmailsWithMissingCerts will be set to an array of email addresses + * (a subset of the input) that do NOT have a usable (valid) certificate. + * + * This function might take significant time to complete, because + * certificate verification involves OCSP, which runs on a background + * thread. + * + * @param {string[]} recipients - The addresses to lookup. + */ +function checkRecipientCerts(recipients) { + return new Promise((resolve, reject) => { + if (gSMPendingCertLookupSet.size) { + reject( + new Error( + "Must not be called while previous checks are still in progress" + ) + ); + } + + gEmailsWithMissingCerts = []; + + function continueCheckRecipientCerts() { + gEmailsWithMissingCerts = recipients.filter( + email => !gSMFields.haveValidCertForEmail(email) + ); + resolve(); + } + + /** @implements {nsIDoneFindCertForEmailCallback} */ + let doneFindCertForEmailCallback = { + QueryInterface: ChromeUtils.generateQI([ + "nsIDoneFindCertForEmailCallback", + ]), + + findCertDone(email, cert) { + let isStaleResult = !gSMPendingCertLookupSet.has(email); + // isStaleResult true means, this recipient was removed by the + // user while we were looking for the cert in the background. + // Let's remember the result, but don't trigger any actions + // based on it. + + if (cert) { + gSMFields.cacheValidCertForEmail(email, cert ? cert.dbKey : ""); + } + if (isStaleResult) { + return; + } + gSMPendingCertLookupSet.delete(email); + if (!cert && !gSMCertsAlreadyLookedUpInLDAP.has(email)) { + let autocompleteLdap = Services.prefs.getBoolPref( + "ldap_2.autoComplete.useDirectory" + ); + + if (autocompleteLdap) { + gSMCertsAlreadyLookedUpInLDAP.add(email); + + let autocompleteDirectory = null; + if (gCurrentIdentity.overrideGlobalPref) { + autocompleteDirectory = gCurrentIdentity.directoryServer; + } else { + autocompleteDirectory = Services.prefs.getCharPref( + "ldap_2.autoComplete.directoryServer" + ); + } + + if (autocompleteDirectory) { + window.openDialog( + "chrome://messenger-smime/content/certFetchingStatus.xhtml", + "", + "chrome,resizable=1,modal=1,dialog=1", + autocompleteDirectory, + [email] + ); + } + + gSMPendingCertLookupSet.add(email); + gSMFields.asyncFindCertByEmailAddr( + email, + doneFindCertForEmailCallback + ); + } + } + + if (gSMPendingCertLookupSet.size) { + // must continue to wait for more queued lookups to complete + return; + } + + // No more lookups pending. + continueCheckRecipientCerts(); + }, + }; + + for (let email of recipients) { + if (gSMFields.haveValidCertForEmail(email)) { + continue; + } + + if (gSMPendingCertLookupSet.has(email)) { + throw new Error(`cert lookup still pending for ${email}`); + } + + gSMPendingCertLookupSet.add(email); + gSMFields.asyncFindCertByEmailAddr(email, doneFindCertForEmailCallback); + } + + // If we haven't queued any lookups, we continue immediately + if (!gSMPendingCertLookupSet.size) { + continueCheckRecipientCerts(); + } + }); +} + +/** + * gCheckEncryptionStateCompletionIsPending means that async work + * started by checkEncryptionState() has not yet completed. + */ +var gCheckEncryptionStateCompletionIsPending = false; + +/** + * gCheckEncryptionStateNeedsRestart means that checkEncryptionState() + * was called, while its async operations were still running. + * The additional to checkEncryptionState() was treated as a no-op, + * but gCheckEncryptionStateNeedsRestart was set to true, to remember + * that checkEncryptionState() must be immediately restarted after its + * previous execution is done. This will the restarted + * checkEncryptionState() execution to detect and handle changes that + * could result in a different state. + */ +var gCheckEncryptionStateNeedsRestart = false; + +/** + * gWasCESTriggeredByComposerChange is used to track whether an + * encryption-state-checked event should be sent after an ongoing + * execution of checkEncryptionState() is done. + * The purpose of the encryption-state-checked event is to allow our + * automated tests to be notified as soon as an automatic call to + * checkEncryptionState() (and all related async calls) is complete, + * which means all automatic adjustments to the global encryption state + * are done, and the automated test code may proceed to compare the + * state to our exptectations. + * We want that event to be sent after modifications were made to the + * composer window itself, such as sender identity and recipients. + * However, we want to ignore calls to checkEncryptionState() that + * were triggered indirectly after OpenPGP keys were changed. + * If an event was originally triggered by a change to OpenPGP keys, + * and the async processing of checkEncryptionState() was still running, + * and another direct change to the composer window was made, which + * shall result in sending a encryption-state-checked after completion, + * then the flag gWasCESTriggeredByComposerChange will be set, + * which will cause the event to be sent after the restarted call + * to checkEncryptionState() is complete. + */ +var gWasCESTriggeredByComposerChange = false; + +/** + * Perform all checks that are necessary to update the state of + * email encryption, based on the current recipients. This should be + * done whenever the recipient list or the status of available keys/certs + * has changed. All automatic actions for encryption related settings + * will be triggered accordingly. + * This function will trigger async activity, and the resulting actions + * (e.g. update of UI elements) may happen after a delay. + * It's safe to call this while processing hasn't completed yet, in this + * scenario the processing will be restarted, once pending + * activity has completed. + * + * @param {string} [trigger] - A string that gives information about + * the reason why this function is being called. + * This parameter is intended to help with automated testing. + * If the trigger string starts with "openpgp-" then no completition + * event will be dispatched. This allows the automated test code to + * wait for events that are directly related to properties of the + * composer window, only. + */ +async function checkEncryptionState(trigger) { + if (!gLoadingComplete) { + // Let's not do this while we're still loading the composer window, + // it can have side effects, see bug 1777683. + // Also, if multiple recipients are added to an email automatically + // e.g. during reply-all, it doesn't make sense to execute this + // function every time after one of them gets added. + return; + } + + if (!/^openpgp-/.test(trigger)) { + gWasCESTriggeredByComposerChange = true; + } + + if (gCheckEncryptionStateCompletionIsPending) { + // avoid concurrency + gCheckEncryptionStateNeedsRestart = true; + return; + } + + let remindSMime = Services.prefs.getBoolPref( + "mail.smime.remind_encryption_possible" + ); + let remindOpenPGP = Services.prefs.getBoolPref( + "mail.openpgp.remind_encryption_possible" + ); + let autoEnablePref = Services.prefs.getBoolPref( + "mail.e2ee.auto_enable", + false + ); + + if (!gSendEncrypted && !autoEnablePref && !remindSMime && !remindOpenPGP) { + // No need to check. + updateEncryptionDependencies(); + updateKeyCertNotifications([]); + updateEncryptionTechReminder(null); + if (gWasCESTriggeredByComposerChange) { + document.dispatchEvent(new CustomEvent("encryption-state-checked")); + gWasCESTriggeredByComposerChange = false; + } + return; + } + + let recipients = getEncryptionCompatibleRecipients(); + let checkingCerts = mustCheckRecipientCerts(); + let checkingKeys = mustCheckRecipientKeys(); + + async function continueCheckEncryptionStateSub() { + let canEncryptSMIME = + recipients.length && checkingCerts && !gEmailsWithMissingCerts.length; + let canEncryptOpenPGP = + recipients.length && checkingKeys && !gEmailsWithMissingKeys.length; + + let autoEnabledJustNow = false; + + if ( + gSendEncrypted && + gUserTouchedSendEncrypted && + !isPgpConfigured() && + !isSmimeEncryptionConfigured() + ) { + notifyIdentityCannotEncrypt(true, gCurrentIdentity.email); + } else { + notifyIdentityCannotEncrypt(false, gCurrentIdentity.email); + } + + if ( + !gSendEncrypted && + autoEnablePref && + !gUserTouchedSendEncrypted && + recipients.length && + (canEncryptSMIME || canEncryptOpenPGP) + ) { + if (!canEncryptSMIME) { + gSelectedTechnologyIsPGP = true; + } else if (!canEncryptOpenPGP) { + gSelectedTechnologyIsPGP = false; + } + gSendEncrypted = true; + autoEnabledJustNow = true; + removeAutoDisableNotification(); + } + + if ( + !gIsRelatedToEncryptedOriginal && + !autoEnabledJustNow && + !gUserTouchedSendEncrypted && + gSendEncrypted && + !canEncryptSMIME && + !canEncryptOpenPGP + ) { + // The auto_disable pref is ignored if auto_enable is false + let autoDisablePref = Services.prefs.getBoolPref( + "mail.e2ee.auto_disable", + false + ); + if (autoEnablePref && autoDisablePref && !gUserTouchedSendEncrypted) { + gSendEncrypted = false; + let notifyPref = Services.prefs.getBoolPref( + "mail.e2ee.notify_on_auto_disable", + true + ); + if (notifyPref) { + // Most likely the notification is not showing yet, and we + // must append it. (We should have removed an existing + // notification at the time encryption was enabled.) + // However, double check to avoid that we'll show it twice. + const NOTIFICATION_NAME = "e2eeDisableNotification"; + let notification = + gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME); + if (!notification) { + gComposeNotification.appendNotification( + NOTIFICATION_NAME, + { + label: { "l10n-id": "auto-disable-e2ee-warning" }, + priority: gComposeNotification.PRIORITY_WARNING_LOW, + }, + [] + ); + } + } + } + } + + let techPref = gCurrentIdentity.getIntAttribute("e2etechpref"); + + if (gSendEncrypted && canEncryptSMIME && canEncryptOpenPGP) { + // No change if 0 + if (techPref == 1) { + gSelectedTechnologyIsPGP = false; + } else if (techPref == 2) { + gSelectedTechnologyIsPGP = true; + } + } + + if ( + gSendEncrypted && + canEncryptSMIME && + !canEncryptOpenPGP && + gSelectedTechnologyIsPGP + ) { + gSelectedTechnologyIsPGP = false; + } + + if ( + gSendEncrypted && + !canEncryptSMIME && + canEncryptOpenPGP && + !gSelectedTechnologyIsPGP + ) { + gSelectedTechnologyIsPGP = true; + } + + updateEncryptionDependencies(); + + if (!gSendEncrypted) { + updateKeyCertNotifications([]); + if (recipients.length && (canEncryptSMIME || canEncryptOpenPGP)) { + let useTech; + if (canEncryptSMIME && canEncryptOpenPGP) { + if (techPref == 1) { + useTech = "SMIME"; + } else { + useTech = "OpenPGP"; + } + } else { + useTech = canEncryptOpenPGP ? "OpenPGP" : "SMIME"; + } + updateEncryptionTechReminder(useTech); + } else { + updateEncryptionTechReminder(null); + } + } else { + updateKeyCertNotifications( + gSelectedTechnologyIsPGP + ? gEmailsWithMissingKeys + : gEmailsWithMissingCerts + ); + updateEncryptionTechReminder(null); + } + + gCheckEncryptionStateCompletionIsPending = false; + + if (gCheckEncryptionStateNeedsRestart) { + // Recursive call, which is acceptable (and not blocking), + // because necessary long actions will be triggered asynchronously. + gCheckEncryptionStateNeedsRestart = false; + await checkEncryptionState(trigger); + } else if (gWasCESTriggeredByComposerChange) { + document.dispatchEvent(new CustomEvent("encryption-state-checked")); + gWasCESTriggeredByComposerChange = false; + } + } + + let pendingPromises = []; + + if (checkingCerts) { + pendingPromises.push(checkRecipientCerts(recipients)); + } + + if (checkingKeys) { + pendingPromises.push(checkRecipientKeys(recipients)); + } + + gCheckEncryptionStateNeedsRestart = false; + gCheckEncryptionStateCompletionIsPending = true; + + Promise.all(pendingPromises).then(continueCheckEncryptionStateSub); +} + +/** + * Display (or hide) the notification that informs the user that + * encryption is possible (but currently not enabled). + * + * @param {string} technology - The technology that is possible, + * ("OpenPGP" or "SMIME"), or null if none is possible. + */ +function updateEncryptionTechReminder(technology) { + let enableNotification = + gComposeNotification.getNotificationWithValue("enableNotification"); + if (enableNotification) { + gComposeNotification.removeNotification(enableNotification); + } + + if (!technology || (technology != "OpenPGP" && technology != "SMIME")) { + return; + } + + let labelId = + technology == "OpenPGP" + ? "can-encrypt-openpgp-notification" + : "can-encrypt-smime-notification"; + + gComposeNotification.appendNotification( + "enableNotification", + { + label: { "l10n-id": labelId }, + priority: gComposeNotification.PRIORITY_INFO_LOW, + }, + [ + { + "l10n-id": "can-e2e-encrypt-button", + callback() { + gSelectedTechnologyIsPGP = technology == "OpenPGP"; + gSendEncrypted = true; + gUserTouchedSendEncrypted = true; + checkEncryptionState(); + return true; + }, + }, + ] + ); +} + +/** + * Display (or hide) the notification that informs the user that + * encryption isn't possible, because the currently selected Sender + * (From) identity isn't configured for end-to-end-encryption. + * + * @param {boolean} show - Show if true, hide if false. + * @param {string} addr - email address to show in notification + */ +async function notifyIdentityCannotEncrypt(show, addr) { + const NOTIFICATION_NAME = "IdentityCannotEncrypt"; + + let notification = + gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME); + + if (show) { + if (!notification) { + gComposeNotification.appendNotification( + NOTIFICATION_NAME, + { + label: await document.l10n.formatValue( + "openpgp-key-issue-notification-from", + { + addr, + } + ), + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + }, + [] + ); + } + } else if (notification) { + gComposeNotification.removeNotification(notification); + } +} + +/** + * Show an appropriate notification based on the given list of + * email addresses that cannot be used with email encryption + * (because of missing usable OpenPGP public keys or S/MIME certs). + * The list may be empty, which means no notification will be shown + * (or existing notifications will be removed). + * + * @param {string[]} emailsWithMissing - The email addresses that prevent + * using encryption, because certs/keys are missing. + */ +function updateKeyCertNotifications(emailsWithMissing) { + const NOTIFICATION_NAME = "keyNotification"; + + let notification = + gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME); + if (notification) { + gComposeNotification.removeNotification(notification); + } + + // Always refresh the pills UI. + clearRecipPillKeyIssues(); + + // Interrupt if we don't have any issue. + if (!emailsWithMissing.length) { + return; + } + + // Update recipient pills. + for (let pill of document.querySelectorAll("mail-address-pill")) { + if ( + emailsWithMissing.includes(pill.emailAddress.toLowerCase()) && + !pill.classList.contains("invalid-address") + ) { + pill.classList.add("key-issue"); + } + } + + /** + * Display the new key notification. + */ + let buttons = []; + buttons.push({ + "l10n-id": "key-notification-disable-encryption", + callback() { + gUserTouchedSendEncrypted = true; + gSendEncrypted = false; + checkEncryptionState(); + return true; + }, + }); + + if (gSelectedTechnologyIsPGP) { + buttons.push({ + "l10n-id": "key-notification-resolve", + callback() { + showMessageComposeSecurityStatus(); + return true; + }, + }); + } + + let label; + + if (emailsWithMissing.length == 1) { + let id = gSelectedTechnologyIsPGP + ? "openpgp-key-issue-notification-single" + : "smime-cert-issue-notification-single"; + label = { + "l10n-id": id, + "l10n-args": { addr: emailsWithMissing[0] }, + }; + } else { + let id = gSelectedTechnologyIsPGP + ? "openpgp-key-issue-notification-multi" + : "smime-cert-issue-notification-multi"; + + label = { + "l10n-id": id, + "l10n-args": { count: emailsWithMissing.length }, + }; + } + + gComposeNotification.appendNotification( + NOTIFICATION_NAME, + { + label, + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + }, + buttons + ); +} + +/** + * Returns whether the attachment notification should be suppressed regardless + * of the state of keywords. + */ +function attachmentNotificationSupressed() { + return ( + gDisableAttachmentReminder || + gManualAttachmentReminder || + gAttachmentBucket.getRowCount() + ); +} + +var attachmentWorker = new Worker("resource:///modules/AttachmentChecker.jsm"); + +// The array of currently found keywords. Or null if keyword detection wasn't +// run yet so we don't know. +attachmentWorker.lastMessage = null; + +attachmentWorker.onerror = function (error) { + console.error("Attachment Notification Worker error!!! " + error.message); + throw error; +}; + +/** + * Called when attachmentWorker finishes checking of the message for keywords. + * + * @param event If defined, event.data contains an array of found keywords. + * @param aManage If set to true and we determine keywords have changed, + * manage the notification. + * If set to false, just store the new keyword list but do not + * touch the notification. That effectively eats the + * "keywords changed" event which usually shows the notification + * if it was hidden. See manageAttachmentNotification(). + */ +attachmentWorker.onmessage = function (event, aManage = true) { + // Exit if keywords haven't changed. + if ( + !event || + (attachmentWorker.lastMessage && + event.data.toString() == attachmentWorker.lastMessage.toString()) + ) { + return; + } + + let data = event ? event.data : []; + attachmentWorker.lastMessage = data.slice(0); + if (aManage) { + manageAttachmentNotification(true); + } +}; + +/** + * Update attachment-related internal flags, UI, and commands. + * Called when number of attachments changes. + * + * @param aShowPane {string} "show": show the attachment pane + * "hide": hide the attachment pane + * omitted: just update without changing pane visibility + * @param aContentChanged {Boolean} optional value to assign to gContentChanged; + * defaults to true. + */ +function AttachmentsChanged(aShowPane, aContentChanged = true) { + gContentChanged = aContentChanged; + updateAttachmentPane(aShowPane); + manageAttachmentNotification(true); + updateAttachmentItems(); +} + +/** + * This functions returns an array of valid spellcheck languages. It checks + * that a dictionary exists for the language passed in, if any. It also + * retrieves the corresponding preference and ensures that a dictionary exists. + * If not, it adjusts the preference accordingly. + * When the nominated dictionary does not exist, the effects are very confusing + * to the user: Inline spell checking does not work, although the option is + * selected and a spell check dictionary seems to be selected in the options + * dialog (the dropdown shows the first list member if the value is not in + * the list). It is not at all obvious that the preference value is wrong. + * This case can happen two scenarios: + * 1) The dictionary that was selected in the preference is removed. + * 2) The selected dictionary changes the way it announces itself to the system, + * so for example "it_IT" changes to "it-IT" and the previously stored + * preference value doesn't apply any more. + * + * @param {string[]|null} [draftLanguages] - Languages that the message was + * composed in. + * @returns {string[]} + */ +function getValidSpellcheckerDictionaries(draftLanguages) { + let prefValue = Services.prefs.getCharPref("spellchecker.dictionary"); + let spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService( + Ci.mozISpellCheckingEngine + ); + let dictionaries = Array.from(new Set(prefValue?.split(","))); + + let dictList = spellChecker.getDictionaryList(); + let count = dictList.length; + + if (count == 0) { + // If there are no dictionaries, we can't check the value, so return it. + return dictionaries; + } + + // Make sure that the draft language contains a valid value. + if ( + draftLanguages && + draftLanguages.every(language => dictList.includes(language)) + ) { + return draftLanguages; + } + + // Make sure preference contains a valid value. + if (dictionaries.every(language => dictList.includes(language))) { + return dictionaries; + } + + // Set a valid value, any value will do. + Services.prefs.setCharPref("spellchecker.dictionary", dictList[0]); + return [dictList[0]]; +} + +var dictionaryRemovalObserver = { + observe(aSubject, aTopic, aData) { + if (aTopic != "spellcheck-dictionary-remove") { + return; + } + let spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService( + Ci.mozISpellCheckingEngine + ); + + let dictList = spellChecker.getDictionaryList(); + let languages = Array.from(gActiveDictionaries); + languages = languages.filter(lang => dictList.includes(lang)); + if (languages.length === 0) { + // Set a valid language from the preference. + let prefValue = Services.prefs.getCharPref("spellchecker.dictionary"); + let prefLanguages = prefValue?.split(",") ?? []; + languages = prefLanguages.filter(lang => dictList.includes(lang)); + if (prefLanguages.length != languages.length && languages.length > 0) { + // Fix the preference while we're here. We know it's invalid. + Services.prefs.setCharPref( + "spellchecker.dictionary", + languages.join(",") + ); + } + } + // Only update the language if we will still be left with any active choice. + if (languages.length > 0) { + ComposeChangeLanguage(languages); + } + }, + + isAdded: false, + + addObserver() { + Services.obs.addObserver(this, "spellcheck-dictionary-remove"); + this.isAdded = true; + }, + + removeObserver() { + if (this.isAdded) { + Services.obs.removeObserver(this, "spellcheck-dictionary-remove"); + this.isAdded = false; + } + }, +}; + +function EditorClick(event) { + if (event.target.matches(".remove-card")) { + let card = event.target.closest(".moz-card"); + let url = card.querySelector(".url").href; + if (card.matches(".url-replaced")) { + card.replaceWith(url); + } else { + card.remove(); + } + } else if (event.target.matches(`.add-card[data-opened='${gOpened}']`)) { + let url = event.target.getAttribute("data-url"); + let meRect = document.getElementById("messageEditor").getClientRects()[0]; + let settings = document.getElementById("linkPreviewSettings"); + let settingsW = 500; + settings.style.position = "fixed"; + settings.style.left = + Math.max(settingsW + 20, event.clientX) - settingsW + "px"; + settings.style.top = meRect.top + event.clientY + 20 + "px"; + settings.hidden = false; + event.target.remove(); + settings.querySelector(".close").onclick = event => { + settings.hidden = true; + }; + settings.querySelector(".preview-replace").onclick = event => { + addLinkPreview(url, true); + settings.hidden = true; + }; + settings.querySelector(".preview-autoadd").onclick = event => { + Services.prefs.setBoolPref( + "mail.compose.add_link_preview", + event.target.checked + ); + }; + settings.querySelector(".preview-replace").focus(); + settings.onkeydown = event => { + if (event.key == "Escape") { + settings.hidden = true; + } + }; + } +} + +/** + * Grab Open Graph or Twitter card data from the URL and insert a link preview + * into the editor. If no proper data could be found, nothing is inserted. + * + * @param {string} url - The URL to add preview for. + */ +async function addLinkPreview(url) { + return fetch(url) + .then(response => response.text()) + .then(text => { + let doc = new DOMParser().parseFromString(text, "text/html"); + + // If the url has an Open Graph or Twitter card, create a nicer + // representation and use that instead. + // @see https://ogp.me/ + // @see https://developer.twitter.com/en/docs/twitter-for-websites/cards/ + // Also look for standard meta information as a fallback. + + let title = + doc + .querySelector("meta[property='og:title'],meta[name='twitter:title']") + ?.getAttribute("content") || + doc.querySelector("title")?.textContent.trim(); + let description = doc + .querySelector( + "meta[property='og:description'],meta[name='twitter:description'],meta[name='description']" + ) + ?.getAttribute("content"); + + // Handle the case where we didn't get proper data. + if (!title && !description) { + console.debug(`No link preview data for url=${url}`); + return; + } + + let image = doc + .querySelector("meta[property='og:image']") + ?.getAttribute("content"); + let alt = + doc + .querySelector("meta[property='og:image:alt']") + ?.getAttribute("content") || ""; + if (!image) { + image = doc + .querySelector("meta[name='twitter:image']") + ?.getAttribute("content"); + alt = + doc + .querySelector("meta[name='twitter:image:alt']") + ?.getAttribute("content") || ""; + } + let imgIsTouchIcon = false; + if (!image) { + image = doc + .querySelector( + `link[rel='icon']:is( + [sizes~='any'], + [sizes~='196x196' i], + [sizes~='192x192' i] + [sizes~='180x180' i], + [sizes~='128x128' i] + )` + ) + ?.getAttribute("href"); + alt = ""; + imgIsTouchIcon = Boolean(image); + } + + // Grab our template and fill in the variables. + let card = document + .getElementById("dataCardTemplate") + .content.cloneNode(true).firstElementChild; + card.id = "card-" + Date.now(); + card.querySelector("img").src = image; + card.querySelector("img").alt = alt; + card.querySelector(".title").textContent = title; + + card.querySelector(".description").textContent = description; + card.querySelector(".url").textContent = "🔗 " + url; + card.querySelector(".url").href = url; + card.querySelector(".url").title = new URL(url).hostname; + card.querySelector(".site").textContent = new URL(url).hostname; + + // twitter:card "summary" = Summary Card + // twitter:card "summary_large_image" = Summary Card with Large Image + if ( + !imgIsTouchIcon && + (doc.querySelector( + "meta[name='twitter:card'][content='summary_large_image']" + ) || + doc + .querySelector("meta[property='og:image:width']") + ?.getAttribute("content") >= 600) + ) { + card.querySelector("img").style.width = "600px"; + } + + if (!image) { + card.querySelector(".card-pic").remove(); + } + + // If subject is empty, set that as well. + let subject = document.getElementById("msgSubject"); + if (!subject.value && title) { + subject.value = title; + } + + // Select the inserted URL so that if the preview is found one can + // use undo to remove it and only use the URL instead. + // Only do it if there was no typing after the url. + let selection = getBrowser().contentDocument.getSelection(); + let n = selection.focusNode; + if (n.textContent.endsWith(url)) { + selection.extend(n, n.textContent.lastIndexOf(url)); + card.classList.add("url-replaced"); + } + + // Add a line after the card. Otherwise it's hard to continue writing. + let line = GetCurrentEditor().returnInParagraphCreatesNewParagraph + ? "<p> </p>" + : "<br />"; + card.classList.add("loading"); // Used for fade-in effect. + getBrowser().contentDocument.execCommand( + "insertHTML", + false, + card.outerHTML + line + ); + let cardInDoc = getBrowser().contentDocument.getElementById(card.id); + cardInDoc.classList.remove("loading"); + }); +} + +/** + * On paste or drop, we may want to modify the content before inserting it into + * the editor, replacing file URLs with data URLs when appropriate. + */ +function onPasteOrDrop(e) { + if (!gMsgCompose.composeHTML) { + // We're in the plain text editor. Nothing to do here. + return; + } + gMsgCompose.allowRemoteContent = true; + + // For paste use e.clipboardData, for drop use e.dataTransfer. + let dataTransfer = "clipboardData" in e ? e.clipboardData : e.dataTransfer; + if ( + Services.prefs.getBoolPref("mail.compose.add_link_preview", false) && + !Services.io.offline && + !dataTransfer.types.includes("text/html") + ) { + let type = dataTransfer.types.find(t => + ["text/uri-list", "text/x-moz-url", "text/plain"].includes(t) + ); + if (type) { + let url = dataTransfer.getData(type).split("\n")[0].trim(); + if (/^https?:\/\/\S+$/.test(url)) { + e.preventDefault(); // We'll handle the pasting manually. + getBrowser().contentDocument.execCommand("insertHTML", false, url); + addLinkPreview(url); + return; + } + } + } + + if (!dataTransfer.types.includes("text/html")) { + return; + } + + // Ok, we have html content to paste. + let html = dataTransfer.getData("text/html"); + let doc = new DOMParser().parseFromString(html, "text/html"); + let tmpD = Services.dirsvc.get("TmpD", Ci.nsIFile); + let pendingConversions = 0; + let needToPreventDefault = true; + for (let img of doc.images) { + if (!/^file:/i.test(img.src)) { + // Doesn't start with file:. Nothing to do here. + continue; + } + + // This may throw if the URL is invalid for the OS. + let nsFile; + try { + nsFile = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler) + .getFileFromURLSpec(img.src); + } catch (ex) { + continue; + } + + if (!nsFile.exists()) { + continue; + } + + if (!tmpD.contains(nsFile)) { + // Not anywhere under the temp dir. + continue; + } + + let contentType = Cc["@mozilla.org/mime;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromFile(nsFile); + if (!contentType.startsWith("image/")) { + continue; + } + + // If we ever get here, we need to prevent the default paste or drop since + // the code below will do its own insertion. + if (needToPreventDefault) { + e.preventDefault(); + needToPreventDefault = false; + } + + File.createFromNsIFile(nsFile).then(function (file) { + if (file.lastModified < Date.now() - 60000) { + // Not put in temp in the last minute. May be something other than + // a copy-paste. Let's not allow that. + return; + } + + let doTheInsert = function () { + // Now run it through sanitation to make sure there wasn't any + // unwanted things in the content. + let ParserUtils = Cc["@mozilla.org/parserutils;1"].getService( + Ci.nsIParserUtils + ); + let html2 = ParserUtils.sanitize( + doc.documentElement.innerHTML, + ParserUtils.SanitizerAllowStyle + ); + getBrowser().contentDocument.execCommand("insertHTML", false, html2); + }; + + // Everything checks out. Convert file to data URL. + let reader = new FileReader(); + reader.addEventListener("load", function () { + let dataURL = reader.result; + pendingConversions--; + img.src = dataURL; + if (pendingConversions == 0) { + doTheInsert(); + } + }); + reader.addEventListener("error", function () { + pendingConversions--; + if (pendingConversions == 0) { + doTheInsert(); + } + }); + + pendingConversions++; + reader.readAsDataURL(file); + }); + } +} + +/* eslint-disable complexity */ +async function ComposeStartup() { + // Findbar overlay + if (!document.getElementById("findbar-replaceButton")) { + let replaceButton = document.createXULElement("toolbarbutton"); + replaceButton.setAttribute("id", "findbar-replaceButton"); + replaceButton.setAttribute("class", "toolbarbutton-1 tabbable"); + replaceButton.setAttribute( + "label", + getComposeBundle().getString("replaceButton.label") + ); + replaceButton.setAttribute( + "accesskey", + getComposeBundle().getString("replaceButton.accesskey") + ); + replaceButton.setAttribute( + "tooltiptext", + getComposeBundle().getString("replaceButton.tooltip") + ); + replaceButton.setAttribute("oncommand", "findbarFindReplace();"); + + let findbar = document.getElementById("FindToolbar"); + let lastButton = findbar.getElement("find-entire-word"); + let tSeparator = document.createXULElement("toolbarseparator"); + tSeparator.setAttribute("id", "findbar-beforeReplaceSeparator"); + lastButton.parentNode.insertBefore( + replaceButton, + lastButton.nextElementSibling + ); + lastButton.parentNode.insertBefore( + tSeparator, + lastButton.nextElementSibling + ); + } + + var params = null; // New way to pass parameters to the compose window as a nsIMsgComposeParameters object + var args = null; // old way, parameters are passed as a string + gBodyFromArgs = false; + + if (window.arguments && window.arguments[0]) { + try { + if (window.arguments[0] instanceof Ci.nsIMsgComposeParams) { + params = window.arguments[0]; + gBodyFromArgs = params.composeFields && params.composeFields.body; + } else { + params = handleMailtoArgs(window.arguments[0]); + } + } catch (ex) { + dump("ERROR with parameters: " + ex + "\n"); + } + + // if still no dice, try and see if the params is an old fashioned list of string attributes + // XXX can we get rid of this yet? + if (!params) { + args = GetArgs(window.arguments[0]); + } + } + + // Set a sane starting width/height for all resolutions on new profiles. + // Do this before the window loads. + if (!document.documentElement.hasAttribute("width")) { + // Prefer 860x800. + let defaultHeight = Math.min(screen.availHeight, 800); + let defaultWidth = Math.min(screen.availWidth, 860); + + // On small screens, default to maximized state. + if (defaultHeight <= 600) { + document.documentElement.setAttribute("sizemode", "maximized"); + } + + document.documentElement.setAttribute("width", defaultWidth); + document.documentElement.setAttribute("height", defaultHeight); + // Make sure we're safe at the left/top edge of screen + document.documentElement.setAttribute("screenX", screen.availLeft); + document.documentElement.setAttribute("screenY", screen.availTop); + } + + // Observe dictionary removals. + dictionaryRemovalObserver.addObserver(); + + let messageEditor = document.getElementById("messageEditor"); + messageEditor.addEventListener("paste", onPasteOrDrop); + messageEditor.addEventListener("drop", onPasteOrDrop); + + let identityList = document.getElementById("msgIdentity"); + if (identityList) { + FillIdentityList(identityList); + } + + if (!params) { + // This code will go away soon as now arguments are passed to the window using a object of type nsMsgComposeParams instead of a string + + params = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance( + Ci.nsIMsgComposeParams + ); + params.composeFields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + + if (args) { + // Convert old fashion arguments into params + var composeFields = params.composeFields; + if (args.bodyislink == "true") { + params.bodyIsLink = true; + } + if (args.type) { + params.type = args.type; + } + if (args.format) { + // Only use valid values. + if ( + args.format == Ci.nsIMsgCompFormat.PlainText || + args.format == Ci.nsIMsgCompFormat.HTML || + args.format == Ci.nsIMsgCompFormat.OppositeOfDefault + ) { + params.format = args.format; + } else if (args.format.toLowerCase().trim() == "html") { + params.format = Ci.nsIMsgCompFormat.HTML; + } else if (args.format.toLowerCase().trim() == "text") { + params.format = Ci.nsIMsgCompFormat.PlainText; + } + } + if (args.originalMsgURI) { + params.originalMsgURI = args.originalMsgURI; + } + if (args.preselectid) { + params.identity = MailServices.accounts.getIdentity(args.preselectid); + } + if (args.from) { + composeFields.from = args.from; + } + if (args.to) { + composeFields.to = args.to; + } + if (args.cc) { + composeFields.cc = args.cc; + } + if (args.bcc) { + composeFields.bcc = args.bcc; + } + if (args.newsgroups) { + composeFields.newsgroups = args.newsgroups; + } + if (args.subject) { + composeFields.subject = args.subject; + } + if (args.attachment && window.arguments[1] instanceof Ci.nsICommandLine) { + let attachmentList = args.attachment.split(","); + for (let attachmentName of attachmentList) { + // resolveURI does all the magic around working out what the + // attachment is, including web pages, and generating the correct uri. + let uri = window.arguments[1].resolveURI(attachmentName); + let attachment = Cc[ + "@mozilla.org/messengercompose/attachment;1" + ].createInstance(Ci.nsIMsgAttachment); + // If uri is for a file and it exists set the attachment size. + if (uri instanceof Ci.nsIFileURL) { + if (uri.file.exists()) { + attachment.size = uri.file.fileSize; + } else { + attachment = null; + } + } + + // Only want to attach if a file that exists or it is not a file. + if (attachment) { + attachment.url = uri.spec; + composeFields.addAttachment(attachment); + } else { + let title = getComposeBundle().getString("errorFileAttachTitle"); + let msg = getComposeBundle().getFormattedString( + "errorFileAttachMessage", + [attachmentName] + ); + Services.prompt.alert(null, title, msg); + } + } + } + if (args.newshost) { + composeFields.newshost = args.newshost; + } + if (args.message) { + let msgFile = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsIFile + ); + if (PathUtils.parent(args.message) == ".") { + let workingDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile); + args.message = PathUtils.join( + workingDir.path, + PathUtils.filename(args.message) + ); + } + msgFile.initWithPath(args.message); + + if (!msgFile.exists()) { + let title = getComposeBundle().getString("errorFileMessageTitle"); + let msg = getComposeBundle().getFormattedString( + "errorFileMessageMessage", + [args.message] + ); + Services.prompt.alert(null, title, msg); + } else { + let data = ""; + let fstream = null; + let cstream = null; + + try { + fstream = Cc[ + "@mozilla.org/network/file-input-stream;1" + ].createInstance(Ci.nsIFileInputStream); + cstream = Cc[ + "@mozilla.org/intl/converter-input-stream;1" + ].createInstance(Ci.nsIConverterInputStream); + fstream.init(msgFile, -1, 0, 0); // Open file in default/read-only mode. + cstream.init(fstream, "UTF-8", 0, 0); + + let str = {}; + let read = 0; + + do { + // Read as much as we can and put it in str.value. + read = cstream.readString(0xffffffff, str); + data += str.value; + } while (read != 0); + } catch (e) { + let title = getComposeBundle().getString("errorFileMessageTitle"); + let msg = getComposeBundle().getFormattedString( + "errorLoadFileMessageMessage", + [args.message] + ); + Services.prompt.alert(null, title, msg); + } finally { + if (cstream) { + cstream.close(); + } + if (fstream) { + fstream.close(); + } + } + + if (data) { + let pos = data.search(/\S/); // Find first non-whitespace character. + + if ( + params.format != Ci.nsIMsgCompFormat.PlainText && + (args.message.endsWith(".htm") || + args.message.endsWith(".html") || + data.substr(pos, 14).toLowerCase() == "<!doctype html" || + data.substr(pos, 5).toLowerCase() == "<html") + ) { + // We replace line breaks because otherwise they'll be converted to + // <br> in nsMsgCompose::BuildBodyMessageAndSignature(). + // Don't do the conversion if the user asked explicitly for plain text. + data = data.replace(/\r?\n/g, " "); + } + gBodyFromArgs = true; + composeFields.body = data; + } + } + } else if (args.body) { + gBodyFromArgs = true; + composeFields.body = args.body; + } + } + } + + gComposeType = params.type; + + // Detect correct identity when missing or mismatched. An identity with no + // email is likely not valid. + // When editing a draft, 'params.identity' is pre-populated with the identity + // that created the draft or the identity owning the draft folder for a + // "foreign" draft, see ComposeMessage() in mailCommands.js. We don't want the + // latter so use the creator identity which could be null. + // Only do this detection for drafts and templates. + // Redirect will have from set as the original sender and we don't want to + // warn about that. + if ( + gComposeType == Ci.nsIMsgCompType.Draft || + gComposeType == Ci.nsIMsgCompType.Template + ) { + let creatorKey = params.composeFields.creatorIdentityKey; + params.identity = creatorKey + ? MailServices.accounts.getIdentity(creatorKey) + : null; + } + + let from = null; + // Get the from address from the headers. For Redirect, from is set to + // the original author, so don't look at it here. + if (params.composeFields.from && gComposeType != Ci.nsIMsgCompType.Redirect) { + let fromAddrs = MailServices.headerParser.parseEncodedHeader( + params.composeFields.from, + null + ); + if (fromAddrs.length) { + from = fromAddrs[0].email.toLowerCase(); + } + } + + if ( + !params.identity || + !params.identity.email || + (from && !emailSimilar(from, params.identity.email)) + ) { + let identities = MailServices.accounts.allIdentities; + let suitableCount = 0; + + // Search for a matching identity. + if (from) { + for (let ident of identities) { + if (ident.email && from == ident.email.toLowerCase()) { + if (suitableCount == 0) { + params.identity = ident; + } + suitableCount++; + if (suitableCount > 1) { + // No need to find more, it's already not unique. + break; + } + } + } + } + + if (!params.identity || !params.identity.email) { + let identity = null; + // No preset identity and no match, so use the default account. + let defaultAccount = MailServices.accounts.defaultAccount; + if (defaultAccount) { + identity = defaultAccount.defaultIdentity; + } + if (!identity) { + // Get the first identity we have in the list. + let identitykey = identityList + .getItemAtIndex(0) + .getAttribute("identitykey"); + identity = MailServices.accounts.getIdentity(identitykey); + } + params.identity = identity; + } + + // Warn if no or more than one match was found. + // But don't warn for +suffix additions (a+b@c.com). + if ( + from && + (suitableCount > 1 || + (suitableCount == 0 && !emailSimilar(from, params.identity.email))) + ) { + gComposeNotificationBar.setIdentityWarning(params.identity.identityName); + } + } + + if (params.identity) { + identityList.selectedItem = identityList.getElementsByAttribute( + "identitykey", + params.identity.key + )[0]; + } + + // Here we set the From from the original message, be it a draft or another + // message, for example a template, we want to "edit as new". + // Only do this if the message is our own draft or template or any type of reply. + if ( + params.composeFields.from && + (params.composeFields.creatorIdentityKey || + gComposeType == Ci.nsIMsgCompType.Reply || + gComposeType == Ci.nsIMsgCompType.ReplyAll || + gComposeType == Ci.nsIMsgCompType.ReplyToSender || + gComposeType == Ci.nsIMsgCompType.ReplyToGroup || + gComposeType == Ci.nsIMsgCompType.ReplyToSenderAndGroup || + gComposeType == Ci.nsIMsgCompType.ReplyToList) + ) { + let from = MailServices.headerParser + .parseEncodedHeader(params.composeFields.from, null) + .join(", "); + if (from != identityList.value) { + MakeFromFieldEditable(true); + identityList.value = from; + } + } + LoadIdentity(true); + + // Get the <editor> element to startup an editor + var editorElement = GetCurrentEditorElement(); + + // Remember the original message URI. When editing a draft which is a reply + // or forwarded message, this gets overwritten by the ancestor's message URI so + // the disposition flags ("replied" or "forwarded") can be set on the ancestor. + // For our purposes we need the URI of the message being processed, not its + // original ancestor. + gOriginalMsgURI = params.originalMsgURI; + gMsgCompose = MailServices.compose.initCompose( + params, + window, + editorElement.docShell + ); + + // If a message is a draft, we rely on draft status flags to decide + // about encryption setting. Don't set gIsRelatedToEncryptedOriginal + // simply because a message was saved as an encrypted draft, because + // we save draft messages encrypted as soon as the account is able + // to encrypt, regardless of the user's desire for encryption for + // this message. + + if ( + gComposeType != Ci.nsIMsgCompType.Draft && + gComposeType != Ci.nsIMsgCompType.Template && + gEncryptedURIService && + gEncryptedURIService.isEncrypted(gMsgCompose.originalMsgURI) + ) { + gIsRelatedToEncryptedOriginal = true; + } + + gMsgCompose.addMsgSendListener(gSendListener); + + document + .getElementById("dsnMenu") + .setAttribute("checked", gMsgCompose.compFields.DSN); + document + .getElementById("cmd_attachVCard") + .setAttribute("checked", gMsgCompose.compFields.attachVCard); + document + .getElementById("cmd_attachPublicKey") + .setAttribute("checked", gAttachMyPublicPGPKey); + toggleAttachmentReminder(gMsgCompose.compFields.attachmentReminder); + initSendFormatMenu(); + + let editortype = gMsgCompose.composeHTML ? "htmlmail" : "textmail"; + editorElement.makeEditable(editortype, true); + + // setEditorType MUST be called before setContentWindow + if (gMsgCompose.composeHTML) { + initLocalFontFaceMenu(document.getElementById("FontFacePopup")); + } else { + // We are editing in plain text mode, so hide the formatting menus and the + // output format selector. + document.getElementById("FormatToolbar").hidden = true; + document.getElementById("formatMenu").hidden = true; + document.getElementById("insertMenu").hidden = true; + document.getElementById("menu_showFormatToolbar").hidden = true; + document.getElementById("outputFormatMenu").hidden = true; + } + + // Do setup common to Message Composer and Web Composer. + EditorSharedStartup(); + ToggleReturnReceipt(gMsgCompose.compFields.returnReceipt); + + if (params.bodyIsLink) { + let body = gMsgCompose.compFields.body; + if (gMsgCompose.composeHTML) { + let cleanBody; + try { + cleanBody = decodeURI(body); + } catch (e) { + cleanBody = body; + } + + body = body.replace(/&/g, "&"); + gMsgCompose.compFields.body = + '<br /><a href="' + body + '">' + cleanBody + "</a><br />"; + } else { + gMsgCompose.compFields.body = "\n<" + body + ">\n"; + } + } + + document.getElementById("msgSubject").value = gMsgCompose.compFields.subject; + + // Do not await async calls before registering the stateListener, otherwise it + // will miss states. + gMsgCompose.RegisterStateListener(stateListener); + + let addedAttachmentItems = await AddAttachments( + gMsgCompose.compFields.attachments, + false + ); + // If any of the pre-loaded attachments is a cloudFile, this is most probably a + // re-opened draft. Restore the cloudFile information. + for (let attachmentItem of addedAttachmentItems) { + if ( + attachmentItem.attachment.sendViaCloud && + attachmentItem.attachment.contentLocation && + attachmentItem.attachment.cloudFileAccountKey && + attachmentItem.attachment.cloudPartHeaderData + ) { + let byteString = atob(attachmentItem.attachment.cloudPartHeaderData); + let uploadFromDraft = JSON.parse( + MailStringUtils.byteStringToString(byteString) + ); + if (uploadFromDraft && uploadFromDraft.path && uploadFromDraft.name) { + let cloudFileUpload; + let cloudFileAccount = cloudFileAccounts.getAccount( + attachmentItem.attachment.cloudFileAccountKey + ); + let bigFile = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsIFile + ); + bigFile.initWithPath(uploadFromDraft.path); + + if (cloudFileAccount) { + // Try to find the upload for the draft attachment in the already known + // uploads. + cloudFileUpload = cloudFileAccount + .getPreviousUploads() + .find( + upload => + upload.url == attachmentItem.attachment.contentLocation && + upload.url == uploadFromDraft.url && + upload.id == uploadFromDraft.id && + upload.name == uploadFromDraft.name && + upload.size == uploadFromDraft.size && + upload.path == uploadFromDraft.path && + upload.serviceName == uploadFromDraft.serviceName && + upload.serviceIcon == uploadFromDraft.serviceIcon && + upload.serviceUrl == uploadFromDraft.serviceUrl && + upload.downloadPasswordProtected == + uploadFromDraft.downloadPasswordProtected && + upload.downloadLimit == uploadFromDraft.downloadLimit && + upload.downloadExpiryDate == uploadFromDraft.downloadExpiryDate + ); + if (!cloudFileUpload) { + // Create a new upload from the data stored in the draft. + cloudFileUpload = cloudFileAccount.newUploadForFile( + bigFile, + uploadFromDraft + ); + } + // A restored cloudFile may have been send/used already in a previous + // session, or may be changed and reverted again by not saving a draft. + // Mark it as immutable. + cloudFileAccount.markAsImmutable(cloudFileUpload.id); + attachmentItem.cloudFileAccount = cloudFileAccount; + attachmentItem.cloudFileUpload = cloudFileUpload; + } else { + attachmentItem.cloudFileUpload = uploadFromDraft; + delete attachmentItem.cloudFileUpload.id; + } + + // Restore file information from the linked real file. + attachmentItem.attachment.name = uploadFromDraft.name; + attachmentItem.attachment.size = uploadFromDraft.size; + let bigAttachment; + if (bigFile.exists()) { + bigAttachment = FileToAttachment(bigFile); + } + if (bigAttachment && bigAttachment.size == uploadFromDraft.size) { + // Remove the temporary html placeholder file. + let uri = Services.io + .newURI(attachmentItem.attachment.url) + .QueryInterface(Ci.nsIFileURL); + await IOUtils.remove(uri.file.path); + + attachmentItem.attachment.url = bigAttachment.url; + attachmentItem.attachment.contentType = ""; + attachmentItem.attachment.temporary = false; + } + + await updateAttachmentItemProperties(attachmentItem); + continue; + } + } + // Did not find the required data in the draft to reconstruct the cloudFile + // information. Fall back to no-draft-restore-support. + attachmentItem.attachment.sendViaCloud = false; + } + + if (Services.prefs.getBoolPref("mail.compose.show_attachment_pane")) { + toggleAttachmentPane("show"); + } + + // Fill custom headers. + let otherHeaders = Services.prefs + .getCharPref("mail.compose.other.header", "") + .split(",") + .map(h => h.trim()) + .filter(Boolean); + for (let i = 0; i < otherHeaders.length; i++) { + if (gMsgCompose.compFields.otherHeaders[i]) { + let row = document.getElementById(`addressRow${otherHeaders[i]}`); + addressRowSetVisibility(row, true); + let input = document.getElementById(`${otherHeaders[i]}AddrInput`); + input.value = gMsgCompose.compFields.otherHeaders[i]; + } + } + + document + .getElementById("msgcomposeWindow") + .dispatchEvent( + new Event("compose-window-init", { bubbles: false, cancelable: true }) + ); + + dispatchAttachmentBucketEvent( + "attachments-added", + gMsgCompose.compFields.attachments + ); + + // Add an observer to be called when document is done loading, + // which creates the editor. + try { + GetCurrentCommandManager().addCommandObserver( + gMsgEditorCreationObserver, + "obs_documentCreated" + ); + + // Load empty page to create the editor. The "?compose" is there so this + // URL does not exactly match "about:blank", which has some drawbacks. In + // particular it prevents WebExtension content scripts from running in + // this document. + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }; + editorElement.webNavigation.loadURI( + Services.io.newURI("about:blank?compose"), + loadURIOptions + ); + } catch (e) { + console.error(e); + } + + gEditingDraft = gMsgCompose.compFields.draftId; + + // Set up contacts sidebar. + let pageURL = document.URL; + let contactsSplitter = document.getElementById("contactsSplitter"); + let contactsShown = Services.xulStore.getValue( + pageURL, + "contactsSplitter", + "shown" + ); + let contactsWidth = Services.xulStore.getValue( + pageURL, + "contactsSplitter", + "width" + ); + contactsSplitter.width = + contactsWidth == "" ? null : parseFloat(contactsWidth); + setContactsSidebarVisibility(contactsShown == "true", false); + contactsSplitter.addEventListener("splitter-resized", () => { + let width = contactsSplitter.width; + Services.xulStore.setValue( + pageURL, + "contactsSplitter", + "width", + width == null ? "" : String(width) + ); + }); + contactsSplitter.addEventListener("splitter-collapsed", () => { + Services.xulStore.setValue(pageURL, "contactsSplitter", "shown", "false"); + }); + contactsSplitter.addEventListener("splitter-expanded", () => { + Services.xulStore.setValue(pageURL, "contactsSplitter", "shown", "true"); + }); + + // Update the priority button. + if (gMsgCompose.compFields.priority) { + updatePriorityToolbarButton(gMsgCompose.compFields.priority); + } + + gAutoSaveInterval = Services.prefs.getBoolPref("mail.compose.autosave") + ? Services.prefs.getIntPref("mail.compose.autosaveinterval") * 60000 + : 0; + + if (gAutoSaveInterval) { + gAutoSaveTimeout = setTimeout(AutoSave, gAutoSaveInterval); + } + + gAutoSaveKickedIn = false; +} +/* eslint-enable complexity */ + +function splitEmailAddress(aEmail) { + let at = aEmail.lastIndexOf("@"); + return at != -1 ? [aEmail.slice(0, at), aEmail.slice(at + 1)] : [aEmail, ""]; +} + +// Emails are equal ignoring +suffixes (email+suffix@example.com). +function emailSimilar(a, b) { + if (!a || !b) { + return a == b; + } + a = splitEmailAddress(a.toLowerCase()); + b = splitEmailAddress(b.toLowerCase()); + return a[1] == b[1] && a[0].split("+", 1)[0] == b[0].split("+", 1)[0]; +} + +// The new, nice, simple way of getting notified when a new editor has been created +var gMsgEditorCreationObserver = { + observe(aSubject, aTopic, aData) { + if (aTopic == "obs_documentCreated") { + var editor = GetCurrentEditor(); + if (editor && GetCurrentCommandManager() == aSubject) { + InitEditor(); + } + // Now that we know this document is an editor, update commands now if + // the document has focus, or next time it receives focus via + // CommandUpdate_MsgCompose() + if (gLastWindowToHaveFocus == document.commandDispatcher.focusedWindow) { + updateComposeItems(); + } else { + gLastWindowToHaveFocus = null; + } + } + }, +}; + +/** + * Adjust sign/encrypt settings after the identity was switched. + * + * @param {?nsIMsgIdentity} prevIdentity - The previously selected + * identity, when switching to a different identity. + * Null on initial identity setup. + */ +async function adjustEncryptAfterIdentityChange(prevIdentity) { + let identityHasConfiguredSMIME = + isSmimeSigningConfigured() || isSmimeEncryptionConfigured(); + + let identityHasConfiguredOpenPGP = isPgpConfigured(); + + // Show widgets based on the technologies available across all identities. + let allEmailIdentities = MailServices.accounts.allIdentities.filter( + i => i.email + ); + let anyIdentityHasConfiguredOpenPGP = allEmailIdentities.some(i => + i.getUnicharAttribute("openpgp_key_id") + ); + let anyIdentityHasConfiguredSMIMEEncryption = allEmailIdentities.some(i => + i.getUnicharAttribute("encryption_cert_name") + ); + + // Disable encryption widgets if this identity has no encryption configured. + // However, if encryption is currently enabled, we must keep it enabled, + // to allow the user to manually disable encryption (we don't disable + // encryption automatically, as the user might have seen that it is + // enabled and might rely on it). + let e2eeConfigured = + identityHasConfiguredOpenPGP || identityHasConfiguredSMIME; + + let autoEnablePref = Services.prefs.getBoolPref( + "mail.e2ee.auto_enable", + false + ); + + // If neither OpenPGP nor SMIME are configured for any identity, + // then hide the entire menu. + let encOpt = document.getElementById("button-encryption-options"); + if (encOpt) { + encOpt.hidden = + !anyIdentityHasConfiguredOpenPGP && + !anyIdentityHasConfiguredSMIMEEncryption; + encOpt.disabled = !e2eeConfigured && !gSendEncrypted; + document.getElementById("encTech_OpenPGP_Toolbar").disabled = + !identityHasConfiguredOpenPGP; + document.getElementById("encTech_SMIME_Toolbar").disabled = + !identityHasConfiguredSMIME; + } + document.getElementById("encryptionMenu").hidden = + !anyIdentityHasConfiguredOpenPGP && + !anyIdentityHasConfiguredSMIMEEncryption; + + // Show menu items only if both technologies are available. + document.getElementById("encTech_OpenPGP_Menubar").hidden = + !anyIdentityHasConfiguredOpenPGP || + !anyIdentityHasConfiguredSMIMEEncryption; + document.getElementById("encTech_SMIME_Menubar").hidden = + !anyIdentityHasConfiguredOpenPGP || + !anyIdentityHasConfiguredSMIMEEncryption; + document.getElementById("encryptionOptionsSeparator_Menubar").hidden = + !anyIdentityHasConfiguredOpenPGP || + !anyIdentityHasConfiguredSMIMEEncryption; + + let encToggle = document.getElementById("button-encryption"); + if (encToggle) { + encToggle.disabled = !e2eeConfigured && !gSendEncrypted; + } + let sigToggle = document.getElementById("button-signing"); + if (sigToggle) { + sigToggle.disabled = !e2eeConfigured; + } + + document.getElementById("encryptionMenu").disabled = + !e2eeConfigured && !gSendEncrypted; + + // Enable the encryption menus of the technologies that are configured for + // this identity. + document.getElementById("encTech_OpenPGP_Menubar").disabled = + !identityHasConfiguredOpenPGP; + + document.getElementById("encTech_SMIME_Menubar").disabled = + !identityHasConfiguredSMIME; + + if (!prevIdentity) { + // For identities without any e2ee setup, we want a good default + // technology selection. Avoid a technology that isn't configured + // anywhere. + + if (identityHasConfiguredOpenPGP) { + gSelectedTechnologyIsPGP = true; + } else if (identityHasConfiguredSMIME) { + gSelectedTechnologyIsPGP = false; + } else { + gSelectedTechnologyIsPGP = anyIdentityHasConfiguredOpenPGP; + } + + if (identityHasConfiguredOpenPGP) { + if (!identityHasConfiguredSMIME) { + gSelectedTechnologyIsPGP = true; + } else { + // both are configured + let techPref = gCurrentIdentity.getIntAttribute("e2etechpref"); + gSelectedTechnologyIsPGP = techPref != 1; + } + } + + gSendSigned = false; + + if (autoEnablePref) { + gSendEncrypted = gIsRelatedToEncryptedOriginal; + } else { + gSendEncrypted = + gIsRelatedToEncryptedOriginal || + ((identityHasConfiguredOpenPGP || identityHasConfiguredSMIME) && + gCurrentIdentity.encryptionPolicy > 0); + } + + await checkEncryptionState(); + return; + } + + // Not initialCall (switching from, or changed recipients) + + // If the new identity has only one technology configured, + // which is different than the currently selected technology, + // then switch over to that other technology. + // However, if the new account doesn't have any technology + // configured, then it doesn't really matter, so let's keep what's + // currently selected for consistency (in case the user switches + // the identity again). + if ( + gSelectedTechnologyIsPGP && + !identityHasConfiguredOpenPGP && + identityHasConfiguredSMIME + ) { + gSelectedTechnologyIsPGP = false; + } else if ( + !gSelectedTechnologyIsPGP && + !identityHasConfiguredSMIME && + identityHasConfiguredOpenPGP + ) { + gSelectedTechnologyIsPGP = true; + } + + if ( + !autoEnablePref && + !gSendEncrypted && + !gUserTouchedEncryptSubject && + prevIdentity.encryptionPolicy == 0 && + gCurrentIdentity.encryptionPolicy > 0 + ) { + gSendEncrypted = true; + } + + await checkEncryptionState(); +} + +async function ComposeLoad() { + updateTroubleshootMenuItem(); + let otherHeaders = Services.prefs + .getCharPref("mail.compose.other.header", "") + .split(",") + .map(h => h.trim()) + .filter(Boolean); + + AddMessageComposeOfflineQuitObserver(); + + BondOpenPGP.init(); + + // Give the message header a minimum height based on its current height, + // before more recipient rows are revealed in #extraAddressRowsArea. This + // ensures that the area cannot be shrunk below its current height by the + // #headersSplitter. + // NOTE: At this stage, we only expect the "To" row to be visible within the + // recipients container. + let messageHeader = document.getElementById("MsgHeadersToolbar"); + let recipientsContainer = document.getElementById("recipientsContainer"); + // In the unlikely situation where the recipients container is already + // overflowing, we make sure to increase the minHeight by the overflow. + let headerHeight = + messageHeader.clientHeight + + recipientsContainer.scrollHeight - + recipientsContainer.clientHeight; + messageHeader.style.minHeight = `${headerHeight}px`; + + // Setup the attachment bucket. + gAttachmentBucket = document.getElementById("attachmentBucket"); + + let attachmentArea = document.getElementById("attachmentArea"); + attachmentArea.addEventListener("toggle", attachmentAreaOnToggle); + + // Setup the attachment animation counter. + gAttachmentCounter = document.getElementById("newAttachmentIndicator"); + gAttachmentCounter.addEventListener( + "animationend", + toggleAttachmentAnimation + ); + + // Set up the drag & drop event listeners. + let messageArea = document.getElementById("messageArea"); + messageArea.addEventListener("dragover", event => + envelopeDragObserver.onDragOver(event) + ); + messageArea.addEventListener("dragleave", event => + envelopeDragObserver.onDragLeave(event) + ); + messageArea.addEventListener("drop", event => + envelopeDragObserver.onDrop(event) + ); + + // Setup the attachment overlay animation listeners. + let overlay = document.getElementById("dropAttachmentOverlay"); + overlay.addEventListener("animationend", e => { + // Make the overlay constantly visible If the user is dragging a file over + // the compose windown. + if (e.animationName == "showing-animation") { + // We don't remove the "showing" class here since the dragOver event will + // keep adding it and we would have a flashing effect. + overlay.classList.add("show"); + return; + } + + // Permanently hide the overlay after the hiding animation ended. + if (e.animationName == "hiding-animation") { + overlay.classList.remove("show", "hiding"); + // Remove the hover class from the child items to reset the style. + document.getElementById("addInline").classList.remove("hover"); + document.getElementById("addAsAttachment").classList.remove("hover"); + } + }); + + if (otherHeaders) { + let extraAddressRowsMenu = document.getElementById("extraAddressRowsMenu"); + + let existingTypes = Array.from( + document.querySelectorAll(".address-row"), + row => row.dataset.recipienttype + ); + + for (let header of otherHeaders) { + if (existingTypes.includes(header)) { + continue; + } + existingTypes.push(header); + + header = header.trim(); + let recipient = { + rowId: `addressRow${header}`, + labelId: `${header}AddrLabel`, + containerId: `${header}AddrContainer`, + inputId: `${header}AddrInput`, + showRowMenuItemId: `${header}ShowAddressRowMenuItem`, + type: header, + }; + + let newEls = recipientsContainer.buildRecipientRow(recipient, true); + + recipientsContainer.appendChild(newEls.row); + extraAddressRowsMenu.appendChild(newEls.showRowMenuItem); + } + } + + try { + SetupCommandUpdateHandlers(); + await ComposeStartup(); + } catch (ex) { + console.error(ex); + Services.prompt.alert( + window, + getComposeBundle().getString("initErrorDlogTitle"), + getComposeBundle().getString("initErrorDlgMessage") + ); + + MsgComposeCloseWindow(); + return; + } + + ToolbarIconColor.init(); + + // initialize the customizeDone method on the customizeable toolbar + var toolbox = document.getElementById("compose-toolbox"); + toolbox.customizeDone = function (aEvent) { + MailToolboxCustomizeDone(aEvent, "CustomizeComposeToolbar"); + }; + + updateAttachmentPane(); + updateAriaLabelsAndTooltipsOfAllAddressRows(); + + for (let input of document.querySelectorAll(".address-row-input")) { + input.onBeforeHandleKeyDown = event => + addressInputOnBeforeHandleKeyDown(event); + } + + top.controllers.appendController(SecurityController); + gMsgCompose.compFields.composeSecure = null; + gSMFields = Cc[ + "@mozilla.org/messengercompose/composesecure;1" + ].createInstance(Ci.nsIMsgComposeSecure); + if (gSMFields) { + gMsgCompose.compFields.composeSecure = gSMFields; + } + + // Set initial encryption settings. + adjustEncryptAfterIdentityChange(null); + + ExtensionParent.apiManager.emit( + "extension-browser-inserted", + GetCurrentEditorElement() + ); + + setComposeLabelsAndMenuItems(); + setKeyboardShortcuts(); + + gFocusAreas = [ + { + // #abContactsPanel. + // NOTE: If focus is within the browser shadow document, then the + // top.document.activeElement points to the browser, which is below + // #contactsSidebar. + root: document.getElementById("contactsSidebar"), + focus: focusContactsSidebarSearchInput, + }, + { + // #msgIdentity, .recipient-button and #extraAddressRowsMenuButton. + root: document.getElementById("top-gradient-box"), + focus: focusMsgIdentity, + }, + ...Array.from(document.querySelectorAll(".address-row"), row => { + return { root: row, focus: focusAddressRowInput }; + }), + { + root: document.getElementById("subject-box"), + focus: focusSubjectInput, + }, + // "#FormatToolbox" cannot receive focus. + { + // #messageEditor and #FindToolbar + root: document.getElementById("messageArea"), + focus: focusMsgBody, + }, + { + root: document.getElementById("attachmentArea"), + focus: focusAttachmentBucket, + }, + { + root: document.getElementById("compose-notification-bottom"), + focus: focusNotification, + }, + { + root: document.getElementById("status-bar"), + focus: focusStatusBar, + }, + ]; + + UIDensity.registerWindow(window); + UIFontSize.registerWindow(window); +} + +/** + * Add fluent strings to labels and menu items requiring a shortcut key. + */ +function setComposeLabelsAndMenuItems() { + // To field. + document.l10n.setAttributes( + document.getElementById("menu_showToField"), + "show-to-row-main-menuitem", + { + key: SHOW_TO_KEY, + } + ); + document.l10n.setAttributes( + document.getElementById("addr_toShowAddressRowMenuItem"), + "show-to-row-extra-menuitem" + ); + document.l10n.setAttributes( + document.getElementById("addr_toShowAddressRowButton"), + "show-to-row-button", + { + key: SHOW_TO_KEY, + } + ); + + // Cc field. + document.l10n.setAttributes( + document.getElementById("menu_showCcField"), + "show-cc-row-main-menuitem", + { + key: SHOW_CC_KEY, + } + ); + document.l10n.setAttributes( + document.getElementById("addr_ccShowAddressRowMenuItem"), + "show-cc-row-extra-menuitem" + ); + document.l10n.setAttributes( + document.getElementById("addr_ccShowAddressRowButton"), + "show-cc-row-button", + { + key: SHOW_CC_KEY, + } + ); + + // Bcc field. + document.l10n.setAttributes( + document.getElementById("menu_showBccField"), + "show-bcc-row-main-menuitem", + { + key: SHOW_BCC_KEY, + } + ); + document.l10n.setAttributes( + document.getElementById("addr_bccShowAddressRowMenuItem"), + "show-bcc-row-extra-menuitem" + ); + document.l10n.setAttributes( + document.getElementById("addr_bccShowAddressRowButton"), + "show-bcc-row-button", + { + key: SHOW_BCC_KEY, + } + ); +} + +/** + * Add a keydown document event listener for international keyboard shortcuts. + */ +async function setKeyboardShortcuts() { + let [filePickerKey, toggleBucketKey] = await l10nCompose.formatValues([ + { id: "trigger-attachment-picker-key" }, + { id: "toggle-attachment-pane-key" }, + ]); + + document.addEventListener("keydown", event => { + // Return if we don't have the right modifier combination, CTRL/CMD + SHIFT, + // or if the pressed key is a modifier (each modifier will keep firing + // keydown event until another key is pressed in addition). + if ( + !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) || + !event.shiftKey || + ["Shift", "Control", "Meta"].includes(event.key) + ) { + return; + } + + // Always use lowercase to compare the key and avoid OS inconsistencies: + // For Cmd/Ctrl+Shift+A, on Mac, key = "a" vs. on Windows/Linux, key = "A". + switch (event.key.toLowerCase()) { + // Always prevent the default behavior of the keydown if we intercepted + // the key in order to avoid triggering OS specific shortcuts. + case filePickerKey.toLowerCase(): + // Ctrl/Cmd+Shift+A. + event.preventDefault(); + goDoCommand("cmd_attachFile"); + break; + case toggleBucketKey.toLowerCase(): + // Ctrl/Cmd+Shift+M. + event.preventDefault(); + goDoCommand("cmd_toggleAttachmentPane"); + break; + case SHOW_TO_KEY.toLowerCase(): + // Ctrl/Cmd+Shift+T. + event.preventDefault(); + showAndFocusAddressRow("addressRowTo"); + break; + case SHOW_CC_KEY.toLowerCase(): + // Ctrl/Cmd+Shift+C. + event.preventDefault(); + showAndFocusAddressRow("addressRowCc"); + break; + case SHOW_BCC_KEY.toLowerCase(): + // Ctrl/Cmd+Shift+B. + event.preventDefault(); + showAndFocusAddressRow("addressRowBcc"); + break; + } + }); + + document.addEventListener("keypress", event => { + // If the user presses Esc and the drop attachment overlay is still visible, + // call the onDragLeave() method to properly hide it. + if ( + event.key == "Escape" && + document + .getElementById("dropAttachmentOverlay") + .classList.contains("show") + ) { + envelopeDragObserver.onDragLeave(event); + } + }); +} + +function ComposeUnload() { + // Send notification that the window is going away completely. + document + .getElementById("msgcomposeWindow") + .dispatchEvent( + new Event("compose-window-unload", { bubbles: false, cancelable: false }) + ); + + GetCurrentCommandManager().removeCommandObserver( + gMsgEditorCreationObserver, + "obs_documentCreated" + ); + UnloadCommandUpdateHandlers(); + + // In some tests, the window is closed so quickly that the observer + // hasn't fired and removed itself yet, so let's remove it here. + spellCheckReadyObserver.removeObserver(); + // Stop spell checker so personal dictionary is saved. + enableInlineSpellCheck(false); + + EditorCleanup(); + + if (gMsgCompose) { + gMsgCompose.removeMsgSendListener(gSendListener); + } + + RemoveMessageComposeOfflineQuitObserver(); + gAttachmentNotifier.shutdown(); + ToolbarIconColor.uninit(); + + // Stop observing dictionary removals. + dictionaryRemovalObserver.removeObserver(); + + if (gMsgCompose) { + // Notify the SendListener that Send has been aborted and Stopped + gMsgCompose.onSendNotPerformed(null, Cr.NS_ERROR_ABORT); + gMsgCompose.UnregisterStateListener(stateListener); + } + if (gAutoSaveTimeout) { + clearTimeout(gAutoSaveTimeout); + } + if (msgWindow) { + msgWindow.closeWindow(); + } + + ReleaseGlobalVariables(); + + top.controllers.removeController(SecurityController); + + // This destroys the window for us. + MsgComposeCloseWindow(); +} + +function onEncryptionChoice(value) { + switch (value) { + case "OpenPGP": + if (isPgpConfigured()) { + gSelectedTechnologyIsPGP = true; + checkEncryptionState(); + } + break; + + case "SMIME": + if (isSmimeEncryptionConfigured()) { + gSelectedTechnologyIsPGP = false; + checkEncryptionState(); + } + break; + + case "enc": + toggleEncryptMessage(); + break; + + case "encsub": + gEncryptSubject = !gEncryptSubject; + gUserTouchedEncryptSubject = true; + updateEncryptedSubject(); + break; + + case "sig": + toggleGlobalSignMessage(); + break; + + case "status": + showMessageComposeSecurityStatus(); + break; + + case "manager": + openKeyManager(); + break; + } +} + +var SecurityController = { + supportsCommand(command) { + switch (command) { + case "cmd_viewSecurityStatus": + return true; + + default: + return false; + } + }, + + isCommandEnabled(command) { + switch (command) { + case "cmd_viewSecurityStatus": + return true; + + default: + return false; + } + }, +}; + +function updateEncryptOptionsMenuElements() { + let encOpt = document.getElementById("button-encryption-options"); + if (encOpt) { + document.l10n.setAttributes( + encOpt, + gSelectedTechnologyIsPGP + ? "encryption-options-openpgp" + : "encryption-options-smime" + ); + document.l10n.setAttributes( + document.getElementById("menu_recipientStatus_Toolbar"), + gSelectedTechnologyIsPGP ? "menu-manage-keys" : "menu-view-certificates" + ); + document.getElementById("menu_securityEncryptSubject_Toolbar").hidden = + !gSelectedTechnologyIsPGP; + } + document.l10n.setAttributes( + document.getElementById("menu_recipientStatus_Menubar"), + gSelectedTechnologyIsPGP ? "menu-manage-keys" : "menu-view-certificates" + ); + document.getElementById("menu_securityEncryptSubject_Menubar").hidden = + !gSelectedTechnologyIsPGP; +} + +/** + * Update the aria labels of all non-custom address inputs and all pills in the + * addressing area. Also update the tooltips of the close labels of all address + * rows, including custom header fields. + */ +async function updateAriaLabelsAndTooltipsOfAllAddressRows() { + for (let row of document + .getElementById("recipientsContainer") + .querySelectorAll(".address-row")) { + updateAriaLabelsOfAddressRow(row); + updateTooltipsOfAddressRow(row); + } +} + +/** + * Update the aria labels of the address input and all pills of an address row. + * This is needed whenever a pill gets added or removed, because the aria label + * of each pill contains the current count of all pills in that row ("1 of n"). + * + * @param {Element} row - The address row. + */ +async function updateAriaLabelsOfAddressRow(row) { + // Bail out for custom header input where pills are disabled. + if (row.classList.contains("address-row-raw")) { + return; + } + let input = row.querySelector(".address-row-input"); + + let type = row.querySelector(".address-label-container > label").value; + let pills = row.querySelectorAll("mail-address-pill"); + + input.setAttribute( + "aria-label", + await l10nCompose.formatValue("address-input-type-aria-label", { + type, + count: pills.length, + }) + ); + + for (let pill of pills) { + pill.setAttribute( + "aria-label", + await l10nCompose.formatValue("pill-aria-label", { + email: pill.fullAddress, + count: pills.length, + }) + ); + } +} + +/** + * Update the tooltip of the close label of an address row. + * + * @param {Element} row - The address row. + */ +function updateTooltipsOfAddressRow(row) { + let type = row.querySelector(".address-label-container > label").value; + let el = row.querySelector(".remove-field-button"); + document.l10n.setAttributes(el, "remove-address-row-button", { type }); +} + +function onSendSMIME() { + let emailAddresses = []; + + try { + if (!gMsgCompose.compFields.composeSecure.requireEncryptMessage) { + return; + } + + for (let email of getEncryptionCompatibleRecipients()) { + if (!gSMFields.haveValidCertForEmail(email)) { + emailAddresses.push(email); + } + } + } catch (e) { + return; + } + + if (emailAddresses.length == 0) { + return; + } + + // The rules here: If the current identity has a directoryServer set, then + // use that, otherwise, try the global preference instead. + + let autocompleteDirectory; + + // Does the current identity override the global preference? + if (gCurrentIdentity.overrideGlobalPref) { + autocompleteDirectory = gCurrentIdentity.directoryServer; + } else if (Services.prefs.getBoolPref("ldap_2.autoComplete.useDirectory")) { + // Try the global one + autocompleteDirectory = Services.prefs.getCharPref( + "ldap_2.autoComplete.directoryServer" + ); + } + + if (autocompleteDirectory) { + window.openDialog( + "chrome://messenger-smime/content/certFetchingStatus.xhtml", + "", + "chrome,modal,resizable,centerscreen", + autocompleteDirectory, + emailAddresses + ); + } +} + +// Add-ons can override this to customize the behavior. +function DoSpellCheckBeforeSend() { + return Services.prefs.getBoolPref("mail.SpellCheckBeforeSend"); +} + +/** + * Updates gMsgCompose.compFields to match the UI. + * + * @returns {nsIMsgCompFields} + */ +function GetComposeDetails() { + let msgCompFields = gMsgCompose.compFields; + + Recipients2CompFields(msgCompFields); + let addresses = MailServices.headerParser.makeFromDisplayAddress( + document.getElementById("msgIdentity").value + ); + msgCompFields.from = MailServices.headerParser.makeMimeHeader(addresses); + msgCompFields.subject = document.getElementById("msgSubject").value; + Attachments2CompFields(msgCompFields); + + return msgCompFields; +} + +/** + * Updates the UI to match newValues. + * + * @param {object} newValues - New values to use. Values that should not change + * should be null or not present. + * @param {string} [newValues.to] + * @param {string} [newValues.cc] + * @param {string} [newValues.bcc] + * @param {string} [newValues.replyTo] + * @param {string} [newValues.newsgroups] + * @param {string} [newValues.followupTo] + * @param {string} [newValues.subject] + * @param {string} [newValues.body] + * @param {string} [newValues.plainTextBody] + */ +function SetComposeDetails(newValues) { + if (newValues.identityKey !== null) { + let identityList = document.getElementById("msgIdentity"); + for (let menuItem of identityList.menupopup.children) { + if (menuItem.getAttribute("identitykey") == newValues.identityKey) { + identityList.selectedItem = menuItem; + LoadIdentity(false); + break; + } + } + } + CompFields2Recipients(newValues); + if (typeof newValues.subject == "string") { + gMsgCompose.compFields.subject = document.getElementById( + "msgSubject" + ).value = newValues.subject; + SetComposeWindowTitle(); + } + if ( + typeof newValues.body == "string" && + typeof newValues.plainTextBody == "string" + ) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + let editor = GetCurrentEditor(); + if (typeof newValues.body == "string") { + if (!IsHTMLEditor()) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + editor.rebuildDocumentFromSource(newValues.body); + gMsgCompose.bodyModified = true; + } + if (typeof newValues.plainTextBody == "string") { + editor.selectAll(); + // Remove \r from line endings, which cause extra newlines (bug 1672407). + let mailEditor = editor.QueryInterface(Ci.nsIEditorMailSupport); + if (newValues.plainTextBody === "") { + editor.deleteSelection(editor.eNone, editor.eStrip); + } else { + mailEditor.insertTextWithQuotations( + newValues.plainTextBody.replaceAll("\r\n", "\n") + ); + } + gMsgCompose.bodyModified = true; + } + gContentChanged = true; +} + +/** + * Handles message sending operations. + * + * @param {nsIMsgCompDeliverMode} mode - The delivery mode of the operation. + */ +async function GenericSendMessage(msgType) { + let msgCompFields = GetComposeDetails(); + + // Some other msgCompFields have already been updated instantly in their + // respective toggle functions, e.g. ToggleReturnReceipt(), ToggleDSN(), + // ToggleAttachVCard(), and toggleAttachmentReminder(). + + let sending = + msgType == Ci.nsIMsgCompDeliverMode.Now || + msgType == Ci.nsIMsgCompDeliverMode.Later || + msgType == Ci.nsIMsgCompDeliverMode.Background; + + // Notify about a new message being prepared for sending. + window.dispatchEvent( + new CustomEvent("compose-prepare-message-start", { + detail: { msgType }, + }) + ); + + try { + if (sending) { + // Since the onBeforeSend event can manipulate compose details, execute it + // before the final sanity checks. + try { + await new Promise((resolve, reject) => { + let beforeSendEvent = new CustomEvent("beforesend", { + cancelable: true, + detail: { + resolve, + reject, + }, + }); + window.dispatchEvent(beforeSendEvent); + if (!beforeSendEvent.defaultPrevented) { + resolve(); + } + }); + } catch (ex) { + throw new Error(`Send aborted by an onBeforeSend event`); + } + + expandRecipients(); + // Check if e-mail addresses are complete, in case user turned off + // autocomplete to local domain. + if (!CheckValidEmailAddress(msgCompFields)) { + throw new Error(`Send aborted: invalid recipient address found`); + } + + // Do we need to check the spelling? + if (DoSpellCheckBeforeSend()) { + // We disable spellcheck for the following -subject line, attachment + // pane, identity and addressing widget therefore we need to explicitly + // focus on the mail body when we have to do a spellcheck. + focusMsgBody(); + window.cancelSendMessage = false; + window.openDialog( + "chrome://messenger/content/messengercompose/EdSpellCheck.xhtml", + "_blank", + "dialog,close,titlebar,modal,resizable", + true, + true, + false + ); + + if (window.cancelSendMessage) { + throw new Error(`Send aborted by the user: spelling errors found`); + } + } + + // Strip trailing spaces and long consecutive WSP sequences from the + // subject line to prevent getting only WSP chars on a folded line. + let subject = msgCompFields.subject; + let fixedSubject = subject.replace(/\s{74,}/g, " ").trimRight(); + if (fixedSubject != subject) { + subject = fixedSubject; + msgCompFields.subject = fixedSubject; + document.getElementById("msgSubject").value = fixedSubject; + } + + // Remind the person if there isn't a subject + if (subject == "") { + if ( + Services.prompt.confirmEx( + window, + getComposeBundle().getString("subjectEmptyTitle"), + getComposeBundle().getString("subjectEmptyMessage"), + Services.prompt.BUTTON_TITLE_IS_STRING * + Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_IS_STRING * + Services.prompt.BUTTON_POS_1, + getComposeBundle().getString("sendWithEmptySubjectButton"), + getComposeBundle().getString("cancelSendingButton"), + null, + null, + { value: 0 } + ) == 1 + ) { + document.getElementById("msgSubject").focus(); + throw new Error(`Send aborted by the user: subject missing`); + } + } + + // Attachment Reminder: Alert the user if + // - the user requested "Remind me later" from either the notification bar or the menu + // (alert regardless of the number of files already attached: we can't guess for how many + // or which files users want the reminder, and guessing wrong will annoy them a lot), OR + // - the aggressive pref is set and the latest notification is still showing (implying + // that the message has no attachment(s) yet, message still contains some attachment + // keywords, and notification was not dismissed). + if ( + gManualAttachmentReminder || + (Services.prefs.getBoolPref( + "mail.compose.attachment_reminder_aggressive" + ) && + gComposeNotification.getNotificationWithValue("attachmentReminder")) + ) { + let flags = + Services.prompt.BUTTON_POS_0 * + Services.prompt.BUTTON_TITLE_IS_STRING + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_IS_STRING; + let hadForgotten = Services.prompt.confirmEx( + window, + getComposeBundle().getString("attachmentReminderTitle"), + getComposeBundle().getString("attachmentReminderMsg"), + flags, + getComposeBundle().getString("attachmentReminderFalseAlarm"), + getComposeBundle().getString("attachmentReminderYesIForgot"), + null, + null, + { value: 0 } + ); + // Deactivate manual attachment reminder after showing the alert to avoid alert loop. + // We also deactivate reminder when user ignores alert with [x] or [ESC]. + if (gManualAttachmentReminder) { + toggleAttachmentReminder(false); + } + + if (hadForgotten) { + throw new Error(`Send aborted by the user: attachment missing`); + } + } + + // Aggressive many public recipients prompt. + let publicRecipientCount = getPublicAddressPillsCount(); + if ( + Services.prefs.getBoolPref( + "mail.compose.warn_public_recipients.aggressive" + ) && + publicRecipientCount >= + Services.prefs.getIntPref( + "mail.compose.warn_public_recipients.threshold" + ) + ) { + let flags = + Services.prompt.BUTTON_POS_0 * + Services.prompt.BUTTON_TITLE_IS_STRING + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_IS_STRING; + let [title, msg, cancel, send] = l10nComposeSync.formatValuesSync([ + "many-public-recipients-prompt-title", + { + id: "many-public-recipients-prompt-msg", + args: { count: getPublicAddressPillsCount() }, + }, + "many-public-recipients-prompt-cancel", + "many-public-recipients-prompt-send", + ]); + let willCancel = Services.prompt.confirmEx( + window, + title, + msg, + flags, + send, + cancel, + null, + null, + { value: 0 } + ); + + if (willCancel) { + if (!gRecipientObserver) { + // Re-create this observer as it is destroyed when the user dismisses + // the warning. + gRecipientObserver = new MutationObserver(function (mutations) { + if (mutations.some(m => m.type == "childList")) { + checkPublicRecipientsLimit(); + } + }); + } + checkPublicRecipientsLimit(); + throw new Error( + `Send aborted by the user: too many public recipients found` + ); + } + } + + // Check if the user tries to send a message to a newsgroup through a mail + // account. + var currentAccountKey = getCurrentAccountKey(); + let account = MailServices.accounts.getAccount(currentAccountKey); + if ( + account.incomingServer.type != "nntp" && + msgCompFields.newsgroups != "" + ) { + const kDontAskAgainPref = "mail.compose.dontWarnMail2Newsgroup"; + // default to ask user if the pref is not set + let dontAskAgain = Services.prefs.getBoolPref(kDontAskAgainPref); + if (!dontAskAgain) { + let checkbox = { value: false }; + let okToProceed = Services.prompt.confirmCheck( + window, + getComposeBundle().getString("noNewsgroupSupportTitle"), + getComposeBundle().getString("recipientDlogMessage"), + getComposeBundle().getString("CheckMsg"), + checkbox + ); + if (!okToProceed) { + throw new Error(`Send aborted by the user: wrong account used`); + } + + if (checkbox.value) { + Services.prefs.setBoolPref(kDontAskAgainPref, true); + } + } + + // remove newsgroups to prevent news_p to be set + // in nsMsgComposeAndSend::DeliverMessage() + msgCompFields.newsgroups = ""; + } + + if (Services.prefs.getBoolPref("mail.compose.add_link_preview", true)) { + // Remove any card "close" button from content before sending. + for (let close of getBrowser().contentDocument.querySelectorAll( + ".moz-card .remove-card" + )) { + close.remove(); + } + } + + let sendFormat = determineSendFormat(); + switch (sendFormat) { + case Ci.nsIMsgCompSendFormat.PlainText: + msgCompFields.forcePlainText = true; + msgCompFields.useMultipartAlternative = false; + break; + case Ci.nsIMsgCompSendFormat.HTML: + msgCompFields.forcePlainText = false; + msgCompFields.useMultipartAlternative = false; + break; + case Ci.nsIMsgCompSendFormat.Both: + msgCompFields.forcePlainText = false; + msgCompFields.useMultipartAlternative = true; + break; + default: + throw new Error(`Invalid send format ${sendFormat}`); + } + } + + await CompleteGenericSendMessage(msgType); + window.dispatchEvent(new CustomEvent("compose-prepare-message-success")); + } catch (exception) { + console.error(exception); + window.dispatchEvent( + new CustomEvent("compose-prepare-message-failure", { + detail: { exception }, + }) + ); + } +} + +/** + * Finishes message sending. This should ONLY be called directly from + * GenericSendMessage. This is a separate function so that it can be easily mocked + * in tests. + * + * @param msgType nsIMsgCompDeliverMode of the operation. + */ +async function CompleteGenericSendMessage(msgType) { + // hook for extra compose pre-processing + Services.obs.notifyObservers(window, "mail:composeOnSend"); + + if (!gSelectedTechnologyIsPGP) { + gMsgCompose.compFields.composeSecure.requireEncryptMessage = gSendEncrypted; + gMsgCompose.compFields.composeSecure.signMessage = gSendSigned; + onSendSMIME(); + } + + let sendError = null; + try { + // Just before we try to send the message, fire off the + // compose-send-message event for listeners, so they can do + // any pre-security work before sending. + var event = document.createEvent("UIEvents"); + event.initEvent("compose-send-message", false, true); + var msgcomposeWindow = document.getElementById("msgcomposeWindow"); + msgcomposeWindow.setAttribute("msgtype", msgType); + msgcomposeWindow.dispatchEvent(event); + if (event.defaultPrevented) { + throw Components.Exception( + "compose-send-message prevented", + Cr.NS_ERROR_ABORT + ); + } + + gAutoSaving = msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft; + + // disable the ui if we're not auto-saving + if (!gAutoSaving) { + ToggleWindowLock(true); + } else { + // If we're auto saving, mark the body as not changed here, and not + // when the save is done, because the user might change it between now + // and when the save is done. + SetContentAndBodyAsUnmodified(); + } + + // Keep track of send/saved cloudFiles and mark them as immutable. + let items = [...gAttachmentBucket.itemChildren]; + for (let item of items) { + if (item.attachment.sendViaCloud && item.cloudFileAccount) { + item.cloudFileAccount.markAsImmutable(item.cloudFileUpload.id); + } + } + + var progress = Cc["@mozilla.org/messenger/progress;1"].createInstance( + Ci.nsIMsgProgress + ); + if (progress) { + progress.registerListener(progressListener); + if ( + msgType == Ci.nsIMsgCompDeliverMode.Save || + msgType == Ci.nsIMsgCompDeliverMode.SaveAsDraft || + msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft || + msgType == Ci.nsIMsgCompDeliverMode.SaveAsTemplate + ) { + gSaveOperationInProgress = true; + } else { + gSendOperationInProgress = true; + } + } + msgWindow.domWindow = window; + msgWindow.rootDocShell.allowAuth = true; + await gMsgCompose.sendMsg( + msgType, + gCurrentIdentity, + getCurrentAccountKey(), + msgWindow, + progress + ); + } catch (ex) { + console.error("GenericSendMessage FAILED: " + ex); + ToggleWindowLock(false); + sendError = ex; + } + + if ( + msgType == Ci.nsIMsgCompDeliverMode.Now || + msgType == Ci.nsIMsgCompDeliverMode.Later || + msgType == Ci.nsIMsgCompDeliverMode.Background + ) { + window.dispatchEvent(new CustomEvent("aftersend")); + + let maxSize = + Services.prefs.getIntPref("mail.compose.big_attachments.threshold_kb") * + 1024; + let items = [...gAttachmentBucket.itemChildren]; + + // When any big attachment is not sent via filelink, increment + // `tb.filelink.ignored`. + if ( + items.some( + item => item.attachment.size >= maxSize && !item.attachment.sendViaCloud + ) + ) { + Services.telemetry.scalarAdd("tb.filelink.ignored", 1); + } + } else if ( + msgType == Ci.nsIMsgCompDeliverMode.Save || + msgType == Ci.nsIMsgCompDeliverMode.SaveAsDraft || + msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft || + msgType == Ci.nsIMsgCompDeliverMode.SaveAsTemplate + ) { + window.dispatchEvent(new CustomEvent("aftersave")); + } + + if (sendError) { + throw sendError; + } +} + +/** + * Check if the given email address is valid (contains an @). + * + * @param {string} address - The email address string to check. + */ +function isValidAddress(address) { + return address.includes("@", 1) && !address.endsWith("@"); +} + +/** + * Check if the given news address is valid (contains a dot). + * + * @param {string} address - The news address string to check. + */ +function isValidNewsAddress(address) { + return address.includes(".", 1) && !address.endsWith("."); +} + +/** + * Force the focus on the autocomplete input if the user clicks on an empty + * area of the address container. + * + * @param {Event} event - the event triggered by the click. + */ +function focusAddressInputOnClick(event) { + let container = event.target; + if (container.classList.contains("address-container")) { + container.querySelector(".address-row-input").focus(); + } +} + +/** + * Keep the Send buttons disabled until any recipient is entered. + */ +function updateSendLock() { + gSendLocked = true; + if (!gMsgCompose) { + return; + } + + const addressRows = [ + "toAddrContainer", + "ccAddrContainer", + "bccAddrContainer", + "newsgroupsAddrContainer", + ]; + + for (let parentID of addressRows) { + if (!gSendLocked) { + break; + } + + let parent = document.getElementById(parentID); + + if (!parent) { + continue; + } + + for (let address of parent.querySelectorAll(".address-pill")) { + let listNames = MimeParser.parseHeaderField( + address.fullAddress, + MimeParser.HEADER_ADDRESS + ); + let isMailingList = + listNames.length > 0 && + MailServices.ab.mailListNameExists(listNames[0].name); + + if ( + isValidAddress(address.emailAddress) || + isMailingList || + address.emailInput.classList.contains("news-input") + ) { + gSendLocked = false; + break; + } + } + } + + // Check the non pillified input text inside the autocomplete input fields. + for (let input of document.querySelectorAll( + ".address-row:not(.hidden):not(.address-row-raw) .address-row-input" + )) { + let inputValueTrim = input.value.trim(); + // If there's no text in the input, proceed with next input. + if (!inputValueTrim) { + continue; + } + // If text contains " >> " (typically from an unfinished autocompletion), + // lock Send and return. + if (inputValueTrim.includes(" >> ")) { + gSendLocked = true; + return; + } + + // If we find at least one valid pill, and in spite of potential other + // invalid pills or invalid addresses in the input, enable the Send button. + // It might be disabled again if the above autocomplete artifact is present + // in a subsequent row, to prevent sending the artifact as a valid address. + if ( + input.classList.contains("news-input") + ? isValidNewsAddress(inputValueTrim) + : isValidAddress(inputValueTrim) + ) { + gSendLocked = false; + } + } +} + +/** + * Check if the entered addresses are valid and alert the user if they are not. + * + * @param aMsgCompFields A nsIMsgCompFields object containing the fields to check. + */ +function CheckValidEmailAddress(aMsgCompFields) { + let invalidStr; + let recipientCount = 0; + // Check that each of the To, CC, and BCC recipients contains a '@'. + for (let type of ["to", "cc", "bcc"]) { + let recipients = aMsgCompFields.splitRecipients( + aMsgCompFields[type], + false + ); + // MsgCompFields contains only non-empty recipients. + recipientCount += recipients.length; + for (let recipient of recipients) { + if (!isValidAddress(recipient)) { + invalidStr = recipient; + break; + } + } + if (invalidStr) { + break; + } + } + + if (recipientCount == 0 && aMsgCompFields.newsgroups.trim() == "") { + Services.prompt.alert( + window, + getComposeBundle().getString("addressInvalidTitle"), + getComposeBundle().getString("noRecipients") + ); + return false; + } + + if (invalidStr) { + Services.prompt.alert( + window, + getComposeBundle().getString("addressInvalidTitle"), + getComposeBundle().getFormattedString("addressInvalid", [invalidStr], 1) + ); + return false; + } + + return true; +} + +/** + * Cycle through all the currently visible autocomplete addressing rows and + * generate pills for those inputs with leftover strings. Do the same if we + * have a pill currently being edited. This is necessary in case a user writes + * an extra address and clicks "Send" or "Save as..." before the text is + * converted into a pill. The input onBlur doesn't work if the click interaction + * happens on the window's menu bar. + */ +async function pillifyRecipients() { + for (let input of document.querySelectorAll( + ".address-row:not(.hidden):not(.address-row-raw) .address-row-input" + )) { + // If we find a leftover string in the input field, create a pill. If the + // newly created pill is not a valid address, the sending will stop. + if (input.value.trim()) { + recipientAddPills(input); + } + } + + // Update the currently editing pill, if any. + // It's impossible to edit more than one pill at once. + await document.querySelector("mail-address-pill.editing")?.updatePill(); +} + +/** + * Handle the dragover event on a recipient disclosure label. + * + * @param {Event} - The DOM dragover event on a recipient disclosure label. + */ +function showAddressRowButtonOnDragover(event) { + // Prevent dragover event's default action (which resets the current drag + // operation to "none"). + event.preventDefault(); +} + +/** + * Handle the drop event on a recipient disclosure label. + * + * @param {Event} - The DOM drop event on a recipient disclosure label. + */ +function showAddressRowButtonOnDrop(event) { + if (event.dataTransfer.types.includes("text/pills")) { + // If the dragged data includes the type "text/pills", we believe that + // the user is dragging our own pills, so we try to move the selected pills + // to the address row of the recipient label they were dropped on (Cc, Bcc, + // etc.), which will also show the row if needed. If there are no selected + // pills (so "text/pills" was generated elsewhere), moveSelectedPills() will + // bail out and we'll do nothing. + let row = document.getElementById(event.target.dataset.addressRow); + document.getElementById("recipientsContainer").moveSelectedPills(row); + } +} + +/** + * Command handler: Cut the selected pills. + */ +function cutSelectedPillsOnCommand() { + document.getElementById("recipientsContainer").cutSelectedPills(); +} + +/** + * Command handler: Copy the selected pills. + */ +function copySelectedPillsOnCommand() { + document.getElementById("recipientsContainer").copySelectedPills(); +} + +/** + * Command handler: Select the focused pill and all siblings in the same + * address row. + * + * @param {Element} focusPill - The focused <mail-address-pill> element. + */ +function selectAllSiblingPillsOnCommand(focusPill) { + let recipientsContainer = document.getElementById("recipientsContainer"); + // First deselect all pills to ensure that no pills outside the current + // address row are selected, e.g. when this action was triggered from + // context menu on already selected pill(s). + recipientsContainer.deselectAllPills(); + // Select all pills of the current address row. + recipientsContainer.selectSiblingPills(focusPill); +} + +/** + * Command handler: Select all recipient pills in the addressing area. + */ +function selectAllPillsOnCommand() { + document.getElementById("recipientsContainer").selectAllPills(); +} + +/** + * Command handler: Delete the selected pills. + */ +function deleteSelectedPillsOnCommand() { + document.getElementById("recipientsContainer").removeSelectedPills(); +} + +/** + * Command handler: Move the selected pills to another address row. + * + * @param {string} rowId - The id of the address row to move to. + */ +function moveSelectedPillsOnCommand(rowId) { + document + .getElementById("recipientsContainer") + .moveSelectedPills(document.getElementById(rowId)); +} + +/** + * Check if there are too many public recipients and offer to send them as BCC. + */ +function checkPublicRecipientsLimit() { + let notification = gComposeNotification.getNotificationWithValue( + "warnPublicRecipientsNotification" + ); + + let recipLimit = Services.prefs.getIntPref( + "mail.compose.warn_public_recipients.threshold" + ); + + let publicAddressPillsCount = getPublicAddressPillsCount(); + + if (publicAddressPillsCount < recipLimit) { + if (notification) { + gComposeNotification.removeNotification(notification); + } + return; + } + + // Reuse the existing notification since one is shown already. + if (notification) { + if (publicAddressPillsCount > 1) { + document.l10n.setAttributes( + notification.messageText, + "public-recipients-notice-multi", + { + count: publicAddressPillsCount, + } + ); + } else { + document.l10n.setAttributes( + notification.messageText, + "public-recipients-notice-single" + ); + } + return; + } + + // Construct the notification as we don't have one. + let bccButton = { + "l10n-id": "many-public-recipients-bcc", + callback() { + // Get public addresses before we remove the pills. + let publicAddresses = getPublicAddressPills().map( + pill => pill.fullAddress + ); + + addressRowClearPills(document.getElementById("addressRowTo")); + addressRowClearPills(document.getElementById("addressRowCc")); + // Add previously public address pills to Bcc address row and select them. + let bccRow = document.getElementById("addressRowBcc"); + addressRowAddRecipientsArray(bccRow, publicAddresses, true); + // Focus last added pill to prevent sticky selection with focus elsewhere. + bccRow.querySelector("mail-address-pill:last-of-type").focus(); + return false; + }, + }; + + let ignoreButton = { + "l10n-id": "many-public-recipients-ignore", + callback() { + gRecipientObserver.disconnect(); + gRecipientObserver = null; + // After closing notification with `Keep Recipients Public`, actively + // manage focus to prevent weird focus change e.g. to Contacts Sidebar. + // If focus was in addressing area before, restore that as the user might + // dismiss the notification when it appears while still adding recipients. + if (gLastFocusElement?.classList.contains("address-input")) { + gLastFocusElement.focus(); + return false; + } + + // Otherwise if there's no subject yet, focus that (ux-error-prevention). + let msgSubject = document.getElementById("msgSubject"); + if (!msgSubject.value) { + msgSubject.focus(); + return false; + } + + // Otherwise default to focusing message body. + document.getElementById("messageEditor").focus(); + return false; + }, + }; + + // NOTE: setting "public-recipients-notice-single" below, after the notification + // has been appended, so that the notification can be found and no further + // notifications are appended. + notification = gComposeNotification.appendNotification( + "warnPublicRecipientsNotification", + { + label: "", // "public-recipients-notice-single" + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + eventCallback(state) { + if (state == "dismissed") { + ignoreButton.callback(); + } + }, + }, + [bccButton, ignoreButton] + ); + + if (notification) { + if (publicAddressPillsCount > 1) { + document.l10n.setAttributes( + notification.messageText, + "public-recipients-notice-multi", + { + count: publicAddressPillsCount, + } + ); + } else { + document.l10n.setAttributes( + notification.messageText, + "public-recipients-notice-single" + ); + } + } +} + +/** + * Get all the address pills in the "To" and "Cc" fields. + * + * @returns {Element[]} All <mail-address-pill> elements in "To" and "CC" fields. + */ +function getPublicAddressPills() { + return [ + ...document.querySelectorAll("#toAddrContainer > mail-address-pill"), + ...document.querySelectorAll("#ccAddrContainer > mail-address-pill"), + ]; +} + +/** + * Gets the count of all the address pills in the "To" and "Cc" fields. This + * takes mailing lists into consideration as well. + */ +function getPublicAddressPillsCount() { + let pills = getPublicAddressPills(); + return pills.reduce( + (total, pill) => + pill.isMailList ? total + pill.listAddressCount : total + 1, + 0 + ); +} + +/** + * Check for Bcc recipients in an encrypted message and warn the user. + * The warning is not shown if the only Bcc recipient is the sender. + */ +async function checkEncryptedBccRecipients() { + let notification = gComposeNotification.getNotificationWithValue( + "warnEncryptedBccRecipients" + ); + + if (!gWantCannotEncryptBCCNotification) { + if (notification) { + gComposeNotification.removeNotification(notification); + } + return; + } + + let bccRecipients = [ + ...document.querySelectorAll("#bccAddrContainer > mail-address-pill"), + ]; + let bccIsSender = bccRecipients.every( + pill => pill.emailAddress == gCurrentIdentity.email + ); + + if (!gSendEncrypted || !bccRecipients.length || bccIsSender) { + if (notification) { + gComposeNotification.removeNotification(notification); + } + return; + } + + if (notification) { + return; + } + + let ignoreButton = { + "l10n-id": "encrypted-bcc-ignore-button", + callback() { + gWantCannotEncryptBCCNotification = false; + return false; + }, + }; + + gComposeNotification.appendNotification( + "warnEncryptedBccRecipients", + { + label: await document.l10n.formatValue("encrypted-bcc-warning"), + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + eventCallback(state) { + if (state == "dismissed") { + ignoreButton.callback(); + } + }, + }, + [ignoreButton] + ); +} + +async function SendMessage() { + await pillifyRecipients(); + let sendInBackground = Services.prefs.getBoolPref( + "mailnews.sendInBackground" + ); + if (sendInBackground && AppConstants.platform != "macosx") { + let count = [...Services.wm.getEnumerator(null)].length; + if (count == 1) { + sendInBackground = false; + } + } + + await GenericSendMessage( + sendInBackground + ? Ci.nsIMsgCompDeliverMode.Background + : Ci.nsIMsgCompDeliverMode.Now + ); + ExitFullscreenMode(); +} + +async function SendMessageWithCheck() { + await pillifyRecipients(); + var warn = Services.prefs.getBoolPref("mail.warn_on_send_accel_key"); + + if (warn) { + let bundle = getComposeBundle(); + let checkValue = { value: false }; + let buttonPressed = Services.prompt.confirmEx( + window, + bundle.getString("sendMessageCheckWindowTitle"), + bundle.getString("sendMessageCheckLabel"), + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1, + bundle.getString("sendMessageCheckSendButtonLabel"), + null, + null, + bundle.getString("CheckMsg"), + checkValue + ); + if (buttonPressed != 0) { + return; + } + if (checkValue.value) { + Services.prefs.setBoolPref("mail.warn_on_send_accel_key", false); + } + } + + let sendInBackground = Services.prefs.getBoolPref( + "mailnews.sendInBackground" + ); + + let mode; + if (Services.io.offline) { + mode = Ci.nsIMsgCompDeliverMode.Later; + } else { + mode = sendInBackground + ? Ci.nsIMsgCompDeliverMode.Background + : Ci.nsIMsgCompDeliverMode.Now; + } + await GenericSendMessage(mode); + ExitFullscreenMode(); +} + +async function SendMessageLater() { + await pillifyRecipients(); + await GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later); + ExitFullscreenMode(); +} + +function ExitFullscreenMode() { + // On OS X we need to deliberately exit full screen mode after sending. + if (AppConstants.platform == "macosx") { + window.fullScreen = false; + } +} + +function Save() { + switch (defaultSaveOperation) { + case "file": + SaveAsFile(false); + break; + case "template": + SaveAsTemplate(false).catch(console.error); + break; + default: + SaveAsDraft(false).catch(console.error); + break; + } +} + +function SaveAsFile(saveAs) { + GetCurrentEditorElement().contentDocument.title = + document.getElementById("msgSubject").value; + + if (gMsgCompose.bodyConvertible() == Ci.nsIMsgCompConvertible.Plain) { + SaveDocument(saveAs, false, "text/plain"); + } else { + SaveDocument(saveAs, false, "text/html"); + } + defaultSaveOperation = "file"; +} + +async function SaveAsDraft() { + gAutoSaveKickedIn = false; + gEditingDraft = true; + + await pillifyRecipients(); + await GenericSendMessage(Ci.nsIMsgCompDeliverMode.SaveAsDraft); + defaultSaveOperation = "draft"; +} + +async function SaveAsTemplate() { + gAutoSaveKickedIn = false; + gEditingDraft = false; + + await pillifyRecipients(); + let savedReferences = null; + if (gMsgCompose && gMsgCompose.compFields) { + // Clear References header. When we use the template, we don't want that + // header, yet, "edit as new message" maintains it. So we need to clear + // it when saving the template. + // Note: The In-Reply-To header is the last entry in the references header, + // so it will get cleared as well. + savedReferences = gMsgCompose.compFields.references; + gMsgCompose.compFields.references = null; + } + + await GenericSendMessage(Ci.nsIMsgCompDeliverMode.SaveAsTemplate); + defaultSaveOperation = "template"; + + if (savedReferences) { + gMsgCompose.compFields.references = savedReferences; + } +} + +// Sets the additional FCC, in addition to the default FCC. +function MessageFcc(aFolder) { + if (!gMsgCompose) { + return; + } + + var msgCompFields = gMsgCompose.compFields; + if (!msgCompFields) { + return; + } + + // Get the uri for the folder to FCC into. + var fccURI = aFolder.URI; + msgCompFields.fcc2 = msgCompFields.fcc2 == fccURI ? "nocopy://" : fccURI; +} + +function updateOptionsMenu() { + setSecuritySettings("_Menubar"); + + let menuItem = document.getElementById("menu_inlineSpellCheck"); + if (gSpellCheckingEnabled) { + menuItem.setAttribute("checked", "true"); + } else { + menuItem.removeAttribute("checked"); + } +} + +function updatePriorityMenu() { + if (gMsgCompose) { + var msgCompFields = gMsgCompose.compFields; + if (msgCompFields && msgCompFields.priority) { + var priorityMenu = document.getElementById("priorityMenu"); + priorityMenu.querySelector('[checked="true"]').removeAttribute("checked"); + priorityMenu + .querySelector('[value="' + msgCompFields.priority + '"]') + .setAttribute("checked", "true"); + } + } +} + +function updatePriorityToolbarButton(newPriorityValue) { + var prioritymenu = document.getElementById("priorityMenu-button"); + if (prioritymenu) { + prioritymenu.value = newPriorityValue; + } +} + +function PriorityMenuSelect(target) { + if (gMsgCompose) { + var msgCompFields = gMsgCompose.compFields; + if (msgCompFields) { + msgCompFields.priority = target.getAttribute("value"); + } + + // keep priority toolbar button in synch with possible changes via the menu item + updatePriorityToolbarButton(target.getAttribute("value")); + } +} + +/** + * Initialise the send format menu using the current gMsgCompose.compFields. + */ +function initSendFormatMenu() { + let formatToId = new Map([ + [Ci.nsIMsgCompSendFormat.PlainText, "format_plain"], + [Ci.nsIMsgCompSendFormat.HTML, "format_html"], + [Ci.nsIMsgCompSendFormat.Both, "format_both"], + [Ci.nsIMsgCompSendFormat.Auto, "format_auto"], + ]); + + let sendFormat = gMsgCompose.compFields.deliveryFormat; + + if (sendFormat == Ci.nsIMsgCompSendFormat.Unset) { + sendFormat = Services.prefs.getIntPref( + "mail.default_send_format", + Ci.nsIMsgCompSendFormat.Auto + ); + + if (!formatToId.has(sendFormat)) { + // Unknown preference value. + sendFormat = Ci.nsIMsgCompSendFormat.Auto; + } + } + + // Make the composition field uses the same as determined above. Specifically, + // if the deliveryFormat was Unset, we now set it to a specific value. + gMsgCompose.compFields.deliveryFormat = sendFormat; + + for (let [format, id] of formatToId.entries()) { + let menuitem = document.getElementById(id); + menuitem.value = String(format); + if (format == sendFormat) { + menuitem.setAttribute("checked", "true"); + } else { + menuitem.removeAttribute("checked"); + } + } + + document + .getElementById("outputFormatMenu") + .addEventListener("command", event => { + let prevSendFormat = gMsgCompose.compFields.deliveryFormat; + let newSendFormat = parseInt(event.target.value, 10); + gMsgCompose.compFields.deliveryFormat = newSendFormat; + gContentChanged = prevSendFormat != newSendFormat; + }); +} + +/** + * Walk through a plain text list of recipients and add them to the inline spell + * checker ignore list, e.g. to avoid that known recipient names get marked + * wrong in message body. + * + * @param {string} aAddressesToAdd - A (comma-separated) recipient(s) string. + */ +function addRecipientsToIgnoreList(aAddressesToAdd) { + if (gSpellCheckingEnabled) { + // break the list of potentially many recipients back into individual names + let addresses = + MailServices.headerParser.parseEncodedHeader(aAddressesToAdd); + let tokenizedNames = []; + + // Each name could consist of multiple word delimited by either commas or spaces, i.e. Green Lantern + // or Lantern,Green. Tokenize on comma first, then tokenize again on spaces. + for (let addr of addresses) { + if (!addr.name) { + continue; + } + let splitNames = addr.name.split(","); + for (let i = 0; i < splitNames.length; i++) { + // now tokenize off of white space + let splitNamesFromWhiteSpaceArray = splitNames[i].split(" "); + for ( + let whiteSpaceIndex = 0; + whiteSpaceIndex < splitNamesFromWhiteSpaceArray.length; + whiteSpaceIndex++ + ) { + if (splitNamesFromWhiteSpaceArray[whiteSpaceIndex]) { + tokenizedNames.push(splitNamesFromWhiteSpaceArray[whiteSpaceIndex]); + } + } + } + } + spellCheckReadyObserver.addWordsToIgnore(tokenizedNames); + } +} + +/** + * Observer waiting for spell checker to become initialized or to complete + * checking. When it fires, it pushes new words to be ignored to the speller. + */ +var spellCheckReadyObserver = { + _topic: "inlineSpellChecker-spellCheck-ended", + + _ignoreWords: [], + + observe(aSubject, aTopic, aData) { + if (aTopic != this._topic) { + return; + } + + this.removeObserver(); + this._addWords(); + }, + + _isAdded: false, + + addObserver() { + if (this._isAdded) { + return; + } + + Services.obs.addObserver(this, this._topic); + this._isAdded = true; + }, + + removeObserver() { + if (!this._isAdded) { + return; + } + + Services.obs.removeObserver(this, this._topic); + this._clearPendingWords(); + this._isAdded = false; + }, + + addWordsToIgnore(aIgnoreWords) { + this._ignoreWords.push(...aIgnoreWords); + let checker = GetCurrentEditorSpellChecker(); + if (!checker || checker.spellCheckPending) { + // spellchecker is enabled, but we must wait for its init to complete + this.addObserver(); + } else { + this._addWords(); + } + }, + + _addWords() { + // At the time the speller finally got initialized, we may already be closing + // the compose together with the speller, so we need to check if they + // are still valid. + let checker = GetCurrentEditorSpellChecker(); + if (gMsgCompose && checker?.enableRealTimeSpell) { + checker.ignoreWords(this._ignoreWords); + } + this._clearPendingWords(); + }, + + _clearPendingWords() { + this._ignoreWords.length = 0; + }, +}; + +/** + * Called if the list of recipients changed in any way. + * + * @param {boolean} automatic - Set to true if the change of recipients was + * invoked programmatically and should not be considered a change of message + * content. + */ +function onRecipientsChanged(automatic) { + if (!automatic) { + gContentChanged = true; + } + updateSendCommands(true); +} + +/** + * Show the popup identified by aPopupID + * at the anchor element identified by aAnchorID. + * + * Note: All but the first 2 parameters are identical with the parameters of + * the openPopup() method of XUL popup element. For details, please consult docs. + * Except aPopupID, all parameters are optional. + * Example: showPopupById("aPopupID", "aAnchorID"); + * + * @param aPopupID the ID of the popup element to be shown + * @param aAnchorID the ID of an element to which the popup should be anchored + * @param aPosition a single-word alignment value for the position parameter + * of openPopup() method; defaults to "after_start" if omitted. + * @param x x offset from default position + * @param y y offset from default position + * @param isContextMenu {boolean} For details, see documentation. + * @param attributesOverride {boolean} whether the position attribute on the + * popup node overrides the position parameter + * @param triggerEvent the event that triggered the popup + */ +function showPopupById( + aPopupID, + aAnchorID, + aPosition = "after_start", + x, + y, + isContextMenu, + attributesOverride, + triggerEvent +) { + let popup = document.getElementById(aPopupID); + let anchor = document.getElementById(aAnchorID); + popup.openPopup( + anchor, + aPosition, + x, + y, + isContextMenu, + attributesOverride, + triggerEvent + ); +} + +function InitLanguageMenu() { + var languageMenuList = document.getElementById("languageMenuList"); + if (!languageMenuList) { + return; + } + + var spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService( + Ci.mozISpellCheckingEngine + ); + + // Get the list of dictionaries from + // the spellchecker. + + var dictList = spellChecker.getDictionaryList(); + + let extraItemCount = dictList.length === 0 ? 1 : 2; + + // If dictionary count hasn't changed then no need to update the menu. + if (dictList.length + extraItemCount == languageMenuList.childElementCount) { + return; + } + + var sortedList = gSpellChecker.sortDictionaryList(dictList); + + let getMoreItem = document.createXULElement("menuitem"); + document.l10n.setAttributes(getMoreItem, "spell-add-dictionaries"); + getMoreItem.addEventListener("command", event => { + event.stopPropagation(); + openDictionaryList(); + }); + let getMoreArray = [getMoreItem]; + + if (extraItemCount > 1) { + getMoreArray.unshift(document.createXULElement("menuseparator")); + } + + // Remove any languages from the list. + languageMenuList.replaceChildren( + ...sortedList.map(dict => { + let item = document.createXULElement("menuitem"); + item.setAttribute("label", dict.displayName); + item.setAttribute("value", dict.localeCode); + item.setAttribute("type", "checkbox"); + item.setAttribute("selection-type", "multiple"); + if (dictList.length > 1) { + item.setAttribute("closemenu", "none"); + } + return item; + }), + ...getMoreArray + ); +} + +function OnShowDictionaryMenu(aTarget) { + InitLanguageMenu(); + + for (let item of aTarget.children) { + item.setAttribute( + "checked", + gActiveDictionaries.has(item.getAttribute("value")) + ); + } +} + +function languageMenuListOpened() { + document + .getElementById("languageStatusButton") + .setAttribute("aria-expanded", "true"); +} + +function languageMenuListClosed() { + document + .getElementById("languageStatusButton") + .setAttribute("aria-expanded", "false"); +} + +/** + * Set of the active dictionaries. We maintain this cached state so we don't + * need a spell checker instance to know the active dictionaries. This is + * especially relevant when inline spell checking is disabled. + * + * @type {Set<string>} + */ +var gActiveDictionaries = new Set(); +/** + * Change the language of the composition and if we are using inline + * spell check, recheck the message with the new dictionary. + * + * Note: called from the "Check Spelling" panel in SelectLanguage(). + * + * @param {string[]} languages - New languages to set. + */ +async function ComposeChangeLanguage(languages) { + let currentLanguage = document.documentElement.getAttribute("lang"); + if ( + (languages.length === 1 && currentLanguage != languages[0]) || + languages.length !== 1 + ) { + let languageToSet = ""; + if (languages.length === 1) { + languageToSet = languages[0]; + } + // Update the document language as well. + document.documentElement.setAttribute("lang", languageToSet); + } + + await gSpellChecker?.selectDictionaries(languages); + + let checker = GetCurrentEditorSpellChecker(); + if (checker?.spellChecker) { + await checker.spellChecker.setCurrentDictionaries(languages); + } + // Update subject spell checker languages. If for some reason the spell + // checker isn't ready yet, don't auto-create it, hence pass 'false'. + let subjectSpellChecker = checker?.spellChecker + ? document.getElementById("msgSubject").editor.getInlineSpellChecker(false) + : null; + if (subjectSpellChecker?.spellChecker) { + await subjectSpellChecker.spellChecker.setCurrentDictionaries(languages); + } + + // now check the document over again with the new dictionary + if (gSpellCheckingEnabled) { + if (checker?.spellChecker) { + checker.spellCheckRange(null); + } + + if (subjectSpellChecker?.spellChecker) { + // Also force a recheck of the subject. + subjectSpellChecker.spellCheckRange(null); + } + } + + await updateLanguageInStatusBar(languages); + + // Update the language in the composition fields, so we can save it + // to the draft next time. + if (gMsgCompose?.compFields) { + let langs = ""; + if (!Services.prefs.getBoolPref("mail.suppress_content_language")) { + langs = languages.join(", "); + } + gMsgCompose.compFields.contentLanguage = langs; + } + + gActiveDictionaries = new Set(languages); + + // Notify compose WebExtension API about changed dictionaries. + window.dispatchEvent( + new CustomEvent("active-dictionaries-changed", { + detail: languages.join(","), + }) + ); +} + +/** + * Change the language of the composition and if we are using inline + * spell check, recheck the message with the new dictionary. + * + * @param {Event} event - Event of selecting an item in the spelling button + * menulist popup. + */ +function ChangeLanguage(event) { + let curLangs = new Set(gActiveDictionaries); + if (curLangs.has(event.target.value)) { + curLangs.delete(event.target.value); + } else { + curLangs.add(event.target.value); + } + ComposeChangeLanguage(Array.from(curLangs)); + event.stopPropagation(); +} + +/** + * Update the active dictionaries in the status bar. + * + * @param {string[]} dictionaries + */ +async function updateLanguageInStatusBar(dictionaries) { + // HACK: calling sortDictionaryList (in InitLanguageMenu) may fail the first + // time due to synchronous loading of the .ftl files. If we load the files + // and wait for a known value asynchronously, no such failure will happen. + await new Localization([ + "toolkit/intl/languageNames.ftl", + "toolkit/intl/regionNames.ftl", + ]).formatValue("language-name-en"); + + InitLanguageMenu(); + let languageMenuList = document.getElementById("languageMenuList"); + let languageStatusButton = document.getElementById("languageStatusButton"); + if (!languageMenuList || !languageStatusButton) { + return; + } + + if (!dictionaries) { + dictionaries = Array.from(gActiveDictionaries); + } + let listFormat = new Intl.ListFormat(undefined, { + type: "conjunction", + style: "short", + }); + let languages = []; + let item = languageMenuList.firstElementChild; + + // No status display, if there is only one or no spelling dictionary available. + if (languageMenuList.childElementCount <= 3) { + languageStatusButton.hidden = true; + languageStatusButton.textContent = ""; + return; + } + + languageStatusButton.hidden = false; + while (item) { + if (item.tagName.toLowerCase() === "menuseparator") { + break; + } + if (dictionaries.includes(item.getAttribute("value"))) { + languages.push(item.getAttribute("label")); + } + item = item.nextElementSibling; + } + if (languages.length > 0) { + languageStatusButton.textContent = listFormat.format(languages); + } else { + languageStatusButton.textContent = listFormat.format(dictionaries); + } +} + +/** + * Toggle Return Receipt (Disposition-Notification-To: header). + * + * @param {boolean} [forcedState] - Forced state to use for returnReceipt. + * If not set, the current state will be toggled. + */ +function ToggleReturnReceipt(forcedState) { + let msgCompFields = gMsgCompose.compFields; + if (!msgCompFields) { + return; + } + if (forcedState === undefined) { + msgCompFields.returnReceipt = !msgCompFields.returnReceipt; + gReceiptOptionChanged = true; + } else { + if (msgCompFields.returnReceipt != forcedState) { + gReceiptOptionChanged = true; + } + msgCompFields.returnReceipt = forcedState; + } + for (let item of document.querySelectorAll(`menuitem[command="cmd_toggleReturnReceipt"], + toolbarbutton[command="cmd_toggleReturnReceipt"]`)) { + item.setAttribute("checked", msgCompFields.returnReceipt); + } +} + +function ToggleDSN(target) { + let msgCompFields = gMsgCompose.compFields; + if (msgCompFields) { + msgCompFields.DSN = !msgCompFields.DSN; + target.setAttribute("checked", msgCompFields.DSN); + gDSNOptionChanged = true; + } +} + +function ToggleAttachVCard(target) { + var msgCompFields = gMsgCompose.compFields; + if (msgCompFields) { + msgCompFields.attachVCard = !msgCompFields.attachVCard; + target.setAttribute("checked", msgCompFields.attachVCard); + gAttachVCardOptionChanged = true; + } +} + +/** + * Toggles or sets the status of manual Attachment Reminder, i.e. whether + * the user will get the "Attachment Reminder" alert before sending or not. + * Toggles checkmark on "Remind me later" menuitem and internal + * gManualAttachmentReminder flag accordingly. + * + * @param aState (optional) true = activate reminder. + * false = deactivate reminder. + * (default) = toggle reminder state. + */ +function toggleAttachmentReminder(aState = !gManualAttachmentReminder) { + gManualAttachmentReminder = aState; + document.getElementById("cmd_remindLater").setAttribute("checked", aState); + gMsgCompose.compFields.attachmentReminder = aState; + + // If we enabled manual reminder, the reminder can't be turned off. + if (aState) { + gDisableAttachmentReminder = false; + } + + manageAttachmentNotification(false); +} + +/** + * Triggers or removes the CSS animation for the counter of newly uploaded + * attachments. + */ +function toggleAttachmentAnimation() { + gAttachmentCounter.classList.toggle("is_animating"); +} + +function FillIdentityList(menulist) { + let accounts = FolderUtils.allAccountsSorted(true); + + let accountHadSeparator = false; + let firstAccountWithIdentities = true; + for (let account of accounts) { + let identities = account.identities; + + if (identities.length == 0) { + continue; + } + + let needSeparator = identities.length > 1; + if (needSeparator || accountHadSeparator) { + // Separate identities from this account from the previous + // account's identities if there is more than 1 in the current + // or previous account. + if (!firstAccountWithIdentities) { + // only if this is not the first account shown + let separator = document.createXULElement("menuseparator"); + menulist.menupopup.appendChild(separator); + } + accountHadSeparator = needSeparator; + } + firstAccountWithIdentities = false; + + for (let i = 0; i < identities.length; i++) { + let identity = identities[i]; + let item = menulist.appendItem( + identity.identityName, + identity.fullAddress, + account.incomingServer.prettyName + ); + item.setAttribute("identitykey", identity.key); + item.setAttribute("accountkey", account.key); + if (i == 0) { + // Mark the first identity as default. + item.setAttribute("default", "true"); + } + // Create the menuitem description and add it after the last label in the + // menuitem internals. + let desc = document.createXULElement("label"); + desc.value = item.getAttribute("description"); + desc.classList.add("menu-description"); + desc.setAttribute("crop", "end"); + item.querySelector("label:last-child").after(desc); + } + } + + menulist.menupopup.appendChild(document.createXULElement("menuseparator")); + menulist.menupopup + .appendChild(document.createXULElement("menuitem")) + .setAttribute("command", "cmd_customizeFromAddress"); +} + +function getCurrentAccountKey() { + // Get the account's key. + let identityList = document.getElementById("msgIdentity"); + return identityList.getAttribute("accountkey"); +} + +function getCurrentIdentityKey() { + // Get the identity key. + return gCurrentIdentity.key; +} + +function AdjustFocus() { + // If is NNTP account, check the newsgroup field. + let account = MailServices.accounts.getAccount(getCurrentAccountKey()); + let accountType = account.incomingServer.type; + + let element = + accountType == "nntp" + ? document.getElementById("newsgroupsAddrContainer") + : document.getElementById("toAddrContainer"); + + // Focus on the recipient input field if no pills are present. + if (element.querySelectorAll("mail-address-pill").length == 0) { + element.querySelector(".address-row-input").focus(); + return; + } + + // Focus subject if empty. + element = document.getElementById("msgSubject"); + if (element.value == "") { + element.focus(); + return; + } + + // Focus message body. + focusMsgBody(); +} + +/** + * Set the compose window title with flavors (Write | Print Preview). + * + * @param isPrintPreview (optional) true: Set title for 'Print Preview' window. + * false: Set title for 'Write' window (default). + */ +function SetComposeWindowTitle(isPrintPreview = false) { + let aStringName = isPrintPreview + ? "windowTitlePrintPreview" + : "windowTitleWrite"; + let subject = + document.getElementById("msgSubject").value.trim() || + getComposeBundle().getString("defaultSubject"); + let brandBundle = document.getElementById("brandBundle"); + let brandShortName = brandBundle.getString("brandShortName"); + let newTitle = getComposeBundle().getFormattedString(aStringName, [ + subject, + brandShortName, + ]); + document.title = newTitle; + if (AppConstants.platform == "macosx") { + document.getElementById("titlebar-title-label").value = newTitle; + } +} + +// Check for changes to document and allow saving before closing +// This is hooked up to the OS's window close widget (e.g., "X" for Windows) +function ComposeCanClose() { + // No open compose window? + if (!gMsgCompose) { + return true; + } + + // Do this early, so ldap sessions have a better chance to + // cleanup after themselves. + if (gSendOperationInProgress || gSaveOperationInProgress) { + let result; + + let brandBundle = document.getElementById("brandBundle"); + let brandShortName = brandBundle.getString("brandShortName"); + let promptTitle = gSendOperationInProgress + ? getComposeBundle().getString("quitComposeWindowTitle") + : getComposeBundle().getString("quitComposeWindowSaveTitle"); + let promptMsg = gSendOperationInProgress + ? getComposeBundle().getFormattedString( + "quitComposeWindowMessage2", + [brandShortName], + 1 + ) + : getComposeBundle().getFormattedString( + "quitComposeWindowSaveMessage", + [brandShortName], + 1 + ); + let quitButtonLabel = getComposeBundle().getString( + "quitComposeWindowQuitButtonLabel2" + ); + let waitButtonLabel = getComposeBundle().getString( + "quitComposeWindowWaitButtonLabel2" + ); + + result = Services.prompt.confirmEx( + window, + promptTitle, + promptMsg, + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1, + waitButtonLabel, + quitButtonLabel, + null, + null, + { value: 0 } + ); + + if (result == 1) { + gMsgCompose.abort(); + return true; + } + return false; + } + + // Returns FALSE only if user cancels save action + if ( + gContentChanged || + gMsgCompose.bodyModified || + gAutoSaveKickedIn || + gReceiptOptionChanged || + gDSNOptionChanged + ) { + // call window.focus, since we need to pop up a dialog + // and therefore need to be visible (to prevent user confusion) + window.focus(); + let draftFolderURI = gCurrentIdentity.draftFolder; + let draftFolderName = + MailUtils.getOrCreateFolder(draftFolderURI).prettyName; + let result = Services.prompt.confirmEx( + window, + getComposeBundle().getString("saveDlogTitle"), + getComposeBundle().getFormattedString("saveDlogMessages3", [ + draftFolderName, + ]), + Services.prompt.BUTTON_TITLE_SAVE * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 + + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_2, + null, + null, + getComposeBundle().getString("discardButtonLabel"), + null, + { value: 0 } + ); + switch (result) { + case 0: // Save + // Since we're going to save the message, we tell toolkit that + // the close command failed, by returning false, and then + // we close the window ourselves after the save is done. + gCloseWindowAfterSave = true; + // We catch the exception because we need to tell toolkit that it + // shouldn't close the window, because we're going to close it + // ourselves. If we don't tell toolkit that, and then close the window + // ourselves, the toolkit code that keeps track of the open windows + // gets off by one and the app can close unexpectedly on os's that + // shutdown the app when the last window is closed. + GenericSendMessage(Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft).catch( + console.error + ); + return false; + case 1: // Cancel + return false; + case 2: // Don't Save + // don't delete the draft if we didn't start off editing a draft + // and the user hasn't explicitly saved it. + if (!gEditingDraft && gAutoSaveKickedIn) { + RemoveDraft(); + } + // Remove auto-saved draft created during "edit template". + if (gMsgCompose.compFields.templateId && gAutoSaveKickedIn) { + RemoveDraft(); + } + break; + } + } + + return true; +} + +function RemoveDraft() { + try { + var draftUri = gMsgCompose.compFields.draftId; + var msgKey = draftUri.substr(draftUri.indexOf("#") + 1); + let folder = MailUtils.getExistingFolder(gMsgCompose.savedFolderURI); + if (!folder) { + return; + } + try { + if (folder.getFlag(Ci.nsMsgFolderFlags.Drafts)) { + let msgHdr = folder.GetMessageHeader(msgKey); + folder.deleteMessages([msgHdr], null, true, false, null, false); + } + } catch (ex) { + // couldn't find header - perhaps an imap folder. + var imapFolder = folder.QueryInterface(Ci.nsIMsgImapMailFolder); + if (imapFolder) { + imapFolder.storeImapFlags( + Ci.nsMsgFolderFlags.Expunged, + true, + [msgKey], + null + ); + } + } + } catch (ex) {} +} + +function SetContentAndBodyAsUnmodified() { + gMsgCompose.bodyModified = false; + gContentChanged = false; +} + +function MsgComposeCloseWindow() { + if (gMsgCompose) { + gMsgCompose.CloseWindow(); + } else { + window.close(); + } +} + +function GetLastAttachDirectory() { + var lastDirectory; + + try { + lastDirectory = Services.prefs.getComplexValue( + kComposeAttachDirPrefName, + Ci.nsIFile + ); + } catch (ex) { + // this will fail the first time we attach a file + // as we won't have a pref value. + lastDirectory = null; + } + + return lastDirectory; +} + +// attachedLocalFile must be a nsIFile +function SetLastAttachDirectory(attachedLocalFile) { + try { + let file = attachedLocalFile.QueryInterface(Ci.nsIFile); + let parent = file.parent.QueryInterface(Ci.nsIFile); + + Services.prefs.setComplexValue( + kComposeAttachDirPrefName, + Ci.nsIFile, + parent + ); + } catch (ex) { + dump("error: SetLastAttachDirectory failed: " + ex + "\n"); + } +} + +function AttachFile() { + if (gAttachmentBucket.itemCount) { + // If there are existing attachments already, restore attachment pane before + // showing the file picker so that user can see them while adding more. + toggleAttachmentPane("show"); + } + + // Get file using nsIFilePicker and convert to URL + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init( + window, + getComposeBundle().getString("chooseFileToAttach"), + Ci.nsIFilePicker.modeOpenMultiple + ); + + let lastDirectory = GetLastAttachDirectory(); + if (lastDirectory) { + fp.displayDirectory = lastDirectory; + } + + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.open(rv => { + if (rv != Ci.nsIFilePicker.returnOK || !fp.files) { + return; + } + + let file; + let attachments = []; + + for (file of [...fp.files]) { + attachments.push(FileToAttachment(file)); + } + + AddAttachments(attachments); + SetLastAttachDirectory(file); + }); +} + +/** + * Convert an nsIFile instance into an nsIMsgAttachment. + * + * @param file the nsIFile + * @returns an attachment pointing to the file + */ +function FileToAttachment(file) { + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + let attachment = Cc[ + "@mozilla.org/messengercompose/attachment;1" + ].createInstance(Ci.nsIMsgAttachment); + + attachment.url = fileHandler.getURLSpecFromActualFile(file); + attachment.size = file.fileSize; + return attachment; +} + +async function messageAttachmentToFile(attachment) { + let pathTempDir = PathUtils.join( + PathUtils.tempDir, + "pid-" + Services.appinfo.processID + ); + await IOUtils.makeDirectory(pathTempDir, { permissions: 0o700 }); + let pathTempFile = await IOUtils.createUniqueFile( + pathTempDir, + attachment.name.replaceAll(/[/:*?\"<>|]/g, "_"), + 0o600 + ); + let tempFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + tempFile.initWithPath(pathTempFile); + let extAppLauncher = Cc[ + "@mozilla.org/uriloader/external-helper-app-service;1" + ].getService(Ci.nsPIExternalAppLauncher); + extAppLauncher.deleteTemporaryFileOnExit(tempFile); + + let service = MailServices.messageServiceFromURI(attachment.url); + let bytes = await new Promise((resolve, reject) => { + let streamlistener = { + _data: [], + _stream: null, + onDataAvailable(aRequest, aInputStream, aOffset, aCount) { + if (!this._stream) { + this._stream = Cc[ + "@mozilla.org/scriptableinputstream;1" + ].createInstance(Ci.nsIScriptableInputStream); + this._stream.init(aInputStream); + } + this._data.push(this._stream.read(aCount)); + }, + onStartRequest() {}, + onStopRequest(aRequest, aStatus) { + if (aStatus == Cr.NS_OK) { + resolve(this._data.join("")); + } else { + console.error(aStatus); + reject(); + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIStreamListener", + "nsIRequestObserver", + ]), + }; + + service.streamMessage( + attachment.url, + streamlistener, + null, // aMsgWindow + null, // aUrlListener + false, // aConvertData + "" //aAdditionalHeader + ); + }); + await IOUtils.write( + pathTempFile, + lazy.MailStringUtils.byteStringToUint8Array(bytes) + ); + return tempFile; +} + +/** + * Add a list of attachment objects as attachments. The attachment URLs must + * be set. + * + * @param {nsIMsgAttachment[]} aAttachments - Objects to add as attachments. + * @param {boolean} [aContentChanged=true] - Optional value to assign gContentChanged + * after adding attachments. + */ +async function AddAttachments(aAttachments, aContentChanged = true) { + let addedAttachments = []; + let items = []; + + for (let attachment of aAttachments) { + if (!attachment?.url || DuplicateFileAlreadyAttached(attachment)) { + continue; + } + + if (!attachment.name) { + attachment.name = gMsgCompose.AttachmentPrettyName(attachment.url, null); + } + + // For security reasons, don't allow *-message:// uris to leak out. + // We don't want to reveal the .slt path (for mailbox://), or the username + // or hostname. + // Don't allow file or mail/news protocol uris to leak out either. + if ( + /^mailbox-message:|^imap-message:|^news-message:/i.test(attachment.name) + ) { + attachment.name = getComposeBundle().getString( + "messageAttachmentSafeName" + ); + } else if (/^file:|^mailbox:|^imap:|^s?news:/i.test(attachment.name)) { + attachment.name = getComposeBundle().getString("partAttachmentSafeName"); + } + + // Create temporary files for message attachments. + if ( + /^mailbox-message:|^imap-message:|^news-message:/i.test(attachment.url) + ) { + try { + let messageFile = await messageAttachmentToFile(attachment); + // Store the original mailbox:// url in contentLocation. + attachment.contentLocation = attachment.url; + attachment.url = Services.io.newFileURI(messageFile).spec; + } catch (ex) { + console.error( + `Could not save message attachment ${attachment.url} as file: ${ex}` + ); + } + } + + if ( + attachment.msgUri && + /^mailbox-message:|^imap-message:|^news-message:/i.test( + attachment.msgUri + ) && + attachment.url && + /^mailbox:|^imap:|^s?news:/i.test(attachment.url) + ) { + // This is an attachment of another message, create a temporary file and + // update the url. + let pathTempDir = PathUtils.join( + PathUtils.tempDir, + "pid-" + Services.appinfo.processID + ); + await IOUtils.makeDirectory(pathTempDir, { permissions: 0o700 }); + let tempDir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + tempDir.initWithPath(pathTempDir); + + let tempFile = gMessenger.saveAttachmentToFolder( + attachment.contentType, + attachment.url, + encodeURIComponent(attachment.name), + attachment.msgUri, + tempDir + ); + let extAppLauncher = Cc[ + "@mozilla.org/uriloader/external-helper-app-service;1" + ].getService(Ci.nsPIExternalAppLauncher); + extAppLauncher.deleteTemporaryFileOnExit(tempFile); + // Store the original mailbox:// url in contentLocation. + attachment.contentLocation = attachment.url; + attachment.url = Services.io.newFileURI(tempFile).spec; + } + + let item = gAttachmentBucket.appendItem(attachment); + addedAttachments.push(attachment); + + let tooltiptext; + try { + tooltiptext = decodeURI(attachment.url); + } catch { + tooltiptext = attachment.url; + } + item.setAttribute("tooltiptext", tooltiptext); + item.addEventListener("command", OpenSelectedAttachment); + items.push(item); + } + + if (addedAttachments.length > 0) { + // Trigger a visual feedback to let the user know how many attachments have + // been added. + gAttachmentCounter.textContent = `+${addedAttachments.length}`; + toggleAttachmentAnimation(); + + // Move the focus on the last attached file so the user can see a visual + // feedback of what was added. + gAttachmentBucket.selectedIndex = gAttachmentBucket.getIndexOfItem( + items[items.length - 1] + ); + + // Ensure the selected item is visible and if not the box will scroll to it. + gAttachmentBucket.ensureIndexIsVisible(gAttachmentBucket.selectedIndex); + + AttachmentsChanged("show", aContentChanged); + dispatchAttachmentBucketEvent("attachments-added", addedAttachments); + + // Set min height for the attachment bucket. + if (!gAttachmentBucket.style.minHeight) { + // Min height is the height of the first child plus padding and border. + // Note: we assume the computed styles have px values. + let bucketStyle = getComputedStyle(gAttachmentBucket); + let childStyle = getComputedStyle(gAttachmentBucket.firstChild); + let minHeight = + gAttachmentBucket.firstChild.getBoundingClientRect().height + + parseFloat(childStyle.marginBlockStart) + + parseFloat(childStyle.marginBlockEnd) + + parseFloat(bucketStyle.paddingBlockStart) + + parseFloat(bucketStyle.paddingBlockEnd) + + parseFloat(bucketStyle.borderBlockStartWidth) + + parseFloat(bucketStyle.borderBlockEndWidth); + gAttachmentBucket.style.minHeight = `${minHeight}px`; + } + } + + // Always show the attachment pane if we have any attachment, to prevent + // keeping the panel collapsed when the user interacts with the attachment + // button. + if (gAttachmentBucket.itemCount) { + toggleAttachmentPane("show"); + } + + return items; +} + +/** + * Returns a sorted-by-index, "non-live" array of attachment list items. + * + * @param aAscending {boolean}: true (default): sort return array ascending + * false : sort return array descending + * @param aSelectedOnly {boolean}: true: return array of selected items only. + * false (default): return array of all items. + * + * @returns {Array} an array of (all | selected) listItem elements in + * attachmentBucket listbox, "non-live" and sorted by their index + * in the list; [] if there are (no | no selected) attachments. + */ +function attachmentsGetSortedArray(aAscending = true, aSelectedOnly = false) { + let listItems; + + if (aSelectedOnly) { + // Selected attachments only. + if (!gAttachmentBucket.selectedCount) { + return []; + } + + // gAttachmentBucket.selectedItems is a "live" and "unordered" node list + // (items get added in the order they were added to the selection). But we + // want a stable ("non-live") array of selected items, sorted by their index + // in the list. + listItems = [...gAttachmentBucket.selectedItems]; + } else { + // All attachments. + if (!gAttachmentBucket.itemCount) { + return []; + } + + listItems = [...gAttachmentBucket.itemChildren]; + } + + if (aAscending) { + listItems.sort( + (a, b) => + gAttachmentBucket.getIndexOfItem(a) - + gAttachmentBucket.getIndexOfItem(b) + ); + } else { + // descending + listItems.sort( + (a, b) => + gAttachmentBucket.getIndexOfItem(b) - + gAttachmentBucket.getIndexOfItem(a) + ); + } + return listItems; +} + +/** + * Returns a sorted-by-index, "non-live" array of selected attachment list items. + * + * @param aAscending {boolean}: true (default): sort return array ascending + * false : sort return array descending + * @returns {Array} an array of selected listitem elements in attachmentBucket + * listbox, "non-live" and sorted by their index in the list; + * [] if no attachments selected + */ +function attachmentsSelectionGetSortedArray(aAscending = true) { + return attachmentsGetSortedArray(aAscending, true); +} + +/** + * Return true if the selected attachment items are a coherent block in the list, + * otherwise false. + * + * @param aListPosition (optional) - "top" : Return true only if the block is + * at the top of the list. + * "bottom": Return true only if the block is + * at the bottom of the list. + * @returns {boolean} true : The selected attachment items are a coherent block + * (at the list edge if/as specified by 'aListPosition'), + * or only 1 item selected. + * false: The selected attachment items are NOT a coherent block + * (at the list edge if/as specified by 'aListPosition'), + * or no attachments selected, or no attachments, + * or no attachmentBucket. + */ +function attachmentsSelectionIsBlock(aListPosition) { + if (!gAttachmentBucket.selectedCount) { + // No attachments selected, no attachments, or no attachmentBucket. + return false; + } + + let selItems = attachmentsSelectionGetSortedArray(); + let indexFirstSelAttachment = gAttachmentBucket.getIndexOfItem(selItems[0]); + let indexLastSelAttachment = gAttachmentBucket.getIndexOfItem( + selItems[gAttachmentBucket.selectedCount - 1] + ); + let isBlock = + indexFirstSelAttachment == + indexLastSelAttachment + 1 - gAttachmentBucket.selectedCount; + + switch (aListPosition) { + case "top": + // True if selection is a coherent block at the top of the list. + return indexFirstSelAttachment == 0 && isBlock; + case "bottom": + // True if selection is a coherent block at the bottom of the list. + return ( + indexLastSelAttachment == gAttachmentBucket.itemCount - 1 && isBlock + ); + default: + // True if selection is a coherent block. + return isBlock; + } +} + +function AttachPage() { + let result = { value: "http://" }; + if ( + Services.prompt.prompt( + window, + getComposeBundle().getString("attachPageDlogTitle"), + getComposeBundle().getString("attachPageDlogMessage"), + result, + null, + { value: 0 } + ) + ) { + if (result.value.length <= "http://".length) { + // Nothing filled, just show the dialog again. + AttachPage(); + return; + } + + let attachment = Cc[ + "@mozilla.org/messengercompose/attachment;1" + ].createInstance(Ci.nsIMsgAttachment); + attachment.url = result.value; + AddAttachments([attachment]); + } +} + +/** + * Check if the given attachment already exists in the attachment bucket. + * + * @param nsIMsgAttachment - the attachment to check + * @returns true if the attachment is already attached + */ +function DuplicateFileAlreadyAttached(attachment) { + for (let item of gAttachmentBucket.itemChildren) { + if (item.attachment && item.attachment.url) { + if (item.attachment.url == attachment.url) { + return true; + } + // Also check, if an attachment has been saved as a temporary file and its + // original url is a match. + if ( + item.attachment.contentLocation && + item.attachment.contentLocation == attachment.url + ) { + return true; + } + } + } + + return false; +} + +function Attachments2CompFields(compFields) { + // First, we need to clear all attachment in the compose fields. + compFields.removeAttachments(); + + for (let item of gAttachmentBucket.itemChildren) { + if (item.attachment) { + compFields.addAttachment(item.attachment); + } + } +} + +async function RemoveAllAttachments() { + // Ensure that attachment pane is shown before removing all attachments. + toggleAttachmentPane("show"); + + if (!gAttachmentBucket.itemCount) { + return; + } + + await RemoveAttachments(gAttachmentBucket.itemChildren); +} + +/** + * Show or hide the attachment pane after updating its header bar information + * (number and total file size of attachments) and tooltip. + * + * @param aShowBucket {Boolean} true: show the attachment pane + * false (or omitted): hide the attachment pane + */ +function UpdateAttachmentBucket(aShowBucket) { + updateAttachmentPane(aShowBucket ? "show" : "hide"); +} + +/** + * Update the header bar information (number and total file size of attachments) + * and tooltip of attachment pane, then (optionally) show or hide the pane. + * + * @param aShowPane {string} "show": show the attachment pane + * "hide": hide the attachment pane + * omitted: just update without changing pane visibility + */ +function updateAttachmentPane(aShowPane) { + let count = gAttachmentBucket.itemCount; + + document.l10n.setAttributes( + document.getElementById("attachmentBucketCount"), + "attachment-bucket-count-value", + { + count, + } + ); + + let attachmentsSize = 0; + for (let item of gAttachmentBucket.itemChildren) { + gAttachmentBucket.invalidateItem(item); + attachmentsSize += item.cloudHtmlFileSize + ? item.cloudHtmlFileSize + : item.attachment.size; + } + + document.getElementById("attachmentBucketSize").textContent = + count > 0 ? gMessenger.formatFileSize(attachmentsSize) : ""; + + document + .getElementById("composeContentBox") + .classList.toggle("attachment-area-hidden", !count); + + attachmentBucketUpdateTooltips(); + + // If aShowPane argument is omitted, it's just updating, so we're done. + if (aShowPane === undefined) { + return; + } + + // Otherwise, show or hide the panel per aShowPane argument. + toggleAttachmentPane(aShowPane); +} + +async function RemoveSelectedAttachment() { + if (!gAttachmentBucket.selectedCount) { + return; + } + + await RemoveAttachments(gAttachmentBucket.selectedItems); +} + +/** + * Removes the provided attachmentItems from the composer and deletes all + * associated cloud files. + * + * Note: Cloud file delete errors are not considered to be fatal errors. They do + * not prevent the attachments from being removed from the composer. Such + * errors are caught and logged to the console. + * + * @param {DOMNode[]} items - AttachmentItems to be removed + */ +async function RemoveAttachments(items) { + // Remember the current focus index so we can try to restore it when done. + let focusIndex = gAttachmentBucket.currentIndex; + + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + let removedAttachments = []; + + let promises = []; + for (let i = items.length - 1; i >= 0; i--) { + let item = items[i]; + + if (item.attachment.sendViaCloud && item.cloudFileAccount) { + if (item.uploading) { + let file = fileHandler.getFileFromURLSpec(item.attachment.url); + promises.push( + item.uploading + .cancelFileUpload(window, file) + .catch(ex => console.warn(ex.message)) + ); + } else { + promises.push( + item.cloudFileAccount + .deleteFile(window, item.cloudFileUpload.id) + .catch(ex => console.warn(ex.message)) + ); + } + } + + removedAttachments.push(item.attachment); + // Let's release the attachment object held by the node else it won't go + // away until the window is destroyed + item.attachment = null; + item.remove(); + } + + if (removedAttachments.length > 0) { + // Bug 1661507 workaround: Force update of selectedCount and selectedItem, + // both wrong after item removal, to avoid confusion for listening command + // controllers. + gAttachmentBucket.clearSelection(); + + AttachmentsChanged(); + dispatchAttachmentBucketEvent("attachments-removed", removedAttachments); + } + + // Collapse the attachment container if all the items have been deleted. + if (!gAttachmentBucket.itemCount) { + toggleAttachmentPane("hide"); + } else { + // Try to restore the original focused item or somewhere close by. + gAttachmentBucket.currentIndex = + focusIndex < gAttachmentBucket.itemCount + ? focusIndex + : gAttachmentBucket.itemCount - 1; + } + + await Promise.all(promises); +} + +async function RenameSelectedAttachment() { + if (gAttachmentBucket.selectedItems.length != 1) { + // Not one attachment selected. + return; + } + + let item = gAttachmentBucket.getSelectedItem(0); + let originalName = item.attachment.name; + let attachmentName = { value: originalName }; + if ( + Services.prompt.prompt( + window, + getComposeBundle().getString("renameAttachmentTitle"), + getComposeBundle().getString("renameAttachmentMessage"), + attachmentName, + null, + { value: 0 } + ) + ) { + if (attachmentName.value == "" || attachmentName.value == originalName) { + // Name was not filled nor changed, bail out. + return; + } + try { + await UpdateAttachment(item, { + name: attachmentName.value, + relatedCloudFileUpload: item.CloudFileUpload, + }); + } catch (ex) { + showLocalizedCloudFileAlert(ex); + } + } +} + +/* eslint-disable complexity */ +/** + * Move selected attachment(s) within the attachment list. + * + * @param {string} aDirection - The direction in which to move the attachments. + * "left" : Move attachments left in the list. + * "right" : Move attachments right in the list. + * "top" : Move attachments to the top of the list. + * "bottom" : Move attachments to the bottom of the list. + * "bundleUp" : Move attachments together (upwards). + * "bundleDown": Move attachments together (downwards). + * "toggleSort": Sort attachments alphabetically (toggle). + */ +function moveSelectedAttachments(aDirection) { + // Command controllers will bail out if no or all attachments are selected, + // or if block selections can't be moved, or if other direction-specific + // adverse circumstances prevent the intended movement. + if (!aDirection) { + return; + } + + // Ensure focus on gAttachmentBucket when we're coming from + // 'Reorder Attachments' panel. + gAttachmentBucket.focus(); + + // Get a sorted and "non-live" array of gAttachmentBucket.selectedItems. + let selItems = attachmentsSelectionGetSortedArray(); + + // In case of misspelled aDirection. + let visibleIndex = gAttachmentBucket.currentIndex; + // Keep track of the item we had focused originally. Deselect it though, + // since listbox gets confused if you move its focused item around. + let focusItem = gAttachmentBucket.currentItem; + gAttachmentBucket.currentItem = null; + let upwards; + let targetItem; + + switch (aDirection) { + case "left": + case "right": + // Move selected attachments upwards/downwards. + upwards = aDirection == "left"; + let blockItems = []; + + for (let item of selItems) { + // Handle adjacent selected items en block, via blockItems array. + blockItems.push(item); // Add current selItem to blockItems. + let nextItem = item.nextElementSibling; + if (!nextItem || !nextItem.selected) { + // If current selItem is the last blockItem, check out its adjacent + // item in the intended direction to see if there's room for moving. + // Note that the block might contain one or more items. + let checkItem = upwards + ? blockItems[0].previousElementSibling + : nextItem; + // If block-adjacent checkItem exists (and is not selected because + // then it would be part of the block), we can move the block to the + // right position. + if (checkItem) { + targetItem = upwards + ? // Upwards: Insert block items before checkItem, + // i.e. before previousElementSibling of block. + checkItem + : // Downwards: Insert block items *after* checkItem, + // i.e. *before* nextElementSibling.nextElementSibling of block, + // which works according to spec even if that's null. + checkItem.nextElementSibling; + // Move current blockItems. + for (let blockItem of blockItems) { + gAttachmentBucket.insertBefore(blockItem, targetItem); + } + } + // Else if checkItem doesn't exist, the block is already at the edge + // of the list, so we can't move it in the intended direction. + blockItems.length = 0; // Either way, we're done with the current block. + } + // Else if current selItem is NOT the end of the current block, proceed: + // Add next selItem to the block and see if that's the end of the block. + } // Next selItem. + + // Ensure helpful visibility of moved items (scroll into view if needed): + // If first item of selection is now at the top, first list item. + // Else if last item of selection is now at the bottom, last list item. + // Otherwise, let's see where we are going by ensuring visibility of the + // nearest unselected sibling of selection according to direction of move. + if (gAttachmentBucket.getIndexOfItem(selItems[0]) == 0) { + visibleIndex = 0; + } else if ( + gAttachmentBucket.getIndexOfItem(selItems[selItems.length - 1]) == + gAttachmentBucket.itemCount - 1 + ) { + visibleIndex = gAttachmentBucket.itemCount - 1; + } else if (upwards) { + visibleIndex = gAttachmentBucket.getIndexOfItem( + selItems[0].previousElementSibling + ); + } else { + visibleIndex = gAttachmentBucket.getIndexOfItem( + selItems[selItems.length - 1].nextElementSibling + ); + } + break; + + case "top": + case "bottom": + case "bundleUp": + case "bundleDown": + // Bundle selected attachments to top/bottom of the list or upwards/downwards. + + upwards = ["top", "bundleUp"].includes(aDirection); + // Downwards: Reverse order of selItems so we can use the same algorithm. + if (!upwards) { + selItems.reverse(); + } + + if (["top", "bottom"].includes(aDirection)) { + let listEdgeItem = gAttachmentBucket.getItemAtIndex( + upwards ? 0 : gAttachmentBucket.itemCount - 1 + ); + let selEdgeItem = selItems[0]; + if (selEdgeItem != listEdgeItem) { + // Top/Bottom: Move the first/last selected item to the edge of the list + // so that we always have an initial anchor target block in the right + // place, so we can use the same algorithm for top/bottom and + // inner bundling. + targetItem = upwards + ? // Upwards: Insert before first list item. + listEdgeItem + : // Downwards: Insert after last list item, i.e. + // *before* non-existing listEdgeItem.nextElementSibling, + // which is null. It works because it's a feature. + null; + gAttachmentBucket.insertBefore(selEdgeItem, targetItem); + } + } + // We now have a selected block (at least one item) at the target position. + // Let's find the end (inner edge) of that block and move only the + // remaining selected items to avoid unnecessary moves. + targetItem = null; + for (let item of selItems) { + if (targetItem) { + // We know where to move it, so move it! + gAttachmentBucket.insertBefore(item, targetItem); + if (!upwards) { + // Downwards: As selItems are reversed, and there's no insertAfter() + // method to insert *after* a stable target, we need to insert + // *before* the first item of the target block at target position, + // which is the current selItem which we've just moved onto the block. + targetItem = item; + } + } else { + // If there's no targetItem yet, find the inner edge of the target block. + let nextItem = upwards + ? item.nextElementSibling + : item.previousElementSibling; + if (!nextItem.selected) { + // If nextItem is not selected, current selItem is the inner edge of + // the initial anchor target block, so we can set targetItem. + targetItem = upwards + ? // Upwards: set stable targetItem. + nextItem + : // Downwards: set initial targetItem. + item; + } + // Else if nextItem is selected, it is still part of initial anchor + // target block, so just proceed to look for the edge of that block. + } + } // next selItem + + // Ensure visibility of first/last selected item after the move. + visibleIndex = gAttachmentBucket.getIndexOfItem(selItems[0]); + break; + + case "toggleSort": + // Sort the selected attachments alphabetically after moving them together. + // The command updater of cmd_sortAttachmentsToggle toggles the sorting + // direction based on the current sorting and block status of the selection. + + let toggleCmd = document.getElementById("cmd_sortAttachmentsToggle"); + let sortDirection = + toggleCmd.getAttribute("sortdirection") || "ascending"; + let sortItems; + let sortSelection; + + if (gAttachmentBucket.selectedCount > 1) { + // Sort selected attachments only. + sortSelection = true; + sortItems = selItems; + // Move selected attachments together before sorting as a block. + goDoCommand("cmd_moveAttachmentBundleUp"); + + // Find the end of the selected block to find our targetItem. + for (let item of selItems) { + let nextItem = item.nextElementSibling; + if (!nextItem || !nextItem.selected) { + // If there's no nextItem (block at list bottom), or nextItem is + // not selected, we've reached the end of the block. + // Set the block's nextElementSibling as targetItem and exit loop. + // Works by definition even if nextElementSibling aka nextItem is null. + targetItem = nextItem; + break; + } + // else if (nextItem && nextItem.selected), nextItem is still part of + // the block, so proceed with checking its nextElementSibling. + } // next selItem + } else { + // Sort all attachments. + sortSelection = false; + sortItems = attachmentsGetSortedArray(); + targetItem = null; // Insert at the end of the list. + } + // Now let's sort our sortItems according to sortDirection. + if (sortDirection == "ascending") { + sortItems.sort((a, b) => + a.attachment.name.localeCompare(b.attachment.name) + ); + } else { + // "descending" + sortItems.sort((a, b) => + b.attachment.name.localeCompare(a.attachment.name) + ); + } + + // Insert sortItems in new order before the nextElementSibling of the block. + for (let item of sortItems) { + gAttachmentBucket.insertBefore(item, targetItem); + } + + if (sortSelection) { + // After sorting selection: Ensure visibility of first selected item. + visibleIndex = gAttachmentBucket.getIndexOfItem(selItems[0]); + } else { + // After sorting all items: Ensure visibility of selected item, + // otherwise first list item. + visibleIndex = + selItems.length == 1 ? gAttachmentBucket.selectedIndex : 0; + } + break; + } // end switch (aDirection) + + // Restore original focus. + gAttachmentBucket.currentItem = focusItem; + // Ensure smart visibility of a relevant item according to direction. + gAttachmentBucket.ensureIndexIsVisible(visibleIndex); + + // Moving selected items around does not trigger auto-updating of our command + // handlers, so we must do it now as the position of selected items has changed. + updateReorderAttachmentsItems(); +} +/* eslint-enable complexity */ + +/** + * Toggle attachment pane view state: show or hide it. + * If aAction parameter is omitted, toggle current view state. + * + * @param {string} [aAction = "toggle"] - "show": show attachment pane + * "hide": hide attachment pane + * "toggle": toggle attachment pane + */ +function toggleAttachmentPane(aAction = "toggle") { + let attachmentArea = document.getElementById("attachmentArea"); + + if (aAction == "toggle") { + // Interrupt if we don't have any attachment as we don't want nor need to + // show an empty container. + if (!gAttachmentBucket.itemCount) { + return; + } + + if (attachmentArea.open && document.activeElement != gAttachmentBucket) { + // Interrupt and move the focus to the attachment pane if it's already + // visible but not currently focused. + moveFocusToAttachmentPane(); + return; + } + + // Toggle attachment pane. + attachmentArea.open = !attachmentArea.open; + } else { + attachmentArea.open = aAction != "hide"; + } +} + +/** + * Update the #attachmentArea according to its open state. + */ +function attachmentAreaOnToggle() { + let attachmentArea = document.getElementById("attachmentArea"); + let bucketHasFocus = document.activeElement == gAttachmentBucket; + if (attachmentArea.open && !bucketHasFocus) { + moveFocusToAttachmentPane(); + } else if (!attachmentArea.open && bucketHasFocus) { + // Move the focus to the message body only if the bucket was focused. + focusMsgBody(); + } + + // Make the splitter non-interactive whilst the bucket is hidden. + document + .getElementById("composeContentBox") + .classList.toggle("attachment-bucket-closed", !attachmentArea.open); + + // Update the checkmark on menuitems hooked up with cmd_toggleAttachmentPane. + // Menuitem does not have .checked property nor .toggleAttribute(), sigh. + for (let menuitem of document.querySelectorAll( + 'menuitem[command="cmd_toggleAttachmentPane"]' + )) { + if (attachmentArea.open) { + menuitem.setAttribute("checked", "true"); + continue; + } + menuitem.removeAttribute("checked"); + } + + // Update the title based on the collapsed status of the bucket. + document.l10n.setAttributes( + attachmentArea.querySelector("summary"), + attachmentArea.open ? "attachment-area-hide" : "attachment-area-show" + ); +} + +/** + * Ensure the focus is properly moved to the Attachment Bucket, and to the first + * available item if present. + */ +function moveFocusToAttachmentPane() { + gAttachmentBucket.focus(); + + if (gAttachmentBucket.currentItem) { + gAttachmentBucket.ensureElementIsVisible(gAttachmentBucket.currentItem); + } +} + +function showReorderAttachmentsPanel() { + // Ensure attachment pane visibility as it might be collapsed. + toggleAttachmentPane("show"); + showPopupById( + "reorderAttachmentsPanel", + "attachmentBucket", + "after_start", + 15, + 0 + ); + // After the panel is shown, focus attachmentBucket so that keyboard + // operation for selecting and moving attachment items works; the panel + // helpfully presents the keyboard shortcuts for moving things around. + // Bucket focus is also required because the panel will only close with ESC + // or attachmentBucketOnBlur(), and that's because we're using noautohide as + // event.preventDefault() of onpopuphiding event fails when the panel + // is auto-hiding, but we don't want panel to hide when focus goes to bucket. + gAttachmentBucket.focus(); +} + +/** + * Returns a string representing the current sort order of selected attachment + * items by their names. We don't check if selected items form a coherent block + * or not; use attachmentsSelectionIsBlock() to check on that. + * + * @returns {string} "ascending" : Sort order is ascending. + * "descending": Sort order is descending. + * "equivalent": The names of all selected items are equivalent. + * "" : There's no sort order, or only 1 item selected, + * or no items selected, or no attachments, + * or no attachmentBucket. + */ +function attachmentsSelectionGetSortOrder() { + return attachmentsGetSortOrder(true); +} + +/** + * Returns a string representing the current sort order of attachment items + * by their names. + * + * @param aSelectedOnly {boolean}: true: return sort order of selected items only. + * false (default): return sort order of all items. + * + * @returns {string} "ascending" : Sort order is ascending. + * "descending": Sort order is descending. + * "equivalent": The names of the items are equivalent. + * "" : There's no sort order, or no attachments, + * or no attachmentBucket; or (with aSelectedOnly), + * only 1 item selected, or no items selected. + */ +function attachmentsGetSortOrder(aSelectedOnly = false) { + let listItems; + if (aSelectedOnly) { + if (gAttachmentBucket.selectedCount <= 1) { + return ""; + } + + listItems = attachmentsSelectionGetSortedArray(); + } else { + // aSelectedOnly == false + if (!gAttachmentBucket.itemCount) { + return ""; + } + + listItems = attachmentsGetSortedArray(); + } + + // We're comparing each item to the next item, so exclude the last item. + let listItems1 = listItems.slice(0, -1); + let someAscending; + let someDescending; + + // Check if some adjacent items are sorted ascending. + someAscending = listItems1.some( + (item, index) => + item.attachment.name.localeCompare(listItems[index + 1].attachment.name) < + 0 + ); + + // Check if some adjacent items are sorted descending. + someDescending = listItems1.some( + (item, index) => + item.attachment.name.localeCompare(listItems[index + 1].attachment.name) > + 0 + ); + + // Unsorted (but not all equivalent in sort order) + if (someAscending && someDescending) { + return ""; + } + + if (someAscending && !someDescending) { + return "ascending"; + } + + if (someDescending && !someAscending) { + return "descending"; + } + + // No ascending pairs, no descending pairs, so all equivalent in sort order. + // if (!someAscending && !someDescending) + return "equivalent"; +} + +function reorderAttachmentsPanelOnPopupShowing() { + let panel = document.getElementById("reorderAttachmentsPanel"); + let buttonsNodeList = panel.querySelectorAll(".panelButton"); + let buttons = [...buttonsNodeList]; // convert NodeList to Array + // Let's add some pretty keyboard shortcuts to the buttons. + buttons.forEach(btn => { + if (btn.hasAttribute("key")) { + btn.setAttribute("prettykey", getPrettyKey(btn.getAttribute("key"))); + } + }); + // Focus attachment bucket to activate attachmentBucketController, which is + // required for updating the reorder commands. + gAttachmentBucket.focus(); + // We're updating commands before showing the panel so that button states + // don't change after the panel is shown, and also because focus is still + // in attachment bucket right now, which is required for updating them. + updateReorderAttachmentsItems(); +} + +function attachmentHeaderContextOnPopupShowing() { + let initiallyShowItem = document.getElementById( + "attachmentHeaderContext_initiallyShowItem" + ); + + initiallyShowItem.setAttribute( + "checked", + Services.prefs.getBoolPref("mail.compose.show_attachment_pane") + ); +} + +function toggleInitiallyShowAttachmentPane(aMenuItem) { + Services.prefs.setBoolPref( + "mail.compose.show_attachment_pane", + aMenuItem.getAttribute("checked") + ); +} + +/** + * Handle blur event on attachment pane and control visibility of + * reorderAttachmentsPanel. + */ +function attachmentBucketOnBlur() { + let reorderAttachmentsPanel = document.getElementById( + "reorderAttachmentsPanel" + ); + // If attachment pane has really lost focus, and if reorderAttachmentsPanel is + // not currently in the process of showing up, hide reorderAttachmentsPanel. + // Otherwise, keep attachments selected and the reorderAttachmentsPanel open + // when reordering and after renaming via dialog. + if ( + document.activeElement.id != "attachmentBucket" && + reorderAttachmentsPanel.state != "showing" + ) { + reorderAttachmentsPanel.hidePopup(); + } +} + +/** + * Handle the keypress on the attachment bucket. + * + * @param {Event} event - The keypress DOM Event. + */ +function attachmentBucketOnKeyPress(event) { + // Interrupt if the Alt modifier is pressed, meaning the user is reordering + // the list of attachments. + if (event.altKey) { + return; + } + + switch (event.key) { + case "Escape": + let reorderAttachmentsPanel = document.getElementById( + "reorderAttachmentsPanel" + ); + + // Close the reorderAttachmentsPanel if open and interrupt. + if (reorderAttachmentsPanel.state == "open") { + reorderAttachmentsPanel.hidePopup(); + return; + } + + if (gAttachmentBucket.itemCount) { + // Deselect selected items in a full bucket if any. + if (gAttachmentBucket.selectedCount) { + gAttachmentBucket.clearSelection(); + return; + } + + // Move the focus to the message body. + focusMsgBody(); + return; + } + + // Close an empty bucket. + toggleAttachmentPane("hide"); + break; + + case "Enter": + // Enter on empty bucket to add file attachments, convenience + // keyboard equivalent of single-click on bucket whitespace. + if (!gAttachmentBucket.itemCount) { + goDoCommand("cmd_attachFile"); + } + break; + + case "ArrowLeft": + gAttachmentBucket.moveByOffset(-1, !event.ctrlKey, event.shiftKey); + event.preventDefault(); + break; + + case "ArrowRight": + gAttachmentBucket.moveByOffset(1, !event.ctrlKey, event.shiftKey); + event.preventDefault(); + break; + + case "ArrowDown": + gAttachmentBucket.moveByOffset( + gAttachmentBucket._itemsPerRow(), + !event.ctrlKey, + event.shiftKey + ); + event.preventDefault(); + break; + + case "ArrowUp": + gAttachmentBucket.moveByOffset( + -gAttachmentBucket._itemsPerRow(), + !event.ctrlKey, + event.shiftKey + ); + + event.preventDefault(); + break; + } +} + +function attachmentBucketOnClick(aEvent) { + // Handle click on attachment pane whitespace normally clear selection. + // If there are no attachments in the bucket, show 'Attach File(s)' dialog. + if ( + aEvent.button == 0 && + aEvent.target.getAttribute("is") == "attachment-list" && + !aEvent.target.firstElementChild + ) { + goDoCommand("cmd_attachFile"); + } +} + +function attachmentBucketOnSelect() { + attachmentBucketUpdateTooltips(); + updateAttachmentItems(); +} + +function attachmentBucketUpdateTooltips() { + // Attachment pane whitespace tooltip + if (gAttachmentBucket.selectedCount) { + gAttachmentBucket.tooltipText = getComposeBundle().getString( + "attachmentBucketClearSelectionTooltip" + ); + } else { + gAttachmentBucket.tooltipText = getComposeBundle().getString( + "attachmentBucketAttachFilesTooltip" + ); + } +} + +function OpenSelectedAttachment() { + if (gAttachmentBucket.selectedItems.length != 1) { + return; + } + let attachment = gAttachmentBucket.getSelectedItem(0).attachment; + let attachmentUrl = attachment.url; + + let messagePrefix = /^mailbox-message:|^imap-message:|^news-message:/i; + if (messagePrefix.test(attachmentUrl)) { + // we must be dealing with a forwarded attachment, treat this special + let msgHdr = + MailServices.messageServiceFromURI(attachmentUrl).messageURIToMsgHdr( + attachmentUrl + ); + if (msgHdr) { + MailUtils.openMessageInNewWindow(msgHdr); + } + return; + } + if ( + attachment.contentType == "application/pdf" || + /\.pdf$/i.test(attachment.name) + ) { + // @see msgHdrView.js which has simililar opening functionality + let handlerInfo = gMIMEService.getFromTypeAndExtension( + attachment.contentType, + attachment.name.split(".").pop() + ); + // Only open a new tab for pdfs if we are handling them internally. + if ( + !handlerInfo.alwaysAskBeforeHandling && + handlerInfo.preferredAction == Ci.nsIHandlerInfo.handleInternally + ) { + // Add the content type to avoid a "how do you want to open this?" + // dialog. The type may already be there, but that doesn't matter. + let url = attachment.url; + if (!url.includes("type=")) { + url += url.includes("?") ? "&" : "?"; + url += "type=application/pdf"; + } + let tabmail = Services.wm + .getMostRecentWindow("mail:3pane") + ?.document.getElementById("tabmail"); + if (tabmail) { + tabmail.openTab("contentTab", { + url, + background: false, + linkHandler: "single-page", + }); + tabmail.ownerGlobal.focus(); + return; + } + // If no tabmail, open PDF same as other attachments. + } + } + let uri = Services.io.newURI(attachmentUrl); + let channel = Services.io.newChannelFromURI( + uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + let uriLoader = Cc["@mozilla.org/uriloader;1"].getService(Ci.nsIURILoader); + uriLoader.openURI(channel, true, new nsAttachmentOpener()); +} + +function nsAttachmentOpener() {} + +nsAttachmentOpener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIURIContentListener", + "nsIInterfaceRequestor", + ]), + + doContent(contentType, isContentPreferred, request, contentHandler) { + // If we came here to display an attached message, make sure we provide a type. + if (/[?&]part=/i.test(request.URI.query)) { + let newQuery = request.URI.query + "&type=message/rfc822"; + request.URI = request.URI.mutate().setQuery(newQuery).finalize(); + } + let newHandler = Cc[ + "@mozilla.org/uriloader/content-handler;1?type=application/x-message-display" + ].createInstance(Ci.nsIContentHandler); + newHandler.handleContent("application/x-message-display", this, request); + return true; + }, + + isPreferred(contentType, desiredContentType) { + if (contentType == "message/rfc822") { + return true; + } + return false; + }, + + canHandleContent(contentType, isContentPreferred, desiredContentType) { + return false; + }, + + getInterface(iid) { + if (iid.equals(Ci.nsIDOMWindow)) { + return window; + } + if (iid.equals(Ci.nsIDocShell)) { + return window.docShell; + } + return this.QueryInterface(iid); + }, + + loadCookie: null, + parentContentListener: null, +}; + +/** + * Determine the sending format depending on the selected format, or the content + * of the message body. + * + * @returns {nsIMsgCompSendFormat} The determined send format: either PlainText, + * HTML or Both (never Auto or Unset). + */ +function determineSendFormat() { + if (!gMsgCompose.composeHTML) { + return Ci.nsIMsgCompSendFormat.PlainText; + } + + let sendFormat = gMsgCompose.compFields.deliveryFormat; + if (sendFormat != Ci.nsIMsgCompSendFormat.Auto) { + return sendFormat; + } + + // Auto downgrade if safe to do so. + let convertible; + try { + convertible = gMsgCompose.bodyConvertible(); + } catch (ex) { + return Ci.nsIMsgCompSendFormat.Both; + } + return convertible == Ci.nsIMsgCompConvertible.Plain + ? Ci.nsIMsgCompSendFormat.PlainText + : Ci.nsIMsgCompSendFormat.Both; +} + +/** + * Expands mailinglists found in the recipient fields. + */ +function expandRecipients() { + gMsgCompose.expandMailingLists(); +} + +/** + * Hides addressing options (To, CC, Bcc, Newsgroup, Followup-To, etc.) + * that are not relevant for the account type used for sending. + * + * @param {string} accountKey - Key of the account that is currently selected + * as the sending account. + * @param {string} prevKey - Key of the account that was previously selected + * as the sending account. + */ +function hideIrrelevantAddressingOptions(accountKey, prevKey) { + let showNews = false; + for (let account of MailServices.accounts.accounts) { + if (account.incomingServer.type == "nntp") { + showNews = true; + } + } + // If there is no News (NNTP) account existing then + // hide the Newsgroup and Followup-To recipient type menuitems. + for (let item of document.querySelectorAll(".news-show-row-menuitem")) { + showAddressRowMenuItemSetVisibility(item, showNews); + } + + let account = MailServices.accounts.getAccount(accountKey); + let accountType = account.incomingServer.type; + + // If the new account is a News (NNTP) account. + if (accountType == "nntp") { + updateUIforNNTPAccount(); + return; + } + + // If the new account is a Mail account and a previous account was selected. + if (accountType != "nntp" && prevKey != "") { + updateUIforMailAccount(); + } +} + +function LoadIdentity(startup) { + let identityElement = document.getElementById("msgIdentity"); + let prevIdentity = gCurrentIdentity; + + let idKey = null; + let accountKey = null; + let prevKey = getCurrentAccountKey(); + if (identityElement.selectedItem) { + // Set the identity key value on the menu list. + idKey = identityElement.selectedItem.getAttribute("identitykey"); + identityElement.setAttribute("identitykey", idKey); + gCurrentIdentity = MailServices.accounts.getIdentity(idKey); + + // Set the account key value on the menu list. + accountKey = identityElement.selectedItem.getAttribute("accountkey"); + identityElement.setAttribute("accountkey", accountKey); + + // Update the addressing options only if a new account was selected. + if (prevKey != getCurrentAccountKey()) { + hideIrrelevantAddressingOptions(accountKey, prevKey); + } + } + for (let input of document.querySelectorAll(".mail-input,.news-input")) { + let params = JSON.parse(input.searchParam); + params.idKey = idKey; + params.accountKey = accountKey; + input.searchParam = JSON.stringify(params); + } + + if (startup) { + // During compose startup, bail out here. + return; + } + + // Since switching the signature loses the caret position, we record it + // and restore it later. + let editor = GetCurrentEditor(); + let selection = editor.selection; + let range = selection.getRangeAt(0); + let start = range.startOffset; + let startNode = range.startContainer; + + editor.enableUndo(false); + + // Handle non-startup changing of identity. + if (prevIdentity && idKey != prevIdentity.key) { + let changedRecipients = false; + let prevReplyTo = prevIdentity.replyTo; + let prevCc = ""; + let prevBcc = ""; + let prevReceipt = prevIdentity.requestReturnReceipt; + let prevDSN = prevIdentity.DSN; + let prevAttachVCard = prevIdentity.attachVCard; + + if (prevIdentity.doCc && prevIdentity.doCcList) { + prevCc += prevIdentity.doCcList; + } + + if (prevIdentity.doBcc && prevIdentity.doBccList) { + prevBcc += prevIdentity.doBccList; + } + + let newReplyTo = gCurrentIdentity.replyTo; + let newCc = ""; + let newBcc = ""; + let newReceipt = gCurrentIdentity.requestReturnReceipt; + let newDSN = gCurrentIdentity.DSN; + let newAttachVCard = gCurrentIdentity.attachVCard; + + if (gCurrentIdentity.doCc && gCurrentIdentity.doCcList) { + newCc += gCurrentIdentity.doCcList; + } + + if (gCurrentIdentity.doBcc && gCurrentIdentity.doBccList) { + newBcc += gCurrentIdentity.doBccList; + } + + let msgCompFields = gMsgCompose.compFields; + // Update recipients in msgCompFields to match pills currently in the UI. + Recipients2CompFields(msgCompFields); + + if ( + !gReceiptOptionChanged && + prevReceipt == msgCompFields.returnReceipt && + prevReceipt != newReceipt + ) { + msgCompFields.returnReceipt = newReceipt; + ToggleReturnReceipt(msgCompFields.returnReceipt); + } + + if ( + !gDSNOptionChanged && + prevDSN == msgCompFields.DSN && + prevDSN != newDSN + ) { + msgCompFields.DSN = newDSN; + document + .getElementById("dsnMenu") + .setAttribute("checked", msgCompFields.DSN); + } + + if ( + !gAttachVCardOptionChanged && + prevAttachVCard == msgCompFields.attachVCard && + prevAttachVCard != newAttachVCard + ) { + msgCompFields.attachVCard = newAttachVCard; + document + .getElementById("cmd_attachVCard") + .setAttribute("checked", msgCompFields.attachVCard); + } + + if (newReplyTo != prevReplyTo) { + if (prevReplyTo != "") { + awRemoveRecipients(msgCompFields, "addr_reply", prevReplyTo); + } + if (newReplyTo != "") { + awAddRecipients(msgCompFields, "addr_reply", newReplyTo); + } + } + + let toCcAddrs = new Set([ + ...msgCompFields.splitRecipients(msgCompFields.to, true), + ...msgCompFields.splitRecipients(msgCompFields.cc, true), + ]); + + if (newCc != prevCc) { + if (prevCc) { + awRemoveRecipients(msgCompFields, "addr_cc", prevCc); + } + if (newCc) { + // Add only Auto-Cc recipients whose email is not already in To or CC. + newCc = msgCompFields + .splitRecipients(newCc, false) + .filter( + x => !toCcAddrs.has(...msgCompFields.splitRecipients(x, true)) + ) + .join(", "); + awAddRecipients(msgCompFields, "addr_cc", newCc); + } + changedRecipients = true; + } + + if (newBcc != prevBcc) { + let toCcBccAddrs = new Set([ + ...toCcAddrs, + ...msgCompFields.splitRecipients(newCc, true), + ...msgCompFields.splitRecipients(msgCompFields.bcc, true), + ]); + + if (prevBcc) { + awRemoveRecipients(msgCompFields, "addr_bcc", prevBcc); + } + if (newBcc) { + // Add only Auto-Bcc recipients whose email is not already in To, Cc, + // Bcc, or added as Auto-CC from newCc declared above. + newBcc = msgCompFields + .splitRecipients(newBcc, false) + .filter( + x => !toCcBccAddrs.has(...msgCompFields.splitRecipients(x, true)) + ) + .join(", "); + awAddRecipients(msgCompFields, "addr_bcc", newBcc); + } + changedRecipients = true; + } + + // Handle showing/hiding of empty CC/BCC row after changing identity. + // Whenever "Cc/Bcc these email addresses" aka mail.identity.id#.doCc/doBcc + // is checked in Account Settings, show the address row, even if empty. + // This is a feature especially for ux-efficiency of enterprise workflows. + let addressRowCc = document.getElementById("addressRowCc"); + if (gCurrentIdentity.doCc) { + // Per identity's doCc pref, show CC row, even if empty. + showAndFocusAddressRow("addressRowCc"); + } else if ( + prevIdentity.doCc && + !addressRowCc.querySelector("mail-address-pill") + ) { + // Current identity doesn't need CC row shown, but previous identity did. + // Hide CC row if it's empty. + addressRowSetVisibility(addressRowCc, false); + } + + let addressRowBcc = document.getElementById("addressRowBcc"); + if (gCurrentIdentity.doBcc) { + // Per identity's doBcc pref, show BCC row, even if empty. + showAndFocusAddressRow("addressRowBcc"); + } else if ( + prevIdentity.doBcc && + !addressRowBcc.querySelector("mail-address-pill") + ) { + // Current identity doesn't need BCC row shown, but previous identity did. + // Hide BCC row if it's empty. + addressRowSetVisibility(addressRowBcc, false); + } + + // Trigger async checking and updating of encryption UI. + adjustEncryptAfterIdentityChange(prevIdentity); + + try { + gMsgCompose.identity = gCurrentIdentity; + } catch (ex) { + dump("### Cannot change the identity: " + ex + "\n"); + } + + window.dispatchEvent(new CustomEvent("compose-from-changed")); + + gComposeNotificationBar.clearIdentityWarning(); + + // Trigger this method only if the Cc or Bcc recipients changed from the + // previous identity. + if (changedRecipients) { + onRecipientsChanged(true); + } + } + + // Only do this if we aren't starting up... + // It gets done as part of startup already. + addRecipientsToIgnoreList(gCurrentIdentity.fullAddress); + + // If the From field is editable, reset the address from the identity. + if (identityElement.editable) { + identityElement.value = identityElement.selectedItem.value; + identityElement.placeholder = getComposeBundle().getFormattedString( + "msgIdentityPlaceholder", + [identityElement.selectedItem.value] + ); + } + + editor.enableUndo(true); + editor.resetModificationCount(); + selection.collapse(startNode, start); + + // Try to focus the first available address row. If there are none, focus the + // Subject which is always available. + for (let row of document.querySelectorAll(".address-row")) { + if (focusAddressRowInput(row)) { + return; + } + } + focusSubjectInput(); +} + +function MakeFromFieldEditable(ignoreWarning) { + let bundle = getComposeBundle(); + if ( + !ignoreWarning && + !Services.prefs.getBoolPref("mail.compose.warned_about_customize_from") + ) { + var check = { value: false }; + if ( + Services.prompt.confirmEx( + window, + bundle.getString("customizeFromAddressTitle"), + bundle.getString("customizeFromAddressWarning"), + Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_OK + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL + + Services.prompt.BUTTON_POS_1_DEFAULT, + null, + null, + null, + bundle.getString("customizeFromAddressIgnore"), + check + ) != 0 + ) { + return; + } + Services.prefs.setBoolPref( + "mail.compose.warned_about_customize_from", + check.value + ); + } + + let customizeMenuitem = document.getElementById("cmd_customizeFromAddress"); + customizeMenuitem.setAttribute("disabled", "true"); + let identityElement = document.getElementById("msgIdentity"); + let identityElementWidth = `${ + identityElement.getBoundingClientRect().width + }px`; + identityElement.style.width = identityElementWidth; + identityElement.removeAttribute("type"); + identityElement.setAttribute("editable", "true"); + identityElement.focus(); + identityElement.value = identityElement.selectedItem.value; + identityElement.select(); + identityElement.placeholder = bundle.getFormattedString( + "msgIdentityPlaceholder", + [identityElement.selectedItem.value] + ); +} + +/** + * Set up autocomplete search parameters for address inputs of inbuilt headers. + * + * @param {Element} input - The address input of an inbuilt header field. + */ +function setupAutocompleteInput(input) { + let params = JSON.parse(input.getAttribute("autocompletesearchparam")); + params.type = input.closest(".address-row").dataset.recipienttype; + input.setAttribute("autocompletesearchparam", JSON.stringify(params)); + + // This method overrides the autocomplete binding's openPopup (essentially + // duplicating the logic from the autocomplete popup binding's + // openAutocompletePopup method), modifying it so that the popup is aligned + // and sized based on the parentNode of the input field. + input.openPopup = () => { + if (input.focused) { + input.popup.openAutocompletePopup( + input.nsIAutocompleteInput, + input.closest(".address-container") + ); + } + }; +} + +/** + * Handle the keypress event of the From field. + * + * @param {Event} event - A DOM keypress event on #msgIdentity. + */ +function fromKeyPress(event) { + if (event.key == "Enter") { + // Move the focus to the first available address input. + document + .querySelector( + "#recipientsContainer .address-row:not(.hidden) .address-row-input" + ) + .focus(); + } +} + +/** + * Handle the keypress event of the subject input. + * + * @param {Event} event - A DOM keypress event on #msgSubject. + */ +function subjectKeyPress(event) { + if (event.key == "Delete" && event.repeat && gPreventRowDeletionKeysRepeat) { + // Prevent repeated Delete keypress event if the flag is set. + event.preventDefault(); + return; + } + // Enable repeated deletion if any other key is pressed, or if the Delete + // keypress event is not repeated, or if the flag is already false. + gPreventRowDeletionKeysRepeat = false; + + // Move the focus to the body only if the Enter key is pressed without any + // modifier, as that would mean the user wants to send the message. + if (event.key == "Enter" && !event.ctrlKey && !event.metaKey) { + focusMsgBody(); + } +} + +/** + * Handle the input event of the subject input element. + * + * @param {Event} event - A DOM input event on #msgSubject. + */ +function msgSubjectOnInput(event) { + gSubjectChanged = true; + gContentChanged = true; + SetComposeWindowTitle(); +} + +// Content types supported in the envelopeDragObserver. +const DROP_FLAVORS = [ + "application/x-moz-file", + "text/x-moz-address", + "text/x-moz-message", + "text/x-moz-url", + "text/uri-list", +]; + +// We can drag and drop addresses, files, messages and urls into the compose +// envelope. +var envelopeDragObserver = { + /** + * Adjust the drop target when dragging from the attachment bucket onto itself + * by picking the nearest possible insertion point (generally, between two + * list items). + * + * @param {Event} event - The drag-and-drop event being performed. + * @returns {attachmentitem|string} - the adjusted drop target: + * - an attachmentitem node for inserting *before* + * - "none" if this isn't a valid insertion point + * - "afterLastItem" for appending at the bottom of the list. + */ + _adjustDropTarget(event) { + let target = event.target; + if (target == gAttachmentBucket) { + // Dragging or dropping at top/bottom border of the listbox + if ( + (event.screenY - target.screenY) / + target.getBoundingClientRect().height < + 0.5 + ) { + target = gAttachmentBucket.firstElementChild; + } else { + target = gAttachmentBucket.lastElementChild; + } + // We'll check below if this is a valid target. + } else if (target.id == "attachmentBucketCount") { + // Dragging or dropping at top border of the listbox. + // Allow bottom half of attachment list header as extended drop target + // for top of list, because otherwise it would be too small. + if ( + (event.screenY - target.screenY) / + target.getBoundingClientRect().height >= + 0.5 + ) { + target = gAttachmentBucket.firstElementChild; + // We'll check below if this is a valid target. + } else { + // Top half of attachment list header: sorry, can't drop here. + return "none"; + } + } + + // Target is an attachmentitem. + if (target.matches("richlistitem.attachmentItem")) { + // If we're dragging/dropping in bottom half of attachmentitem, + // adjust target to target.nextElementSibling (to show dropmarker above that). + if ( + (event.screenY - target.screenY) / + target.getBoundingClientRect().height >= + 0.5 + ) { + target = target.nextElementSibling; + + // If there's no target.nextElementSibling, we're dragging/dropping + // to the bottom of the list. + if (!target) { + // We can't move a bottom block selection to the bottom. + if (attachmentsSelectionIsBlock("bottom")) { + return "none"; + } + + // Not a bottom block selection: Target is *after* the last item. + return "afterLastItem"; + } + } + // Check if the adjusted target attachmentitem is a valid target. + let isBlock = attachmentsSelectionIsBlock(); + let prevItem = target.previousElementSibling; + // If target is first list item, there's no previous sibling; + // treat like unselected previous sibling. + let prevSelected = prevItem ? prevItem.selected : false; + if ( + (target.selected && (isBlock || prevSelected)) || + // target at end of block selection + (isBlock && prevSelected) + ) { + // We can't move a block selection before/after itself, + // or any selection onto itself, so trigger dropeffect "none". + return "none"; + } + return target; + } + + return "none"; + }, + + _showDropMarker(targetItem) { + // Hide old drop marker. + this._hideDropMarker(); + + if (targetItem == "afterLastItem") { + targetItem = gAttachmentBucket.lastElementChild; + targetItem.setAttribute("dropOn", "after"); + } else { + targetItem.setAttribute("dropOn", "before"); + } + }, + + _hideDropMarker() { + gAttachmentBucket + .querySelector(".attachmentItem[dropOn]") + ?.removeAttribute("dropOn"); + }, + + /** + * Loop through all the valid data type flavors and return a list of valid + * attachments to handle the various drag&drop actions. + * + * @param {Event} event - The drag-and-drop event being performed. + * @param {boolean} isDropping - If the action was performed from the onDrop + * method and it needs to handle pills creation. + * + * @returns {nsIMsgAttachment[]} - The array of valid attachments. + */ + getValidAttachments(event, isDropping) { + let attachments = []; + let dt = event.dataTransfer; + let dataList = []; + + // Extract all the flavors matching the data type of the dragged elements. + for (let i = 0; i < dt.mozItemCount; i++) { + let types = Array.from(dt.mozTypesAt(i)); + for (let flavor of DROP_FLAVORS) { + if (types.includes(flavor)) { + let data = dt.mozGetDataAt(flavor, i); + if (data) { + dataList.push({ data, flavor }); + break; + } + } + } + } + + // Check if we have any valid attachment in the dragged data. + for (let { data, flavor } of dataList) { + gIsValidInline = false; + let isValidAttachment = false; + let prettyName; + let size; + let contentType; + let msgUri; + let cloudFileInfo; + + // We could be dropping an attachment of various flavors OR an address; + // check and do the right thing. + switch (flavor) { + // Process attachments. + case "application/x-moz-file": + if (data instanceof Ci.nsIFile) { + size = data.fileSize; + } + try { + data = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler) + .getURLSpecFromActualFile(data); + isValidAttachment = true; + } catch (e) { + console.error( + "Couldn't process the dragged file " + data.leafName + ":" + e + ); + } + break; + + case "text/x-moz-message": + isValidAttachment = true; + let msgHdr = + MailServices.messageServiceFromURI(data).messageURIToMsgHdr(data); + prettyName = msgHdr.mime2DecodedSubject; + if (Services.prefs.getBoolPref("mail.forward_add_extension")) { + prettyName += ".eml"; + } + + size = msgHdr.messageSize; + contentType = "message/rfc822"; + break; + + // Data type representing: + // - URL strings dragged from a URL bar (Allow both attach and append). + // NOTE: This only works for macOS and Windows. + // - Attachments dragged from another message (Only attach). + // - Images dragged from the body of another message (Only append). + case "text/uri-list": + case "text/x-moz-url": + let pieces = data.split("\n"); + data = pieces[0]; + if (pieces.length > 1) { + prettyName = pieces[1]; + } + if (pieces.length > 2) { + size = parseInt(pieces[2]); + } + if (pieces.length > 3) { + contentType = pieces[3]; + } + if (pieces.length > 4) { + msgUri = pieces[4]; + } + if (pieces.length > 6) { + cloudFileInfo = { + cloudFileAccountKey: pieces[5], + cloudPartHeaderData: pieces[6], + }; + } + + // Show the attachment overlay only if the user is not dragging an + // image form another message, since we can't get the correct file + // name, nor we can properly handle the append inline outside the + // editor drop event. + isValidAttachment = !event.dataTransfer.types.includes( + "application/x-moz-nativeimage" + ); + // Show the append inline overlay only if this is not a file that was + // dragged from the attachment bucket of another message. + gIsValidInline = !event.dataTransfer.types.includes( + "application/x-moz-file-promise" + ); + break; + + // Process address: Drop it into recipient field. + case "text/x-moz-address": + // Process the drop only if the message body wasn't the target and we + // called this method from the onDrop() method. + if (event.target.baseURI != "about:blank?compose" && isDropping) { + DropRecipient(event.target, data); + // Prevent the default behaviour which drops the address text into + // the widget. + event.preventDefault(); + } + break; + } + + // Create the attachment and add it to attachments array. + if (isValidAttachment) { + let attachment = Cc[ + "@mozilla.org/messengercompose/attachment;1" + ].createInstance(Ci.nsIMsgAttachment); + attachment.url = data; + attachment.name = prettyName; + attachment.contentType = contentType; + attachment.msgUri = msgUri; + + if (size !== undefined) { + attachment.size = size; + } + + if (cloudFileInfo) { + attachment.cloudFileAccountKey = cloudFileInfo.cloudFileAccountKey; + attachment.cloudPartHeaderData = cloudFileInfo.cloudPartHeaderData; + } + + attachments.push(attachment); + } + } + + return attachments; + }, + + /** + * Reorder the attachments dragged within the attachment bucket. + * + * @param {Event} event - The drag event. + */ + _reorderDraggedAttachments(event) { + // Adjust the drop target according to mouse position on list (items). + let target = this._adjustDropTarget(event); + // Get a non-live, sorted list of selected attachment list items. + let selItems = attachmentsSelectionGetSortedArray(); + // Keep track of the item we had focused originally. Deselect it though, + // since listbox gets confused if you move its focused item around. + let focus = gAttachmentBucket.currentItem; + gAttachmentBucket.currentItem = null; + // Moving possibly non-coherent multiple selections around correctly + // is much more complex than one might think... + if ( + (target.matches && target.matches("richlistitem.attachmentItem")) || + target == "afterLastItem" + ) { + // Drop before targetItem in the list, or after last item. + let blockItems = []; + let targetItem; + for (let item of selItems) { + blockItems.push(item); + if (target == "afterLastItem") { + // Original target is the end of the list; append all items there. + gAttachmentBucket.appendChild(item); + } else if (target == selItems[0]) { + // Original target is first item of first selected block. + if (blockItems.includes(target)) { + // Item is in first block: do nothing, find the end of the block. + let nextItem = item.nextElementSibling; + if (!nextItem || !nextItem.selected) { + // We've reached the end of the first block. + blockItems.length = 0; + targetItem = nextItem; + } + } else { + // Item is NOT in first block: insert before targetItem, + // i.e. after end of first block. + gAttachmentBucket.insertBefore(item, targetItem); + } + } else if (target.selected) { + // Original target is not first item of first block, + // but first item of another block. + if ( + gAttachmentBucket.getIndexOfItem(item) < + gAttachmentBucket.getIndexOfItem(target) + ) { + // Insert all items from preceding blocks before original target. + gAttachmentBucket.insertBefore(item, target); + } else if (blockItems.includes(target)) { + // target is included in any selected block except first: + // do nothing for that block, find its end. + let nextItem = item.nextElementSibling; + if (!nextItem || !nextItem.selected) { + // end of block containing target + blockItems.length = 0; + targetItem = nextItem; + } + } else { + // Item from block after block containing target: insert before + // targetItem, i.e. after end of block containing target. + gAttachmentBucket.insertBefore(item, targetItem); + } + } else { + // target != selItems [0] + // Original target is NOT first item of any block, and NOT selected: + // Insert all items before the original target. + gAttachmentBucket.insertBefore(item, target); + } + } + } + gAttachmentBucket.currentItem = focus; + }, + + handleInlineDrop(event) { + // It would be nice here to be able to append images, but we can't really + // assume if users want to add the image URL as clickable link or embedded + // image, so we always default to clickable link. + // We can later explore adding some UI choice to allow controlling the + // outcome of this drop action, but users can still copy and paste the image + // in the editor to cirumvent this potential issue. + let editor = GetCurrentEditor(); + let attachments = this.getValidAttachments(event, true); + + for (let attachment of attachments) { + if (!attachment?.url) { + continue; + } + + let link = editor.createElementWithDefaults("a"); + link.setAttribute("href", attachment.url); + link.textContent = + attachment.name || + gMsgCompose.AttachmentPrettyName(attachment.url, null); + editor.insertElementAtSelection(link, true); + } + }, + + async onDrop(event) { + this._hideDropOverlay(); + + let dragSession = gDragService.getCurrentSession(); + if (dragSession.sourceNode?.parentNode == gAttachmentBucket) { + // We dragged from the attachment pane onto itself, so instead of + // attaching a new object, we're just reordering them. + this._reorderDraggedAttachments(event); + this._hideDropMarker(); + return; + } + + // Interrupt if we're dropping elements from within the message body. + if (dragSession.sourceNode?.ownerDocument.URL == "about:blank?compose") { + return; + } + + // Interrupt if we're not dropping a file from outside the compose window + // and we're not dragging a supported data type. + if ( + !event.dataTransfer.files.length && + !DROP_FLAVORS.some(f => event.dataTransfer.types.includes(f)) + ) { + return; + } + + // If the drop happened on the inline container, and the dragged data is + // valid for inline, bail out and handle it as inline text link. + if (event.target.id == "addInline" && gIsValidInline) { + this.handleInlineDrop(event); + return; + } + + // Handle the inline adding of images without triggering the creation of + // any attachment if the user dropped only images above the #addInline box. + if ( + event.target.id == "addInline" && + !this.isNotDraggingOnlyImages(event.dataTransfer) + ) { + this.appendImagesInline(event.dataTransfer); + return; + } + + let attachments = this.getValidAttachments(event, true); + + // Interrupt if we don't have anything to attach. + if (!attachments.length) { + return; + } + + let addedAttachmentItems = await AddAttachments(attachments); + // Convert attachments back to cloudFiles, if any. + for (let attachmentItem of addedAttachmentItems) { + if ( + !attachmentItem.attachment.cloudFileAccountKey || + !attachmentItem.attachment.cloudPartHeaderData + ) { + continue; + } + try { + let account = cloudFileAccounts.getAccount( + attachmentItem.attachment.cloudFileAccountKey + ); + let upload = JSON.parse( + atob(attachmentItem.attachment.cloudPartHeaderData) + ); + await UpdateAttachment(attachmentItem, { + cloudFileAccount: account, + relatedCloudFileUpload: upload, + }); + } catch (ex) { + showLocalizedCloudFileAlert(ex); + } + } + gAttachmentBucket.focus(); + + // Stop the propagation only if we actually attached something. + event.stopPropagation(); + }, + + onDragOver(event) { + let dragSession = gDragService.getCurrentSession(); + + // Check if we're dragging from the attachment bucket onto itself. + if (dragSession.sourceNode?.parentNode == gAttachmentBucket) { + event.stopPropagation(); + event.preventDefault(); + + // Show a drop marker. + let target = this._adjustDropTarget(event); + + if ( + (target.matches && target.matches("richlistitem.attachmentItem")) || + target == "afterLastItem" + ) { + // Adjusted target is an attachment list item; show dropmarker. + this._showDropMarker(target); + return; + } + + // target == "none", target is not a listItem, or no target: + // Indicate that we can't drop here. + this._hideDropMarker(); + event.dataTransfer.dropEffect = "none"; + return; + } + + // Interrupt if we're dragging elements from within the message body. + if (dragSession.sourceNode?.ownerDocument.URL == "about:blank?compose") { + return; + } + + // No need to check for the same dragged files if the previous dragging + // action didn't end. + if (gIsDraggingAttachments) { + // Prevent the default action of the event otherwise the onDrop event + // won't be triggered. + event.preventDefault(); + this.detectHoveredOverlay(event.target.id); + return; + } + + if (DROP_FLAVORS.some(f => event.dataTransfer.types.includes(f))) { + // Show the drop overlay only if we dragged files or supported types. + let attachments = this.getValidAttachments(event); + if (attachments.length) { + // We're dragging files that can potentially be attached or added + // inline, so update the variable. + gIsDraggingAttachments = true; + + event.stopPropagation(); + event.preventDefault(); + document + .getElementById("dropAttachmentOverlay") + .classList.add("showing"); + + document.l10n.setAttributes( + document.getElementById("addAsAttachmentLabel"), + "drop-file-label-attachment", + { + count: attachments.length || 1, + } + ); + + document.l10n.setAttributes( + document.getElementById("addInlineLabel"), + "drop-file-label-inline", + { + count: attachments.length || 1, + } + ); + + // Show the #addInline box only if the user is dragging text that we + // want to allow adding as text, as well as dragging only images, and + // if this is not a plain text message. + // NOTE: We're using event.dataTransfer.files.length instead of + // attachments.length because we only need to consider images coming + // from outside the application. The attachments array might contain + // files dragged from other compose windows or received message, which + // should not trigger the inline attachment overlay. + document + .getElementById("addInline") + .classList.toggle( + "hidden", + !gIsValidInline && + (!event.dataTransfer.files.length || + this.isNotDraggingOnlyImages(event.dataTransfer) || + !gMsgCompose.composeHTML) + ); + } else { + DragAddressOverTargetControl(event); + } + } + + this.detectHoveredOverlay(event.target.id); + }, + + onDragLeave(event) { + // Set the variable to false as a drag leave event was triggered. + gIsDraggingAttachments = false; + + // We use a timeout since a drag leave event might occur also when the drag + // motion passes above a child element and doesn't actually leave the + // compose window. + setTimeout(() => { + // If after the timeout, the dragging boolean is true, it means the user + // is still dragging something above the compose window, so let's bail out + // to prevent visual flickering of the drop overlay. + if (gIsDraggingAttachments) { + return; + } + + this._hideDropOverlay(); + }, 100); + + this._hideDropMarker(); + }, + + /** + * Hide the drag & drop overlay and update the global dragging variable to + * false. This operations are set in a dedicated method since they need to be + * called outside of the onDragleave() method. + */ + _hideDropOverlay() { + gIsDraggingAttachments = false; + + let overlay = document.getElementById("dropAttachmentOverlay"); + overlay.classList.remove("showing"); + overlay.classList.add("hiding"); + }, + + /** + * Loop through all the currently dragged or dropped files to see if there's + * at least 1 file which is not an image. + * + * @param {DataTransfer} dataTransfer - The dataTransfer object from the drag + * or drop event. + * @returns {boolean} True if at least one file is not an image. + */ + isNotDraggingOnlyImages(dataTransfer) { + for (let file of dataTransfer.files) { + if (!file.type.includes("image/")) { + return true; + } + } + return false; + }, + + /** + * Add or remove the hover effect to the droppable containers. We can't do it + * simply via CSS since the hover events don't work when dragging an item. + * + * @param {string} targetId - The ID of the hovered overlay element. + */ + detectHoveredOverlay(targetId) { + document + .getElementById("addInline") + .classList.toggle("hover", targetId == "addInline"); + document + .getElementById("addAsAttachment") + .classList.toggle("hover", targetId == "addAsAttachment"); + }, + + /** + * Loop through all the images that have been dropped above the #addInline + * box and create an image element to append to the message body. + * + * @param {DataTransfer} dataTransfer - The dataTransfer object from the drop + * event. + */ + appendImagesInline(dataTransfer) { + focusMsgBody(); + let editor = GetCurrentEditor(); + editor.beginTransaction(); + + for (let file of dataTransfer.files) { + if (!file.mozFullPath) { + continue; + } + + let realFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + realFile.initWithPath(file.mozFullPath); + + let imageElement; + try { + imageElement = editor.createElementWithDefaults("img"); + } catch (e) { + dump("Failed to create a new image element!\n"); + console.error(e); + continue; + } + + let src = Services.io.newFileURI(realFile).spec; + imageElement.setAttribute("src", src); + imageElement.setAttribute("moz-do-not-send", "false"); + + editor.insertElementAtSelection(imageElement, true); + + try { + loadBlockedImage(src); + } catch (e) { + dump("Failed to load the appended image!\n"); + console.error(e); + continue; + } + } + + editor.endTransaction(); + }, +}; + +// See attachmentListDNDObserver, which should have the same logic. +let attachmentBucketDNDObserver = { + 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( + gAttachmentBucket.querySelectorAll(".attachmentItem[selected]"), + item => item.attachment + ); + setupDataTransfer(event, attachments); + } + event.stopPropagation(); + }, +}; + +function DisplaySaveFolderDlg(folderURI) { + try { + var showDialog = gCurrentIdentity.showSaveMsgDlg; + } catch (e) { + return; + } + + if (showDialog) { + let msgfolder = MailUtils.getExistingFolder(folderURI); + if (!msgfolder) { + return; + } + let checkbox = { value: 0 }; + let bundle = getComposeBundle(); + let SaveDlgTitle = bundle.getString("SaveDialogTitle"); + let dlgMsg = bundle.getFormattedString("SaveDialogMsg", [ + msgfolder.name, + msgfolder.server.prettyName, + ]); + + Services.prompt.alertCheck( + window, + SaveDlgTitle, + dlgMsg, + bundle.getString("CheckMsg"), + checkbox + ); + try { + gCurrentIdentity.showSaveMsgDlg = !checkbox.value; + } catch (e) {} + } +} + +/** + * Focus the people search input in the contacts side panel. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @returns {boolean} - Whether the peopleSearchInput was focused. + */ +function focusContactsSidebarSearchInput() { + if (document.getElementById("contactsSplitter").isCollapsed) { + return false; + } + let input = document + .getElementById("contactsBrowser") + .contentDocument.getElementById("peopleSearchInput"); + if (!input) { + return false; + } + input.focus(); + return true; +} + +/** + * Focus the "From" identity input/selector. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @returns {true} - Always returns true. + */ +function focusMsgIdentity() { + document.getElementById("msgIdentity").focus(); + return true; +} + +/** + * Focus the address row input, provided the row is not hidden. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @param {Element} row - The address row to focus. + * + * @returns {boolean} - Whether the input was focused. + */ +function focusAddressRowInput(row) { + if (row.classList.contains("hidden")) { + return false; + } + row.querySelector(".address-row-input").focus(); + return true; +} + +/** + * Focus the "Subject" input. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @returns {true} - Always returns true. + */ +function focusSubjectInput() { + document.getElementById("msgSubject").focus(); + return true; +} + +/** + * Focus the composed message body. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @returns {true} - Always returns true. + */ +function focusMsgBody() { + // window.content.focus() fails to blur the currently focused element + document.commandDispatcher.advanceFocusIntoSubtree( + document.getElementById("messageArea") + ); + return true; +} + +/** + * Focus the attachment bucket, provided it is not hidden. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @param {Element} attachmentArea - The attachment container. + * + * @returns {boolean} - Whether the attachment bucket was focused. + */ +function focusAttachmentBucket(attachmentArea) { + if ( + document + .getElementById("composeContentBox") + .classList.contains("attachment-area-hidden") + ) { + return false; + } + if (!attachmentArea.open) { + // Focus the expander instead. + attachmentArea.querySelector("summary").focus(); + return true; + } + gAttachmentBucket.focus(); + return true; +} + +/** + * Focus the first notification button. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @returns {boolean} - Whether a notification received focused. + */ +function focusNotification() { + let notification = gComposeNotification.allNotifications[0]; + if (notification) { + let button = notification.buttonContainer.querySelector("button"); + if (button) { + button.focus(); + } else { + // Focus the close button instead. + notification.closeButton.focus(); + } + return true; + } + return false; +} + +/** + * Focus the first focusable descendant of the status bar. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @param {Element} attachmentArea - The status bar. + * + * @returns {boolean} - Whether a status bar descendant received focused. + */ +function focusStatusBar(statusBar) { + let button = statusBar.querySelector("button:not([hidden])"); + if (!button) { + return false; + } + button.focus(); + return true; +} + +/** + * Fast-track focus ring: Switch focus between important (not all) elements + * in the message compose window in response to Ctrl+[Shift+]Tab or [Shift+]F6. + * + * @param {Event} event - A DOM keyboard event of a fast focus ring shortcut key + */ +function moveFocusToNeighbouringArea(event) { + event.preventDefault(); + let currentElement = document.activeElement; + + for (let i = 0; i < gFocusAreas.length; i++) { + // Go through each area and check if focus is within. + let area = gFocusAreas[i]; + if (!area.root.contains(currentElement)) { + continue; + } + // Focus is within, so we find the neighbouring area to move focus to. + let end = i; + while (true) { + // Get the next neighbour. + // NOTE: The focus will loop around. + if (event.shiftKey) { + // Move focus backward. If the index points to the start of the Array, + // we loop back to the end of the Array. + i = (i || gFocusAreas.length) - 1; + } else { + // Move focus forward. If the index points to the end of the Array, we + // loop back to the start of the Array. + i = (i + 1) % gFocusAreas.length; + } + if (i == end) { + // Full loop around without finding an area to focus. + // Unexpected, but we make sure to stop looping. + break; + } + area = gFocusAreas[i]; + if (area.focus(area.root)) { + // Successfully moved focus. + break; + } + // Else, try the next neighbour. + } + return; + } + // Focus is currently outside the gFocusAreas list, so do nothing. +} + +/** + * If the contacts sidebar is shown, hide it. Otherwise, show the contacts + * sidebar and focus it. + */ +function toggleContactsSidebar() { + setContactsSidebarVisibility( + document.getElementById("contactsSplitter").isCollapsed, + true + ); +} + +/** + * Show or hide contacts sidebar. + * + * @param {boolean} show - Whether to show the sidebar or hide the sidebar. + * @param {boolean} focus - Whether to focus peopleSearchInput if the sidebar is + * shown. + */ +function setContactsSidebarVisibility(show, focus) { + let contactsSplitter = document.getElementById("contactsSplitter"); + let sidebarAddrMenu = document.getElementById("menu_AddressSidebar"); + let contactsButton = document.getElementById("button-contacts"); + + if (show) { + contactsSplitter.expand(); + sidebarAddrMenu.setAttribute("checked", "true"); + if (contactsButton) { + contactsButton.setAttribute("checked", "true"); + } + + let contactsBrowser = document.getElementById("contactsBrowser"); + if (contactsBrowser.getAttribute("src") == "") { + // Url not yet set, load contacts side bar and focus the search + // input if applicable: We pass "?focus" as a URL querystring, then via + // onload event of <window id="abContactsPanel">, in AbPanelLoad() of + // abContactsPanel.js, we do the focusing first thing to avoid timing + // issues when trying to focus from here while contacts side bar is still + // loading. + let url = "chrome://messenger/content/addressbook/abContactsPanel.xhtml"; + if (focus) { + url += "?focus"; + } + contactsBrowser.setAttribute("src", url); + } else if (focus) { + // Url already set, so we can focus immediately if applicable. + focusContactsSidebarSearchInput(); + } + } else { + let contactsSidebar = document.getElementById("contactsSidebar"); + // Before closing, check if the focus was within the contacts sidebar. + let sidebarFocussed = contactsSidebar.contains(document.activeElement); + + contactsSplitter.collapse(); + sidebarAddrMenu.removeAttribute("checked"); + if (contactsButton) { + contactsButton.removeAttribute("checked"); + } + + // Don't change the focus unless it was within the contacts sidebar. + if (!sidebarFocussed) { + return; + } + // Else, we need to explicitly move the focus out of the contacts sidebar. + // We choose the subject input if it is empty, otherwise the message body. + if (!document.getElementById("msgSubject").value) { + focusSubjectInput(); + } else { + focusMsgBody(); + } + } +} + +function loadHTMLMsgPrefs() { + let fontFace = Services.prefs.getStringPref("msgcompose.font_face", ""); + if (fontFace) { + doStatefulCommand("cmd_fontFace", fontFace, true); + } + + let fontSize = Services.prefs.getCharPref("msgcompose.font_size", "3"); + EditorSetFontSize(fontSize); + + let bodyElement = GetBodyElement(); + + let useDefault = Services.prefs.getBoolPref("msgcompose.default_colors"); + + let textColor = useDefault + ? "" + : Services.prefs.getCharPref("msgcompose.text_color", ""); + if (!bodyElement.getAttribute("text") && textColor) { + bodyElement.setAttribute("text", textColor); + gDefaultTextColor = textColor; + document.getElementById("cmd_fontColor").setAttribute("state", textColor); + onFontColorChange(); + } + + let bgColor = useDefault + ? "" + : Services.prefs.getCharPref("msgcompose.background_color", ""); + if (!bodyElement.getAttribute("bgcolor") && bgColor) { + bodyElement.setAttribute("bgcolor", bgColor); + gDefaultBackgroundColor = bgColor; + document + .getElementById("cmd_backgroundColor") + .setAttribute("state", bgColor); + onBackgroundColorChange(); + } +} + +async function AutoSave() { + if ( + gMsgCompose.editor && + (gContentChanged || gMsgCompose.bodyModified) && + !gSendOperationInProgress && + !gSaveOperationInProgress + ) { + try { + await GenericSendMessage(Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft); + } catch (ex) { + console.error(ex); + } + gAutoSaveKickedIn = true; + } + + gAutoSaveTimeout = setTimeout(AutoSave, gAutoSaveInterval); +} + +/** + * Periodically check for keywords in the message. + */ +var gAttachmentNotifier = { + _obs: null, + + enabled: false, + + init(aDocument) { + if (this._obs) { + this.shutdown(); + } + + this.enabled = Services.prefs.getBoolPref( + "mail.compose.attachment_reminder" + ); + if (!this.enabled) { + return; + } + + this._obs = new MutationObserver(function (aMutations) { + gAttachmentNotifier.timer.cancel(); + gAttachmentNotifier.timer.initWithCallback( + gAttachmentNotifier.event, + 500, + Ci.nsITimer.TYPE_ONE_SHOT + ); + }); + + this._obs.observe(aDocument, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }); + + // Add an input event listener for the subject field since there + // are ways of changing its value without key presses. + document + .getElementById("msgSubject") + .addEventListener("input", this.subjectInputObserver, true); + + // We could have been opened with a draft message already containing + // some keywords, so run the checker once to pick them up. + this.event.notify(); + }, + + // Timer based function triggered by the inputEventListener + // for the subject field. + subjectInputObserver() { + gAttachmentNotifier.timer.cancel(); + gAttachmentNotifier.timer.initWithCallback( + gAttachmentNotifier.event, + 500, + Ci.nsITimer.TYPE_ONE_SHOT + ); + }, + + /** + * Checks for new keywords synchronously and run the usual handler. + * + * @param aManage Determines whether to manage the notification according to keywords found. + */ + redetectKeywords(aManage) { + if (!this.enabled) { + return; + } + + attachmentWorker.onmessage( + { data: this._checkForAttachmentKeywords(false) }, + aManage + ); + }, + + /** + * Check if there are any keywords in the message. + * + * @param async Whether we should run the regex checker asynchronously or not. + * + * @returns If async is true, attachmentWorker.message is called with the array + * of found keywords and this function returns null. + * If it is false, the array is returned from this function immediately. + */ + _checkForAttachmentKeywords(async) { + if (!this.enabled) { + return async ? null : []; + } + + if (attachmentNotificationSupressed()) { + // If we know we don't need to show the notification, + // we can skip the expensive checking of keywords in the message. + // but mark it in the .lastMessage that the keywords are unknown. + attachmentWorker.lastMessage = null; + return async ? null : []; + } + + let keywordsInCsv = Services.prefs.getComplexValue( + "mail.compose.attachment_reminder_keywords", + Ci.nsIPrefLocalizedString + ).data; + let mailBody = getBrowser().contentDocument.querySelector("body"); + + // We use a new document and import the body into it. We do that to avoid + // loading images that were previously blocked. Content policy of the newly + // created data document will block the loads. Details: Bug 1409458 comment #22. + let newDoc = getBrowser().contentDocument.implementation.createDocument( + "", + "", + null + ); + let mailBodyNode = newDoc.importNode(mailBody, true); + + // Don't check quoted text from reply. + let blockquotes = mailBodyNode.getElementsByTagName("blockquote"); + for (let i = blockquotes.length - 1; i >= 0; i--) { + blockquotes[i].remove(); + } + + // For plaintext composition the quotes we need to find and exclude are + // <span _moz_quote="true">. + let spans = mailBodyNode.querySelectorAll("span[_moz_quote]"); + for (let i = spans.length - 1; i >= 0; i--) { + spans[i].remove(); + } + + // Ignore signature (html compose mode). + let sigs = mailBodyNode.getElementsByClassName("moz-signature"); + for (let i = sigs.length - 1; i >= 0; i--) { + sigs[i].remove(); + } + + // Replace brs with line breaks so node.textContent won't pull foo<br>bar + // together to foobar. + let brs = mailBodyNode.getElementsByTagName("br"); + for (let i = brs.length - 1; i >= 0; i--) { + brs[i].parentNode.replaceChild( + mailBodyNode.ownerDocument.createTextNode("\n"), + brs[i] + ); + } + + // Ignore signature (plain text compose mode). + let mailData = mailBodyNode.textContent; + let sigIndex = mailData.indexOf("-- \n"); + if (sigIndex > 0) { + mailData = mailData.substring(0, sigIndex); + } + + // Ignore replied messages (plain text and html compose mode). + let repText = getComposeBundle().getString( + "mailnews.reply_header_originalmessage" + ); + let repIndex = mailData.indexOf(repText); + if (repIndex > 0) { + mailData = mailData.substring(0, repIndex); + } + + // Ignore forwarded messages (plain text and html compose mode). + let fwdText = getComposeBundle().getString( + "mailnews.forward_header_originalmessage" + ); + let fwdIndex = mailData.indexOf(fwdText); + if (fwdIndex > 0) { + mailData = mailData.substring(0, fwdIndex); + } + + // Prepend the subject to see if the subject contains any attachment + // keywords too, after making sure that the subject has changed + // or after reopening a draft. For reply, redirect and forward, + // only check when the input was changed by the user. + let subject = document.getElementById("msgSubject").value; + if ( + subject && + (gSubjectChanged || + (gEditingDraft && + (gComposeType == Ci.nsIMsgCompType.New || + gComposeType == Ci.nsIMsgCompType.NewsPost || + gComposeType == Ci.nsIMsgCompType.Draft || + gComposeType == Ci.nsIMsgCompType.Template || + gComposeType == Ci.nsIMsgCompType.EditTemplate || + gComposeType == Ci.nsIMsgCompType.EditAsNew || + gComposeType == Ci.nsIMsgCompType.MailToUrl))) + ) { + mailData = subject + " " + mailData; + } + + if (!async) { + return AttachmentChecker.getAttachmentKeywords(mailData, keywordsInCsv); + } + + attachmentWorker.postMessage([mailData, keywordsInCsv]); + return null; + }, + + shutdown() { + if (this._obs) { + this._obs.disconnect(); + } + gAttachmentNotifier.timer.cancel(); + + this._obs = null; + }, + + event: { + notify(timer) { + // Only run the checker if the compose window is initialized + // and not shutting down. + if (gMsgCompose) { + // This runs the attachmentWorker asynchronously so if keywords are found + // manageAttachmentNotification is run from attachmentWorker.onmessage. + gAttachmentNotifier._checkForAttachmentKeywords(true); + } + }, + }, + + timer: Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer), +}; + +/** + * Helper function to remove a query part from a URL, so for example: + * ...?remove=xx&other=yy becomes ...?other=yy. + * + * @param aURL the URL from which to remove the query part + * @param aQuery the query part to remove + * @returns the URL with the query part removed + */ +function removeQueryPart(aURL, aQuery) { + // Quick pre-check. + if (!aURL.includes(aQuery)) { + return aURL; + } + + let indexQM = aURL.indexOf("?"); + if (indexQM < 0) { + return aURL; + } + + let queryParts = aURL.substr(indexQM + 1).split("&"); + let indexPart = queryParts.indexOf(aQuery); + if (indexPart < 0) { + return aURL; + } + queryParts.splice(indexPart, 1); + return aURL.substr(0, indexQM + 1) + queryParts.join("&"); +} + +function InitEditor() { + var editor = GetCurrentEditor(); + + // Set eEditorMailMask flag to avoid using content prefs for spell checker, + // otherwise dictionary setting in preferences is ignored and dictionary is + // inconsistent in subject and message body. + let eEditorMailMask = Ci.nsIEditor.eEditorMailMask; + editor.flags |= eEditorMailMask; + document.getElementById("msgSubject").editor.flags |= eEditorMailMask; + + // Control insertion of line breaks. + editor.returnInParagraphCreatesNewParagraph = Services.prefs.getBoolPref( + "editor.CR_creates_new_p" + ); + editor.document.execCommand( + "defaultparagraphseparator", + false, + gMsgCompose.composeHTML && + Services.prefs.getBoolPref("mail.compose.default_to_paragraph") + ? "p" + : "br" + ); + if (gMsgCompose.composeHTML) { + // Re-enable table/image resizers. + editor.QueryInterface( + Ci.nsIHTMLAbsPosEditor + ).absolutePositioningEnabled = true; + editor.QueryInterface( + Ci.nsIHTMLInlineTableEditor + ).inlineTableEditingEnabled = true; + editor.QueryInterface(Ci.nsIHTMLObjectResizer).objectResizingEnabled = true; + } + + // We use loadSheetUsingURIString so that we get a synchronous load, rather + // than having a late-finishing async load mark our editor as modified when + // the user hasn't typed anything yet, but that means the sheet must not + // @import slow things, especially not over the network. + let domWindowUtils = GetCurrentEditorElement().contentWindow.windowUtils; + domWindowUtils.loadSheetUsingURIString( + "chrome://messenger/skin/messageQuotes.css", + domWindowUtils.AGENT_SHEET + ); + domWindowUtils.loadSheetUsingURIString( + "chrome://messenger/skin/shared/composerOverlay.css", + domWindowUtils.AGENT_SHEET + ); + + window.content.browsingContext.allowJavascript = false; + window.content.browsingContext.docShell.allowAuth = false; + window.content.browsingContext.docShell.allowMetaRedirects = false; + gMsgCompose.initEditor(editor, window.content); + + if (!editor.document.doctype) { + editor.document.insertBefore( + editor.document.implementation.createDocumentType("html", "", ""), + editor.document.firstChild + ); + } + + // Then, we enable related UI entries. + enableInlineSpellCheck(Services.prefs.getBoolPref("mail.spellcheck.inline")); + gAttachmentNotifier.init(editor.document); + + // Listen for spellchecker changes, set document language to + // dictionary picked by the user via the right-click menu in the editor. + document.addEventListener("spellcheck-changed", updateDocumentLanguage); + + // XXX: the error event fires twice for each load. Why?? + editor.document.body.addEventListener( + "error", + function (event) { + if (event.target.localName != "img") { + return; + } + + if (event.target.getAttribute("moz-do-not-send") == "true") { + return; + } + + let src = event.target.src; + if (!src) { + return; + } + if (!/^file:/i.test(src)) { + // Check if this is a protocol that can fetch parts. + let protocol = src.substr(0, src.indexOf(":")).toLowerCase(); + if ( + !( + Services.io.getProtocolHandler(protocol) instanceof + Ci.nsIMsgMessageFetchPartService + ) + ) { + // Can't fetch parts, don't try to load. + return; + } + } + + if (event.target.classList.contains("loading-internal")) { + // We're already loading this, or tried so unsuccessfully. + return; + } + if (gOriginalMsgURI) { + let msgSvc = MailServices.messageServiceFromURI(gOriginalMsgURI); + let originalMsgNeckoURI = msgSvc.getUrlForUri(gOriginalMsgURI); + if ( + src.startsWith( + removeQueryPart( + originalMsgNeckoURI.spec, + "type=application/x-message-display" + ) + ) || + // Special hack for saved messages. + (src.includes("?number=0&") && + originalMsgNeckoURI.spec.startsWith("file://") && + src.startsWith( + removeQueryPart( + originalMsgNeckoURI.spec, + "type=application/x-message-display" + ).replace("file://", "mailbox://") + "number=0" + )) + ) { + // Reply/Forward/Edit Draft/Edit as New can contain references to + // images in the original message. Load those and make them data: URLs + // now. + event.target.classList.add("loading-internal"); + try { + loadBlockedImage(src); + } catch (e) { + // Couldn't load the referenced image. + console.error(e); + } + } else { + // Appears to reference a random message. Notify and keep blocking. + gComposeNotificationBar.setBlockedContent(src); + } + } else { + // For file:, and references to parts of random messages, show the + // blocked content notification. + gComposeNotificationBar.setBlockedContent(src); + } + }, + true + ); + + // Convert mailnews URL back to data: URL. + let background = editor.document.body.background; + if (background && gOriginalMsgURI) { + // Check that background has the same URL as the message itself. + let msgSvc = MailServices.messageServiceFromURI(gOriginalMsgURI); + let originalMsgNeckoURI = msgSvc.getUrlForUri(gOriginalMsgURI); + if ( + background.startsWith( + removeQueryPart( + originalMsgNeckoURI.spec, + "type=application/x-message-display" + ) + ) + ) { + try { + editor.document.body.background = loadBlockedImage(background, true); + } catch (e) { + // Couldn't load the referenced image. + console.error(e); + } + } + } + + // Run menubar initialization first, to avoid TabsInTitlebar code picking + // up mutations from it and causing a reflow. + if (AppConstants.platform != "macosx") { + AutoHideMenubar.init(); + } + + // For plain text compose, set the styles for quoted text according to + // preferences. + if (!gMsgCompose.composeHTML) { + let style = editor.document.createElement("style"); + editor.document.head.appendChild(style); + let fontStyle = ""; + let fontSize = ""; + switch (Services.prefs.getIntPref("mail.quoted_style")) { + case 1: + fontStyle = "font-weight: bold;"; + break; + case 2: + fontStyle = "font-style: italic;"; + break; + case 3: + fontStyle = "font-weight: bold; font-style: italic;"; + break; + } + + switch (Services.prefs.getIntPref("mail.quoted_size")) { + case 1: + fontSize = "font-size: large;"; + break; + case 2: + fontSize = "font-size: small;"; + break; + } + + let citationColor = + "color: " + Services.prefs.getCharPref("mail.citation_color") + ";"; + + style.sheet.insertRule( + `span[_moz_quote="true"] { + ${fontStyle} + ${fontSize} + ${citationColor} + }` + ); + gMsgCompose.bodyModified = false; + } + + // Set document language to the draft language or the preference + // if this is a draft or template we prepared. + let draftLanguages = null; + if ( + gMsgCompose.compFields.creatorIdentityKey && + gMsgCompose.compFields.contentLanguage + ) { + draftLanguages = gMsgCompose.compFields.contentLanguage + .split(",") + .map(lang => lang.trim()); + } + + let dictionaries = getValidSpellcheckerDictionaries(draftLanguages); + ComposeChangeLanguage(dictionaries).catch(console.error); +} + +function setFontSize(event) { + // Increase Font Menuitem and Decrease Font Menuitem from the main menu + // will call this function because of oncommand attribute on the menupopup + // and fontSize will be null for such function calls. + let fontSize = event.target.value; + if (fontSize) { + EditorSetFontSize(fontSize); + } +} + +function setParagraphState(event) { + editorSetParagraphState(event.target.value); +} + +// This is used as event listener to spellcheck-changed event to update +// document language. +function updateDocumentLanguage(e) { + ComposeChangeLanguage(e.detail.dictionaries).catch(console.error); +} + +function toggleSpellCheckingEnabled() { + enableInlineSpellCheck(!gSpellCheckingEnabled); +} + +// This function is called either at startup (see InitEditor above), or when +// the user clicks on one of the two menu items that allow them to toggle the +// spellcheck feature (either context menu or Options menu). +function enableInlineSpellCheck(aEnableInlineSpellCheck) { + let checker = GetCurrentEditorSpellChecker(); + if (!checker) { + return; + } + if (gSpellCheckingEnabled != aEnableInlineSpellCheck) { + // If state of spellchecker is about to change, clear any pending observer. + spellCheckReadyObserver.removeObserver(); + } + + gSpellCheckingEnabled = checker.enableRealTimeSpell = aEnableInlineSpellCheck; + document + .getElementById("msgSubject") + .setAttribute("spellcheck", aEnableInlineSpellCheck); +} + +function getMailToolbox() { + return document.getElementById("compose-toolbox"); +} + +/** + * Helper function to dispatch a CustomEvent to the attachmentbucket. + * + * @param aEventType the name of the event to fire. + * @param aData any detail data to pass to the CustomEvent. + */ +function dispatchAttachmentBucketEvent(aEventType, aData) { + gAttachmentBucket.dispatchEvent( + new CustomEvent(aEventType, { + bubbles: true, + cancelable: true, + detail: aData, + }) + ); +} + +/** Update state of zoom type (text vs. full) menu item. */ +function UpdateFullZoomMenu() { + let menuItem = document.getElementById("menu_fullZoomToggle"); + menuItem.setAttribute("checked", !ZoomManager.useFullZoom); +} + +/** + * Return the <editor> element of the mail compose window. The name is somewhat + * unfortunate; we need to maintain it since the zoom manager, view source and + * other functions still rely on it. + */ +function getBrowser() { + return document.getElementById("messageEditor"); +} + +function goUpdateMailMenuItems(commandset) { + for (let i = 0; i < commandset.children.length; i++) { + let commandID = commandset.children[i].getAttribute("id"); + if (commandID) { + goUpdateCommand(commandID); + } + } +} + +/** + * Object to handle message related notifications that are showing in a + * notificationbox below the composed message content. + */ +var gComposeNotificationBar = { + get brandBundle() { + delete this.brandBundle; + return (this.brandBundle = document.getElementById("brandBundle")); + }, + + setBlockedContent(aBlockedURI) { + let brandName = this.brandBundle.getString("brandShortName"); + let buttonLabel = getComposeBundle().getString( + AppConstants.platform == "win" + ? "blockedContentPrefLabel" + : "blockedContentPrefLabelUnix" + ); + let buttonAccesskey = getComposeBundle().getString( + AppConstants.platform == "win" + ? "blockedContentPrefAccesskey" + : "blockedContentPrefAccesskeyUnix" + ); + + let buttons = [ + { + label: buttonLabel, + accessKey: buttonAccesskey, + popup: "blockedContentOptions", + callback(aNotification, aButton) { + return true; // keep notification open + }, + }, + ]; + + // The popup value is a space separated list of all the blocked urls. + let popup = document.getElementById("blockedContentOptions"); + let urls = popup.value ? popup.value.split(" ") : []; + if (!urls.includes(aBlockedURI)) { + urls.push(aBlockedURI); + } + popup.value = urls.join(" "); + + let msg = getComposeBundle().getFormattedString("blockedContentMessage", [ + brandName, + brandName, + ]); + msg = PluralForm.get(urls.length, msg); + + if (!this.isShowingBlockedContentNotification()) { + gComposeNotification.appendNotification( + "blockedContent", + { + label: msg, + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + }, + buttons + ); + } else { + gComposeNotification + .getNotificationWithValue("blockedContent") + .setAttribute("label", msg); + } + }, + + isShowingBlockedContentNotification() { + return !!gComposeNotification.getNotificationWithValue("blockedContent"); + }, + + clearBlockedContentNotification() { + gComposeNotification.removeNotification( + gComposeNotification.getNotificationWithValue("blockedContent") + ); + }, + + clearNotifications(aValue) { + gComposeNotification.removeAllNotifications(true); + }, + + /** + * Show a warning notification when a newly typed identity in the Form field + * doesn't match any existing identity. + * + * @param {string} identity - The name of the identity to add to the + * notification. Most likely an email address. + */ + async setIdentityWarning(identity) { + // Bail out if we are already showing this type of notification. + if (gComposeNotification.getNotificationWithValue("identityWarning")) { + return; + } + + gComposeNotification.appendNotification( + "identityWarning", + { + label: await document.l10n.formatValue( + "compose-missing-identity-warning", + { + identity, + } + ), + priority: gComposeNotification.PRIORITY_WARNING_HIGH, + }, + null + ); + }, + + clearIdentityWarning() { + let idWarning = + gComposeNotification.getNotificationWithValue("identityWarning"); + if (idWarning) { + gComposeNotification.removeNotification(idWarning); + } + }, +}; + +/** + * Populate the menuitems of what blocked content to unblock. + */ +function onBlockedContentOptionsShowing(aEvent) { + let urls = aEvent.target.value ? aEvent.target.value.split(" ") : []; + + // Out with the old... + while (aEvent.target.lastChild) { + aEvent.target.lastChild.remove(); + } + + // ... and in with the new. + for (let url of urls) { + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute( + "label", + getComposeBundle().getFormattedString("blockedAllowResource", [url]) + ); + menuitem.setAttribute("crop", "center"); + menuitem.setAttribute("value", url); + menuitem.setAttribute( + "oncommand", + "onUnblockResource(this.value, this.parentNode);" + ); + aEvent.target.appendChild(menuitem); + } +} + +/** + * Handle clicking the "Load <url>" in the blocked content notification bar. + * + * @param {string} aURL - the URL that was unblocked + * @param {Node} aNode - the node holding as value the URLs of the blocked + * resources in the message (space separated). + */ +function onUnblockResource(aURL, aNode) { + try { + loadBlockedImage(aURL); + } catch (e) { + // Couldn't load the referenced image. + console.error(e); + } finally { + // Remove it from the list on success and failure. + let urls = aNode.value.split(" "); + for (let i = 0; i < urls.length; i++) { + if (urls[i] == aURL) { + urls.splice(i, 1); + aNode.value = urls.join(" "); + if (urls.length == 0) { + gComposeNotificationBar.clearBlockedContentNotification(); + } + break; + } + } + } +} + +/** + * Convert the blocked content to a data URL and swap the src to that for the + * elements that were using it. + * + * @param {string} aURL - (necko) URL to unblock + * @param {Bool} aReturnDataURL - return data: URL instead of processing image + * @returns {string} the image as data: URL. + * @throw Error() if reading the data failed + */ +function loadBlockedImage(aURL, aReturnDataURL = false) { + let filename; + if (/^(file|chrome|moz-extension):/i.test(aURL)) { + filename = aURL.substr(aURL.lastIndexOf("/") + 1); + } else { + let fnMatch = /[?&;]filename=([^?&]+)/.exec(aURL); + filename = (fnMatch && fnMatch[1]) || ""; + } + filename = decodeURIComponent(filename); + let uri = Services.io.newURI(aURL); + let contentType; + if (filename) { + try { + contentType = Cc["@mozilla.org/mime;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromURI(uri); + } catch (ex) { + contentType = "image/png"; + } + + if (!contentType.startsWith("image/")) { + // Unsafe to unblock this. It would just be garbage either way. + throw new Error( + "Won't unblock; URL=" + aURL + ", contentType=" + contentType + ); + } + } else { + // Assuming image/png is the best we can do. + contentType = "image/png"; + } + let channel = Services.io.newChannelFromURI( + uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + let inputStream = channel.open(); + let stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + stream.setInputStream(inputStream); + let streamData = ""; + try { + while (stream.available() > 0) { + streamData += stream.readBytes(stream.available()); + } + } catch (e) { + stream.close(); + throw new Error("Couldn't read all data from URL=" + aURL + " (" + e + ")"); + } + stream.close(); + let encoded = btoa(streamData); + let dataURL = + "data:" + + contentType + + (filename ? ";filename=" + encodeURIComponent(filename) : "") + + ";base64," + + encoded; + + if (aReturnDataURL) { + return dataURL; + } + + let editor = GetCurrentEditor(); + for (let img of editor.document.images) { + if (img.src == aURL) { + img.src = dataURL; // Swap to data URL. + img.classList.remove("loading-internal"); + } + } + + return null; +} + +/** + * Update state of encrypted/signed toolbar buttons + */ +function showSendEncryptedAndSigned() { + let encToggle = document.getElementById("button-encryption"); + if (encToggle) { + if (gSendEncrypted) { + encToggle.setAttribute("checked", "true"); + } else { + encToggle.removeAttribute("checked"); + } + } + + let sigToggle = document.getElementById("button-signing"); + if (sigToggle) { + if (gSendSigned) { + sigToggle.setAttribute("checked", "true"); + } else { + sigToggle.removeAttribute("checked"); + } + } + + // Should button remain enabled? Identity might be unable to + // encrypt, but we might have kept button enabled after identity change. + let identityHasConfiguredSMIME = + isSmimeSigningConfigured() || isSmimeEncryptionConfigured(); + let identityHasConfiguredOpenPGP = isPgpConfigured(); + let e2eeNotConfigured = + !identityHasConfiguredOpenPGP && !identityHasConfiguredSMIME; + + if (encToggle) { + encToggle.disabled = e2eeNotConfigured && !gSendEncrypted; + } + if (sigToggle) { + sigToggle.disabled = e2eeNotConfigured; + } +} + +/** + * Look at the current encryption setting, and perform necessary + * automatic adjustments to related settings. + */ +function updateEncryptionDependencies() { + let canSign = gSelectedTechnologyIsPGP + ? isPgpConfigured() + : isSmimeSigningConfigured(); + + if (!canSign) { + gSendSigned = false; + gUserTouchedSendSigned = false; + } else if (!gSendEncrypted) { + if (!gUserTouchedSendSigned) { + gSendSigned = gCurrentIdentity.signMail; + } + } else if (!gUserTouchedSendSigned) { + gSendSigned = true; + } + + // if (!gSendEncrypted) we don't need to change gEncryptSubject, + // it will be ignored anyway. + if (gSendEncrypted) { + if (!gUserTouchedEncryptSubject) { + gEncryptSubject = gCurrentIdentity.protectSubject; + } + } + + if (!gSendSigned) { + if (!gUserTouchedAttachMyPubKey) { + gAttachMyPublicPGPKey = false; + } + } else if (!gUserTouchedAttachMyPubKey) { + gAttachMyPublicPGPKey = gCurrentIdentity.attachPgpKey; + } + + if (!gSendEncrypted) { + clearRecipPillKeyIssues(); + } + + if (gSMFields && !gSelectedTechnologyIsPGP) { + gSMFields.requireEncryptMessage = gSendEncrypted; + gSMFields.signMessage = gSendSigned; + } + + updateAttachMyPubKey(); + + updateEncryptedSubject(); + showSendEncryptedAndSigned(); + + updateEncryptOptionsMenuElements(); + checkEncryptedBccRecipients(); +} + +/** + * Listen to the click events on the compose window. + * + * @param {Event} event - The DOM Event + */ +function composeWindowOnClick(event) { + // Don't deselect pills if the click happened on another pill as the selection + // and focus change is handled by the pill itself. We also ignore clicks on + // toolbarbuttons, menus, and menu items. This will also prevent the unwanted + // deselection when opening the context menu on macOS. + if ( + event.target?.tagName == "mail-address-pill" || + event.target?.tagName == "toolbarbutton" || + event.target?.tagName == "menu" || + event.target?.tagName == "menuitem" + ) { + return; + } + + document.getElementById("recipientsContainer").deselectAllPills(); +} |