Bug 1590121 - API to interact with the message being drafted in a compose window. r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Fri, 31 Jan 2020 14:00:45 +1300
changeset 38069 7054e74ce11bab0284518b737a36c0b9535500ee
parent 38068 27929debc910a87a9fbb790b3d73c8bbbb65b62d
child 38070 e5e31792c53c86ffaea936104d37438bdf28d579
push id398
push userclokep@gmail.com
push dateMon, 09 Mar 2020 19:10:28 +0000
reviewersmkmelin
bugs1590121
Bug 1590121 - API to interact with the message being drafted in a 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_begin.js
mail/components/extensions/test/browser/browser_ext_compose_details.js
mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js
mail/components/extensions/test/browser/head.js
--- a/mail/components/compose/content/MsgComposeCommands.js
+++ b/mail/components/compose/content/MsgComposeCommands.js
@@ -3577,30 +3577,66 @@ function GetCharsetUIString() {
 }
 
 // Add-ons can override this to customize the behavior.
 function DoSpellCheckBeforeSend() {
   return Services.prefs.getBoolPref("mail.SpellCheckBeforeSend");
 }
 
 /**
- * Handles message sending operations.
- * @param msgType nsIMsgCompDeliverMode of the operation.
+ * Updates gMsgCompose.compFields to match the UI.
+ *
+ * @returns {nsIMsgCompFields}
  */
-function GenericSendMessage(msgType) {
-  var msgCompFields = gMsgCompose.compFields;
+function GetComposeDetails() {
+  let msgCompFields = gMsgCompose.compFields;
 
   Recipients2CompFields(msgCompFields);
   let addresses = MailServices.headerParser.makeFromDisplayAddress(
     document.getElementById("msgIdentity").value
   );
   msgCompFields.from = MailServices.headerParser.makeMimeHeader(addresses);
-  var subject = document.getElementById("msgSubject").value;
-  msgCompFields.subject = subject;
+  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} [fields.to]
+ * @param {string} [fields.cc]
+ * @param {string} [fields.bcc]
+ * @param {string} [fields.replyTo]
+ * @param {string} [fields.newsgroups]
+ * @param {string} [fields.followupTo]
+ * @param {string} [fields.subject]
+ */
+function SetComposeDetails(newValues) {
+  CompFields2Recipients(newValues);
+  if (newValues.subject !== null) {
+    gMsgCompose.compFields.subject = document.getElementById(
+      "msgSubject"
+    ).value = newValues.subject;
+    SetComposeWindowTitle();
+  }
+}
+
+/**
+ * Handles message sending operations.
+ *
+ * @param {nsIMsgCompDeliverMode} mode - The delivery mode of the operation.
+ */
+function GenericSendMessage(msgType) {
+  let msgCompFields = GetComposeDetails();
+  let subject = msgCompFields.subject;
+
   // 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;
--- a/mail/components/extensions/parent/ext-compose.js
+++ b/mail/components/extensions/parent/ext-compose.js
@@ -3,16 +3,52 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 ChromeUtils.defineModuleGetter(
   this,
   "MailServices",
   "resource:///modules/MailServices.jsm"
 );
 
+async function parseComposeRecipientList(list) {
+  if (Array.isArray(list)) {
+    let recipients = [];
+    for (let recipient of list) {
+      if (typeof recipient == "string") {
+        recipients.push(recipient);
+        continue;
+      }
+      if (!("addressBookCache" in this)) {
+        await extensions.asyncLoadModule("addressBook");
+      }
+      if (recipient.type == "contact") {
+        let contactNode = this.addressBookCache.findContactById(recipient.id);
+        recipients.push(
+          MailServices.headerParser.makeMimeAddress(
+            contactNode.item.displayName,
+            contactNode.item.primaryEmail
+          )
+        );
+      } else {
+        let mailingListNode = this.addressBookCache.findMailingListById(
+          recipient.id
+        );
+        recipients.push(
+          MailServices.headerParser.makeMimeAddress(
+            mailingListNode.item.dirName,
+            mailingListNode.item.description || mailingListNode.item.dirName
+          )
+        );
+      }
+    }
+    return recipients.join(",");
+  }
+  return list;
+}
+
 async function openComposeWindow(relatedMessageId, type, composeParams) {
   // ForwardInline is totally broken, see bug 1513824.
   if (type == Ci.nsIMsgCompType.ForwardInline) {
     let msgHdr = null;
     let msgURI = null;
     let hdrIdentity = null;
     if (relatedMessageId) {
       msgHdr = messageTracker.getMessage(relatedMessageId);
@@ -38,63 +74,68 @@ async function openComposeWindow(related
   ].createInstance(Ci.nsIMsgCompFields);
 
   if (relatedMessageId) {
     let msgHdr = messageTracker.getMessage(relatedMessageId);
     params.originalMsgURI = msgHdr.folder.getUriForMsg(msgHdr);
   }
   params.type = type;
   if (composeParams) {
-    for (let field of ["to", "cc", "bcc"]) {
-      if (Array.isArray(composeParams[field])) {
-        let recipients = [];
-        for (let recipient of composeParams[field]) {
-          if (typeof recipient == "string") {
-            recipients.push(recipient);
-            continue;
-          }
-          if (!("addressBookCache" in this)) {
-            await extensions.asyncLoadModule("addressBook");
-          }
-          if (recipient.type == "contact") {
-            let contactNode = this.addressBookCache.findContactById(
-              recipient.id
-            );
-            recipients.push(
-              MailServices.headerParser.makeMimeAddress(
-                contactNode.item.displayName,
-                contactNode.item.primaryEmail
-              )
-            );
-          } else {
-            let mailingListNode = this.addressBookCache.findMailingListById(
-              recipient.id
-            );
-            recipients.push(
-              MailServices.headerParser.makeMimeAddress(
-                mailingListNode.item.dirName,
-                mailingListNode.item.description || mailingListNode.item.dirName
-              )
-            );
-          }
-        }
-        composeFields[field] = recipients.join(",");
+    for (let field of ["to", "cc", "bcc", "replyTo", "followupTo"]) {
+      composeFields[field] = await parseComposeRecipientList(
+        composeParams[field]
+      );
+    }
+    if (composeParams.newsgroups) {
+      if (Array.isArray(composeParams.newsgroups)) {
+        composeFields.newsgroups = composeParams.newsgroups.join(",");
+      } else {
+        composeFields.newsgroups = composeParams.newsgroups;
       }
     }
-    for (let field of ["replyTo", "subject", "body"]) {
+    for (let field of ["subject", "body"]) {
       if (composeParams[field]) {
         composeFields[field] = composeParams[field];
       }
     }
   }
 
   params.composeFields = composeFields;
   MailServices.compose.OpenComposeWindowWithParams(null, params);
 }
 
+function getComposeState(composeWindow) {
+  let composeFields = composeWindow.GetComposeDetails();
+
+  let details = {
+    to: composeFields.splitRecipients(composeFields.to, false),
+    cc: composeFields.splitRecipients(composeFields.cc, false),
+    bcc: composeFields.splitRecipients(composeFields.bcc, false),
+    replyTo: composeFields.splitRecipients(composeFields.replyTo, false),
+    followupTo: composeFields.splitRecipients(composeFields.followupTo, false),
+    newsgroups: composeFields.newsgroups
+      ? composeFields.newsgroups.split(",")
+      : [],
+    subject: composeFields.subject,
+  };
+  return details;
+}
+
+async function setComposeState(composeWindow, details) {
+  for (let field of ["to", "cc", "bcc", "replyTo", "followupTo"]) {
+    if (field in details) {
+      details[field] = await parseComposeRecipientList(details[field]);
+    }
+  }
+  if (Array.isArray(details.newsgroups)) {
+    details.newsgroups = details.newsgroups.join(",");
+  }
+  composeWindow.SetComposeDetails(details);
+}
+
 var composeEventTracker = new (class extends EventEmitter {
   constructor() {
     super();
     this.listenerCount = 0;
   }
   on(event, listener) {
     super.on(event, listener);
 
@@ -112,39 +153,64 @@ var composeEventTracker = new (class ext
     }
   }
   async handleEvent(event) {
     event.preventDefault();
 
     let msgType = event.detail;
     let composeWindow = event.target;
 
-    let results = await this.emit("compose-before-send");
+    let results = await this.emit(
+      "compose-before-send",
+      getComposeState(composeWindow)
+    );
     if (results && results.length > 0) {
       for (let result of results) {
         if (result) {
           if (result.cancel) {
             return;
           }
+          if (result.details) {
+            setComposeState(composeWindow, result.details);
+          }
         }
       }
     }
     composeWindow.CompleteGenericSendMessage(msgType);
   }
 })();
 
 this.compose = class extends ExtensionAPI {
   getAPI(context) {
+    function getComposeTab(tabId) {
+      let tab = tabManager.get(tabId);
+      if (tab instanceof TabmailTab) {
+        throw new ExtensionError("Not a valid compose window");
+      }
+      let location = tab.nativeTab.location.href;
+      if (
+        location !=
+        "chrome://messenger/content/messengercompose/messengercompose.xhtml"
+      ) {
+        throw new ExtensionError(`Not a valid compose window: ${location}`);
+      }
+      return tab;
+    }
+
+    let { extension } = context;
+    let { tabManager } = extension;
     return {
       compose: {
         onBeforeSend: new EventManager({
           context,
           name: "compose.onBeforeSend",
           register: fire => {
-            let listener = () => fire.async();
+            let listener = (event, details) => {
+              return fire.async(details);
+            };
 
             composeEventTracker.on("compose-before-send", listener);
             return () => {
               composeEventTracker.off("compose-before-send", listener);
             };
           },
         }).api(),
         async beginNew(composeParams) {
@@ -166,12 +232,20 @@ this.compose = class extends ExtensionAP
           } else if (
             forwardType === null &&
             Services.prefs.getIntPref("mail.forward_message_mode") == 0
           ) {
             type = Ci.nsIMsgCompType.ForwardAsAttachment;
           }
           openComposeWindow(messageId, type, composeParams);
         },
+        getComposeDetails(tabId) {
+          let tab = getComposeTab(tabId);
+          return getComposeState(tab.nativeTab);
+        },
+        setComposeDetails(tabId, details) {
+          let tab = getComposeTab(tabId);
+          return setComposeState(tab.nativeTab, details);
+        },
       },
     };
   }
 };
--- a/mail/components/extensions/schemas/compose.json
+++ b/mail/components/extensions/schemas/compose.json
@@ -24,42 +24,71 @@
                   "mailingList"
                 ]
               }
             }
           }
         ]
       },
       {
-        "id": "ComposeParams",
-        "type": "object",
-        "properties": {
-          "to": {
+        "id": "ComposeRecipientList",
+        "choices": [
+          {
+            "type": "string",
+            "description": "A name and email address in the format \"Name <email@example.com>\", or just an email address."
+          },
+          {
             "type": "array",
             "items": {
               "$ref": "ComposeRecipient"
             },
             "optional": true
+          }
+        ]
+      },
+      {
+        "id": "ComposeDetails",
+        "type": "object",
+        "description": "Used by various functions to represent the state of a message being composed. Note that functions using this type may have a partial implementation.",
+        "properties": {
+          "to": {
+            "$ref": "ComposeRecipientList",
+            "optional": true
           },
           "cc": {
-            "type": "array",
-            "items": {
-              "$ref": "ComposeRecipient"
-            },
+            "$ref": "ComposeRecipientList",
             "optional": true
           },
           "bcc": {
-            "type": "array",
-            "items": {
-              "$ref": "ComposeRecipient"
-            },
+            "$ref": "ComposeRecipientList",
             "optional": true
           },
           "replyTo": {
-            "type": "string",
+            "$ref": "ComposeRecipientList",
+            "optional": true
+          },
+          "followupTo": {
+            "$ref": "ComposeRecipientList",
+            "optional": true
+          },
+          "newsgroups": {
+            "choices": [
+              {
+                "type": "string",
+                "description": "A newsgroup name."
+              },
+              {
+                "type": "array",
+                "items": {
+                  "type": "string"
+                },
+                "description": "An array of newsgroup names.",
+                "optional": true
+              }
+            ],
             "optional": true
           },
           "subject": {
             "type": "string",
             "optional": true
           },
           "body": {
             "type": "string",
@@ -68,36 +97,49 @@
         }
       }
     ],
     "events": [
       {
         "name": "onBeforeSend",
         "type": "function",
         "description": "Fired when a message is about to be sent from the compose window.",
+        "parameters": [
+          {
+            "name": "details",
+            "$ref": "ComposeDetails",
+            "description": "The current state of the compose window. This is functionally the same as the :ref:`compose.getComposeDetails` function."
+          }
+        ],
         "returns": {
           "type": "object",
           "properties": {
             "cancel": {
               "type": "boolean",
-              "optional": true
+              "optional": true,
+              "description": "Cancels the send."
+            },
+            "details": {
+              "$ref": "ComposeDetails",
+              "optional": true,
+              "description": "Updates the compose window. See the :ref:`compose.setComposeDetails` function for more information."
             }
           }
         }
       }
     ],
     "functions": [
       {
         "name": "beginNew",
         "type": "function",
         "async": true,
         "parameters": [
           {
             "name": "details",
-            "$ref": "ComposeParams",
+            "$ref": "ComposeDetails",
             "optional": true
           }
         ]
       },
       {
         "name": "beginReply",
         "type": "function",
         "async": true,
@@ -137,16 +179,46 @@
             "enum": [
               "forwardInline",
               "forwardAsAttachment"
             ],
             "optional": true
           },
           {
             "name": "details",
-            "$ref": "ComposeParams",
+            "$ref": "ComposeDetails",
             "optional": true
           }
         ]
+      },
+      {
+        "name": "getComposeDetails",
+        "type": "function",
+        "async": true,
+        "description": "Fetches the current state of a compose window. Currently only a limited amount of information is available, more will be added in later versions.",
+        "parameters": [
+          {
+            "type": "integer",
+            "name": "tabId",
+            "minimum": 0
+          }
+        ]
+      },
+      {
+        "name": "setComposeDetails",
+        "type": "function",
+        "async": true,
+        "description": "Updates the compose window. Specify only fields that you want to change. Currently only the to/cc/bcc/replyTo/followupTo/newsgroups fields and the subject are implemented.",
+        "parameters": [
+          {
+            "type": "integer",
+            "name": "tabId",
+            "minimum": 0
+          },
+          {
+            "name": "details",
+            "$ref": "ComposeDetails"
+          }
+        ]
       }
     ]
   }
 ]
--- 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_details.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]
--- a/mail/components/extensions/test/browser/browser_ext_compose_begin.js
+++ b/mail/components/extensions/test/browser/browser_ext_compose_begin.js
@@ -56,122 +56,114 @@ add_task(async () => {
         }),
         john: await browser.contacts.create(addressBook, {
           DisplayName: "John Watson",
           PrimaryEmail: "john@bakerstreet.invalid",
         }),
       };
       let list = await browser.mailingLists.create(addressBook, {
         name: "Holmes and Watson",
-        description: "Tenants at 221B",
+        description: "Tenants221B",
       });
       await browser.mailingLists.addMember(list, contacts.sherlock);
       await browser.mailingLists.addMember(list, contacts.john);
 
       let createdWindowPromise;
 
       // Start a new message.
 
       createdWindowPromise = waitForEvent("onCreated");
       await browser.compose.beginNew();
       await checkWindow({});
 
-      // Start a new message, with a subject and recipients.
+      // Start a new message, with a subject and recipients as strings.
+
+      createdWindowPromise = waitForEvent("onCreated");
+      await browser.compose.beginNew({
+        to: "Sherlock Holmes <sherlock@bakerstreet.invalid>",
+        cc: "John Watson <john@bakerstreet.invalid>",
+        subject: "Did you miss me?",
+      });
+      await checkWindow({
+        to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"],
+        cc: ["John Watson <john@bakerstreet.invalid>"],
+        subject: "Did you miss me?",
+      });
+
+      // Start a new message, with a subject and recipients as string arrays.
 
       createdWindowPromise = waitForEvent("onCreated");
       await browser.compose.beginNew({
         to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"],
         cc: ["John Watson <john@bakerstreet.invalid>"],
         subject: "Did you miss me?",
       });
       await checkWindow({
-        to: "Sherlock Holmes <sherlock@bakerstreet.invalid>",
-        cc: "John Watson <john@bakerstreet.invalid>",
+        to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"],
+        cc: ["John Watson <john@bakerstreet.invalid>"],
         subject: "Did you miss me?",
       });
 
       // Start a new message, with a subject and recipients as contacts.
 
       createdWindowPromise = waitForEvent("onCreated");
       await browser.compose.beginNew({
         to: [{ id: contacts.sherlock, type: "contact" }],
         cc: [{ id: contacts.john, type: "contact" }],
         subject: "Did you miss me?",
       });
       await checkWindow({
-        to: "Sherlock Holmes <sherlock@bakerstreet.invalid>",
-        cc: "John Watson <john@bakerstreet.invalid>",
+        to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"],
+        cc: ["John Watson <john@bakerstreet.invalid>"],
         subject: "Did you miss me?",
       });
 
       // Start a new message, with a subject and recipients as a mailing list.
 
       createdWindowPromise = waitForEvent("onCreated");
       await browser.compose.beginNew({
         to: [{ id: list, type: "mailingList" }],
         subject: "Did you miss me?",
       });
       await checkWindow({
-        to: 'Holmes and Watson <"Tenants at 221B">',
+        to: ["Holmes and Watson <Tenants221B>"],
         subject: "Did you miss me?",
       });
 
       // Reply to a message.
 
       createdWindowPromise = waitForEvent("onCreated");
       await browser.compose.beginReply(messages[0].id);
       await checkWindow({
-        to: messages[0].author.replace(/"/g, ""),
+        to: [messages[0].author.replace(/"/g, "")],
         subject: `Re: ${messages[0].subject}`,
       });
 
       // Forward a message.
 
       createdWindowPromise = waitForEvent("onCreated");
       await browser.compose.beginForward(
         messages[1].id,
         "forwardAsAttachment",
         {
           to: ["Mycroft Holmes <mycroft@bakerstreet.invalid>"],
         }
       );
       await checkWindow({
-        to: "Mycroft Holmes <mycroft@bakerstreet.invalid>",
+        to: ["Mycroft Holmes <mycroft@bakerstreet.invalid>"],
         subject: `Fwd: ${messages[1].subject}`,
       });
 
       await browser.addressBooks.delete(addressBook);
       browser.test.notifyPass("finished");
     },
     manifest: { permissions: ["accountsRead", "addressBooks", "messagesRead"] },
   });
 
   extension.onMessage("checkWindow", async expected => {
-    let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
-    is(composeWindows.length, 1);
-    await new Promise(resolve => composeWindows[0].setTimeout(resolve));
-
-    let fields = Cc[
-      "@mozilla.org/messengercompose/composefields;1"
-    ].createInstance(Ci.nsIMsgCompFields);
-    composeWindows[0].Recipients2CompFields(fields);
-    for (let field of ["to", "cc", "bcc", "replyTo"]) {
-      if (field in expected) {
-        is(fields[field], expected[field], `${field} is correct`);
-      } else {
-        is(fields[field], "", `${field} is empty`);
-      }
-    }
-
-    let subject = composeWindows[0].document.getElementById("msgSubject").value;
-    if ("subject" in expected) {
-      is(subject, expected.subject, "subject is correct");
-    } else {
-      is(subject, "", "subject is empty");
-    }
-
+    await checkComposeHeaders(expected);
     extension.sendMessage();
   });
 
   await extension.startup();
   await extension.awaitFinish("finished");
   await extension.unload();
 });
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_compose_details.js
@@ -0,0 +1,253 @@
+/* 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/. */
+
+add_task(async () => {
+  addIdentity(createAccount());
+
+  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 checkWindow(expected) {
+        let state = await browser.compose.getComposeDetails(createdTab.id);
+        for (let field of [
+          "to",
+          "cc",
+          "bcc",
+          "replyTo",
+          "followupTo",
+          "newsgroups",
+        ]) {
+          if (field in expected) {
+            browser.test.assertEq(
+              expected[field].length,
+              state[field].length,
+              `${field} has the right number of values`
+            );
+            for (let i = 0; i < expected[field].length; i++) {
+              browser.test.assertEq(expected[field][i], state[field][i]);
+            }
+          } else {
+            browser.test.assertEq(0, state[field].length, `${field} is empty`);
+          }
+        }
+        if (expected.subject) {
+          browser.test.assertEq(
+            expected.subject,
+            state.subject,
+            "subject is correct"
+          );
+        } else {
+          browser.test.assertTrue(!state.subject, "subject is empty");
+        }
+
+        await new Promise(resolve => {
+          browser.test.onMessage.addListener(function listener() {
+            browser.test.onMessage.removeListener(listener);
+            resolve();
+          });
+          browser.test.sendMessage("checkWindow", expected);
+        });
+      }
+
+      let addressBook = await browser.addressBooks.create({
+        name: "Baker Street",
+      });
+      let contacts = {
+        sherlock: await browser.contacts.create(addressBook, {
+          DisplayName: "Sherlock Holmes",
+          PrimaryEmail: "sherlock@bakerstreet.invalid",
+        }),
+        john: await browser.contacts.create(addressBook, {
+          DisplayName: "John Watson",
+          PrimaryEmail: "john@bakerstreet.invalid",
+        }),
+      };
+      let list = await browser.mailingLists.create(addressBook, {
+        name: "Holmes and Watson",
+        description: "Tenants221B",
+      });
+      await browser.mailingLists.addMember(list, contacts.sherlock);
+      await browser.mailingLists.addMember(list, contacts.john);
+
+      // Start a new message.
+
+      let createdWindowPromise = waitForEvent("onCreated");
+      await browser.compose.beginNew();
+      let createdWindow = await createdWindowPromise;
+      let [createdTab] = await browser.tabs.query({
+        windowId: createdWindow.id,
+      });
+
+      await checkWindow({});
+
+      let tests = [
+        {
+          // Single input, string.
+          input: { to: "Greg Lestrade <greg@bakerstreet.invalid>" },
+          expected: { to: ["Greg Lestrade <greg@bakerstreet.invalid>"] },
+        },
+        {
+          // Empty string. Done here so we have something to clear.
+          input: { to: "" },
+          expected: {},
+        },
+        {
+          // Single input, array with string.
+          input: { to: ["John Watson <john@bakerstreet.invalid>"] },
+          expected: { to: ["John Watson <john@bakerstreet.invalid>"] },
+        },
+        {
+          // Empty array. Done here so we have something to clear.
+          input: { to: [] },
+          expected: {},
+        },
+        {
+          // Single input, array with contact.
+          input: { to: [{ id: contacts.sherlock, type: "contact" }] },
+          expected: { to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"] },
+        },
+        {
+          // Null input. This should not clear the field.
+          input: { to: null },
+          expected: { to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"] },
+        },
+        {
+          // Single input, array with mailing list.
+          input: { to: [{ id: list, type: "mailingList" }] },
+          expected: { to: ["Holmes and Watson <Tenants221B>"] },
+        },
+        {
+          // Multiple inputs, string.
+          input: {
+            to:
+              "Molly Hooper <molly@bakerstreet.invalid>, Mrs Hudson <mrs_hudson@bakerstreet.invalid>",
+          },
+          expected: {
+            to: [
+              "Molly Hooper <molly@bakerstreet.invalid>",
+              "Mrs Hudson <mrs_hudson@bakerstreet.invalid>",
+            ],
+          },
+        },
+        {
+          // Multiple inputs, array with strings.
+          input: {
+            to: [
+              "Irene Adler <irene@bakerstreet.invalid>",
+              "Mary Watson <mary@bakerstreet.invalid>",
+            ],
+          },
+          expected: {
+            to: [
+              "Irene Adler <irene@bakerstreet.invalid>",
+              "Mary Watson <mary@bakerstreet.invalid>",
+            ],
+          },
+        },
+        {
+          // Multiple inputs, mixed.
+          input: {
+            to: [
+              { id: contacts.sherlock, type: "contact" },
+              "Mycroft Holmes <mycroft@bakerstreet.invalid>",
+            ],
+          },
+          expected: {
+            to: [
+              "Sherlock Holmes <sherlock@bakerstreet.invalid>",
+              "Mycroft Holmes <mycroft@bakerstreet.invalid>",
+            ],
+          },
+        },
+        {
+          // A newsgroup, string.
+          input: {
+            to: "",
+            newsgroups: "invalid.fake.newsgroup",
+          },
+          expected: {
+            newsgroups: ["invalid.fake.newsgroup"],
+          },
+        },
+        {
+          // Multiple newsgroups, string.
+          input: {
+            newsgroups: "invalid.fake.newsgroup, invalid.real.newsgroup",
+          },
+          expected: {
+            newsgroups: ["invalid.fake.newsgroup", "invalid.real.newsgroup"],
+          },
+        },
+        {
+          // A newsgroup, array with string.
+          input: {
+            newsgroups: ["invalid.real.newsgroup"],
+          },
+          expected: {
+            newsgroups: ["invalid.real.newsgroup"],
+          },
+        },
+        {
+          // Multiple newsgroup, array with string.
+          input: {
+            newsgroups: ["invalid.fake.newsgroup", "invalid.real.newsgroup"],
+          },
+          expected: {
+            newsgroups: ["invalid.fake.newsgroup", "invalid.real.newsgroup"],
+          },
+        },
+        {
+          // Change the subject.
+          input: {
+            newsgroups: "",
+            subject: "This is a test",
+          },
+          expected: {
+            subject: "This is a test",
+          },
+        },
+        {
+          // Clear the subject.
+          input: {
+            subject: "",
+          },
+          expected: {},
+        },
+      ];
+      for (let test of tests) {
+        browser.test.log(`Checking input: ${JSON.stringify(test.input)}`);
+        await browser.compose.setComposeDetails(createdTab.id, test.input);
+        await checkWindow(test.expected);
+      }
+
+      // Clean up.
+
+      let removedWindowPromise = waitForEvent("onRemoved");
+      browser.windows.remove(createdWindow.id);
+      await removedWindowPromise;
+
+      await browser.addressBooks.delete(addressBook);
+      browser.test.notifyPass("finished");
+    },
+    manifest: { permissions: ["accountsRead", "addressBooks", "messagesRead"] },
+  });
+
+  extension.onMessage("checkWindow", async expected => {
+    await checkComposeHeaders(expected);
+    extension.sendMessage();
+  });
+
+  await extension.startup();
+  await extension.awaitFinish("finished");
+  await extension.unload();
+});
--- a/mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js
+++ b/mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js
@@ -38,28 +38,40 @@ add_task(async () => {
           browser.test.onMessage.addListener(function listener() {
             browser.test.onMessage.removeListener(listener);
             resolve();
           });
           browser.test.sendMessage("checkIfSent", sendIsFailure);
         });
       }
 
+      function checkWindow(expected) {
+        return new Promise(resolve => {
+          browser.test.onMessage.addListener(function listener() {
+            browser.test.onMessage.removeListener(listener);
+            resolve();
+          });
+          browser.test.sendMessage("checkWindow", expected);
+        });
+      }
+
       // 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);
 
+      await checkWindow({ to: ["test@test.invalid"], subject: "Test" });
+
       // 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;
@@ -110,16 +122,53 @@ add_task(async () => {
       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.
 
+      // Add a listener that changes the subject. Sending should continue and
+      // the subject should change. This is largely the same code as tested in
+      // browser_ext_compose_details.js, so just test that the change happens.
+
+      // First check that the original headers are unmodified.
+      await checkWindow({ to: ["test@test.invalid"], subject: "Test" });
+
+      let listener5 = details => {
+        listener5.fired = true;
+        listener5.details = details;
+        return {
+          details: {
+            subject: "Changed by listener5",
+          },
+        };
+      };
+      browser.compose.onBeforeSend.addListener(listener5);
+      await beginSend(false);
+      browser.test.assertTrue(listener5.fired, "listener5 was fired");
+      browser.test.assertEq(1, listener5.details.to.length);
+      browser.test.assertEq(
+        "test@test.invalid",
+        listener5.details.to[0],
+        "listener5 recipient correct"
+      );
+      browser.test.assertEq(
+        "Test",
+        listener5.details.subject,
+        "listener5 subject correct"
+      );
+      // First check that the subject has changed but recipient hasn't.
+      await checkWindow({
+        to: ["test@test.invalid"],
+        subject: "Changed by listener5",
+      });
+      browser.compose.onBeforeSend.removeListener(listener5);
+
       // Clean up.
 
       let removedWindowPromise = waitForEvent("onRemoved");
       browser.windows.remove(createdWindow.id);
       await removedWindowPromise;
 
       browser.test.notifyPass("finished");
     },
@@ -158,12 +207,17 @@ add_task(async () => {
     } else {
       ok(sendIsFailure, "didn't try to send a message, but should have");
     }
 
     didTryToSendMessage = false;
     extension.sendMessage();
   });
 
+  extension.onMessage("checkWindow", async expected => {
+    await checkComposeHeaders(expected);
+    extension.sendMessage();
+  });
+
   await extension.startup();
   await extension.awaitFinish("finished");
   await extension.unload();
 });
--- a/mail/components/extensions/test/browser/head.js
+++ b/mail/components/extensions/test/browser/head.js
@@ -165,8 +165,61 @@ async function openNewMailWindow(options
   );
   await Promise.all([
     BrowserTestUtils.waitForEvent(win, "focus", true),
     BrowserTestUtils.waitForEvent(win, "activate", true),
   ]);
 
   return win;
 }
+
+/**
+ * Check the headers of an open compose window against expected values.
+ *
+ * @param {Object} expected - A dictionary of expected headers.
+ *    Omit headers that should have no value.
+ * @param {string[]} [fields.to]
+ * @param {string[]} [fields.cc]
+ * @param {string[]} [fields.bcc]
+ * @param {string[]} [fields.replyTo]
+ * @param {string[]} [fields.followupTo]
+ * @param {string[]} [fields.newsgroups]
+ * @param {string} [fields.subject]
+ */
+async function checkComposeHeaders(expected) {
+  let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+  is(composeWindows.length, 1);
+  let composeDocument = composeWindows[0].document;
+  await new Promise(resolve => composeWindows[0].setTimeout(resolve));
+
+  let checkField = (fieldName, elementId) => {
+    let pills = composeDocument
+      .getElementById(elementId)
+      .getElementsByTagName("mail-address-pill");
+
+    if (fieldName in expected) {
+      is(
+        pills.length,
+        expected[fieldName].length,
+        `${fieldName} has the right number of pills`
+      );
+      for (let i = 0; i < expected[fieldName].length; i++) {
+        is(pills[i].label, expected[fieldName][i]);
+      }
+    } else {
+      is(pills.length, 0, `${fieldName} is empty`);
+    }
+  };
+
+  checkField("to", "addressRowTo");
+  checkField("cc", "addressRowCc");
+  checkField("bcc", "addressRowBcc");
+  checkField("replyTo", "addressRowReply");
+  checkField("followupTo", "addressRowFollowup");
+  checkField("newsgroups", "addressRowNewsgroups");
+
+  let subject = composeDocument.getElementById("msgSubject").value;
+  if ("subject" in expected) {
+    is(subject, expected.subject, "subject is correct");
+  } else {
+    is(subject, "", "subject is empty");
+  }
+}