Bug 1532528 - Add an API event that can block message sending from the compose window. r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Mon, 13 Jan 2020 10:58:43 +1300
changeset 38060 f1815f2e593f40fedabbf45da7dbab12c9373254
parent 38059 f66abf60532e01c159c5c35e257b93e24e54e89f
child 38061 c3b8a0f83f7147f366fef2f606f33647001bea56
push id398
push userclokep@gmail.com
push dateMon, 09 Mar 2020 19:10:28 +0000
reviewersmkmelin
bugs1532528
Bug 1532528 - Add an API event that can block message sending from the compose window. r=mkmelin
mail/components/compose/content/MsgComposeCommands.js
mail/components/extensions/parent/ext-compose.js
mail/components/extensions/schemas/compose.json
mail/components/extensions/test/browser/browser.ini
mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js
--- a/mail/components/compose/content/MsgComposeCommands.js
+++ b/mail/components/compose/content/MsgComposeCommands.js
@@ -3575,17 +3575,16 @@ function GetCharsetUIString() {
   return "";
 }
 
 // Add-ons can override this to customize the behavior.
 function DoSpellCheckBeforeSend() {
   return Services.prefs.getBoolPref("mail.SpellCheckBeforeSend");
 }
 
-/* eslint-disable complexity */
 /**
  * Handles message sending operations.
  * @param msgType nsIMsgCompDeliverMode of the operation.
  */
 function GenericSendMessage(msgType) {
   var msgCompFields = gMsgCompose.compFields;
 
   Recipients2CompFields(msgCompFields);
@@ -3790,18 +3789,36 @@ function GenericSendMessage(msgType) {
         msgCompFields.forcePlainText = false;
         msgCompFields.useMultipartAlternative = true;
         break;
       default:
         throw new Error(
           "Invalid nsIMsgCompSendFormat action; action=" + action
         );
     }
-  }
-
+
+    let beforeSendEvent = new CustomEvent("beforesend", {
+      cancelable: true,
+      detail: msgType,
+    });
+    window.dispatchEvent(beforeSendEvent);
+    if (beforeSendEvent.defaultPrevented) {
+      return;
+    }
+  }
+
+  CompleteGenericSendMessage(msgType);
+}
+
+/**
+ * Finishes message sending. This should ONLY be called directly from
+ * GenericSendMessage, or if GenericSendMessage was interrupted by your code.
+ * @param msgType nsIMsgCompDeliverMode of the operation.
+ */
+function CompleteGenericSendMessage(msgType) {
   // hook for extra compose pre-processing
   Services.obs.notifyObservers(window, "mail:composeOnSend");
 
   var originalCharset = gMsgCompose.compFields.characterSet;
   // Check if the headers of composing mail can be converted to a mail charset.
   if (
     msgType == Ci.nsIMsgCompDeliverMode.Now ||
     msgType == Ci.nsIMsgCompDeliverMode.Later ||
@@ -3817,17 +3834,17 @@ function GenericSendMessage(msgType) {
     if (
       !gMsgCompose.checkCharsetConversion(getCurrentIdentity(), fallbackCharset)
     ) {
       let disableFallback = Services.prefs.getBoolPref(
         "mailnews.disable_fallback_to_utf8." + originalCharset,
         false
       );
       if (disableFallback) {
-        msgCompFields.needToCheckCharset = false;
+        gMsgCompose.compFields.needToCheckCharset = false;
       } else {
         fallbackCharset.value = "UTF-8";
       }
     }
 
     if (
       fallbackCharset &&
       fallbackCharset.value &&
@@ -3890,17 +3907,16 @@ function GenericSendMessage(msgType) {
   } catch (ex) {
     Cu.reportError("GenericSendMessage FAILED: " + ex);
     ToggleWindowLock(false);
   }
   if (gMsgCompose && originalCharset != gMsgCompose.compFields.characterSet) {
     SetDocumentCharacterSet(gMsgCompose.compFields.characterSet);
   }
 }
-/* eslint-enable complexity */
 
 /**
  * Check if the given address is valid (contains a @).
  *
  * @param aAddress  The address string to check.
  */
 function isValidAddress(aAddress) {
   return aAddress.includes("@", 1) && !aAddress.endsWith("@");
--- a/mail/components/extensions/parent/ext-compose.js
+++ b/mail/components/extensions/parent/ext-compose.js
@@ -85,20 +85,73 @@ async function openComposeWindow(related
       }
     }
   }
 
   params.composeFields = composeFields;
   MailServices.compose.OpenComposeWindowWithParams(null, params);
 }
 
+var composeEventTracker = new (class extends EventEmitter {
+  constructor() {
+    super();
+    this.listenerCount = 0;
+  }
+  on(event, listener) {
+    super.on(event, listener);
+
+    this.listenerCount++;
+    if (this.listenerCount == 1) {
+      windowTracker.addListener("beforesend", this);
+    }
+  }
+  off(event, listener) {
+    super.off(event, listener);
+
+    this.listenerCount--;
+    if (this.listenerCount == 0) {
+      windowTracker.removeListener("beforesend", this);
+    }
+  }
+  async handleEvent(event) {
+    event.preventDefault();
+
+    let msgType = event.detail;
+    let composeWindow = event.target;
+
+    let results = await this.emit("compose-before-send");
+    if (results && results.length > 0) {
+      for (let result of results) {
+        if (result) {
+          if (result.cancel) {
+            return;
+          }
+        }
+      }
+    }
+    composeWindow.CompleteGenericSendMessage(msgType);
+  }
+})();
+
 this.compose = class extends ExtensionAPI {
   getAPI(context) {
     return {
       compose: {
+        onBeforeSend: new EventManager({
+          context,
+          name: "compose.onBeforeSend",
+          register: fire => {
+            let listener = () => fire.async();
+
+            composeEventTracker.on("compose-before-send", listener);
+            return () => {
+              composeEventTracker.off("compose-before-send", listener);
+            };
+          },
+        }).api(),
         async beginNew(composeParams) {
           openComposeWindow(null, Ci.nsIMsgCompType.New, composeParams);
         },
         beginReply(messageId, replyType) {
           let type = Ci.nsIMsgCompType.Reply;
           if (replyType == "replyToList") {
             type = Ci.nsIMsgCompType.ReplyToList;
           } else if (replyType == "replyToAll") {
--- a/mail/components/extensions/schemas/compose.json
+++ b/mail/components/extensions/schemas/compose.json
@@ -63,16 +63,32 @@
           },
           "body": {
             "type": "string",
             "optional": true
           }
         }
       }
     ],
+    "events": [
+      {
+        "name": "onBeforeSend",
+        "type": "function",
+        "description": "Fired when a message is about to be sent from the compose window.",
+        "returns": {
+          "type": "object",
+          "properties": {
+            "cancel": {
+              "type": "boolean",
+              "optional": true
+            }
+          }
+        }
+      }
+    ],
     "functions": [
       {
         "name": "beginNew",
         "type": "function",
         "async": true,
         "parameters": [
           {
             "name": "details",
--- a/mail/components/extensions/test/browser/browser.ini
+++ b/mail/components/extensions/test/browser/browser.ini
@@ -16,16 +16,17 @@ tags = webextensions
 
 [browser_ext_addressBooksUI.js]
 [browser_ext_browserAction.js]
 [browser_ext_commands_execute_browser_action.js]
 [browser_ext_commands_getAll.js]
 [browser_ext_commands_onCommand.js]
 [browser_ext_commands_update.js]
 [browser_ext_compose_begin.js]
+[browser_ext_compose_onBeforeSend.js]
 [browser_ext_composeAction.js]
 [browser_ext_mailTabs.js]
 [browser_ext_menus.js]
 support-files = data/content.html
 [browser_ext_messageDisplay.js]
 [browser_ext_messageDisplayAction.js]
 [browser_ext_quickFilter.js]
 [browser_ext_windows.js]
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js
@@ -0,0 +1,169 @@
+/* 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/. */
+
+var { ExtensionSupport } = ChromeUtils.import(
+  "resource:///modules/ExtensionSupport.jsm"
+);
+
+add_task(async () => {
+  let account = createAccount();
+  addIdentity(account);
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: async () => {
+      function waitForEvent(eventName) {
+        return new Promise(resolve => {
+          let listener = window => {
+            browser.windows[eventName].removeListener(listener);
+            resolve(window);
+          };
+          browser.windows[eventName].addListener(listener);
+        });
+      }
+
+      async function beginSend(sendIsFailure) {
+        await new Promise(resolve => {
+          browser.test.onMessage.addListener(function listener() {
+            browser.test.onMessage.removeListener(listener);
+            resolve();
+          });
+          browser.test.sendMessage("beginSend");
+        });
+        return checkIfSent(sendIsFailure);
+      }
+
+      function checkIfSent(sendIsFailure) {
+        return new Promise(resolve => {
+          browser.test.onMessage.addListener(function listener() {
+            browser.test.onMessage.removeListener(listener);
+            resolve();
+          });
+          browser.test.sendMessage("checkIfSent", sendIsFailure);
+        });
+      }
+
+      // Open a compose window with a message. The message will never send
+      // because we removed the sending function, so we can attempt to send
+      // it over and over.
+
+      let createdWindowPromise = waitForEvent("onCreated");
+      await browser.compose.beginNew({
+        to: ["test@test.invalid"],
+        subject: "Test",
+      });
+      let createdWindow = await createdWindowPromise;
+      browser.test.assertEq("messageCompose", createdWindow.type);
+
+      // Send the message. No listeners exist, so sending should continue.
+
+      await beginSend(false);
+
+      // Add a non-cancelling listener. Sending should continue.
+
+      let listener1 = () => {
+        listener1.fired = true;
+        return {};
+      };
+      browser.compose.onBeforeSend.addListener(listener1);
+      await beginSend(false);
+      browser.test.assertTrue(listener1.fired, "listener1 was fired");
+      browser.compose.onBeforeSend.removeListener(listener1);
+
+      // Add a cancelling listener. Sending should not continue.
+
+      let listener2 = () => {
+        listener2.fired = true;
+        return { cancel: true };
+      };
+      browser.compose.onBeforeSend.addListener(listener2);
+      await beginSend(true);
+      browser.test.assertTrue(listener2.fired, "listener2 was fired");
+      browser.compose.onBeforeSend.removeListener(listener2);
+      await beginSend(false); // Removing the listener worked.
+
+      // Add a listener returning a Promise. Resolve the Promise to unblock.
+      // Sending should continue.
+
+      let listener3 = () => {
+        listener3.fired = true;
+        return new Promise(resolve => {
+          listener3.resolve = resolve;
+        });
+      };
+      browser.compose.onBeforeSend.addListener(listener3);
+      await beginSend(true);
+      browser.test.assertTrue(listener3.fired, "listener3 was fired");
+      listener3.resolve({ cancel: false });
+      await checkIfSent(false);
+      browser.compose.onBeforeSend.removeListener(listener3);
+
+      // Add a listener returning a Promise. Resolve the Promise to cancel.
+      // Sending should not continue.
+
+      let listener4 = () => {
+        listener4.fired = true;
+        return new Promise(resolve => {
+          listener4.resolve = resolve;
+        });
+      };
+      browser.compose.onBeforeSend.addListener(listener4);
+      await beginSend(true);
+      browser.test.assertTrue(listener4.fired, "listener4 was fired");
+      listener4.resolve({ cancel: true });
+      await checkIfSent(true);
+      browser.compose.onBeforeSend.removeListener(listener4);
+      await beginSend(false); // Removing the listener worked.
+
+      // Clean up.
+
+      let removedWindowPromise = waitForEvent("onRemoved");
+      browser.windows.remove(createdWindow.id);
+      await removedWindowPromise;
+
+      browser.test.notifyPass("finished");
+    },
+    manifest: { permissions: ["accountsRead", "messagesRead"] },
+  });
+
+  // We can't allow sending to actually happen, this is a test. For every
+  // compose window that opens, replace the function which does the actual
+  // sending with one that only records when it has been called.
+  let didTryToSendMessage = false;
+  ExtensionSupport.registerWindowListener("xpcshell", {
+    chromeURLs: [
+      "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+    ],
+    onLoadWindow(window) {
+      window.CompleteGenericSendMessage = function(msgType) {
+        didTryToSendMessage = true;
+      };
+    },
+  });
+  registerCleanupFunction(() =>
+    ExtensionSupport.unregisterWindowListener("xpcshell")
+  );
+
+  extension.onMessage("beginSend", async () => {
+    let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+    is(composeWindows.length, 1);
+
+    composeWindows[0].GenericSendMessage(Ci.nsIMsgCompDeliverMode.Now);
+    extension.sendMessage();
+  });
+
+  extension.onMessage("checkIfSent", async sendIsFailure => {
+    if (didTryToSendMessage) {
+      ok(!sendIsFailure, "tried to send a message, but should not have");
+    } else {
+      ok(sendIsFailure, "didn't try to send a message, but should have");
+    }
+
+    didTryToSendMessage = false;
+    extension.sendMessage();
+  });
+
+  await extension.startup();
+  await extension.awaitFinish("finished");
+  await extension.unload();
+});