summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/extensions
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/components/extensions
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/mail/components/extensions')
-rw-r--r--comm/mail/components/extensions/ExtensionBrowsingData.sys.mjs78
-rw-r--r--comm/mail/components/extensions/ExtensionPopups.sys.mjs635
-rw-r--r--comm/mail/components/extensions/ExtensionToolbarButtons.jsm949
-rw-r--r--comm/mail/components/extensions/MailExtensionShortcuts.jsm87
-rw-r--r--comm/mail/components/extensions/child/.eslintrc.js15
-rw-r--r--comm/mail/components/extensions/child/ext-extensionScripts.js83
-rw-r--r--comm/mail/components/extensions/child/ext-mail.js28
-rw-r--r--comm/mail/components/extensions/child/ext-menus.js290
-rw-r--r--comm/mail/components/extensions/child/ext-tabs.js23
-rw-r--r--comm/mail/components/extensions/ext-mail.json171
-rw-r--r--comm/mail/components/extensions/extension.svg19
-rw-r--r--comm/mail/components/extensions/extensionPopup.js557
-rw-r--r--comm/mail/components/extensions/extensionPopup.xhtml92
-rw-r--r--comm/mail/components/extensions/extensions-mail.manifest4
-rw-r--r--comm/mail/components/extensions/jar.mn68
-rw-r--r--comm/mail/components/extensions/moz.build27
-rw-r--r--comm/mail/components/extensions/parent/.eslintrc.js81
-rw-r--r--comm/mail/components/extensions/parent/ext-accounts.js283
-rw-r--r--comm/mail/components/extensions/parent/ext-addressBook.js1587
-rw-r--r--comm/mail/components/extensions/parent/ext-browserAction.js329
-rw-r--r--comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js365
-rw-r--r--comm/mail/components/extensions/parent/ext-cloudFile.js804
-rw-r--r--comm/mail/components/extensions/parent/ext-commands.js103
-rw-r--r--comm/mail/components/extensions/parent/ext-compose.js1703
-rw-r--r--comm/mail/components/extensions/parent/ext-composeAction.js154
-rw-r--r--comm/mail/components/extensions/parent/ext-extensionScripts.js185
-rw-r--r--comm/mail/components/extensions/parent/ext-folders.js675
-rw-r--r--comm/mail/components/extensions/parent/ext-identities.js360
-rw-r--r--comm/mail/components/extensions/parent/ext-mail.js2883
-rw-r--r--comm/mail/components/extensions/parent/ext-mailTabs.js485
-rw-r--r--comm/mail/components/extensions/parent/ext-menus.js1544
-rw-r--r--comm/mail/components/extensions/parent/ext-messageDisplay.js348
-rw-r--r--comm/mail/components/extensions/parent/ext-messageDisplayAction.js251
-rw-r--r--comm/mail/components/extensions/parent/ext-messages.js1563
-rw-r--r--comm/mail/components/extensions/parent/ext-sessions.js62
-rw-r--r--comm/mail/components/extensions/parent/ext-spaces.js364
-rw-r--r--comm/mail/components/extensions/parent/ext-spacesToolbar.js308
-rw-r--r--comm/mail/components/extensions/parent/ext-tabs.js822
-rw-r--r--comm/mail/components/extensions/parent/ext-theme.js543
-rw-r--r--comm/mail/components/extensions/parent/ext-windows.js555
-rw-r--r--comm/mail/components/extensions/processScript.js71
-rw-r--r--comm/mail/components/extensions/schemas/LICENSE27
-rw-r--r--comm/mail/components/extensions/schemas/accounts.json235
-rw-r--r--comm/mail/components/extensions/schemas/addressBook.json977
-rw-r--r--comm/mail/components/extensions/schemas/browserAction.json848
-rw-r--r--comm/mail/components/extensions/schemas/chrome_settings_overrides.json194
-rw-r--r--comm/mail/components/extensions/schemas/cloudFile.json501
-rw-r--r--comm/mail/components/extensions/schemas/commands.json279
-rw-r--r--comm/mail/components/extensions/schemas/compose.json937
-rw-r--r--comm/mail/components/extensions/schemas/composeAction.json722
-rw-r--r--comm/mail/components/extensions/schemas/extensionScripts.json133
-rw-r--r--comm/mail/components/extensions/schemas/folders.json408
-rw-r--r--comm/mail/components/extensions/schemas/identities.json277
-rw-r--r--comm/mail/components/extensions/schemas/mailTabs.json428
-rw-r--r--comm/mail/components/extensions/schemas/menus.json757
-rw-r--r--comm/mail/components/extensions/schemas/menus_child.json31
-rw-r--r--comm/mail/components/extensions/schemas/messageDisplay.json159
-rw-r--r--comm/mail/components/extensions/schemas/messageDisplayAction.json721
-rw-r--r--comm/mail/components/extensions/schemas/messages.json933
-rw-r--r--comm/mail/components/extensions/schemas/sessions.json76
-rw-r--r--comm/mail/components/extensions/schemas/spaces.json290
-rw-r--r--comm/mail/components/extensions/schemas/spacesToolbar.json175
-rw-r--r--comm/mail/components/extensions/schemas/tabs.json989
-rw-r--r--comm/mail/components/extensions/schemas/theme.json542
-rw-r--r--comm/mail/components/extensions/schemas/windows.json511
-rw-r--r--comm/mail/components/extensions/test/AppUiTestDelegate.sys.mjs6
-rw-r--r--comm/mail/components/extensions/test/browser/.eslintrc.js7
-rw-r--r--comm/mail/components/extensions/test/browser/browser.ini135
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_addressBooksUI.js116
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_browserAction_customized.js29
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_browserAction_not_customized.js17
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click.js399
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click_mv3_event_pages.js82
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_browserAction_properties.js348
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_bug1812530.js200
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_clickHandler.js614
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_cloudFile.js1444
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js226
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_execute_compose_action.js138
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_execute_message_display_action.js168
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_getAll.js142
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_onChanged.js59
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand.js577
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand_bug1845236.js74
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_commands_update.js357
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_composeAction.js268
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click.js266
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click_mv3_event_pages.js52
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_composeAction_properties.js125
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_composeScripts.js531
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_attachments.js2268
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_attachments.js116
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_body.js397
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_bug1691254.js141
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_forward.js339
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_headers.js178
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_identity.js102
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_new.js136
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_begin_reply.js146
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_bug1692439.js160
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_bug1804796.js80
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_details.js725
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_details_body.js469
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_details_headers.js727
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_dictionaries.js214
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js1010
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_saveDraft.js416
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_saveTemplate.js432
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_compose_sendMessage.js733
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_contentScripts.js438
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_content_handler.js334
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_content_tabs_navigation_menu.js250
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_mailTabs.js898
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_mailTabs_mv3.js162
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_action.js424
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_compose.js179
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_content.js253
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_folder_pane.js97
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_message_panes.js180
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_tabs.js77
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_context_tools_main_menu.js156
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_message_one_attachment.js395
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_message_two_attachments.js397
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_popup_action.js405
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu.js582
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js375
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay.js1016
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction.js337
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click.js294
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click_mv3_event_pages.js113
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_properties.js184
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplayScripts.js636
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1827032.js38
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1828056.js212
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_file.js221
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_headerMessageId.js221
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_messageId.js221
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_message_external.js427
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_messages_open_attachment.js107
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_quickFilter.js132
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_sessions.js90
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_spaces.js1047
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_spacesToolbar.js755
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_content.js336
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js275
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_events.js591
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_move.js306
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_onCreated_bug1817872.js226
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_query.js113
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tabs_update_reload.js578
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_themes_onUpdated.js150
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_tooltip_in_extension_pages.js685
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows.js439
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows_bug1732559.js94
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows_create_normal_cookieStoreId.js116
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows_create_popup_cookieStoreId.js255
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows_events.js405
-rw-r--r--comm/mail/components/extensions/test/browser/browser_ext_windows_types.js121
-rw-r--r--comm/mail/components/extensions/test/browser/data/cloudFile1.txt1
-rw-r--r--comm/mail/components/extensions/test/browser/data/cloudFile2.txt1
-rw-r--r--comm/mail/components/extensions/test/browser/data/content.html12
-rw-r--r--comm/mail/components/extensions/test/browser/data/content_body.html1
-rw-r--r--comm/mail/components/extensions/test/browser/data/linktest.html11
-rw-r--r--comm/mail/components/extensions/test/browser/data/tb-logo.pngbin0 -> 6462 bytes
-rw-r--r--comm/mail/components/extensions/test/browser/head.js1533
-rw-r--r--comm/mail/components/extensions/test/browser/head_menus.js733
-rw-r--r--comm/mail/components/extensions/test/browser/messages/attachedMessageSample.eml186
-rw-r--r--comm/mail/components/extensions/test/browser/messages/messageWithLink.eml26
-rw-r--r--comm/mail/components/extensions/test/browser/test_browserAction.js845
-rw-r--r--comm/mail/components/extensions/test/xpcshell/.eslintrc.js13
-rw-r--r--comm/mail/components/extensions/test/xpcshell/data/utils.js124
-rw-r--r--comm/mail/components/extensions/test/xpcshell/head-imap.js12
-rw-r--r--comm/mail/components/extensions/test/xpcshell/head-nntp.js12
-rw-r--r--comm/mail/components/extensions/test/xpcshell/head.js298
-rw-r--r--comm/mail/components/extensions/test/xpcshell/images/redPixel.pngbin0 -> 119 bytes
-rw-r--r--comm/mail/components/extensions/test/xpcshell/images/whitePixel.pngbin0 -> 69 bytes
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/alternative.eml23
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/attachedMessageWithMissingHeaders.eml35
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/nestedMessages.eml127
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample01.eml11
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample02.eml121
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample03.eml43
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample04.eml10
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample05.eml10
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample06.eml8
-rw-r--r--comm/mail/components/extensions/test/xpcshell/messages/sample07.eml24
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_accounts.js1089
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_accounts_mv3_event_pages.js220
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_addressBook.js2043
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_provider.js139
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_quickSearch.js238
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_readonly.js148
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_remote.js101
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_alias.js123
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_browserAction_unifiedtoolbar_restart.js350
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_experiments.js279
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_folders.js560
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_folders_mv3_event_pages.js374
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_identities_mv3_event_pages.js146
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages.js730
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_attachments.js499
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_get.js1073
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_id.js256
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_import.js121
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_move_copy_delete.js656
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_onNewMailReceived.js153
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_query.js333
-rw-r--r--comm/mail/components/extensions/test/xpcshell/test_ext_messages_update.js415
-rw-r--r--comm/mail/components/extensions/test/xpcshell/xpcshell-imap.ini7
-rw-r--r--comm/mail/components/extensions/test/xpcshell/xpcshell-local.ini23
-rw-r--r--comm/mail/components/extensions/test/xpcshell/xpcshell-nntp.ini7
-rw-r--r--comm/mail/components/extensions/test/xpcshell/xpcshell.ini17
212 files changed, 76536 insertions, 0 deletions
diff --git a/comm/mail/components/extensions/ExtensionBrowsingData.sys.mjs b/comm/mail/components/extensions/ExtensionBrowsingData.sys.mjs
new file mode 100644
index 0000000000..26fb1040a6
--- /dev/null
+++ b/comm/mail/components/extensions/ExtensionBrowsingData.sys.mjs
@@ -0,0 +1,78 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "makeRange", () => {
+ const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+ );
+ // Defined in ext-browsingData.js
+ return ExtensionParent.apiManager.global.makeRange;
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ Sanitizer: "resource:///modules/Sanitizer.jsm",
+});
+
+export class BrowsingDataDelegate {
+ // Unused for now
+ constructor(extension) {}
+
+ // This method returns undefined for all data types that are _not_ handled by
+ // this delegate.
+ handleRemoval(dataType, options) {
+ switch (dataType) {
+ case "downloads":
+ return lazy.Sanitizer.items.downloads.clear(lazy.makeRange(options));
+ case "formData":
+ return lazy.Sanitizer.items.formdata.clear(lazy.makeRange(options));
+ case "history":
+ return lazy.Sanitizer.items.history.clear(lazy.makeRange(options));
+
+ default:
+ return undefined;
+ }
+ }
+
+ settings() {
+ const PREF_DOMAIN = "privacy.cpd.";
+ // The following prefs are the only ones in Firefox that match corresponding
+ // values used by Chrome when rerturning settings.
+ const PREF_LIST = ["cache", "cookies", "history", "formdata", "downloads"];
+
+ // since will be the start of what is returned by Sanitizer.getClearRange
+ // divided by 1000 to convert to ms.
+ // If Sanitizer.getClearRange returns undefined that means the range is
+ // currently "Everything", so we should set since to 0.
+ let clearRange = lazy.Sanitizer.getClearRange();
+ let since = clearRange ? clearRange[0] / 1000 : 0;
+ let options = { since };
+
+ let dataToRemove = {};
+ let dataRemovalPermitted = {};
+
+ for (let item of PREF_LIST) {
+ // The property formData needs a different case than the
+ // formdata preference.
+ const name = item === "formdata" ? "formData" : item;
+ dataToRemove[name] = lazy.Preferences.get(`${PREF_DOMAIN}${item}`);
+ // Firefox doesn't have the same concept of dataRemovalPermitted
+ // as Chrome, so it will always be true.
+ dataRemovalPermitted[name] = true;
+ }
+
+ return Promise.resolve({
+ options,
+ dataToRemove,
+ dataRemovalPermitted,
+ });
+ }
+}
diff --git a/comm/mail/components/extensions/ExtensionPopups.sys.mjs b/comm/mail/components/extensions/ExtensionPopups.sys.mjs
new file mode 100644
index 0000000000..15f5b7f4c9
--- /dev/null
+++ b/comm/mail/components/extensions/ExtensionPopups.sys.mjs
@@ -0,0 +1,635 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+/* This file is a much-modified copy of browser/components/extensions/ExtensionPopups.sys.mjs. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+
+var { DefaultWeakMap, ExtensionError, promiseEvent } = ExtensionUtils;
+
+const POPUP_LOAD_TIMEOUT_MS = 200;
+
+XPCOMUtils.defineLazyGetter(lazy, "standaloneStylesheets", () => {
+ let stylesheets = [];
+
+ if (AppConstants.platform === "macosx") {
+ stylesheets.push("chrome://browser/content/extension-mac-panel.css");
+ } else if (AppConstants.platform === "win") {
+ stylesheets.push("chrome://browser/content/extension-win-panel.css");
+ } else if (AppConstants.platform === "linux") {
+ stylesheets.push("chrome://browser/content/extension-linux-panel.css");
+ }
+ return stylesheets;
+});
+
+const REMOTE_PANEL_ID = "webextension-remote-preload-panel";
+
+export class BasePopup {
+ constructor(
+ extension,
+ viewNode,
+ popupURL,
+ browserStyle,
+ fixedWidth = false,
+ blockParser = false
+ ) {
+ this.extension = extension;
+ this.popupURL = popupURL;
+ this.viewNode = viewNode;
+ this.browserStyle = browserStyle;
+ this.window = viewNode.ownerGlobal;
+ this.destroyed = false;
+ this.fixedWidth = fixedWidth;
+ this.blockParser = blockParser;
+
+ extension.callOnClose(this);
+
+ this.contentReady = new Promise(resolve => {
+ this._resolveContentReady = resolve;
+ });
+
+ this.window.addEventListener("unload", this);
+ this.viewNode.addEventListener("popuphiding", this);
+ this.panel.addEventListener("popuppositioned", this, {
+ once: true,
+ capture: true,
+ });
+
+ this.browser = null;
+ this.browserLoaded = new Promise((resolve, reject) => {
+ this.browserLoadedDeferred = { resolve, reject };
+ });
+ this.browserReady = this.createBrowser(viewNode, popupURL);
+
+ BasePopup.instances.get(this.window).set(extension, this);
+ }
+
+ static for(extension, window) {
+ return BasePopup.instances.get(window).get(extension);
+ }
+
+ close() {
+ this.closePopup();
+ }
+
+ destroy() {
+ this.extension.forgetOnClose(this);
+
+ this.window.removeEventListener("unload", this);
+
+ this.destroyed = true;
+ this.browserLoadedDeferred.reject(new ExtensionError("Popup destroyed"));
+ // Ignore unhandled rejections if the "attach" method is not called.
+ this.browserLoaded.catch(() => {});
+
+ BasePopup.instances.get(this.window).delete(this.extension);
+
+ return this.browserReady.then(() => {
+ if (this.browser) {
+ this.destroyBrowser(this.browser, true);
+ this.browser.parentNode.remove();
+ }
+ if (this.stack) {
+ this.stack.remove();
+ }
+
+ if (this.viewNode) {
+ this.viewNode.removeEventListener("popuphiding", this);
+ delete this.viewNode.customRectGetter;
+ }
+
+ let { panel } = this;
+ if (panel) {
+ panel.removeEventListener("popuppositioned", this, { capture: true });
+ }
+ if (panel && panel.id !== REMOTE_PANEL_ID) {
+ panel.style.removeProperty("--arrowpanel-background");
+ panel.style.removeProperty("--arrowpanel-border-color");
+ panel.removeAttribute("remote");
+ }
+
+ this.browser = null;
+ this.stack = null;
+ this.viewNode = null;
+ });
+ }
+
+ destroyBrowser(browser, finalize = false) {
+ let mm = browser.messageManager;
+ // If the browser has already been removed from the document, because the
+ // popup was closed externally, there will be no message manager here, so
+ // just replace our receiveMessage method with a stub.
+ if (mm) {
+ mm.removeMessageListener("Extension:BrowserBackgroundChanged", this);
+ mm.removeMessageListener("Extension:BrowserContentLoaded", this);
+ mm.removeMessageListener("Extension:BrowserResized", this);
+ } else if (finalize) {
+ this.receiveMessage = () => {};
+ }
+ browser.removeEventListener("pagetitlechanged", this);
+ browser.removeEventListener("DOMWindowClose", this);
+ }
+
+ get STYLESHEETS() {
+ let sheets = [];
+
+ if (this.browserStyle) {
+ sheets.push(...lazy.ExtensionParent.extensionStylesheets);
+ }
+ if (!this.fixedWidth) {
+ sheets.push(...lazy.standaloneStylesheets);
+ }
+
+ return sheets;
+ }
+
+ get panel() {
+ let panel = this.viewNode;
+ while (panel && panel.localName != "panel") {
+ panel = panel.parentNode;
+ }
+ return panel;
+ }
+
+ receiveMessage({ name, data }) {
+ switch (name) {
+ case "Extension:BrowserBackgroundChanged":
+ this.setBackground(data.background);
+ break;
+
+ case "Extension:BrowserContentLoaded":
+ this.browserLoadedDeferred.resolve();
+ break;
+
+ case "Extension:BrowserResized":
+ this._resolveContentReady();
+ if (this.ignoreResizes) {
+ this.dimensions = data;
+ } else {
+ this.resizeBrowser(data);
+ }
+ break;
+ }
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "unload":
+ case "popuphiding":
+ if (!this.destroyed) {
+ this.destroy();
+ }
+ break;
+ case "popuppositioned":
+ if (!this.destroyed) {
+ this.browserLoaded
+ .then(() => {
+ if (this.destroyed) {
+ return;
+ }
+ // Wait the reflow before asking the popup panel to grab the focus, otherwise
+ // `nsFocusManager::SetFocus` may ignore out request because the panel view
+ // visibility is still set to `nsViewVisibility_kHide` (waiting the document
+ // to be fully flushed makes us sure that when the popup panel grabs the focus
+ // nsMenuPopupFrame::LayoutPopup has already been colled and set the frame
+ // visibility to `nsViewVisibility_kShow`).
+ this.browser.ownerGlobal.promiseDocumentFlushed(() => {
+ if (this.destroyed) {
+ return;
+ }
+ this.browser.messageManager.sendAsyncMessage(
+ "Extension:GrabFocus",
+ {}
+ );
+ });
+ })
+ .catch(() => {
+ // If the panel closes too fast an exception is raised here and tests will fail.
+ });
+ }
+ break;
+
+ case "pagetitlechanged":
+ this.viewNode.setAttribute("aria-label", this.browser.contentTitle);
+ break;
+
+ case "DOMWindowClose":
+ this.closePopup();
+ break;
+ }
+ }
+
+ createBrowser(viewNode, popupURL = null) {
+ let document = viewNode.ownerDocument;
+
+ let stack = document.createXULElement("stack");
+ stack.setAttribute("class", "webextension-popup-stack");
+
+ let browser = document.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "webext-browsers");
+ browser.setAttribute("class", "webextension-popup-browser");
+ browser.setAttribute("webextension-view-type", "popup");
+ browser.setAttribute("tooltip", "aHTMLTooltip");
+ browser.setAttribute("context", "browserContext");
+ browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ browser.setAttribute("selectmenulist", "ContentSelectDropdown");
+ browser.setAttribute("constrainpopups", "false");
+ browser.setAttribute("datetimepicker", "DateTimePickerPanel");
+
+ // Ensure the browser will initially load in the same group as other
+ // browsers from the same extension.
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ this.extension.policy.browsingContextGroupId
+ );
+
+ if (this.extension.remote) {
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", this.extension.remoteType);
+ browser.setAttribute("maychangeremoteness", "true");
+ }
+
+ // We only need flex sizing for the sake of the slide-in sub-views of the
+ // main menu panel, so that the browser occupies the full width of the view,
+ // and also takes up any extra height that's available to it.
+ browser.setAttribute("flex", "1");
+ stack.setAttribute("flex", "1");
+
+ // Note: When using noautohide panels, the popup manager will add width and
+ // height attributes to the panel, breaking our resize code, if the browser
+ // starts out smaller than 30px by 10px. This isn't an issue now, but it
+ // will be if and when we popup debugging.
+
+ this.browser = browser;
+ this.stack = stack;
+
+ let readyPromise;
+ if (this.extension.remote) {
+ readyPromise = promiseEvent(browser, "XULFrameLoaderCreated");
+ } else {
+ readyPromise = promiseEvent(browser, "load");
+ }
+
+ stack.appendChild(browser);
+ viewNode.appendChild(stack);
+
+ if (!this.extension.remote) {
+ // FIXME: bug 1494029 - this code used to rely on the browser binding
+ // accessing browser.contentWindow. This is a stopgap to continue doing
+ // that, but we should get rid of it in the long term.
+ browser.contentWindow; // eslint-disable-line no-unused-expressions
+ }
+
+ let setupBrowser = browser => {
+ let mm = browser.messageManager;
+ mm.addMessageListener("Extension:BrowserBackgroundChanged", this);
+ mm.addMessageListener("Extension:BrowserContentLoaded", this);
+ mm.addMessageListener("Extension:BrowserResized", this);
+ browser.addEventListener("pagetitlechanged", this);
+ browser.addEventListener("DOMWindowClose", this);
+
+ lazy.ExtensionParent.apiManager.emit(
+ "extension-browser-inserted",
+ browser
+ );
+ return browser;
+ };
+
+ const initBrowser = () => {
+ setupBrowser(browser);
+ let mm = browser.messageManager;
+
+ mm.loadFrameScript(
+ "chrome://extensions/content/ext-browser-content.js",
+ false,
+ true
+ );
+
+ mm.sendAsyncMessage("Extension:InitBrowser", {
+ allowScriptsToClose: true,
+ blockParser: this.blockParser,
+ fixedWidth: this.fixedWidth,
+ maxWidth: 800,
+ maxHeight: 600,
+ stylesheets: this.STYLESHEETS,
+ });
+ };
+
+ browser.addEventListener("DidChangeBrowserRemoteness", initBrowser); // eslint-disable-line mozilla/balanced-listeners
+
+ if (!popupURL) {
+ // For remote browsers, we can't do any setup until the frame loader is
+ // created. Non-remote browsers get a message manager immediately, so
+ // there's no need to wait for the load event.
+ if (this.extension.remote) {
+ return readyPromise.then(() => setupBrowser(browser));
+ }
+ return setupBrowser(browser);
+ }
+
+ return readyPromise.then(() => {
+ initBrowser();
+ browser.fixupAndLoadURIString(popupURL, {
+ triggeringPrincipal: this.extension.principal,
+ });
+ });
+ }
+
+ unblockParser() {
+ this.browserReady.then(browser => {
+ if (this.destroyed) {
+ return;
+ }
+ this.browser.messageManager.sendAsyncMessage("Extension:UnblockParser");
+ });
+ }
+
+ resizeBrowser({ width, height, detail }) {
+ if (this.fixedWidth) {
+ // Figure out how much extra space we have on the side of the panel
+ // opposite the arrow.
+ let side = this.panel.getAttribute("side") == "top" ? "bottom" : "top";
+ let maxHeight = this.viewHeight + this.extraHeight[side];
+
+ height = Math.min(height, maxHeight);
+ this.browser.style.height = `${height}px`;
+
+ // Used by the panelmultiview code to figure out sizing without reparenting
+ // (which would destroy the browser and break us).
+ this.lastCalculatedInViewHeight = Math.max(height, this.viewHeight);
+ } else {
+ this.browser.style.width = `${width}px`;
+ this.browser.style.minWidth = `${width}px`;
+ this.browser.style.height = `${height}px`;
+ this.browser.style.minHeight = `${height}px`;
+ }
+
+ let event = new this.window.CustomEvent("WebExtPopupResized", { detail });
+ this.browser.dispatchEvent(event);
+ }
+
+ setBackground(background) {
+ // Panels inherit the applied theme (light, dark, etc) and there is a high
+ // likelihood that most extension authors will not have tested with a dark theme.
+ // If they have not set a background-color, we force it to white to ensure visibility
+ // of the extension content. Passing `null` should be treated the same as no argument,
+ // which is why we can't use default parameters here.
+ if (!background) {
+ background = "#fff";
+ }
+ if (this.panel.id != "widget-overflow") {
+ this.panel.style.setProperty("--arrowpanel-background", background);
+ }
+ if (background == "#fff") {
+ // Set a usable default color that work with the default background-color.
+ this.panel.style.setProperty(
+ "--arrowpanel-border-color",
+ "hsla(210,4%,10%,.15)"
+ );
+ }
+ this.background = background;
+ }
+}
+
+export class ViewPopup extends BasePopup {
+ constructor(
+ extension,
+ window,
+ popupURL,
+ browserStyle,
+ fixedWidth,
+ blockParser
+ ) {
+ let document = window.document;
+
+ let createPanel = remote => {
+ let panel = document.createXULElement("panel");
+ panel.setAttribute("type", "arrow");
+ panel.setAttribute("class", "panel-no-padding");
+ if (remote) {
+ panel.setAttribute("remote", "true");
+ }
+ panel.setAttribute("neverhidden", "true");
+
+ document.getElementById("mainPopupSet").appendChild(panel);
+ return panel;
+ };
+
+ // Firefox creates a temporary panel to hold the browser while it pre-loads
+ // its content (starting on mouseover already). This panel will never be shown,
+ // but the browser's docShell will be swapped with the browser in the real
+ // panel when it's ready (in ViewPopup.attach()).
+ // For remote extensions, Firefox shares this temporary panel between all
+ // extensions.
+
+ // NOTE: Thunderbird currently does not pre-load the popup and really uses
+ // the "temporary" panel when displaying the popup to the user.
+ let panel;
+ if (extension.remote) {
+ panel = document.getElementById(REMOTE_PANEL_ID);
+ if (!panel) {
+ panel = createPanel(true);
+ panel.id = REMOTE_PANEL_ID;
+ }
+ } else {
+ panel = createPanel();
+ }
+
+ super(extension, panel, popupURL, browserStyle, fixedWidth, blockParser);
+
+ this.ignoreResizes = true;
+
+ this.attached = false;
+ this.shown = false;
+ this.tempPanel = panel;
+ this.tempBrowser = this.browser;
+
+ this.browser.classList.add("webextension-preload-browser");
+ }
+
+ /**
+ * Attaches the pre-loaded browser to the given view node, and reserves a
+ * promise which resolves when the browser is ready.
+ *
+ * NOTE: Not used by Thunderbird.
+ *
+ * @param {Element} viewNode
+ * The node to attach the browser to.
+ * @returns {Promise<boolean>}
+ * Resolves when the browser is ready. Resolves to `false` if the
+ * browser was destroyed before it was fully loaded, and the popup
+ * should be closed, or `true` otherwise.
+ */
+ async attach(viewNode) {
+ if (this.destroyed) {
+ return false;
+ }
+ this.viewNode.removeEventListener(this.DESTROY_EVENT, this);
+ this.panel.removeEventListener("popuppositioned", this, {
+ once: true,
+ capture: true,
+ });
+
+ this.viewNode = viewNode;
+ this.viewNode.addEventListener(this.DESTROY_EVENT, this);
+ this.viewNode.setAttribute("closemenu", "none");
+
+ this.panel.addEventListener("popuppositioned", this, {
+ once: true,
+ capture: true,
+ });
+ if (this.extension.remote) {
+ this.panel.setAttribute("remote", "true");
+ }
+
+ // Wait until the browser element is fully initialized, and give it at least
+ // a short grace period to finish loading its initial content, if necessary.
+ //
+ // In practice, the browser that was created by the mousdown handler should
+ // nearly always be ready by this point.
+ await Promise.all([
+ this.browserReady,
+ Promise.race([
+ // This promise may be rejected if the popup calls window.close()
+ // before it has fully loaded.
+ this.browserLoaded.catch(() => {}),
+ new Promise(resolve => lazy.setTimeout(resolve, POPUP_LOAD_TIMEOUT_MS)),
+ ]),
+ ]);
+
+ const { panel } = this;
+
+ if (!this.destroyed && !panel) {
+ this.destroy();
+ }
+
+ if (this.destroyed) {
+ this.viewNode.hidePopup();
+ return false;
+ }
+
+ this.attached = true;
+
+ this.setBackground(this.background);
+
+ let flushPromise = this.window.promiseDocumentFlushed(() => {
+ let win = this.window;
+
+ // Calculate the extra height available on the screen above and below the
+ // menu panel. Use that to calculate the how much the sub-view may grow.
+ let popupRect = panel.getBoundingClientRect();
+ let screenBottom = win.screen.availTop + win.screen.availHeight;
+ let popupBottom = win.mozInnerScreenY + popupRect.bottom;
+ let popupTop = win.mozInnerScreenY + popupRect.top;
+
+ // Store the initial height of the view, so that we never resize menu panel
+ // sub-views smaller than the initial height of the menu.
+ this.viewHeight = viewNode.getBoundingClientRect().height;
+
+ this.extraHeight = {
+ bottom: Math.max(0, screenBottom - popupBottom),
+ top: Math.max(0, popupTop - win.screen.availTop),
+ };
+ });
+
+ // Create a new browser in the real popup.
+ let browser = this.browser;
+ await this.createBrowser(this.viewNode);
+
+ this.browser.swapDocShells(browser);
+ this.destroyBrowser(browser);
+
+ await flushPromise;
+
+ // Check if the popup has been destroyed while we were waiting for the
+ // document flush promise to be resolve.
+ if (this.destroyed) {
+ this.closePopup();
+ this.destroy();
+ return false;
+ }
+
+ if (this.dimensions) {
+ if (this.fixedWidth) {
+ delete this.dimensions.width;
+ }
+ this.resizeBrowser(this.dimensions);
+ }
+
+ this.ignoreResizes = false;
+
+ this.viewNode.customRectGetter = () => {
+ return { height: this.lastCalculatedInViewHeight || this.viewHeight };
+ };
+
+ this.removeTempPanel();
+
+ this.shown = true;
+
+ if (this.destroyed) {
+ this.closePopup();
+ this.destroy();
+ return false;
+ }
+
+ let event = new this.window.CustomEvent("WebExtPopupLoaded", {
+ bubbles: true,
+ detail: { extension: this.extension },
+ });
+ this.browser.dispatchEvent(event);
+
+ return true;
+ }
+
+ removeTempPanel() {
+ if (this.tempPanel) {
+ // NOTE: Thunderbird currently does not pre-load the popup into a temporary
+ // panel as Firefox is doing it. We therefore do not have to "save"
+ // the temporary panel for later re-use, but really have to remove it.
+ // See Bug 1451058 for why Firefox uses the following conditional
+ // remove().
+
+ // if (this.tempPanel.id !== REMOTE_PANEL_ID) {
+ this.tempPanel.remove();
+ // }
+ this.tempPanel = null;
+ }
+ if (this.tempBrowser) {
+ this.tempBrowser.parentNode.remove();
+ this.tempBrowser = null;
+ }
+ }
+
+ destroy() {
+ return super.destroy().then(() => {
+ this.removeTempPanel();
+ });
+ }
+
+ closePopup() {
+ this.viewNode.hidePopup();
+ }
+}
+
+/**
+ * A map of active popups for a given browser window.
+ *
+ * WeakMap[window -> WeakMap[Extension -> BasePopup]]
+ */
+BasePopup.instances = new DefaultWeakMap(() => new WeakMap());
diff --git a/comm/mail/components/extensions/ExtensionToolbarButtons.jsm b/comm/mail/components/extensions/ExtensionToolbarButtons.jsm
new file mode 100644
index 0000000000..86e66e06e9
--- /dev/null
+++ b/comm/mail/components/extensions/ExtensionToolbarButtons.jsm
@@ -0,0 +1,949 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "ToolbarButtonAPI",
+ "getIconData",
+ "getCachedAllowedSpaces",
+ "setCachedAllowedSpaces",
+];
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ViewPopup: "resource:///modules/ExtensionPopups.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "ExtensionSupport",
+ "resource:///modules/ExtensionSupport.jsm"
+);
+const { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+const { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+var { EventManager, ExtensionAPIPersistent, makeWidgetId } = ExtensionCommon;
+
+var { IconDetails, StartupCache } = ExtensionParent;
+
+var { DefaultWeakMap, ExtensionError } = ExtensionUtils;
+
+var DEFAULT_ICON = "chrome://messenger/content/extension.svg";
+
+function getCachedAllowedSpaces() {
+ let cache = {};
+ if (
+ Services.xulStore.hasValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "unifiedToolbar",
+ "allowedExtSpaces"
+ )
+ ) {
+ let rawCache = Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "unifiedToolbar",
+ "allowedExtSpaces"
+ );
+ cache = JSON.parse(rawCache);
+ }
+ return new Map(Object.entries(cache));
+}
+
+function setCachedAllowedSpaces(allowedSpacesMap) {
+ Services.xulStore.setValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "unifiedToolbar",
+ "allowedExtSpaces",
+ JSON.stringify(Object.fromEntries(allowedSpacesMap))
+ );
+}
+
+/**
+ * Get icon properties for updating the UI.
+ *
+ * @param {object} icons
+ * Contains the icon information, typically the extension manifest
+ */
+function getIconData(icons, extension) {
+ let baseSize = 16;
+ let { icon, size } = IconDetails.getPreferredIcon(icons, extension, baseSize);
+
+ let legacy = false;
+
+ // If the best available icon size is not divisible by 16, check if we have
+ // an 18px icon to fall back to, and trim off the padding instead.
+ if (size % 16 && typeof icon === "string" && !icon.endsWith(".svg")) {
+ let result = IconDetails.getPreferredIcon(icons, extension, 18);
+
+ if (result.size % 18 == 0) {
+ baseSize = 18;
+ icon = result.icon;
+ legacy = true;
+ }
+ }
+
+ let getIcon = (size, theme) => {
+ let { icon } = IconDetails.getPreferredIcon(icons, extension, size);
+ if (typeof icon === "object") {
+ if (icon[theme] == IconDetails.DEFAULT_ICON) {
+ icon[theme] = DEFAULT_ICON;
+ }
+ return IconDetails.escapeUrl(icon[theme]);
+ }
+ if (icon == IconDetails.DEFAULT_ICON) {
+ return DEFAULT_ICON;
+ }
+ return IconDetails.escapeUrl(icon);
+ };
+
+ let style = [];
+ let getStyle = (name, size) => {
+ style.push([
+ `--webextension-${name}`,
+ `url("${getIcon(size, "default")}")`,
+ ]);
+ style.push([
+ `--webextension-${name}-light`,
+ `url("${getIcon(size, "light")}")`,
+ ]);
+ style.push([
+ `--webextension-${name}-dark`,
+ `url("${getIcon(size, "dark")}")`,
+ ]);
+ };
+
+ getStyle("menupanel-image", 32);
+ getStyle("menupanel-image-2x", 64);
+ getStyle("toolbar-image", baseSize);
+ getStyle("toolbar-image-2x", baseSize * 2);
+
+ let realIcon = getIcon(size, "default");
+
+ return { style, legacy, realIcon };
+}
+
+var ToolbarButtonAPI = class extends ExtensionAPIPersistent {
+ constructor(extension, global) {
+ super(extension);
+ this.global = global;
+ this.tabContext = new this.global.TabContext(target =>
+ this.getContextData(null)
+ );
+ }
+
+ /**
+ * If this action is available in the unified toolbar.
+ *
+ * @type {boolean}
+ */
+ inUnifiedToolbar = false;
+
+ /**
+ * Called when the extension is enabled.
+ *
+ * @param {string} entryName
+ * The name of the property in the extension manifest
+ */
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+ this.paint = this.paint.bind(this);
+ this.unpaint = this.unpaint.bind(this);
+
+ if (this.manifest?.type == "menu" && this.manifest.default_popup) {
+ console.warn(
+ `The "default_popup" manifest entry is not supported for action buttons with type "menu".`
+ );
+ }
+
+ this.widgetId = makeWidgetId(extension.id);
+ this.id = `${this.widgetId}-${this.moduleName}-toolbarbutton`;
+ this.eventQueue = [];
+
+ let options = extension.manifest[entryName];
+ this.defaults = {
+ enabled: true,
+ label: options.default_label,
+ title: options.default_title || extension.name,
+ badgeText: "",
+ badgeBackgroundColor: null,
+ popup: options.default_popup || "",
+ type: options.type,
+ };
+ this.globals = Object.create(this.defaults);
+
+ this.browserStyle = options.browser_style;
+
+ this.defaults.icon = await StartupCache.get(
+ extension,
+ [this.manifestName, "default_icon"],
+ () =>
+ IconDetails.normalize(
+ {
+ path: options.default_icon,
+ iconType: this.manifestName,
+ themeIcons: options.theme_icons,
+ },
+ extension
+ )
+ );
+
+ this.iconData = new DefaultWeakMap(icons => getIconData(icons, extension));
+ this.iconData.set(
+ this.defaults.icon,
+ await StartupCache.get(
+ extension,
+ [this.manifestName, "default_icon_data"],
+ () => getIconData(this.defaults.icon, extension)
+ )
+ );
+
+ lazy.ExtensionSupport.registerWindowListener(this.id, {
+ chromeURLs: this.windowURLs,
+ onLoadWindow: window => {
+ this.paint(window);
+ },
+ });
+
+ extension.callOnClose(this);
+ }
+
+ /**
+ * Called when the extension is disabled or removed.
+ */
+ close() {
+ lazy.ExtensionSupport.unregisterWindowListener(this.id);
+ for (let window of lazy.ExtensionSupport.openWindows) {
+ if (this.windowURLs.includes(window.location.href)) {
+ this.unpaint(window);
+ }
+ }
+ }
+
+ /**
+ * Creates a toolbar button.
+ *
+ * @param {Window} window
+ */
+ makeButton(window) {
+ let { document } = window;
+ let button;
+ switch (this.globals.type) {
+ case "menu":
+ {
+ button = document.createXULElement("toolbarbutton");
+ button.setAttribute("type", "menu");
+ button.setAttribute("wantdropmarker", "true");
+ let menupopup = document.createXULElement("menupopup");
+ menupopup.dataset.actionMenu = this.manifestName;
+ menupopup.dataset.extensionId = this.extension.id;
+ button.appendChild(menupopup);
+ }
+ break;
+ case "button":
+ button = document.createXULElement("toolbarbutton");
+ break;
+ }
+ button.id = this.id;
+ button.classList.add("toolbarbutton-1");
+ button.classList.add("webextension-action");
+ button.setAttribute("badged", "true");
+ button.setAttribute("data-extensionid", this.extension.id);
+ button.addEventListener("mousedown", this);
+ this.updateButton(button, this.globals);
+ return button;
+ }
+
+ /**
+ * Returns an element in the toolbar, which is to be used as default insertion
+ * point for new toolbar buttons in non-customizable toolbars.
+ *
+ * May return null to append new buttons to the end of the toolbar.
+ *
+ * @param {DOMElement} toolbar - a toolbar node
+ * @returns {DOMElement} a node which is to be used as insertion point, or null
+ */
+ getNonCustomizableToolbarInsertionPoint(toolbar) {
+ return null;
+ }
+
+ /**
+ * Adds a toolbar button to a customizable toolbar in this window.
+ *
+ * @param {Window} window
+ */
+ customizableToolbarPaint(window) {
+ let windowURL = window.location.href;
+ let { document } = window;
+ if (document.getElementById(this.id)) {
+ return;
+ }
+
+ let toolbox = document.getElementById(this.toolboxId);
+ if (!toolbox) {
+ return;
+ }
+
+ // Get all toolbars which link to or are children of this.toolboxId and check
+ // if the button has been moved to a non-default toolbar.
+ let toolbars = window.document.querySelectorAll(
+ `#${this.toolboxId} toolbar, toolbar[toolboxid="${this.toolboxId}"]`
+ );
+ for (let toolbar of toolbars) {
+ let currentSet = Services.xulStore
+ .getValue(windowURL, toolbar.id, "currentset")
+ .split(",")
+ .filter(Boolean);
+ if (currentSet.includes(this.id)) {
+ this.toolbarId = toolbar.id;
+ break;
+ }
+ }
+
+ let toolbar = document.getElementById(this.toolbarId);
+ let button = this.makeButton(window);
+ if (toolbox.palette) {
+ toolbox.palette.appendChild(button);
+ } else {
+ toolbar.appendChild(button);
+ }
+
+ // Handle the special case where this toolbar does not yet have a currentset
+ // defined.
+ if (!Services.xulStore.hasValue(windowURL, this.toolbarId, "currentset")) {
+ let defaultSet = toolbar
+ .getAttribute("defaultset")
+ .split(",")
+ .filter(Boolean);
+ Services.xulStore.setValue(
+ windowURL,
+ this.toolbarId,
+ "currentset",
+ defaultSet.join(",")
+ );
+ }
+
+ // Add new buttons to currentset: If the extensionset does not include the
+ // button, it is a new one which needs to be added.
+ let extensionSet = Services.xulStore
+ .getValue(windowURL, this.toolbarId, "extensionset")
+ .split(",")
+ .filter(Boolean);
+ if (!extensionSet.includes(this.id)) {
+ extensionSet.push(this.id);
+ Services.xulStore.setValue(
+ windowURL,
+ this.toolbarId,
+ "extensionset",
+ extensionSet.join(",")
+ );
+ let currentSet = Services.xulStore
+ .getValue(windowURL, this.toolbarId, "currentset")
+ .split(",")
+ .filter(Boolean);
+ if (!currentSet.includes(this.id)) {
+ currentSet.push(this.id);
+ Services.xulStore.setValue(
+ windowURL,
+ this.toolbarId,
+ "currentset",
+ currentSet.join(",")
+ );
+ }
+ }
+
+ let currentSet = Services.xulStore.getValue(
+ windowURL,
+ this.toolbarId,
+ "currentset"
+ );
+
+ toolbar.currentSet = currentSet;
+ toolbar.setAttribute("currentset", toolbar.currentSet);
+
+ if (this.extension.hasPermission("menus")) {
+ document.addEventListener("popupshowing", this);
+ }
+ }
+
+ /**
+ * Adds a toolbar button to a non-customizable toolbar in this window.
+ *
+ * @param {Window} window
+ */
+ nonCustomizableToolbarPaint(window) {
+ let { document } = window;
+ let windowURL = window.location.href;
+ if (document.getElementById(this.id)) {
+ return;
+ }
+ let toolbar = document.getElementById(this.toolbarId);
+ let before = this.getNonCustomizableToolbarInsertionPoint(toolbar);
+ let button = this.makeButton(window);
+ let currentSet = Services.xulStore
+ .getValue(windowURL, toolbar.id, "currentset")
+ .split(",")
+ .filter(Boolean);
+ if (!currentSet.includes(this.id)) {
+ currentSet.push(this.id);
+ Services.xulStore.setValue(
+ windowURL,
+ toolbar.id,
+ "currentset",
+ currentSet.join(",")
+ );
+ } else {
+ for (let id of [...currentSet].reverse()) {
+ if (!id.endsWith(`-${this.manifestName}-toolbarbutton`)) {
+ continue;
+ }
+ if (id == this.id) {
+ break;
+ }
+ let element = document.getElementById(id);
+ if (element) {
+ before = element;
+ }
+ }
+ }
+ toolbar.insertBefore(button, before);
+
+ if (this.extension.hasPermission("menus")) {
+ document.addEventListener("popupshowing", this);
+ }
+ }
+
+ /**
+ * Adds a toolbar button to a toolbar in this window.
+ *
+ * @param {Window} window
+ */
+ paint(window) {
+ let toolbar = window.document.getElementById(this.toolbarId);
+ if (toolbar.hasAttribute("customizable")) {
+ return this.customizableToolbarPaint(window);
+ }
+ return this.nonCustomizableToolbarPaint(window);
+ }
+
+ /**
+ * Removes the toolbar button from this window.
+ *
+ * @param {Window} window
+ */
+ unpaint(window) {
+ let { document } = window;
+
+ if (this.extension.hasPermission("menus")) {
+ document.removeEventListener("popupshowing", this);
+ }
+
+ let button = document.getElementById(this.id);
+ if (button) {
+ button.remove();
+ }
+ }
+
+ /**
+ * Return the toolbar button if it is currently visible in the given window.
+ *
+ * @param window
+ * @returns {DOMElement} the toolbar button element, or null
+ */
+ getToolbarButton(window) {
+ let button = window.document.getElementById(this.id);
+ let toolbar = button?.closest("toolbar");
+ return button && !toolbar?.collapsed ? button : null;
+ }
+
+ /**
+ * Triggers this browser action for the given window, with the same effects as
+ * if it were clicked by a user.
+ *
+ * This has no effect if the browser action is disabled for, or not
+ * present in, the given window.
+ *
+ * @param {Window} window
+ * @param {object} options
+ * @param {boolean} options.requirePopupUrl - do not fall back to emitting an
+ * onClickedEvent, if no popupURL is
+ * set and consider this action fail
+ *
+ * @returns {boolean} status if action could be successfully triggered
+ */
+ async triggerAction(window, options = {}) {
+ let button = this.getToolbarButton(window);
+ let { popup: popupURL, enabled } = this.getContextData(
+ this.getTargetFromWindow(window)
+ );
+
+ let success = false;
+ if (button && enabled) {
+ window.focus();
+
+ if (popupURL) {
+ success = true;
+ let popup =
+ lazy.ViewPopup.for(this.extension, window.top) ||
+ this.getPopup(window.top, popupURL);
+ popup.viewNode.openPopup(button, "bottomleft topleft", 0, 0);
+ } else if (!options.requirePopupUrl) {
+ if (!this.lastClickInfo) {
+ this.lastClickInfo = { button: 0, modifiers: [] };
+ }
+ this.emit("click", window.top, this.lastClickInfo);
+ success = true;
+ }
+ }
+
+ delete this.lastClickInfo;
+ return success;
+ }
+
+ /**
+ * Event listener.
+ *
+ * @param {Event} event
+ */
+ handleEvent(event) {
+ let window = event.target.ownerGlobal;
+ switch (event.type) {
+ case "click":
+ case "mousedown":
+ if (event.button == 0) {
+ // Bail out, if this is a menu typed action button or any of its menu entries.
+ if (
+ event.target.tagName == "menu" ||
+ event.target.tagName == "menuitem" ||
+ event.target.getAttribute("type") == "menu"
+ ) {
+ return;
+ }
+
+ this.lastClickInfo = {
+ button: 0,
+ modifiers: this.global.clickModifiersFromEvent(event),
+ };
+ this.triggerAction(window);
+ }
+ break;
+ case "TabSelect":
+ this.updateWindow(window);
+ break;
+ }
+ }
+
+ /**
+ * Returns a potentially pre-loaded popup for the given URL in the given
+ * window. If a matching pre-load popup already exists, returns that.
+ * Otherwise, initializes a new one.
+ *
+ * If a pre-load popup exists which does not match, it is destroyed before a
+ * new one is created.
+ *
+ * @param {Window} window
+ * The browser window in which to create the popup.
+ * @param {string} popupURL
+ * The URL to load into the popup.
+ * @param {boolean} [blockParser = false]
+ * True if the HTML parser should initially be blocked.
+ * @returns {ViewPopup}
+ */
+ getPopup(window, popupURL, blockParser = false) {
+ let popup = new lazy.ViewPopup(
+ this.extension,
+ window,
+ popupURL,
+ this.browserStyle,
+ false,
+ blockParser
+ );
+ popup.ignoreResizes = false;
+ return popup;
+ }
+
+ /**
+ * Update the toolbar button |node| with the tab context data
+ * in |tabData|.
+ *
+ * @param {XULElement} node
+ * XUL toolbarbutton to update
+ * @param {object} tabData
+ * Properties to set
+ * @param {boolean} sync
+ * Whether to perform the update immediately
+ */
+ updateButton(node, tabData, sync = false) {
+ let title = tabData.title || this.extension.name;
+ let label = tabData.label;
+ let callback = () => {
+ node.setAttribute("tooltiptext", title);
+ node.setAttribute("label", label || title);
+ node.setAttribute(
+ "hideWebExtensionLabel",
+ label === "" ? "true" : "false"
+ );
+
+ if (tabData.badgeText) {
+ node.setAttribute("badge", tabData.badgeText);
+ } else {
+ node.removeAttribute("badge");
+ }
+
+ if (tabData.enabled) {
+ node.removeAttribute("disabled");
+ } else {
+ node.setAttribute("disabled", "true");
+ }
+
+ let color = tabData.badgeBackgroundColor;
+ if (color) {
+ color = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${
+ color[3] / 255
+ })`;
+ node.setAttribute("badgeStyle", `background-color: ${color};`);
+ } else {
+ node.removeAttribute("badgeStyle");
+ }
+
+ let { style } = this.iconData.get(tabData.icon);
+
+ for (let [name, value] of style) {
+ node.style.setProperty(name, value);
+ }
+ };
+ if (sync) {
+ callback();
+ } else {
+ node.ownerGlobal.requestAnimationFrame(callback);
+ }
+ }
+
+ /**
+ * Update the toolbar button for a given window.
+ *
+ * @param {ChromeWindow} window
+ * Browser chrome window.
+ */
+ async updateWindow(window) {
+ let button = this.getToolbarButton(window);
+ if (button) {
+ let tabData = this.getContextData(this.getTargetFromWindow(window));
+ this.updateButton(button, tabData);
+ }
+ await new Promise(window.requestAnimationFrame);
+ }
+
+ /**
+ * Update the toolbar button when the extension changes the icon, title, url, etc.
+ * If it only changes a parameter for a single tab, `target` will be that tab.
+ * If it only changes a parameter for a single window, `target` will be that window.
+ * Otherwise `target` will be null.
+ *
+ * @param {XULElement|ChromeWindow|null} target
+ * Browser tab or browser chrome window, may be null.
+ */
+ async updateOnChange(target) {
+ if (target) {
+ let window = Cu.getGlobalForObject(target);
+ if (target === window) {
+ await this.updateWindow(window);
+ } else {
+ let tabmail = window.document.getElementById("tabmail");
+ if (tabmail && target == tabmail.selectedTab) {
+ await this.updateWindow(window);
+ }
+ }
+ } else {
+ let promises = [];
+ for (let window of lazy.ExtensionSupport.openWindows) {
+ if (this.windowURLs.includes(window.location.href)) {
+ promises.push(this.updateWindow(window));
+ }
+ }
+ await Promise.all(promises);
+ }
+ }
+
+ /**
+ * Gets the active tab of the passed window if the window has tabs, or the
+ * window itself.
+ *
+ * @param {ChromeWindow} window
+ * @returns {XULElement|ChromeWindow}
+ */
+ getTargetFromWindow(window) {
+ let tabmail = window.top.document.getElementById("tabmail");
+ if (!tabmail) {
+ return window.top;
+ }
+
+ if (window == window.top) {
+ return tabmail.currentTabInfo;
+ }
+ if (window.parent != window.top) {
+ window = window.parent;
+ }
+ return tabmail.tabInfo.find(t => t.chromeBrowser?.contentWindow == window);
+ }
+
+ /**
+ * Gets the target object corresponding to the `details` parameter of the various
+ * get* and set* API methods.
+ *
+ * @param {object} details
+ * An object with optional `tabId` or `windowId` properties.
+ * @throws if `windowId` is specified, this is not valid in Thunderbird.
+ * @returns {XULElement|ChromeWindow|null}
+ * If a `tabId` was specified, the corresponding XULElement tab.
+ * If a `windowId` was specified, the corresponding ChromeWindow.
+ * Otherwise, `null`.
+ */
+ getTargetFromDetails({ tabId, windowId }) {
+ if (windowId != null) {
+ throw new ExtensionError("windowId is not allowed, use tabId instead.");
+ }
+ if (tabId != null) {
+ return this.global.tabTracker.getTab(tabId);
+ }
+ return null;
+ }
+
+ /**
+ * Gets the data associated with a tab, window, or the global one.
+ *
+ * @param {XULElement|ChromeWindow|null} target
+ * A XULElement tab, a ChromeWindow, or null for the global data.
+ * @returns {object}
+ * The icon, title, badge, etc. associated with the target.
+ */
+ getContextData(target) {
+ if (target) {
+ return this.tabContext.get(target);
+ }
+ return this.globals;
+ }
+
+ /**
+ * Set a global, window specific or tab specific property.
+ *
+ * @param {object} details
+ * An object with optional `tabId` or `windowId` properties.
+ * @param {string} prop
+ * String property to set. Should should be one of "icon", "title", "label",
+ * "badgeText", "popup", "badgeBackgroundColor" or "enabled".
+ * @param {string} value
+ * Value for prop.
+ */
+ async setProperty(details, prop, value) {
+ let target = this.getTargetFromDetails(details);
+ let values = this.getContextData(target);
+ if (value === null) {
+ delete values[prop];
+ } else {
+ values[prop] = value;
+ }
+
+ await this.updateOnChange(target);
+ }
+
+ /**
+ * Retrieve the value of a global, window specific or tab specific property.
+ *
+ * @param {object} details
+ * An object with optional `tabId` or `windowId` properties.
+ * @param {string} prop
+ * String property to retrieve. Should should be one of "icon", "title", "label",
+ * "badgeText", "popup", "badgeBackgroundColor" or "enabled".
+ * @returns {string} value
+ * Value of prop.
+ */
+ getProperty(details, prop) {
+ return this.getContextData(this.getTargetFromDetails(details))[prop];
+ }
+
+ PERSISTENT_EVENTS = {
+ onClicked({ context, fire }) {
+ const { extension } = this;
+ const { tabManager, windowManager } = extension;
+
+ async function listener(_event, window, clickInfo) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+
+ // TODO: We should double-check if the tab is already being closed by the time
+ // the background script got started and we converted the primed listener.
+
+ let win = windowManager.wrapWindow(window);
+ fire.sync(tabManager.convert(win.activeTab.nativeTab), clickInfo);
+ }
+ this.on("click", listener);
+ return {
+ unregister: () => {
+ this.off("click", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ /**
+ * WebExtension API.
+ *
+ * @param {object} context
+ */
+ getAPI(context) {
+ let { extension } = context;
+
+ let action = this;
+
+ return {
+ [this.manifestName]: {
+ onClicked: new EventManager({
+ context,
+ module: this.moduleName,
+ event: "onClicked",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+
+ async enable(tabId) {
+ await action.setProperty({ tabId }, "enabled", true);
+ },
+
+ async disable(tabId) {
+ await action.setProperty({ tabId }, "enabled", false);
+ },
+
+ isEnabled(details) {
+ return action.getProperty(details, "enabled");
+ },
+
+ async setTitle(details) {
+ await action.setProperty(details, "title", details.title);
+ },
+
+ getTitle(details) {
+ return action.getProperty(details, "title");
+ },
+
+ async setLabel(details) {
+ await action.setProperty(details, "label", details.label);
+ },
+
+ getLabel(details) {
+ return action.getProperty(details, "label");
+ },
+
+ async setIcon(details) {
+ details.iconType = this.manifestName;
+
+ let icon = IconDetails.normalize(details, extension, context);
+ if (!Object.keys(icon).length) {
+ icon = null;
+ }
+ await action.setProperty(details, "icon", icon);
+ },
+
+ async setBadgeText(details) {
+ await action.setProperty(details, "badgeText", details.text);
+ },
+
+ getBadgeText(details) {
+ return action.getProperty(details, "badgeText");
+ },
+
+ async setPopup(details) {
+ if (this.manifest?.type == "menu") {
+ console.warn(
+ `Popups are not supported for action buttons with type "menu".`
+ );
+ }
+
+ // Note: Chrome resolves arguments to setIcon relative to the calling
+ // context, but resolves arguments to setPopup relative to the extension
+ // root.
+ // For internal consistency, we currently resolve both relative to the
+ // calling context.
+ let url = details.popup && context.uri.resolve(details.popup);
+ if (url && !context.checkLoadURL(url)) {
+ return Promise.reject({ message: `Access denied for URL ${url}` });
+ }
+ await action.setProperty(details, "popup", url);
+ return Promise.resolve(null);
+ },
+
+ getPopup(details) {
+ if (this.manifest?.type == "menu") {
+ console.warn(
+ `Popups are not supported for action buttons with type "menu".`
+ );
+ }
+
+ return action.getProperty(details, "popup");
+ },
+
+ async setBadgeBackgroundColor(details) {
+ let color = details.color;
+ if (typeof color == "string") {
+ let col = InspectorUtils.colorToRGBA(color);
+ if (!col) {
+ throw new ExtensionError(
+ `Invalid badge background color: "${color}"`
+ );
+ }
+ color = col && [col.r, col.g, col.b, Math.round(col.a * 255)];
+ }
+ await action.setProperty(details, "badgeBackgroundColor", color);
+ },
+
+ getBadgeBackgroundColor(details, callback) {
+ let color = action.getProperty(details, "badgeBackgroundColor");
+ return color || [0xd9, 0, 0, 255];
+ },
+
+ openPopup(options) {
+ if (this.manifest?.type == "menu") {
+ console.warn(
+ `Popups are not supported for action buttons with type "menu".`
+ );
+ return false;
+ }
+
+ let window;
+ if (options?.windowId) {
+ window = action.global.windowTracker.getWindow(
+ options.windowId,
+ context
+ );
+ if (!window) {
+ return Promise.reject({
+ message: `Invalid window ID: ${options.windowId}`,
+ });
+ }
+ } else {
+ window = Services.wm.getMostRecentWindow("");
+ }
+
+ // When triggering the action here, we consider a missing popupUrl as a failure and will not
+ // cause an onClickedEvent.
+ return action.triggerAction(window, { requirePopupUrl: true });
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/MailExtensionShortcuts.jsm b/comm/mail/components/extensions/MailExtensionShortcuts.jsm
new file mode 100644
index 0000000000..f3c4d8eef7
--- /dev/null
+++ b/comm/mail/components/extensions/MailExtensionShortcuts.jsm
@@ -0,0 +1,87 @@
+/* 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/. */
+"use strict";
+
+const EXPORTED_SYMBOLS = ["MailExtensionShortcuts"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { ExtensionShortcuts } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionShortcuts.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "browserActionFor", () => {
+ return lazy.ExtensionParent.apiManager.global.browserActionFor;
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "composeActionFor", () => {
+ return lazy.ExtensionParent.apiManager.global.composeActionFor;
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "messageDisplayActionFor", () => {
+ return lazy.ExtensionParent.apiManager.global.messageDisplayActionFor;
+});
+
+const EXECUTE_ACTION = "_execute_action";
+const EXECUTE_BROWSER_ACTION = "_execute_browser_action";
+const EXECUTE_MSG_DISPLAY_ACTION = "_execute_message_display_action";
+const EXECUTE_COMPOSE_ACTION = "_execute_compose_action";
+
+class MailExtensionShortcuts extends ExtensionShortcuts {
+ /**
+ * Builds a XUL Key element and attaches an onCommand listener which
+ * emits a command event with the provided name when fired.
+ *
+ * @param {Document} doc The XUL document.
+ * @param {string} name The name of the command.
+ * @param {string} shortcut The shortcut provided in the manifest.
+ * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
+ *
+ * @returns {Document} The newly created Key element.
+ */
+ buildKey(doc, name, shortcut) {
+ let keyElement = this.buildKeyFromShortcut(doc, name, shortcut);
+
+ // We need to have the attribute "oncommand" for the "command" listener to fire,
+ // and it is currently ignored when set to the empty string.
+ keyElement.setAttribute("oncommand", "//");
+
+ /* eslint-disable mozilla/balanced-listeners */
+ // We remove all references to the key elements when the extension is shutdown,
+ // therefore the listeners for these elements will be garbage collected.
+ keyElement.addEventListener("command", event => {
+ let action;
+ if (
+ name == EXECUTE_BROWSER_ACTION &&
+ this.extension.manifestVersion < 3
+ ) {
+ action = lazy.browserActionFor(this.extension);
+ } else if (name == EXECUTE_ACTION && this.extension.manifestVersion > 2) {
+ action = lazy.browserActionFor(this.extension);
+ } else if (name == EXECUTE_COMPOSE_ACTION) {
+ action = lazy.composeActionFor(this.extension);
+ } else if (name == EXECUTE_MSG_DISPLAY_ACTION) {
+ action = lazy.messageDisplayActionFor(this.extension);
+ } else {
+ this.extension.tabManager.addActiveTabPermission();
+ this.onCommand(name);
+ return;
+ }
+ if (action) {
+ let win = event.target.ownerGlobal;
+ action.triggerAction(win);
+ }
+ });
+ /* eslint-enable mozilla/balanced-listeners */
+
+ return keyElement;
+ }
+}
diff --git a/comm/mail/components/extensions/child/.eslintrc.js b/comm/mail/components/extensions/child/.eslintrc.js
new file mode 100644
index 0000000000..970cd0874e
--- /dev/null
+++ b/comm/mail/components/extensions/child/.eslintrc.js
@@ -0,0 +1,15 @@
+"use strict";
+
+module.exports = {
+ globals: {
+ // These are defined in the WebExtension script scopes by ExtensionCommon.jsm.
+ // From toolkit/components/extensions/.eslintrc.js.
+ ExtensionAPI: true,
+ ExtensionCommon: true,
+ extensions: true,
+ ExtensionUtils: true,
+
+ // From toolkit/components/extensions/child/.eslintrc.js.
+ EventManager: true,
+ },
+};
diff --git a/comm/mail/components/extensions/child/ext-extensionScripts.js b/comm/mail/components/extensions/child/ext-extensionScripts.js
new file mode 100644
index 0000000000..5d5f364c3e
--- /dev/null
+++ b/comm/mail/components/extensions/child/ext-extensionScripts.js
@@ -0,0 +1,83 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+"use strict";
+
+var { ExtensionError } = ExtensionUtils;
+
+/**
+ * Represents (in the child extension process) a script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ExtensionPageContextChild} context
+ * The extension context which has registered the script.
+ * @param {string} scriptId
+ * An unique id that represents the registered script
+ * (generated and used internally to identify it across the different processes).
+ */
+class ExtensionScriptChild {
+ constructor(type, context, scriptId) {
+ this.type = type;
+ this.context = context;
+ this.scriptId = scriptId;
+ this.unregistered = false;
+ }
+
+ async unregister() {
+ if (this.unregistered) {
+ throw new ExtensionError("script already unregistered");
+ }
+
+ this.unregistered = true;
+
+ await this.context.childManager.callParentAsyncFunction(
+ "extensionScripts.unregister",
+ [this.scriptId]
+ );
+
+ this.context = null;
+ }
+
+ api() {
+ const { context } = this;
+
+ return {
+ unregister: () => {
+ return context.wrapPromise(this.unregister());
+ },
+ };
+ }
+}
+
+this.extensionScripts = class extends ExtensionAPI {
+ getAPI(context) {
+ let api = {
+ register(options) {
+ return context.cloneScope.Promise.resolve().then(async () => {
+ const scriptId = await context.childManager.callParentAsyncFunction(
+ "extensionScripts.register",
+ [this.type, options]
+ );
+
+ const registeredScript = new ExtensionScriptChild(
+ this.type,
+ context,
+ scriptId
+ );
+
+ return Cu.cloneInto(registeredScript.api(), context.cloneScope, {
+ cloneFunctions: true,
+ });
+ });
+ },
+ };
+
+ return {
+ composeScripts: { type: "compose", ...api },
+ messageDisplayScripts: { type: "messageDisplay", ...api },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/child/ext-mail.js b/comm/mail/components/extensions/child/ext-mail.js
new file mode 100644
index 0000000000..4c85692f91
--- /dev/null
+++ b/comm/mail/components/extensions/child/ext-mail.js
@@ -0,0 +1,28 @@
+/* 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/. */
+
+"use strict";
+
+extensions.registerModules({
+ extensionScripts: {
+ url: "chrome://messenger/content/child/ext-extensionScripts.js",
+ scopes: ["addon_child"],
+ paths: [["composeScripts"], ["messageDisplayScripts"]],
+ },
+ identity: {
+ url: "chrome://extensions/content/child/ext-identity.js",
+ scopes: ["addon_child"],
+ paths: [["identity"]],
+ },
+ menus: {
+ url: "chrome://messenger/content/child/ext-menus.js",
+ scopes: ["addon_child"],
+ paths: [["menus"]],
+ },
+ tabs: {
+ url: "chrome://messenger/content/child/ext-tabs.js",
+ scopes: ["addon_child"],
+ paths: [["tabs"]],
+ },
+});
diff --git a/comm/mail/components/extensions/child/ext-menus.js b/comm/mail/components/extensions/child/ext-menus.js
new file mode 100644
index 0000000000..a8dab40b15
--- /dev/null
+++ b/comm/mail/components/extensions/child/ext-menus.js
@@ -0,0 +1,290 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+"use strict";
+
+var { withHandlingUserInput } = ExtensionCommon;
+
+var { ExtensionError } = ExtensionUtils;
+
+// If id is not specified for an item we use an integer.
+// This ID need only be unique within a single addon. Since all addon code that
+// can use this API runs in the same process, this local variable suffices.
+var gNextMenuItemID = 0;
+
+// Map[Extension -> Map[string or id, ContextMenusClickPropHandler]]
+var gPropHandlers = new Map();
+
+// The menus API supports an "onclick" attribute in the create/update
+// methods to register a callback. This class manages these onclick properties.
+class ContextMenusClickPropHandler {
+ constructor(context) {
+ this.context = context;
+ // Map[string or integer -> callback]
+ this.onclickMap = new Map();
+ this.dispatchEvent = this.dispatchEvent.bind(this);
+ }
+
+ // A listener on menus.onClicked that forwards the event to the only
+ // listener, if any.
+ dispatchEvent(info, tab) {
+ let onclick = this.onclickMap.get(info.menuItemId);
+ if (onclick) {
+ // No need for runSafe or anything because we are already being run inside
+ // an event handler -- the event is just being forwarded to the actual
+ // handler.
+ withHandlingUserInput(this.context.contentWindow, () =>
+ onclick(info, tab)
+ );
+ }
+ }
+
+ // Sets the `onclick` handler for the given menu item.
+ // The `onclick` function MUST be owned by `this.context`.
+ setListener(id, onclick) {
+ if (this.onclickMap.size === 0) {
+ this.context.childManager
+ .getParentEvent("menus.onClicked")
+ .addListener(this.dispatchEvent);
+ this.context.callOnClose(this);
+ }
+ this.onclickMap.set(id, onclick);
+
+ let propHandlerMap = gPropHandlers.get(this.context.extension);
+ if (!propHandlerMap) {
+ propHandlerMap = new Map();
+ } else {
+ // If the current callback was created in a different context, remove it
+ // from the other context.
+ let propHandler = propHandlerMap.get(id);
+ if (propHandler && propHandler !== this) {
+ propHandler.unsetListener(id);
+ }
+ }
+ propHandlerMap.set(id, this);
+ gPropHandlers.set(this.context.extension, propHandlerMap);
+ }
+
+ // Deletes the `onclick` handler for the given menu item.
+ // The `onclick` function MUST be owned by `this.context`.
+ unsetListener(id) {
+ if (!this.onclickMap.delete(id)) {
+ return;
+ }
+ if (this.onclickMap.size === 0) {
+ this.context.childManager
+ .getParentEvent("menus.onClicked")
+ .removeListener(this.dispatchEvent);
+ this.context.forgetOnClose(this);
+ }
+ let propHandlerMap = gPropHandlers.get(this.context.extension);
+ propHandlerMap.delete(id);
+ if (propHandlerMap.size === 0) {
+ gPropHandlers.delete(this.context.extension);
+ }
+ }
+
+ // Deletes the `onclick` handler for the given menu item, if any, regardless
+ // of the context where it was created.
+ unsetListenerFromAnyContext(id) {
+ let propHandlerMap = gPropHandlers.get(this.context.extension);
+ let propHandler = propHandlerMap && propHandlerMap.get(id);
+ if (propHandler) {
+ propHandler.unsetListener(id);
+ }
+ }
+
+ // Remove all `onclick` handlers of the extension.
+ deleteAllListenersFromExtension() {
+ let propHandlerMap = gPropHandlers.get(this.context.extension);
+ if (propHandlerMap) {
+ for (let [id, propHandler] of propHandlerMap) {
+ propHandler.unsetListener(id);
+ }
+ }
+ }
+
+ // Removes all `onclick` handlers from this context.
+ close() {
+ for (let id of this.onclickMap.keys()) {
+ this.unsetListener(id);
+ }
+ }
+}
+
+this.menus = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+ let onClickedProp = new ContextMenusClickPropHandler(context);
+ let pendingMenuEvent;
+
+ return {
+ menus: {
+ create(createProperties, callback) {
+ let caller = context.getCaller();
+
+ if (extension.persistentBackground && createProperties.id === null) {
+ createProperties.id = ++gNextMenuItemID;
+ }
+ let { onclick } = createProperties;
+ if (onclick && !context.extension.persistentBackground) {
+ throw new ExtensionError(
+ `Property "onclick" cannot be used in menus.create, replace with an "onClicked" event listener.`
+ );
+ }
+ delete createProperties.onclick;
+ context.childManager
+ .callParentAsyncFunction("menus.create", [createProperties])
+ .then(() => {
+ if (onclick) {
+ onClickedProp.setListener(createProperties.id, onclick);
+ }
+ if (callback) {
+ context.runSafeWithoutClone(callback);
+ }
+ })
+ .catch(error => {
+ context.withLastError(error, caller, () => {
+ if (callback) {
+ context.runSafeWithoutClone(callback);
+ }
+ });
+ });
+ return createProperties.id;
+ },
+
+ update(id, updateProperties) {
+ let { onclick } = updateProperties;
+ if (onclick && !context.extension.persistentBackground) {
+ throw new ExtensionError(
+ `Property "onclick" cannot be used in menus.update, replace with an "onClicked" event listener.`
+ );
+ }
+ delete updateProperties.onclick;
+ return context.childManager
+ .callParentAsyncFunction("menus.update", [id, updateProperties])
+ .then(() => {
+ if (onclick) {
+ onClickedProp.setListener(id, onclick);
+ } else if (onclick === null) {
+ onClickedProp.unsetListenerFromAnyContext(id);
+ }
+ // else onclick is not set so it should not be changed.
+ });
+ },
+
+ remove(id) {
+ onClickedProp.unsetListenerFromAnyContext(id);
+ return context.childManager.callParentAsyncFunction("menus.remove", [
+ id,
+ ]);
+ },
+
+ removeAll() {
+ onClickedProp.deleteAllListenersFromExtension();
+
+ return context.childManager.callParentAsyncFunction(
+ "menus.removeAll",
+ []
+ );
+ },
+
+ overrideContext(contextOptions) {
+ let checkValidArg = (contextType, propKey) => {
+ if (contextOptions.context !== contextType) {
+ if (contextOptions[propKey]) {
+ throw new ExtensionError(
+ `Property "${propKey}" can only be used with context "${contextType}"`
+ );
+ }
+ return false;
+ }
+ if (contextOptions.showDefaults) {
+ throw new ExtensionError(
+ `Property "showDefaults" cannot be used with context "${contextType}"`
+ );
+ }
+ if (!contextOptions[propKey]) {
+ throw new ExtensionError(
+ `Property "${propKey}" is required for context "${contextType}"`
+ );
+ }
+ return true;
+ };
+ if (checkValidArg("tab", "tabId")) {
+ if (!context.extension.hasPermission("tabs")) {
+ throw new ExtensionError(
+ `The "tab" context requires the "tabs" permission.`
+ );
+ }
+ }
+ if (checkValidArg("bookmark", "bookmarkId")) {
+ if (!context.extension.hasPermission("bookmarks")) {
+ throw new ExtensionError(
+ `The "bookmark" context requires the "bookmarks" permission.`
+ );
+ }
+ }
+
+ let webExtContextData = {
+ extensionId: context.extension.id,
+ showDefaults: contextOptions.showDefaults,
+ overrideContext: contextOptions.context,
+ bookmarkId: contextOptions.bookmarkId,
+ tabId: contextOptions.tabId,
+ };
+
+ if (pendingMenuEvent) {
+ // overrideContext is called more than once during the same event.
+ pendingMenuEvent.webExtContextData = webExtContextData;
+ return;
+ }
+ pendingMenuEvent = {
+ webExtContextData,
+ observe(subject, topic, data) {
+ pendingMenuEvent = null;
+ Services.obs.removeObserver(this, "on-prepare-contextmenu");
+ subject = subject.wrappedJSObject;
+ if (context.principal.subsumes(subject.principal)) {
+ subject.setWebExtContextData(this.webExtContextData);
+ }
+ },
+ run() {
+ // "on-prepare-contextmenu" is expected to be observed before the
+ // end of the "contextmenu" event dispatch. This task is queued
+ // in case that does not happen, e.g. when the menu is not shown.
+ // ... or if the method was not called during a contextmenu event.
+ if (pendingMenuEvent === this) {
+ pendingMenuEvent = null;
+ Services.obs.removeObserver(this, "on-prepare-contextmenu");
+ }
+ },
+ };
+ Services.obs.addObserver(pendingMenuEvent, "on-prepare-contextmenu");
+ Services.tm.dispatchToMainThread(pendingMenuEvent);
+ },
+
+ onClicked: new EventManager({
+ context,
+ name: "menus.onClicked",
+ register: fire => {
+ let listener = (info, tab) => {
+ withHandlingUserInput(context.contentWindow, () =>
+ fire.sync(info, tab)
+ );
+ };
+
+ let event = context.childManager.getParentEvent("menus.onClicked");
+ event.addListener(listener);
+ return () => {
+ event.removeListener(listener);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/child/ext-tabs.js b/comm/mail/components/extensions/child/ext-tabs.js
new file mode 100644
index 0000000000..173c2b5f63
--- /dev/null
+++ b/comm/mail/components/extensions/child/ext-tabs.js
@@ -0,0 +1,23 @@
+/* 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/. */
+
+"use strict";
+
+this.tabs = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ tabs: {
+ connect(tabId, options) {
+ let { frameId = null, name = "" } = options || {};
+ return context.messenger.connect({ name, tabId, frameId });
+ },
+
+ sendMessage(tabId, message, options, callback) {
+ let arg = { tabId, frameId: options?.frameId, message, callback };
+ return context.messenger.sendRuntimeMessage(arg);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/ext-mail.json b/comm/mail/components/extensions/ext-mail.json
new file mode 100644
index 0000000000..fe02227612
--- /dev/null
+++ b/comm/mail/components/extensions/ext-mail.json
@@ -0,0 +1,171 @@
+{
+ "accounts": {
+ "url": "chrome://messenger/content/parent/ext-accounts.js",
+ "schema": "chrome://messenger/content/schemas/accounts.json",
+ "scopes": ["addon_parent"],
+ "paths": [["accounts"]]
+ },
+ "addressBook": {
+ "url": "chrome://messenger/content/parent/ext-addressBook.js",
+ "schema": "chrome://messenger/content/schemas/addressBook.json",
+ "scopes": ["addon_parent"],
+ "paths": [["addressBooks"], ["contacts"], ["mailingLists"]]
+ },
+ "browserAction": {
+ "url": "chrome://messenger/content/parent/ext-browserAction.js",
+ "schema": "chrome://messenger/content/schemas/browserAction.json",
+ "scopes": ["addon_parent"],
+ "manifest": ["action", "browser_action"],
+ "events": ["update", "uninstall"],
+ "paths": [["action"], ["browserAction"]]
+ },
+ "browsingData": {
+ "url": "chrome://extensions/content/parent/ext-browsingData.js",
+ "schema": "chrome://extensions/content/schemas/browsing_data.json",
+ "scopes": ["addon_parent"],
+ "paths": [["browsingData"]]
+ },
+ "chrome_settings_overrides": {
+ "url": "chrome://messenger/content/parent/ext-chrome-settings-overrides.js",
+ "scopes": [],
+ "events": ["update", "uninstall"],
+ "schema": "chrome://messenger/content/schemas/chrome_settings_overrides.json",
+ "manifest": ["chrome_settings_overrides"]
+ },
+ "cloudFile": {
+ "url": "chrome://messenger/content/parent/ext-cloudFile.js",
+ "schema": "chrome://messenger/content/schemas/cloudFile.json",
+ "scopes": ["addon_parent", "content_parent"],
+ "manifest": ["cloud_file"],
+ "paths": [["cloudFile"]]
+ },
+ "commands": {
+ "url": "chrome://messenger/content/parent/ext-commands.js",
+ "schema": "chrome://messenger/content/schemas/commands.json",
+ "scopes": ["addon_parent"],
+ "events": ["uninstall"],
+ "manifest": ["commands"],
+ "paths": [["commands"]]
+ },
+ "compose": {
+ "url": "chrome://messenger/content/parent/ext-compose.js",
+ "schema": "chrome://messenger/content/schemas/compose.json",
+ "scopes": ["addon_parent"],
+ "paths": [["compose"]]
+ },
+ "composeAction": {
+ "url": "chrome://messenger/content/parent/ext-composeAction.js",
+ "schema": "chrome://messenger/content/schemas/composeAction.json",
+ "scopes": ["addon_parent"],
+ "manifest": ["compose_action"],
+ "events": ["uninstall"],
+ "paths": [["composeAction"]]
+ },
+ "extensionScripts": {
+ "url": "chrome://messenger/content/parent/ext-extensionScripts.js",
+ "schema": "chrome://messenger/content/schemas/extensionScripts.json",
+ "scopes": ["addon_parent"],
+ "paths": [["extensionScripts"]]
+ },
+ "folders": {
+ "url": "chrome://messenger/content/parent/ext-folders.js",
+ "schema": "chrome://messenger/content/schemas/folders.json",
+ "scopes": ["addon_parent"],
+ "paths": [["folders"]]
+ },
+ "geckoProfiler": {
+ "url": "chrome://extensions/content/parent/ext-geckoProfiler.js",
+ "schema": "chrome://extensions/content/schemas/geckoProfiler.json",
+ "scopes": ["addon_parent"],
+ "paths": [["geckoProfiler"]]
+ },
+ "identities": {
+ "url": "chrome://messenger/content/parent/ext-identities.js",
+ "schema": "chrome://messenger/content/schemas/identities.json",
+ "scopes": ["addon_parent"],
+ "paths": [["identities"]]
+ },
+ "identity": {
+ "url": "chrome://extensions/content/parent/ext-identity.js",
+ "schema": "chrome://extensions/content/schemas/identity.json",
+ "scopes": ["addon_parent"],
+ "paths": [["identity"]]
+ },
+ "mailTabs": {
+ "url": "chrome://messenger/content/parent/ext-mailTabs.js",
+ "schema": "chrome://messenger/content/schemas/mailTabs.json",
+ "scopes": ["addon_parent"],
+ "manifest": ["mailTabs"],
+ "paths": [["mailTabs"]]
+ },
+ "menusChild": {
+ "schema": "chrome://messenger/content/schemas/menus_child.json",
+ "scopes": ["addon_child", "content_child", "devtools_child"]
+ },
+ "menus": {
+ "url": "chrome://messenger/content/parent/ext-menus.js",
+ "schema": "chrome://messenger/content/schemas/menus.json",
+ "scopes": ["addon_parent"],
+ "events": ["startup"],
+ "permissions": ["menus"],
+ "paths": [["menus"]]
+ },
+ "messageDisplay": {
+ "url": "chrome://messenger/content/parent/ext-messageDisplay.js",
+ "schema": "chrome://messenger/content/schemas/messageDisplay.json",
+ "scopes": ["addon_parent"],
+ "paths": [["messageDisplay"]]
+ },
+ "messageDisplayAction": {
+ "url": "chrome://messenger/content/parent/ext-messageDisplayAction.js",
+ "schema": "chrome://messenger/content/schemas/messageDisplayAction.json",
+ "scopes": ["addon_parent"],
+ "manifest": ["message_display_action"],
+ "events": ["uninstall"],
+ "paths": [["messageDisplayAction"]]
+ },
+ "messages": {
+ "url": "chrome://messenger/content/parent/ext-messages.js",
+ "schema": "chrome://messenger/content/schemas/messages.json",
+ "scopes": ["addon_parent"],
+ "manifest": ["messages"],
+ "paths": [["messages"]]
+ },
+ "pkcs11": {
+ "url": "chrome://messenger/content/parent/ext-pkcs11.js",
+ "schema": "chrome://messenger/content/schemas/pkcs11.json",
+ "scopes": ["addon_parent"],
+ "paths": [["pkcs11"]]
+ },
+ "sessions": {
+ "url": "chrome://messenger/content/parent/ext-sessions.js",
+ "schema": "chrome://messenger/content/schemas/sessions.json",
+ "scopes": ["addon_parent"],
+ "events": ["uninstall"],
+ "paths": [["sessions"]]
+ },
+ "spaces": {
+ "url": "chrome://messenger/content/parent/ext-spaces.js",
+ "schema": "chrome://messenger/content/schemas/spaces.json",
+ "scopes": ["addon_parent"],
+ "paths": [["spaces"]]
+ },
+ "spacesToolbar": {
+ "url": "chrome://messenger/content/parent/ext-spacesToolbar.js",
+ "schema": "chrome://messenger/content/schemas/spacesToolbar.json",
+ "scopes": ["addon_parent"],
+ "paths": [["spacesToolbar"]]
+ },
+ "tabs": {
+ "url": "chrome://messenger/content/parent/ext-tabs.js",
+ "schema": "chrome://messenger/content/schemas/tabs.json",
+ "scopes": ["addon_parent"],
+ "paths": [["tabs"]]
+ },
+ "windows": {
+ "url": "chrome://messenger/content/parent/ext-windows.js",
+ "schema": "chrome://messenger/content/schemas/windows.json",
+ "scopes": ["addon_parent"],
+ "paths": [["windows"]]
+ }
+}
diff --git a/comm/mail/components/extensions/extension.svg b/comm/mail/components/extensions/extension.svg
new file mode 100644
index 0000000000..a164552538
--- /dev/null
+++ b/comm/mail/components/extensions/extension.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ width="64" height="64" viewBox="0 0 64 64">
+ <defs>
+ <style>
+ .style-puzzle-piece {
+ fill: url('#gradient-linear-puzzle-piece');
+ }
+ </style>
+ <linearGradient id="gradient-linear-puzzle-piece" x1="0%" y1="0%" x2="0%" y2="100%">
+ <stop offset="0%" stop-color="#66cc52" stop-opacity="1"/>
+ <stop offset="100%" stop-color="#60bf4c" stop-opacity="1"/>
+ </linearGradient>
+ </defs>
+ <path class="style-puzzle-piece" d="M42,62c2.2,0,4-1.8,4-4l0-14.2c0,0,0.4-3.7,2.8-3.7c2.4,0,2.2,3.9,6.7,3.9c2.3,0,6.2-1.2,6.2-8.2 c0-7-3.9-7.9-6.2-7.9c-4.5,0-4.3,3.7-6.7,3.7c-2.4,0-2.8-3.8-2.8-3.8V22c0-2.2-1.8-4-4-4H31.5c0,0-3.4-0.6-3.4-3 c0-2.4,3.8-2.6,3.8-7.1c0-2.3-1.3-5.9-8.3-5.9s-8,3.6-8,5.9c0,4.5,3.4,4.7,3.4,7.1c0,2.4-3.4,3-3.4,3H6c-2.2,0-4,1.8-4,4l0,7.8 c0,0-0.4,6,4.4,6c3.1,0,3.2-4.1,7.3-4.1c2,0,4,1.9,4,6c0,4.2-2,6.3-4,6.3c-4,0-4.2-4.1-7.3-4.1c-4.8,0-4.4,5.8-4.4,5.8L2,58 c0,2.2,1.8,4,4,4H19c0,0,6.3,0.4,6.3-4.4c0-3.1-4-3.6-4-7.7c0-2,2.2-4.5,6.4-4.5c4.2,0,6.6,2.5,6.6,4.5c0,4-3.9,4.6-3.9,7.7 c0,4.9,6.3,4.4,6.3,4.4H42z"/>
+</svg>
diff --git a/comm/mail/components/extensions/extensionPopup.js b/comm/mail/components/extensions/extensionPopup.js
new file mode 100644
index 0000000000..ac0431e2ce
--- /dev/null
+++ b/comm/mail/components/extensions/extensionPopup.js
@@ -0,0 +1,557 @@
+/* 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 { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { BrowserUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/BrowserUtils.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PrintUtils",
+ "chrome://messenger/content/printUtils.js"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+});
+
+var gContextMenu;
+
+/* globals reporterListener */
+
+/**
+ * @implements {nsICommandController}
+ */
+var contentController = {
+ commands: {
+ cmd_reload: {
+ isEnabled() {
+ return !contentProgress.busy;
+ },
+ doCommand() {
+ document.getElementById("requestFrame").reload();
+ },
+ },
+ cmd_stop: {
+ isEnabled() {
+ return contentProgress.busy;
+ },
+ doCommand() {
+ document.getElementById("requestFrame").stop();
+ },
+ },
+ "Browser:Back": {
+ isEnabled() {
+ return gBrowser.canGoBack;
+ },
+ doCommand() {
+ gBrowser.goBack();
+ },
+ },
+ "Browser:Forward": {
+ isEnabled() {
+ return gBrowser.canGoForward;
+ },
+ doCommand() {
+ gBrowser.goForward();
+ },
+ },
+ },
+
+ supportsCommand(command) {
+ return command in this.commands;
+ },
+ isCommandEnabled(command) {
+ if (!this.supportsCommand(command)) {
+ return false;
+ }
+ let cmd = this.commands[command];
+ return cmd.isEnabled();
+ },
+ doCommand(command) {
+ if (!this.supportsCommand(command)) {
+ return;
+ }
+ let cmd = this.commands[command];
+ if (!cmd.isEnabled()) {
+ return;
+ }
+ cmd.doCommand();
+ },
+ onEvent(event) {},
+};
+
+/**
+ * @implements {nsIBrowserDOMWindow}
+ */
+class nsBrowserAccess {
+ QueryInterface = ChromeUtils.generateQI(["nsIBrowserDOMWindow"]);
+
+ _openURIInNewTab(
+ aURI,
+ aReferrerInfo,
+ aIsExternal,
+ aOpenWindowInfo = null,
+ aTriggeringPrincipal = null,
+ aCsp = null,
+ aSkipLoad = false,
+ aMessageManagerGroup = null
+ ) {
+ // This is a popup which must not have more than one tab, so open the new tab
+ // in the most recent mail window.
+ let win = Services.wm.getMostRecentWindow("mail:3pane", true);
+
+ if (!win) {
+ // We couldn't find a suitable window, a new one needs to be opened.
+ return null;
+ }
+
+ let loadInBackground = Services.prefs.getBoolPref(
+ "browser.tabs.loadDivertedInBackground"
+ );
+
+ let tabmail = win.document.getElementById("tabmail");
+ let newTab = tabmail.openTab("contentTab", {
+ background: loadInBackground,
+ csp: aCsp,
+ linkHandler: aMessageManagerGroup,
+ openWindowInfo: aOpenWindowInfo,
+ referrerInfo: aReferrerInfo,
+ skipLoad: aSkipLoad,
+ triggeringPrincipal: aTriggeringPrincipal,
+ url: aURI ? aURI.spec : "about:blank",
+ });
+
+ win.focus();
+
+ return newTab.browser;
+ }
+
+ createContentWindow(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp
+ ) {
+ throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ createContentWindowInFrame(aURI, aParams, aWhere, aFlags, aName) {
+ // Passing a null-URI to only create the content window,
+ // and pass true for aSkipLoad to prevent loading of
+ // about:blank
+ return this.getContentWindowOrOpenURIInFrame(
+ null,
+ aParams,
+ aWhere,
+ aFlags,
+ aName,
+ true
+ );
+ }
+
+ openURI(aURI, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) {
+ throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ openURIInFrame(aURI, aParams, aWhere, aFlags, aName) {
+ throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ getContentWindowOrOpenURI(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp,
+ aSkipLoad
+ ) {
+ throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ getContentWindowOrOpenURIInFrame(
+ aURI,
+ aParams,
+ aWhere,
+ aFlags,
+ aName,
+ aSkipLoad
+ ) {
+ if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) {
+ return PrintUtils.handleStaticCloneCreatedForPrint(
+ aParams.openWindowInfo
+ );
+ }
+
+ if (aWhere != Ci.nsIBrowserDOMWindow.OPEN_NEWTAB) {
+ Services.console.logStringMessage(
+ "Error: openURIInFrame can only open in new tabs or print"
+ );
+ return null;
+ }
+
+ let isExternal = !!(aFlags & Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL);
+
+ return this._openURIInNewTab(
+ aURI,
+ aParams.referrerInfo,
+ isExternal,
+ aParams.openWindowInfo,
+ aParams.triggeringPrincipal,
+ aParams.csp,
+ aSkipLoad,
+ aParams.openerBrowser?.getAttribute("messagemanagergroup")
+ );
+ }
+
+ canClose() {
+ return true;
+ }
+
+ get tabCount() {
+ return 1;
+ }
+}
+
+function loadRequestedUrl() {
+ let browser = document.getElementById("requestFrame");
+ browser.addProgressListener(reporterListener, Ci.nsIWebProgress.NOTIFY_ALL);
+ browser.addEventListener(
+ "DOMWindowClose",
+ () => {
+ if (browser.getAttribute("allowscriptstoclose") == "true") {
+ window.close();
+ }
+ },
+ true
+ );
+ browser.addEventListener(
+ "pagetitlechanged",
+ () => gBrowser.updateTitlebar(),
+ true
+ );
+
+ // This window does double duty. If window.arguments[0] is a string, it's
+ // probably being called by browser.identity.launchWebAuthFlowInParent.
+
+ // Otherwise, it's probably being called by browser.windows.create, with an
+ // array of URLs to open in tabs. We'll only attempt to open the first,
+ // which is consistent with Firefox behaviour.
+
+ if (typeof window.arguments[0] == "string") {
+ MailE10SUtils.loadURI(browser, window.arguments[0]);
+ } else {
+ if (window.arguments[1].wrappedJSObject.allowScriptsToClose) {
+ browser.setAttribute("allowscriptstoclose", "true");
+ }
+ let tabParams = window.arguments[1].wrappedJSObject.tabs[0].tabParams;
+ if (tabParams.userContextId) {
+ browser.setAttribute("usercontextid", tabParams.userContextId);
+ // The usercontextid is only read on frame creation, so recreate it.
+ browser.replaceWith(browser);
+ }
+ ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+ MailE10SUtils.loadURI(browser, tabParams.url);
+ }
+}
+
+// Fake it 'til you make it.
+var gBrowser = {
+ get canGoBack() {
+ return this.selectedBrowser.canGoBack;
+ },
+
+ get canGoForward() {
+ return this.selectedBrowser.canGoForward;
+ },
+
+ goForward(requireUserInteraction) {
+ return this.selectedBrowser.goForward(requireUserInteraction);
+ },
+
+ goBack(requireUserInteraction) {
+ return this.selectedBrowser.goBack(requireUserInteraction);
+ },
+
+ get selectedBrowser() {
+ return document.getElementById("requestFrame");
+ },
+ _getAndMaybeCreateDateTimePickerPanel() {
+ return this.selectedBrowser.dateTimePicker;
+ },
+ get webNavigation() {
+ return this.selectedBrowser.webNavigation;
+ },
+ async updateTitlebar() {
+ let docTitle =
+ browser.browsingContext?.currentWindowGlobal?.documentTitle?.trim() || "";
+ if (!docTitle) {
+ // If the document title is blank, use the default title.
+ docTitle = await document.l10n.formatValue(
+ "extension-popup-default-title"
+ );
+ } else {
+ // Let l10n handle the addition of separator and modifier.
+ docTitle = await document.l10n.formatValue("extension-popup-title", {
+ title: docTitle,
+ });
+ }
+
+ // Add preface, if defined.
+ let docElement = document.documentElement;
+ if (docElement.hasAttribute("titlepreface")) {
+ docTitle = docElement.getAttribute("titlepreface") + docTitle;
+ }
+
+ document.title = docTitle;
+ },
+ getTabForBrowser(browser) {
+ return null;
+ },
+};
+
+this.__defineGetter__("browser", getBrowser);
+
+function getBrowser() {
+ return gBrowser.selectedBrowser;
+}
+
+var gBrowserInit = {
+ onDOMContentLoaded() {
+ // This needs setting up before we create the first remote browser.
+ window.docShell.treeOwner
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).XULBrowserWindow = window.XULBrowserWindow;
+
+ window.tryToClose = () => {
+ if (window.onclose()) {
+ window.close();
+ }
+ };
+
+ window.onclose = event => {
+ let { permitUnload } = gBrowser.selectedBrowser.permitUnload();
+ return permitUnload;
+ };
+
+ window.browserDOMWindow = new nsBrowserAccess();
+
+ let initiallyFocusedElement = document.commandDispatcher.focusedElement;
+ let promise = gBrowser.selectedBrowser.isRemoteBrowser
+ ? PromiseUtils.defer().promise
+ : Promise.resolve();
+
+ contentProgress.addListener({
+ onStateChange(browser, webProgress, request, stateFlags, statusCode) {
+ if (!webProgress.isTopLevel) {
+ return;
+ }
+
+ let status;
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) {
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ status = "loading";
+ } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ status = "complete";
+ }
+ } else if (
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ statusCode == Cr.NS_BINDING_ABORTED
+ ) {
+ status = "complete";
+ }
+
+ contentProgress.busy = status == "loading";
+ },
+ });
+ contentProgress.addProgressListenerToBrowser(gBrowser.selectedBrowser);
+
+ top.controllers.appendController(contentController);
+
+ promise.then(() => {
+ // If focus didn't move while we were waiting, we're okay to move to
+ // the browser.
+ if (
+ document.commandDispatcher.focusedElement == initiallyFocusedElement
+ ) {
+ gBrowser.selectedBrowser.focus();
+ }
+ loadRequestedUrl();
+ });
+ },
+
+ isAdoptingTab() {
+ // Required for compatibility with toolkit's ext-webNavigation.js
+ return false;
+ },
+};
+
+/**
+ * @implements {nsIXULBrowserWindow}
+ */
+var XULBrowserWindow = {
+ // Used in mailWindows to show the link in the status bar, but popup windows
+ // do not have one. 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() {
+ // Popup windows have a single tab.
+ return 1;
+ },
+};
+
+/**
+ * Combines all nsIWebProgress notifications from all content browsers in this
+ * window and reports them to the registered listeners.
+ *
+ * @see WindowTracker (ext-mail.js)
+ * @see StatusListener, WindowTrackerBase (ext-tabs-base.js)
+ */
+var contentProgress = {
+ _listeners: new Set(),
+ busy: false,
+
+ addListener(listener) {
+ this._listeners.add(listener);
+ },
+
+ removeListener(listener) {
+ this._listeners.delete(listener);
+ },
+
+ callListeners(method, args) {
+ for (let listener of this._listeners.values()) {
+ if (method in listener) {
+ try {
+ listener[method](...args);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+ },
+
+ /**
+ * Ensure that `browser` has a ProgressListener attached to it.
+ *
+ * @param {Browser} browser
+ */
+ addProgressListenerToBrowser(browser) {
+ if (browser?.webProgress && !browser._progressListener) {
+ browser._progressListener = new contentProgress.ProgressListener(browser);
+ browser.webProgress.addProgressListener(
+ browser._progressListener,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ }
+ },
+
+ // @implements {nsIWebProgressListener}
+ // @implements {nsIWebProgressListener2}
+ ProgressListener: class {
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ "nsISupportsWeakReference",
+ ]);
+
+ constructor(browser) {
+ this.browser = browser;
+ }
+
+ callListeners(method, args) {
+ args.unshift(this.browser);
+ contentProgress.callListeners(method, args);
+ }
+
+ onProgressChange(...args) {
+ this.callListeners("onProgressChange", args);
+ }
+
+ onProgressChange64(...args) {
+ this.callListeners("onProgressChange64", args);
+ }
+
+ onLocationChange(...args) {
+ this.callListeners("onLocationChange", args);
+ }
+
+ onStateChange(...args) {
+ this.callListeners("onStateChange", args);
+ }
+
+ onStatusChange(...args) {
+ this.callListeners("onStatusChange", args);
+ }
+
+ onSecurityChange(...args) {
+ this.callListeners("onSecurityChange", args);
+ }
+
+ onContentBlockingEvent(...args) {
+ this.callListeners("onContentBlockingEvent", args);
+ }
+
+ onRefreshAttempted(...args) {
+ return this.callListeners("onRefreshAttempted", args);
+ }
+ },
+};
+
+// The listener of DOMContentLoaded must be set on window, rather than
+// document, because the window can go away before the event is fired.
+// In that case, we don't want to initialize anything, otherwise we
+// may be leaking things because they will never be destroyed after.
+window.addEventListener(
+ "DOMContentLoaded",
+ gBrowserInit.onDOMContentLoaded.bind(gBrowserInit),
+ { once: true }
+);
diff --git a/comm/mail/components/extensions/extensionPopup.xhtml b/comm/mail/components/extensions/extensionPopup.xhtml
new file mode 100644
index 0000000000..f12ca3e182
--- /dev/null
+++ b/comm/mail/components/extensions/extensionPopup.xhtml
@@ -0,0 +1,92 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://global/skin/popup.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/tabmail.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/browserRequest.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/extensionPopup.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+
+<!DOCTYPE html [
+ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+ %brandDTD;
+ <!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd">
+ %messengerDTD;
+]>
+<html id="browserRequest" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="mail:extensionPopup"
+ width="800" height="500"
+ scrolling="false">
+<head>
+ <title data-l10n-id="extension-popup-default-title"></title>
+ <link rel="localization" href="branding/brand.ftl"/>
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="messenger/extensions/popup.ftl"/>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/viewZoomOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/browserRequest.js"></script>
+ <script defer="defer" src="chrome://messenger/content/browserPopups.js"></script>
+ <script defer="defer" src="chrome://messenger/content/extensionPopup.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <popupset id="mainPopupSet">
+ <tooltip id="aHTMLTooltip" page="true"/>
+#include ../../base/content/widgets/browserPopups.inc.xhtml
+ </popupset>
+
+ <commandset>
+ <command id="cmd_copyLink" oncommand="goDoCommand('cmd_copyLink')" disabled="false"/>
+ <command id="cmd_copyImage" oncommand="goDoCommand('cmd_copyImageContents')" disabled="false"/>
+ <command id="cmd_close" oncommand="window.tryToClose()"/>
+ <command id="cmd_reload" oncommand="goDoCommand('cmd_reload');"/>
+ <command id="cmd_stop" oncommand="goDoCommand('cmd_stop');"/>
+ <command id="Browser:Back" oncommand="goDoCommand('Browser:Back');"/>
+ <command id="Browser:Forward" oncommand="goDoCommand('Browser:Forward');"/>
+ </commandset>
+
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+ <keyset id="popupKeys">
+ <key id="key_close" data-l10n-id="close-shortcut" command="cmd_close" modifiers="accel" reserved="true"/>
+ </keyset>
+
+ <keyset id="browserKeys">
+ #ifdef XP_MACOSX
+ <key id="key_goBackKb" keycode="VK_LEFT" oncommand="gBrowser.goBack()" modifiers="accel"/>
+ <key id="key_goForwardKb" keycode="VK_RIGHT" oncommand="gBrowser.goForward()" modifiers="accel"/>
+ #else
+ <key id="key_goBackKb" keycode="VK_LEFT" oncommand="gBrowser.goBack()" modifiers="alt" />
+ <key id="key_goForwardKb" keycode="VK_RIGHT" oncommand="gBrowser.goForward()" modifiers="alt" />
+ #endif
+ </keyset>
+
+ <!-- Use the same styling and semantics as content tabs. -->
+ <html:div id="header" class="contentTabAddress">
+ <html:img id="security-icon" class="contentTabSecurity" />
+ <html:input id="headerMessage" class="contentTabUrlInput themeableSearchBox"
+ readonly="readonly" />
+ </html:div>
+ <stack flex="1">
+ <browser id="requestFrame"
+ type="content"
+ src="about:blank"
+ flex="1"
+ tooltip="aHTMLTooltip"
+ autocompletepopup="PopupAutoComplete"
+ context="browserContext"
+ messagemanagergroup="single-site"/>
+ </stack>
+</html:body>
+</html>
diff --git a/comm/mail/components/extensions/extensions-mail.manifest b/comm/mail/components/extensions/extensions-mail.manifest
new file mode 100644
index 0000000000..314ab8f31b
--- /dev/null
+++ b/comm/mail/components/extensions/extensions-mail.manifest
@@ -0,0 +1,4 @@
+category webextension-modules mail chrome://messenger/content/ext-mail.json
+
+category webextension-scripts c-mail chrome://messenger/content/parent/ext-mail.js
+category webextension-scripts-addon mail chrome://messenger/content/child/ext-mail.js
diff --git a/comm/mail/components/extensions/jar.mn b/comm/mail/components/extensions/jar.mn
new file mode 100644
index 0000000000..defc845af2
--- /dev/null
+++ b/comm/mail/components/extensions/jar.mn
@@ -0,0 +1,68 @@
+# 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/.
+
+messenger.jar:
+ content/messenger/ext-mail.json (ext-mail.json)
+ content/messenger/extension.svg (extension.svg)
+ content/messenger/extensionPopup.js (extensionPopup.js)
+* content/messenger/extensionPopup.xhtml (extensionPopup.xhtml)
+ content/messenger/processScript.js (processScript.js)
+
+ content/messenger/child/ext-extensionScripts.js (child/ext-extensionScripts.js)
+ content/messenger/child/ext-mail.js (child/ext-mail.js)
+ content/messenger/child/ext-menus.js (child/ext-menus.js)
+ content/messenger/child/ext-tabs.js (child/ext-tabs.js)
+
+ content/messenger/parent/ext-accounts.js (parent/ext-accounts.js)
+ content/messenger/parent/ext-addressBook.js (parent/ext-addressBook.js)
+ content/messenger/parent/ext-browserAction.js (parent/ext-browserAction.js)
+ content/messenger/parent/ext-chrome-settings-overrides.js (parent/ext-chrome-settings-overrides.js)
+ content/messenger/parent/ext-cloudFile.js (parent/ext-cloudFile.js)
+ content/messenger/parent/ext-commands.js (parent/ext-commands.js)
+ content/messenger/parent/ext-compose.js (parent/ext-compose.js)
+ content/messenger/parent/ext-composeAction.js (parent/ext-composeAction.js)
+ content/messenger/parent/ext-extensionScripts.js (parent/ext-extensionScripts.js)
+ content/messenger/parent/ext-folders.js (parent/ext-folders.js)
+ content/messenger/parent/ext-identities.js (parent/ext-identities.js)
+ content/messenger/parent/ext-mail.js (parent/ext-mail.js)
+ content/messenger/parent/ext-mailTabs.js (parent/ext-mailTabs.js)
+ content/messenger/parent/ext-menus.js (parent/ext-menus.js)
+ content/messenger/parent/ext-messageDisplay.js (parent/ext-messageDisplay.js)
+ content/messenger/parent/ext-messageDisplayAction.js (parent/ext-messageDisplayAction.js)
+ content/messenger/parent/ext-messages.js (parent/ext-messages.js)
+ content/messenger/parent/ext-pkcs11.js (/browser/components/extensions/parent/ext-pkcs11.js)
+ content/messenger/parent/ext-sessions.js (parent/ext-sessions.js)
+ content/messenger/parent/ext-spaces.js (parent/ext-spaces.js)
+ content/messenger/parent/ext-spacesToolbar.js (parent/ext-spacesToolbar.js)
+ content/messenger/parent/ext-tabs.js (parent/ext-tabs.js)
+ content/messenger/parent/ext-theme.js (parent/ext-theme.js)
+ content/messenger/parent/ext-windows.js (parent/ext-windows.js)
+
+ content/messenger/schemas/accounts.json (schemas/accounts.json)
+ content/messenger/schemas/addressBook.json (schemas/addressBook.json)
+ content/messenger/schemas/browserAction.json (schemas/browserAction.json)
+ content/messenger/schemas/chrome_settings_overrides.json (schemas/chrome_settings_overrides.json)
+ content/messenger/schemas/cloudFile.json (schemas/cloudFile.json)
+ content/messenger/schemas/commands.json (schemas/commands.json)
+ content/messenger/schemas/compose.json (schemas/compose.json)
+ content/messenger/schemas/composeAction.json (schemas/composeAction.json)
+ content/messenger/schemas/extensionScripts.json (schemas/extensionScripts.json)
+ content/messenger/schemas/folders.json (schemas/folders.json)
+ content/messenger/schemas/identities.json (schemas/identities.json)
+ content/messenger/schemas/mailTabs.json (schemas/mailTabs.json)
+ content/messenger/schemas/menus.json (schemas/menus.json)
+ content/messenger/schemas/menus_child.json (schemas/menus_child.json)
+ content/messenger/schemas/messageDisplay.json (schemas/messageDisplay.json)
+ content/messenger/schemas/messageDisplayAction.json (schemas/messageDisplayAction.json)
+ content/messenger/schemas/messages.json (schemas/messages.json)
+ content/messenger/schemas/pkcs11.json (/browser/components/extensions/schemas/pkcs11.json)
+ content/messenger/schemas/sessions.json (schemas/sessions.json)
+ content/messenger/schemas/spaces.json (schemas/spaces.json)
+ content/messenger/schemas/spacesToolbar.json (schemas/spacesToolbar.json)
+ content/messenger/schemas/tabs.json (schemas/tabs.json)
+ content/messenger/schemas/theme.json (schemas/theme.json)
+ content/messenger/schemas/windows.json (schemas/windows.json)
+
+% override chrome://extensions/content/schemas/theme.json chrome://messenger/content/schemas/theme.json
+% override chrome://extensions/content/parent/ext-theme.js chrome://messenger/content/parent/ext-theme.js
diff --git a/comm/mail/components/extensions/moz.build b/comm/mail/components/extensions/moz.build
new file mode 100644
index 0000000000..7ee56cdfe5
--- /dev/null
+++ b/comm/mail/components/extensions/moz.build
@@ -0,0 +1,27 @@
+# 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/.
+
+EXTRA_COMPONENTS += [
+ "extensions-mail.manifest",
+]
+
+EXTRA_JS_MODULES += [
+ "ExtensionBrowsingData.sys.mjs",
+ "ExtensionPopups.sys.mjs",
+ "ExtensionToolbarButtons.jsm",
+ "MailExtensionShortcuts.jsm",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+TESTING_JS_MODULES += [
+ "test/AppUiTestDelegate.sys.mjs",
+]
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"]
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/xpcshell/xpcshell-imap.ini",
+ "test/xpcshell/xpcshell-local.ini",
+ "test/xpcshell/xpcshell-nntp.ini",
+]
diff --git a/comm/mail/components/extensions/parent/.eslintrc.js b/comm/mail/components/extensions/parent/.eslintrc.js
new file mode 100644
index 0000000000..73279358eb
--- /dev/null
+++ b/comm/mail/components/extensions/parent/.eslintrc.js
@@ -0,0 +1,81 @@
+"use strict";
+
+module.exports = {
+ globals: {
+ // These are defined in the WebExtension script scopes by ExtensionCommon.jsm.
+ // From toolkit/components/extensions/.eslintrc.js.
+ ExtensionAPI: true,
+ ExtensionAPIPersistent: true,
+ ExtensionCommon: true,
+ ExtensionUtils: true,
+ extensions: true,
+ global: true,
+ Services: true,
+
+ // From toolkit/components/extensions/parent/.eslintrc.js.
+ CONTAINER_STORE: true,
+ DEFAULT_STORE: true,
+ EventEmitter: true,
+ EventManager: true,
+ InputEventManager: true,
+ PRIVATE_STORE: true,
+ TabBase: true,
+ TabManagerBase: true,
+ TabTrackerBase: true,
+ WindowBase: true,
+ WindowManagerBase: true,
+ WindowTrackerBase: true,
+ getContainerForCookieStoreId: true,
+ getUserContextIdForCookieStoreId: true,
+ getCookieStoreIdForOriginAttributes: true,
+ getCookieStoreIdForContainer: true,
+ getCookieStoreIdForTab: true,
+ isContainerCookieStoreId: true,
+ isDefaultCookieStoreId: true,
+ isPrivateCookieStoreId: true,
+ isValidCookieStoreId: true,
+
+ // These are defined in ext-mail.js.
+ ADDRESS_BOOK_WINDOW_URI: true,
+ COMPOSE_WINDOW_URI: true,
+ MAIN_WINDOW_URI: true,
+ MESSAGE_WINDOW_URI: true,
+ MESSAGE_PROTOCOLS: true,
+ NOTIFICATION_COLLAPSE_TIME: true,
+ ExtensionError: true,
+ Tab: true,
+ TabmailTab: true,
+ Window: true,
+ TabmailWindow: true,
+ clickModifiersFromEvent: true,
+ convertFolder: true,
+ convertAccount: true,
+ traverseSubfolders: true,
+ convertMailIdentity: true,
+ convertMessage: true,
+ folderPathToURI: true,
+ folderURIToPath: true,
+ getNormalWindowReady: true,
+ getRealFileForFile: true,
+ getTabBrowser: true,
+ getTabTabmail: true,
+ getTabWindow: true,
+ messageListTracker: true,
+ messageTracker: true,
+ nsDummyMsgHeader: true,
+ spaceTracker: true,
+ tabGetSender: true,
+ tabTracker: true,
+ windowTracker: true,
+
+ // ext-browserAction.js
+ browserActionFor: true,
+ },
+ rules: {
+ // From toolkit/components/extensions/.eslintrc.js.
+ // Disable reject-importGlobalProperties because we don't want to include
+ // these in the sandbox directly as that would potentially mean the
+ // imported properties would be instantiated up-front rather than lazily.
+ "mozilla/reject-importGlobalProperties": "off",
+ },
+};
diff --git a/comm/mail/components/extensions/parent/ext-accounts.js b/comm/mail/components/extensions/parent/ext-accounts.js
new file mode 100644
index 0000000000..2388f896c7
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-accounts.js
@@ -0,0 +1,283 @@
+/* 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/. */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * @implements {nsIObserver}
+ * @implements {nsIMsgFolderListener}
+ */
+var accountsTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.monitoredAccounts = new Map();
+
+ // Keep track of accounts data monitored for changes.
+ for (let nativeAccount of MailServices.accounts.accounts) {
+ this.monitoredAccounts.set(
+ nativeAccount.key,
+ this.getMonitoredProperties(nativeAccount)
+ );
+ }
+ }
+
+ getMonitoredProperties(nativeAccount) {
+ return {
+ name: nativeAccount.incomingServer.prettyName,
+ defaultIdentityKey: nativeAccount.defaultIdentity?.key,
+ };
+ }
+
+ getChangedMonitoredProperty(nativeAccount, propertyName) {
+ if (!nativeAccount || !this.monitoredAccounts.has(nativeAccount.key)) {
+ return false;
+ }
+ let values = this.monitoredAccounts.get(nativeAccount.key);
+ let propertyValue =
+ this.getMonitoredProperties(nativeAccount)[propertyName];
+ if (propertyValue && values[propertyName] != propertyValue) {
+ values[propertyName] = propertyValue;
+ this.monitoredAccounts.set(nativeAccount.key, values);
+ return propertyValue;
+ }
+ return false;
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ // nsIMsgFolderListener
+ MailServices.mfn.addListener(this, MailServices.mfn.folderAdded);
+ Services.prefs.addObserver("mail.server.", this);
+ Services.prefs.addObserver("mail.account.", this);
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ MailServices.mfn.removeListener(this);
+ Services.prefs.removeObserver("mail.server.", this);
+ Services.prefs.removeObserver("mail.account.", this);
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ }
+ }
+
+ // nsIMsgFolderListener
+ folderAdded(folder) {
+ // If the account of this folder is unknown, it is new and this is the
+ // initial root folder after the account has been created.
+ let server = folder.server;
+ let nativeAccount = MailServices.accounts.FindAccountForServer(server);
+ if (nativeAccount && !this.monitoredAccounts.has(nativeAccount.key)) {
+ this.monitoredAccounts.set(
+ nativeAccount.key,
+ this.getMonitoredProperties(nativeAccount)
+ );
+ let account = convertAccount(nativeAccount, false);
+ this.emit("account-added", nativeAccount.key, account);
+ }
+ }
+
+ // nsIObserver
+ _notifications = ["message-account-removed"];
+
+ async observe(subject, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ {
+ let [, type, key, property] = data.split(".");
+
+ if (type == "server" && property == "name") {
+ let server;
+ try {
+ server = MailServices.accounts.getIncomingServer(key);
+ } catch (ex) {
+ // Fails for servers being removed.
+ return;
+ }
+ let nativeAccount =
+ MailServices.accounts.FindAccountForServer(server);
+
+ let name = this.getChangedMonitoredProperty(nativeAccount, "name");
+ if (name) {
+ this.emit("account-updated", nativeAccount.key, {
+ id: nativeAccount.key,
+ name,
+ });
+ }
+ }
+
+ if (type == "account" && property == "identities") {
+ let nativeAccount = MailServices.accounts.getAccount(key);
+
+ let defaultIdentityKey = this.getChangedMonitoredProperty(
+ nativeAccount,
+ "defaultIdentityKey"
+ );
+ if (defaultIdentityKey) {
+ this.emit("account-updated", nativeAccount.key, {
+ id: nativeAccount.key,
+ defaultIdentity: convertMailIdentity(
+ nativeAccount,
+ nativeAccount.defaultIdentity
+ ),
+ });
+ }
+ }
+ }
+ break;
+
+ case "message-account-removed":
+ if (this.monitoredAccounts.has(data)) {
+ this.monitoredAccounts.delete(data);
+ this.emit("account-removed", data);
+ }
+ break;
+ }
+ }
+})();
+
+this.accounts = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCreated({ context, fire }) {
+ async function listener(_event, key, account) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, account);
+ }
+ accountsTracker.on("account-added", listener);
+ return {
+ unregister: () => {
+ accountsTracker.off("account-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onUpdated({ context, fire }) {
+ async function listener(_event, key, changedValues) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, changedValues);
+ }
+ accountsTracker.on("account-updated", listener);
+ return {
+ unregister: () => {
+ accountsTracker.off("account-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ async function listener(_event, key) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key);
+ }
+ accountsTracker.on("account-removed", listener);
+ return {
+ unregister: () => {
+ accountsTracker.off("account-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ constructor(...args) {
+ super(...args);
+ accountsTracker.incrementListeners();
+ }
+
+ onShutdown() {
+ accountsTracker.decrementListeners();
+ }
+
+ getAPI(context) {
+ return {
+ accounts: {
+ async list(includeFolders) {
+ let accounts = [];
+ for (let account of MailServices.accounts.accounts) {
+ account = convertAccount(account, includeFolders);
+ if (account) {
+ accounts.push(account);
+ }
+ }
+ return accounts;
+ },
+ async get(accountId, includeFolders) {
+ let account = MailServices.accounts.getAccount(accountId);
+ return convertAccount(account, includeFolders);
+ },
+ async getDefault(includeFolders) {
+ let account = MailServices.accounts.defaultAccount;
+ return convertAccount(account, includeFolders);
+ },
+ async getDefaultIdentity(accountId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ return convertMailIdentity(account, account?.defaultIdentity);
+ },
+ async setDefaultIdentity(accountId, identityId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ if (!account) {
+ throw new ExtensionError(`Account not found: ${accountId}`);
+ }
+ for (let identity of account.identities) {
+ if (identity.key == identityId) {
+ account.defaultIdentity = identity;
+ return;
+ }
+ }
+ throw new ExtensionError(
+ `Identity ${identityId} not found for ${accountId}`
+ );
+ },
+ onCreated: new EventManager({
+ context,
+ module: "accounts",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "accounts",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "accounts",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-addressBook.js b/comm/mail/components/extensions/parent/ext-addressBook.js
new file mode 100644
index 0000000000..14b0ce8cd0
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-addressBook.js
@@ -0,0 +1,1587 @@
+/* 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 { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { AddrBookDirectory } = ChromeUtils.import(
+ "resource:///modules/AddrBookDirectory.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["fetch", "File", "FileReader"]);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ newUID: "resource:///modules/AddrBookUtils.jsm",
+ AddrBookCard: "resource:///modules/AddrBookCard.jsm",
+ BANISHED_PROPERTIES: "resource:///modules/VCardUtils.jsm",
+ VCardProperties: "resource:///modules/VCardUtils.jsm",
+ VCardPropertyEntry: "resource:///modules/VCardUtils.jsm",
+ VCardUtils: "resource:///modules/VCardUtils.jsm",
+});
+
+// nsIAbCard.idl contains a list of properties that Thunderbird uses. Extensions are not
+// restricted to using only these properties, but the following properties cannot
+// be modified by an extension.
+const hiddenProperties = [
+ "DbRowID",
+ "LowercasePrimaryEmail",
+ "LastModifiedDate",
+ "PopularityIndex",
+ "RecordKey",
+ "UID",
+ "_etag",
+ "_href",
+ "_vCard",
+ "vCard",
+ "PhotoName",
+ "PhotoURL",
+ "PhotoType",
+];
+
+/**
+ * Reads a DOM File and returns a Promise for its dataUrl.
+ *
+ * @param {File} file
+ * @returns {string}
+ */
+function getDataUrl(file) {
+ return new Promise((resolve, reject) => {
+ var reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = function () {
+ resolve(reader.result);
+ };
+ reader.onerror = function (error) {
+ reject(new ExtensionError(error));
+ };
+ });
+}
+
+/**
+ * Returns the image type of the given contentType string, or throws if the
+ * contentType is not an image type supported by the address book.
+ *
+ * @param {string} contentType - The contentType of a photo.
+ * @returns {string} - Either "png" or "jpeg". Throws otherwise.
+ */
+function getImageType(contentType) {
+ let typeParts = contentType.toLowerCase().split("/");
+ if (typeParts[0] != "image" || !["jpeg", "png"].includes(typeParts[1])) {
+ throw new ExtensionError(`Unsupported image format: ${contentType}`);
+ }
+ return typeParts[1];
+}
+
+/**
+ * Adds a PHOTO VCardPropertyEntry for the given photo file.
+ *
+ * @param {VCardProperties} vCardProperties
+ * @param {File} photoFile
+ * @returns {VCardPropertyEntry}
+ */
+async function addVCardPhotoEntry(vCardProperties, photoFile) {
+ let dataUrl = await getDataUrl(photoFile);
+ if (vCardProperties.getFirstValue("version") == "4.0") {
+ vCardProperties.addEntry(
+ new VCardPropertyEntry("photo", {}, "url", dataUrl)
+ );
+ } else {
+ // If vCard version is not 4.0, default to 3.0.
+ vCardProperties.addEntry(
+ new VCardPropertyEntry(
+ "photo",
+ { encoding: "B", type: getImageType(photoFile.type).toUpperCase() },
+ "binary",
+ dataUrl.substring(dataUrl.indexOf(",") + 1)
+ )
+ );
+ }
+}
+
+/**
+ * Returns a DOM File object for the contact photo of the given contact.
+ *
+ * @param {string} id - The id of the contact
+ * @returns {File} The photo of the contact, or null.
+ */
+async function getPhotoFile(id) {
+ let { item } = addressBookCache.findContactById(id);
+ let photoUrl = item.photoURL;
+ if (!photoUrl) {
+ return null;
+ }
+
+ try {
+ if (photoUrl.startsWith("file://")) {
+ let realFile = Services.io
+ .newURI(photoUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ let file = await File.createFromNsIFile(realFile);
+ let type = getImageType(file.type);
+ // Clone the File object to be able to give it the correct name, matching
+ // the dataUrl/webUrl code path below.
+ return new File([file], `${id}.${type}`, { type: `image/${type}` });
+ }
+
+ // Retrieve dataUrls or webUrls.
+ let result = await fetch(photoUrl);
+ let type = getImageType(result.headers.get("content-type"));
+ let blob = await result.blob();
+ return new File([blob], `${id}.${type}`, { type: `image/${type}` });
+ } catch (ex) {
+ console.error(`Failed to read photo information for ${id}: ` + ex);
+ }
+
+ return null;
+}
+
+/**
+ * Sets the provided file as the primary photo of the given contact.
+ *
+ * @param {string} id - The id of the contact
+ * @param {File} file - The new photo
+ */
+async function setPhotoFile(id, file) {
+ let node = addressBookCache.findContactById(id);
+ let vCardProperties = vCardPropertiesFromCard(node.item);
+
+ try {
+ let type = getImageType(file.type);
+
+ // If the contact already has a photoUrl, replace it with the same url type.
+ // Otherwise save the photo as a local file, except for CardDAV contacts.
+ let photoUrl = node.item.photoURL;
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ let useFile = photoUrl
+ ? photoUrl.startsWith("file://")
+ : parentNode.item.dirType != Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE;
+
+ if (useFile) {
+ let oldPhotoFile;
+ if (photoUrl) {
+ try {
+ oldPhotoFile = Services.io
+ .newURI(photoUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ } catch (ex) {
+ console.error(`Ignoring invalid photoUrl ${photoUrl}: ` + ex);
+ }
+ }
+ let pathPhotoFile = await IOUtils.createUniqueFile(
+ PathUtils.join(PathUtils.profileDir, "Photos"),
+ `${id}.${type}`,
+ 0o600
+ );
+
+ if (file.mozFullPath) {
+ // The file object was created by selecting a real file through a file
+ // picker and is directly linked to a local file. Do a low level copy.
+ await IOUtils.copy(file.mozFullPath, pathPhotoFile);
+ } else {
+ // The file object is a data blob. Dump it into a real file.
+ let buffer = await file.arrayBuffer();
+ await IOUtils.write(pathPhotoFile, new Uint8Array(buffer));
+ }
+
+ // Set the PhotoName.
+ node.item.setProperty("PhotoName", PathUtils.filename(pathPhotoFile));
+
+ // Delete the old photo file.
+ if (oldPhotoFile?.exists()) {
+ try {
+ await IOUtils.remove(oldPhotoFile.path);
+ } catch (ex) {
+ console.error(`Failed to delete old photo file for ${id}: ` + ex);
+ }
+ }
+ } else {
+ // Follow the UI and replace the entire entry.
+ vCardProperties.clearValues("photo");
+ await addVCardPhotoEntry(vCardProperties, file);
+ }
+ parentNode.item.modifyCard(node.item);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Failed to read new photo information for ${id}: ` + ex
+ );
+ }
+}
+
+/**
+ * Gets the VCardProperties of the given card either directly or by reconstructing
+ * from a set of flat standard properties.
+ *
+ * @param {nsIAbCard/AddrBookCard} card
+ * @returns {VCardProperties}
+ */
+function vCardPropertiesFromCard(card) {
+ if (card.supportsVCard) {
+ return card.vCardProperties;
+ }
+ return VCardProperties.fromPropertyMap(
+ new Map(Array.from(card.properties, p => [p.name, p.value]))
+ );
+}
+
+/**
+ * Creates a new AddrBookCard from a set of flat standard properties.
+ *
+ * @param {ContactProperties} properties - a key/value properties object
+ * @param {string} uid - optional UID for the card
+ * @returns {AddrBookCard}
+ */
+function flatPropertiesToAbCard(properties, uid) {
+ // Do not use VCardUtils.propertyMapToVCard().
+ let vCard = VCardProperties.fromPropertyMap(
+ new Map(Object.entries(properties))
+ ).toVCard();
+ return VCardUtils.vCardToAbCard(vCard, uid);
+}
+
+/**
+ * Checks if the given property is a custom contact property, which can be exposed
+ * to WebExtensions.
+ *
+ * @param {string} name - property name
+ * @returns {boolean}
+ */
+function isCustomProperty(name) {
+ return (
+ !hiddenProperties.includes(name) &&
+ !BANISHED_PROPERTIES.includes(name) &&
+ name.match(/^\w+$/)
+ );
+}
+
+/**
+ * Adds the provided originalProperties to the card, adjusted by the changes
+ * given in updateProperties. All banished properties are skipped and the updated
+ * properties must be valid according to isCustomProperty().
+ *
+ * @param {AddrBookCard} card - a card to receive the provided properties
+ * @param {ContactProperties} updateProperties - a key/value object with properties
+ * to update the provided originalProperties
+ * @param {nsIProperties} originalProperties - properties to be cloned onto
+ * the provided card
+ */
+function addProperties(card, updateProperties, originalProperties) {
+ let updates = Object.entries(updateProperties).filter(e =>
+ isCustomProperty(e[0])
+ );
+ let mergedProperties = originalProperties
+ ? new Map([
+ ...Array.from(originalProperties, p => [p.name, p.value]),
+ ...updates,
+ ])
+ : new Map(updates);
+
+ for (let [name, value] of mergedProperties) {
+ if (
+ !BANISHED_PROPERTIES.includes(name) &&
+ value != "" &&
+ value != null &&
+ value != undefined
+ ) {
+ card.setProperty(name, value);
+ }
+ }
+}
+
+/**
+ * Address book that supports finding cards only for a search (like LDAP).
+ *
+ * @implements {nsIAbDirectory}
+ */
+class ExtSearchBook extends AddrBookDirectory {
+ constructor(fire, context, args = {}) {
+ super();
+ this.fire = fire;
+ this._readOnly = true;
+ this._isSecure = Boolean(args.isSecure);
+ this._dirName = String(args.addressBookName ?? context.extension.name);
+ this._fileName = "";
+ this._uid = String(args.id ?? newUID());
+ this._uri = "searchaddr://" + this.UID;
+ this.lastModifiedDate = 0;
+ this.isMailList = false;
+ this.listNickName = "";
+ this.description = "";
+ this._dirPrefId = "";
+ }
+ /**
+ * @see {AddrBookDirectory}
+ */
+ get lists() {
+ return new Map();
+ }
+ /**
+ * @see {AddrBookDirectory}
+ */
+ get cards() {
+ return new Map();
+ }
+ // nsIAbDirectory
+ get isRemote() {
+ return true;
+ }
+ get isSecure() {
+ return this._isSecure;
+ }
+ getCardFromProperty(aProperty, aValue, aCaseSensitive) {
+ return null;
+ }
+ getCardsFromProperty(aProperty, aValue, aCaseSensitive) {
+ return [];
+ }
+ get dirType() {
+ return Ci.nsIAbManager.ASYNC_DIRECTORY_TYPE;
+ }
+ get position() {
+ return 0;
+ }
+ get childCardCount() {
+ return 0;
+ }
+ useForAutocomplete(aIdentityKey) {
+ // AddrBookDirectory defaults to true
+ return false;
+ }
+ get supportsMailingLists() {
+ return false;
+ }
+ setLocalizedStringValue(aName, aValue) {}
+ async search(aQuery, aSearchString, aListener) {
+ try {
+ if (this.fire.wakeup) {
+ await this.fire.wakeup();
+ }
+ let { results, isCompleteResult } = await this.fire.async(
+ await addressBookCache.convert(
+ addressBookCache.addressBooks.get(this.UID)
+ ),
+ aSearchString,
+ aQuery
+ );
+ for (let resultData of results) {
+ let card;
+ // A specified vCard is winning over any individual standard property.
+ if (resultData.vCard) {
+ try {
+ card = VCardUtils.vCardToAbCard(resultData.vCard);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Invalid vCard data: ${resultData.vCard}.`
+ );
+ }
+ } else {
+ card = flatPropertiesToAbCard(resultData);
+ }
+ // Add custom properties to the property bag.
+ addProperties(card, resultData);
+ card.directoryUID = this.UID;
+ aListener.onSearchFoundCard(card);
+ }
+ aListener.onSearchFinished(Cr.NS_OK, isCompleteResult, null, "");
+ } catch (ex) {
+ aListener.onSearchFinished(
+ ex.result || Cr.NS_ERROR_FAILURE,
+ true,
+ null,
+ ""
+ );
+ }
+ }
+}
+
+/**
+ * Cache of items in the address book "tree".
+ *
+ * @implements {nsIObserver}
+ */
+var addressBookCache = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.flush();
+ }
+ _makeContactNode(contact, parent) {
+ contact.QueryInterface(Ci.nsIAbCard);
+ return {
+ id: contact.UID,
+ parentId: parent.UID,
+ type: "contact",
+ item: contact,
+ };
+ }
+ _makeDirectoryNode(directory, parent = null) {
+ directory.QueryInterface(Ci.nsIAbDirectory);
+ let node = {
+ id: directory.UID,
+ type: directory.isMailList ? "mailingList" : "addressBook",
+ item: directory,
+ };
+ if (parent) {
+ node.parentId = parent.UID;
+ }
+ return node;
+ }
+ _populateListContacts(mailingList) {
+ mailingList.contacts = new Map();
+ for (let contact of mailingList.item.childCards) {
+ let newNode = this._makeContactNode(contact, mailingList.item);
+ mailingList.contacts.set(newNode.id, newNode);
+ }
+ }
+ getListContacts(mailingList) {
+ if (!mailingList.contacts) {
+ this._populateListContacts(mailingList);
+ }
+ return [...mailingList.contacts.values()];
+ }
+ _populateContacts(addressBook) {
+ addressBook.contacts = new Map();
+ for (let contact of addressBook.item.childCards) {
+ if (!contact.isMailList) {
+ let newNode = this._makeContactNode(contact, addressBook.item);
+ this._contacts.set(newNode.id, newNode);
+ addressBook.contacts.set(newNode.id, newNode);
+ }
+ }
+ }
+ getContacts(addressBook) {
+ if (!addressBook.contacts) {
+ this._populateContacts(addressBook);
+ }
+ return [...addressBook.contacts.values()];
+ }
+ _populateMailingLists(parent) {
+ parent.mailingLists = new Map();
+ for (let mailingList of parent.item.childNodes) {
+ let newNode = this._makeDirectoryNode(mailingList, parent.item);
+ this._mailingLists.set(newNode.id, newNode);
+ parent.mailingLists.set(newNode.id, newNode);
+ }
+ }
+ getMailingLists(parent) {
+ if (!parent.mailingLists) {
+ this._populateMailingLists(parent);
+ }
+ return [...parent.mailingLists.values()];
+ }
+ get addressBooks() {
+ if (!this._addressBooks) {
+ this._addressBooks = new Map();
+ for (let tld of MailServices.ab.directories) {
+ this._addressBooks.set(tld.UID, this._makeDirectoryNode(tld));
+ }
+ }
+ return this._addressBooks;
+ }
+ flush() {
+ this._contacts = new Map();
+ this._mailingLists = new Map();
+ this._addressBooks = null;
+ }
+ findAddressBookById(id) {
+ let addressBook = this.addressBooks.get(id);
+ if (addressBook) {
+ return addressBook;
+ }
+ throw new ExtensionUtils.ExtensionError(
+ `addressBook with id=${id} could not be found.`
+ );
+ }
+ findMailingListById(id) {
+ if (this._mailingLists.has(id)) {
+ return this._mailingLists.get(id);
+ }
+ for (let addressBook of this.addressBooks.values()) {
+ if (!addressBook.mailingLists) {
+ this._populateMailingLists(addressBook);
+ if (addressBook.mailingLists.has(id)) {
+ return addressBook.mailingLists.get(id);
+ }
+ }
+ }
+ throw new ExtensionUtils.ExtensionError(
+ `mailingList with id=${id} could not be found.`
+ );
+ }
+ findContactById(id, bookHint) {
+ if (this._contacts.has(id)) {
+ return this._contacts.get(id);
+ }
+ if (bookHint && !bookHint.contacts) {
+ this._populateContacts(bookHint);
+ if (bookHint.contacts.has(id)) {
+ return bookHint.contacts.get(id);
+ }
+ }
+ for (let addressBook of this.addressBooks.values()) {
+ if (!addressBook.contacts) {
+ this._populateContacts(addressBook);
+ if (addressBook.contacts.has(id)) {
+ return addressBook.contacts.get(id);
+ }
+ }
+ }
+ throw new ExtensionUtils.ExtensionError(
+ `contact with id=${id} could not be found.`
+ );
+ }
+ async convert(node, complete) {
+ if (node === null) {
+ return node;
+ }
+ if (Array.isArray(node)) {
+ let cards = await Promise.allSettled(
+ node.map(i => this.convert(i, complete))
+ );
+ return cards.filter(card => card.value).map(card => card.value);
+ }
+
+ let copy = {};
+ for (let key of ["id", "parentId", "type"]) {
+ if (key in node) {
+ copy[key] = node[key];
+ }
+ }
+
+ if (complete) {
+ if (node.type == "addressBook") {
+ copy.mailingLists = await this.convert(
+ this.getMailingLists(node),
+ true
+ );
+ copy.contacts = await this.convert(this.getContacts(node), true);
+ }
+ if (node.type == "mailingList") {
+ copy.contacts = await this.convert(this.getListContacts(node), true);
+ }
+ }
+
+ switch (node.type) {
+ case "addressBook":
+ copy.name = node.item.dirName;
+ copy.readOnly = node.item.readOnly;
+ copy.remote = node.item.isRemote;
+ break;
+ case "contact": {
+ // Clone the vCardProperties of this contact, so we can manipulate them
+ // for the WebExtension, but do not actually change the stored data.
+ let vCardProperties = vCardPropertiesFromCard(node.item).clone();
+ copy.properties = {};
+
+ // Build a flat property list from vCardProperties.
+ for (let [name, value] of vCardProperties.toPropertyMap()) {
+ copy.properties[name] = "" + value;
+ }
+
+ // Return all other exposed properties stored in the nodes property bag.
+ for (let property of Array.from(node.item.properties).filter(e =>
+ isCustomProperty(e.name)
+ )) {
+ copy.properties[property.name] = "" + property.value;
+ }
+
+ // If this card has no photo vCard entry, but a local photo, add it to its vCard: Thunderbird
+ // does not store photos of local address books in the internal _vCard property, to reduce
+ // the amount of data stored in its database.
+ let photoName = node.item.getProperty("PhotoName", "");
+ let vCardPhoto = vCardProperties.getFirstValue("photo");
+ if (!vCardPhoto && photoName) {
+ try {
+ let realPhotoFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ realPhotoFile.append("Photos");
+ realPhotoFile.append(photoName);
+ let photoFile = await File.createFromNsIFile(realPhotoFile);
+ await addVCardPhotoEntry(vCardProperties, photoFile);
+ } catch (ex) {
+ console.error(
+ `Failed to read photo information for ${node.id}: ` + ex
+ );
+ }
+ }
+
+ // Add the vCard.
+ copy.properties.vCard = vCardProperties.toVCard();
+
+ let parentNode;
+ try {
+ parentNode = this.findAddressBookById(node.parentId);
+ } catch (ex) {
+ // Parent might be a mailing list.
+ parentNode = this.findMailingListById(node.parentId);
+ }
+ copy.readOnly = parentNode.item.readOnly;
+ copy.remote = parentNode.item.isRemote;
+ break;
+ }
+ case "mailingList":
+ copy.name = node.item.dirName;
+ copy.nickName = node.item.listNickName;
+ copy.description = node.item.description;
+ let parentNode = this.findAddressBookById(node.parentId);
+ copy.readOnly = parentNode.item.readOnly;
+ copy.remote = parentNode.item.isRemote;
+ break;
+ }
+
+ return copy;
+ }
+
+ // nsIObserver
+ _notifications = [
+ "addrbook-directory-created",
+ "addrbook-directory-updated",
+ "addrbook-directory-deleted",
+ "addrbook-contact-created",
+ "addrbook-contact-properties-updated",
+ "addrbook-contact-deleted",
+ "addrbook-list-created",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ "addrbook-list-member-added",
+ "addrbook-list-member-removed",
+ ];
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "addrbook-directory-created": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let newNode = this._makeDirectoryNode(subject);
+ if (this._addressBooks) {
+ this._addressBooks.set(newNode.id, newNode);
+ }
+
+ this.emit("address-book-created", newNode);
+ break;
+ }
+ case "addrbook-directory-updated": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ this.emit("address-book-updated", this._makeDirectoryNode(subject));
+ break;
+ }
+ case "addrbook-directory-deleted": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let uid = subject.UID;
+ if (this._addressBooks?.has(uid)) {
+ let parentNode = this._addressBooks.get(uid);
+ if (parentNode.contacts) {
+ for (let id of parentNode.contacts.keys()) {
+ this._contacts.delete(id);
+ }
+ }
+ if (parentNode.mailingLists) {
+ for (let id of parentNode.mailingLists.keys()) {
+ this._mailingLists.delete(id);
+ }
+ }
+ this._addressBooks.delete(uid);
+ }
+
+ this.emit("address-book-deleted", uid);
+ break;
+ }
+ case "addrbook-contact-created": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let parent = MailServices.ab.getDirectoryFromUID(data);
+ let newNode = this._makeContactNode(subject, parent);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.contacts) {
+ parentNode.contacts.set(newNode.id, newNode);
+ }
+ this._contacts.set(newNode.id, newNode);
+ }
+
+ this.emit("contact-created", newNode);
+ break;
+ }
+ case "addrbook-contact-properties-updated": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let parentUID = subject.directoryUID;
+ let parent = MailServices.ab.getDirectoryFromUID(parentUID);
+ let newNode = this._makeContactNode(subject, parent);
+ if (this._addressBooks?.has(parentUID)) {
+ let parentNode = this._addressBooks.get(parentUID);
+ if (parentNode.contacts) {
+ parentNode.contacts.set(newNode.id, newNode);
+ this._contacts.set(newNode.id, newNode);
+ }
+ if (parentNode.mailingLists) {
+ for (let mailingList of parentNode.mailingLists.values()) {
+ if (
+ mailingList.contacts &&
+ mailingList.contacts.has(newNode.id)
+ ) {
+ mailingList.contacts.get(newNode.id).item = subject;
+ }
+ }
+ }
+ }
+
+ this.emit("contact-updated", newNode, JSON.parse(data));
+ break;
+ }
+ case "addrbook-contact-deleted": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let uid = subject.UID;
+ this._contacts.delete(uid);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.contacts) {
+ parentNode.contacts.delete(uid);
+ }
+ }
+
+ this.emit("contact-deleted", data, uid);
+ break;
+ }
+ case "addrbook-list-created": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let parent = MailServices.ab.getDirectoryFromUID(data);
+ let newNode = this._makeDirectoryNode(subject, parent);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.mailingLists) {
+ parentNode.mailingLists.set(newNode.id, newNode);
+ }
+ this._mailingLists.set(newNode.id, newNode);
+ }
+
+ this.emit("mailing-list-created", newNode);
+ break;
+ }
+ case "addrbook-list-updated": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let listNode = this.findMailingListById(subject.UID);
+ listNode.item = subject;
+
+ this.emit("mailing-list-updated", listNode);
+ break;
+ }
+ case "addrbook-list-deleted": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let uid = subject.UID;
+ this._mailingLists.delete(uid);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.mailingLists) {
+ parentNode.mailingLists.delete(uid);
+ }
+ }
+
+ this.emit("mailing-list-deleted", data, uid);
+ break;
+ }
+ case "addrbook-list-member-added": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let parentNode = this.findMailingListById(data);
+ let newNode = this._makeContactNode(subject, parentNode.item);
+ if (
+ this._mailingLists.has(data) &&
+ this._mailingLists.get(data).contacts
+ ) {
+ this._mailingLists.get(data).contacts.set(newNode.id, newNode);
+ }
+ this.emit("mailing-list-member-added", newNode);
+ break;
+ }
+ case "addrbook-list-member-removed": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let uid = subject.UID;
+ if (this._mailingLists.has(data)) {
+ let parentNode = this._mailingLists.get(data);
+ if (parentNode.contacts) {
+ parentNode.contacts.delete(uid);
+ }
+ }
+
+ this.emit("mailing-list-member-removed", data, uid);
+ break;
+ }
+ }
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+
+ this.flush();
+ }
+ }
+})();
+
+this.addressBook = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ // addressBooks.*
+ onAddressBookCreated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("address-book-created", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("address-book-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAddressBookUpdated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("address-book-updated", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("address-book-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAddressBookDeleted({ context, fire }) {
+ let listener = async (event, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(itemUID);
+ };
+ addressBookCache.on("address-book-deleted", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("address-book-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ // contacts.*
+ onContactCreated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("contact-created", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("contact-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onContactUpdated({ context, fire }) {
+ let listener = async (event, node, changes) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let filteredChanges = {};
+ // Find changes in flat properties stored in the vCard.
+ if (changes.hasOwnProperty("_vCard")) {
+ let oldVCardProperties = VCardProperties.fromVCard(
+ changes._vCard.oldValue
+ ).toPropertyMap();
+ let newVCardProperties = VCardProperties.fromVCard(
+ changes._vCard.newValue
+ ).toPropertyMap();
+ for (let [name, value] of oldVCardProperties) {
+ if (newVCardProperties.get(name) != value) {
+ filteredChanges[name] = {
+ oldValue: value,
+ newValue: newVCardProperties.get(name) ?? null,
+ };
+ }
+ }
+ for (let [name, value] of newVCardProperties) {
+ if (
+ !filteredChanges.hasOwnProperty(name) &&
+ oldVCardProperties.get(name) != value
+ ) {
+ filteredChanges[name] = {
+ oldValue: oldVCardProperties.get(name) ?? null,
+ newValue: value,
+ };
+ }
+ }
+ }
+ for (let [name, value] of Object.entries(changes)) {
+ if (!filteredChanges.hasOwnProperty(name) && isCustomProperty(name)) {
+ filteredChanges[name] = value;
+ }
+ }
+ fire.sync(await addressBookCache.convert(node), filteredChanges);
+ };
+ addressBookCache.on("contact-updated", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("contact-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onContactDeleted({ context, fire }) {
+ let listener = async (event, parentUID, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(parentUID, itemUID);
+ };
+ addressBookCache.on("contact-deleted", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("contact-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ // mailingLists.*
+ onMailingListCreated({ context, fire }) {
+ let listener = async (event, node) => {
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("mailing-list-created", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMailingListUpdated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("mailing-list-updated", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMailingListDeleted({ context, fire }) {
+ let listener = async (event, parentUID, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(parentUID, itemUID);
+ };
+ addressBookCache.on("mailing-list-deleted", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMemberAdded({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("mailing-list-member-added", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-member-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMemberRemoved({ context, fire }) {
+ let listener = async (event, parentUID, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(parentUID, itemUID);
+ };
+ addressBookCache.on("mailing-list-member-removed", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-member-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ constructor(...args) {
+ super(...args);
+ addressBookCache.incrementListeners();
+ }
+
+ onShutdown() {
+ addressBookCache.decrementListeners();
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ return {
+ addressBooks: {
+ async openUI() {
+ let messengerWindow = windowTracker.topNormalWindow;
+ let abWindow = await messengerWindow.toAddressBook();
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ let abTab = messengerWindow.document
+ .getElementById("tabmail")
+ .tabInfo.find(t => t.mode.name == "addressBookTab");
+ return tabManager.convert(abTab);
+ },
+ async closeUI() {
+ for (let win of Services.wm.getEnumerator("mail:3pane")) {
+ let tabmail = win.document.getElementById("tabmail");
+ for (let tab of tabmail.tabInfo.slice()) {
+ if (tab.browser?.currentURI.spec == "about:addressbook") {
+ tabmail.closeTab(tab);
+ }
+ }
+ }
+ },
+
+ list(complete = false) {
+ return addressBookCache.convert(
+ [...addressBookCache.addressBooks.values()],
+ complete
+ );
+ },
+ get(id, complete = false) {
+ return addressBookCache.convert(
+ addressBookCache.findAddressBookById(id),
+ complete
+ );
+ },
+ create({ name }) {
+ let dirName = MailServices.ab.newAddressBook(
+ name,
+ "",
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let directory = MailServices.ab.getDirectoryFromId(dirName);
+ return directory.UID;
+ },
+ update(id, { name }) {
+ let node = addressBookCache.findAddressBookById(id);
+ node.item.dirName = name;
+ },
+ async delete(id) {
+ let node = addressBookCache.findAddressBookById(id);
+ let deletePromise = new Promise(resolve => {
+ let listener = () => {
+ addressBookCache.off("address-book-deleted", listener);
+ resolve();
+ };
+ addressBookCache.on("address-book-deleted", listener);
+ });
+ MailServices.ab.deleteAddressBook(node.item.URI);
+ await deletePromise;
+ },
+
+ // The module name is addressBook as defined in ext-mail.json.
+ onCreated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onAddressBookCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onAddressBookUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onAddressBookDeleted",
+ extensionApi: this,
+ }).api(),
+
+ provider: {
+ onSearchRequest: new EventManager({
+ context,
+ name: "addressBooks.provider.onSearchRequest",
+ register: (fire, args) => {
+ if (addressBookCache.addressBooks.has(args.id)) {
+ throw new ExtensionUtils.ExtensionError(
+ `addressBook with id=${args.id} already exists.`
+ );
+ }
+ let dir = new ExtSearchBook(fire, context, args);
+ dir.init();
+ MailServices.ab.addAddressBook(dir);
+ return () => {
+ MailServices.ab.deleteAddressBook(dir.URI);
+ };
+ },
+ }).api(),
+ },
+ },
+ contacts: {
+ list(parentId) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ return addressBookCache.convert(
+ addressBookCache.getContacts(parentNode),
+ false
+ );
+ },
+ async quickSearch(parentId, queryInfo) {
+ const { getSearchTokens, getModelQuery, generateQueryURI } =
+ ChromeUtils.import("resource:///modules/ABQueryUtils.jsm");
+
+ let searchString;
+ if (typeof queryInfo == "string") {
+ searchString = queryInfo;
+ queryInfo = {
+ includeRemote: true,
+ includeLocal: true,
+ includeReadOnly: true,
+ includeReadWrite: true,
+ };
+ } else {
+ searchString = queryInfo.searchString;
+ }
+
+ let searchWords = getSearchTokens(searchString);
+ if (searchWords.length == 0) {
+ return [];
+ }
+ let searchFormat = getModelQuery(
+ "mail.addr_book.quicksearchquery.format"
+ );
+ let searchQuery = generateQueryURI(searchFormat, searchWords);
+
+ let booksToSearch;
+ if (parentId == null) {
+ booksToSearch = [...addressBookCache.addressBooks.values()];
+ } else {
+ booksToSearch = [addressBookCache.findAddressBookById(parentId)];
+ }
+
+ let results = [];
+ let promises = [];
+ for (let book of booksToSearch) {
+ if (
+ (book.item.isRemote && !queryInfo.includeRemote) ||
+ (!book.item.isRemote && !queryInfo.includeLocal) ||
+ (book.item.readOnly && !queryInfo.includeReadOnly) ||
+ (!book.item.readOnly && !queryInfo.includeReadWrite)
+ ) {
+ continue;
+ }
+ promises.push(
+ new Promise(resolve => {
+ book.item.search(searchQuery, searchString, {
+ onSearchFinished(status, complete, secInfo, location) {
+ resolve();
+ },
+ onSearchFoundCard(contact) {
+ if (contact.isMailList) {
+ return;
+ }
+ results.push(
+ addressBookCache._makeContactNode(contact, book.item)
+ );
+ },
+ });
+ })
+ );
+ }
+ await Promise.all(promises);
+
+ return addressBookCache.convert(results, false);
+ },
+ get(id) {
+ return addressBookCache.convert(
+ addressBookCache.findContactById(id),
+ false
+ );
+ },
+ async getPhoto(id) {
+ return getPhotoFile(id);
+ },
+ async setPhoto(id, file) {
+ return setPhotoFile(id, file);
+ },
+ create(parentId, id, createData) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot create a contact in a read-only address book"
+ );
+ }
+
+ let card;
+ // A specified vCard is winning over any individual standard property.
+ if (createData.vCard) {
+ try {
+ card = VCardUtils.vCardToAbCard(createData.vCard, id);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Invalid vCard data: ${createData.vCard}.`
+ );
+ }
+ } else {
+ card = flatPropertiesToAbCard(createData, id);
+ }
+ // Add custom properties to the property bag.
+ addProperties(card, createData);
+
+ // Check if the new card has an enforced UID.
+ if (card.vCardProperties.getFirstValue("uid")) {
+ let duplicateExists = false;
+ try {
+ // Second argument is only a hint, all address books are checked.
+ addressBookCache.findContactById(card.UID, parentId);
+ duplicateExists = true;
+ } catch (ex) {
+ // Do nothing. We want this to throw because no contact was found.
+ }
+ if (duplicateExists) {
+ throw new ExtensionError(`Duplicate contact id: ${card.UID}`);
+ }
+ }
+
+ let newCard = parentNode.item.addCard(card);
+ return newCard.UID;
+ },
+ update(id, updateData) {
+ let node = addressBookCache.findContactById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot modify a contact in a read-only address book"
+ );
+ }
+
+ // A specified vCard is winning over any individual standard property.
+ // While a vCard is replacing the entire contact, specified standard
+ // properties only update single entries (setting a value to null
+ // clears it / promotes the next value of the same kind).
+ let card;
+ if (updateData.vCard) {
+ let vCardUID;
+ try {
+ card = new AddrBookCard();
+ card.UID = node.item.UID;
+ card.setProperty(
+ "_vCard",
+ VCardUtils.translateVCard21(updateData.vCard)
+ );
+ vCardUID = card.vCardProperties.getFirstValue("uid");
+ } catch (ex) {
+ throw new ExtensionError(
+ `Invalid vCard data: ${updateData.vCard}.`
+ );
+ }
+ if (vCardUID && vCardUID != node.item.UID) {
+ throw new ExtensionError(
+ `The card's UID ${node.item.UID} may not be changed: ${updateData.vCard}.`
+ );
+ }
+ } else {
+ // Get the current vCardProperties, build a propertyMap and create
+ // vCardParsed which allows to identify all currently exposed entries
+ // based on the typeName used in VCardUtils.jsm (e.g. adr.work).
+ let vCardProperties = vCardPropertiesFromCard(node.item);
+ let vCardParsed = VCardUtils._parse(vCardProperties.entries);
+ let propertyMap = vCardProperties.toPropertyMap();
+
+ // Save the old exposed state.
+ let oldProperties = VCardProperties.fromPropertyMap(propertyMap);
+ let oldParsed = VCardUtils._parse(oldProperties.entries);
+ // Update the propertyMap.
+ for (let [name, value] of Object.entries(updateData)) {
+ propertyMap.set(name, value);
+ }
+ // Save the new exposed state.
+ let newProperties = VCardProperties.fromPropertyMap(propertyMap);
+ let newParsed = VCardUtils._parse(newProperties.entries);
+
+ // Evaluate the differences and update the still existing entries,
+ // mark removed items for deletion.
+ let deleteLog = [];
+ for (let typeName of oldParsed.keys()) {
+ if (typeName == "version") {
+ continue;
+ }
+ for (let idx = 0; idx < oldParsed.get(typeName).length; idx++) {
+ if (
+ newParsed.has(typeName) &&
+ idx < newParsed.get(typeName).length
+ ) {
+ let originalIndex = vCardParsed.get(typeName)[idx].index;
+ let newEntryIndex = newParsed.get(typeName)[idx].index;
+ vCardProperties.entries[originalIndex] =
+ newProperties.entries[newEntryIndex];
+ // Mark this item as handled.
+ newParsed.get(typeName)[idx] = null;
+ } else {
+ deleteLog.push(vCardParsed.get(typeName)[idx].index);
+ }
+ }
+ }
+
+ // Remove entries which have been marked for deletion.
+ for (let deleteIndex of deleteLog.sort((a, b) => a < b)) {
+ vCardProperties.entries.splice(deleteIndex, 1);
+ }
+
+ // Add new entries.
+ for (let typeName of newParsed.keys()) {
+ if (typeName == "version") {
+ continue;
+ }
+ for (let newEntry of newParsed.get(typeName)) {
+ if (newEntry) {
+ vCardProperties.addEntry(
+ newProperties.entries[newEntry.index]
+ );
+ }
+ }
+ }
+
+ // Create a new card with the original UID from the updated vCardProperties.
+ card = VCardUtils.vCardToAbCard(
+ vCardProperties.toVCard(),
+ node.item.UID
+ );
+ }
+
+ // Clone original properties and update custom properties.
+ addProperties(card, updateData, node.item.properties);
+
+ parentNode.item.modifyCard(card);
+ },
+ delete(id) {
+ let node = addressBookCache.findContactById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot delete a contact in a read-only address book"
+ );
+ }
+
+ parentNode.item.deleteCards([node.item]);
+ },
+
+ // The module name is addressBook as defined in ext-mail.json.
+ onCreated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onContactCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onContactUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onContactDeleted",
+ extensionApi: this,
+ }).api(),
+ },
+ mailingLists: {
+ list(parentId) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ return addressBookCache.convert(
+ addressBookCache.getMailingLists(parentNode),
+ false
+ );
+ },
+ get(id) {
+ return addressBookCache.convert(
+ addressBookCache.findMailingListById(id),
+ false
+ );
+ },
+ create(parentId, { name, nickName, description }) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot create a mailing list in a read-only address book"
+ );
+ }
+ let mailList = Cc[
+ "@mozilla.org/addressbook/directoryproperty;1"
+ ].createInstance(Ci.nsIAbDirectory);
+ mailList.isMailList = true;
+ mailList.dirName = name;
+ mailList.listNickName = nickName === null ? "" : nickName;
+ mailList.description = description === null ? "" : description;
+
+ let newMailList = parentNode.item.addMailList(mailList);
+ return newMailList.UID;
+ },
+ update(id, { name, nickName, description }) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot modify a mailing list in a read-only address book"
+ );
+ }
+ node.item.dirName = name;
+ node.item.listNickName = nickName === null ? "" : nickName;
+ node.item.description = description === null ? "" : description;
+ node.item.editMailListToDatabase(null);
+ },
+ delete(id) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot delete a mailing list in a read-only address book"
+ );
+ }
+ parentNode.item.deleteDirectory(node.item);
+ },
+
+ listMembers(id) {
+ let node = addressBookCache.findMailingListById(id);
+ return addressBookCache.convert(
+ addressBookCache.getListContacts(node),
+ false
+ );
+ },
+ addMember(id, contactId) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot add to a mailing list in a read-only address book"
+ );
+ }
+ let contactNode = addressBookCache.findContactById(contactId);
+ node.item.addCard(contactNode.item);
+ },
+ removeMember(id, contactId) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot remove from a mailing list in a read-only address book"
+ );
+ }
+ let contactNode = addressBookCache.findContactById(contactId);
+
+ node.item.deleteCards([contactNode.item]);
+ },
+
+ // The module name is addressBook as defined in ext-mail.json.
+ onCreated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMailingListCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMailingListUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMailingListDeleted",
+ extensionApi: this,
+ }).api(),
+ onMemberAdded: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMemberAdded",
+ extensionApi: this,
+ }).api(),
+ onMemberRemoved: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMemberRemoved",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-browserAction.js b/comm/mail/components/extensions/parent/ext-browserAction.js
new file mode 100644
index 0000000000..de07f9e3a2
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-browserAction.js
@@ -0,0 +1,329 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ storeState: "resource:///modules/CustomizationState.mjs",
+ getState: "resource:///modules/CustomizationState.mjs",
+ registerExtension: "resource:///modules/CustomizableItems.sys.mjs",
+ unregisterExtension: "resource:///modules/CustomizableItems.sys.mjs",
+ EXTENSION_PREFIX: "resource:///modules/CustomizableItems.sys.mjs",
+});
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ToolbarButtonAPI: "resource:///modules/ExtensionToolbarButtons.jsm",
+ getCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm",
+ setCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm",
+});
+
+var { makeWidgetId } = ExtensionCommon;
+
+const browserActionMap = new WeakMap();
+
+this.browserAction = class extends ToolbarButtonAPI {
+ static for(extension) {
+ return browserActionMap.get(extension);
+ }
+
+ /**
+ * A browser_action can be placed in the unified toolbar of the main window and
+ * in the XUL toolbar of the message window. We conditionally bypass XUL toolbar
+ * behavior by using the following custom method implementations.
+ */
+
+ paint(window) {
+ // Ignore XUL toolbar paint requests for the main window.
+ if (window.location.href != MAIN_WINDOW_URI) {
+ super.paint(window);
+ }
+ }
+
+ unpaint(window) {
+ // Ignore XUL toolbar unpaint requests for the main window.
+ if (window.location.href != MAIN_WINDOW_URI) {
+ super.unpaint(window);
+ }
+ }
+
+ /**
+ * Return the toolbar button if it is currently visible in the given window.
+ *
+ * @param window
+ * @returns {DOMElement} the toolbar button element, or null
+ */
+ getToolbarButton(window) {
+ // Return the visible button from the unified toolbar, if this is the main window.
+ if (window.location.href == MAIN_WINDOW_URI) {
+ let buttonItem = window.document.querySelector(
+ `#unifiedToolbarContent [item-id="ext-${this.extension.id}"]`
+ );
+ return (
+ buttonItem &&
+ !buttonItem.hidden &&
+ window.document.querySelector(
+ `#unifiedToolbarContent [extension="${this.extension.id}"]`
+ )
+ );
+ }
+ return super.getToolbarButton(window);
+ }
+
+ updateButton(button, tabData) {
+ if (button.applyTabData) {
+ // This is an extension-action-button customElement and therefore a button
+ // in the unified toolbar and needs special handling.
+ button.applyTabData(tabData);
+ } else {
+ super.updateButton(button, tabData);
+ }
+ }
+
+ async onManifestEntry(entryName) {
+ await super.onManifestEntry(entryName);
+ browserActionMap.set(this.extension, this);
+
+ // Check if a browser_action was added to the unified toolbar.
+ if (this.windowURLs.includes(MAIN_WINDOW_URI)) {
+ await registerExtension(this.extension.id, this.allowedSpaces);
+ const currentToolbarState = getState();
+ const unifiedToolbarButtonId = `${EXTENSION_PREFIX}${this.extension.id}`;
+
+ // Load the cached allowed spaces. Make sure there are no awaited promises
+ // before storing the updated allowed spaces, as it could have been changed
+ // elsewhere.
+ let cachedAllowedSpaces = getCachedAllowedSpaces();
+ let priorAllowedSpaces = cachedAllowedSpaces.get(this.extension.id);
+
+ // If the extension has set allowedSpaces to an empty array, the button needs
+ // to be added to all available spaces.
+ let allowedSpaces =
+ this.allowedSpaces.length == 0
+ ? [
+ "mail",
+ "addressbook",
+ "calendar",
+ "tasks",
+ "chat",
+ "settings",
+ "default",
+ ]
+ : this.allowedSpaces;
+
+ // Manually add the button to all customized spaces, where it has not been
+ // allowed in the prior version of this add-on (if any). This automatically
+ // covers the install and the update case, including staged updates.
+ // Spaces which have not been customized will receive the button from
+ // getDefaultItemIdsForSpace() in CustomizableItems.sys.mjs.
+ let missingSpacesInState = allowedSpaces.filter(
+ space =>
+ (!priorAllowedSpaces || !priorAllowedSpaces.includes(space)) &&
+ space !== "default" &&
+ currentToolbarState.hasOwnProperty(space) &&
+ !currentToolbarState[space].includes(unifiedToolbarButtonId)
+ );
+ for (const space of missingSpacesInState) {
+ currentToolbarState[space].push(unifiedToolbarButtonId);
+ }
+
+ // Manually remove button from all customized spaces, if it is no longer
+ // allowed. This will remove its stored customized positioning information.
+ // If a space becomes allowed again later, the button will be added to the
+ // end of the space and not at its former customized location.
+ let invalidSpacesInState = [];
+ if (priorAllowedSpaces) {
+ invalidSpacesInState = priorAllowedSpaces.filter(
+ space =>
+ space !== "default" &&
+ !allowedSpaces.includes(space) &&
+ currentToolbarState.hasOwnProperty(space) &&
+ currentToolbarState[space].includes(unifiedToolbarButtonId)
+ );
+ for (const space of invalidSpacesInState) {
+ currentToolbarState[space] = currentToolbarState[space].filter(
+ id => id != unifiedToolbarButtonId
+ );
+ }
+ }
+
+ // Update the cached values for the allowed spaces.
+ cachedAllowedSpaces.set(this.extension.id, allowedSpaces);
+ setCachedAllowedSpaces(cachedAllowedSpaces);
+
+ if (missingSpacesInState.length || invalidSpacesInState.length) {
+ storeState(currentToolbarState);
+ } else {
+ Services.obs.notifyObservers(null, "unified-toolbar-state-change");
+ }
+ }
+ }
+
+ close() {
+ super.close();
+ browserActionMap.delete(this.extension);
+ windowTracker.removeListener("TabSelect", this);
+ // Unregister the extension from the unified toolbar.
+ if (this.windowURLs.includes(MAIN_WINDOW_URI)) {
+ unregisterExtension(this.extension.id);
+ Services.obs.notifyObservers(null, "unified-toolbar-state-change");
+ }
+ }
+
+ constructor(extension) {
+ super(extension, global);
+ this.manifest_name =
+ extension.manifestVersion < 3 ? "browser_action" : "action";
+ this.manifestName =
+ extension.manifestVersion < 3 ? "browserAction" : "action";
+ this.manifest = extension.manifest[this.manifest_name];
+ // browserAction was renamed to action in MV3, but its module name is
+ // still "browserAction" because that is the name used in ext-mail.json,
+ // independently from the manifest version.
+ this.moduleName = "browserAction";
+
+ this.windowURLs = [];
+ if (this.manifest.default_windows.includes("normal")) {
+ this.windowURLs.push(MAIN_WINDOW_URI);
+ }
+ if (this.manifest.default_windows.includes("messageDisplay")) {
+ this.windowURLs.push(MESSAGE_WINDOW_URI);
+ }
+
+ this.toolboxId = "mail-toolbox";
+ this.toolbarId = "mail-bar3";
+
+ this.allowedSpaces =
+ this.extension.manifest[this.manifest_name].allowed_spaces;
+
+ windowTracker.addListener("TabSelect", this);
+ }
+
+ static onUpdate(extensionId, manifest) {
+ // These manifest entries can exist and be null.
+ if (!manifest.browser_action && !manifest.action) {
+ this.#removeFromUnifiedToolbar(extensionId);
+ }
+ }
+
+ static onUninstall(extensionId) {
+ let widgetId = makeWidgetId(extensionId);
+ let id = `${widgetId}-browserAction-toolbarbutton`;
+
+ // Check all possible XUL toolbars and remove the toolbarbutton if found.
+ // Sadly we have to hardcode these values here, as the add-on is already
+ // shutdown when onUninstall is called.
+ let toolbars = ["mail-bar3", "toolbar-menubar"];
+ for (let toolbar of toolbars) {
+ for (let setName of ["currentset", "extensionset"]) {
+ let set = Services.xulStore
+ .getValue(MESSAGE_WINDOW_URI, toolbar, setName)
+ .split(",");
+ let newSet = set.filter(e => e != id);
+ if (newSet.length < set.length) {
+ Services.xulStore.setValue(
+ MESSAGE_WINDOW_URI,
+ toolbar,
+ setName,
+ newSet.join(",")
+ );
+ }
+ }
+ }
+
+ this.#removeFromUnifiedToolbar(extensionId);
+ }
+
+ static #removeFromUnifiedToolbar(extensionId) {
+ const currentToolbarState = getState();
+ const unifiedToolbarButtonId = `${EXTENSION_PREFIX}${extensionId}`;
+ let modifiedState = false;
+ for (const space of Object.keys(currentToolbarState)) {
+ if (currentToolbarState[space].includes(unifiedToolbarButtonId)) {
+ currentToolbarState[space].splice(
+ currentToolbarState[space].indexOf(unifiedToolbarButtonId),
+ 1
+ );
+ modifiedState = true;
+ }
+ }
+ if (modifiedState) {
+ storeState(currentToolbarState);
+ }
+
+ // Update cachedAllowedSpaces for the unified toolbar.
+ let cachedAllowedSpaces = getCachedAllowedSpaces();
+ if (cachedAllowedSpaces.has(extensionId)) {
+ cachedAllowedSpaces.delete(extensionId);
+ setCachedAllowedSpaces(cachedAllowedSpaces);
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+ let window = event.target.ownerGlobal;
+
+ switch (event.type) {
+ case "popupshowing":
+ const menu = event.target;
+ if (menu.tagName != "menupopup") {
+ return;
+ }
+
+ // This needs to work in normal window and message window.
+ let tab = tabTracker.activeTab;
+ let browser = tab.linkedBrowser || tab.getBrowser?.();
+
+ const trigger = menu.triggerNode;
+ const node =
+ window.document.getElementById(this.id) ||
+ (this.windowURLs.includes(MAIN_WINDOW_URI) &&
+ window.document.querySelector(
+ `#unifiedToolbarContent [item-id="${EXTENSION_PREFIX}${this.extension.id}"]`
+ ));
+ const contexts = [
+ "toolbar-context-menu",
+ "customizationPanelItemContextMenu",
+ "unifiedToolbarMenu",
+ ];
+ if (contexts.includes(menu.id) && node && node.contains(trigger)) {
+ const action =
+ this.extension.manifestVersion < 3 ? "onBrowserAction" : "onAction";
+ global.actionContextMenu({
+ tab,
+ pageUrl: browser?.currentURI?.spec,
+ extension: this.extension,
+ [action]: true,
+ menu,
+ });
+ }
+
+ if (
+ menu.dataset.actionMenu == this.manifestName &&
+ this.extension.id == menu.dataset.extensionId
+ ) {
+ const action =
+ this.extension.manifestVersion < 3
+ ? "inBrowserActionMenu"
+ : "inActionMenu";
+ global.actionContextMenu({
+ tab,
+ pageUrl: browser?.currentURI?.spec,
+ extension: this.extension,
+ [action]: true,
+ menu,
+ });
+ }
+ break;
+ }
+ }
+};
+
+global.browserActionFor = this.browserAction.for;
diff --git a/comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js b/comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js
new file mode 100644
index 0000000000..9f3d624b76
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js
@@ -0,0 +1,365 @@
+/* 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/. */
+
+/* global searchInitialized */
+
+// Copy of browser/components/extensions/parent/ext-chrome-settings-overrides.js
+// minus HomePage.jsm (+ dependent ExtensionControlledPopup.sys.mjs and
+// ExtensionPermissions.jsm usage).
+
+"use strict";
+
+var { ExtensionPreferencesManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+});
+const DEFAULT_SEARCH_STORE_TYPE = "default_search";
+const DEFAULT_SEARCH_SETTING_NAME = "defaultSearch";
+const ENGINE_ADDED_SETTING_NAME = "engineAdded";
+
+// When an extension starts up, a search engine may asynchronously be
+// registered, without blocking the startup. When an extension is
+// uninstalled, we need to wait for this registration to finish
+// before running the uninstallation handler.
+// Map[extension id -> Promise]
+var pendingSearchSetupTasks = new Map();
+
+this.chrome_settings_overrides = class extends ExtensionAPI {
+ static async processDefaultSearchSetting(action, id) {
+ await ExtensionSettingsStore.initialize();
+ let item = ExtensionSettingsStore.getSetting(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ id
+ );
+ if (!item) {
+ return;
+ }
+ let control = await ExtensionSettingsStore.getLevelOfControl(
+ id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ item = ExtensionSettingsStore[action](
+ id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ if (item && control == "controlled_by_this_extension") {
+ try {
+ let engine = Services.search.getEngineByName(
+ item.value || item.initialValue
+ );
+ if (engine) {
+ await Services.search.setDefault(
+ engine,
+ action == "enable"
+ ? Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ : Ci.nsISearchService.CHANGE_REASON_ADDON_UNINSTALL
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ static async removeEngine(id) {
+ try {
+ await Services.search.removeWebExtensionEngine(id);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ static removeSearchSettings(id) {
+ return Promise.all([
+ this.processDefaultSearchSetting("removeSetting", id),
+ this.removeEngine(id),
+ ]);
+ }
+
+ static async onUninstall(id) {
+ let searchStartupPromise = pendingSearchSetupTasks.get(id);
+ if (searchStartupPromise) {
+ await searchStartupPromise.catch(console.error);
+ }
+ // Note: We do not have to deal with homepage here as it is managed by
+ // the ExtensionPreferencesManager.
+ return Promise.all([this.removeSearchSettings(id)]);
+ }
+
+ static async onUpdate(id, manifest) {
+ let search_provider = manifest?.chrome_settings_overrides?.search_provider;
+
+ if (!search_provider) {
+ // Remove setting and engine from search if necessary.
+ this.removeSearchSettings(id);
+ } else if (!search_provider.is_default) {
+ // Remove the setting, but keep the engine in search.
+ chrome_settings_overrides.processDefaultSearchSetting(
+ "removeSetting",
+ id
+ );
+ }
+ }
+
+ static async onDisable(id) {
+ await chrome_settings_overrides.processDefaultSearchSetting("disable", id);
+ await chrome_settings_overrides.removeEngine(id);
+ }
+
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+ let { manifest } = extension;
+ if (manifest.chrome_settings_overrides.search_provider) {
+ // Registering a search engine can potentially take a long while,
+ // or not complete at all (when searchInitialized is never resolved),
+ // so we are deliberately not awaiting the returned promise here.
+ let searchStartupPromise =
+ this.processSearchProviderManifestEntry().finally(() => {
+ if (
+ pendingSearchSetupTasks.get(extension.id) === searchStartupPromise
+ ) {
+ pendingSearchSetupTasks.delete(extension.id);
+ // This is primarily for tests so that we know when an extension
+ // has finished initialising.
+ ExtensionParent.apiManager.emit("searchEngineProcessed", extension);
+ }
+ });
+
+ // Save the promise so we can await at onUninstall.
+ pendingSearchSetupTasks.set(extension.id, searchStartupPromise);
+ }
+ }
+
+ async ensureSetting(engineName, disable = false) {
+ let { extension } = this;
+ // Ensure the addon always has a setting
+ await ExtensionSettingsStore.initialize();
+ let item = ExtensionSettingsStore.getSetting(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ extension.id
+ );
+ if (!item) {
+ let defaultEngine = await Services.search.getDefault();
+ item = await ExtensionSettingsStore.addSetting(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ engineName,
+ () => defaultEngine.name
+ );
+ // If there was no setting, we're fixing old behavior in this api.
+ // A lack of a setting would mean it was disabled before, disable it now.
+ disable =
+ disable ||
+ ["ADDON_UPGRADE", "ADDON_DOWNGRADE", "ADDON_ENABLE"].includes(
+ extension.startupReason
+ );
+ }
+
+ // Ensure the item is disabled (either if exists and is not default or if it does not
+ // exist yet).
+ if (disable) {
+ item = await ExtensionSettingsStore.disable(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+ return item;
+ }
+
+ async promptDefaultSearch(engineName) {
+ let { extension } = this;
+ // Don't ask if it is already the current engine
+ let engine = Services.search.getEngineByName(engineName);
+ let defaultEngine = await Services.search.getDefault();
+ if (defaultEngine.name == engine.name) {
+ return;
+ }
+ // Ensures the setting exists and is disabled. If the
+ // user somehow bypasses the prompt, we do not want this
+ // setting enabled for this extension.
+ await this.ensureSetting(engineName, true);
+
+ let subject = {
+ wrappedJSObject: {
+ // This is a hack because we don't have the browser of
+ // the actual install. This means the popup might show
+ // in a different window. Will be addressed in a followup bug.
+ // As well, we still notify if no topWindow exists to support
+ // testing from xpcshell.
+ browser: windowTracker.topWindow?.gBrowser.selectedBrowser,
+ id: extension.id,
+ name: extension.name,
+ icon: extension.iconURL,
+ currentEngine: defaultEngine.name,
+ newEngine: engineName,
+ async respond(allow) {
+ if (allow) {
+ await chrome_settings_overrides.processDefaultSearchSetting(
+ "enable",
+ extension.id
+ );
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ }
+ // For testing
+ Services.obs.notifyObservers(
+ null,
+ "webextension-defaultsearch-prompt-response"
+ );
+ },
+ },
+ };
+ Services.obs.notifyObservers(subject, "webextension-defaultsearch-prompt");
+ }
+
+ async processSearchProviderManifestEntry() {
+ let { extension } = this;
+ let { manifest } = extension;
+ let searchProvider = manifest.chrome_settings_overrides.search_provider;
+
+ // If we're not being requested to be set as default, then all we need
+ // to do is to add the engine to the service. The search service can cope
+ // with receiving added engines before it is initialised, so we don't have
+ // to wait for it. Search Service will also prevent overriding a builtin
+ // engine appropriately.
+ if (!searchProvider.is_default) {
+ await this.addSearchEngine();
+ return;
+ }
+
+ await searchInitialized;
+ if (!this.extension) {
+ console.error(
+ `Extension shut down before search provider was registered`
+ );
+ return;
+ }
+
+ let engineName = searchProvider.name.trim();
+ let result = await Services.search.maybeSetAndOverrideDefault(extension);
+ // This will only be set to true when the specified engine is an app-provided
+ // engine, or when it is an allowed add-on defined in the list stored in
+ // SearchDefaultOverrideAllowlistHandler.
+ if (result.canChangeToAppProvided) {
+ await this.setDefault(engineName, true);
+ }
+ if (!result.canInstallEngine) {
+ // This extension is overriding an app-provided one, so we don't
+ // add its engine as well.
+ return;
+ }
+ await this.addSearchEngine();
+ if (extension.startupReason === "ADDON_INSTALL") {
+ await this.promptDefaultSearch(engineName);
+ } else {
+ // Needs to be called every time to handle reenabling.
+ await this.setDefault(engineName);
+ }
+ }
+
+ async setDefault(engineName, skipEnablePrompt = false) {
+ let { extension } = this;
+ if (extension.startupReason === "ADDON_INSTALL") {
+ // We should only get here if an extension is setting an app-provided
+ // engine to default and we are ignoring the addons other engine settings.
+ // In this case we do not show the prompt to the user.
+ let item = await this.ensureSetting(engineName);
+ await Services.search.setDefault(
+ Services.search.getEngineByName(item.value),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (
+ ["ADDON_UPGRADE", "ADDON_DOWNGRADE", "ADDON_ENABLE"].includes(
+ extension.startupReason
+ )
+ ) {
+ // We would be called for every extension being enabled, we should verify
+ // that it has control and only then set it as default
+ let control = await ExtensionSettingsStore.getLevelOfControl(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+
+ // Check for an inconsistency between the value returned by getLevelOfcontrol
+ // and the current engine actually set.
+ if (
+ control === "controlled_by_this_extension" &&
+ Services.search.defaultEngine.name !== engineName
+ ) {
+ // Check for and fix any inconsistency between the extensions settings storage
+ // and the current engine actually set. If settings claims the extension is default
+ // but the search service claims otherwise, select what the search service claims
+ // (See Bug 1767550).
+ const allSettings = ExtensionSettingsStore.getAllSettings(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ for (const setting of allSettings) {
+ if (setting.value !== Services.search.defaultEngine.name) {
+ await ExtensionSettingsStore.disable(
+ setting.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+ }
+ control = await ExtensionSettingsStore.getLevelOfControl(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+
+ if (control === "controlled_by_this_extension") {
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (control === "controllable_by_this_extension") {
+ if (skipEnablePrompt) {
+ // For overriding app-provided engines, we don't prompt, so set
+ // the default straight away.
+ await chrome_settings_overrides.processDefaultSearchSetting(
+ "enable",
+ extension.id
+ );
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (extension.startupReason == "ADDON_ENABLE") {
+ // This extension has precedence, but is not in control. Ask the user.
+ await this.promptDefaultSearch(engineName);
+ }
+ }
+ }
+ }
+
+ async addSearchEngine() {
+ let { extension } = this;
+ try {
+ await Services.search.addEnginesFromExtension(extension);
+ } catch (e) {
+ console.error(e);
+ return false;
+ }
+ return true;
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-cloudFile.js b/comm/mail/components/extensions/parent/ext-cloudFile.js
new file mode 100644
index 0000000000..74193d8d14
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-cloudFile.js
@@ -0,0 +1,804 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["File", "FileReader"]);
+
+async function promiseFileRead(nsifile) {
+ let blob = await File.createFromNsIFile(nsifile);
+
+ return new Promise((resolve, reject) => {
+ let reader = new FileReader();
+ reader.addEventListener("loadend", event => {
+ if (event.target.error) {
+ reject(event.target.error);
+ } else {
+ resolve(event.target.result);
+ }
+ });
+
+ reader.readAsArrayBuffer(blob);
+ });
+}
+
+class CloudFileAccount {
+ constructor(accountKey, extension) {
+ this.accountKey = accountKey;
+ this.extension = extension;
+ this._configured = false;
+ this.lastError = "";
+ this.managementURL = this.extension.manifest.cloud_file.management_url;
+ this.reuseUploads = this.extension.manifest.cloud_file.reuse_uploads;
+ this.browserStyle = this.extension.manifest.cloud_file.browser_style;
+ this.quota = {
+ uploadSizeLimit: -1,
+ spaceRemaining: -1,
+ spaceUsed: -1,
+ };
+
+ this._nextId = 1;
+ this._uploads = new Map();
+ }
+
+ get type() {
+ return `ext-${this.extension.id}`;
+ }
+ get displayName() {
+ return Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${this.accountKey}.displayName`,
+ this.extension.manifest.cloud_file.name
+ );
+ }
+ get iconURL() {
+ if (this.extension.manifest.icons) {
+ let { icon } = ExtensionParent.IconDetails.getPreferredIcon(
+ this.extension.manifest.icons,
+ this.extension,
+ 32
+ );
+ return this.extension.baseURI.resolve(icon);
+ }
+ return "chrome://messenger/content/extension.svg";
+ }
+ get fileUploadSizeLimit() {
+ return this.quota.uploadSizeLimit;
+ }
+ get remainingFileSpace() {
+ return this.quota.spaceRemaining;
+ }
+ get fileSpaceUsed() {
+ return this.quota.spaceUsed;
+ }
+ get configured() {
+ return this._configured;
+ }
+ set configured(value) {
+ value = !!value;
+ if (value != this._configured) {
+ this._configured = value;
+ cloudFileAccounts.emit("accountConfigured", this);
+ }
+ }
+ get createNewAccountUrl() {
+ return this.extension.manifest.cloud_file.new_account_url;
+ }
+
+ /**
+ * @typedef CloudFileDate
+ * @property {integer} timestamp - milliseconds since epoch
+ * @property {DateTimeFormat} format - format object of Intl.DateTimeFormat
+ */
+
+ /**
+ * @typedef CloudFileUpload
+ * // Values used in the WebExtension CloudFile type.
+ * @property {string} id - uploadId of the file
+ * @property {string} name - name of the file
+ * @property {string} url - url of the uploaded file
+ * // Properties of the local file.
+ * @property {string} path - path of the local file
+ * @property {string} size - size of the local file
+ * // Template information.
+ * @property {string} serviceName - name of the upload service provider
+ * @property {string} serviceIcon - icon of the upload service provider
+ * @property {string} serviceUrl - web interface of the upload service provider
+ * @property {boolean} downloadPasswordProtected - link is password protected
+ * @property {integer} downloadLimit - download limit of the link
+ * @property {CloudFileDate} downloadExpiryDate - expiry date of the link
+ * // Usage tracking.
+ * @property {boolean} immutable - if the cloud file url may be changed
+ */
+
+ /**
+ * Marks the specified upload as immutable.
+ *
+ * @param {integer} id - id of the upload
+ */
+ markAsImmutable(id) {
+ if (this._uploads.has(id)) {
+ let upload = this._uploads.get(id);
+ upload.immutable = true;
+ this._uploads.set(id, upload);
+ }
+ }
+
+ /**
+ * Returns a new upload entry, based on the provided file and data.
+ *
+ * @param {nsIFile} file
+ * @param {CloudFileUpload} data
+ * @returns {CloudFileUpload}
+ */
+ newUploadForFile(file, data = {}) {
+ let id = this._nextId++;
+ let upload = {
+ // Values used in the WebExtension CloudFile type.
+ id,
+ name: data.name ?? file.leafName,
+ url: data.url ?? null,
+ // Properties of the local file.
+ path: file.path,
+ size: file.exists() ? file.fileSize : data.size || 0,
+ // Template information.
+ serviceName: data.serviceName ?? this.displayName,
+ serviceIcon: data.serviceIcon ?? this.iconURL,
+ serviceUrl: data.serviceUrl ?? "",
+ downloadPasswordProtected: data.downloadPasswordProtected ?? false,
+ downloadLimit: data.downloadLimit ?? 0,
+ downloadExpiryDate: data.downloadExpiryDate ?? null,
+ // Usage tracking.
+ immutable: data.immutable ?? false,
+ };
+
+ this._uploads.set(id, upload);
+ return upload;
+ }
+
+ /**
+ * Initiate a WebExtension cloudFile upload by preparing a CloudFile object &
+ * and triggering an onFileUpload event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {nsIFile} file File to be uploaded.
+ * @param {string} [name] Name of the file after it has been uploaded. Defaults
+ * to the original filename of the uploaded file.
+ * @param {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.
+ * @returns {CloudFileUpload} Information about the uploaded file.
+ */
+ async uploadFile(window, file, name = file.leafName, relatedCloudFileUpload) {
+ let data = await File.createFromNsIFile(file);
+
+ if (
+ this.remainingFileSpace != -1 &&
+ file.fileSize > this.remainingFileSpace
+ ) {
+ throw Components.Exception(
+ `Quota error: Can't upload file. Only ${this.remainingFileSpace}KB left of quota.`,
+ cloudFileAccounts.constants.uploadWouldExceedQuota
+ );
+ }
+
+ if (
+ this.fileUploadSizeLimit != -1 &&
+ file.fileSize > this.fileUploadSizeLimit
+ ) {
+ throw Components.Exception(
+ `Upload error: File size is ${file.fileSize}KB and exceeds the file size limit of ${this.fileUploadSizeLimit}KB`,
+ cloudFileAccounts.constants.uploadExceedsFileLimit
+ );
+ }
+
+ let upload = this.newUploadForFile(file, { name });
+ let id = upload.id;
+ let relatedFileInfo;
+ if (relatedCloudFileUpload) {
+ relatedFileInfo = {
+ id: relatedCloudFileUpload.id,
+ name: relatedCloudFileUpload.name,
+ url: relatedCloudFileUpload.url,
+ templateInfo: relatedCloudFileUpload.templateInfo,
+ dataChanged: relatedCloudFileUpload.path != upload.path,
+ };
+ }
+
+ let results;
+ try {
+ results = await this.extension.emit(
+ "uploadFile",
+ this,
+ { id, name, data },
+ window,
+ relatedFileInfo
+ );
+ } catch (ex) {
+ this._uploads.delete(id);
+ if (ex.result == 0x80530014) {
+ // NS_ERROR_DOM_ABORT_ERR
+ throw Components.Exception(
+ "Upload cancelled.",
+ cloudFileAccounts.constants.uploadCancelled
+ );
+ } else {
+ throw Components.Exception(
+ `Upload error: ${ex.message}`,
+ cloudFileAccounts.constants.uploadErr
+ );
+ }
+ }
+
+ if (
+ results &&
+ results.length > 0 &&
+ results[0] &&
+ (results[0].aborted || results[0].url || results[0].error)
+ ) {
+ if (results[0].error) {
+ this._uploads.delete(id);
+ if (typeof results[0].error == "boolean") {
+ throw Components.Exception(
+ "Upload error.",
+ cloudFileAccounts.constants.uploadErr
+ );
+ } else {
+ throw Components.Exception(
+ results[0].error,
+ cloudFileAccounts.constants.uploadErrWithCustomMessage
+ );
+ }
+ }
+
+ if (results[0].aborted) {
+ this._uploads.delete(id);
+ throw Components.Exception(
+ "Upload cancelled.",
+ cloudFileAccounts.constants.uploadCancelled
+ );
+ }
+
+ if (results[0].templateInfo) {
+ upload.templateInfo = results[0].templateInfo;
+
+ if (results[0].templateInfo.service_name) {
+ upload.serviceName = results[0].templateInfo.service_name;
+ }
+ if (results[0].templateInfo.service_icon) {
+ upload.serviceIcon = this.extension.baseURI.resolve(
+ results[0].templateInfo.service_icon
+ );
+ }
+ if (results[0].templateInfo.service_url) {
+ upload.serviceUrl = results[0].templateInfo.service_url;
+ }
+ if (results[0].templateInfo.download_password_protected) {
+ upload.downloadPasswordProtected =
+ results[0].templateInfo.download_password_protected;
+ }
+ if (results[0].templateInfo.download_limit) {
+ upload.downloadLimit = results[0].templateInfo.download_limit;
+ }
+ if (results[0].templateInfo.download_expiry_date) {
+ // Event return value types are not checked by the WebExtension framework,
+ // manual verification is required.
+ if (
+ results[0].templateInfo.download_expiry_date.timestamp &&
+ Number.isInteger(
+ results[0].templateInfo.download_expiry_date.timestamp
+ )
+ ) {
+ upload.downloadExpiryDate =
+ results[0].templateInfo.download_expiry_date;
+ } else {
+ console.warn(
+ "Invalid CloudFileTemplateInfo.download_expiry_date object, the timestamp property is required and it must be of type integer."
+ );
+ }
+ }
+ }
+
+ upload.url = results[0].url;
+
+ return { ...upload };
+ }
+
+ this._uploads.delete(id);
+ throw Components.Exception(
+ `Upload error: Missing cloudFile.onFileUpload listener for ${this.extension.id} (or it is not returning url or aborted)`,
+ cloudFileAccounts.constants.uploadErr
+ );
+ }
+
+ /**
+ * Checks if the url of the given upload has been used already.
+ *
+ * @param {CloudFileUpload} cloudFileUpload
+ */
+ isReusedUpload(cloudFileUpload) {
+ if (!cloudFileUpload) {
+ return false;
+ }
+
+ // Find matching url in known uploads and check if it is immutable.
+ let isImmutableUrl = url => {
+ return [...this._uploads.values()].some(u => u.immutable && u.url == url);
+ };
+
+ // Check all open windows if the url is used elsewhere.
+ let isDuplicateUrl = url => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ if (composeWindows.length == 0) {
+ return false;
+ }
+ let countsPerWindow = composeWindows.map(window => {
+ let bucket = window.document.getElementById("attachmentBucket");
+ if (!bucket) {
+ return 0;
+ }
+ return [...bucket.childNodes].filter(
+ node => node.attachment.contentLocation == url
+ ).length;
+ });
+
+ return countsPerWindow.reduce((prev, curr) => prev + curr) > 1;
+ };
+
+ return (
+ isImmutableUrl(cloudFileUpload.url) || isDuplicateUrl(cloudFileUpload.url)
+ );
+ }
+
+ /**
+ * Initiate a WebExtension cloudFile rename by triggering an onFileRename event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {Integer} uploadId Id of the uploaded file.
+ * @param {string} newName The requested new name of the file.
+ * @returns {CloudFileUpload} Information about the renamed file.
+ */
+ async renameFile(window, uploadId, newName) {
+ if (!this._uploads.has(uploadId)) {
+ throw Components.Exception(
+ "Rename error.",
+ cloudFileAccounts.constants.renameErr
+ );
+ }
+
+ let upload = this._uploads.get(uploadId);
+ let results;
+ try {
+ results = await this.extension.emit(
+ "renameFile",
+ this,
+ uploadId,
+ newName,
+ window
+ );
+ } catch (ex) {
+ throw Components.Exception(
+ `Rename error: ${ex.message}`,
+ cloudFileAccounts.constants.renameErr
+ );
+ }
+
+ if (!results || results.length == 0) {
+ throw Components.Exception(
+ `Rename error: Missing cloudFile.onFileRename listener for ${this.extension.id}`,
+ cloudFileAccounts.constants.renameNotSupported
+ );
+ }
+
+ if (results[0]) {
+ if (results[0].error) {
+ if (typeof results[0].error == "boolean") {
+ throw Components.Exception(
+ "Rename error.",
+ cloudFileAccounts.constants.renameErr
+ );
+ } else {
+ throw Components.Exception(
+ results[0].error,
+ cloudFileAccounts.constants.renameErrWithCustomMessage
+ );
+ }
+ }
+
+ if (results[0].url) {
+ upload.url = results[0].url;
+ }
+ }
+
+ upload.name = newName;
+ return upload;
+ }
+
+ urlForFile(uploadId) {
+ return this._uploads.get(uploadId).url;
+ }
+
+ /**
+ * Cancel a WebExtension cloudFile upload by triggering an onFileUploadAbort
+ * event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {nsIFile} file File to be uploaded.
+ */
+ async cancelFileUpload(window, file) {
+ let path = file.path;
+ let uploadId = -1;
+ for (let upload of this._uploads.values()) {
+ if (!upload.url && upload.path == path) {
+ uploadId = upload.id;
+ break;
+ }
+ }
+
+ if (uploadId == -1) {
+ console.error(`No upload in progress for file ${file.path}`);
+ return false;
+ }
+
+ let result = await this.extension.emit(
+ "uploadAbort",
+ this,
+ uploadId,
+ window
+ );
+ if (result && result.length > 0) {
+ return true;
+ }
+
+ console.error(
+ `Missing cloudFile.onFileUploadAbort listener for ${this.extension.id}`
+ );
+ return false;
+ }
+
+ getPreviousUploads() {
+ return [...this._uploads.values()].map(u => {
+ return { ...u };
+ });
+ }
+
+ /**
+ * Delete a WebExtension cloudFile upload by triggering an onFileDeleted event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {Integer} uploadId Id of the uploaded file.
+ */
+ async deleteFile(window, uploadId) {
+ if (!this.extension.emitter.has("deleteFile")) {
+ throw Components.Exception(
+ `Delete error: Missing cloudFile.onFileDeleted listener for ${this.extension.id}`,
+ cloudFileAccounts.constants.deleteErr
+ );
+ }
+
+ try {
+ if (this._uploads.has(uploadId)) {
+ let upload = this._uploads.get(uploadId);
+ if (!this.isReusedUpload(upload)) {
+ await this.extension.emit("deleteFile", this, uploadId, window);
+ this._uploads.delete(uploadId);
+ }
+ }
+ } catch (ex) {
+ throw Components.Exception(
+ `Delete error: ${ex.message}`,
+ cloudFileAccounts.constants.deleteErr
+ );
+ }
+ }
+}
+
+function convertCloudFileAccount(nativeAccount) {
+ return {
+ id: nativeAccount.accountKey,
+ name: nativeAccount.displayName,
+ configured: nativeAccount.configured,
+ uploadSizeLimit: nativeAccount.fileUploadSizeLimit,
+ spaceRemaining: nativeAccount.remainingFileSpace,
+ spaceUsed: nativeAccount.fileSpaceUsed,
+ managementUrl: nativeAccount.managementURL,
+ };
+}
+
+this.cloudFile = class extends ExtensionAPIPersistent {
+ get providerType() {
+ return `ext-${this.extension.id}`;
+ }
+
+ onManifestEntry(entryName) {
+ if (entryName == "cloud_file") {
+ let { extension } = this;
+ cloudFileAccounts.registerProvider(this.providerType, {
+ type: this.providerType,
+ displayName: extension.manifest.cloud_file.name,
+ get iconURL() {
+ if (extension.manifest.icons) {
+ let { icon } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 32
+ );
+ return extension.baseURI.resolve(icon);
+ }
+ return "chrome://messenger/content/extension.svg";
+ },
+ initAccount(accountKey) {
+ return new CloudFileAccount(accountKey, extension);
+ },
+ });
+ }
+ }
+
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+ cloudFileAccounts.unregisterProvider(this.providerType);
+ }
+
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onFileUpload({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(
+ _event,
+ account,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, { id, name, data }, tab, relatedFileInfo);
+ }
+ extension.on("uploadFile", listener);
+ return {
+ unregister: () => {
+ extension.off("uploadFile", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onFileUploadAbort({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(_event, account, id, tab) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, id, tab);
+ }
+ extension.on("uploadAbort", listener);
+ return {
+ unregister: () => {
+ extension.off("uploadAbort", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onFileRename({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(_event, account, id, newName, tab) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, id, newName, tab);
+ }
+ extension.on("renameFile", listener);
+ return {
+ unregister: () => {
+ extension.off("renameFile", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onFileDeleted({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(_event, account, id, tab) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, id, tab);
+ }
+ extension.on("deleteFile", listener);
+ return {
+ unregister: () => {
+ extension.off("deleteFile", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onAccountAdded({ context, fire }) {
+ const self = this;
+ async function listener(_event, nativeAccount) {
+ if (nativeAccount.type != self.providerType) {
+ return null;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ return fire.async(convertCloudFileAccount(nativeAccount));
+ }
+ cloudFileAccounts.on("accountAdded", listener);
+ return {
+ unregister: () => {
+ cloudFileAccounts.off("accountAdded", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onAccountDeleted({ context, fire }) {
+ const self = this;
+ async function listener(_event, key, type) {
+ if (self.providerType != type) {
+ return null;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ return fire.async(key);
+ }
+ cloudFileAccounts.on("accountDeleted", listener);
+ return {
+ unregister: () => {
+ cloudFileAccounts.off("accountDeleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let self = this;
+
+ return {
+ cloudFile: {
+ onFileUpload: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileUpload",
+ extensionApi: this,
+ }).api(),
+
+ onFileUploadAbort: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileUploadAbort",
+ extensionApi: this,
+ }).api(),
+
+ onFileRename: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileRename",
+ extensionApi: this,
+ }).api(),
+
+ onFileDeleted: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileDeleted",
+ extensionApi: this,
+ }).api(),
+
+ onAccountAdded: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onAccountAdded",
+ extensionApi: this,
+ }).api(),
+
+ onAccountDeleted: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onAccountDeleted",
+ extensionApi: this,
+ }).api(),
+
+ async getAccount(accountId) {
+ let account = cloudFileAccounts.getAccount(accountId);
+
+ if (!account || account.type != self.providerType) {
+ return undefined;
+ }
+
+ return convertCloudFileAccount(account);
+ },
+
+ async getAllAccounts() {
+ return cloudFileAccounts
+ .getAccountsForType(self.providerType)
+ .map(convertCloudFileAccount);
+ },
+
+ async updateAccount(accountId, updateProperties) {
+ let account = cloudFileAccounts.getAccount(accountId);
+
+ if (!account || account.type != self.providerType) {
+ return undefined;
+ }
+ if (updateProperties.configured !== null) {
+ account.configured = updateProperties.configured;
+ }
+ if (updateProperties.uploadSizeLimit !== null) {
+ account.quota.uploadSizeLimit = updateProperties.uploadSizeLimit;
+ }
+ if (updateProperties.spaceRemaining !== null) {
+ account.quota.spaceRemaining = updateProperties.spaceRemaining;
+ }
+ if (updateProperties.spaceUsed !== null) {
+ account.quota.spaceUsed = updateProperties.spaceUsed;
+ }
+ if (updateProperties.managementUrl !== null) {
+ account.managementURL = updateProperties.managementUrl;
+ }
+
+ return convertCloudFileAccount(account);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-commands.js b/comm/mail/components/extensions/parent/ext-commands.js
new file mode 100644
index 0000000000..309793b7fa
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-commands.js
@@ -0,0 +1,103 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailExtensionShortcuts",
+ "resource:///modules/MailExtensionShortcuts.jsm"
+);
+
+this.commands = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCommand({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(eventName, commandName) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let tab = tabManager.convert(tabTracker.activeTab);
+ fire.async(commandName, tab);
+ }
+ this.on("command", listener);
+ return {
+ unregister: () => {
+ this.off("command", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ onChanged({ context, fire }) {
+ async function listener(eventName, changeInfo) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(changeInfo);
+ }
+ this.on("shortcutChanged", listener);
+ return {
+ unregister: () => {
+ this.off("shortcutChanged", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ };
+
+ static onUninstall(extensionId) {
+ return MailExtensionShortcuts.removeCommandsFromStorage(extensionId);
+ }
+
+ async onManifestEntry(entryName) {
+ let shortcuts = new MailExtensionShortcuts({
+ extension: this.extension,
+ onCommand: name => this.emit("command", name),
+ onShortcutChanged: changeInfo => this.emit("shortcutChanged", changeInfo),
+ });
+ this.extension.shortcuts = shortcuts;
+ await shortcuts.loadCommands();
+ await shortcuts.register();
+ }
+
+ onShutdown() {
+ this.extension.shortcuts.unregister();
+ }
+
+ getAPI(context) {
+ return {
+ commands: {
+ getAll: () => this.extension.shortcuts.allCommands(),
+ update: args => this.extension.shortcuts.updateCommand(args),
+ reset: name => this.extension.shortcuts.resetCommand(name),
+ onCommand: new EventManager({
+ context,
+ module: "commands",
+ event: "onCommand",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onChanged: new EventManager({
+ context,
+ module: "commands",
+ event: "onChanged",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-compose.js b/comm/mail/components/extensions/parent/ext-compose.js
new file mode 100644
index 0000000000..33a52c5e08
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-compose.js
@@ -0,0 +1,1703 @@
+/* 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 { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["IOUtils", "PathUtils"]);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+let { MsgUtils } = ChromeUtils.import(
+ "resource:///modules/MimeMessageUtils.jsm"
+);
+let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["File"]);
+
+const deliveryFormats = [
+ { id: Ci.nsIMsgCompSendFormat.Auto, value: "auto" },
+ { id: Ci.nsIMsgCompSendFormat.PlainText, value: "plaintext" },
+ { id: Ci.nsIMsgCompSendFormat.HTML, value: "html" },
+ { id: Ci.nsIMsgCompSendFormat.Both, value: "both" },
+];
+
+async function parseComposeRecipientList(
+ list,
+ requireSingleValidEmail = false
+) {
+ if (!list) {
+ return list;
+ }
+
+ function isValidAddress(address) {
+ return address.includes("@", 1) && !address.endsWith("@");
+ }
+
+ // A ComposeRecipientList could be just a single ComposeRecipient.
+ if (!Array.isArray(list)) {
+ list = [list];
+ }
+
+ let recipients = [];
+ for (let recipient of list) {
+ if (typeof recipient == "string") {
+ let addressObjects =
+ MailServices.headerParser.makeFromDisplayAddress(recipient);
+
+ for (let ao of addressObjects) {
+ if (requireSingleValidEmail && !isValidAddress(ao.email)) {
+ throw new ExtensionError(`Invalid address: ${ao.email}`);
+ }
+ recipients.push(
+ MailServices.headerParser.makeMimeAddress(ao.name, ao.email)
+ );
+ }
+ continue;
+ }
+ if (!("addressBookCache" in this)) {
+ await extensions.asyncLoadModule("addressBook");
+ }
+ if (recipient.type == "contact") {
+ let contactNode = this.addressBookCache.findContactById(recipient.id);
+
+ if (
+ requireSingleValidEmail &&
+ !isValidAddress(contactNode.item.primaryEmail)
+ ) {
+ throw new ExtensionError(
+ `Contact does not have a valid email address: ${recipient.id}`
+ );
+ }
+ recipients.push(
+ MailServices.headerParser.makeMimeAddress(
+ contactNode.item.displayName,
+ contactNode.item.primaryEmail
+ )
+ );
+ } else {
+ if (requireSingleValidEmail) {
+ throw new ExtensionError("Mailing list not allowed.");
+ }
+
+ let mailingListNode = this.addressBookCache.findMailingListById(
+ recipient.id
+ );
+ recipients.push(
+ MailServices.headerParser.makeMimeAddress(
+ mailingListNode.item.dirName,
+ mailingListNode.item.description || mailingListNode.item.dirName
+ )
+ );
+ }
+ }
+ if (requireSingleValidEmail && recipients.length != 1) {
+ throw new ExtensionError(
+ `Exactly one address instead of ${recipients.length} is required.`
+ );
+ }
+ return recipients.join(",");
+}
+
+function composeWindowIsReady(composeWindow) {
+ return new Promise(resolve => {
+ if (composeWindow.composeEditorReady) {
+ resolve();
+ return;
+ }
+ composeWindow.addEventListener("compose-editor-ready", resolve, {
+ once: true,
+ });
+ });
+}
+
+async function openComposeWindow(relatedMessageId, type, details, extension) {
+ let format = Ci.nsIMsgCompFormat.Default;
+ let identity = null;
+
+ if (details) {
+ if (details.isPlainText != null) {
+ format = details.isPlainText
+ ? Ci.nsIMsgCompFormat.PlainText
+ : Ci.nsIMsgCompFormat.HTML;
+ } else {
+ // If none or both of details.body and details.plainTextBody are given, the
+ // default compose format will be used.
+ if (details.body != null && details.plainTextBody == null) {
+ format = Ci.nsIMsgCompFormat.HTML;
+ }
+ if (details.plainTextBody != null && details.body == null) {
+ format = Ci.nsIMsgCompFormat.PlainText;
+ }
+ }
+
+ if (details.identityId != null) {
+ if (!extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Using identities requires the "accountsRead" permission'
+ );
+ }
+
+ identity = MailServices.accounts.allIdentities.find(
+ i => i.key == details.identityId
+ );
+ if (!identity) {
+ throw new ExtensionError(`Identity not found: ${details.identityId}`);
+ }
+ }
+ }
+
+ // ForwardInline is totally broken, see bug 1513824. Fake it 'til we make it.
+ if (
+ [
+ Ci.nsIMsgCompType.ForwardInline,
+ Ci.nsIMsgCompType.Redirect,
+ Ci.nsIMsgCompType.EditAsNew,
+ Ci.nsIMsgCompType.Template,
+ ].includes(type)
+ ) {
+ let msgHdr = null;
+ let msgURI = null;
+ if (relatedMessageId) {
+ msgHdr = messageTracker.getMessage(relatedMessageId);
+ msgURI = msgHdr.folder.getUriForMsg(msgHdr);
+ }
+
+ // For the types in this code path, OpenComposeWindow only uses
+ // nsIMsgCompFormat.Default or OppositeOfDefault. Check which is needed.
+ // See https://hg.mozilla.org/comm-central/file/592fb5c396ebbb75d4acd1f1287a26f56f4164b3/mailnews/compose/src/nsMsgComposeService.cpp#l395
+ if (format != Ci.nsIMsgCompFormat.Default) {
+ // The mimeConverter used in this code path is not setting any format but
+ // defaults to plaintext if no identity and also no default account is set.
+ // The "mail.identity.default.compose_html" preference is NOT used.
+ let usedIdentity =
+ identity || MailServices.accounts.defaultAccount?.defaultIdentity;
+ let defaultFormat = usedIdentity?.composeHtml
+ ? Ci.nsIMsgCompFormat.HTML
+ : Ci.nsIMsgCompFormat.PlainText;
+ format =
+ format == defaultFormat
+ ? Ci.nsIMsgCompFormat.Default
+ : Ci.nsIMsgCompFormat.OppositeOfDefault;
+ }
+
+ let composeWindowPromise = new Promise(resolve => {
+ function listener(event) {
+ let composeWindow = event.target.ownerGlobal;
+ // Skip if this window has been processed already. This already helps
+ // a lot to assign the opened windows in the correct order to the
+ // OpenCompomposeWindow calls.
+ if (composeWindowTracker.has(composeWindow)) {
+ return;
+ }
+ // Do a few more checks to make sure we are looking at the expected
+ // window. This is still a hack. We need to make OpenCompomposeWindow
+ // actually return the opened window.
+ let _msgURI = composeWindow.gMsgCompose.originalMsgURI;
+ let _type = composeWindow.gComposeType;
+ if (_msgURI == msgURI && _type == type) {
+ composeWindowTracker.add(composeWindow);
+ windowTracker.removeListener("compose-editor-ready", listener);
+ resolve(composeWindow);
+ }
+ }
+ windowTracker.addListener("compose-editor-ready", listener);
+ });
+ MailServices.compose.OpenComposeWindow(
+ null,
+ msgHdr,
+ msgURI,
+ type,
+ format,
+ identity,
+ null,
+ null
+ );
+ let composeWindow = await composeWindowPromise;
+
+ if (details) {
+ await setComposeDetails(composeWindow, details, extension);
+ if (details.attachments != null) {
+ let attachmentData = [];
+ for (let data of details.attachments) {
+ attachmentData.push(await createAttachment(data));
+ }
+ await AddAttachmentsToWindow(composeWindow, attachmentData);
+ }
+ }
+ composeWindow.gContentChanged = false;
+ return composeWindow;
+ }
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ if (relatedMessageId) {
+ let msgHdr = messageTracker.getMessage(relatedMessageId);
+ params.originalMsgURI = msgHdr.folder.getUriForMsg(msgHdr);
+ }
+
+ params.type = type;
+ params.format = format;
+ if (identity) {
+ params.identity = identity;
+ }
+
+ params.composeFields = composeFields;
+ let composeWindow = Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ params
+ );
+ await composeWindowIsReady(composeWindow);
+
+ // Not all details can be set with params for all types, so some need an extra
+ // call to setComposeDetails here. Since we have to use setComposeDetails for
+ // the EditAsNew code path, unify API behavior by always calling it here too.
+ if (details) {
+ await setComposeDetails(composeWindow, details, extension);
+ if (details.attachments != null) {
+ let attachmentData = [];
+ for (let data of details.attachments) {
+ attachmentData.push(await createAttachment(data));
+ }
+ await AddAttachmentsToWindow(composeWindow, attachmentData);
+ }
+ }
+ composeWindow.gContentChanged = false;
+ return composeWindow;
+}
+
+/**
+ * Converts "\r\n" line breaks to "\n" and removes trailing line breaks.
+ *
+ * @param {string} content - original content
+ * @returns {string} - trimmed content
+ */
+function trimContent(content) {
+ let data = content.replaceAll("\r\n", "\n").split("\n");
+ while (data[data.length - 1] == "") {
+ data.pop();
+ }
+ return data.join("\n");
+}
+
+/**
+ * Get the compose details of the requested compose window.
+ *
+ * @param {DOMWindow} composeWindow
+ * @param {ExtensionData} extension
+ * @returns {ComposeDetails}
+ *
+ * @see mail/components/extensions/schemas/compose.json
+ */
+async function getComposeDetails(composeWindow, extension) {
+ let composeFields = composeWindow.GetComposeDetails();
+ let editor = composeWindow.GetCurrentEditor();
+
+ let type;
+ // check all known nsIMsgComposeParams
+ switch (composeWindow.gComposeType) {
+ case Ci.nsIMsgCompType.Draft:
+ type = "draft";
+ break;
+ case Ci.nsIMsgCompType.New:
+ case Ci.nsIMsgCompType.Template:
+ case Ci.nsIMsgCompType.MailToUrl:
+ case Ci.nsIMsgCompType.EditAsNew:
+ case Ci.nsIMsgCompType.EditTemplate:
+ case Ci.nsIMsgCompType.NewsPost:
+ type = "new";
+ 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:
+ type = "reply";
+ break;
+ case Ci.nsIMsgCompType.ForwardAsAttachment:
+ case Ci.nsIMsgCompType.ForwardInline:
+ type = "forward";
+ break;
+ case Ci.nsIMsgCompType.Redirect:
+ type = "redirect";
+ break;
+ }
+
+ let relatedMessageId = null;
+ if (composeWindow.gMsgCompose.originalMsgURI) {
+ try {
+ // This throws for messages opened from file and then being replied to.
+ let relatedMsgHdr = composeWindow.gMessenger.msgHdrFromURI(
+ composeWindow.gMsgCompose.originalMsgURI
+ );
+ relatedMessageId = messageTracker.getId(relatedMsgHdr);
+ } catch (ex) {
+ // We are currently unable to get the fake msgHdr from the uri of messages
+ // opened from file.
+ }
+ }
+
+ let customHeaders = [...composeFields.headerNames]
+ .map(h => h.toLowerCase())
+ .filter(h => h.startsWith("x-"))
+ .map(h => {
+ return {
+ // All-lower-case-names are ugly, so capitalize first letters.
+ name: h.replace(/(^|-)[a-z]/g, function (match) {
+ return match.toUpperCase();
+ }),
+ value: composeFields.getHeader(h),
+ };
+ });
+
+ // We have two file carbon copy settings: fcc and fcc2. fcc allows to override
+ // the default identity fcc and fcc2 is coupled to the UI selection.
+ let overrideDefaultFcc = false;
+ if (composeFields.fcc && composeFields.fcc != "") {
+ overrideDefaultFcc = true;
+ }
+ let overrideDefaultFccFolder = "";
+ if (overrideDefaultFcc && !composeFields.fcc.startsWith("nocopy://")) {
+ let folder = MailUtils.getExistingFolder(composeFields.fcc);
+ if (folder) {
+ overrideDefaultFccFolder = convertFolder(folder);
+ }
+ }
+ let additionalFccFolder = "";
+ if (composeFields.fcc2 && !composeFields.fcc2.startsWith("nocopy://")) {
+ let folder = MailUtils.getExistingFolder(composeFields.fcc2);
+ if (folder) {
+ additionalFccFolder = convertFolder(folder);
+ }
+ }
+
+ let deliveryFormat = composeWindow.IsHTMLEditor()
+ ? deliveryFormats.find(f => f.id == composeFields.deliveryFormat).value
+ : null;
+
+ let body = trimContent(
+ editor.outputToString("text/html", Ci.nsIDocumentEncoder.OutputRaw)
+ );
+ let plainTextBody;
+ if (composeWindow.IsHTMLEditor()) {
+ plainTextBody = trimContent(MsgUtils.convertToPlainText(body, true));
+ } else {
+ plainTextBody = parserUtils.convertToPlainText(
+ body,
+ Ci.nsIDocumentEncoder.OutputLFLineBreak,
+ 0
+ );
+ // Remove the extra new line at the end.
+ if (plainTextBody.endsWith("\n")) {
+ plainTextBody = plainTextBody.slice(0, -1);
+ }
+ }
+
+ let details = {
+ from: composeFields.splitRecipients(composeFields.from, false).shift(),
+ to: composeFields.splitRecipients(composeFields.to, false),
+ cc: composeFields.splitRecipients(composeFields.cc, false),
+ bcc: composeFields.splitRecipients(composeFields.bcc, false),
+ overrideDefaultFcc,
+ overrideDefaultFccFolder: overrideDefaultFcc
+ ? overrideDefaultFccFolder
+ : null,
+ additionalFccFolder,
+ type,
+ relatedMessageId,
+ replyTo: composeFields.splitRecipients(composeFields.replyTo, false),
+ followupTo: composeFields.splitRecipients(composeFields.followupTo, false),
+ newsgroups: composeFields.newsgroups
+ ? composeFields.newsgroups.split(",")
+ : [],
+ subject: composeFields.subject,
+ isPlainText: !composeWindow.IsHTMLEditor(),
+ deliveryFormat,
+ body,
+ plainTextBody,
+ customHeaders,
+ priority: composeFields.priority.toLowerCase() || "normal",
+ returnReceipt: composeFields.returnReceipt,
+ deliveryStatusNotification: composeFields.DSN,
+ attachVCard: composeFields.attachVCard,
+ };
+ if (extension.hasPermission("accountsRead")) {
+ details.identityId = composeWindow.getCurrentIdentityKey();
+ }
+ return details;
+}
+
+async function setFromField(composeWindow, details, extension) {
+ if (!details || details.from == null) {
+ return;
+ }
+
+ let from;
+ // Re-throw exceptions from parseComposeRecipientList with a prefix to
+ // minimize developers debugging time and make clear where restrictions are
+ // coming from.
+ try {
+ from = await parseComposeRecipientList(details.from, true);
+ } catch (ex) {
+ throw new ExtensionError(`ComposeDetails.from: ${ex.message}`);
+ }
+ if (!from) {
+ throw new ExtensionError(
+ "ComposeDetails.from: Address must not be set to an empty string."
+ );
+ }
+
+ let identityList = composeWindow.document.getElementById("msgIdentity");
+ // Make the from field editable only, if from differs from the currently shown identity.
+ if (from != identityList.value) {
+ let activeElement = composeWindow.document.activeElement;
+ // Manually update from, using the same approach used in
+ // https://hg.mozilla.org/comm-central/file/1283451c02926e2b7506a6450445b81f6d076f89/mail/components/compose/content/MsgComposeCommands.js#l3621
+ composeWindow.MakeFromFieldEditable(true);
+ identityList.value = from;
+ activeElement.focus();
+ }
+}
+
+/**
+ * Updates the compose details of the specified compose window, overwriting any
+ * property given in the details object.
+ *
+ * @param {DOMWindow} composeWindow
+ * @param {ComposeDetails} details - compose details to update the composer with
+ * @param {ExtensionData} extension
+ *
+ * @see mail/components/extensions/schemas/compose.json
+ */
+async function setComposeDetails(composeWindow, details, extension) {
+ let activeElement = composeWindow.document.activeElement;
+
+ // Check if conflicting formats have been specified.
+ if (
+ details.isPlainText === true &&
+ details.body != null &&
+ details.plainTextBody == null
+ ) {
+ throw new ExtensionError(
+ "Conflicting format setting: isPlainText = true and providing a body but no plainTextBody."
+ );
+ }
+ if (
+ details.isPlainText === false &&
+ details.body == null &&
+ details.plainTextBody != null
+ ) {
+ throw new ExtensionError(
+ "Conflicting format setting: isPlainText = false and providing a plainTextBody but no body."
+ );
+ }
+
+ // Remove any unsupported body type. Otherwise, this will throw an
+ // NS_UNEXPECTED_ERROR later. Note: setComposeDetails cannot change the compose
+ // format, details.isPlainText is ignored.
+ if (composeWindow.IsHTMLEditor()) {
+ delete details.plainTextBody;
+ } else {
+ delete details.body;
+ }
+
+ if (details.identityId) {
+ if (!extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Using identities requires the "accountsRead" permission'
+ );
+ }
+
+ let identity = MailServices.accounts.allIdentities.find(
+ i => i.key == details.identityId
+ );
+ if (!identity) {
+ throw new ExtensionError(`Identity not found: ${details.identityId}`);
+ }
+ let identityElement = composeWindow.document.getElementById("msgIdentity");
+ identityElement.selectedItem = [
+ ...identityElement.childNodes[0].childNodes,
+ ].find(e => e.getAttribute("identitykey") == details.identityId);
+ composeWindow.LoadIdentity(false);
+ }
+ 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);
+ await setFromField(composeWindow, details, extension);
+
+ // Set file carbon copy values.
+ if (details.overrideDefaultFcc === false) {
+ composeWindow.gMsgCompose.compFields.fcc = "";
+ } else if (details.overrideDefaultFccFolder != null) {
+ // Override identity fcc with enforced value.
+ if (details.overrideDefaultFccFolder) {
+ let uri = folderPathToURI(
+ details.overrideDefaultFccFolder.accountId,
+ details.overrideDefaultFccFolder.path
+ );
+ let folder = MailUtils.getExistingFolder(uri);
+ if (folder) {
+ composeWindow.gMsgCompose.compFields.fcc = uri;
+ } else {
+ throw new ExtensionError(
+ `Invalid MailFolder: {accountId:${details.overrideDefaultFccFolder.accountId}, path:${details.overrideDefaultFccFolder.path}}`
+ );
+ }
+ } else {
+ composeWindow.gMsgCompose.compFields.fcc = "nocopy://";
+ }
+ } else if (
+ details.overrideDefaultFcc === true &&
+ composeWindow.gMsgCompose.compFields.fcc == ""
+ ) {
+ throw new ExtensionError(
+ `Setting overrideDefaultFcc to true requires setting overrideDefaultFccFolder as well`
+ );
+ }
+
+ if (details.additionalFccFolder != null) {
+ if (details.additionalFccFolder) {
+ let uri = folderPathToURI(
+ details.additionalFccFolder.accountId,
+ details.additionalFccFolder.path
+ );
+ let folder = MailUtils.getExistingFolder(uri);
+ if (folder) {
+ composeWindow.gMsgCompose.compFields.fcc2 = uri;
+ } else {
+ throw new ExtensionError(
+ `Invalid MailFolder: {accountId:${details.additionalFccFolder.accountId}, path:${details.additionalFccFolder.path}}`
+ );
+ }
+ } else {
+ composeWindow.gMsgCompose.compFields.fcc2 = "";
+ }
+ }
+
+ // Update custom headers, if specified.
+ if (details.customHeaders) {
+ let newHeaderNames = details.customHeaders.map(h => h.name.toUpperCase());
+ let obsoleteHeaderNames = [
+ ...composeWindow.gMsgCompose.compFields.headerNames,
+ ]
+ .map(h => h.toUpperCase())
+ .filter(h => h.startsWith("X-") && !newHeaderNames.hasOwnProperty(h));
+
+ for (let headerName of obsoleteHeaderNames) {
+ composeWindow.gMsgCompose.compFields.deleteHeader(headerName);
+ }
+ for (let { name, value } of details.customHeaders) {
+ composeWindow.gMsgCompose.compFields.setHeader(name, value);
+ }
+ }
+
+ // Update priorities. The enum in the schema defines all allowed values, no
+ // need to validate here.
+ if (details.priority) {
+ if (details.priority == "normal") {
+ composeWindow.gMsgCompose.compFields.priority = "";
+ } else {
+ composeWindow.gMsgCompose.compFields.priority =
+ details.priority[0].toUpperCase() + details.priority.slice(1);
+ }
+ composeWindow.updatePriorityToolbarButton(
+ composeWindow.gMsgCompose.compFields.priority
+ );
+ }
+
+ // Update receipt notifications.
+ if (details.returnReceipt != null) {
+ composeWindow.ToggleReturnReceipt(details.returnReceipt);
+ }
+
+ if (
+ details.deliveryStatusNotification != null &&
+ details.deliveryStatusNotification !=
+ composeWindow.gMsgCompose.compFields.DSN
+ ) {
+ let target = composeWindow.document.getElementById("dsnMenu");
+ composeWindow.ToggleDSN(target);
+ }
+
+ if (details.deliveryFormat && composeWindow.IsHTMLEditor()) {
+ // Do not throw when a deliveryFormat is set on a plaint text composer, because
+ // it is allowed to set ComposeDetails of an html composer onto a plain text
+ // composer (and automatically pick the plainText body). The deliveryFormat
+ // will be ignored.
+ composeWindow.gMsgCompose.compFields.deliveryFormat = deliveryFormats.find(
+ f => f.value == details.deliveryFormat
+ ).id;
+ composeWindow.initSendFormatMenu();
+ }
+
+ if (details.attachVCard != null) {
+ composeWindow.gMsgCompose.compFields.attachVCard = details.attachVCard;
+ composeWindow.gAttachVCardOptionChanged = true;
+ }
+
+ activeElement.focus();
+}
+
+async function fileURLForFile(file) {
+ let realFile = await getRealFileForFile(file);
+ return Services.io.newFileURI(realFile).spec;
+}
+
+async function createAttachment(data) {
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+
+ if (data.id) {
+ if (!composeAttachmentTracker.hasAttachment(data.id)) {
+ throw new ExtensionError(`Invalid attachment ID: ${data.id}`);
+ }
+
+ let { attachment: originalAttachment, window: originalWindow } =
+ composeAttachmentTracker.getAttachment(data.id);
+
+ let originalAttachmentItem =
+ originalWindow.gAttachmentBucket.findItemForAttachment(
+ originalAttachment
+ );
+
+ attachment.name = data.name || originalAttachment.name;
+ attachment.size = originalAttachment.size;
+ attachment.url = originalAttachment.url;
+
+ return {
+ attachment,
+ originalAttachment,
+ originalCloudFileAccount: originalAttachmentItem.cloudFileAccount,
+ originalCloudFileUpload: originalAttachmentItem.cloudFileUpload,
+ };
+ }
+
+ if (data.file) {
+ attachment.name = data.name || data.file.name;
+ attachment.size = data.file.size;
+ attachment.url = await fileURLForFile(data.file);
+ attachment.contentType = data.file.type;
+ return { attachment };
+ }
+
+ throw new ExtensionError(`Failed to create attachment.`);
+}
+
+async function AddAttachmentsToWindow(window, attachmentData) {
+ await window.AddAttachments(attachmentData.map(a => a.attachment));
+ // Check if an attachment has been cloned and the cloudFileUpload needs to be
+ // re-applied.
+ for (let entry of attachmentData) {
+ let addedAttachmentItem = window.gAttachmentBucket.findItemForAttachment(
+ entry.attachment
+ );
+ if (!addedAttachmentItem) {
+ continue;
+ }
+
+ if (
+ !entry.originalAttachment ||
+ !entry.originalCloudFileAccount ||
+ !entry.originalCloudFileUpload
+ ) {
+ continue;
+ }
+
+ let updateSettings = {
+ cloudFileAccount: entry.originalCloudFileAccount,
+ relatedCloudFileUpload: entry.originalCloudFileUpload,
+ };
+ if (entry.originalAttachment.name != entry.attachment.name) {
+ updateSettings.name = entry.attachment.name;
+ }
+
+ try {
+ await window.UpdateAttachment(addedAttachmentItem, updateSettings);
+ } catch (ex) {
+ throw new ExtensionError(ex.message);
+ }
+ }
+}
+
+var composeStates = {
+ _states: {
+ canSendNow: "cmd_sendNow",
+ canSendLater: "cmd_sendLater",
+ },
+
+ getStates(tab) {
+ let states = {};
+ for (let [state, command] of Object.entries(this._states)) {
+ state[state] = tab.nativeTab.defaultController.isCommandEnabled(command);
+ }
+ return states;
+ },
+
+ // Translate core states (commands) to API states.
+ convert(states) {
+ let converted = {};
+ for (let [state, command] of Object.entries(this._states)) {
+ if (states.hasOwnProperty(command)) {
+ converted[state] = states[command];
+ }
+ }
+ return converted;
+ },
+};
+
+class MsgOperationObserver {
+ constructor(composeWindow) {
+ this.composeWindow = composeWindow;
+ this.savedMessages = [];
+ this.headerMessageId = null;
+ this.deliveryCallbacks = null;
+ this.preparedCallbacks = null;
+ this.classifiedMessages = new Map();
+
+ // The preparedPromise fulfills when the message has been prepared and handed
+ // over to the send process.
+ this.preparedPromise = new Promise((resolve, reject) => {
+ this.preparedCallbacks = { resolve, reject };
+ });
+
+ // The deliveryPromise fulfills when the message has been saved/send.
+ this.deliveryPromise = new Promise((resolve, reject) => {
+ this.deliveryCallbacks = { resolve, reject };
+ });
+
+ Services.obs.addObserver(this, "mail:composeSendProgressStop");
+ this.composeWindow.gMsgCompose.addMsgSendListener(this);
+ MailServices.mfn.addListener(this, MailServices.mfn.msgsClassified);
+ this.composeWindow.addEventListener(
+ "compose-prepare-message-success",
+ event => this.preparedCallbacks.resolve(),
+ { once: true }
+ );
+ this.composeWindow.addEventListener(
+ "compose-prepare-message-failure",
+ event => this.preparedCallbacks.reject(event.detail.exception),
+ { once: true }
+ );
+ }
+
+ // Observer for mail:composeSendProgressStop.
+ observe(subject, topic, data) {
+ let { composeWindow } = subject.wrappedJSObject;
+ if (composeWindow == this.composeWindow) {
+ this.deliveryCallbacks.resolve();
+ }
+ }
+
+ // nsIMsgSendListener
+ onStartSending(msgID, msgSize) {}
+ onProgress(msgID, progress, progressMax) {}
+ onStatus(msgID, msg) {}
+ onStopSending(msgID, status, msg, returnFile) {
+ if (!Components.isSuccessCode(status)) {
+ this.deliveryCallbacks.reject(
+ new ExtensionError("Message operation failed")
+ );
+ return;
+ }
+ // In case of success, this is only called for sendNow, stating the
+ // headerMessageId of the outgoing message.
+ // The msgID starts with < and ends with > which is not used by the API.
+ this.headerMessageId = msgID.replace(/^<|>$/g, "");
+ }
+ onGetDraftFolderURI(msgID, folderURI) {
+ // Only called for save operations and sendLater. Collect messageIds and
+ // folders of saved messages.
+ let headerMessageId = msgID.replace(/^<|>$/g, "");
+ this.savedMessages.push(JSON.stringify({ headerMessageId, folderURI }));
+ }
+ onSendNotPerformed(msgID, status) {}
+ onTransportSecurityError(msgID, status, secInfo, location) {}
+
+ // Implementation for nsIMsgFolderListener::msgsClassified
+ msgsClassified(msgs, junkProcessed, traitProcessed) {
+ // Collect all msgHdrs added to folders during the current message operation.
+ for (let msgHdr of msgs) {
+ let key = JSON.stringify({
+ headerMessageId: msgHdr.messageId,
+ folderURI: msgHdr.folder.URI,
+ });
+ if (!this.classifiedMessages.has(key)) {
+ this.classifiedMessages.set(key, convertMessage(msgHdr));
+ }
+ }
+ }
+
+ /**
+ * @typedef MsgOperationInfo
+ * @property {string} headerMessageId - the id used in the "Message-Id" header
+ * of the outgoing message, only available for the "sendNow" mode
+ * @property {MessageHeader[]} messages - array of WebExtension MessageHeader
+ * objects, with information about saved messages (depends on fcc config)
+ * @see mail/components/extensions/schemas/compose.json
+ */
+
+ /**
+ * Returns a Promise, which resolves once the message operation has finished.
+ *
+ * @returns {Promise<MsgOperationInfo>} - Promise for information about the
+ * performed message operation.
+ */
+ async waitForOperation() {
+ try {
+ await Promise.all([this.deliveryPromise, this.preparedPromise]);
+ return {
+ messages: this.savedMessages
+ .map(m => this.classifiedMessages.get(m))
+ .filter(Boolean),
+ headerMessageId: this.headerMessageId,
+ };
+ } catch (ex) {
+ // In case of error, reject the pending delivery Promise.
+ this.deliveryCallbacks.reject();
+ throw ex;
+ } finally {
+ MailServices.mfn.removeListener(this);
+ Services.obs.removeObserver(this, "mail:composeSendProgressStop");
+ this.composeWindow?.gMsgCompose?.removeMsgSendListener(this);
+ }
+ }
+}
+
+/**
+ * @typedef MsgOperationReturnValue
+ * @property {string} headerMessageId - the id used in the "Message-Id" header
+ * of the outgoing message, only available for the "sendNow" mode
+ * @property {MessageHeader[]} messages - array of WebExtension MessageHeader
+ * objects, with information about saved messages (depends on fcc config)
+ * @see mail/components/extensions/schemas/compose.json
+ * @property {string} mode - the mode of the message operation
+ * @see mail/components/extensions/schemas/compose.json
+ */
+
+/**
+ * Executes the given save/send command. The returned Promise resolves once the
+ * message operation has finished.
+ *
+ * @returns {Promise<MsgOperationReturnValue>} - Promise for information about
+ * the performed message operation, which is passed to the WebExtension.
+ */
+async function goDoCommand(composeWindow, extension, mode) {
+ let commands = new Map([
+ ["draft", "cmd_saveAsDraft"],
+ ["template", "cmd_saveAsTemplate"],
+ ["sendNow", "cmd_sendNow"],
+ ["sendLater", "cmd_sendLater"],
+ ]);
+
+ if (!commands.has(mode)) {
+ throw new ExtensionError(`Unsupported mode: ${mode}`);
+ }
+
+ if (!composeWindow.defaultController.isCommandEnabled(commands.get(mode))) {
+ throw new ExtensionError(
+ `Message compose window not ready for the requested command`
+ );
+ }
+
+ let sendPromise = new Promise((resolve, reject) => {
+ let listener = {
+ onSuccess(window, mode, messages, headerMessageId) {
+ if (window == composeWindow) {
+ afterSaveSendEventTracker.removeListener(listener);
+ let info = { mode, messages };
+ if (mode == "sendNow") {
+ info.headerMessageId = headerMessageId;
+ }
+ resolve(info);
+ }
+ },
+ onFailure(window, mode, exception) {
+ if (window == composeWindow) {
+ afterSaveSendEventTracker.removeListener(listener);
+ reject(exception);
+ }
+ },
+ modes: [mode],
+ extension,
+ };
+ afterSaveSendEventTracker.addListener(listener);
+ });
+
+ // Initiate send.
+ switch (mode) {
+ case "draft":
+ composeWindow.SaveAsDraft();
+ break;
+ case "template":
+ composeWindow.SaveAsTemplate();
+ break;
+ case "sendNow":
+ composeWindow.SendMessage();
+ break;
+ case "sendLater":
+ composeWindow.SendMessageLater();
+ break;
+ }
+ return sendPromise;
+}
+
+var afterSaveSendEventTracker = {
+ listeners: new Set(),
+
+ addListener(listener) {
+ this.listeners.add(listener);
+ },
+ removeListener(listener) {
+ this.listeners.delete(listener);
+ },
+ async handleSuccess(window, mode, messages, headerMessageId) {
+ for (let listener of this.listeners) {
+ if (!listener.modes.includes(mode)) {
+ continue;
+ }
+ await listener.onSuccess(
+ window,
+ mode,
+ messages.map(message => {
+ // Strip data from MessageHeader if this extension doesn't have
+ // the required permission.
+ let clone = Object.assign({}, message);
+ if (!listener.extension.hasPermission("accountsRead")) {
+ delete clone.folders;
+ }
+ return clone;
+ }),
+ headerMessageId
+ );
+ }
+ },
+ async handleFailure(window, mode, exception) {
+ for (let listener of this.listeners) {
+ if (!listener.modes.includes(mode)) {
+ continue;
+ }
+ await listener.onFailure(window, mode, exception);
+ }
+ },
+
+ // Event handler for the "compose-prepare-message-start", which initiates a
+ // new message operation (send or save).
+ handleEvent(event) {
+ let composeWindow = event.target;
+ let msgType = event.detail.msgType;
+
+ let modes = new Map([
+ [Ci.nsIMsgCompDeliverMode.SaveAsDraft, "draft"],
+ [Ci.nsIMsgCompDeliverMode.SaveAsTemplate, "template"],
+ [Ci.nsIMsgCompDeliverMode.Now, "sendNow"],
+ [Ci.nsIMsgCompDeliverMode.Later, "sendLater"],
+ ]);
+ let mode = modes.get(msgType);
+
+ if (mode && this.listeners.size > 0) {
+ let msgOperationObserver = new MsgOperationObserver(composeWindow);
+ msgOperationObserver
+ .waitForOperation()
+ .then(msgOperationInfo =>
+ this.handleSuccess(
+ composeWindow,
+ mode,
+ msgOperationInfo.messages,
+ msgOperationInfo.headerMessageId
+ )
+ )
+ .catch(msgOperationException =>
+ this.handleFailure(composeWindow, mode, msgOperationException)
+ );
+ }
+ },
+};
+windowTracker.addListener(
+ "compose-prepare-message-start",
+ afterSaveSendEventTracker
+);
+
+var beforeSendEventTracker = {
+ listeners: new Set(),
+
+ addListener(listener) {
+ this.listeners.add(listener);
+ if (this.listeners.size == 1) {
+ windowTracker.addListener("beforesend", this);
+ }
+ },
+ removeListener(listener) {
+ this.listeners.delete(listener);
+ if (this.listeners.size == 0) {
+ windowTracker.removeListener("beforesend", this);
+ }
+ },
+ async handleEvent(event) {
+ event.preventDefault();
+
+ let sendPromise = event.detail;
+ let composeWindow = event.target;
+ await composeWindowIsReady(composeWindow);
+ composeWindow.ToggleWindowLock(true);
+
+ // Send process waits till sendPromise.resolve() or sendPromise.reject() is
+ // called.
+
+ for (let { handler, extension } of this.listeners) {
+ let result = await handler(
+ composeWindow,
+ await getComposeDetails(composeWindow, extension)
+ );
+ if (!result) {
+ continue;
+ }
+ if (result.cancel) {
+ composeWindow.ToggleWindowLock(false);
+ sendPromise.reject();
+ return;
+ }
+ if (result.details) {
+ await setComposeDetails(composeWindow, result.details, extension);
+ }
+ }
+
+ // Load the new details into gMsgCompose.compFields for sending.
+ composeWindow.GetComposeDetails();
+
+ composeWindow.ToggleWindowLock(false);
+ sendPromise.resolve();
+ },
+};
+
+var composeAttachmentTracker = {
+ _nextId: 1,
+ _attachments: new Map(),
+ _attachmentIds: new Map(),
+
+ getId(attachment, window) {
+ if (this._attachmentIds.has(attachment)) {
+ return this._attachmentIds.get(attachment).id;
+ }
+ let id = this._nextId++;
+ this._attachments.set(id, { attachment, window });
+ this._attachmentIds.set(attachment, { id, window });
+ return id;
+ },
+
+ getAttachment(id) {
+ return this._attachments.get(id);
+ },
+
+ hasAttachment(id) {
+ return this._attachments.has(id);
+ },
+
+ forgetAttachment(attachment) {
+ // This is called on all attachments when the window closes, whether the
+ // attachments have been assigned IDs or not.
+ let id = this._attachmentIds.get(attachment)?.id;
+ if (id) {
+ this._attachmentIds.delete(attachment);
+ this._attachments.delete(id);
+ }
+ },
+
+ forgetAttachments(window) {
+ if (window.location.href == COMPOSE_WINDOW_URI) {
+ let bucket = window.document.getElementById("attachmentBucket");
+ for (let item of bucket.itemChildren) {
+ this.forgetAttachment(item.attachment);
+ }
+ }
+ },
+
+ convert(attachment, window) {
+ return {
+ id: this.getId(attachment, window),
+ name: attachment.name,
+ size: attachment.size,
+ };
+ },
+
+ getFile(attachment) {
+ if (!attachment) {
+ return null;
+ }
+ let uri = Services.io.newURI(attachment.url).QueryInterface(Ci.nsIFileURL);
+ // Enforce the actual filename used in the composer, do not leak internal or
+ // temporary filenames.
+ return File.createFromNsIFile(uri.file, { name: attachment.name });
+ },
+};
+
+windowTracker.addCloseListener(
+ composeAttachmentTracker.forgetAttachments.bind(composeAttachmentTracker)
+);
+
+var composeWindowTracker = new Set();
+windowTracker.addCloseListener(window => composeWindowTracker.delete(window));
+
+this.compose = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onBeforeSend({ context, fire }) {
+ const { extension } = this;
+ const { tabManager, windowManager } = extension;
+ let listener = {
+ async handler(window, details) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let win = windowManager.wrapWindow(window);
+ return fire.async(
+ tabManager.convert(win.activeTab.nativeTab),
+ details
+ );
+ },
+ extension,
+ };
+
+ beforeSendEventTracker.addListener(listener);
+ return {
+ unregister: () => {
+ beforeSendEventTracker.removeListener(listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAfterSend({ context, fire }) {
+ const { extension } = this;
+ const { tabManager, windowManager } = extension;
+ let listener = {
+ async onSuccess(window, mode, messages, headerMessageId) {
+ let win = windowManager.wrapWindow(window);
+ let tab = tabManager.convert(win.activeTab.nativeTab);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let sendInfo = { mode, messages };
+ if (mode == "sendNow") {
+ sendInfo.headerMessageId = headerMessageId;
+ }
+ return fire.async(tab, sendInfo);
+ },
+ async onFailure(window, mode, exception) {
+ let win = windowManager.wrapWindow(window);
+ let tab = tabManager.convert(win.activeTab.nativeTab);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ return fire.async(tab, {
+ mode,
+ messages: [],
+ error: exception.message,
+ });
+ },
+ modes: ["sendNow", "sendLater"],
+ extension,
+ };
+ afterSaveSendEventTracker.addListener(listener);
+ return {
+ unregister: () => {
+ afterSaveSendEventTracker.removeListener(listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAfterSave({ context, fire }) {
+ const { extension } = this;
+ const { tabManager, windowManager } = extension;
+ let listener = {
+ async onSuccess(window, mode, messages, headerMessageId) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let win = windowManager.wrapWindow(window);
+ let saveInfo = { mode, messages };
+ return fire.async(
+ tabManager.convert(win.activeTab.nativeTab),
+ saveInfo
+ );
+ },
+ async onFailure(window, mode, exception) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let win = windowManager.wrapWindow(window);
+ return fire.async(tabManager.convert(win.activeTab.nativeTab), {
+ mode,
+ messages: [],
+ error: exception.message,
+ });
+ },
+ modes: ["draft", "template"],
+ extension,
+ };
+ afterSaveSendEventTracker.addListener(listener);
+ return {
+ unregister: () => {
+ afterSaveSendEventTracker.removeListener(listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAttachmentAdded({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ for (let attachment of event.detail) {
+ attachment = composeAttachmentTracker.convert(
+ attachment,
+ event.target.ownerGlobal
+ );
+ fire.async(tabManager.convert(event.target.ownerGlobal), attachment);
+ }
+ }
+ windowTracker.addListener("attachments-added", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("attachments-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAttachmentRemoved({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ for (let attachment of event.detail) {
+ let attachmentId = composeAttachmentTracker.getId(
+ attachment,
+ event.target.ownerGlobal
+ );
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ attachmentId
+ );
+ composeAttachmentTracker.forgetAttachment(attachment);
+ }
+ }
+ windowTracker.addListener("attachments-removed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("attachments-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onIdentityChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ event.target.getCurrentIdentityKey()
+ );
+ }
+ windowTracker.addListener("compose-from-changed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("compose-from-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onComposeStateChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ composeStates.convert(event.detail)
+ );
+ }
+ windowTracker.addListener("compose-state-changed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("compose-state-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onActiveDictionariesChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let activeDictionaries = event.detail.split(",");
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine)
+ .getDictionaryList()
+ .reduce((list, dict) => {
+ list[dict] = activeDictionaries.includes(dict);
+ return list;
+ }, {})
+ );
+ }
+ windowTracker.addListener("active-dictionaries-changed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("active-dictionaries-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ /**
+ * Guard to make sure the API waits until the compose tab has been fully loaded,
+ * to cope with tabs.onCreated returning tabs very early.
+ *
+ * @param {integer} tabId
+ * @returns {Tab} a fully loaded messageCompose tab
+ */
+ async function getComposeTab(tabId) {
+ let tab = tabManager.get(tabId);
+ if (tab.type != "messageCompose") {
+ throw new ExtensionError(`Invalid compose tab: ${tabId}`);
+ }
+ await composeWindowIsReady(tab.nativeTab);
+ return tab;
+ }
+
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ return {
+ compose: {
+ onBeforeSend: new EventManager({
+ context,
+ module: "compose",
+ event: "onBeforeSend",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onAfterSend: new EventManager({
+ context,
+ module: "compose",
+ event: "onAfterSend",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onAfterSave: new EventManager({
+ context,
+ module: "compose",
+ event: "onAfterSave",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onAttachmentAdded: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onAttachmentAdded",
+ extensionApi: this,
+ }).api(),
+ onAttachmentRemoved: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onAttachmentRemoved",
+ extensionApi: this,
+ }).api(),
+ onIdentityChanged: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onIdentityChanged",
+ extensionApi: this,
+ }).api(),
+ onComposeStateChanged: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onComposeStateChanged",
+ extensionApi: this,
+ }).api(),
+ onActiveDictionariesChanged: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onActiveDictionariesChanged",
+ extensionApi: this,
+ }).api(),
+ async beginNew(messageId, details) {
+ let type = Ci.nsIMsgCompType.New;
+ if (messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ type =
+ msgHdr.flags & Ci.nsMsgMessageFlags.Template
+ ? Ci.nsIMsgCompType.Template
+ : Ci.nsIMsgCompType.EditAsNew;
+ }
+ let composeWindow = await openComposeWindow(
+ messageId,
+ type,
+ details,
+ extension
+ );
+ return tabManager.convert(composeWindow);
+ },
+ async beginReply(messageId, replyType, details) {
+ let type = Ci.nsIMsgCompType.Reply;
+ if (replyType == "replyToList") {
+ type = Ci.nsIMsgCompType.ReplyToList;
+ } else if (replyType == "replyToAll") {
+ type = Ci.nsIMsgCompType.ReplyAll;
+ }
+ let composeWindow = await openComposeWindow(
+ messageId,
+ type,
+ details,
+ extension
+ );
+ return tabManager.convert(composeWindow);
+ },
+ async beginForward(messageId, forwardType, details) {
+ let type = Ci.nsIMsgCompType.ForwardInline;
+ if (forwardType == "forwardAsAttachment") {
+ type = Ci.nsIMsgCompType.ForwardAsAttachment;
+ } else if (
+ forwardType === null &&
+ Services.prefs.getIntPref("mail.forward_message_mode") == 0
+ ) {
+ type = Ci.nsIMsgCompType.ForwardAsAttachment;
+ }
+ let composeWindow = await openComposeWindow(
+ messageId,
+ type,
+ details,
+ extension
+ );
+ return tabManager.convert(composeWindow);
+ },
+ async saveMessage(tabId, options) {
+ let tab = await getComposeTab(tabId);
+ let saveMode = options?.mode || "draft";
+
+ try {
+ return await goDoCommand(
+ tab.nativeTab,
+ context.extension,
+ saveMode
+ );
+ } catch (ex) {
+ throw new ExtensionError(
+ `compose.saveMessage failed: ${ex.message}`
+ );
+ }
+ },
+ async sendMessage(tabId, options) {
+ let tab = await getComposeTab(tabId);
+ let sendMode = options?.mode;
+ if (!["sendLater", "sendNow"].includes(sendMode)) {
+ sendMode = Services.io.offline ? "sendLater" : "sendNow";
+ }
+
+ try {
+ return await goDoCommand(
+ tab.nativeTab,
+ context.extension,
+ sendMode
+ );
+ } catch (ex) {
+ throw new ExtensionError(
+ `compose.sendMessage failed: ${ex.message}`
+ );
+ }
+ },
+ async getComposeState(tabId) {
+ let tab = await getComposeTab(tabId);
+ return composeStates.getStates(tab);
+ },
+ async getComposeDetails(tabId) {
+ let tab = await getComposeTab(tabId);
+ return getComposeDetails(tab.nativeTab, extension);
+ },
+ async setComposeDetails(tabId, details) {
+ let tab = await getComposeTab(tabId);
+ return setComposeDetails(tab.nativeTab, details, extension);
+ },
+ async getActiveDictionaries(tabId) {
+ let tab = await getComposeTab(tabId);
+ let dictionaries = tab.nativeTab.gActiveDictionaries;
+
+ // Return the list of installed dictionaries, setting those who are
+ // enabled to true.
+ return Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine)
+ .getDictionaryList()
+ .reduce((list, dict) => {
+ list[dict] = dictionaries.has(dict);
+ return list;
+ }, {});
+ },
+ async setActiveDictionaries(tabId, activeDictionaries) {
+ let tab = await getComposeTab(tabId);
+ let installedDictionaries = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine)
+ .getDictionaryList();
+
+ for (let dict of activeDictionaries) {
+ if (!installedDictionaries.includes(dict)) {
+ throw new ExtensionError(`Dictionary not found: ${dict}`);
+ }
+ }
+
+ await tab.nativeTab.ComposeChangeLanguage(activeDictionaries);
+ },
+ async listAttachments(tabId) {
+ let tab = await getComposeTab(tabId);
+
+ let bucket =
+ tab.nativeTab.document.getElementById("attachmentBucket");
+ let attachments = [];
+ for (let item of bucket.itemChildren) {
+ attachments.push(
+ composeAttachmentTracker.convert(item.attachment, tab.nativeTab)
+ );
+ }
+ return attachments;
+ },
+ async getAttachmentFile(attachmentId) {
+ if (!composeAttachmentTracker.hasAttachment(attachmentId)) {
+ throw new ExtensionError(`Invalid attachment: ${attachmentId}`);
+ }
+ let { attachment } =
+ composeAttachmentTracker.getAttachment(attachmentId);
+ return composeAttachmentTracker.getFile(attachment);
+ },
+ async addAttachment(tabId, data) {
+ let tab = await getComposeTab(tabId);
+ let attachmentData = await createAttachment(data);
+ await AddAttachmentsToWindow(tab.nativeTab, [attachmentData]);
+ return composeAttachmentTracker.convert(
+ attachmentData.attachment,
+ tab.nativeTab
+ );
+ },
+ async updateAttachment(tabId, attachmentId, data) {
+ let tab = await getComposeTab(tabId);
+ if (!composeAttachmentTracker.hasAttachment(attachmentId)) {
+ throw new ExtensionError(`Invalid attachment: ${attachmentId}`);
+ }
+ let { attachment, window } =
+ composeAttachmentTracker.getAttachment(attachmentId);
+ if (window != tab.nativeTab) {
+ throw new ExtensionError(
+ `Attachment ${attachmentId} is not associated with tab ${tabId}`
+ );
+ }
+
+ let attachmentItem =
+ window.gAttachmentBucket.findItemForAttachment(attachment);
+ if (!attachmentItem) {
+ throw new ExtensionError(`Unexpected invalid attachment item`);
+ }
+
+ if (!data.file && !data.name) {
+ throw new ExtensionError(
+ `Either data.file or data.name property must be specified`
+ );
+ }
+
+ let realFile = data.file ? await getRealFileForFile(data.file) : null;
+ try {
+ await window.UpdateAttachment(attachmentItem, {
+ file: realFile,
+ name: data.name,
+ relatedCloudFileUpload: attachmentItem.cloudFileUpload,
+ });
+ } catch (ex) {
+ throw new ExtensionError(ex.message);
+ }
+
+ return composeAttachmentTracker.convert(attachmentItem.attachment);
+ },
+ async removeAttachment(tabId, attachmentId) {
+ let tab = await getComposeTab(tabId);
+ if (!composeAttachmentTracker.hasAttachment(attachmentId)) {
+ throw new ExtensionError(`Invalid attachment: ${attachmentId}`);
+ }
+ let { attachment, window } =
+ composeAttachmentTracker.getAttachment(attachmentId);
+ if (window != tab.nativeTab) {
+ throw new ExtensionError(
+ `Attachment ${attachmentId} is not associated with tab ${tabId}`
+ );
+ }
+
+ let item = window.gAttachmentBucket.findItemForAttachment(attachment);
+ await window.RemoveAttachments([item]);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-composeAction.js b/comm/mail/components/extensions/parent/ext-composeAction.js
new file mode 100644
index 0000000000..fb2a462d33
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-composeAction.js
@@ -0,0 +1,154 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ToolbarButtonAPI",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+const composeActionMap = new WeakMap();
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+this.composeAction = class extends ToolbarButtonAPI {
+ static for(extension) {
+ return composeActionMap.get(extension);
+ }
+
+ async onManifestEntry(entryName) {
+ await super.onManifestEntry(entryName);
+ composeActionMap.set(this.extension, this);
+ }
+
+ close() {
+ super.close();
+ composeActionMap.delete(this.extension);
+ }
+
+ constructor(extension) {
+ super(extension, global);
+ this.manifest_name = "compose_action";
+ this.manifestName = "composeAction";
+ this.manifest = extension.manifest[this.manifest_name];
+ this.moduleName = this.manifestName;
+
+ this.windowURLs = [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ];
+ let isFormatToolbar =
+ extension.manifest.compose_action.default_area == "formattoolbar";
+ this.toolboxId = isFormatToolbar ? "FormatToolbox" : "compose-toolbox";
+ this.toolbarId = isFormatToolbar ? "FormatToolbar" : "composeToolbar2";
+ }
+
+ static onUninstall(extensionId) {
+ let widgetId = makeWidgetId(extensionId);
+ let id = `${widgetId}-composeAction-toolbarbutton`;
+ let windowURL =
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml";
+
+ // Check all possible toolbars and remove the toolbarbutton if found.
+ // Sadly we have to hardcode these values here, as the add-on is already
+ // shutdown when onUninstall is called.
+ let toolbars = ["composeToolbar2", "FormatToolbar"];
+ for (let toolbar of toolbars) {
+ for (let setName of ["currentset", "extensionset"]) {
+ let set = Services.xulStore
+ .getValue(windowURL, toolbar, setName)
+ .split(",");
+ let newSet = set.filter(e => e != id);
+ if (newSet.length < set.length) {
+ Services.xulStore.setValue(
+ windowURL,
+ toolbar,
+ setName,
+ newSet.join(",")
+ );
+ }
+ }
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+ let window = event.target.ownerGlobal;
+
+ switch (event.type) {
+ case "popupshowing":
+ const menu = event.target;
+ if (menu.tagName != "menupopup") {
+ return;
+ }
+
+ const trigger = menu.triggerNode;
+ const node = window.document.getElementById(this.id);
+ const contexts = [
+ "format-toolbar-context-menu",
+ "toolbar-context-menu",
+ "customizationPanelItemContextMenu",
+ ];
+ if (contexts.includes(menu.id) && node && node.contains(trigger)) {
+ global.actionContextMenu({
+ tab: window,
+ pageUrl: window.browser.currentURI.spec,
+ extension: this.extension,
+ onComposeAction: true,
+ menu,
+ });
+ }
+
+ if (
+ menu.dataset.actionMenu == "composeAction" &&
+ this.extension.id == menu.dataset.extensionId
+ ) {
+ global.actionContextMenu({
+ tab: window,
+ pageUrl: window.browser.currentURI.spec,
+ extension: this.extension,
+ inComposeActionMenu: true,
+ menu,
+ });
+ }
+ break;
+ }
+ }
+
+ makeButton(window) {
+ let button = super.makeButton(window);
+ if (this.toolbarId == "FormatToolbar") {
+ button.classList.add("formatting-button");
+ // The format toolbar has no associated context menu. Add one directly to
+ // this button.
+ button.setAttribute("context", "format-toolbar-context-menu");
+ }
+ return button;
+ }
+
+ /**
+ * Returns an element in the toolbar, which is to be used as default insertion
+ * point for new toolbar buttons in non-customizable toolbars.
+ *
+ * May return null to append new buttons to the end of the toolbar.
+ *
+ * @param {DOMElement} toolbar - a toolbar node
+ * @returns {DOMElement} a node which is to be used as insertion point, or null
+ */
+ getNonCustomizableToolbarInsertionPoint(toolbar) {
+ let before = toolbar.lastElementChild;
+ while (before.localName == "spacer") {
+ before = before.previousElementSibling;
+ }
+ return before.nextElementSibling;
+ }
+};
+
+global.composeActionFor = this.composeAction.for;
diff --git a/comm/mail/components/extensions/parent/ext-extensionScripts.js b/comm/mail/components/extensions/parent/ext-extensionScripts.js
new file mode 100644
index 0000000000..ef5da07586
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-extensionScripts.js
@@ -0,0 +1,185 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+
+var { getUniqueId } = ExtensionUtils;
+
+let scripts = new Set();
+
+ExtensionSupport.registerWindowListener("ext-composeScripts", {
+ chromeURLs: [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ],
+ onLoadWindow: async win => {
+ await new Promise(resolve =>
+ win.addEventListener("compose-editor-ready", resolve, { once: true })
+ );
+ for (let script of scripts) {
+ if (script.type == "compose") {
+ script.executeInWindow(
+ win,
+ script.extension.tabManager.getWrapper(win)
+ );
+ }
+ }
+ },
+});
+
+ExtensionSupport.registerWindowListener("ext-messageDisplayScripts", {
+ chromeURLs: [
+ "chrome://messenger/content/messageWindow.xhtml",
+ "chrome://messenger/content/messenger.xhtml",
+ ],
+ onLoadWindow(win) {
+ win.addEventListener("MsgLoaded", event => {
+ // `event.target` is an about:message window.
+ let nativeTab = event.target.tabOrWindow;
+ for (let script of scripts) {
+ if (script.type == "messageDisplay") {
+ script.executeInWindow(
+ win,
+ script.extension.tabManager.wrapTab(nativeTab)
+ );
+ }
+ }
+ });
+ },
+});
+
+/**
+ * Represents (in the main browser process) a script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ProxyContextParent} context
+ * The parent proxy context related to the extension context which
+ * has registered the script.
+ * @param {RegisteredScriptOptions} details
+ * The options object related to the registered script
+ * (which has the properties described in the extensionScripts.json
+ * JSON API schema file).
+ */
+class ExtensionScriptParent {
+ constructor(type, context, details) {
+ this.type = type;
+ this.context = context;
+ this.extension = context.extension;
+ this.scriptId = getUniqueId();
+
+ this.options = this._convertOptions(details);
+ context.callOnClose(this);
+
+ scripts.add(this);
+ }
+
+ close() {
+ this.destroy();
+ }
+
+ destroy() {
+ if (this.destroyed) {
+ throw new ExtensionError("Unable to destroy ExtensionScriptParent twice");
+ }
+
+ scripts.delete(this);
+
+ this.destroyed = true;
+ this.context.forgetOnClose(this);
+ this.context = null;
+ this.options = null;
+ }
+
+ _convertOptions(details) {
+ const options = {
+ js: [],
+ css: [],
+ };
+
+ if (details.js && details.js.length) {
+ options.js = details.js.map(data => {
+ return {
+ code: data.code || null,
+ file: data.file || null,
+ };
+ });
+ }
+
+ if (details.css && details.css.length) {
+ options.css = details.css.map(data => {
+ return {
+ code: data.code || null,
+ file: data.file || null,
+ };
+ });
+ }
+
+ return options;
+ }
+
+ async executeInWindow(window, tab) {
+ for (let css of this.options.css) {
+ await tab.insertCSS(this.context, { ...css, frameId: null });
+ }
+ for (let js of this.options.js) {
+ await tab.executeScript(this.context, { ...js, frameId: null });
+ }
+ window.dispatchEvent(new window.CustomEvent("extension-scripts-added"));
+ }
+}
+
+this.extensionScripts = class extends ExtensionAPI {
+ getAPI(context) {
+ // Map of the script registered from the extension context.
+ //
+ // Map<scriptId -> ExtensionScriptParent>
+ const parentScriptsMap = new Map();
+
+ // Unregister all the scriptId related to a context when it is closed.
+ context.callOnClose({
+ close() {
+ for (let script of parentScriptsMap.values()) {
+ script.destroy();
+ }
+ parentScriptsMap.clear();
+ },
+ });
+
+ return {
+ extensionScripts: {
+ async register(type, details) {
+ const script = new ExtensionScriptParent(type, context, details);
+ const { scriptId } = script;
+
+ parentScriptsMap.set(scriptId, script);
+ return scriptId;
+ },
+
+ // This method is not available to the extension code, the extension code
+ // doesn't have access to the internally used scriptId, on the contrary
+ // the extension code will call script.unregister on the script API object
+ // that is resolved from the register API method returned promise.
+ async unregister(scriptId) {
+ const script = parentScriptsMap.get(scriptId);
+ if (!script) {
+ console.error(new ExtensionError(`No such script ID: ${scriptId}`));
+
+ return;
+ }
+
+ parentScriptsMap.delete(scriptId);
+ script.destroy();
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-folders.js b/comm/mail/components/extensions/parent/ext-folders.js
new file mode 100644
index 0000000000..63704b9dd7
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-folders.js
@@ -0,0 +1,675 @@
+/* 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/. */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+});
+
+/**
+ * Tracks folder events.
+ *
+ * @implements {nsIMsgFolderListener}
+ */
+var folderTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.pendingInfoNotifications = new ExtensionUtils.DefaultMap(
+ () => new Map()
+ );
+ this.deferredInfoNotifications = new ExtensionUtils.DefaultMap(
+ folder =>
+ new DeferredTask(
+ () => this.emitPendingInfoNotification(folder),
+ NOTIFICATION_COLLAPSE_TIME
+ )
+ );
+ }
+
+ on(...args) {
+ super.on(...args);
+ this.incrementListeners();
+ }
+
+ off(...args) {
+ super.off(...args);
+ this.decrementListeners();
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ // nsIMsgFolderListener
+ const flags =
+ MailServices.mfn.folderAdded |
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted |
+ MailServices.mfn.folderRenamed;
+ MailServices.mfn.addListener(this, flags);
+ // nsIFolderListener
+ MailServices.mailSession.AddFolderListener(
+ this,
+ Ci.nsIFolderListener.intPropertyChanged
+ );
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ MailServices.mfn.removeListener(this);
+ MailServices.mailSession.RemoveFolderListener(this);
+ }
+ }
+
+ // nsIFolderListener
+
+ onFolderIntPropertyChanged(item, property, oldValue, newValue) {
+ if (!(item instanceof Ci.nsIMsgFolder)) {
+ return;
+ }
+
+ switch (property) {
+ case "FolderFlag":
+ if (
+ (oldValue & Ci.nsMsgFolderFlags.Favorite) !=
+ (newValue & Ci.nsMsgFolderFlags.Favorite)
+ ) {
+ this.addPendingInfoNotification(
+ item,
+ "favorite",
+ !!(newValue & Ci.nsMsgFolderFlags.Favorite)
+ );
+ }
+ break;
+ case "TotalMessages":
+ this.addPendingInfoNotification(item, "totalMessageCount", newValue);
+ break;
+ case "TotalUnreadMessages":
+ this.addPendingInfoNotification(item, "unreadMessageCount", newValue);
+ break;
+ }
+ }
+
+ addPendingInfoNotification(folder, key, value) {
+ // If there is already a notification entry, decide if it must be emitted,
+ // or if it can be collapsed: Message count changes can be collapsed.
+ // This also collapses multiple different notifications types into a
+ // single event.
+ if (
+ ["favorite"].includes(key) &&
+ this.deferredInfoNotifications.has(folder) &&
+ this.pendingInfoNotifications.get(folder).has(key)
+ ) {
+ this.deferredInfoNotifications.get(folder).disarm();
+ this.emitPendingInfoNotification(folder);
+ }
+
+ this.pendingInfoNotifications.get(folder).set(key, value);
+ this.deferredInfoNotifications.get(folder).disarm();
+ this.deferredInfoNotifications.get(folder).arm();
+ }
+
+ emitPendingInfoNotification(folder) {
+ let folderInfo = this.pendingInfoNotifications.get(folder);
+ if (folderInfo.size > 0) {
+ this.emit(
+ "folder-info-changed",
+ convertFolder(folder),
+ Object.fromEntries(folderInfo)
+ );
+ this.pendingInfoNotifications.delete(folder);
+ }
+ }
+
+ // nsIMsgFolderListener
+
+ folderAdded(childFolder) {
+ this.emit("folder-created", convertFolder(childFolder));
+ }
+ folderDeleted(oldFolder) {
+ // Deleting an account, will trigger delete notifications for its folders,
+ // but the account lookup fails, so skip them.
+ let server = oldFolder.server;
+ let account = MailServices.accounts.FindAccountForServer(server);
+ if (account) {
+ this.emit("folder-deleted", convertFolder(oldFolder, account.key));
+ }
+ }
+ folderMoveCopyCompleted(move, srcFolder, targetFolder) {
+ // targetFolder is not the copied/moved folder, but its parent. Find the
+ // actual folder by its name (which is unique).
+ let dstFolder = null;
+ if (targetFolder && targetFolder.hasSubFolders) {
+ dstFolder = targetFolder.subFolders.find(
+ f => f.prettyName == srcFolder.prettyName
+ );
+ }
+
+ if (move) {
+ this.emit(
+ "folder-moved",
+ convertFolder(srcFolder),
+ convertFolder(dstFolder)
+ );
+ } else {
+ this.emit(
+ "folder-copied",
+ convertFolder(srcFolder),
+ convertFolder(dstFolder)
+ );
+ }
+ }
+ folderRenamed(oldFolder, newFolder) {
+ this.emit(
+ "folder-renamed",
+ convertFolder(oldFolder),
+ convertFolder(newFolder)
+ );
+ }
+})();
+
+/**
+ * Accepts a MailFolder or a MailAccount and returns the actual folder and its
+ * accountId. Throws if the requested folder does not exist.
+ */
+function getFolder({ accountId, path, id }) {
+ if (id && !path && !accountId) {
+ accountId = id;
+ path = "/";
+ }
+
+ let uri = folderPathToURI(accountId, path);
+ let folder = MailServices.folderLookup.getFolderForURL(uri);
+ if (!folder) {
+ throw new ExtensionError(`Folder not found: ${path}`);
+ }
+ return { folder, accountId };
+}
+
+/**
+ * Copy or Move a folder.
+ */
+async function doMoveCopyOperation(source, destination, isMove) {
+ // The schema file allows destination to be either a MailFolder or a
+ // MailAccount.
+ let srcFolder = getFolder(source);
+ let dstFolder = getFolder(destination);
+
+ if (
+ srcFolder.folder.server.type == "nntp" ||
+ dstFolder.folder.server.type == "nntp"
+ ) {
+ throw new ExtensionError(
+ `folders.${isMove ? "move" : "copy"}() is not supported in news accounts`
+ );
+ }
+
+ if (
+ dstFolder.folder.hasSubFolders &&
+ dstFolder.folder.subFolders.find(
+ f => f.prettyName == srcFolder.folder.prettyName
+ )
+ ) {
+ throw new ExtensionError(
+ `folders.${isMove ? "move" : "copy"}() failed, because ${
+ srcFolder.folder.prettyName
+ } already exists in ${folderURIToPath(
+ dstFolder.accountId,
+ dstFolder.folder.URI
+ )}`
+ );
+ }
+
+ let rv = await new Promise(resolve => {
+ let _destination = null;
+ const listener = {
+ folderMoveCopyCompleted(_isMove, _srcFolder, _dstFolder) {
+ if (
+ _destination != null ||
+ _isMove != isMove ||
+ _srcFolder.URI != srcFolder.folder.URI ||
+ _dstFolder.URI != dstFolder.folder.URI
+ ) {
+ return;
+ }
+
+ // The targetFolder is not the copied/moved folder, but its parent.
+ // Find the actual folder by its name (which is unique).
+ if (_dstFolder && _dstFolder.hasSubFolders) {
+ _destination = _dstFolder.subFolders.find(
+ f => f.prettyName == _srcFolder.prettyName
+ );
+ }
+ },
+ };
+ MailServices.mfn.addListener(
+ listener,
+ MailServices.mfn.folderMoveCopyCompleted
+ );
+ MailServices.copy.copyFolder(
+ srcFolder.folder,
+ dstFolder.folder,
+ isMove,
+ {
+ OnStartCopy() {},
+ OnProgress() {},
+ SetMessageKey() {},
+ GetMessageId() {},
+ OnStopCopy(status) {
+ MailServices.mfn.removeListener(listener);
+ resolve({
+ status,
+ folder: _destination,
+ });
+ },
+ },
+ null
+ );
+ });
+
+ if (!Components.isSuccessCode(rv.status)) {
+ throw new ExtensionError(
+ `folders.${isMove ? "move" : "copy"}() failed for unknown reasons`
+ );
+ }
+
+ return convertFolder(rv.folder, dstFolder.accountId);
+}
+
+/**
+ * Wait for a folder operation.
+ */
+function waitForOperation(flags, uri) {
+ return new Promise(resolve => {
+ MailServices.mfn.addListener(
+ {
+ folderAdded(childFolder) {
+ if (childFolder.parent.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve(childFolder);
+ },
+ folderDeleted(oldFolder) {
+ if (oldFolder.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve();
+ },
+ folderMoveCopyCompleted(move, srcFolder, destFolder) {
+ if (srcFolder.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve(destFolder);
+ },
+ folderRenamed(oldFolder, newFolder) {
+ if (oldFolder.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve(newFolder);
+ },
+ },
+ flags
+ );
+ });
+}
+
+this.folders = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCreated({ context, fire }) {
+ async function listener(event, createdMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(createdMailFolder);
+ }
+ folderTracker.on("folder-created", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onRenamed({ context, fire }) {
+ async function listener(event, originalMailFolder, renamedMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(originalMailFolder, renamedMailFolder);
+ }
+ folderTracker.on("folder-renamed", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-renamed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMoved({ context, fire }) {
+ async function listener(event, srcMailFolder, dstMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcMailFolder, dstMailFolder);
+ }
+ folderTracker.on("folder-moved", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-moved", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onCopied({ context, fire }) {
+ async function listener(event, srcMailFolder, dstMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcMailFolder, dstMailFolder);
+ }
+ folderTracker.on("folder-copied", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-copied", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ async function listener(event, deletedMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(deletedMailFolder);
+ }
+ folderTracker.on("folder-deleted", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onFolderInfoChanged({ context, fire }) {
+ async function listener(event, changedMailFolder, mailFolderInfo) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(changedMailFolder, mailFolderInfo);
+ }
+ folderTracker.on("folder-info-changed", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-info-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ return {
+ folders: {
+ onCreated: new EventManager({
+ context,
+ module: "folders",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+ onRenamed: new EventManager({
+ context,
+ module: "folders",
+ event: "onRenamed",
+ extensionApi: this,
+ }).api(),
+ onMoved: new EventManager({
+ context,
+ module: "folders",
+ event: "onMoved",
+ extensionApi: this,
+ }).api(),
+ onCopied: new EventManager({
+ context,
+ module: "folders",
+ event: "onCopied",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "folders",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ onFolderInfoChanged: new EventManager({
+ context,
+ module: "folders",
+ event: "onFolderInfoChanged",
+ extensionApi: this,
+ }).api(),
+ async create(parent, childName) {
+ // The schema file allows parent to be either a MailFolder or a
+ // MailAccount.
+ let { folder: parentFolder, accountId } = getFolder(parent);
+
+ if (
+ parentFolder.hasSubFolders &&
+ parentFolder.subFolders.find(f => f.prettyName == childName)
+ ) {
+ throw new ExtensionError(
+ `folders.create() failed, because ${childName} already exists in ${folderURIToPath(
+ accountId,
+ parentFolder.URI
+ )}`
+ );
+ }
+
+ let childFolderPromise = waitForOperation(
+ MailServices.mfn.folderAdded,
+ parentFolder.URI
+ );
+ parentFolder.createSubfolder(childName, null);
+
+ let childFolder = await childFolderPromise;
+ return convertFolder(childFolder, accountId);
+ },
+ async rename({ accountId, path }, newName) {
+ let { folder } = getFolder({ accountId, path });
+
+ if (!folder.parent) {
+ throw new ExtensionError(
+ `folders.rename() failed, because it cannot rename the root of the account`
+ );
+ }
+ if (folder.server.type == "nntp") {
+ throw new ExtensionError(
+ `folders.rename() is not supported in news accounts`
+ );
+ }
+
+ if (folder.parent.subFolders.find(f => f.prettyName == newName)) {
+ throw new ExtensionError(
+ `folders.rename() failed, because ${newName} already exists in ${folderURIToPath(
+ accountId,
+ folder.parent.URI
+ )}`
+ );
+ }
+
+ let newFolderPromise = waitForOperation(
+ MailServices.mfn.folderRenamed,
+ folder.URI
+ );
+ folder.rename(newName, null);
+
+ let newFolder = await newFolderPromise;
+ return convertFolder(newFolder, accountId);
+ },
+ async move(source, destination) {
+ return doMoveCopyOperation(source, destination, true /* isMove */);
+ },
+ async copy(source, destination) {
+ return doMoveCopyOperation(source, destination, false /* isMove */);
+ },
+ async delete({ accountId, path }) {
+ if (
+ !context.extension.hasPermission("accountsFolders") ||
+ !context.extension.hasPermission("messagesDelete")
+ ) {
+ throw new ExtensionError(
+ 'Using folders.delete() requires the "accountsFolders" and the "messagesDelete" permission'
+ );
+ }
+
+ let { folder } = getFolder({ accountId, path });
+ if (folder.server.type == "nntp") {
+ throw new ExtensionError(
+ `folders.delete() is not supported in news accounts`
+ );
+ }
+
+ if (folder.server.type == "imap") {
+ let inTrash = false;
+ let parent = folder.parent;
+ while (!inTrash && parent) {
+ inTrash = parent.flags & Ci.nsMsgFolderFlags.Trash;
+ parent = parent.parent;
+ }
+ if (inTrash) {
+ // FixMe: The UI is not updated, the folder is still shown, only after
+ // a restart it is removed from trash.
+ let deletedPromise = new Promise(resolve => {
+ MailServices.imap.deleteFolder(
+ folder,
+ {
+ OnStartRunningUrl() {},
+ OnStopRunningUrl(url, status) {
+ resolve(status);
+ },
+ },
+ null
+ );
+ });
+ let status = await deletedPromise;
+ if (!Components.isSuccessCode(status)) {
+ throw new ExtensionError(
+ `folders.delete() failed for unknown reasons`
+ );
+ }
+ } else {
+ // FixMe: Accounts could have their trash folder outside of their
+ // own folder structure.
+ let trash = folder.server.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Trash
+ );
+ let deletedPromise = new Promise(resolve => {
+ MailServices.imap.moveFolder(
+ folder,
+ trash,
+ {
+ OnStartRunningUrl() {},
+ OnStopRunningUrl(url, status) {
+ resolve(status);
+ },
+ },
+ null
+ );
+ });
+ let status = await deletedPromise;
+ if (!Components.isSuccessCode(status)) {
+ throw new ExtensionError(
+ `folders.delete() failed for unknown reasons`
+ );
+ }
+ }
+ } else {
+ let deletedPromise = waitForOperation(
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted,
+ folder.URI
+ );
+ folder.deleteSelf(null);
+ await deletedPromise;
+ }
+ },
+ async getFolderInfo({ accountId, path }) {
+ let { folder } = getFolder({ accountId, path });
+
+ let mailFolderInfo = {
+ favorite: folder.getFlag(Ci.nsMsgFolderFlags.Favorite),
+ totalMessageCount: folder.getTotalMessages(false),
+ unreadMessageCount: folder.getNumUnread(false),
+ };
+
+ return mailFolderInfo;
+ },
+ async getParentFolders({ accountId, path }, includeFolders) {
+ let { folder } = getFolder({ accountId, path });
+ let parentFolders = [];
+ // We do not consider the absolute root ("/") as a root folder, but
+ // the first real folders (all folders returned in MailAccount.folders
+ // are considered root folders).
+ while (folder.parent != null && folder.parent.parent != null) {
+ folder = folder.parent;
+
+ if (includeFolders) {
+ parentFolders.push(traverseSubfolders(folder, accountId));
+ } else {
+ parentFolders.push(convertFolder(folder, accountId));
+ }
+ }
+ return parentFolders;
+ },
+ async getSubFolders(accountOrFolder, includeFolders) {
+ let { folder, accountId } = getFolder(accountOrFolder);
+ let subFolders = [];
+ if (folder.hasSubFolders) {
+ for (let subFolder of folder.subFolders) {
+ if (includeFolders) {
+ subFolders.push(traverseSubfolders(subFolder, accountId));
+ } else {
+ subFolders.push(convertFolder(subFolder, accountId));
+ }
+ }
+ }
+ return subFolders;
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-identities.js b/comm/mail/components/extensions/parent/ext-identities.js
new file mode 100644
index 0000000000..1b9e719ebe
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-identities.js
@@ -0,0 +1,360 @@
+/* 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/. */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+});
+
+function findIdentityAndAccount(identityId) {
+ for (let account of MailServices.accounts.accounts) {
+ for (let identity of account.identities) {
+ if (identity.key == identityId) {
+ return { account, identity };
+ }
+ }
+ }
+ return null;
+}
+
+function checkForProtectedProperties(details) {
+ const protectedProperties = ["id", "accountId"];
+ for (let [key, value] of Object.entries(details)) {
+ // Check only properties explicitly provided.
+ if (value != null && protectedProperties.includes(key)) {
+ throw new ExtensionError(
+ `Setting the ${key} property of a MailIdentity is not supported.`
+ );
+ }
+ }
+}
+
+function updateIdentity(identity, details) {
+ for (let [key, value] of Object.entries(details)) {
+ // Update only properties explicitly provided.
+ if (value == null) {
+ continue;
+ }
+ // Map from WebExtension property names to nsIMsgIdentity property names.
+ switch (key) {
+ case "signatureIsPlainText":
+ identity.htmlSigFormat = !value;
+ break;
+ case "name":
+ identity.fullName = value;
+ break;
+ case "signature":
+ identity.htmlSigText = value;
+ break;
+ default:
+ identity[key] = value;
+ }
+ }
+}
+
+/**
+ * @implements {nsIObserver}
+ */
+var identitiesTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+
+ this.identities = new Map();
+ this.deferredNotifications = new ExtensionUtils.DefaultMap(
+ key =>
+ new DeferredTask(
+ () => this.emitPendingNotification(key),
+ NOTIFICATION_COLLAPSE_TIME
+ )
+ );
+
+ // Keep track of identities and their values, to suppress superfluous
+ // update notifications. The deferredTask timer is used to collapse multiple
+ // update notifications.
+ for (let account of MailServices.accounts.accounts) {
+ for (let identity of account.identities) {
+ this.identities.set(
+ identity.key,
+ convertMailIdentity(account, identity)
+ );
+ }
+ }
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ Services.prefs.addObserver("mail.identity.", this);
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ Services.prefs.removeObserver("mail.identity.", this);
+ }
+ }
+
+ emitPendingNotification(key) {
+ let ia = findIdentityAndAccount(key);
+ if (!ia) {
+ return;
+ }
+
+ let oldValues = this.identities.get(key);
+ let newValues = convertMailIdentity(ia.account, ia.identity);
+ let changedValues = {};
+ for (let propertyName of Object.keys(newValues)) {
+ if (
+ !oldValues.hasOwnProperty(propertyName) ||
+ oldValues[propertyName] != newValues[propertyName]
+ ) {
+ changedValues[propertyName] = newValues[propertyName];
+ }
+ }
+ if (Object.keys(changedValues).length > 0) {
+ changedValues.accountId = ia.account.key;
+ changedValues.id = ia.identity.key;
+ let notification =
+ Object.keys(oldValues).length == 0
+ ? "account-identity-added"
+ : "account-identity-updated";
+ this.identities.set(key, newValues);
+ this.emit(notification, key, changedValues);
+ }
+ }
+
+ // nsIObserver
+ _notifications = ["account-identity-added", "account-identity-removed"];
+
+ async observe(subject, topic, data) {
+ switch (topic) {
+ case "account-identity-added":
+ {
+ let key = data;
+ this.identities.set(key, {});
+ this.deferredNotifications.get(key).arm();
+ }
+ break;
+
+ case "nsPref:changed":
+ {
+ let key = data.split(".").slice(2, 3).pop();
+
+ // Ignore update notifications for created identities, before they are
+ // added to an account (looks like they are cloned from a default
+ // identity). Also ignore notifications for deleted identities.
+ if (
+ key &&
+ this.identities.has(key) &&
+ this.identities.get(key) != null
+ ) {
+ this.deferredNotifications.get(key).disarm();
+ this.deferredNotifications.get(key).arm();
+ }
+ }
+ break;
+
+ case "account-identity-removed":
+ {
+ let key = data;
+ if (
+ key &&
+ this.identities.has(key) &&
+ this.identities.get(key) != null
+ ) {
+ // Mark identities as deleted instead of removing them.
+ this.identities.set(key, null);
+ // Force any pending notification to be emitted.
+ await this.deferredNotifications.get(key).finalize();
+
+ this.emit("account-identity-removed", key);
+ }
+ }
+ break;
+ }
+ }
+})();
+
+this.identities = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCreated({ context, fire }) {
+ async function listener(event, key, identity) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, identity);
+ }
+ identitiesTracker.on("account-identity-added", listener);
+ return {
+ unregister: () => {
+ identitiesTracker.off("account-identity-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onUpdated({ context, fire }) {
+ async function listener(event, key, changedValues) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, changedValues);
+ }
+ identitiesTracker.on("account-identity-updated", listener);
+ return {
+ unregister: () => {
+ identitiesTracker.off("account-identity-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ async function listener(event, key) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key);
+ }
+ identitiesTracker.on("account-identity-removed", listener);
+ return {
+ unregister: () => {
+ identitiesTracker.off("account-identity-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ constructor(...args) {
+ super(...args);
+ identitiesTracker.incrementListeners();
+ }
+
+ onShutdown() {
+ identitiesTracker.decrementListeners();
+ }
+
+ getAPI(context) {
+ return {
+ identities: {
+ async list(accountId) {
+ let accounts = accountId
+ ? [MailServices.accounts.getAccount(accountId)]
+ : MailServices.accounts.accounts;
+
+ let identities = [];
+ for (let account of accounts) {
+ for (let identity of account.identities) {
+ identities.push(convertMailIdentity(account, identity));
+ }
+ }
+ return identities;
+ },
+ async get(identityId) {
+ let ia = findIdentityAndAccount(identityId);
+ return ia ? convertMailIdentity(ia.account, ia.identity) : null;
+ },
+ async delete(identityId) {
+ let ia = findIdentityAndAccount(identityId);
+ if (!ia) {
+ throw new ExtensionError(`Identity not found: ${identityId}`);
+ }
+ if (
+ ia.account?.defaultIdentity &&
+ ia.account.defaultIdentity.key == ia.identity.key
+ ) {
+ throw new ExtensionError(
+ `Identity ${identityId} is the default identity of account ${ia.account.key} and cannot be deleted`
+ );
+ }
+ ia.account.removeIdentity(ia.identity);
+ },
+ async create(accountId, details) {
+ let account = MailServices.accounts.getAccount(accountId);
+ if (!account) {
+ throw new ExtensionError(`Account not found: ${accountId}`);
+ }
+ // Abort and throw, if details include protected properties.
+ checkForProtectedProperties(details);
+
+ let identity = MailServices.accounts.createIdentity();
+ updateIdentity(identity, details);
+ account.addIdentity(identity);
+ return convertMailIdentity(account, identity);
+ },
+ async update(identityId, details) {
+ let ia = findIdentityAndAccount(identityId);
+ if (!ia) {
+ throw new ExtensionError(`Identity not found: ${identityId}`);
+ }
+ // Abort and throw, if details include protected properties.
+ checkForProtectedProperties(details);
+
+ updateIdentity(ia.identity, details);
+ return convertMailIdentity(ia.account, ia.identity);
+ },
+ async getDefault(accountId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ return convertMailIdentity(account, account?.defaultIdentity);
+ },
+ async setDefault(accountId, identityId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ if (!account) {
+ throw new ExtensionError(`Account not found: ${accountId}`);
+ }
+ for (let identity of account.identities) {
+ if (identity.key == identityId) {
+ account.defaultIdentity = identity;
+ return;
+ }
+ }
+ throw new ExtensionError(
+ `Identity ${identityId} not found for ${accountId}`
+ );
+ },
+ onCreated: new EventManager({
+ context,
+ module: "identities",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "identities",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "identities",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-mail.js b/comm/mail/components/extensions/parent/ext-mail.js
new file mode 100644
index 0000000000..31e86fe7b4
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-mail.js
@@ -0,0 +1,2883 @@
+/* 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 { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var { ExtensionError, getInnerWindowID } = ExtensionUtils;
+var { defineLazyGetter, makeWidgetId } = ExtensionCommon;
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionContent: "resource://gre/modules/ExtensionContent.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ MailServices: "resource:///modules/MailServices.jsm",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gJunkThreshold",
+ "mail.adaptivefilters.junk_threshold",
+ 90
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gMessagesPerPage",
+ "extensions.webextensions.messagesPerPage",
+ 100
+);
+XPCOMUtils.defineLazyGlobalGetters(this, [
+ "IOUtils",
+ "PathUtils",
+ "FileReader",
+]);
+
+const MAIN_WINDOW_URI = "chrome://messenger/content/messenger.xhtml";
+const POPUP_WINDOW_URI = "chrome://messenger/content/extensionPopup.xhtml";
+const COMPOSE_WINDOW_URI =
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml";
+const MESSAGE_WINDOW_URI = "chrome://messenger/content/messageWindow.xhtml";
+const MESSAGE_PROTOCOLS = ["imap", "mailbox", "news", "nntp", "snews"];
+
+const NOTIFICATION_COLLAPSE_TIME = 200;
+
+(function () {
+ // Monkey-patch all processes to add the "messenger" alias in all contexts.
+ Services.ppmm.loadProcessScript(
+ "chrome://messenger/content/processScript.js",
+ true
+ );
+
+ // This allows scripts to run in the compose document or message display
+ // document if and only if the extension has permission.
+ let { defaultConstructor } = ExtensionContent.contentScripts;
+ ExtensionContent.contentScripts.defaultConstructor = function (matcher) {
+ let script = defaultConstructor.call(this, matcher);
+
+ let { matchesWindowGlobal } = script;
+ script.matchesWindowGlobal = function (windowGlobal) {
+ let { browsingContext, windowContext } = windowGlobal;
+
+ if (
+ browsingContext.topChromeWindow?.location.href == COMPOSE_WINDOW_URI &&
+ windowContext.documentPrincipal.isNullPrincipal &&
+ windowContext.documentURI?.spec == "about:blank?compose"
+ ) {
+ return script.extension.hasPermission("compose");
+ }
+
+ if (MESSAGE_PROTOCOLS.includes(windowContext.documentURI?.scheme)) {
+ return script.extension.hasPermission("messagesModify");
+ }
+
+ return matchesWindowGlobal.apply(script, arguments);
+ };
+
+ return script;
+ };
+})();
+
+let tabTracker;
+let spaceTracker;
+let windowTracker;
+
+// This function is pretty tightly tied to Extension.jsm.
+// Its job is to fill in the |tab| property of the sender.
+const getSender = (extension, target, sender) => {
+ let tabId = -1;
+ if ("tabId" in sender) {
+ // The message came from a privileged extension page running in a tab. In
+ // that case, it should include a tabId property (which is filled in by the
+ // page-open listener below).
+ tabId = sender.tabId;
+ delete sender.tabId;
+ } else if (
+ ExtensionCommon.instanceOf(target, "XULFrameElement") ||
+ ExtensionCommon.instanceOf(target, "HTMLIFrameElement")
+ ) {
+ tabId = tabTracker.getBrowserData(target).tabId;
+ }
+
+ if (tabId != null && tabId >= 0) {
+ let tab = extension.tabManager.get(tabId, null);
+ if (tab) {
+ sender.tab = tab.convert();
+ }
+ }
+};
+
+// Used by Extension.jsm.
+global.tabGetSender = getSender;
+
+global.clickModifiersFromEvent = event => {
+ const map = {
+ shiftKey: "Shift",
+ altKey: "Alt",
+ metaKey: "Command",
+ ctrlKey: "Ctrl",
+ };
+ let modifiers = Object.keys(map)
+ .filter(key => event[key])
+ .map(key => map[key]);
+
+ if (event.ctrlKey && AppConstants.platform === "macosx") {
+ modifiers.push("MacCtrl");
+ }
+
+ return modifiers;
+};
+
+global.openOptionsPage = extension => {
+ let window = windowTracker.topNormalWindow;
+ if (!window) {
+ return Promise.reject({ message: "No mail window available" });
+ }
+
+ if (extension.manifest.options_ui.open_in_tab) {
+ window.switchToTabHavingURI(extension.manifest.options_ui.page, true, {
+ triggeringPrincipal: extension.principal,
+ });
+ return Promise.resolve();
+ }
+
+ let viewId = `addons://detail/${encodeURIComponent(
+ extension.id
+ )}/preferences`;
+
+ return window.openAddonsMgr(viewId);
+};
+
+/**
+ * Returns a real file for the given DOM File.
+ *
+ * @param {File} file - the DOM File
+ * @returns {nsIFile}
+ */
+async function getRealFileForFile(file) {
+ if (file.mozFullPath) {
+ let realFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ realFile.initWithPath(file.mozFullPath);
+ return realFile;
+ }
+
+ let pathTempFile = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ file.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 bytes = await new Promise(function (resolve) {
+ let reader = new FileReader();
+ reader.onloadend = function () {
+ resolve(new Uint8Array(reader.result));
+ };
+ reader.readAsArrayBuffer(file);
+ });
+
+ await IOUtils.write(pathTempFile, bytes);
+ return tempFile;
+}
+
+/**
+ * Gets the window for a tabmail tabInfo.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabInfo object to get the browser for
+ * @returns {Window} - The browser element for the tab
+ */
+function getTabWindow(nativeTabInfo) {
+ return Cu.getGlobalForObject(nativeTabInfo);
+}
+global.getTabWindow = getTabWindow;
+
+/**
+ * Gets the tabmail for a tabmail tabInfo.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabInfo object to get the browser for
+ * @returns {?XULElement} - The browser element for the tab
+ */
+function getTabTabmail(nativeTabInfo) {
+ return getTabWindow(nativeTabInfo).document.getElementById("tabmail");
+}
+global.getTabTabmail = getTabTabmail;
+
+/**
+ * Gets the tab browser for the tabmail tabInfo.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabInfo object to get the browser for
+ * @returns {?XULElement} The browser element for the tab
+ */
+function getTabBrowser(nativeTabInfo) {
+ if (!nativeTabInfo) {
+ return null;
+ }
+
+ if (nativeTabInfo.mode) {
+ if (nativeTabInfo.mode.getBrowser) {
+ return nativeTabInfo.mode.getBrowser(nativeTabInfo);
+ }
+
+ if (nativeTabInfo.mode.tabType.getBrowser) {
+ return nativeTabInfo.mode.tabType.getBrowser(nativeTabInfo);
+ }
+ }
+
+ if (nativeTabInfo.ownerGlobal && nativeTabInfo.ownerGlobal.getBrowser) {
+ return nativeTabInfo.ownerGlobal.getBrowser();
+ }
+
+ return null;
+}
+global.getTabBrowser = getTabBrowser;
+
+/**
+ * Manages tab-specific and window-specific context data, and dispatches
+ * tab select events across all windows.
+ */
+global.TabContext = class extends EventEmitter {
+ /**
+ * @param {Function} getDefaultPrototype
+ * Provides the prototype of the context value for a tab or window when there is none.
+ * Called with a XULElement or ChromeWindow argument.
+ * Should return an object or null.
+ */
+ constructor(getDefaultPrototype) {
+ super();
+ this.getDefaultPrototype = getDefaultPrototype;
+ this.tabData = new WeakMap();
+ }
+
+ /**
+ * Returns the context data associated with `keyObject`.
+ *
+ * @param {XULElement|ChromeWindow} keyObject
+ * Browser tab or browser chrome window.
+ * @returns {object}
+ */
+ get(keyObject) {
+ if (!this.tabData.has(keyObject)) {
+ let data = Object.create(this.getDefaultPrototype(keyObject));
+ this.tabData.set(keyObject, data);
+ }
+
+ return this.tabData.get(keyObject);
+ }
+
+ /**
+ * Clears the context data associated with `keyObject`.
+ *
+ * @param {XULElement|ChromeWindow} keyObject
+ * Browser tab or browser chrome window.
+ */
+ clear(keyObject) {
+ this.tabData.delete(keyObject);
+ }
+};
+
+/* global searchInitialized */
+// This promise is used to wait for the search service to be initialized.
+// None of the code in the WebExtension modules requests that initialization.
+// It is assumed that it is started at some point. That might never happen,
+// e.g. if the application shuts down before the search service initializes.
+XPCOMUtils.defineLazyGetter(global, "searchInitialized", () => {
+ if (Services.search.isInitialized) {
+ return Promise.resolve();
+ }
+ return ExtensionUtils.promiseObserved(
+ "browser-search-service",
+ (_, data) => data == "init-complete"
+ );
+});
+
+/**
+ * Class for dummy message Headers.
+ */
+class nsDummyMsgHeader {
+ constructor(msgHdr) {
+ this.mProperties = [];
+ this.messageSize = 0;
+ this.author = null;
+ this.subject = "";
+ this.recipients = null;
+ this.ccList = null;
+ this.listPost = null;
+ this.messageId = null;
+ this.date = 0;
+ this.accountKey = "";
+ this.flags = 0;
+ // If you change us to return a fake folder, please update
+ // folderDisplay.js's FolderDisplayWidget's selectedMessageIsExternal getter.
+ this.folder = null;
+
+ if (msgHdr) {
+ for (let member of [
+ "accountKey",
+ "ccList",
+ "date",
+ "flags",
+ "listPost",
+ "messageId",
+ "messageSize",
+ ]) {
+ // Members are either (associative) arrays or primitives.
+ if (typeof msgHdr[member] == "object") {
+ this[member] = [];
+ for (let property in msgHdr[member]) {
+ this[member][property] = msgHdr[member][property];
+ }
+ } else {
+ this[member] = msgHdr[member];
+ }
+ }
+ this.author = msgHdr.mime2DecodedAuthor;
+ this.recipients = msgHdr.mime2DecodedRecipients;
+ this.subject = msgHdr.mime2DecodedSubject;
+ this.mProperties.dummyMsgUrl = msgHdr.getStringProperty("dummyMsgUrl");
+ this.mProperties.dummyMsgLastModifiedTime = msgHdr.getUint32Property(
+ "dummyMsgLastModifiedTime"
+ );
+ }
+ }
+ getProperty(aProperty) {
+ return this.getStringProperty(aProperty);
+ }
+ setProperty(aProperty, aVal) {
+ return this.setStringProperty(aProperty, aVal);
+ }
+ getStringProperty(aProperty) {
+ if (aProperty in this.mProperties) {
+ return this.mProperties[aProperty];
+ }
+ return "";
+ }
+ setStringProperty(aProperty, aVal) {
+ this.mProperties[aProperty] = aVal;
+ }
+ getUint32Property(aProperty) {
+ if (aProperty in this.mProperties) {
+ return parseInt(this.mProperties[aProperty]);
+ }
+ return 0;
+ }
+ setUint32Property(aProperty, aVal) {
+ this.mProperties[aProperty] = aVal.toString();
+ }
+ markHasAttachments(hasAttachments) {}
+ get mime2DecodedAuthor() {
+ return this.author;
+ }
+ get mime2DecodedSubject() {
+ return this.subject;
+ }
+ get mime2DecodedRecipients() {
+ return this.recipients;
+ }
+}
+
+/**
+ * Returns the WebExtension window type for the given window, or null, if it is
+ * not supported.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {[string]} - The WebExtension type of the window
+ */
+function getWebExtensionWindowType(window) {
+ let { documentElement } = window.document;
+ if (!documentElement) {
+ return null;
+ }
+ switch (documentElement.getAttribute("windowtype")) {
+ case "msgcompose":
+ return "messageCompose";
+ case "mail:messageWindow":
+ return "messageDisplay";
+ case "mail:extensionPopup":
+ return "popup";
+ case "mail:3pane":
+ return "normal";
+ default:
+ return "unknown";
+ }
+}
+
+/**
+ * The window tracker tracks opening and closing Thunderbird windows. Each window has an id, which
+ * is mapped to native window objects.
+ */
+class WindowTracker extends WindowTrackerBase {
+ /**
+ * Adds a tab progress listener to the given mail window.
+ *
+ * @param {DOMWindow} window - The mail window to which to add the listener.
+ * @param {object} listener - The listener to add
+ */
+ addProgressListener(window, listener) {
+ if (window.contentProgress) {
+ window.contentProgress.addListener(listener);
+ }
+ }
+
+ /**
+ * Removes a tab progress listener from the given mail window.
+ *
+ * @param {DOMWindow} window - The mail window from which to remove the listener.
+ * @param {object} listener - The listener to remove
+ */
+ removeProgressListener(window, listener) {
+ if (window.contentProgress) {
+ window.contentProgress.removeListener(listener);
+ }
+ }
+
+ /**
+ * Determines if the passed window object is supported by the windows API. The
+ * function name is for base class compatibility with toolkit.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {boolean} True, if the window is supported by the windows API
+ */
+ isBrowserWindow(window) {
+ let type = getWebExtensionWindowType(window);
+ return !!type && type != "unknown";
+ }
+
+ /**
+ * Determines if the passed window object is a mail window but not the main
+ * window. This is useful to find windows where the window itself is the
+ * "nativeTab" object in API terms.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {boolean} True, if the window is a mail window but not the main window
+ */
+ isSecondaryWindow(window) {
+ let { documentElement } = window.document;
+ if (!documentElement) {
+ return false;
+ }
+
+ return ["msgcompose", "mail:messageWindow", "mail:extensionPopup"].includes(
+ documentElement.getAttribute("windowtype")
+ );
+ }
+
+ /**
+ * The currently active, or topmost window supported by the API, or null if no
+ * supported window is currently open.
+ *
+ * @property {?DOMWindow} topWindow
+ * @readonly
+ */
+ get topWindow() {
+ let win = Services.wm.getMostRecentWindow(null);
+ // If we're lucky, this is a window supported by the API and we can return it
+ // directly.
+ if (win && !this.isBrowserWindow(win)) {
+ win = null;
+ // This is oldest to newest, so this gets a bit ugly.
+ for (let nextWin of Services.wm.getEnumerator(null)) {
+ if (this.isBrowserWindow(nextWin)) {
+ win = nextWin;
+ }
+ }
+ }
+ return win;
+ }
+
+ /**
+ * The currently active, or topmost window, or null if no window is currently open, that
+ * is not private browsing.
+ *
+ * @property {DOMWindow|null} topWindow
+ * @readonly
+ */
+ get topNonPBWindow() {
+ // Thunderbird does not support private browsing, return topWindow.
+ return this.topWindow;
+ }
+
+ /**
+ * The currently active, or topmost, mail window, or null if no mail window is currently open.
+ * Will only return the topmost "normal" (i.e., not popup) window.
+ *
+ * @property {?DOMWindow} topNormalWindow
+ * @readonly
+ */
+ get topNormalWindow() {
+ return Services.wm.getMostRecentWindow("mail:3pane");
+ }
+}
+
+/**
+ * Convenience class to keep track of and manage spaces.
+ */
+class SpaceTracker {
+ /**
+ * @typedef SpaceData
+ * @property {string} name - name of the space as used by the extension
+ * @property {integer} spaceId - id of the space as used by the tabs API
+ * @property {string} spaceButtonId - id of the button of this space in the
+ * spaces toolbar
+ * @property {string} defaultUrl - the url for the default space tab
+ * @property {ButtonProperties} buttonProperties
+ * @see mail/components/extensions/schemas/spaces.json
+ * @property {ExtensionData} extension - the extension the space belongs to
+ */
+
+ constructor() {
+ this._nextId = 1;
+ this._spaceData = new Map();
+ this._spaceIds = new Map();
+
+ // Keep this in sync with the default spaces in gSpacesToolbar.
+ let builtInSpaces = [
+ {
+ name: "mail",
+ spaceButtonId: "mailButton",
+ tabInSpace: tabInfo =>
+ ["folder", "mail3PaneTab", "mailMessageTab"].includes(
+ tabInfo.mode.name
+ )
+ ? 1
+ : 0,
+ },
+ {
+ name: "addressbook",
+ spaceButtonId: "addressBookButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "addressBookTab" ? 1 : 0),
+ },
+ {
+ name: "calendar",
+ spaceButtonId: "calendarButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "calendar" ? 1 : 0),
+ },
+ {
+ name: "tasks",
+ spaceButtonId: "tasksButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "tasks" ? 1 : 0),
+ },
+ {
+ name: "chat",
+ spaceButtonId: "chatButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "chat" ? 1 : 0),
+ },
+ {
+ name: "settings",
+ spaceButtonId: "settingsButton",
+ tabInSpace: tabInfo => {
+ switch (tabInfo.mode.name) {
+ case "preferencesTab":
+ // A primary tab that the open method creates.
+ return 1;
+ case "contentTab":
+ let url = tabInfo.urlbar?.value;
+ if (url == "about:accountsettings" || url == "about:addons") {
+ // A secondary tab, that is related to this space.
+ return 2;
+ }
+ }
+ return 0;
+ },
+ },
+ ];
+ for (let builtInSpace of builtInSpaces) {
+ this._add(builtInSpace);
+ }
+ }
+
+ findSpaceForTab(tabInfo) {
+ for (let spaceData of this._spaceData.values()) {
+ if (spaceData.tabInSpace(tabInfo)) {
+ return spaceData;
+ }
+ }
+ return undefined;
+ }
+
+ _add(spaceData) {
+ let spaceId = this._nextId++;
+ let { spaceButtonId } = spaceData;
+ this._spaceData.set(spaceButtonId, { ...spaceData, spaceId });
+ this._spaceIds.set(spaceId, spaceButtonId);
+ return { ...spaceData, spaceId };
+ }
+
+ /**
+ * Generate an id of the form <add-on-id>-spacesButton-<spaceId>.
+ *
+ * @param {string} name - name of the space as used by the extension
+ * @param {ExtensionData} extension
+ * @returns {string} id of the html element of the spaces toolbar button of
+ * this space
+ */
+ _getSpaceButtonId(name, extension) {
+ return `${makeWidgetId(extension.id)}-spacesButton-${name}`;
+ }
+
+ /**
+ * Get the SpaceData for the space with the given name for the given extension.
+ *
+ * @param {string} name - name of the space as used by the extension
+ * @param {ExtensionData} extension
+ * @returns {SpaceData}
+ */
+ fromSpaceName(name, extension) {
+ let spaceButtonId = this._getSpaceButtonId(name, extension);
+ return this.fromSpaceButtonId(spaceButtonId);
+ }
+
+ /**
+ * Get the SpaceData for the space with the given spaceId.
+ *
+ * @param {integer} spaceId - id of the space as used by the tabs API
+ * @returns {SpaceData}
+ */
+ fromSpaceId(spaceId) {
+ let spaceButtonId = this._spaceIds.get(spaceId);
+ return this.fromSpaceButtonId(spaceButtonId);
+ }
+
+ /**
+ * Get the SpaceData for the space with the given spaceButtonId.
+ *
+ * @param {string} spaceButtonId - id of the html element of a spaces toolbar
+ * button
+ * @returns {SpaceData}
+ */
+ fromSpaceButtonId(spaceButtonId) {
+ if (!spaceButtonId || !this._spaceData.has(spaceButtonId)) {
+ return null;
+ }
+ return this._spaceData.get(spaceButtonId);
+ }
+
+ /**
+ * Create a new space and return its SpaceData.
+ *
+ * @param {string} name - name of the space as used by the extension
+ * @param {string} defaultUrl - the url for the default space tab
+ * @param {ButtonProperties} buttonProperties
+ * @see mail/components/extensions/schemas/spaces.json
+ * @param {ExtensionData} extension - the extension the space belongs to
+ * @returns {SpaceData}
+ */
+ async create(name, defaultUrl, buttonProperties, extension) {
+ let spaceButtonId = this._getSpaceButtonId(name, extension);
+ if (this._spaceData.has(spaceButtonId)) {
+ return false;
+ }
+ return this._add({
+ name,
+ spaceButtonId,
+ tabInSpace: tabInfo => (tabInfo.spaceButtonId == spaceButtonId ? 1 : 0),
+ defaultUrl,
+ buttonProperties,
+ extension,
+ });
+ }
+
+ /**
+ * Return a WebExtension Space object, representing the given spaceData.
+ *
+ * @param {SpaceData} spaceData
+ * @returns {Space} - @see mail/components/extensions/schemas/spaces.json
+ */
+ convert(spaceData, extension) {
+ let space = {
+ id: spaceData.spaceId,
+ name: spaceData.name,
+ isBuiltIn: !spaceData.extension,
+ isSelfOwned: spaceData.extension?.id == extension.id,
+ };
+ if (spaceData.extension && extension.hasPermission("management")) {
+ space.extensionId = spaceData.extension.id;
+ }
+ return space;
+ }
+
+ /**
+ * Remove a space and its SpaceData from the tracker.
+ *
+ * @param {SpaceData} spaceData
+ */
+ remove(spaceData) {
+ if (!this._spaceData.has(spaceData.spaceButtonId)) {
+ return;
+ }
+ this._spaceData.delete(spaceData.spaceButtonId);
+ }
+
+ /**
+ * Update spaceData for a space in the tracker.
+ *
+ * @param {SpaceData} spaceData
+ */
+ update(spaceData) {
+ if (!this._spaceData.has(spaceData.spaceButtonId)) {
+ return;
+ }
+ this._spaceData.set(spaceData.spaceButtonId, spaceData);
+ }
+
+ /**
+ * Return the SpaceData of all spaces known to the tracker.
+ *
+ * @returns {SpaceData[]}
+ */
+ getAll() {
+ return this._spaceData.values();
+ }
+}
+
+/**
+ * Tracks the opening and closing of tabs and maps them between their numeric WebExtension ID and
+ * the native tab info objects.
+ */
+class TabTracker extends TabTrackerBase {
+ constructor() {
+ super();
+
+ this._tabs = new WeakMap();
+ this._browsers = new Map();
+ this._tabIds = new Map();
+ this._nextId = 1;
+ this._movingTabs = new Map();
+
+ this._handleTabDestroyed = this._handleTabDestroyed.bind(this);
+
+ ExtensionSupport.registerWindowListener("ext-sessions", {
+ chromeURLs: [MAIN_WINDOW_URI],
+ onLoadWindow(window) {
+ window.gTabmail.registerTabMonitor({
+ monitorName: "extensionSession",
+ onTabTitleChanged(aTab) {},
+ onTabClosing(aTab) {},
+ onTabPersist(aTab) {
+ return aTab._ext.extensionSession;
+ },
+ onTabRestored(aTab, aState) {
+ aTab._ext.extensionSession = aState;
+ },
+ onTabSwitched(aNewTab, aOldTab) {},
+ onTabOpened(aTab) {},
+ });
+ },
+ });
+ }
+
+ /**
+ * Initialize tab tracking listeners the first time that an event listener is added.
+ */
+ init() {
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+
+ this._handleWindowOpen = this._handleWindowOpen.bind(this);
+ this._handleWindowClose = this._handleWindowClose.bind(this);
+
+ windowTracker.addListener("TabClose", this);
+ windowTracker.addListener("TabOpen", this);
+ windowTracker.addListener("TabSelect", this);
+ windowTracker.addOpenListener(this._handleWindowOpen);
+ windowTracker.addCloseListener(this._handleWindowClose);
+
+ /* eslint-disable mozilla/balanced-listeners */
+ this.on("tab-detached", this._handleTabDestroyed);
+ this.on("tab-removed", this._handleTabDestroyed);
+ /* eslint-enable mozilla/balanced-listeners */
+ }
+
+ /**
+ * Returns the numeric ID for the given native tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabmail tabInfo for which to return an ID
+ * @returns {Integer} The tab's numeric ID
+ */
+ getId(nativeTabInfo) {
+ let id = this._tabs.get(nativeTabInfo);
+ if (id) {
+ return id;
+ }
+
+ this.init();
+
+ id = this._nextId++;
+ this.setId(nativeTabInfo, id);
+ return id;
+ }
+
+ /**
+ * Returns the tab id corresponding to the given browser element.
+ *
+ * @param {XULElement} browser - The <browser> element to retrieve for
+ * @returns {Integer} The tab's numeric ID
+ */
+ getBrowserTabId(browser) {
+ let id = this._browsers.get(browser.browserId);
+ if (id) {
+ return id;
+ }
+
+ let window = browser.browsingContext.topChromeWindow;
+ let tabmail = window.document.getElementById("tabmail");
+ let tab = tabmail && tabmail.getTabForBrowser(browser);
+
+ if (tab) {
+ id = this.getId(tab);
+ this._browsers.set(browser.browserId, id);
+ return id;
+ }
+ if (windowTracker.isSecondaryWindow(window)) {
+ return this.getId(window);
+ }
+ return -1;
+ }
+
+ /**
+ * Records the tab information for the given tabInfo object.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info to record for
+ * @param {Integer} id - The tab id to record
+ */
+ setId(nativeTabInfo, id) {
+ this._tabs.set(nativeTabInfo, id);
+ let browser = getTabBrowser(nativeTabInfo);
+ if (browser) {
+ this._browsers.set(browser.browserId, id);
+ }
+ this._tabIds.set(id, nativeTabInfo);
+ }
+
+ /**
+ * Function to call when a tab was close, deletes tab information for the tab.
+ *
+ * @param {Event} event - The event triggering the detroyal
+ * @param {{ nativeTabInfo:NativeTabInfo}} - The object containing tab info
+ */
+ _handleTabDestroyed(event, { nativeTabInfo }) {
+ let id = this._tabs.get(nativeTabInfo);
+ if (id) {
+ this._tabs.delete(nativeTabInfo);
+ if (nativeTabInfo.browser) {
+ this._browsers.delete(nativeTabInfo.browser.browserId);
+ }
+ if (this._tabIds.get(id) === nativeTabInfo) {
+ this._tabIds.delete(id);
+ }
+ }
+ }
+
+ /**
+ * Returns the native tab with the given numeric ID.
+ *
+ * @param {Integer} tabId - The numeric ID of the tab to return.
+ * @param {*} default_ - The value to return if no tab exists with the given ID.
+ * @returns {NativeTabInfo} The tab information for the given id.
+ */
+ getTab(tabId, default_ = undefined) {
+ let nativeTabInfo = this._tabIds.get(tabId);
+ if (nativeTabInfo) {
+ return nativeTabInfo;
+ }
+ if (default_ !== undefined) {
+ return default_;
+ }
+ throw new ExtensionError(`Invalid tab ID: ${tabId}`);
+ }
+
+ /**
+ * Handles load events for recently-opened windows, and adds additional
+ * listeners which may only be safely added when the window is fully loaded.
+ *
+ * @param {Event} event - A DOM event to handle.
+ */
+ handleEvent(event) {
+ let nativeTabInfo = event.detail.tabInfo;
+
+ switch (event.type) {
+ case "TabOpen": {
+ // Save the current tab, since the newly-created tab will likely be
+ // active by the time the promise below resolves and the event is
+ // dispatched.
+ let tabmail = event.target.ownerDocument.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ // We need to delay sending this event until the next tick, since the
+ // tab does not have its final index when the TabOpen event is dispatched.
+ Promise.resolve().then(() => {
+ if (event.detail.moving) {
+ let srcTabId = this._movingTabs.get(event.detail.moving);
+ this.setId(nativeTabInfo, srcTabId);
+ this._movingTabs.delete(event.detail.moving);
+
+ this.emitAttached(nativeTabInfo);
+ } else {
+ this.emitCreated(nativeTabInfo, currentTab);
+ }
+ });
+ break;
+ }
+
+ case "TabClose": {
+ if (event.detail.moving) {
+ this._movingTabs.set(event.detail.moving, this.getId(nativeTabInfo));
+ this.emitDetached(nativeTabInfo);
+ } else {
+ this.emitRemoved(nativeTabInfo, false);
+ }
+ break;
+ }
+
+ case "TabSelect":
+ // Because we are delaying calling emitCreated above, we also need to
+ // delay sending this event because it shouldn't fire before onCreated.
+ Promise.resolve().then(() => {
+ this.emitActivated(nativeTabInfo, event.detail.previousTabInfo);
+ });
+ break;
+ }
+ }
+
+ /**
+ * A private method which is called whenever a new mail window is opened, and dispatches the
+ * necessary events for it.
+ *
+ * @param {DOMWindow} window - The window being opened.
+ */
+ _handleWindowOpen(window) {
+ if (windowTracker.isSecondaryWindow(window)) {
+ this.emit("tab-created", {
+ nativeTabInfo: window,
+ currentTab: window,
+ });
+ return;
+ }
+
+ let tabmail = window.document.getElementById("tabmail");
+ if (!tabmail) {
+ return;
+ }
+
+ for (let nativeTabInfo of tabmail.tabInfo) {
+ this.emitCreated(nativeTabInfo);
+ }
+ }
+
+ /**
+ * A private method which is called whenever a mail window is closed, and dispatches the necessary
+ * events for it.
+ *
+ * @param {DOMWindow} window - The window being closed.
+ */
+ _handleWindowClose(window) {
+ if (windowTracker.isSecondaryWindow(window)) {
+ this.emit("tab-removed", {
+ nativeTabInfo: window,
+ tabId: this.getId(window),
+ windowId: windowTracker.getId(getTabWindow(window)),
+ isWindowClosing: true,
+ });
+ return;
+ }
+
+ let tabmail = window.document.getElementById("tabmail");
+ if (!tabmail) {
+ return;
+ }
+
+ for (let nativeTabInfo of tabmail.tabInfo) {
+ this.emitRemoved(nativeTabInfo, true);
+ }
+ }
+
+ /**
+ * Emits a "tab-activated" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which has been activated.
+ * @param {NativeTab} previousTabInfo - The previously active tab element.
+ */
+ emitActivated(nativeTabInfo, previousTabInfo) {
+ let previousTabId;
+ if (previousTabInfo && !previousTabInfo.closed) {
+ previousTabId = this.getId(previousTabInfo);
+ }
+ this.emit("tab-activated", {
+ tabId: this.getId(nativeTabInfo),
+ previousTabId,
+ windowId: windowTracker.getId(getTabWindow(nativeTabInfo)),
+ });
+ }
+
+ /**
+ * Emits a "tab-attached" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which is being attached.
+ */
+ emitAttached(nativeTabInfo) {
+ let tabId = this.getId(nativeTabInfo);
+ let browser = getTabBrowser(nativeTabInfo);
+ let tabmail = browser.ownerDocument.getElementById("tabmail");
+ let tabIndex = tabmail._getTabContextForTabbyThing(nativeTabInfo)[0];
+ let newWindowId = windowTracker.getId(browser.ownerGlobal);
+
+ this.emit("tab-attached", {
+ nativeTabInfo,
+ tabId,
+ newWindowId,
+ newPosition: tabIndex,
+ });
+ }
+
+ /**
+ * Emits a "tab-detached" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which is being detached.
+ */
+ emitDetached(nativeTabInfo) {
+ let tabId = this.getId(nativeTabInfo);
+ let browser = getTabBrowser(nativeTabInfo);
+ let tabmail = browser.ownerDocument.getElementById("tabmail");
+ let tabIndex = tabmail._getTabContextForTabbyThing(nativeTabInfo)[0];
+ let oldWindowId = windowTracker.getId(browser.ownerGlobal);
+
+ this.emit("tab-detached", {
+ nativeTabInfo,
+ tabId,
+ oldWindowId,
+ oldPosition: tabIndex,
+ });
+ }
+
+ /**
+ * Emits a "tab-created" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which is being created.
+ * @param {?NativeTab} currentTab - The tab info for the currently active tab.
+ */
+ emitCreated(nativeTabInfo, currentTab) {
+ this.emit("tab-created", { nativeTabInfo, currentTab });
+ }
+
+ /**
+ * Emits a "tab-removed" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info in the window to which the tab is being
+ * removed
+ * @param {boolean} isWindowClosing - If true, the window with these tabs is closing
+ */
+ emitRemoved(nativeTabInfo, isWindowClosing) {
+ this.emit("tab-removed", {
+ nativeTabInfo,
+ tabId: this.getId(nativeTabInfo),
+ windowId: windowTracker.getId(getTabWindow(nativeTabInfo)),
+ isWindowClosing,
+ });
+ }
+
+ /**
+ * Returns tab id and window id for the given browser element.
+ *
+ * @param {Element} browser - The browser element to check
+ * @returns {{ tabId:Integer, windowId:Integer }} The browsing data for the element
+ */
+ getBrowserData(browser) {
+ return {
+ tabId: this.getBrowserTabId(browser),
+ windowId: windowTracker.getId(browser.ownerGlobal),
+ };
+ }
+
+ /**
+ * Returns the active tab info for the given window
+ *
+ * @property {?NativeTabInfo} activeTab The active tab
+ * @readonly
+ */
+ get activeTab() {
+ let window = windowTracker.topWindow;
+ let tabmail = window && window.document.getElementById("tabmail");
+ return tabmail ? tabmail.selectedTab : window;
+ }
+}
+
+tabTracker = new TabTracker();
+spaceTracker = new SpaceTracker();
+windowTracker = new WindowTracker();
+Object.assign(global, { tabTracker, spaceTracker, windowTracker });
+
+/**
+ * Extension-specific wrapper around a Thunderbird tab. Note that for actual
+ * tabs in the main window, some of these methods are overridden by the
+ * TabmailTab subclass.
+ */
+class Tab extends TabBase {
+ get spaceId() {
+ let tabWindow = getTabWindow(this.nativeTab);
+ if (getWebExtensionWindowType(tabWindow) != "normal") {
+ return undefined;
+ }
+
+ let spaceData = spaceTracker.findSpaceForTab(this.nativeTab);
+ return spaceData?.spaceId ?? undefined;
+ }
+
+ /** What sort of tab is this? */
+ get type() {
+ switch (this.nativeTab.location?.href) {
+ case COMPOSE_WINDOW_URI:
+ return "messageCompose";
+ case MESSAGE_WINDOW_URI:
+ return "messageDisplay";
+ case POPUP_WINDOW_URI:
+ return "content";
+ default:
+ return null;
+ }
+ }
+
+ /** Overrides the matches function to enable querying for tab types. */
+ matches(queryInfo, context) {
+ // If the query includes url or title, but this is a non-browser tab, return
+ // false directly.
+ if ((queryInfo.url || queryInfo.title) && !this.browser) {
+ return false;
+ }
+ let result = super.matches(queryInfo, context);
+
+ let type = queryInfo.mailTab ? "mail" : queryInfo.type;
+ if (result && type && this.type != type) {
+ return false;
+ }
+
+ if (result && queryInfo.spaceId && this.spaceId != queryInfo.spaceId) {
+ return false;
+ }
+
+ return result;
+ }
+
+ /** Adds the mailTab property and removes some useless properties from a tab object. */
+ convert(fallback) {
+ let result = super.convert(fallback);
+ result.spaceId = this.spaceId;
+ result.type = this.type;
+ result.mailTab = result.type == "mail";
+
+ // These properties are not useful to Thunderbird extensions and are not returned.
+ for (let key of [
+ "attention",
+ "audible",
+ "discarded",
+ "hidden",
+ "incognito",
+ "isArticle",
+ "isInReaderMode",
+ "lastAccessed",
+ "mutedInfo",
+ "pinned",
+ "sharingState",
+ "successorTabId",
+ ]) {
+ delete result[key];
+ }
+
+ return result;
+ }
+
+ /** Always returns false. This feature doesn't exist in Thunderbird. */
+ get _incognito() {
+ return false;
+ }
+
+ /** Returns the XUL browser for the tab. */
+ get browser() {
+ if (this.type == "messageCompose") {
+ return this.nativeTab.GetCurrentEditorElement();
+ }
+ if (this.nativeTab.getBrowser) {
+ return this.nativeTab.getBrowser();
+ }
+ return null;
+ }
+
+ get innerWindowID() {
+ if (!this.browser) {
+ return null;
+ }
+ if (this.type == "messageCompose") {
+ return this.browser.contentWindow.windowUtils.currentInnerWindowID;
+ }
+ return super.innerWindowID;
+ }
+
+ /** Returns the frame loader for the tab. */
+ get frameLoader() {
+ // If we don't have a frameLoader yet, just return a dummy with no width and
+ // height.
+ return super.frameLoader || { lazyWidth: 0, lazyHeight: 0 };
+ }
+
+ /** Returns false if the current tab does not have a url associated. */
+ get matchesHostPermission() {
+ if (!this._url) {
+ return false;
+ }
+ return super.matchesHostPermission;
+ }
+
+ /** Returns the current URL of this tab, without permission checks. */
+ get _url() {
+ if (this.type == "messageCompose") {
+ return undefined;
+ }
+ return this.browser?.currentURI?.spec;
+ }
+
+ /** Returns the current title of this tab, without permission checks. */
+ get _title() {
+ if (this.browser && this.browser.contentTitle) {
+ return this.browser.contentTitle;
+ }
+ return this.nativeTab.label;
+ }
+
+ /** Returns the favIcon, without permission checks. */
+ get _favIconUrl() {
+ return null;
+ }
+
+ /** Returns the last accessed time. */
+ get lastAccessed() {
+ return 0;
+ }
+
+ /** Returns the audible state. */
+ get audible() {
+ return false;
+ }
+
+ /** Returns the cookie store id. */
+ get cookieStoreId() {
+ if (this.browser && this.browser.contentPrincipal) {
+ return getCookieStoreIdForOriginAttributes(
+ this.browser.contentPrincipal.originAttributes
+ );
+ }
+
+ return DEFAULT_STORE;
+ }
+
+ /** Returns the discarded state. */
+ get discarded() {
+ return false;
+ }
+
+ /** Returns the tab height. */
+ get height() {
+ return this.frameLoader.lazyHeight;
+ }
+
+ /** Returns hidden status. */
+ get hidden() {
+ return false;
+ }
+
+ /** Returns the tab index. */
+ get index() {
+ return 0;
+ }
+
+ /** Returns information about the muted state of the tab. */
+ get mutedInfo() {
+ return { muted: false };
+ }
+
+ /** Returns information about the sharing state of the tab. */
+ get sharingState() {
+ return { camera: false, microphone: false, screen: false };
+ }
+
+ /** Returns the pinned state of the tab. */
+ get pinned() {
+ return false;
+ }
+
+ /** Returns the active state of the tab. */
+ get active() {
+ return true;
+ }
+
+ /** Returns the highlighted state of the tab. */
+ get highlighted() {
+ return this.active;
+ }
+
+ /** Returns the selected state of the tab. */
+ get selected() {
+ return this.active;
+ }
+
+ /** Returns the loading status of the tab. */
+ get status() {
+ let isComplete;
+ switch (this.type) {
+ case "messageDisplay":
+ case "addressBook":
+ isComplete = this.browser?.contentDocument?.readyState == "complete";
+ break;
+ case "mail":
+ {
+ // If the messagePane is hidden or all browsers are hidden, there is
+ // nothing to be loaded and we should return complete.
+ let about3Pane = this.nativeTab.chromeBrowser.contentWindow;
+ isComplete =
+ !about3Pane.paneLayout?.messagePaneVisible ||
+ this.browser?.webProgress?.isLoadingDocument === false ||
+ (about3Pane.webBrowser?.hidden &&
+ about3Pane.messageBrowser?.hidden &&
+ about3Pane.multiMessageBrowser?.hidden);
+ }
+ break;
+ case "content":
+ case "special":
+ isComplete = this.browser?.webProgress?.isLoadingDocument === false;
+ break;
+ default:
+ // All other tabs (chat, task, calendar, messageCompose) do not fire the
+ // tabs.onUpdated event (Bug 1827929). Let them always be complete.
+ isComplete = true;
+ }
+ return isComplete ? "complete" : "loading";
+ }
+
+ /** Returns the width of the tab. */
+ get width() {
+ return this.frameLoader.lazyWidth;
+ }
+
+ /** Returns the native window object of the tab. */
+ get window() {
+ return this.nativeTab;
+ }
+
+ /** Returns the window id of the tab. */
+ get windowId() {
+ return windowTracker.getId(this.window);
+ }
+
+ /** Returns the attention state of the tab. */
+ get attention() {
+ return false;
+ }
+
+ /** Returns the article state of the tab. */
+ get isArticle() {
+ return false;
+ }
+
+ /** Returns the reader mode state of the tab. */
+ get isInReaderMode() {
+ return false;
+ }
+
+ /** Returns the id of the successor tab of the tab. */
+ get successorTabId() {
+ return -1;
+ }
+}
+
+class TabmailTab extends Tab {
+ constructor(extension, nativeTab, id) {
+ if (nativeTab.localName == "tab") {
+ let tabmail = nativeTab.ownerDocument.getElementById("tabmail");
+ nativeTab = tabmail._getTabContextForTabbyThing(nativeTab)[1];
+ }
+ super(extension, nativeTab, id);
+ }
+
+ /** What sort of tab is this? */
+ get type() {
+ switch (this.nativeTab.mode.name) {
+ case "mail3PaneTab":
+ return "mail";
+ case "addressBookTab":
+ return "addressBook";
+ case "mailMessageTab":
+ return "messageDisplay";
+ case "contentTab": {
+ let currentURI = this.nativeTab.browser.currentURI;
+ if (currentURI?.schemeIs("about")) {
+ switch (currentURI.filePath) {
+ case "accountprovisioner":
+ return "accountProvisioner";
+ case "blank":
+ return "content";
+ default:
+ return "special";
+ }
+ }
+ if (currentURI?.schemeIs("chrome")) {
+ return "special";
+ }
+ return "content";
+ }
+ case "calendar":
+ case "calendarEvent":
+ case "calendarTask":
+ case "tasks":
+ case "chat":
+ return this.nativeTab.mode.name;
+ case "provisionerCheckoutTab":
+ case "glodaFacet":
+ case "preferencesTab":
+ return "special";
+ default:
+ // We should not get here, unless a new type is registered with tabmail.
+ return null;
+ }
+ }
+
+ /** Returns the XUL browser for the tab. */
+ get browser() {
+ return getTabBrowser(this.nativeTab);
+ }
+
+ /** Returns the favIcon, without permission checks. */
+ get _favIconUrl() {
+ return this.nativeTab.favIconUrl;
+ }
+
+ /** Returns the tabmail element for the tab. */
+ get tabmail() {
+ return getTabTabmail(this.nativeTab);
+ }
+
+ /** Returns the tab index. */
+ get index() {
+ return this.tabmail.tabInfo.indexOf(this.nativeTab);
+ }
+
+ /** Returns the active state of the tab. */
+ get active() {
+ return this.nativeTab == this.tabmail.selectedTab;
+ }
+
+ /** Returns the title of the tab, without permission checks. */
+ get _title() {
+ if (this.browser && this.browser.contentTitle) {
+ return this.browser.contentTitle;
+ }
+ // Do we want to be using this.nativeTab.title instead? The difference is
+ // that the tabNode label may use defaultTabTitle instead, but do we want to
+ // send this out?
+ return this.nativeTab.tabNode.getAttribute("label");
+ }
+
+ /** Returns the native window object of the tab. */
+ get window() {
+ return this.tabmail.ownerGlobal;
+ }
+}
+
+/**
+ * Extension-specific wrapper around a Thunderbird window.
+ */
+class Window extends WindowBase {
+ /**
+ * @property {string} type - The type of the window, as defined by the
+ * WebExtension API.
+ * @see mail/components/extensions/schemas/windows.json
+ * @readonly
+ */
+ get type() {
+ let type = getWebExtensionWindowType(this.window);
+ if (!type) {
+ throw new ExtensionError(
+ "Windows API encountered an invalid window type."
+ );
+ }
+ return type;
+ }
+
+ /** Returns the title of the tab, without permission checks. */
+ get _title() {
+ return this.window.document.title;
+ }
+
+ /** Returns the title of the tab, checking tab permissions. */
+ get title() {
+ // Thunderbird can have an empty active tab while a window is loading
+ if (this.activeTab && this.activeTab.hasTabPermission) {
+ return this._title;
+ }
+ return null;
+ }
+
+ /**
+ * Sets the title preface of the window.
+ *
+ * @param {string} titlePreface - The title preface to set
+ */
+ setTitlePreface(titlePreface) {
+ this.window.document.documentElement.setAttribute(
+ "titlepreface",
+ titlePreface
+ );
+ }
+
+ /** Gets the foucsed state of the window. */
+ get focused() {
+ return this.window.document.hasFocus();
+ }
+
+ /** Gets the top position of the window. */
+ get top() {
+ return this.window.screenY;
+ }
+
+ /** Gets the left position of the window. */
+ get left() {
+ return this.window.screenX;
+ }
+
+ /** Gets the width of the window. */
+ get width() {
+ return this.window.outerWidth;
+ }
+
+ /** Gets the height of the window. */
+ get height() {
+ return this.window.outerHeight;
+ }
+
+ /** Gets the private browsing status of the window. */
+ get incognito() {
+ return false;
+ }
+
+ /** Checks if the window is considered always on top. */
+ get alwaysOnTop() {
+ return this.appWindow.zLevel >= Ci.nsIAppWindow.raisedZ;
+ }
+
+ /** Checks if the window was the last one focused. */
+ get isLastFocused() {
+ return this.window === windowTracker.topWindow;
+ }
+
+ /**
+ * Returns the window state for the given window.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {string} "maximized", "minimized", "normal" or "fullscreen"
+ */
+ static getState(window) {
+ const STATES = {
+ [window.STATE_MAXIMIZED]: "maximized",
+ [window.STATE_MINIMIZED]: "minimized",
+ [window.STATE_NORMAL]: "normal",
+ };
+ let state = STATES[window.windowState];
+ if (window.fullScreen) {
+ state = "fullscreen";
+ }
+ return state;
+ }
+
+ /** Returns the window state for this specific window. */
+ get state() {
+ return Window.getState(this.window);
+ }
+
+ /**
+ * Sets the window state for this specific window.
+ *
+ * @param {string} state - "maximized", "minimized", "normal" or "fullscreen"
+ */
+ async setState(state) {
+ let { window } = this;
+ const expectedState = (function () {
+ switch (state) {
+ case "maximized":
+ return window.STATE_MAXIMIZED;
+ case "minimized":
+ case "docked":
+ return window.STATE_MINIMIZED;
+ case "normal":
+ return window.STATE_NORMAL;
+ case "fullscreen":
+ return window.STATE_FULLSCREEN;
+ }
+ throw new ExtensionError(`Unexpected window state: ${state}`);
+ })();
+
+ const initialState = window.windowState;
+ if (expectedState == initialState) {
+ return;
+ }
+
+ // We check for window.fullScreen here to make sure to exit fullscreen even
+ // if DOM and widget disagree on what the state is. This is a speculative
+ // fix for bug 1780876, ideally it should not be needed.
+ if (initialState == window.STATE_FULLSCREEN || window.fullScreen) {
+ window.fullScreen = false;
+ }
+
+ switch (expectedState) {
+ case window.STATE_MAXIMIZED:
+ window.maximize();
+ break;
+ case window.STATE_MINIMIZED:
+ window.minimize();
+ break;
+
+ case window.STATE_NORMAL:
+ // Restore sometimes returns the window to its previous state, rather
+ // than to the "normal" state, so it may need to be called anywhere from
+ // zero to two times.
+ window.restore();
+ if (window.windowState !== window.STATE_NORMAL) {
+ window.restore();
+ }
+ if (window.windowState !== window.STATE_NORMAL) {
+ // And on OS-X, where normal vs. maximized is basically a heuristic,
+ // we need to cheat.
+ window.sizeToContent();
+ }
+ break;
+
+ case window.STATE_FULLSCREEN:
+ window.fullScreen = true;
+ break;
+
+ default:
+ throw new ExtensionError(`Unexpected window state: ${state}`);
+ }
+
+ if (window.windowState != expectedState) {
+ // On Linux, sizemode changes are asynchronous. Some of them might not
+ // even happen if the window manager doesn't want to, so wait for a bit
+ // instead of forever for a sizemode change that might not ever happen.
+ const noWindowManagerTimeout = 2000;
+
+ let onSizeModeChange;
+ const promiseExpectedSizeMode = new Promise(resolve => {
+ onSizeModeChange = function () {
+ if (window.windowState == expectedState) {
+ resolve();
+ }
+ };
+ window.addEventListener("sizemodechange", onSizeModeChange);
+ });
+
+ await Promise.any([
+ promiseExpectedSizeMode,
+ new Promise(resolve =>
+ window.setTimeout(resolve, noWindowManagerTimeout)
+ ),
+ ]);
+ window.removeEventListener("sizemodechange", onSizeModeChange);
+ }
+
+ if (window.windowState != expectedState) {
+ console.warn(
+ `Window manager refused to set window to state ${expectedState}.`
+ );
+ }
+ }
+
+ /**
+ * Retrieves the (relevant) tabs in this window.
+ *
+ * @yields {Tab} The wrapped Tab in this window
+ */
+ *getTabs() {
+ let { tabManager } = this.extension;
+ yield tabManager.getWrapper(this.window);
+ }
+
+ /**
+ * Returns an iterator of TabBase objects for the highlighted tab in this
+ * window. This is an alias for the active tab.
+ *
+ * @returns {Iterator<TabBase>}
+ */
+ *getHighlightedTabs() {
+ yield this.activeTab;
+ }
+
+ /** Retrieves the active tab in this window */
+ get activeTab() {
+ let { tabManager } = this.extension;
+ return tabManager.getWrapper(this.window);
+ }
+
+ /**
+ * Retrieves the tab at the given index.
+ *
+ * @param {number} index - The index to look at
+ * @returns {Tab} The wrapped tab at the index
+ */
+ getTabAtIndex(index) {
+ let { tabManager } = this.extension;
+ if (index == 0) {
+ return tabManager.getWrapper(this.window);
+ }
+ return null;
+ }
+}
+
+class TabmailWindow extends Window {
+ /** Returns the tabmail element for the tab. */
+ get tabmail() {
+ return this.window.document.getElementById("tabmail");
+ }
+
+ /**
+ * Retrieves the (relevant) tabs in this window.
+ *
+ * @yields {Tab} The wrapped Tab in this window
+ */
+ *getTabs() {
+ let { tabManager } = this.extension;
+
+ for (let nativeTabInfo of this.tabmail.tabInfo) {
+ // Only tabs that have a browser element.
+ yield tabManager.getWrapper(nativeTabInfo);
+ }
+ }
+
+ /** Retrieves the active tab in this window */
+ get activeTab() {
+ let { tabManager } = this.extension;
+ let selectedTab = this.tabmail.selectedTab;
+ if (selectedTab) {
+ return tabManager.getWrapper(selectedTab);
+ }
+ return null;
+ }
+
+ /**
+ * Retrieves the tab at the given index.
+ *
+ * @param {number} index - The index to look at
+ * @returns {Tab} The wrapped tab at the index
+ */
+ getTabAtIndex(index) {
+ let { tabManager } = this.extension;
+ let nativeTabInfo = this.tabmail.tabInfo[index];
+ if (nativeTabInfo) {
+ return tabManager.getWrapper(nativeTabInfo);
+ }
+ return null;
+ }
+}
+
+Object.assign(global, { Tab, Window });
+
+/**
+ * Manages native tabs, their wrappers, and their dynamic permissions for a particular extension.
+ */
+class TabManager extends TabManagerBase {
+ /**
+ * Returns a Tab wrapper for the tab with the given ID.
+ *
+ * @param {integer} tabId - The ID of the tab for which to return a wrapper.
+ * @param {*} default_ - The value to return if no tab exists with the given ID.
+ * @returns {Tab|*} The wrapped tab, or the default value
+ */
+ get(tabId, default_ = undefined) {
+ let nativeTabInfo = tabTracker.getTab(tabId, default_);
+
+ if (nativeTabInfo) {
+ return this.getWrapper(nativeTabInfo);
+ }
+ return default_;
+ }
+
+ /**
+ * If the extension has requested activeTab permission, grant it those permissions for the current
+ * inner window in the given native tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The native tab for which to grant permissions.
+ */
+ addActiveTabPermission(nativeTabInfo = tabTracker.activeTab) {
+ if (nativeTabInfo.browser) {
+ super.addActiveTabPermission(nativeTabInfo);
+ }
+ }
+
+ /**
+ * Revoke the extension's activeTab permissions for the current inner window of the given native
+ * tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The native tab for which to revoke permissions.
+ */
+ revokeActiveTabPermission(nativeTabInfo = tabTracker.activeTab) {
+ super.revokeActiveTabPermission(nativeTabInfo);
+ }
+
+ /**
+ * Determines access using extension context.
+ *
+ * @param {NativeTab} nativeTab
+ * The tab to check access on.
+ * @returns {boolean}
+ * True if the extension has permissions for this tab.
+ */
+ canAccessTab(nativeTab) {
+ return true;
+ }
+
+ /**
+ * Returns a new Tab instance wrapping the given native tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The native tab for which to return a wrapper.
+ * @returns {Tab} The wrapped native tab
+ */
+ wrapTab(nativeTabInfo) {
+ let tabClass = TabmailTab;
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ tabClass = Tab;
+ }
+ return new tabClass(
+ this.extension,
+ nativeTabInfo,
+ tabTracker.getId(nativeTabInfo)
+ );
+ }
+}
+
+/**
+ * Manages native browser windows and their wrappers for a particular extension.
+ */
+class WindowManager extends WindowManagerBase {
+ /**
+ * Returns a Window wrapper for the mail window with the given ID.
+ *
+ * @param {Integer} windowId - The ID of the browser window for which to return a wrapper.
+ * @param {BaseContext} context - The extension context for which the matching is being performed.
+ * Used to determine the current window for relevant properties.
+ * @returns {Window} The wrapped window
+ */
+ get(windowId, context) {
+ let window = windowTracker.getWindow(windowId, context);
+ return this.getWrapper(window);
+ }
+
+ /**
+ * Yields an iterator of WindowBase wrappers for each currently existing browser window.
+ *
+ * @yields {Window}
+ */
+ *getAll() {
+ for (let window of windowTracker.browserWindows()) {
+ yield this.getWrapper(window);
+ }
+ }
+
+ /**
+ * Returns a new Window instance wrapping the given mail window.
+ *
+ * @param {DOMWindow} window - The mail window for which to return a wrapper.
+ * @returns {Window} The wrapped window
+ */
+ wrapWindow(window) {
+ let windowClass = Window;
+ if (
+ window.document.documentElement.getAttribute("windowtype") == "mail:3pane"
+ ) {
+ windowClass = TabmailWindow;
+ }
+ return new windowClass(this.extension, window, windowTracker.getId(window));
+ }
+}
+
+/**
+ * Wait until the normal window identified by the given windowId has finished its
+ * delayed startup. Returns its DOMWindow when done. Waits for the top normal
+ * window, if no window is specified.
+ *
+ * @param {*} [context] - a WebExtension context
+ * @param {*} [windowId] - a WebExtension window id
+ * @returns {DOMWindow}
+ */
+async function getNormalWindowReady(context, windowId) {
+ let window;
+ if (windowId) {
+ let win = context.extension.windowManager.get(windowId, context);
+ if (win.type != "normal") {
+ throw new ExtensionError(
+ `Window with ID ${windowId} is not a normal window`
+ );
+ }
+ window = win.window;
+ } else {
+ window = windowTracker.topNormalWindow;
+ }
+
+ // Wait for session restore.
+ await new Promise(resolve => {
+ if (!window.SessionStoreManager._restored) {
+ let obs = (observedWindow, topic, data) => {
+ if (observedWindow != window) {
+ return;
+ }
+ Services.obs.removeObserver(obs, "mail-tabs-session-restored");
+ resolve();
+ };
+ Services.obs.addObserver(obs, "mail-tabs-session-restored");
+ } else {
+ resolve();
+ }
+ });
+
+ // Wait for all mail3PaneTab's to have been fully restored and loaded.
+ for (let tabInfo of window.gTabmail.tabInfo) {
+ let { chromeBrowser, mode, closed } = tabInfo;
+ if (!closed && mode.name == "mail3PaneTab") {
+ await new Promise(resolve => {
+ if (
+ chromeBrowser.contentDocument.readyState == "complete" &&
+ chromeBrowser.currentURI.spec == "about:3pane"
+ ) {
+ resolve();
+ } else {
+ chromeBrowser.contentWindow.addEventListener(
+ "load",
+ () => resolve(),
+ {
+ once: true,
+ }
+ );
+ }
+ });
+ }
+ }
+
+ return window;
+}
+
+/**
+ * Converts an nsIMsgAccount to a simple object
+ *
+ * @param {nsIMsgAccount} account
+ * @returns {object}
+ */
+function convertAccount(account, includeFolders = true) {
+ if (!account) {
+ return null;
+ }
+
+ account = account.QueryInterface(Ci.nsIMsgAccount);
+ let server = account.incomingServer;
+ if (server.type == "im") {
+ return null;
+ }
+
+ let folders = null;
+ if (includeFolders) {
+ folders = traverseSubfolders(
+ account.incomingServer.rootFolder,
+ account.key
+ ).subFolders;
+ }
+
+ return {
+ id: account.key,
+ name: account.incomingServer.prettyName,
+ type: account.incomingServer.type,
+ folders,
+ identities: account.identities.map(identity =>
+ convertMailIdentity(account, identity)
+ ),
+ };
+}
+
+/**
+ * Converts an nsIMsgIdentity to a simple object for use in messages.
+ *
+ * @param {nsIMsgAccount} account
+ * @param {nsIMsgIdentity} identity
+ * @returns {object}
+ */
+function convertMailIdentity(account, identity) {
+ if (!account || !identity) {
+ return null;
+ }
+ identity = identity.QueryInterface(Ci.nsIMsgIdentity);
+ return {
+ accountId: account.key,
+ id: identity.key,
+ label: identity.label || "",
+ name: identity.fullName || "",
+ email: identity.email || "",
+ replyTo: identity.replyTo || "",
+ organization: identity.organization || "",
+ composeHtml: identity.composeHtml,
+ signature: identity.htmlSigText || "",
+ signatureIsPlainText: !identity.htmlSigFormat,
+ };
+}
+
+/**
+ * The following functions turn nsIMsgFolder references into more human-friendly forms.
+ * A folder can be referenced with the account key, and the path to the folder in that account.
+ */
+
+/**
+ * Convert a folder URI to a human-friendly path.
+ *
+ * @returns {string}
+ */
+function folderURIToPath(accountId, uri) {
+ let server = MailServices.accounts.getAccount(accountId).incomingServer;
+ let rootURI = server.rootFolder.URI;
+ if (rootURI == uri) {
+ return "/";
+ }
+ // The .URI property of an IMAP folder doesn't have %-encoded characters, but
+ // may include literal % chars. Services.io.newURI(uri) applies encodeURI to
+ // the returned filePath, but will not encode any literal % chars, which will
+ // cause decodeURIComponent to fail (bug 1707408).
+ if (server.type == "imap") {
+ return uri.substring(rootURI.length);
+ }
+ let path = Services.io.newURI(uri).filePath;
+ return path.split("/").map(decodeURIComponent).join("/");
+}
+
+/**
+ * Convert a human-friendly path to a folder URI. This function does not assume
+ * that the folder referenced exists.
+ *
+ * @returns {string}
+ */
+function folderPathToURI(accountId, path) {
+ let server = MailServices.accounts.getAccount(accountId).incomingServer;
+ let rootURI = server.rootFolder.URI;
+ if (path == "/") {
+ return rootURI;
+ }
+ // The .URI property of an IMAP folder doesn't have %-encoded characters.
+ // If encoded here, the folder lookup service won't find the folder.
+ if (server.type == "imap") {
+ return rootURI + path;
+ }
+ return (
+ rootURI +
+ path
+ .split("/")
+ .map(p =>
+ encodeURIComponent(p)
+ .replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16))
+ // We do not encode "+" chars in folder URIs. Manually convert them
+ // back to literal + chars, otherwise folder lookup will fail.
+ .replaceAll("%2B", "+")
+ )
+ .join("/")
+ );
+}
+
+const folderTypeMap = new Map([
+ [Ci.nsMsgFolderFlags.Inbox, "inbox"],
+ [Ci.nsMsgFolderFlags.Drafts, "drafts"],
+ [Ci.nsMsgFolderFlags.SentMail, "sent"],
+ [Ci.nsMsgFolderFlags.Trash, "trash"],
+ [Ci.nsMsgFolderFlags.Templates, "templates"],
+ [Ci.nsMsgFolderFlags.Archive, "archives"],
+ [Ci.nsMsgFolderFlags.Junk, "junk"],
+ [Ci.nsMsgFolderFlags.Queue, "outbox"],
+]);
+
+/**
+ * Converts an nsIMsgFolder to a simple object for use in API messages.
+ *
+ * @param {nsIMsgFolder} folder - The folder to convert.
+ * @param {string} [accountId] - An optimization to avoid looking up the
+ * account. The value from nsIMsgHdr.accountKey must not be used here.
+ * @returns {MailFolder}
+ * @see mail/components/extensions/schemas/folders.json
+ */
+function convertFolder(folder, accountId) {
+ if (!folder) {
+ return null;
+ }
+ if (!accountId) {
+ let server = folder.server;
+ let account = MailServices.accounts.FindAccountForServer(server);
+ accountId = account.key;
+ }
+
+ let folderObject = {
+ accountId,
+ name: folder.prettyName,
+ path: folderURIToPath(accountId, folder.URI),
+ };
+
+ for (let [flag, typeName] of folderTypeMap.entries()) {
+ if (folder.flags & flag) {
+ folderObject.type = typeName;
+ }
+ }
+
+ return folderObject;
+}
+
+/**
+ * Converts an nsIMsgFolder and all its subfolders to a simple object for use in
+ * API messages.
+ *
+ * @param {nsIMsgFolder} folder - The folder to convert.
+ * @param {string} [accountId] - An optimization to avoid looking up the
+ * account. The value from nsIMsgHdr.accountKey must not be used here.
+ * @returns {MailFolder}
+ * @see mail/components/extensions/schemas/folders.json
+ */
+function traverseSubfolders(folder, accountId) {
+ let f = convertFolder(folder, accountId);
+ f.subFolders = [];
+ if (folder.hasSubFolders) {
+ // Use the same order as used by Thunderbird.
+ let subFolders = [...folder.subFolders].sort((a, b) =>
+ a.sortOrder == b.sortOrder
+ ? a.name.localeCompare(b.name)
+ : a.sortOrder - b.sortOrder
+ );
+ for (let subFolder of subFolders) {
+ f.subFolders.push(
+ traverseSubfolders(subFolder, accountId || f.accountId)
+ );
+ }
+ }
+ return f;
+}
+
+class FolderManager {
+ constructor(extension) {
+ this.extension = extension;
+ }
+
+ convert(folder, accountId) {
+ return convertFolder(folder, accountId);
+ }
+
+ get(accountId, path) {
+ return MailServices.folderLookup.getFolderForURL(
+ folderPathToURI(accountId, path)
+ );
+ }
+}
+
+/**
+ * Checks if the provided nsIMsgHdr is a dummy message header of an attached message.
+ */
+function isAttachedMessage(msgHdr) {
+ try {
+ return (
+ !msgHdr.folder &&
+ new URL(msgHdr.getStringProperty("dummyMsgUrl")).searchParams.has("part")
+ );
+ } catch (ex) {
+ return false;
+ }
+}
+
+/**
+ * Converts an nsIMsgHdr to a simple object for use in messages.
+ * This function WILL change as the API develops.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {ExtensionData} extension
+ * @returns {MessageHeader} MessageHeader object
+ *
+ * @see /mail/components/extensions/schemas/messages.json
+ */
+function convertMessage(msgHdr, extension) {
+ if (!msgHdr) {
+ return null;
+ }
+
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let junkScore = parseInt(msgHdr.getStringProperty("junkscore"), 10) || 0;
+ let tags = (msgHdr.getStringProperty("keywords") || "")
+ .split(" ")
+ .filter(MailServices.tags.isValidKey);
+
+ let external = !msgHdr.folder;
+
+ // Getting the size of attached messages does not work consistently. For imap://
+ // and mailbox:// messages the returned size in msgHdr.messageSize is 0, and for
+ // file:// messages the returned size is always the total file size
+ // Be consistent here and always return 0. The user can obtain the message size
+ // from the size of the associated attachment file.
+ let size = isAttachedMessage(msgHdr) ? 0 : msgHdr.messageSize;
+
+ let messageObject = {
+ id: messageTracker.getId(msgHdr),
+ date: new Date(Math.round(msgHdr.date / 1000)),
+ author: msgHdr.mime2DecodedAuthor,
+ recipients: composeFields.splitRecipients(
+ msgHdr.mime2DecodedRecipients,
+ false
+ ),
+ ccList: composeFields.splitRecipients(msgHdr.ccList, false),
+ bccList: composeFields.splitRecipients(msgHdr.bccList, false),
+ subject: msgHdr.mime2DecodedSubject,
+ read: msgHdr.isRead,
+ new: !!(msgHdr.flags & Ci.nsMsgMessageFlags.New),
+ headersOnly: !!(msgHdr.flags & Ci.nsMsgMessageFlags.Partial),
+ flagged: !!msgHdr.isFlagged,
+ junk: junkScore >= gJunkThreshold,
+ junkScore,
+ headerMessageId: msgHdr.messageId,
+ size,
+ tags,
+ external,
+ };
+ // convertMessage can be called without providing an extension, if the info is
+ // needed for multiple extensions. The caller has to ensure that the folder info
+ // is not forwarded to extensions, which do not have the required permission.
+ if (
+ msgHdr.folder &&
+ (!extension || extension.hasPermission("accountsRead"))
+ ) {
+ messageObject.folder = convertFolder(msgHdr.folder);
+ }
+ return messageObject;
+}
+
+/**
+ * A map of numeric identifiers to messages for easy reference.
+ *
+ * @implements {nsIFolderListener}
+ * @implements {nsIMsgFolderListener}
+ * @implements {nsIObserver}
+ */
+var messageTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this._nextId = 1;
+ this._messages = new Map();
+ this._messageIds = new Map();
+ this._listenerCount = 0;
+ this._pendingKeyChanges = new Map();
+ this._dummyMessageHeaders = new Map();
+
+ // nsIObserver
+ Services.obs.addObserver(this, "quit-application-granted");
+ Services.obs.addObserver(this, "attachment-delete-msgkey-changed");
+ // nsIFolderListener
+ MailServices.mailSession.AddFolderListener(
+ this,
+ Ci.nsIFolderListener.propertyFlagChanged |
+ Ci.nsIFolderListener.intPropertyChanged
+ );
+ // nsIMsgFolderListener
+ MailServices.mfn.addListener(
+ this,
+ MailServices.mfn.msgsJunkStatusChanged |
+ MailServices.mfn.msgsDeleted |
+ MailServices.mfn.msgsMoveCopyCompleted |
+ MailServices.mfn.msgKeyChanged
+ );
+
+ this._messageOpenListener = {
+ registered: false,
+ async handleEvent(event) {
+ let msgHdr = event.detail;
+ // It is not possible to retrieve the dummyMsgHdr of messages opened
+ // from file at a later time, track them manually.
+ if (
+ msgHdr &&
+ !msgHdr.folder &&
+ msgHdr.getStringProperty("dummyMsgUrl").startsWith("file://")
+ ) {
+ messageTracker.getId(msgHdr);
+ }
+ },
+ };
+ try {
+ windowTracker.addListener("MsgLoaded", this._messageOpenListener);
+ this._messageOpenListener.registered = true;
+ } catch (ex) {
+ // Fails during XPCSHELL tests, which mock the WindowWatcher but do not
+ // implement registerNotification.
+ }
+ }
+
+ cleanup() {
+ // nsIObserver
+ Services.obs.removeObserver(this, "quit-application-granted");
+ Services.obs.removeObserver(this, "attachment-delete-msgkey-changed");
+ // nsIFolderListener
+ MailServices.mailSession.RemoveFolderListener(this);
+ // nsIMsgFolderListener
+ MailServices.mfn.removeListener(this);
+ if (this._messageOpenListener.registered) {
+ windowTracker.removeListener("MsgLoaded", this._messageOpenListener);
+ this._messageOpenListener.registered = false;
+ }
+ }
+
+ /**
+ * Maps the provided message identifier to the given messageTracker id.
+ */
+ _set(id, msgIdentifier, msgHdr) {
+ let hash = JSON.stringify(msgIdentifier);
+ this._messageIds.set(hash, id);
+ this._messages.set(id, msgIdentifier);
+ // Keep track of dummy message headers, which do not have a folderURI property
+ // and cannot be retrieved later.
+ if (msgHdr && !msgHdr.folder) {
+ this._dummyMessageHeaders.set(msgIdentifier.dummyMsgUrl, msgHdr);
+ }
+ }
+
+ /**
+ * Lookup the messageTracker id for the given message identifier, return null
+ * if not known.
+ */
+ _get(msgIdentifier) {
+ let hash = JSON.stringify(msgIdentifier);
+ if (this._messageIds.has(hash)) {
+ return this._messageIds.get(hash);
+ }
+ return null;
+ }
+
+ /**
+ * Removes the provided message identifier from the messageTracker.
+ */
+ _remove(msgIdentifier) {
+ let hash = JSON.stringify(msgIdentifier);
+ let id = this._get(msgIdentifier);
+ this._messages.delete(id);
+ this._messageIds.delete(hash);
+ this._dummyMessageHeaders.delete(msgIdentifier.dummyMsgUrl);
+ }
+
+ /**
+ * Finds a message in the messageTracker or adds it.
+ *
+ * @returns {int} The messageTracker id of the message
+ */
+ getId(msgHdr) {
+ let msgIdentifier;
+ if (msgHdr.folder) {
+ msgIdentifier = {
+ folderURI: msgHdr.folder.URI,
+ messageKey: msgHdr.messageKey,
+ };
+ } else {
+ // Normalize the dummyMsgUrl by sorting its parameters and striping them
+ // to a minimum.
+ let url = new URL(msgHdr.getStringProperty("dummyMsgUrl"));
+ let parameters = Array.from(url.searchParams, p => p[0]).filter(
+ p => !["group", "number", "key", "part"].includes(p)
+ );
+ for (let parameter of parameters) {
+ url.searchParams.delete(parameter);
+ }
+ url.searchParams.sort();
+
+ msgIdentifier = {
+ dummyMsgUrl: url.href,
+ dummyMsgLastModifiedTime: msgHdr.getUint32Property(
+ "dummyMsgLastModifiedTime"
+ ),
+ };
+ }
+
+ let id = this._get(msgIdentifier);
+ if (id) {
+ return id;
+ }
+ id = this._nextId++;
+
+ this._set(id, msgIdentifier, new nsDummyMsgHeader(msgHdr));
+ return id;
+ }
+
+ /**
+ * Check if the provided msgIdentifier belongs to a modified file message.
+ *
+ * @param {*} msgIdentifier - the msgIdentifier object of the message
+ * @returns {boolean}
+ */
+ isModifiedFileMsg(msgIdentifier) {
+ if (!msgIdentifier.dummyMsgUrl?.startsWith("file://")) {
+ return false;
+ }
+
+ try {
+ let file = Services.io
+ .newURI(msgIdentifier.dummyMsgUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ if (!file?.exists()) {
+ throw new ExtensionError("File does not exist");
+ }
+ if (
+ msgIdentifier.dummyMsgLastModifiedTime &&
+ Math.floor(file.lastModifiedTime / 1000000) !=
+ msgIdentifier.dummyMsgLastModifiedTime
+ ) {
+ throw new ExtensionError("File has been modified");
+ }
+ } catch (ex) {
+ console.error(ex);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Retrieves a message from the messageTracker. If the message no longer,
+ * exists it is removed from the messageTracker.
+ *
+ * @returns {nsIMsgHdr} The identifier of the message
+ */
+ getMessage(id) {
+ let msgIdentifier = this._messages.get(id);
+ if (!msgIdentifier) {
+ return null;
+ }
+
+ if (msgIdentifier.folderURI) {
+ let folder = MailServices.folderLookup.getFolderForURL(
+ msgIdentifier.folderURI
+ );
+ if (folder) {
+ let msgHdr = folder.msgDatabase.getMsgHdrForKey(
+ msgIdentifier.messageKey
+ );
+ if (msgHdr) {
+ return msgHdr;
+ }
+ }
+ } else {
+ let msgHdr = this._dummyMessageHeaders.get(msgIdentifier.dummyMsgUrl);
+ if (msgHdr && !this.isModifiedFileMsg(msgIdentifier)) {
+ return msgHdr;
+ }
+ }
+
+ this._remove(msgIdentifier);
+ return null;
+ }
+
+ // nsIFolderListener
+
+ onFolderPropertyFlagChanged(item, property, oldFlag, newFlag) {
+ let changes = {};
+ switch (property) {
+ case "Status":
+ if ((oldFlag ^ newFlag) & Ci.nsMsgMessageFlags.Read) {
+ changes.read = item.isRead;
+ }
+ if ((oldFlag ^ newFlag) & Ci.nsMsgMessageFlags.New) {
+ changes.new = !!(newFlag & Ci.nsMsgMessageFlags.New);
+ }
+ break;
+ case "Flagged":
+ changes.flagged = item.isFlagged;
+ break;
+ case "Keywords":
+ {
+ let tags = item.getStringProperty("keywords");
+ tags = tags ? tags.split(" ") : [];
+ changes.tags = tags.filter(MailServices.tags.isValidKey);
+ }
+ break;
+ }
+ if (Object.keys(changes).length) {
+ this.emit("message-updated", item, changes);
+ }
+ }
+
+ onFolderIntPropertyChanged(folder, property, oldValue, newValue) {
+ switch (property) {
+ case "BiffState":
+ if (newValue == Ci.nsIMsgFolder.nsMsgBiffState_NewMail) {
+ // The folder argument is a root folder.
+ this.findNewMessages(folder);
+ }
+ break;
+ case "NewMailReceived":
+ // The folder argument is a real folder.
+ this.findNewMessages(folder);
+ break;
+ }
+ }
+
+ /**
+ * Finds all folders with new messages in the specified changedFolder and
+ * returns those.
+ *
+ * @see MailNotificationManager._getFirstRealFolderWithNewMail()
+ */
+ findNewMessages(changedFolder) {
+ let folders = changedFolder.descendants;
+ folders.unshift(changedFolder);
+ for (let folder of folders) {
+ let flags = folder.flags;
+ if (
+ !(flags & Ci.nsMsgFolderFlags.Inbox) &&
+ flags & (Ci.nsMsgFolderFlags.SpecialUse | Ci.nsMsgFolderFlags.Virtual)
+ ) {
+ // Do not notify if the folder is not Inbox but one of
+ // Drafts|Trash|SentMail|Templates|Junk|Archive|Queue or Virtual.
+ continue;
+ }
+ let numNewMessages = folder.getNumNewMessages(false);
+ if (!numNewMessages) {
+ continue;
+ }
+ let msgDb = folder.msgDatabase;
+ let newMsgKeys = msgDb.getNewList().slice(-numNewMessages);
+ if (newMsgKeys.length == 0) {
+ continue;
+ }
+ this.emit(
+ "messages-received",
+ folder,
+ newMsgKeys.map(key => msgDb.getMsgHdrForKey(key))
+ );
+ }
+ }
+
+ // nsIMsgFolderListener
+
+ msgsJunkStatusChanged(messages) {
+ for (let msgHdr of messages) {
+ let junkScore = parseInt(msgHdr.getStringProperty("junkscore"), 10) || 0;
+ this.emit("message-updated", msgHdr, {
+ junk: junkScore >= gJunkThreshold,
+ });
+ }
+ }
+
+ msgsDeleted(deletedMsgs) {
+ if (deletedMsgs.length > 0) {
+ this.emit("messages-deleted", deletedMsgs);
+ }
+ }
+
+ msgsMoveCopyCompleted(move, srcMsgs, dstFolder, dstMsgs) {
+ if (srcMsgs.length > 0 && dstMsgs.length > 0) {
+ let emitMsg = move ? "messages-moved" : "messages-copied";
+ this.emit(emitMsg, srcMsgs, dstMsgs);
+ }
+ }
+
+ msgKeyChanged(oldKey, newMsgHdr) {
+ // For IMAP messages there is a delayed update of database keys and if those
+ // keys change, the messageTracker needs to update its maps, otherwise wrong
+ // messages will be returned. Key changes are replayed in multi-step swaps.
+ let newKey = newMsgHdr.messageKey;
+
+ // Replay pending swaps.
+ while (this._pendingKeyChanges.has(oldKey)) {
+ let next = this._pendingKeyChanges.get(oldKey);
+ this._pendingKeyChanges.delete(oldKey);
+ oldKey = next;
+
+ // Check if we are left with a no-op swap and exit early.
+ if (oldKey == newKey) {
+ this._pendingKeyChanges.delete(oldKey);
+ return;
+ }
+ }
+
+ if (oldKey != newKey) {
+ // New key swap, log the mirror swap as pending.
+ this._pendingKeyChanges.set(newKey, oldKey);
+
+ // Swap tracker entries.
+ let oldId = this._get({
+ folderURI: newMsgHdr.folder.URI,
+ messageKey: oldKey,
+ });
+ let newId = this._get({
+ folderURI: newMsgHdr.folder.URI,
+ messageKey: newKey,
+ });
+ this._set(oldId, { folderURI: newMsgHdr.folder.URI, messageKey: newKey });
+ this._set(newId, { folderURI: newMsgHdr.folder.URI, messageKey: oldKey });
+ }
+ }
+
+ // nsIObserver
+
+ /**
+ * Observer to update message tracker if a message has received a new key due
+ * to attachments being removed, which we do not consider to be a new message.
+ */
+ observe(subject, topic, data) {
+ if (topic == "attachment-delete-msgkey-changed") {
+ data = JSON.parse(data);
+
+ if (data && data.folderURI && data.oldMessageKey && data.newMessageKey) {
+ let id = this._get({
+ folderURI: data.folderURI,
+ messageKey: data.oldMessageKey,
+ });
+ if (id) {
+ // Replace tracker entries.
+ this._set(id, {
+ folderURI: data.folderURI,
+ messageKey: data.newMessageKey,
+ });
+ }
+ }
+ } else if (topic == "quit-application-granted") {
+ this.cleanup();
+ }
+ }
+})();
+
+/**
+ * Tracks lists of messages so that an extension can consume them in chunks.
+ * Any WebExtensions method that could return multiple messages should instead call
+ * messageListTracker.startList and return the results, which contain the first
+ * chunk. Further chunks can be fetched by the extension calling
+ * browser.messages.continueList. Chunk size is controlled by a pref.
+ */
+var messageListTracker = {
+ _contextLists: new WeakMap(),
+
+ /**
+ * Takes an array or enumerator of messages and returns the first chunk.
+ *
+ * @returns {object}
+ */
+ startList(messages, extension) {
+ let messageList = this.createList(extension);
+ if (Array.isArray(messages)) {
+ messages = this._createEnumerator(messages);
+ }
+ while (messages.hasMoreElements()) {
+ let next = messages.getNext();
+ messageList.add(next.QueryInterface(Ci.nsIMsgDBHdr));
+ }
+ messageList.done();
+ return this.getNextPage(messageList);
+ },
+
+ _createEnumerator(array) {
+ let current = 0;
+ return {
+ hasMoreElements() {
+ return current < array.length;
+ },
+ getNext() {
+ return array[current++];
+ },
+ };
+ },
+
+ /**
+ * Creates and returns a new messageList object.
+ *
+ * @returns {object}
+ */
+ createList(extension) {
+ let messageListId = Services.uuid.generateUUID().number.substring(1, 37);
+ let messageList = this._createListObject(messageListId, extension);
+ let lists = this._contextLists.get(extension);
+ if (!lists) {
+ lists = new Map();
+ this._contextLists.set(extension, lists);
+ }
+ lists.set(messageListId, messageList);
+ return messageList;
+ },
+
+ /**
+ * Returns the messageList object for a given id.
+ *
+ * @returns {object}
+ */
+ getList(messageListId, extension) {
+ let lists = this._contextLists.get(extension);
+ let messageList = lists ? lists.get(messageListId, null) : null;
+ if (!messageList) {
+ throw new ExtensionError(
+ `No message list for id ${messageListId}. Have you reached the end of a list?`
+ );
+ }
+ return messageList;
+ },
+
+ /**
+ * Returns the first/next message page of the given messageList.
+ *
+ * @returns {object}
+ */
+ async getNextPage(messageList) {
+ let messageListId = messageList.id;
+ let messages = await messageList.getNextPage();
+ if (!messageList.hasMorePages()) {
+ let lists = this._contextLists.get(messageList.extension);
+ if (lists && lists.has(messageListId)) {
+ lists.delete(messageListId);
+ }
+ messageListId = null;
+ }
+ return {
+ id: messageListId,
+ messages,
+ };
+ },
+
+ _createListObject(messageListId, extension) {
+ function getCurrentPage() {
+ return pages.length > 0 ? pages[pages.length - 1] : null;
+ }
+
+ function addPage() {
+ let contents = getCurrentPage();
+ let resolvePage = currentPageResolveCallback;
+
+ pages.push([]);
+ pagePromises.push(
+ new Promise(resolve => {
+ currentPageResolveCallback = resolve;
+ })
+ );
+
+ if (contents && resolvePage) {
+ resolvePage(contents);
+ }
+ }
+
+ let _messageListId = messageListId;
+ let _extension = extension;
+ let isDone = false;
+ let pages = [];
+ let pagePromises = [];
+ let currentPageResolveCallback = null;
+ let readIndex = 0;
+
+ // Add first page.
+ addPage();
+
+ return {
+ get id() {
+ return _messageListId;
+ },
+ get extension() {
+ return _extension;
+ },
+ add(message) {
+ if (isDone) {
+ return;
+ }
+ if (getCurrentPage().length >= gMessagesPerPage) {
+ addPage();
+ }
+ getCurrentPage().push(convertMessage(message, _extension));
+ },
+ done() {
+ if (isDone) {
+ return;
+ }
+ isDone = true;
+ currentPageResolveCallback(getCurrentPage());
+ },
+ hasMorePages() {
+ return readIndex < pages.length;
+ },
+ async getNextPage() {
+ if (readIndex >= pages.length) {
+ return null;
+ }
+ const pageContent = await pagePromises[readIndex];
+ // Increment readIndex only after pagePromise has resolved, so multiple
+ // calls to getNextPage get the same page.
+ readIndex++;
+ return pageContent;
+ },
+ };
+ },
+};
+
+class MessageManager {
+ constructor(extension) {
+ this.extension = extension;
+ }
+
+ convert(msgHdr) {
+ return convertMessage(msgHdr, this.extension);
+ }
+
+ get(id) {
+ return messageTracker.getMessage(id);
+ }
+
+ startMessageList(messageList) {
+ return messageListTracker.startList(messageList, this.extension);
+ }
+}
+
+extensions.on("startup", (type, extension) => {
+ // eslint-disable-line mozilla/balanced-listeners
+ if (extension.hasPermission("accountsRead")) {
+ defineLazyGetter(
+ extension,
+ "folderManager",
+ () => new FolderManager(extension)
+ );
+ }
+ if (extension.hasPermission("addressBooks")) {
+ defineLazyGetter(extension, "addressBookManager", () => {
+ if (!("addressBookCache" in this)) {
+ extensions.loadModule("addressBook");
+ }
+ return {
+ findAddressBookById: this.addressBookCache.findAddressBookById.bind(
+ this.addressBookCache
+ ),
+ findContactById: this.addressBookCache.findContactById.bind(
+ this.addressBookCache
+ ),
+ findMailingListById: this.addressBookCache.findMailingListById.bind(
+ this.addressBookCache
+ ),
+ convert: this.addressBookCache.convert.bind(this.addressBookCache),
+ };
+ });
+ }
+ if (extension.hasPermission("messagesRead")) {
+ defineLazyGetter(
+ extension,
+ "messageManager",
+ () => new MessageManager(extension)
+ );
+ }
+ defineLazyGetter(extension, "tabManager", () => new TabManager(extension));
+ defineLazyGetter(
+ extension,
+ "windowManager",
+ () => new WindowManager(extension)
+ );
+});
diff --git a/comm/mail/components/extensions/parent/ext-mailTabs.js b/comm/mail/components/extensions/parent/ext-mailTabs.js
new file mode 100644
index 0000000000..9cf0bc0844
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-mailTabs.js
@@ -0,0 +1,485 @@
+/* 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 { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ QuickFilterManager: "resource:///modules/QuickFilterManager.jsm",
+ MailServices: "resource:///modules/MailServices.jsm",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gDynamicPaneConfig",
+ "mail.pane_config.dynamic",
+ 0
+);
+
+const LAYOUTS = ["standard", "wide", "vertical"];
+// From nsIMsgDBView.idl
+const SORT_TYPE_MAP = new Map(
+ Object.keys(Ci.nsMsgViewSortType).map(key => {
+ // Change "byFoo" to "foo".
+ let shortKey = key[2].toLowerCase() + key.substring(3);
+ return [Ci.nsMsgViewSortType[key], shortKey];
+ })
+);
+const SORT_ORDER_MAP = new Map(
+ Object.keys(Ci.nsMsgViewSortOrder).map(key => [
+ Ci.nsMsgViewSortOrder[key],
+ key,
+ ])
+);
+
+/**
+ * Converts a mail tab to a simple object for use in messages.
+ *
+ * @returns {object}
+ */
+function convertMailTab(tab, context) {
+ let mailTabObject = {
+ id: tab.id,
+ windowId: tab.windowId,
+ active: tab.active,
+ sortType: null,
+ sortOrder: null,
+ viewType: null,
+ layout: LAYOUTS[gDynamicPaneConfig],
+ folderPaneVisible: null,
+ messagePaneVisible: null,
+ };
+
+ let about3Pane = tab.nativeTab.chromeBrowser.contentWindow;
+ let { gViewWrapper, paneLayout } = about3Pane;
+ mailTabObject.folderPaneVisible = paneLayout.folderPaneVisible;
+ mailTabObject.messagePaneVisible = paneLayout.messagePaneVisible;
+ mailTabObject.sortType = SORT_TYPE_MAP.get(gViewWrapper?.primarySortType);
+ mailTabObject.sortOrder = SORT_ORDER_MAP.get(gViewWrapper?.primarySortOrder);
+ if (gViewWrapper?.showGroupedBySort) {
+ mailTabObject.viewType = "groupedBySortType";
+ } else if (gViewWrapper?.showThreaded) {
+ mailTabObject.viewType = "groupedByThread";
+ } else {
+ mailTabObject.viewType = "ungrouped";
+ }
+ if (context.extension.hasPermission("accountsRead")) {
+ mailTabObject.displayedFolder = convertFolder(about3Pane.gFolder);
+ }
+ return mailTabObject;
+}
+
+/**
+ * Listens for changes in the UI to fire events.
+ */
+var uiListener = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.handleEvent = this.handleEvent.bind(this);
+ this.lastSelected = new WeakMap();
+ }
+
+ handleEvent(event) {
+ let browser = event.target.browsingContext.embedderElement;
+ let tabmail = browser.ownerGlobal.top.document.getElementById("tabmail");
+ let nativeTab = tabmail.tabInfo.find(
+ t =>
+ t.chromeBrowser == browser ||
+ t.chromeBrowser == browser.browsingContext.parent.embedderElement
+ );
+
+ if (nativeTab.mode.name != "mail3PaneTab") {
+ return;
+ }
+
+ let tabId = tabTracker.getId(nativeTab);
+ let tab = tabTracker.getTab(tabId);
+
+ if (event.type == "folderURIChanged") {
+ let folderURI = event.detail;
+ let folder = MailServices.folderLookup.getFolderForURL(folderURI);
+ if (this.lastSelected.get(tab) == folder) {
+ return;
+ }
+ this.lastSelected.set(tab, folder);
+ this.emit("folder-changed", tab, folder);
+ } else if (event.type == "messageURIChanged") {
+ let messages =
+ nativeTab.chromeBrowser.contentWindow.gDBView?.getSelectedMsgHdrs();
+ if (messages) {
+ this.emit("messages-changed", tab, messages);
+ }
+ }
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ windowTracker.addListener("folderURIChanged", this);
+ windowTracker.addListener("messageURIChanged", this);
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ windowTracker.removeListener("folderURIChanged", this);
+ windowTracker.removeListener("messageURIChanged", this);
+ this.lastSelected = new WeakMap();
+ }
+ }
+})();
+
+this.mailTabs = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onDisplayedFolderChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event, tab, folder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(tabManager.convert(tab), convertFolder(folder));
+ }
+ uiListener.on("folder-changed", listener);
+ uiListener.incrementListeners();
+ return {
+ unregister: () => {
+ uiListener.off("folder-changed", listener);
+ uiListener.decrementListeners();
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onSelectedMessagesChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event, tab, messages) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let page = await messageListTracker.startList(messages, extension);
+ fire.sync(tabManager.convert(tab), page);
+ }
+ uiListener.on("messages-changed", listener);
+ uiListener.incrementListeners();
+ return {
+ unregister: () => {
+ uiListener.off("messages-changed", listener);
+ uiListener.decrementListeners();
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ /**
+ * Gets the tab for the given tab id, or the active tab if the id is null.
+ *
+ * @param {?Integer} tabId - The tab id to get
+ * @returns {Tab} The matching tab, or the active tab
+ */
+ async function getTabOrActive(tabId) {
+ let tab;
+ if (tabId) {
+ tab = tabManager.get(tabId);
+ } else {
+ tab = tabManager.wrapTab(tabTracker.activeTab);
+ tabId = tab.id;
+ }
+
+ if (tab && tab.type == "mail") {
+ let windowId = windowTracker.getId(getTabWindow(tab.nativeTab));
+ // Before doing anything with the mail tab, ensure its outer window is
+ // fully loaded.
+ await getNormalWindowReady(context, windowId);
+ return tab;
+ }
+ throw new ExtensionError(`Invalid mail tab ID: ${tabId}`);
+ }
+
+ /**
+ * Set the currently displayed folder in the given tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo
+ * @param {nsIMsgFolder} folder
+ * @param {boolean} restorePreviousSelection - Select the previously selected
+ * messages of the folder, after it has been set.
+ */
+ async function setFolder(nativeTabInfo, folder, restorePreviousSelection) {
+ let about3Pane = nativeTabInfo.chromeBrowser.contentWindow;
+ if (!nativeTabInfo.folder || nativeTabInfo.folder.URI != folder.URI) {
+ await new Promise(resolve => {
+ let listener = event => {
+ if (event.detail == folder.URI) {
+ about3Pane.removeEventListener("folderURIChanged", listener);
+ resolve();
+ }
+ };
+ about3Pane.addEventListener("folderURIChanged", listener);
+ if (restorePreviousSelection) {
+ about3Pane.restoreState({
+ folderURI: folder.URI,
+ });
+ } else {
+ about3Pane.threadPane.forgetSelection(folder.URI);
+ nativeTabInfo.folder = folder;
+ }
+ });
+ }
+ }
+
+ return {
+ mailTabs: {
+ async query({ active, currentWindow, lastFocusedWindow, windowId }) {
+ await getNormalWindowReady();
+ return Array.from(
+ tabManager.query(
+ {
+ active,
+ currentWindow,
+ lastFocusedWindow,
+ mailTab: true,
+ windowId,
+
+ // All of these are needed for tabManager to return every tab we want.
+ cookieStoreId: null,
+ index: null,
+ screen: null,
+ title: null,
+ url: null,
+ windowType: null,
+ },
+ context
+ ),
+ tab => convertMailTab(tab, context)
+ );
+ },
+
+ async get(tabId) {
+ let tab = await getTabOrActive(tabId);
+ return convertMailTab(tab, context);
+ },
+ async getCurrent() {
+ try {
+ let tab = await getTabOrActive();
+ return convertMailTab(tab, context);
+ } catch (e) {
+ // Do not throw, if the active tab is not a mail tab, but return undefined.
+ return undefined;
+ }
+ },
+
+ async update(tabId, args) {
+ let tab = await getTabOrActive(tabId);
+ let { nativeTab } = tab;
+ let about3Pane = nativeTab.chromeBrowser.contentWindow;
+
+ let {
+ displayedFolder,
+ layout,
+ folderPaneVisible,
+ messagePaneVisible,
+ sortOrder,
+ sortType,
+ viewType,
+ } = args;
+
+ if (displayedFolder) {
+ if (!extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Updating the displayed folder requires the "accountsRead" permission'
+ );
+ }
+
+ let folderUri = folderPathToURI(
+ displayedFolder.accountId,
+ displayedFolder.path
+ );
+ let folder = MailServices.folderLookup.getFolderForURL(folderUri);
+ if (!folder) {
+ throw new ExtensionError(
+ `Folder "${displayedFolder.path}" for account ` +
+ `"${displayedFolder.accountId}" not found.`
+ );
+ }
+ await setFolder(nativeTab, folder, true);
+ }
+
+ if (sortType) {
+ // Change "foo" to "byFoo".
+ sortType = "by" + sortType[0].toUpperCase() + sortType.substring(1);
+ if (
+ sortType in Ci.nsMsgViewSortType &&
+ sortOrder &&
+ sortOrder in Ci.nsMsgViewSortOrder
+ ) {
+ about3Pane.gViewWrapper.sort(
+ Ci.nsMsgViewSortType[sortType],
+ Ci.nsMsgViewSortOrder[sortOrder]
+ );
+ }
+ }
+
+ switch (viewType) {
+ case "groupedBySortType":
+ about3Pane.gViewWrapper.showGroupedBySort = true;
+ break;
+ case "groupedByThread":
+ about3Pane.gViewWrapper.showThreaded = true;
+ break;
+ case "ungrouped":
+ about3Pane.gViewWrapper.showUnthreaded = true;
+ break;
+ }
+
+ // Layout applies to all folder tabs.
+ if (layout) {
+ Services.prefs.setIntPref(
+ "mail.pane_config.dynamic",
+ LAYOUTS.indexOf(layout)
+ );
+ }
+
+ if (typeof folderPaneVisible == "boolean") {
+ about3Pane.paneLayout.folderPaneVisible = folderPaneVisible;
+ }
+ if (typeof messagePaneVisible == "boolean") {
+ about3Pane.paneLayout.messagePaneVisible = messagePaneVisible;
+ }
+ },
+
+ async getSelectedMessages(tabId) {
+ let tab = await getTabOrActive(tabId);
+ let dbView = tab.nativeTab.chromeBrowser.contentWindow?.gDBView;
+ let messageList = dbView ? dbView.getSelectedMsgHdrs() : [];
+ return messageListTracker.startList(messageList, extension);
+ },
+
+ async setSelectedMessages(tabId, messageIds) {
+ if (
+ !extension.hasPermission("messagesRead") ||
+ !extension.hasPermission("accountsRead")
+ ) {
+ throw new ExtensionError(
+ 'Using mailTabs.setSelectedMessages() requires the "accountsRead" and the "messagesRead" permission'
+ );
+ }
+
+ let tab = await getTabOrActive(tabId);
+ let refFolder, refMsgId;
+ let msgHdrs = [];
+ for (let messageId of messageIds) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!refFolder) {
+ refFolder = msgHdr.folder;
+ refMsgId = messageId;
+ }
+ if (msgHdr.folder == refFolder) {
+ msgHdrs.push(msgHdr);
+ } else {
+ throw new ExtensionError(
+ `Message ${refMsgId} and message ${messageId} are not in the same folder, cannot select them both.`
+ );
+ }
+ }
+
+ if (refFolder) {
+ await setFolder(tab.nativeTab, refFolder, false);
+ }
+ let about3Pane = tab.nativeTab.chromeBrowser.contentWindow;
+ const selectedIndices = msgHdrs.map(
+ about3Pane.gViewWrapper.getViewIndexForMsgHdr,
+ about3Pane.gViewWrapper
+ );
+ about3Pane.threadTree.selectedIndices = selectedIndices;
+ if (selectedIndices.length) {
+ about3Pane.threadTree.scrollToIndex(selectedIndices[0], true);
+ }
+ },
+
+ async setQuickFilter(tabId, state) {
+ let tab = await getTabOrActive(tabId);
+ let nativeTab = tab.nativeTab;
+ let about3Pane = nativeTab.chromeBrowser.contentWindow;
+
+ let filterer = about3Pane.quickFilterBar.filterer;
+ filterer.clear();
+
+ // Map of QuickFilter state names to possible WebExtensions state names.
+ let stateMap = {
+ unread: "unread",
+ starred: "flagged",
+ addrBook: "contact",
+ attachment: "attachment",
+ };
+
+ filterer.visible = state.show !== false;
+ for (let [key, name] of Object.entries(stateMap)) {
+ filterer.setFilterValue(key, state[name]);
+ }
+
+ if (state.tags) {
+ filterer.filterValues.tags = {
+ mode: "OR",
+ tags: {},
+ };
+ for (let tag of MailServices.tags.getAllTags()) {
+ filterer.filterValues.tags[tag.key] = null;
+ }
+ if (typeof state.tags == "object") {
+ filterer.filterValues.tags.mode =
+ state.tags.mode == "any" ? "OR" : "AND";
+ for (let [key, value] of Object.entries(state.tags.tags)) {
+ filterer.filterValues.tags.tags[key] = value;
+ }
+ }
+ }
+ if (state.text) {
+ filterer.filterValues.text = {
+ states: {
+ recipients: state.text.recipients || false,
+ sender: state.text.author || false,
+ subject: state.text.subject || false,
+ body: state.text.body || false,
+ },
+ text: state.text.text,
+ };
+ }
+
+ about3Pane.quickFilterBar.updateSearch();
+ },
+
+ onDisplayedFolderChanged: new EventManager({
+ context,
+ module: "mailTabs",
+ event: "onDisplayedFolderChanged",
+ extensionApi: this,
+ }).api(),
+
+ onSelectedMessagesChanged: new EventManager({
+ context,
+ module: "mailTabs",
+ event: "onSelectedMessagesChanged",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-menus.js b/comm/mail/components/extensions/parent/ext-menus.js
new file mode 100644
index 0000000000..0db7ddf809
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-menus.js
@@ -0,0 +1,1544 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { SelectionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/SelectionUtils.sys.mjs"
+);
+
+var { DefaultMap, ExtensionError } = ExtensionUtils;
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { IconDetails, StartupCache } = ExtensionParent;
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+const ACTION_MENU_TOP_LEVEL_LIMIT = 6;
+
+// Map[Extension -> Map[ID -> MenuItem]]
+// Note: we want to enumerate all the menu items so
+// this cannot be a weak map.
+var gMenuMap = new Map();
+
+// Map[Extension -> Map[ID -> MenuCreateProperties]]
+// The map object for each extension is a reference to the same
+// object in StartupCache.menus. This provides a non-async
+// getter for that object.
+var gStartupCache = new Map();
+
+// Map[Extension -> MenuItem]
+var gRootItems = new Map();
+
+// Map[Extension -> ID[]]
+// Menu IDs that were eligible for being shown in the current menu.
+var gShownMenuItems = new DefaultMap(() => []);
+
+// Map[Extension -> Set[Contexts]]
+// A DefaultMap (keyed by extension) which keeps track of the
+// contexts with a subscribed onShown event listener.
+var gOnShownSubscribers = new DefaultMap(() => new Set());
+
+// If id is not specified for an item we use an integer.
+var gNextMenuItemID = 0;
+
+// Used to assign unique names to radio groups.
+var gNextRadioGroupID = 0;
+
+// The max length of a menu item's label.
+var gMaxLabelLength = 64;
+
+var gMenuBuilder = {
+ // When a new menu is opened, this function is called and
+ // we populate the |xulMenu| with all the items from extensions
+ // to be displayed. We always clear all the items again when
+ // popuphidden fires.
+ build(contextData) {
+ contextData = this.maybeOverrideContextData(contextData);
+ let xulMenu = contextData.menu;
+ xulMenu.addEventListener("popuphidden", this);
+ this.xulMenu = xulMenu;
+ for (let [, root] of gRootItems) {
+ this.createAndInsertTopLevelElements(root, contextData, null);
+ }
+ this.afterBuildingMenu(contextData);
+
+ if (
+ contextData.webExtContextData &&
+ !contextData.webExtContextData.showDefaults
+ ) {
+ // Wait until nsContextMenu.js has toggled the visibility of the default
+ // menu items before hiding the default items.
+ Promise.resolve().then(() => this.hideDefaultMenuItems());
+ }
+ },
+
+ maybeOverrideContextData(contextData) {
+ let { webExtContextData } = contextData;
+ if (!webExtContextData || !webExtContextData.overrideContext) {
+ return contextData;
+ }
+ let contextDataBase = {
+ menu: contextData.menu,
+ // eslint-disable-next-line no-use-before-define
+ originalViewType: getContextViewType(contextData),
+ originalViewUrl: contextData.inFrame
+ ? contextData.frameUrl
+ : contextData.pageUrl,
+ webExtContextData,
+ };
+ if (webExtContextData.overrideContext === "tab") {
+ // TODO: Handle invalid tabs more gracefully (instead of throwing).
+ let tab = tabTracker.getTab(webExtContextData.tabId);
+ return {
+ ...contextDataBase,
+ tab,
+ pageUrl: tab.linkedBrowser?.currentURI?.spec,
+ onTab: true,
+ };
+ }
+ throw new ExtensionError(
+ `Unexpected overrideContext: ${webExtContextData.overrideContext}`
+ );
+ },
+
+ createAndInsertTopLevelElements(root, contextData, nextSibling) {
+ const newWebExtensionGroupSeparator = () => {
+ let element =
+ this.xulMenu.ownerDocument.createXULElement("menuseparator");
+ element.classList.add("webextension-group-separator");
+ return element;
+ };
+
+ let rootElements;
+ if (
+ contextData.onAction ||
+ contextData.onBrowserAction ||
+ contextData.onComposeAction ||
+ contextData.onMessageDisplayAction
+ ) {
+ if (contextData.extension.id !== root.extension.id) {
+ return;
+ }
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ ACTION_MENU_TOP_LEVEL_LIMIT,
+ false
+ );
+
+ // Action menu items are prepended to the menu, followed by a separator.
+ nextSibling = nextSibling || this.xulMenu.firstElementChild;
+ if (rootElements.length && !this.itemsToCleanUp.has(nextSibling)) {
+ rootElements.push(newWebExtensionGroupSeparator());
+ }
+ } else if (
+ contextData.inActionMenu ||
+ contextData.inBrowserActionMenu ||
+ contextData.inComposeActionMenu ||
+ contextData.inMessageDisplayActionMenu
+ ) {
+ if (contextData.extension.id !== root.extension.id) {
+ return;
+ }
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ Infinity,
+ false
+ );
+ } else if (contextData.webExtContextData) {
+ let { extensionId, showDefaults, overrideContext } =
+ contextData.webExtContextData;
+ if (extensionId === root.extension.id) {
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ Infinity,
+ false
+ );
+ // The extension menu should be rendered at the top, but after the navigation buttons.
+ nextSibling =
+ nextSibling || this.xulMenu.querySelector(":scope > :first-child");
+ if (
+ rootElements.length &&
+ showDefaults &&
+ !this.itemsToCleanUp.has(nextSibling)
+ ) {
+ rootElements.push(newWebExtensionGroupSeparator());
+ }
+ } else if (!showDefaults && !overrideContext) {
+ // When the default menu items should be hidden, menu items from other
+ // extensions should be hidden too.
+ return;
+ }
+ // Fall through to show default extension menu items.
+ }
+
+ if (!rootElements) {
+ rootElements = this.buildTopLevelElements(root, contextData, 1, true);
+ if (
+ rootElements.length &&
+ !this.itemsToCleanUp.has(this.xulMenu.lastElementChild) &&
+ this.xulMenu.firstChild
+ ) {
+ // All extension menu items are appended at the end.
+ // Prepend separator if this is the first extension menu item.
+ rootElements.unshift(newWebExtensionGroupSeparator());
+ }
+ }
+
+ if (!rootElements.length) {
+ return;
+ }
+
+ if (nextSibling) {
+ nextSibling.before(...rootElements);
+ } else {
+ this.xulMenu.append(...rootElements);
+ }
+ for (let item of rootElements) {
+ this.itemsToCleanUp.add(item);
+ }
+ },
+
+ buildElementWithChildren(item, contextData) {
+ const element = this.buildSingleElement(item, contextData);
+ const children = this.buildChildren(item, contextData);
+ if (children.length) {
+ element.firstElementChild.append(...children);
+ }
+ return element;
+ },
+
+ buildChildren(item, contextData) {
+ let groupName;
+ let children = [];
+ for (let child of item.children) {
+ if (child.type == "radio" && !child.groupName) {
+ if (!groupName) {
+ groupName = `webext-radio-group-${gNextRadioGroupID++}`;
+ }
+ child.groupName = groupName;
+ } else {
+ groupName = null;
+ }
+
+ if (child.enabledForContext(contextData)) {
+ children.push(this.buildElementWithChildren(child, contextData));
+ }
+ }
+ return children;
+ },
+
+ buildTopLevelElements(root, contextData, maxCount, forceManifestIcons) {
+ let children = this.buildChildren(root, contextData);
+
+ // TODO: Fix bug 1492969 and remove this whole if block.
+ if (
+ children.length === 1 &&
+ maxCount === 1 &&
+ forceManifestIcons &&
+ AppConstants.platform === "linux" &&
+ children[0].getAttribute("type") === "checkbox"
+ ) {
+ // Keep single checkbox items in the submenu on Linux since
+ // the extension icon overlaps the checkbox otherwise.
+ maxCount = 0;
+ }
+
+ if (children.length > maxCount) {
+ // Move excess items into submenu.
+ let rootElement = this.buildSingleElement(root, contextData);
+ rootElement.setAttribute("ext-type", "top-level-menu");
+ rootElement.firstElementChild.append(...children.splice(maxCount - 1));
+ children.push(rootElement);
+ }
+
+ if (forceManifestIcons) {
+ for (let rootElement of children) {
+ // Display the extension icon on the root element.
+ if (
+ root.extension.manifest.icons &&
+ rootElement.getAttribute("type") !== "checkbox"
+ ) {
+ this.setMenuItemIcon(
+ rootElement,
+ root.extension,
+ contextData,
+ root.extension.manifest.icons
+ );
+ } else {
+ this.removeMenuItemIcon(rootElement);
+ }
+ }
+ }
+ return children;
+ },
+
+ removeSeparatorIfNoTopLevelItems() {
+ // Extension menu items always have have a non-empty ID.
+ let isNonExtensionSeparator = item =>
+ item.nodeName === "menuseparator" && !item.id;
+
+ // itemsToCleanUp contains all top-level menu items. A separator should
+ // only be kept if it is next to an extension menu item.
+ let isExtensionMenuItemSibling = item =>
+ item && this.itemsToCleanUp.has(item) && !isNonExtensionSeparator(item);
+
+ for (let item of this.itemsToCleanUp) {
+ if (isNonExtensionSeparator(item)) {
+ if (
+ !isExtensionMenuItemSibling(item.previousElementSibling) &&
+ !isExtensionMenuItemSibling(item.nextElementSibling)
+ ) {
+ item.remove();
+ this.itemsToCleanUp.delete(item);
+ }
+ }
+ }
+ },
+
+ buildSingleElement(item, contextData) {
+ let doc = contextData.menu.ownerDocument;
+ let element;
+ if (item.children.length) {
+ element = this.createMenuElement(doc, item);
+ } else if (item.type == "separator") {
+ element = doc.createXULElement("menuseparator");
+ } else {
+ element = doc.createXULElement("menuitem");
+ }
+
+ return this.customizeElement(element, item, contextData);
+ },
+
+ createMenuElement(doc, item) {
+ let element = doc.createXULElement("menu");
+ // Menu elements need to have a menupopup child for its menu items.
+ let menupopup = doc.createXULElement("menupopup");
+ element.appendChild(menupopup);
+ return element;
+ },
+
+ customizeElement(element, item, contextData) {
+ let label = item.title;
+ if (label) {
+ let accessKey;
+ label = label.replace(/&([\S\s]|$)/g, (_, nextChar, i) => {
+ if (nextChar === "&") {
+ return "&";
+ }
+ if (accessKey === undefined) {
+ if (nextChar === "%" && label.charAt(i + 2) === "s") {
+ accessKey = "";
+ } else {
+ accessKey = nextChar;
+ }
+ }
+ return nextChar;
+ });
+ element.setAttribute("accesskey", accessKey || "");
+
+ if (contextData.isTextSelected && label.includes("%s")) {
+ let selection = contextData.selectionText.trim();
+ // The rendering engine will truncate the title if it's longer than 64 characters.
+ // But if it makes sense let's try truncate selection text only, to handle cases like
+ // 'look up "%s" in MyDictionary' more elegantly.
+
+ let codePointsToRemove = 0;
+
+ let selectionArray = Array.from(selection);
+
+ let completeLabelLength = label.length - 2 + selectionArray.length;
+ if (completeLabelLength > gMaxLabelLength) {
+ codePointsToRemove = completeLabelLength - gMaxLabelLength;
+ }
+
+ if (codePointsToRemove) {
+ let ellipsis = "\u2026";
+ try {
+ ellipsis = Services.prefs.getComplexValue(
+ "intl.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+ codePointsToRemove += 1;
+ selection =
+ selectionArray.slice(0, -codePointsToRemove).join("") + ellipsis;
+ }
+
+ label = label.replace(/%s/g, selection);
+ }
+
+ element.setAttribute("label", label);
+ }
+
+ element.setAttribute("id", item.elementId);
+
+ if ("icons" in item) {
+ if (item.icons) {
+ this.setMenuItemIcon(element, item.extension, contextData, item.icons);
+ } else {
+ this.removeMenuItemIcon(element);
+ }
+ }
+
+ if (item.type == "checkbox") {
+ element.setAttribute("type", "checkbox");
+ if (item.checked) {
+ element.setAttribute("checked", "true");
+ }
+ } else if (item.type == "radio") {
+ element.setAttribute("type", "radio");
+ element.setAttribute("name", item.groupName);
+ if (item.checked) {
+ element.setAttribute("checked", "true");
+ }
+ }
+
+ if (!item.enabled) {
+ element.setAttribute("disabled", "true");
+ }
+
+ let button;
+
+ element.addEventListener(
+ "command",
+ async event => {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+ const wasChecked = item.checked;
+ if (item.type == "checkbox") {
+ item.checked = !item.checked;
+ } else if (item.type == "radio") {
+ // Deselect all radio items in the current radio group.
+ for (let child of item.parent.children) {
+ if (child.type == "radio" && child.groupName == item.groupName) {
+ child.checked = false;
+ }
+ }
+ // Select the clicked radio item.
+ item.checked = true;
+ }
+
+ let { webExtContextData } = contextData;
+ if (
+ contextData.tab &&
+ // If the menu context was overridden by the extension, do not grant
+ // activeTab since the extension also controls the tabId.
+ (!webExtContextData ||
+ webExtContextData.extensionId !== item.extension.id)
+ ) {
+ item.tabManager.addActiveTabPermission(contextData.tab);
+ }
+
+ let info = await item.getClickInfo(contextData, wasChecked);
+ info.modifiers = clickModifiersFromEvent(event);
+
+ info.button = button;
+ let _execute_action =
+ item.extension.manifestVersion < 3
+ ? "_execute_browser_action"
+ : "_execute_action";
+
+ // Allow menus to open various actions supported in webext prior
+ // to notifying onclicked.
+ let actionFor = {
+ [_execute_action]: global.browserActionFor,
+ _execute_compose_action: global.composeActionFor,
+ _execute_message_display_action: global.messageDisplayActionFor,
+ }[item.command];
+ if (actionFor) {
+ let win = event.target.ownerGlobal;
+ actionFor(item.extension).triggerAction(win);
+ return;
+ }
+
+ item.extension.emit(
+ "webext-menu-menuitem-click",
+ info,
+ contextData.tab
+ );
+ },
+ { once: true }
+ );
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ element.addEventListener("click", event => {
+ if (
+ event.target !== event.currentTarget ||
+ // Ignore menu items that are usually not clickeable,
+ // such as separators and parents of submenus and disabled items.
+ element.localName !== "menuitem" ||
+ element.disabled
+ ) {
+ return;
+ }
+
+ button = event.button;
+ if (event.button) {
+ element.doCommand();
+ contextData.menu.hidePopup();
+ }
+ });
+
+ // Don't publish the ID of the root because the root element is
+ // auto-generated.
+ if (item.parent) {
+ gShownMenuItems.get(item.extension).push(item.id);
+ }
+
+ return element;
+ },
+
+ setMenuItemIcon(element, extension, contextData, icons) {
+ let parentWindow = contextData.menu.ownerGlobal;
+
+ let { icon } = IconDetails.getPreferredIcon(
+ icons,
+ extension,
+ 16 * parentWindow.devicePixelRatio
+ );
+
+ // The extension icons in the manifest are not pre-resolved, since
+ // they're sometimes used by the add-on manager when the extension is
+ // not enabled, and its URLs are not resolvable.
+ let resolvedURL = extension.baseURI.resolve(icon);
+
+ if (element.localName == "menu") {
+ element.setAttribute("class", "menu-iconic");
+ } else if (element.localName == "menuitem") {
+ element.setAttribute("class", "menuitem-iconic");
+ }
+
+ element.setAttribute("image", resolvedURL);
+ },
+
+ // Undo changes from setMenuItemIcon.
+ removeMenuItemIcon(element) {
+ element.removeAttribute("class");
+ element.removeAttribute("image");
+ },
+
+ rebuildMenu(extension) {
+ let { contextData } = this;
+ if (!contextData) {
+ // This happens if the menu is not visible.
+ return;
+ }
+
+ // Find the group of existing top-level items (usually 0 or 1 items)
+ // and remember its position for when the new items are inserted.
+ let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`;
+ let nextSibling = null;
+ for (let item of this.itemsToCleanUp) {
+ if (item.id && item.id.startsWith(elementIdPrefix)) {
+ nextSibling = item.nextSibling;
+ item.remove();
+ this.itemsToCleanUp.delete(item);
+ }
+ }
+
+ let root = gRootItems.get(extension);
+ if (root) {
+ this.createAndInsertTopLevelElements(root, contextData, nextSibling);
+ }
+ this.removeSeparatorIfNoTopLevelItems();
+ },
+
+ // This should be called once, after constructing the top-level menus, if any.
+ afterBuildingMenu(contextData) {
+ function dispatchOnShownEvent(extension) {
+ // Note: gShownMenuItems is a DefaultMap, so .get(extension) causes the
+ // extension to be stored in the map even if there are currently no
+ // shown menu items. This ensures that the onHidden event can be fired
+ // when the menu is closed.
+ let menuIds = gShownMenuItems.get(extension);
+ extension.emit("webext-menu-shown", menuIds, contextData);
+ }
+
+ if (
+ contextData.onAction ||
+ contextData.onBrowserAction ||
+ contextData.onComposeAction ||
+ contextData.onMessageDisplayAction
+ ) {
+ dispatchOnShownEvent(contextData.extension);
+ } else {
+ for (const extension of gOnShownSubscribers.keys()) {
+ dispatchOnShownEvent(extension);
+ }
+ }
+
+ this.contextData = contextData;
+ },
+
+ hideDefaultMenuItems() {
+ for (let item of this.xulMenu.children) {
+ if (!this.itemsToCleanUp.has(item)) {
+ item.hidden = true;
+ }
+ }
+ },
+
+ handleEvent(event) {
+ if (this.xulMenu != event.target || event.type != "popuphidden") {
+ return;
+ }
+
+ delete this.xulMenu;
+ delete this.contextData;
+
+ let target = event.target;
+ target.removeEventListener("popuphidden", this);
+ for (let item of this.itemsToCleanUp) {
+ item.remove();
+ }
+ this.itemsToCleanUp.clear();
+ for (let extension of gShownMenuItems.keys()) {
+ extension.emit("webext-menu-hidden");
+ }
+ gShownMenuItems.clear();
+ },
+
+ itemsToCleanUp: new Set(),
+};
+
+// Called from different action popups.
+global.actionContextMenu = function (contextData) {
+ contextData.originalViewType = "tab";
+ gMenuBuilder.build(contextData);
+};
+
+const contextsMap = {
+ onAudio: "audio",
+ onEditable: "editable",
+ inFrame: "frame",
+ onImage: "image",
+ onLink: "link",
+ onPassword: "password",
+ isTextSelected: "selection",
+ onVideo: "video",
+
+ onAction: "action",
+ onBrowserAction: "browser_action",
+ onComposeAction: "compose_action",
+ onMessageDisplayAction: "message_display_action",
+ inActionMenu: "action_menu",
+ inBrowserActionMenu: "browser_action_menu",
+ inComposeActionMenu: "compose_action_menu",
+ inMessageDisplayActionMenu: "message_display_action_menu",
+
+ onComposeBody: "compose_body",
+ onTab: "tab",
+ inToolsMenu: "tools_menu",
+ selectedMessages: "message_list",
+ selectedFolder: "folder_pane",
+ selectedComposeAttachments: "compose_attachments",
+ selectedMessageAttachments: "message_attachments",
+ allMessageAttachments: "all_message_attachments",
+};
+
+const chromeElementsMap = {
+ msgSubject: "composeSubject",
+ toAddrInput: "composeTo",
+ ccAddrInput: "composeCc",
+ bccAddrInput: "composeBcc",
+ replyAddrInput: "composeReplyTo",
+ newsgroupsAddrInput: "composeNewsgroupTo",
+ followupAddrInput: "composeFollowupTo",
+};
+
+const getMenuContexts = contextData => {
+ let contexts = new Set();
+
+ for (const [key, value] of Object.entries(contextsMap)) {
+ if (contextData[key]) {
+ contexts.add(value);
+ }
+ }
+
+ if (contexts.size === 0) {
+ contexts.add("page");
+ }
+
+ // New non-content contexts supported in Thunderbird are not part of "all".
+ if (!contextData.onTab && !contextData.inToolsMenu) {
+ contexts.add("all");
+ }
+
+ return contexts;
+};
+
+function getContextViewType(contextData) {
+ if ("originalViewType" in contextData) {
+ return contextData.originalViewType;
+ }
+ if (
+ contextData.webExtBrowserType === "popup" ||
+ contextData.webExtBrowserType === "sidebar"
+ ) {
+ return contextData.webExtBrowserType;
+ }
+ if (contextData.tab && contextData.menu.id === "browserContext") {
+ return "tab";
+ }
+ return undefined;
+}
+
+async function addMenuEventInfo(
+ info,
+ contextData,
+ extension,
+ includeSensitiveData
+) {
+ info.viewType = getContextViewType(contextData);
+ if (contextData.onVideo) {
+ info.mediaType = "video";
+ } else if (contextData.onAudio) {
+ info.mediaType = "audio";
+ } else if (contextData.onImage) {
+ info.mediaType = "image";
+ }
+ if (contextData.frameId !== undefined) {
+ info.frameId = contextData.frameId;
+ }
+ info.editable = contextData.onEditable || false;
+ if (includeSensitiveData) {
+ if (contextData.timeStamp) {
+ // Convert to integer, in case the DOMHighResTimeStamp has a fractional part.
+ info.targetElementId = Math.floor(contextData.timeStamp);
+ }
+ if (contextData.onLink) {
+ info.linkText = contextData.linkText;
+ info.linkUrl = contextData.linkUrl;
+ }
+ if (contextData.onAudio || contextData.onImage || contextData.onVideo) {
+ info.srcUrl = contextData.srcUrl;
+ }
+ info.pageUrl = contextData.pageUrl;
+ if (contextData.inFrame) {
+ info.frameUrl = contextData.frameUrl;
+ }
+ if (contextData.isTextSelected) {
+ info.selectionText = contextData.selectionText;
+ }
+ }
+ // If the context was overridden, then frameUrl should be the URL of the
+ // document in which the menu was opened (instead of undefined, even if that
+ // document is not in a frame).
+ if (contextData.originalViewUrl) {
+ info.frameUrl = contextData.originalViewUrl;
+ }
+
+ if (contextData.fieldId) {
+ info.fieldId = contextData.fieldId;
+ }
+
+ if (contextData.selectedMessages && extension.hasPermission("messagesRead")) {
+ info.selectedMessages = await messageListTracker.startList(
+ contextData.selectedMessages,
+ extension
+ );
+ }
+ if (extension.hasPermission("accountsRead")) {
+ for (let folderType of ["displayedFolder", "selectedFolder"]) {
+ if (contextData[folderType]) {
+ let folder = convertFolder(contextData[folderType]);
+ // If the context menu click in the folder pane occurred on a root folder
+ // representing an account, do not include a selectedFolder object, but
+ // the corresponding selectedAccount object.
+ if (folderType == "selectedFolder" && folder.path == "/") {
+ info.selectedAccount = convertAccount(
+ MailServices.accounts.getAccount(folder.accountId)
+ );
+ } else {
+ info[folderType] = traverseSubfolders(
+ contextData[folderType],
+ folder.accountId
+ );
+ }
+ }
+ }
+ }
+ if (
+ (contextData.selectedMessageAttachments ||
+ contextData.allMessageAttachments) &&
+ extension.hasPermission("messagesRead")
+ ) {
+ let attachments =
+ contextData.selectedMessageAttachments ||
+ contextData.allMessageAttachments;
+ info.attachments = attachments.map(attachment => {
+ return {
+ contentType: attachment.contentType,
+ name: attachment.name,
+ size: attachment.size,
+ partName: attachment.partID,
+ };
+ });
+ }
+ if (
+ contextData.selectedComposeAttachments &&
+ extension.hasPermission("compose")
+ ) {
+ if (!("composeAttachmentTracker" in global)) {
+ extensions.loadModule("compose");
+ }
+
+ info.attachments = contextData.selectedComposeAttachments.map(a =>
+ global.composeAttachmentTracker.convert(a, contextData.menu.ownerGlobal)
+ );
+ }
+}
+
+class MenuItem {
+ constructor(extension, createProperties, isRoot = false) {
+ this.extension = extension;
+ this.children = [];
+ this.parent = null;
+ this.tabManager = extension.tabManager;
+
+ this.setDefaults();
+ this.setProps(createProperties);
+
+ if (!this.hasOwnProperty("_id")) {
+ this.id = gNextMenuItemID++;
+ }
+ // If the item is not the root and has no parent
+ // it must be a child of the root.
+ if (!isRoot && !this.parent) {
+ this.root.addChild(this);
+ }
+ }
+
+ static mergeProps(obj, properties) {
+ for (let propName in properties) {
+ if (properties[propName] === null) {
+ // Omitted optional argument.
+ continue;
+ }
+ obj[propName] = properties[propName];
+ }
+
+ if ("icons" in properties) {
+ if (properties.icons === null) {
+ obj.icons = null;
+ } else if (typeof properties.icons == "string") {
+ obj.icons = { 16: properties.icons };
+ }
+ }
+ }
+
+ setProps(createProperties) {
+ MenuItem.mergeProps(this, createProperties);
+
+ if (createProperties.documentUrlPatterns != null) {
+ this.documentUrlMatchPattern = new MatchPatternSet(
+ this.documentUrlPatterns,
+ {
+ restrictSchemes: this.extension.restrictSchemes,
+ }
+ );
+ }
+
+ if (createProperties.targetUrlPatterns != null) {
+ this.targetUrlMatchPattern = new MatchPatternSet(this.targetUrlPatterns, {
+ // restrictSchemes default to false when matching links instead of pages
+ // (see Bug 1280370 for a rationale).
+ restrictSchemes: false,
+ });
+ }
+
+ // If a child MenuItem does not specify any contexts, then it should
+ // inherit the contexts specified from its parent.
+ if (createProperties.parentId && !createProperties.contexts) {
+ this.contexts = this.parent.contexts;
+ }
+ }
+
+ setDefaults() {
+ this.setProps({
+ type: "normal",
+ checked: false,
+ contexts: ["all"],
+ enabled: true,
+ visible: true,
+ });
+ }
+
+ set id(id) {
+ if (this.hasOwnProperty("_id")) {
+ throw new ExtensionError("ID of a MenuItem cannot be changed");
+ }
+ let isIdUsed = gMenuMap.get(this.extension).has(id);
+ if (isIdUsed) {
+ throw new ExtensionError(`ID already exists: ${id}`);
+ }
+ this._id = id;
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get elementId() {
+ let id = this.id;
+ // If the ID is an integer, it is auto-generated and globally unique.
+ // If the ID is a string, it is only unique within one extension and the
+ // ID needs to be concatenated with the extension ID.
+ if (typeof id !== "number") {
+ // To avoid collisions with numeric IDs, add a prefix to string IDs.
+ id = `_${id}`;
+ }
+ return `${makeWidgetId(this.extension.id)}-menuitem-${id}`;
+ }
+
+ ensureValidParentId(parentId) {
+ if (parentId === undefined) {
+ return;
+ }
+ let menuMap = gMenuMap.get(this.extension);
+ if (!menuMap.has(parentId)) {
+ throw new ExtensionError(
+ `Could not find any MenuItem with id: ${parentId}`
+ );
+ }
+ for (let item = menuMap.get(parentId); item; item = item.parent) {
+ if (item === this) {
+ throw new ExtensionError(
+ "MenuItem cannot be an ancestor (or self) of its new parent."
+ );
+ }
+ }
+ }
+
+ /**
+ * When updating menu properties we need to ensure parents exist
+ * in the cache map before children. That allows the menus to be
+ * created in the correct sequence on startup. This reparents the
+ * tree starting from this instance of MenuItem.
+ */
+ reparentInCache() {
+ let { id, extension } = this;
+ let cachedMap = gStartupCache.get(extension);
+ let createProperties = cachedMap.get(id);
+ cachedMap.delete(id);
+ cachedMap.set(id, createProperties);
+
+ for (let child of this.children) {
+ child.reparentInCache();
+ }
+ }
+
+ set parentId(parentId) {
+ this.ensureValidParentId(parentId);
+
+ if (this.parent) {
+ this.parent.detachChild(this);
+ }
+
+ if (parentId === undefined) {
+ this.root.addChild(this);
+ } else {
+ let menuMap = gMenuMap.get(this.extension);
+ menuMap.get(parentId).addChild(this);
+ }
+ }
+
+ get parentId() {
+ return this.parent ? this.parent.id : undefined;
+ }
+
+ addChild(child) {
+ if (child.parent) {
+ throw new ExtensionError("Child MenuItem already has a parent.");
+ }
+ this.children.push(child);
+ child.parent = this;
+ }
+
+ detachChild(child) {
+ let idx = this.children.indexOf(child);
+ if (idx < 0) {
+ throw new ExtensionError(
+ "Child MenuItem not found, it cannot be removed."
+ );
+ }
+ this.children.splice(idx, 1);
+ child.parent = null;
+ }
+
+ get root() {
+ let extension = this.extension;
+ if (!gRootItems.has(extension)) {
+ let root = new MenuItem(
+ extension,
+ { title: extension.name },
+ /* isRoot = */ true
+ );
+ gRootItems.set(extension, root);
+ }
+
+ return gRootItems.get(extension);
+ }
+
+ remove() {
+ if (this.parent) {
+ this.parent.detachChild(this);
+ }
+ let children = this.children.slice(0);
+ for (let child of children) {
+ child.remove();
+ }
+
+ let menuMap = gMenuMap.get(this.extension);
+ menuMap.delete(this.id);
+ // Menu items are saved if !extension.persistentBackground.
+ if (gStartupCache.get(this.extension)?.delete(this.id)) {
+ StartupCache.save();
+ }
+ if (this.root == this) {
+ gRootItems.delete(this.extension);
+ }
+ }
+
+ async getClickInfo(contextData, wasChecked) {
+ let info = {
+ menuItemId: this.id,
+ };
+ if (this.parent) {
+ info.parentMenuItemId = this.parentId;
+ }
+
+ await addMenuEventInfo(info, contextData, this.extension, true);
+
+ if (this.type === "checkbox" || this.type === "radio") {
+ info.checked = this.checked;
+ info.wasChecked = wasChecked;
+ }
+
+ return info;
+ }
+
+ enabledForContext(contextData) {
+ if (!this.visible) {
+ return false;
+ }
+ let contexts = getMenuContexts(contextData);
+ if (!this.contexts.some(n => contexts.has(n))) {
+ return false;
+ }
+
+ if (
+ this.viewTypes &&
+ !this.viewTypes.includes(getContextViewType(contextData))
+ ) {
+ return false;
+ }
+
+ let docPattern = this.documentUrlMatchPattern;
+ // When viewTypes is specified, the menu item is expected to be restricted
+ // to documents. So let documentUrlPatterns always apply to the URL of the
+ // document in which the menu was opened. When maybeOverrideContextData
+ // changes the context, contextData.pageUrl does not reflect that URL any
+ // more, so use contextData.originalViewUrl instead.
+ if (docPattern && this.viewTypes && contextData.originalViewUrl) {
+ if (
+ !docPattern.matches(Services.io.newURI(contextData.originalViewUrl))
+ ) {
+ return false;
+ }
+ docPattern = null; // Null it so that it won't be used with pageURI below.
+ }
+
+ let pageURI = contextData[contextData.inFrame ? "frameUrl" : "pageUrl"];
+ if (pageURI) {
+ pageURI = Services.io.newURI(pageURI);
+ if (docPattern && !docPattern.matches(pageURI)) {
+ return false;
+ }
+ }
+
+ let targetPattern = this.targetUrlMatchPattern;
+ if (targetPattern) {
+ let targetUrls = [];
+ if (contextData.onImage || contextData.onAudio || contextData.onVideo) {
+ // TODO: Double check if srcUrl is always set when we need it.
+ targetUrls.push(contextData.srcUrl);
+ }
+ if (contextData.onLink) {
+ targetUrls.push(contextData.linkUrl);
+ }
+ if (
+ !targetUrls.some(targetUrl =>
+ targetPattern.matches(Services.io.newURI(targetUrl))
+ )
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
+
+// While any extensions are active, this Tracker registers to observe/listen
+// for menu events from both Tools and context menus, both content and chrome.
+const menuTracker = {
+ menuIds: [
+ "tabContextMenu",
+ "folderPaneContext",
+ "msgComposeAttachmentItemContext",
+ "taskPopup",
+ ],
+
+ register() {
+ Services.obs.addObserver(this, "on-build-contextmenu");
+ for (const window of windowTracker.browserWindows()) {
+ this.onWindowOpen(window);
+ }
+ windowTracker.addOpenListener(this.onWindowOpen);
+ },
+
+ unregister() {
+ Services.obs.removeObserver(this, "on-build-contextmenu");
+ for (const window of windowTracker.browserWindows()) {
+ this.cleanupWindow(window);
+ }
+ windowTracker.removeOpenListener(this.onWindowOpen);
+ },
+
+ observe(subject, topic, data) {
+ subject = subject.wrappedJSObject;
+ gMenuBuilder.build(subject);
+ },
+
+ onWindowOpen(window) {
+ // Register the event listener on the window, as some menus we are
+ // interested in are dynamically created:
+ // https://hg.mozilla.org/mozilla-central/file/83a21ab93aff939d348468e69249a3a33ccfca88/toolkit/content/editMenuOverlay.js#l96
+ window.addEventListener("popupshowing", menuTracker);
+ },
+
+ cleanupWindow(window) {
+ window.removeEventListener("popupshowing", this);
+ },
+
+ handleEvent(event) {
+ const menu = event.target;
+ const trigger = menu.triggerNode;
+ const win = menu.ownerGlobal;
+ switch (menu.id) {
+ case "taskPopup": {
+ let info = { menu, inToolsMenu: true };
+ if (
+ win.document.location.href ==
+ "chrome://messenger/content/messenger.xhtml"
+ ) {
+ info.tab = tabTracker.activeTab;
+ // Calendar and Task view do not have a browser/URL.
+ info.pageUrl = info.tab.linkedBrowser?.currentURI?.spec;
+ } else {
+ info.tab = win;
+ }
+ gMenuBuilder.build(info);
+ break;
+ }
+ case "tabContextMenu": {
+ let triggerTab = trigger.closest("tab");
+ const tab = triggerTab || tabTracker.activeTab;
+ const pageUrl = tab.linkedBrowser?.currentURI?.spec;
+ gMenuBuilder.build({ menu, tab, pageUrl, onTab: true });
+ break;
+ }
+ case "folderPaneContext": {
+ const tab = tabTracker.activeTab;
+ const pageUrl = tab.linkedBrowser?.currentURI?.spec;
+ gMenuBuilder.build({
+ menu,
+ tab,
+ pageUrl,
+ selectedFolder: win.folderPaneContextMenu.activeFolder,
+ });
+ break;
+ }
+ case "attachmentListContext": {
+ let attachmentList =
+ menu.ownerGlobal.document.getElementById("attachmentList");
+ let allMessageAttachments = [...attachmentList.children].map(
+ item => item.attachment
+ );
+ gMenuBuilder.build({
+ menu,
+ tab: menu.ownerGlobal,
+ allMessageAttachments,
+ });
+ break;
+ }
+ case "attachmentItemContext": {
+ let attachmentList =
+ menu.ownerGlobal.document.getElementById("attachmentList");
+ let attachmentInfo =
+ menu.ownerGlobal.document.getElementById("attachmentInfo");
+
+ // If we opened the context menu from the attachment info area (the paperclip,
+ // "1 attachment" label, filename, or file size, just grab the first (and
+ // only) attachment as our "selected" attachments.
+ let selectedMessageAttachments;
+ if (
+ menu.triggerNode == attachmentInfo ||
+ menu.triggerNode.parentNode == attachmentInfo
+ ) {
+ selectedMessageAttachments = [
+ attachmentList.getItemAtIndex(0).attachment,
+ ];
+ } else {
+ selectedMessageAttachments = [...attachmentList.selectedItems].map(
+ item => item.attachment
+ );
+ }
+
+ gMenuBuilder.build({
+ menu,
+ tab: menu.ownerGlobal,
+ selectedMessageAttachments,
+ });
+ break;
+ }
+ case "msgComposeAttachmentItemContext": {
+ let bucket = menu.ownerDocument.getElementById("attachmentBucket");
+ let selectedComposeAttachments = [];
+ for (let item of bucket.itemChildren) {
+ if (item.selected) {
+ selectedComposeAttachments.push(item.attachment);
+ }
+ }
+ gMenuBuilder.build({
+ menu,
+ tab: menu.ownerGlobal,
+ selectedComposeAttachments,
+ });
+ break;
+ }
+ default:
+ // Fall back to the triggerNode. Make sure we are not re-triggered by a
+ // sub-menu.
+ if (menu.parentNode.localName == "menu") {
+ return;
+ }
+ if (Object.keys(chromeElementsMap).includes(trigger?.id)) {
+ let selectionInfo = SelectionUtils.getSelectionDetails(win);
+ let isContentSelected = !selectionInfo.docSelectionIsCollapsed;
+ let textSelected = selectionInfo.text;
+ let isTextSelected = !!textSelected.length;
+ gMenuBuilder.build({
+ menu,
+ tab: win,
+ pageUrl: win.browser.currentURI.spec,
+ onEditable: true,
+ isContentSelected,
+ isTextSelected,
+ onTextInput: true,
+ originalViewType: "tab",
+ fieldId: chromeElementsMap[trigger.id],
+ selectionText: isTextSelected ? selectionInfo.fullText : undefined,
+ });
+ }
+ break;
+ }
+ },
+};
+
+this.menus = class extends ExtensionAPIPersistent {
+ constructor(extension) {
+ super(extension);
+
+ if (!gMenuMap.size) {
+ menuTracker.register();
+ }
+ gMenuMap.set(extension, new Map());
+ }
+
+ restoreFromCache() {
+ let { extension } = this;
+ // ensure extension has not shutdown
+ if (!this.extension) {
+ return;
+ }
+ for (let createProperties of gStartupCache.get(extension).values()) {
+ // The order of menu creation is significant, see reparentInCache.
+ let menuItem = new MenuItem(extension, createProperties);
+ gMenuMap.get(extension).set(menuItem.id, menuItem);
+ }
+ // Used for testing
+ extension.emit("webext-menus-created", gMenuMap.get(extension));
+ }
+
+ async onStartup() {
+ let { extension } = this;
+ if (extension.persistentBackground) {
+ return;
+ }
+ // Using the map retains insertion order.
+ let cachedMenus = await StartupCache.menus.get(extension.id, () => {
+ return new Map();
+ });
+ gStartupCache.set(extension, cachedMenus);
+ if (!cachedMenus.size) {
+ return;
+ }
+
+ this.restoreFromCache();
+ }
+
+ onShutdown() {
+ let { extension } = this;
+
+ if (gMenuMap.has(extension)) {
+ gMenuMap.delete(extension);
+ gRootItems.delete(extension);
+ gShownMenuItems.delete(extension);
+ gStartupCache.delete(extension);
+ gOnShownSubscribers.delete(extension);
+ if (!gMenuMap.size) {
+ menuTracker.unregister();
+ }
+ }
+ }
+
+ PERSISTENT_EVENTS = {
+ onShown({ fire }) {
+ let { extension } = this;
+ let listener = async (event, menuIds, contextData) => {
+ let info = {
+ menuIds,
+ contexts: Array.from(getMenuContexts(contextData)),
+ };
+
+ let nativeTab = contextData.tab;
+
+ // The menus.onShown event is fired before the user has consciously
+ // interacted with an extension, so we require permissions before
+ // exposing sensitive contextual data.
+ let contextUrl = contextData.inFrame
+ ? contextData.frameUrl
+ : contextData.pageUrl;
+
+ let ownerDocumentUrl = contextData.menu.ownerDocument.location.href;
+
+ let contextScheme;
+ if (contextUrl) {
+ contextScheme = Services.io.newURI(contextUrl).scheme;
+ }
+
+ let includeSensitiveData =
+ (nativeTab &&
+ extension.tabManager.hasActiveTabPermission(nativeTab)) ||
+ (contextUrl && extension.allowedOrigins.matches(contextUrl)) ||
+ (MESSAGE_PROTOCOLS.includes(contextScheme) &&
+ extension.hasPermission("messagesRead")) ||
+ (ownerDocumentUrl ==
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml" &&
+ extension.hasPermission("compose"));
+
+ await addMenuEventInfo(
+ info,
+ contextData,
+ extension,
+ includeSensitiveData
+ );
+
+ let tab = nativeTab && extension.tabManager.convert(nativeTab);
+ fire.sync(info, tab);
+ };
+ gOnShownSubscribers.get(extension).add(listener);
+ extension.on("webext-menu-shown", listener);
+ return {
+ unregister() {
+ const listeners = gOnShownSubscribers.get(extension);
+ listeners.delete(listener);
+ if (listeners.size === 0) {
+ gOnShownSubscribers.delete(extension);
+ }
+ extension.off("webext-menu-shown", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onHidden({ fire }) {
+ let { extension } = this;
+ let listener = () => {
+ fire.sync();
+ };
+ extension.on("webext-menu-hidden", listener);
+ return {
+ unregister() {
+ extension.off("webext-menu-hidden", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onClicked({ context, fire }) {
+ let { extension } = this;
+ let listener = async (event, info, nativeTab) => {
+ let { linkedBrowser } = nativeTab || tabTracker.activeTab;
+ let tab = nativeTab && extension.tabManager.convert(nativeTab);
+ if (fire.wakeup) {
+ // force the wakeup, thus the call to convert to get the context.
+ await fire.wakeup();
+ // If while waiting the tab disappeared we bail out.
+ if (
+ !linkedBrowser.ownerGlobal.gBrowser.getTabForBrowser(linkedBrowser)
+ ) {
+ console.error(
+ `menus.onClicked: target tab closed during background startup.`
+ );
+ return;
+ }
+ }
+ context.withPendingBrowser(linkedBrowser, () => fire.sync(info, tab));
+ };
+
+ extension.on("webext-menu-menuitem-click", listener);
+ return {
+ unregister() {
+ extension.off("webext-menu-menuitem-click", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ menus: {
+ refresh() {
+ gMenuBuilder.rebuildMenu(extension);
+ },
+
+ onShown: new EventManager({
+ context,
+ module: "menus",
+ event: "onShown",
+ extensionApi: this,
+ }).api(),
+ onHidden: new EventManager({
+ context,
+ module: "menus",
+ event: "onHidden",
+ extensionApi: this,
+ }).api(),
+ onClicked: new EventManager({
+ context,
+ module: "menus",
+ event: "onClicked",
+ extensionApi: this,
+ }).api(),
+
+ create(createProperties) {
+ // event pages require id
+ if (!extension.persistentBackground) {
+ if (!createProperties.id) {
+ throw new ExtensionError(
+ "menus.create requires an id for non-persistent background scripts."
+ );
+ }
+ if (gMenuMap.get(extension).has(createProperties.id)) {
+ throw new ExtensionError(
+ `The menu id ${createProperties.id} already exists in menus.create.`
+ );
+ }
+ }
+
+ // Note that the id is required by the schema. If the addon did not set
+ // it, the implementation of menus.create in the child will add it for
+ // extensions with persistent backgrounds, but not otherwise.
+ let menuItem = new MenuItem(extension, createProperties);
+ gMenuMap.get(extension).set(menuItem.id, menuItem);
+ if (!extension.persistentBackground) {
+ // Only cache properties that are necessary.
+ let cached = {};
+ MenuItem.mergeProps(cached, createProperties);
+ gStartupCache.get(extension).set(menuItem.id, cached);
+ StartupCache.save();
+ }
+ },
+
+ update(id, updateProperties) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (!menuItem) {
+ return;
+ }
+ menuItem.setProps(updateProperties);
+
+ // Update the startup cache for non-persistent extensions.
+ if (extension.persistentBackground) {
+ return;
+ }
+
+ let cached = gStartupCache.get(extension).get(id);
+ let reparent =
+ updateProperties.parentId != null &&
+ cached.parentId != updateProperties.parentId;
+ MenuItem.mergeProps(cached, updateProperties);
+ if (reparent) {
+ // The order of menu creation is significant, see reparentInCache.
+ menuItem.reparentInCache();
+ }
+ StartupCache.save();
+ },
+
+ remove(id) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (menuItem) {
+ menuItem.remove();
+ }
+ },
+
+ removeAll() {
+ let root = gRootItems.get(extension);
+ if (root) {
+ root.remove();
+ }
+ // Should be empty, just extra assurance.
+ if (!extension.persistentBackground) {
+ let cached = gStartupCache.get(extension);
+ if (cached.size) {
+ cached.clear();
+ StartupCache.save();
+ }
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-messageDisplay.js b/comm/mail/components/extensions/parent/ext-messageDisplay.js
new file mode 100644
index 0000000000..98ba2dc75c
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-messageDisplay.js
@@ -0,0 +1,348 @@
+/* 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 { MailConsts } = ChromeUtils.import("resource:///modules/MailConsts.jsm");
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+/**
+ * Returns the currently displayed messages in the given tab.
+ *
+ * @param {Tab} tab
+ * @returns {nsIMsgHdr[]} Array of nsIMsgHdr
+ */
+function getDisplayedMessages(tab) {
+ let nativeTab = tab.nativeTab;
+ if (tab instanceof TabmailTab) {
+ if (nativeTab.mode.name == "mail3PaneTab") {
+ return nativeTab.chromeBrowser.contentWindow.gDBView.getSelectedMsgHdrs();
+ } else if (nativeTab.mode.name == "mailMessageTab") {
+ return [nativeTab.chromeBrowser.contentWindow.gMessage];
+ }
+ } else if (nativeTab?.messageBrowser) {
+ return [nativeTab.messageBrowser.contentWindow.gMessage];
+ }
+ return [];
+}
+
+/**
+ * Wrapper to convert multiple nsIMsgHdr to MessageHeader objects.
+ *
+ * @param {nsIMsgHdr[]} Array of nsIMsgHdr
+ * @param {ExtensionData} extension
+ * @returns {MessageHeader[]} Array of MessageHeader objects
+ *
+ * @see /mail/components/extensions/schemas/messages.json
+ */
+function convertMessages(messages, extension) {
+ let result = [];
+ for (let msg of messages) {
+ let hdr = convertMessage(msg, extension);
+ if (hdr) {
+ result.push(hdr);
+ }
+ }
+ return result;
+}
+
+/**
+ * Check the users preference on opening new messages in tabs or windows.
+ *
+ * @returns {string} - either "tab" or "window"
+ */
+function getDefaultMessageOpenLocation() {
+ let pref = Services.prefs.getIntPref("mail.openMessageBehavior");
+ return pref == MailConsts.OpenMessageBehavior.NEW_TAB ? "tab" : "window";
+}
+
+/**
+ * Return the msgHdr of the message specified in the properties object. Message
+ * can be specified via properties.headerMessageId or properties.messageId.
+ *
+ * @param {object} properties - @see mail/components/extensions/schemas/messageDisplay.json
+ * @throws ExtensionError if an unknown message has been specified
+ * @returns {nsIMsgHdr} the requested msgHdr
+ */
+function getMsgHdr(properties) {
+ if (properties.headerMessageId) {
+ let msgHdr = MailUtils.getMsgHdrForMsgId(properties.headerMessageId);
+ if (!msgHdr) {
+ throw new ExtensionError(
+ `Unknown or invalid headerMessageId: ${properties.headerMessageId}.`
+ );
+ }
+ return msgHdr;
+ }
+ let msgHdr = messageTracker.getMessage(properties.messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(
+ `Unknown or invalid messageId: ${properties.messageId}.`
+ );
+ }
+ return msgHdr;
+}
+
+this.messageDisplay = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onMessageDisplayed({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ let listener = {
+ async handleEvent(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ // `event.target` is an about:message window.
+ let nativeTab = event.target.tabOrWindow;
+ let tab = tabManager.wrapTab(nativeTab);
+ let msg = convertMessage(event.detail, extension);
+ fire.async(tab.convert(), msg);
+ },
+ };
+ windowTracker.addListener("MsgLoaded", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("MsgLoaded", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMessagesDisplayed({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ let listener = {
+ async handleEvent(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ // `event.target` is an about:message or about:3pane window.
+ let nativeTab = event.target.tabOrWindow;
+ let tab = tabManager.wrapTab(nativeTab);
+ let msgs = getDisplayedMessages(tab);
+ fire.async(tab.convert(), convertMessages(msgs, extension));
+ },
+ };
+ windowTracker.addListener("MsgsLoaded", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("MsgsLoaded", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ /**
+ * Guard to make sure the API waits until the message tab has been fully loaded,
+ * to cope with tabs.onCreated returning tabs very early.
+ *
+ * @param {integer} tabId
+ * @returns {Tab} the fully loaded message tab identified by the given tabId,
+ * or null, if invalid
+ */
+ async function getMessageDisplayTab(tabId) {
+ let msgContentWindow;
+ let tab = tabManager.get(tabId);
+ if (tab?.type == "mail") {
+ // In about:3pane only the messageBrowser needs to be checked for its
+ // load state. The webBrowser is invalid, the multiMessageBrowser can
+ // bypass.
+ if (!tab.nativeTab.chromeBrowser.contentWindow.webBrowser.hidden) {
+ return null;
+ }
+ if (
+ !tab.nativeTab.chromeBrowser.contentWindow.multiMessageBrowser.hidden
+ ) {
+ return tab;
+ }
+ msgContentWindow =
+ tab.nativeTab.chromeBrowser.contentWindow.messageBrowser
+ .contentWindow;
+ } else if (tab?.type == "messageDisplay") {
+ msgContentWindow =
+ tab instanceof TabmailTab
+ ? tab.nativeTab.chromeBrowser.contentWindow
+ : tab.nativeTab.messageBrowser.contentWindow;
+ } else {
+ return null;
+ }
+
+ // Make sure the content window has been fully loaded.
+ await new Promise(resolve => {
+ if (msgContentWindow.document.readyState == "complete") {
+ resolve();
+ } else {
+ msgContentWindow.addEventListener(
+ "load",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+
+ // Wait until the message display process has been initiated.
+ await new Promise(resolve => {
+ if (msgContentWindow.msgLoading || msgContentWindow.msgLoaded) {
+ resolve();
+ } else {
+ msgContentWindow.addEventListener(
+ "messageURIChanged",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+
+ // Wait until the message display process has been finished.
+ await new Promise(resolve => {
+ if (msgContentWindow.msgLoaded) {
+ resolve();
+ } else {
+ msgContentWindow.addEventListener(
+ "MsgLoaded",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+
+ // If there is no gMessage, then the display has been cleared.
+ return msgContentWindow.gMessage ? tab : null;
+ }
+
+ let { extension } = context;
+ let { tabManager } = extension;
+ return {
+ messageDisplay: {
+ onMessageDisplayed: new EventManager({
+ context,
+ module: "messageDisplay",
+ event: "onMessageDisplayed",
+ extensionApi: this,
+ }).api(),
+ onMessagesDisplayed: new EventManager({
+ context,
+ module: "messageDisplay",
+ event: "onMessagesDisplayed",
+ extensionApi: this,
+ }).api(),
+ async getDisplayedMessage(tabId) {
+ let tab = await getMessageDisplayTab(tabId);
+ if (!tab) {
+ return null;
+ }
+ let messages = getDisplayedMessages(tab);
+ if (messages.length != 1) {
+ return null;
+ }
+ return convertMessage(messages[0], extension);
+ },
+ async getDisplayedMessages(tabId) {
+ let tab = await getMessageDisplayTab(tabId);
+ if (!tab) {
+ return [];
+ }
+ let messages = getDisplayedMessages(tab);
+ return convertMessages(messages, extension);
+ },
+ async open(properties) {
+ if (
+ ["messageId", "headerMessageId", "file"].reduce(
+ (count, value) => (properties[value] ? count + 1 : count),
+ 0
+ ) != 1
+ ) {
+ throw new ExtensionError(
+ "Exactly one of messageId, headerMessageId or file must be specified."
+ );
+ }
+
+ let messageURI;
+ if (properties.file) {
+ let realFile = await getRealFileForFile(properties.file);
+ messageURI = Services.io
+ .newFileURI(realFile)
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize().spec;
+ } else {
+ let msgHdr = getMsgHdr(properties);
+ if (msgHdr.folder) {
+ messageURI = msgHdr.folder.getUriForMsg(msgHdr);
+ } else {
+ // Add the application/x-message-display type to the url, if missing.
+ // The slash is escaped when setting the type via searchParams, but
+ // core code needs it unescaped.
+ let url = new URL(msgHdr.getStringProperty("dummyMsgUrl"));
+ url.searchParams.delete("type");
+ messageURI = `${url.href}${
+ url.searchParams.toString() ? "&" : "?"
+ }type=application/x-message-display`;
+ }
+ }
+
+ let tab;
+ switch (properties.location || getDefaultMessageOpenLocation()) {
+ case "tab":
+ {
+ let normalWindow = await getNormalWindowReady(
+ context,
+ properties.windowId
+ );
+ let active = properties.active ?? true;
+ let tabmail = normalWindow.document.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ let nativeTabInfo = tabmail.openTab("mailMessageTab", {
+ messageURI,
+ background: !active,
+ });
+ await new Promise(resolve =>
+ nativeTabInfo.chromeBrowser.addEventListener(
+ "MsgLoaded",
+ resolve,
+ { once: true }
+ )
+ );
+ tab = tabManager.convert(nativeTabInfo, currentTab);
+ }
+ break;
+
+ case "window":
+ {
+ // Handle window location.
+ let topNormalWindow = await getNormalWindowReady();
+ let messageWindow = topNormalWindow.MsgOpenNewWindowForMessage(
+ Services.io.newURI(messageURI)
+ );
+ await new Promise(resolve =>
+ messageWindow.addEventListener("MsgLoaded", resolve, {
+ once: true,
+ })
+ );
+ tab = tabManager.convert(messageWindow);
+ }
+ break;
+ }
+ return tab;
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-messageDisplayAction.js b/comm/mail/components/extensions/parent/ext-messageDisplayAction.js
new file mode 100644
index 0000000000..026ddfc736
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-messageDisplayAction.js
@@ -0,0 +1,251 @@
+/* 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/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ToolbarButtonAPI",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+const messageDisplayActionMap = new WeakMap();
+
+this.messageDisplayAction = class extends ToolbarButtonAPI {
+ static for(extension) {
+ return messageDisplayActionMap.get(extension);
+ }
+
+ async onManifestEntry(entryName) {
+ await super.onManifestEntry(entryName);
+ messageDisplayActionMap.set(this.extension, this);
+ }
+
+ close() {
+ super.close();
+ messageDisplayActionMap.delete(this.extension);
+ windowTracker.removeListener("TabSelect", this);
+ }
+
+ constructor(extension) {
+ super(extension, global);
+ this.manifest_name = "message_display_action";
+ this.manifestName = "messageDisplayAction";
+ this.manifest = extension.manifest[this.manifest_name];
+ this.moduleName = this.manifestName;
+
+ this.windowURLs = [
+ "chrome://messenger/content/messenger.xhtml",
+ "chrome://messenger/content/messageWindow.xhtml",
+ ];
+ this.toolboxId = "header-view-toolbox";
+ this.toolbarId = "header-view-toolbar";
+
+ windowTracker.addListener("TabSelect", this);
+ }
+
+ static onUninstall(extensionId) {
+ let widgetId = makeWidgetId(extensionId);
+ let id = `${widgetId}-messageDisplayAction-toolbarbutton`;
+ let toolbar = "header-view-toolbar";
+
+ // Check all possible windows and remove the toolbarbutton if found.
+ // Sadly we have to hardcode these values here, as the add-on is already
+ // shutdown when onUninstall is called.
+ let windowURLs = [
+ "chrome://messenger/content/messenger.xhtml",
+ "chrome://messenger/content/messageWindow.xhtml",
+ ];
+ for (let windowURL of windowURLs) {
+ for (let setName of ["currentset", "extensionset"]) {
+ let set = Services.xulStore
+ .getValue(windowURL, toolbar, setName)
+ .split(",");
+ let newSet = set.filter(e => e != id);
+ if (newSet.length < set.length) {
+ Services.xulStore.setValue(
+ windowURL,
+ toolbar,
+ setName,
+ newSet.join(",")
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class to update every about:message in this window.
+ */
+ paint(window) {
+ window.addEventListener("aboutMessageLoaded", this);
+ for (let bc of window.browsingContext.getAllBrowsingContextsInSubtree()) {
+ if (bc.currentURI.spec == "about:message") {
+ super.paint(bc.window);
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class to update every about:message in this window.
+ */
+ unpaint(window) {
+ window.removeEventListener("aboutMessageLoaded", this);
+ for (let bc of window.browsingContext.getAllBrowsingContextsInSubtree()) {
+ if (bc.currentURI.spec == "about:message") {
+ super.unpaint(bc.window);
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class to update every about:message in this window.
+ */
+ async updateWindow(window) {
+ for (let bc of window.browsingContext.getAllBrowsingContextsInSubtree()) {
+ if (bc.currentURI.spec == "about:message") {
+ super.updateWindow(bc.window);
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class where `target` is a tab, to update
+ * about:message instead of the window.
+ */
+ async updateOnChange(target) {
+ if (!target) {
+ await super.updateOnChange(target);
+ return;
+ }
+
+ let window = Cu.getGlobalForObject(target);
+ if (window == target) {
+ await super.updateOnChange(target);
+ return;
+ }
+
+ let tabmail = window.top.document.getElementById("tabmail");
+ if (!tabmail || target != tabmail.selectedTab) {
+ return;
+ }
+
+ switch (target.mode.name) {
+ case "mail3PaneTab":
+ await this.updateWindow(
+ target.chromeBrowser.contentWindow.messageBrowser.contentWindow
+ );
+ break;
+ case "mailMessageTab":
+ await this.updateWindow(target.chromeBrowser.contentWindow);
+ break;
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+ let window = event.target.ownerGlobal;
+
+ switch (event.type) {
+ case "aboutMessageLoaded":
+ // Add the toolbar button to any about:message that comes along.
+ super.paint(event.target);
+ break;
+ case "popupshowing":
+ const menu = event.target;
+ if (menu.tagName != "menupopup") {
+ return;
+ }
+
+ const trigger = menu.triggerNode;
+ const node = window.document.getElementById(this.id);
+ const contexts = ["header-toolbar-context-menu"];
+ if (contexts.includes(menu.id) && node && node.contains(trigger)) {
+ global.actionContextMenu({
+ tab: window.tabOrWindow,
+ pageUrl: window.getMessagePaneBrowser().currentURI.spec,
+ extension: this.extension,
+ onMessageDisplayAction: true,
+ menu,
+ });
+ }
+
+ if (
+ menu.dataset.actionMenu == "messageDisplayAction" &&
+ this.extension.id == menu.dataset.extensionId
+ ) {
+ global.actionContextMenu({
+ tab: window.tabOrWindow,
+ pageUrl: window.getMessagePaneBrowser().currentURI.spec,
+ extension: this.extension,
+ inMessageDisplayActionMenu: true,
+ menu,
+ });
+ }
+ break;
+ }
+ }
+
+ /**
+ * Overrides the super class to trigger the action in the current about:message.
+ */
+ async triggerAction(window, options) {
+ // Supported message browsers:
+ // - in mail tab (browser could be hidden)
+ // - in message tab
+ // - in message window
+
+ // The passed in window could be the window of one of the supported message
+ // browsers already. To know if the browser is hidden, always re-search the
+ // message window and start at the top.
+ let tabmail = window.top.document.getElementById("tabmail");
+ if (tabmail) {
+ // A mail tab or a message tab.
+ let isHidden =
+ tabmail.currentAbout3Pane &&
+ tabmail.currentAbout3Pane.messageBrowser.hidden;
+
+ if (tabmail.currentAboutMessage && !isHidden) {
+ return super.triggerAction(tabmail.currentAboutMessage, options);
+ }
+ } else if (window.top.messageBrowser) {
+ // A message window.
+ return super.triggerAction(
+ window.top.messageBrowser.contentWindow,
+ options
+ );
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns an element in the toolbar, which is to be used as default insertion
+ * point for new toolbar buttons in non-customizable toolbars.
+ *
+ * May return null to append new buttons to the end of the toolbar.
+ *
+ * @param {DOMElement} toolbar - a toolbar node
+ * @returns {DOMElement} a node which is to be used as insertion point, or null
+ */
+ getNonCustomizableToolbarInsertionPoint(toolbar) {
+ return toolbar.querySelector("#otherActionsButton");
+ }
+
+ makeButton(window) {
+ let button = super.makeButton(window);
+ button.classList.add("message-header-view-button");
+ // The header toolbar has no associated context menu. Add one directly to
+ // this button.
+ button.setAttribute("context", "header-toolbar-context-menu");
+ return button;
+ }
+};
+
+global.messageDisplayActionFor = this.messageDisplayAction.for;
diff --git a/comm/mail/components/extensions/parent/ext-messages.js b/comm/mail/components/extensions/parent/ext-messages.js
new file mode 100644
index 0000000000..7d03b3fa62
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-messages.js
@@ -0,0 +1,1563 @@
+/* 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/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ AttachmentInfo: "resource:///modules/AttachmentInfo.sys.mjs",
+});
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MessageArchiver",
+ "resource:///modules/MessageArchiver.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MimeParser",
+ "resource:///modules/mimeParser.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MsgHdrToMimeMessage",
+ "resource:///modules/gloda/MimeMessage.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "jsmime",
+ "resource:///modules/jsmime.jsm"
+);
+
+var { MailStringUtils } = ChromeUtils.import(
+ "resource:///modules/MailStringUtils.jsm"
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["File", "IOUtils", "PathUtils"]);
+
+var { DefaultMap } = ExtensionUtils;
+
+let messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+/**
+ * Takes a part of a MIME message (as retrieved with MsgHdrToMimeMessage) and
+ * filters out the properties we don't want to send to extensions.
+ */
+function convertMessagePart(part) {
+ let partObject = {};
+ for (let key of ["body", "contentType", "name", "partName", "size"]) {
+ if (key in part) {
+ partObject[key] = part[key];
+ }
+ }
+
+ // Decode headers. This also takes care of headers, which still include
+ // encoded words and need to be RFC 2047 decoded.
+ if ("headers" in part) {
+ partObject.headers = {};
+ for (let header of Object.keys(part.headers)) {
+ partObject.headers[header] = part.headers[header].map(h =>
+ MailServices.mimeConverter.decodeMimeHeader(
+ h,
+ null,
+ false /* override_charset */,
+ true /* eatContinuations */
+ )
+ );
+ }
+ }
+
+ if ("parts" in part && Array.isArray(part.parts) && part.parts.length > 0) {
+ partObject.parts = part.parts.map(convertMessagePart);
+ }
+ return partObject;
+}
+
+async function convertAttachment(attachment) {
+ let rv = {
+ contentType: attachment.contentType,
+ name: attachment.name,
+ size: attachment.size,
+ partName: attachment.partName,
+ };
+
+ if (attachment.contentType.startsWith("message/")) {
+ // The attached message may not have been seen/opened yet, create a dummy
+ // msgHdr.
+ let attachedMsgHdr = new nsDummyMsgHeader();
+
+ attachedMsgHdr.setStringProperty("dummyMsgUrl", attachment.url);
+ attachedMsgHdr.recipients = attachment.headers.to;
+ attachedMsgHdr.ccList = attachment.headers.cc;
+ attachedMsgHdr.bccList = attachment.headers.bcc;
+ attachedMsgHdr.author = attachment.headers.from?.[0] || "";
+ attachedMsgHdr.subject = attachment.headers.subject?.[0] || "";
+
+ let hdrDate = attachment.headers.date?.[0];
+ attachedMsgHdr.date = hdrDate ? Date.parse(hdrDate) * 1000 : 0;
+
+ let hdrId = attachment.headers["message-id"]?.[0];
+ attachedMsgHdr.messageId = hdrId ? hdrId.replace(/^<|>$/g, "") : "";
+
+ rv.message = convertMessage(attachedMsgHdr);
+ }
+
+ return rv;
+}
+
+/**
+ * @typedef MimeMessagePart
+ * @property {MimeMessagePart[]} [attachments] - flat list of attachment parts
+ * found in any of the nested mime parts
+ * @property {string} [body] - the body of the part
+ * @property {Uint8Array} [raw] - the raw binary content of the part
+ * @property {string} [contentType]
+ * @property {string} headers - key-value object with key being a header name
+ * and value an array with all header values found
+ * @property {string} [name] - filename, if part is an attachment
+ * @property {string} partName - name of the mime part (e.g: "1.2")
+ * @property {MimeMessagePart[]} [parts] - nested mime parts
+ * @property {string} [size] - size of the part
+ * @property {string} [url] - message url
+ */
+
+/**
+ * Returns attachments found in the message belonging to the given nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {boolean} includeNestedAttachments - Whether to return all attachments,
+ * including attachments from nested mime parts.
+ * @returns {Promise<MimeMessagePart[]>}
+ */
+async function getAttachments(msgHdr, includeNestedAttachments = false) {
+ let mimeMsg = await getMimeMessage(msgHdr);
+ if (!mimeMsg) {
+ return null;
+ }
+
+ // Reduce returned attachments according to includeNestedAttachments.
+ let level = mimeMsg.partName ? mimeMsg.partName.split(".").length : 0;
+ return mimeMsg.attachments.filter(
+ a => includeNestedAttachments || a.partName.split(".").length == level + 2
+ );
+}
+
+/**
+ * Returns the attachment identified by the provided partName.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {string} partName
+ * @param {object} [options={}] - If the includeRaw property is truthy the raw
+ * attachment contents are included.
+ * @returns {Promise<MimeMessagePart>}
+ */
+async function getAttachment(msgHdr, partName, options = {}) {
+ // It's not ideal to have to call MsgHdrToMimeMessage here again, but we need
+ // the name of the attached file, plus this also gives us the URI without having
+ // to jump through a lot of hoops.
+ let attachment = await getMimeMessage(msgHdr, partName);
+ if (!attachment) {
+ return null;
+ }
+
+ if (options.includeRaw) {
+ let channel = Services.io.newChannelFromURI(
+ Services.io.newURI(attachment.url),
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+
+ attachment.raw = await new Promise((resolve, reject) => {
+ let listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+ listener.init({
+ onStreamComplete(loader, context, status, resultLength, result) {
+ if (Components.isSuccessCode(status)) {
+ resolve(Uint8Array.from(result));
+ } else {
+ reject(
+ new ExtensionError(
+ `Failed to read attachment ${attachment.url} content: ${status}`
+ )
+ );
+ }
+ },
+ });
+ channel.asyncOpen(listener, null);
+ });
+ }
+
+ return attachment;
+}
+
+/**
+ * Returns the <part> parameter of the dummyMsgUrl of the provided nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @returns {string}
+ */
+function getSubMessagePartName(msgHdr) {
+ if (msgHdr.folder || !msgHdr.getStringProperty("dummyMsgUrl")) {
+ return "";
+ }
+
+ return new URL(msgHdr.getStringProperty("dummyMsgUrl")).searchParams.get(
+ "part"
+ );
+}
+
+/**
+ * Returns the nsIMsgHdr of the outer message, if the provided nsIMsgHdr belongs
+ * to a message which is actually an attachment of another message. Returns null
+ * otherwise.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @returns {nsIMsgHdr}
+ */
+function getParentMsgHdr(msgHdr) {
+ if (msgHdr.folder || !msgHdr.getStringProperty("dummyMsgUrl")) {
+ return null;
+ }
+
+ let url = new URL(msgHdr.getStringProperty("dummyMsgUrl"));
+
+ if (url.protocol == "news:") {
+ let newsUrl = `news-message://${url.hostname}/${url.searchParams.get(
+ "group"
+ )}#${url.searchParams.get("key")}`;
+ return messenger.msgHdrFromURI(newsUrl);
+ }
+
+ if (url.protocol == "mailbox:") {
+ // This could be a sub-message of a message opened from file.
+ let fileUrl = `file://${url.pathname}`;
+ let parentMsgHdr = messageTracker._dummyMessageHeaders.get(fileUrl);
+ if (parentMsgHdr) {
+ return parentMsgHdr;
+ }
+ }
+ // Everything else should be a mailbox:// or an imap:// url.
+ let params = Array.from(url.searchParams, p => p[0]).filter(
+ p => !["number"].includes(p)
+ );
+ for (let param of params) {
+ url.searchParams.delete(param);
+ }
+ return Services.io.newURI(url.href).QueryInterface(Ci.nsIMsgMessageUrl)
+ .messageHeader;
+}
+
+/**
+ * Get the raw message for a given nsIMsgHdr.
+ *
+ * @param aMsgHdr - The message header to retrieve the raw message for.
+ * @returns {Promise<string>} - Binary string of the raw message.
+ */
+async function getRawMessage(msgHdr) {
+ // If this message is a sub-message (an attachment of another message), get it
+ // as an attachment from the parent message and return its raw content.
+ let subMsgPartName = getSubMessagePartName(msgHdr);
+ if (subMsgPartName) {
+ let parentMsgHdr = getParentMsgHdr(msgHdr);
+ let attachment = await getAttachment(parentMsgHdr, subMsgPartName, {
+ includeRaw: true,
+ });
+ return attachment.raw.reduce(
+ (prev, curr) => prev + String.fromCharCode(curr),
+ ""
+ );
+ }
+
+ // Messages opened from file do not have a folder property, but
+ // have their url stored as a string property.
+ let msgUri = msgHdr.folder
+ ? msgHdr.folder.generateMessageURI(msgHdr.messageKey)
+ : msgHdr.getStringProperty("dummyMsgUrl");
+
+ let service = MailServices.messageServiceFromURI(msgUri);
+ return 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(request, status) {
+ if (Components.isSuccessCode(status)) {
+ resolve(this._data.join(""));
+ } else {
+ reject(
+ new ExtensionError(
+ `Error while streaming message <${msgUri}>: ${status}`
+ )
+ );
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIRequestObserver",
+ ]),
+ };
+
+ // This is not using aConvertData and therefore works for news:// messages.
+ service.streamMessage(
+ msgUri,
+ streamlistener,
+ null, // aMsgWindow
+ null, // aUrlListener
+ false, // aConvertData
+ "" //aAdditionalHeader
+ );
+ });
+}
+
+/**
+ * Returns MIME parts found in the message identified by the given nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {string} partName - Return only a specific mime part.
+ * @returns {Promise<MimeMessagePart>}
+ */
+async function getMimeMessage(msgHdr, partName = "") {
+ // If this message is a sub-message (an attachment of another message), get the
+ // mime parts of the parent message and return the part of the sub-message.
+ let subMsgPartName = getSubMessagePartName(msgHdr);
+ if (subMsgPartName) {
+ let parentMsgHdr = getParentMsgHdr(msgHdr);
+ if (!parentMsgHdr) {
+ return null;
+ }
+
+ let mimeMsg = await getMimeMessage(parentMsgHdr, partName);
+ if (!mimeMsg) {
+ return null;
+ }
+
+ // If <partName> was specified, the returned mime message is just that part,
+ // no further processing needed. But prevent x-ray vision into the parent.
+ if (partName) {
+ if (partName.split(".").length > subMsgPartName.split(".").length) {
+ return mimeMsg;
+ }
+ return null;
+ }
+
+ // Limit mimeMsg and attachments to the requested <subMessagePart>.
+ let findSubPart = (parts, partName) => {
+ let match = parts.find(a => partName.startsWith(a.partName));
+ if (!match) {
+ throw new ExtensionError(
+ `Unexpected Error: Part ${partName} not found.`
+ );
+ }
+ return match.partName == partName
+ ? match
+ : findSubPart(match.parts, partName);
+ };
+ let subMimeMsg = findSubPart(mimeMsg.parts, subMsgPartName);
+
+ if (mimeMsg.attachments) {
+ subMimeMsg.attachments = mimeMsg.attachments.filter(
+ a =>
+ a.partName != subMsgPartName && a.partName.startsWith(subMsgPartName)
+ );
+ }
+ return subMimeMsg;
+ }
+
+ let mimeMsg = await new Promise(resolve => {
+ MsgHdrToMimeMessage(
+ msgHdr,
+ null,
+ (_msgHdr, mimeMsg) => {
+ mimeMsg.attachments = mimeMsg.allInlineAttachments;
+ resolve(mimeMsg);
+ },
+ true,
+ { examineEncryptedParts: true }
+ );
+ });
+
+ return partName
+ ? mimeMsg.attachments.find(a => a.partName == partName)
+ : mimeMsg;
+}
+
+this.messages = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onNewMailReceived({ context, fire }) {
+ let listener = async (event, folder, newMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert it early.
+ let page = await messageListTracker.startList(newMessages, extension);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(convertFolder(folder), page);
+ };
+ messageTracker.on("messages-received", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-received", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onUpdated({ context, fire }) {
+ let listener = async (event, message, properties) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert it early.
+ let convertedMessage = convertMessage(message, extension);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(convertedMessage, properties);
+ };
+ messageTracker.on("message-updated", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("message-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMoved({ context, fire }) {
+ let listener = async (event, srcMessages, dstMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let srcPage = await messageListTracker.startList(
+ srcMessages,
+ extension
+ );
+ let dstPage = await messageListTracker.startList(
+ dstMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcPage, dstPage);
+ };
+ messageTracker.on("messages-moved", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-moved", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onCopied({ context, fire }) {
+ let listener = async (event, srcMessages, dstMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let srcPage = await messageListTracker.startList(
+ srcMessages,
+ extension
+ );
+ let dstPage = await messageListTracker.startList(
+ dstMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcPage, dstPage);
+ };
+ messageTracker.on("messages-copied", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-copied", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ let listener = async (event, deletedMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let deletedPage = await messageListTracker.startList(
+ deletedMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(deletedPage);
+ };
+ messageTracker.on("messages-deleted", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ const { extension } = this;
+ const { tabManager } = extension;
+
+ function collectMessagesInFolders(messageIds) {
+ let folderMap = new DefaultMap(() => new Set());
+
+ for (let messageId of messageIds) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+
+ let msgHeaderSet = folderMap.get(msgHdr.folder);
+ msgHeaderSet.add(msgHdr);
+ }
+
+ return folderMap;
+ }
+
+ async function createTempFileMessage(msgHdr) {
+ let rawBinaryString = await getRawMessage(msgHdr);
+ let pathEmlFile = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ encodeURIComponent(msgHdr.messageId).replaceAll(/[/:*?\"<>|]/g, "_") +
+ ".eml",
+ 0o600
+ );
+
+ let emlFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ emlFile.initWithPath(pathEmlFile);
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(emlFile);
+
+ let buffer = MailStringUtils.byteStringToUint8Array(rawBinaryString);
+ await IOUtils.write(pathEmlFile, buffer);
+ return emlFile;
+ }
+
+ async function moveOrCopyMessages(messageIds, { accountId, path }, isMove) {
+ if (
+ !context.extension.hasPermission("accountsRead") ||
+ !context.extension.hasPermission("messagesMove")
+ ) {
+ throw new ExtensionError(
+ `Using messages.${
+ isMove ? "move" : "copy"
+ }() requires the "accountsRead" and the "messagesMove" permission`
+ );
+ }
+ let destinationURI = folderPathToURI(accountId, path);
+ let destinationFolder =
+ MailServices.folderLookup.getFolderForURL(destinationURI);
+ try {
+ let promises = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (sourceFolder == destinationFolder) {
+ continue;
+ }
+ let msgHeaders = [...msgHeaderSet];
+
+ // Special handling for external messages.
+ if (!sourceFolder) {
+ if (isMove) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+
+ for (let msgHdr of msgHeaders) {
+ let file;
+ let fileUrl = msgHdr.getStringProperty("dummyMsgUrl");
+ if (fileUrl.startsWith("file://")) {
+ file = Services.io
+ .newURI(fileUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ } else {
+ file = await createTempFileMessage(msgHdr);
+ }
+
+ promises.push(
+ new Promise((resolve, reject) => {
+ MailServices.copy.copyFileMessage(
+ file,
+ destinationFolder,
+ /* msgToReplace */ null,
+ /* isDraftOrTemplate */ false,
+ /* aMsgFlags */ Ci.nsMsgMessageFlags.Read,
+ /* aMsgKeywords */ "",
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null
+ );
+ })
+ );
+ }
+ continue;
+ }
+
+ // Since the archiver falls back to copy if delete is not supported,
+ // lets do that here as well.
+ promises.push(
+ new Promise((resolve, reject) => {
+ MailServices.copy.copyMessages(
+ sourceFolder,
+ msgHeaders,
+ destinationFolder,
+ isMove && sourceFolder.canDeleteMessages,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null,
+ /* allowUndo */ true
+ );
+ })
+ );
+ }
+ await Promise.all(promises);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(
+ `Error ${isMove ? "moving" : "copying"} message: ${ex.message}`
+ );
+ }
+ }
+
+ return {
+ messages: {
+ onNewMailReceived: new EventManager({
+ context,
+ module: "messages",
+ event: "onNewMailReceived",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "messages",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ onMoved: new EventManager({
+ context,
+ module: "messages",
+ event: "onMoved",
+ extensionApi: this,
+ }).api(),
+ onCopied: new EventManager({
+ context,
+ module: "messages",
+ event: "onCopied",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "messages",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ async list({ accountId, path }) {
+ let uri = folderPathToURI(accountId, path);
+ let folder = MailServices.folderLookup.getFolderForURL(uri);
+
+ if (!folder) {
+ throw new ExtensionError(`Folder not found: ${path}`);
+ }
+
+ return messageListTracker.startList(
+ folder.messages,
+ context.extension
+ );
+ },
+ async continueList(messageListId) {
+ let messageList = messageListTracker.getList(
+ messageListId,
+ context.extension
+ );
+ return messageListTracker.getNextPage(messageList);
+ },
+ async get(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let messageHeader = convertMessage(msgHdr, context.extension);
+ if (messageHeader.id != messageId) {
+ throw new ExtensionError(
+ "Unexpected Error: Returned message does not equal requested message."
+ );
+ }
+ return messageHeader;
+ },
+ async getFull(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let mimeMsg = await getMimeMessage(msgHdr);
+ if (!mimeMsg) {
+ throw new ExtensionError(`Error reading message ${messageId}`);
+ }
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.Partial) {
+ // Do not include fake body.
+ mimeMsg.parts = [];
+ }
+ return convertMessagePart(mimeMsg);
+ },
+ async getRaw(messageId, options) {
+ let data_format = options?.data_format;
+ if (!["File", "BinaryString"].includes(data_format)) {
+ data_format =
+ extension.manifestVersion < 3 ? "BinaryString" : "File";
+ }
+
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ try {
+ let raw = await getRawMessage(msgHdr);
+ if (data_format == "File") {
+ // Convert binary string to Uint8Array and return a File.
+ let bytes = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) {
+ bytes[i] = raw.charCodeAt(i) & 0xff;
+ }
+ return new File([bytes], `message-${messageId}.eml`, {
+ type: "message/rfc822",
+ });
+ }
+ return raw;
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error reading message ${messageId}`);
+ }
+ },
+ async listAttachments(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachments = await getAttachments(msgHdr);
+ for (let i = 0; i < attachments.length; i++) {
+ attachments[i] = await convertAttachment(attachments[i]);
+ }
+ return attachments;
+ },
+ async getAttachmentFile(messageId, partName) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachment = await getAttachment(msgHdr, partName, {
+ includeRaw: true,
+ });
+ if (!attachment) {
+ throw new ExtensionError(
+ `Part ${partName} not found in message ${messageId}.`
+ );
+ }
+ return new File([attachment.raw], attachment.name, {
+ type: attachment.contentType,
+ });
+ },
+ async openAttachment(messageId, partName, tabId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachment = await getAttachment(msgHdr, partName);
+ if (!attachment) {
+ throw new ExtensionError(
+ `Part ${partName} not found in message ${messageId}.`
+ );
+ }
+ let attachmentInfo = new AttachmentInfo({
+ contentType: attachment.contentType,
+ url: attachment.url,
+ name: attachment.name,
+ uri: msgHdr.folder.getUriForMsg(msgHdr),
+ isExternalAttachment: attachment.isExternal,
+ message: msgHdr,
+ });
+ let tab = tabManager.get(tabId);
+ try {
+ // Content tabs or content windows use browser, while mail and message
+ // tabs use chromeBrowser.
+ let browser = tab.nativeTab.chromeBrowser || tab.nativeTab.browser;
+ await attachmentInfo.open(browser.browsingContext);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Part ${partName} could not be opened: ${ex}.`
+ );
+ }
+ },
+ async query(queryInfo) {
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ const includesContent = (folder, parts, searchTerm) => {
+ if (!parts || parts.length == 0) {
+ return false;
+ }
+ for (let part of parts) {
+ if (
+ coerceBodyToPlaintext(folder, part).includes(searchTerm) ||
+ includesContent(folder, part.parts, searchTerm)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ const coerceBodyToPlaintext = (folder, part) => {
+ if (!part || !part.body) {
+ return "";
+ }
+ if (part.contentType == "text/plain") {
+ return part.body;
+ }
+ // text/enriched gets transformed into HTML by libmime
+ if (
+ part.contentType == "text/html" ||
+ part.contentType == "text/enriched"
+ ) {
+ return folder.convertMsgSnippetToPlainText(part.body);
+ }
+ return "";
+ };
+
+ /**
+ * Prepare name and email properties of the address object returned by
+ * MailServices.headerParser.makeFromDisplayAddress() to be lower case.
+ * Also fix the name being wrongly returned in the email property, if
+ * the address was just a single name.
+ */
+ const prepareAddress = displayAddr => {
+ let email = displayAddr.email?.toLocaleLowerCase();
+ let name = displayAddr.name?.toLocaleLowerCase();
+ if (email && !name && !email.includes("@")) {
+ name = email;
+ email = null;
+ }
+ return { name, email };
+ };
+
+ /**
+ * Check multiple addresses if they match the provided search address.
+ *
+ * @returns A boolean indicating if search was successful.
+ */
+ const searchInMultipleAddresses = (searchAddress, addresses) => {
+ // Return on first positive match.
+ for (let address of addresses) {
+ let nameMatched =
+ searchAddress.name &&
+ address.name &&
+ address.name.includes(searchAddress.name);
+
+ // Check for email match. Name match being required on top, if
+ // specified.
+ if (
+ (nameMatched || !searchAddress.name) &&
+ searchAddress.email &&
+ address.email &&
+ address.email == searchAddress.email
+ ) {
+ return true;
+ }
+
+ // If address match failed, name match may only be true if no
+ // email has been specified.
+ if (!searchAddress.email && nameMatched) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ /**
+ * Substring match on name and exact match on email. If searchTerm
+ * includes multiple addresses, all of them must match.
+ *
+ * @returns A boolean indicating if search was successful.
+ */
+ const isAddressMatch = (searchTerm, addressObjects) => {
+ let searchAddresses =
+ MailServices.headerParser.makeFromDisplayAddress(searchTerm);
+ if (!searchAddresses || searchAddresses.length == 0) {
+ return false;
+ }
+
+ // Prepare addresses.
+ let addresses = [];
+ for (let addressObject of addressObjects) {
+ let decodedAddressString = addressObject.doRfc2047
+ ? jsmime.headerparser.decodeRFC2047Words(addressObject.addr)
+ : addressObject.addr;
+ for (let address of MailServices.headerParser.makeFromDisplayAddress(
+ decodedAddressString
+ )) {
+ addresses.push(prepareAddress(address));
+ }
+ }
+ if (addresses.length == 0) {
+ return false;
+ }
+
+ let success = false;
+ for (let searchAddress of searchAddresses) {
+ // Exit early if this search was not successfully, but all search
+ // addresses have to be matched.
+ if (
+ !searchInMultipleAddresses(
+ prepareAddress(searchAddress),
+ addresses
+ )
+ ) {
+ return false;
+ }
+ success = true;
+ }
+
+ return success;
+ };
+
+ const checkSearchCriteria = async (folder, msg) => {
+ // Check date ranges.
+ if (
+ queryInfo.fromDate !== null &&
+ msg.dateInSeconds * 1000 < queryInfo.fromDate.getTime()
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.toDate !== null &&
+ msg.dateInSeconds * 1000 > queryInfo.toDate.getTime()
+ ) {
+ return false;
+ }
+
+ // Check headerMessageId.
+ if (
+ queryInfo.headerMessageId &&
+ msg.messageId != queryInfo.headerMessageId
+ ) {
+ return false;
+ }
+
+ // Check unread.
+ if (queryInfo.unread !== null && msg.isRead != !queryInfo.unread) {
+ return false;
+ }
+
+ // Check flagged.
+ if (
+ queryInfo.flagged !== null &&
+ msg.isFlagged != queryInfo.flagged
+ ) {
+ return false;
+ }
+
+ // Check subject (substring match).
+ if (
+ queryInfo.subject &&
+ !msg.mime2DecodedSubject.includes(queryInfo.subject)
+ ) {
+ return false;
+ }
+
+ // Check tags.
+ if (requiredTags || forbiddenTags) {
+ let messageTags = msg.getStringProperty("keywords").split(" ");
+ if (requiredTags.length > 0) {
+ if (
+ queryInfo.tags.mode == "all" &&
+ !requiredTags.every(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.tags.mode == "any" &&
+ !requiredTags.some(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ }
+ if (forbiddenTags.length > 0) {
+ if (
+ queryInfo.tags.mode == "all" &&
+ forbiddenTags.every(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.tags.mode == "any" &&
+ forbiddenTags.some(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ }
+ }
+
+ // Check toMe (case insensitive email address match).
+ if (queryInfo.toMe !== null) {
+ let recipients = [].concat(
+ composeFields.splitRecipients(msg.recipients, true),
+ composeFields.splitRecipients(msg.ccList, true),
+ composeFields.splitRecipients(msg.bccList, true)
+ );
+
+ if (
+ queryInfo.toMe !=
+ recipients.some(email =>
+ identities.includes(email.toLocaleLowerCase())
+ )
+ ) {
+ return false;
+ }
+ }
+
+ // Check fromMe (case insensitive email address match).
+ if (queryInfo.fromMe !== null) {
+ let authors = composeFields.splitRecipients(
+ msg.mime2DecodedAuthor,
+ true
+ );
+ if (
+ queryInfo.fromMe !=
+ authors.some(email =>
+ identities.includes(email.toLocaleLowerCase())
+ )
+ ) {
+ return false;
+ }
+ }
+
+ // Check author.
+ if (
+ queryInfo.author &&
+ !isAddressMatch(queryInfo.author, [
+ { addr: msg.mime2DecodedAuthor, doRfc2047: false },
+ ])
+ ) {
+ return false;
+ }
+
+ // Check recipients.
+ if (
+ queryInfo.recipients &&
+ !isAddressMatch(queryInfo.recipients, [
+ { addr: msg.mime2DecodedRecipients, doRfc2047: false },
+ { addr: msg.ccList, doRfc2047: true },
+ { addr: msg.bccList, doRfc2047: true },
+ ])
+ ) {
+ return false;
+ }
+
+ // Check if fullText is already partially fulfilled.
+ let fullTextBodySearchNeeded = false;
+ if (queryInfo.fullText) {
+ let subjectMatches = msg.mime2DecodedSubject.includes(
+ queryInfo.fullText
+ );
+ let authorMatches = msg.mime2DecodedAuthor.includes(
+ queryInfo.fullText
+ );
+ fullTextBodySearchNeeded = !(subjectMatches || authorMatches);
+ }
+
+ // Check body.
+ if (queryInfo.body || fullTextBodySearchNeeded) {
+ let mimeMsg = await getMimeMessage(msg);
+ if (
+ queryInfo.body &&
+ !includesContent(folder, [mimeMsg], queryInfo.body)
+ ) {
+ return false;
+ }
+ if (
+ fullTextBodySearchNeeded &&
+ !includesContent(folder, [mimeMsg], queryInfo.fullText)
+ ) {
+ return false;
+ }
+ }
+
+ // Check attachments.
+ if (queryInfo.attachment != null) {
+ let attachments = await getAttachments(
+ msg,
+ /* includeNestedAttachments */ true
+ );
+ return !!attachments.length == queryInfo.attachment;
+ }
+
+ return true;
+ };
+
+ const searchMessages = async (
+ folder,
+ messageList,
+ includeSubFolders = false
+ ) => {
+ let messages = null;
+ try {
+ messages = folder.messages;
+ } catch (e) {
+ /* Some folders fail on message query, instead of returning empty */
+ }
+
+ if (messages) {
+ for (let msg of [...messages]) {
+ if (await checkSearchCriteria(folder, msg)) {
+ messageList.add(msg);
+ }
+ }
+ }
+
+ if (includeSubFolders) {
+ for (let subFolder of folder.subFolders) {
+ await searchMessages(subFolder, messageList, true);
+ }
+ }
+ };
+
+ const searchFolders = async (
+ folders,
+ messageList,
+ includeSubFolders = false
+ ) => {
+ for (let folder of folders) {
+ await searchMessages(folder, messageList, includeSubFolders);
+ }
+ return messageList.done();
+ };
+
+ // Prepare case insensitive me filtering.
+ let identities;
+ if (queryInfo.toMe !== null || queryInfo.fromMe !== null) {
+ identities = MailServices.accounts.allIdentities.map(i =>
+ i.email.toLocaleLowerCase()
+ );
+ }
+
+ // Prepare tag filtering.
+ let requiredTags;
+ let forbiddenTags;
+ if (queryInfo.tags) {
+ let availableTags = MailServices.tags.getAllTags();
+ requiredTags = availableTags.filter(
+ tag =>
+ tag.key in queryInfo.tags.tags && queryInfo.tags.tags[tag.key]
+ );
+ forbiddenTags = availableTags.filter(
+ tag =>
+ tag.key in queryInfo.tags.tags && !queryInfo.tags.tags[tag.key]
+ );
+ // If non-existing tags have been required, return immediately with
+ // an empty message list.
+ if (
+ requiredTags.length === 0 &&
+ Object.values(queryInfo.tags.tags).filter(v => v).length > 0
+ ) {
+ return messageListTracker.startList([], context.extension);
+ }
+ requiredTags = requiredTags.map(tag => tag.key);
+ forbiddenTags = forbiddenTags.map(tag => tag.key);
+ }
+
+ // Limit search to a given folder, or search all folders.
+ let folders = [];
+ let includeSubFolders = false;
+ if (queryInfo.folder) {
+ includeSubFolders = !!queryInfo.includeSubFolders;
+ if (!context.extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Querying by folder requires the "accountsRead" permission'
+ );
+ }
+ let folder = MailServices.folderLookup.getFolderForURL(
+ folderPathToURI(queryInfo.folder.accountId, queryInfo.folder.path)
+ );
+ if (!folder) {
+ throw new ExtensionError(
+ `Folder not found: ${queryInfo.folder.path}`
+ );
+ }
+ folders.push(folder);
+ } else {
+ includeSubFolders = true;
+ for (let account of MailServices.accounts.accounts) {
+ folders.push(account.incomingServer.rootFolder);
+ }
+ }
+
+ // The searchFolders() function searches the provided folders for
+ // messages matching the query and adds results to the messageList. It
+ // is an asynchronous function, but it is not awaited here. Instead,
+ // messageListTracker.getNextPage() returns a Promise, which will
+ // fulfill after enough messages for a full page have been added.
+ let messageList = messageListTracker.createList(context.extension);
+ searchFolders(folders, messageList, includeSubFolders);
+ return messageListTracker.getNextPage(messageList);
+ },
+ async update(messageId, newProperties) {
+ try {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ if (!msgHdr.folder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+
+ let msgs = [msgHdr];
+ if (newProperties.read !== null) {
+ msgHdr.folder.markMessagesRead(msgs, newProperties.read);
+ }
+ if (newProperties.flagged !== null) {
+ msgHdr.folder.markMessagesFlagged(msgs, newProperties.flagged);
+ }
+ if (newProperties.junk !== null) {
+ let score = newProperties.junk
+ ? Ci.nsIJunkMailPlugin.IS_SPAM_SCORE
+ : Ci.nsIJunkMailPlugin.IS_HAM_SCORE;
+ msgHdr.folder.setJunkScoreForMessages(msgs, score);
+ // nsIFolderListener::OnFolderEvent is notified about changes through
+ // setJunkScoreForMessages(), but does not provide the actual message.
+ // nsIMsgFolderListener::msgsJunkStatusChanged is notified only by
+ // nsMsgDBView::ApplyCommandToIndices(). Since it only works on
+ // selected messages, we cannot use it here.
+ // Notify msgsJunkStatusChanged() manually.
+ MailServices.mfn.notifyMsgsJunkStatusChanged(msgs);
+ }
+ if (Array.isArray(newProperties.tags)) {
+ let currentTags = msgHdr.getStringProperty("keywords").split(" ");
+
+ for (let { key: tagKey } of MailServices.tags.getAllTags()) {
+ if (newProperties.tags.includes(tagKey)) {
+ if (!currentTags.includes(tagKey)) {
+ msgHdr.folder.addKeywordsToMessages(msgs, tagKey);
+ }
+ } else if (currentTags.includes(tagKey)) {
+ msgHdr.folder.removeKeywordsFromMessages(msgs, tagKey);
+ }
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error updating message: ${ex.message}`);
+ }
+ },
+ async move(messageIds, destination) {
+ return moveOrCopyMessages(messageIds, destination, true);
+ },
+ async copy(messageIds, destination) {
+ return moveOrCopyMessages(messageIds, destination, false);
+ },
+ async delete(messageIds, skipTrash) {
+ try {
+ let promises = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (!sourceFolder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+ if (!sourceFolder.canDeleteMessages) {
+ throw new ExtensionError(
+ `Messages in "${sourceFolder.prettyName}" cannot be deleted`
+ );
+ }
+ promises.push(
+ new Promise((resolve, reject) => {
+ sourceFolder.deleteMessages(
+ [...msgHeaderSet],
+ /* msgWindow */ null,
+ /* deleteStorage */ skipTrash,
+ /* isMove */ false,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* allowUndo */ true
+ );
+ })
+ );
+ }
+ await Promise.all(promises);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error deleting message: ${ex.message}`);
+ }
+ },
+ async import(file, { accountId, path }, properties) {
+ if (
+ !context.extension.hasPermission("accountsRead") ||
+ !context.extension.hasPermission("messagesImport")
+ ) {
+ throw new ExtensionError(
+ `Using messages.import() requires the "accountsRead" and the "messagesImport" permission`
+ );
+ }
+ let destinationURI = folderPathToURI(accountId, path);
+ let destinationFolder =
+ MailServices.folderLookup.getFolderForURL(destinationURI);
+ if (!destinationFolder) {
+ throw new ExtensionError(`Folder not found: ${path}`);
+ }
+ if (!["none", "pop3"].includes(destinationFolder.server.type)) {
+ throw new ExtensionError(
+ `browser.messenger.import() is not supported for ${destinationFolder.server.type} accounts`
+ );
+ }
+ try {
+ let tempFile = await getRealFileForFile(file);
+ let msgHeader = await new Promise((resolve, reject) => {
+ let newKey = null;
+ let msgHdrs = new Map();
+
+ let folderListener = {
+ onMessageAdded(parentItem, msgHdr) {
+ if (destinationFolder.URI != msgHdr.folder.URI) {
+ return;
+ }
+ let key = msgHdr.messageKey;
+ msgHdrs.set(key, msgHdr);
+ if (msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ },
+ onFolderAdded(parent, child) {},
+ };
+
+ // Note: Currently this API is not supported for IMAP. Once this gets added (Bug 1787104),
+ // please note that the MailServices.mfn.addListener will fire only when the IMAP message
+ // is visibly shown in the UI, while MailServices.mailSession.AddFolderListener fires as
+ // soon as it has been added to the database .
+ MailServices.mailSession.AddFolderListener(
+ folderListener,
+ Ci.nsIFolderListener.added
+ );
+
+ let finish = msgHdr => {
+ MailServices.mailSession.RemoveFolderListener(folderListener);
+ resolve(msgHdr);
+ };
+
+ let tags = "";
+ let flags = 0;
+ if (properties) {
+ if (properties.tags) {
+ let knownTags = MailServices.tags
+ .getAllTags()
+ .map(tag => tag.key);
+ tags = properties.tags
+ .filter(tag => knownTags.includes(tag))
+ .join(" ");
+ }
+ flags |= properties.new ? Ci.nsMsgMessageFlags.New : 0;
+ flags |= properties.read ? Ci.nsMsgMessageFlags.Read : 0;
+ flags |= properties.flagged ? Ci.nsMsgMessageFlags.Marked : 0;
+ }
+ MailServices.copy.copyFileMessage(
+ tempFile,
+ destinationFolder,
+ /* msgToReplace */ null,
+ /* isDraftOrTemplate */ false,
+ /* aMsgFlags */ flags,
+ /* aMsgKeywords */ tags,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(aKey) {
+ /* Note: Not fired for offline IMAP. Add missing
+ * if (aCopyState) {
+ * ((nsImapMailCopyState*)aCopyState)->m_listener->SetMessageKey(fakeKey);
+ * }
+ * before firing the OnStopRunningUrl listener in
+ * nsImapService::OfflineAppendFromFile
+ */
+ newKey = aKey;
+ if (msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ },
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ if (newKey && msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null
+ );
+ });
+
+ // Do not wait till the temp file is removed on app shutdown. However, skip deletion if
+ // the provided DOM File was already linked to a real file.
+ if (!file.mozFullPath) {
+ await IOUtils.remove(tempFile.path);
+ }
+ return convertMessage(msgHeader, context.extension);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error importing message: ${ex.message}`);
+ }
+ },
+ async archive(messageIds) {
+ try {
+ let messages = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (!sourceFolder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+ messages.push(...msgHeaderSet);
+ }
+ await new Promise(resolve => {
+ let archiver = new MessageArchiver();
+ archiver.oncomplete = resolve;
+ archiver.archiveMessages(messages);
+ });
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error archiving message: ${ex.message}`);
+ }
+ },
+ async listTags() {
+ return MailServices.tags
+ .getAllTags()
+ .map(({ key, tag, color, ordinal }) => {
+ return {
+ key,
+ tag,
+ color,
+ ordinal,
+ };
+ });
+ },
+ async createTag(key, tag, color) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ if (tags.find(t => t.key == key)) {
+ throw new ExtensionError(`Specified key already exists: ${key}`);
+ }
+ if (tags.find(t => t.tag == tag)) {
+ throw new ExtensionError(`Specified tag already exists: ${tag}`);
+ }
+ MailServices.tags.addTagForKey(key, tag, color, "");
+ },
+ async updateTag(key, updateProperties) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ let tag = tags.find(t => t.key == key);
+ if (!tag) {
+ throw new ExtensionError(`Specified key does not exist: ${key}`);
+ }
+ if (updateProperties.color && tag.color != updateProperties.color) {
+ MailServices.tags.setColorForKey(key, updateProperties.color);
+ }
+ if (updateProperties.tag && tag.tag != updateProperties.tag) {
+ // Don't let the user edit a tag to the name of another existing tag.
+ if (tags.find(t => t.tag == updateProperties.tag)) {
+ throw new ExtensionError(
+ `Specified tag already exists: ${updateProperties.tag}`
+ );
+ }
+ MailServices.tags.setTagForKey(key, updateProperties.tag);
+ }
+ },
+ async deleteTag(key) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ if (!tags.find(t => t.key == key)) {
+ throw new ExtensionError(`Specified key does not exist: ${key}`);
+ }
+ MailServices.tags.deleteKey(key);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-sessions.js b/comm/mail/components/extensions/parent/ext-sessions.js
new file mode 100644
index 0000000000..3abe652fe3
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-sessions.js
@@ -0,0 +1,62 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+function getSessionData(tabId, extension) {
+ let nativeTab = tabTracker.getTab(tabId);
+ let widgetId = makeWidgetId(extension.id);
+
+ if (!nativeTab._ext.extensionSession) {
+ nativeTab._ext.extensionSession = {};
+ }
+ if (!nativeTab._ext.extensionSession[`${widgetId}`]) {
+ nativeTab._ext.extensionSession[`${widgetId}`] = {};
+ }
+ return nativeTab._ext.extensionSession[`${widgetId}`];
+}
+
+this.sessions = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ sessions: {
+ setTabValue(tabId, key, value) {
+ let sessionData = getSessionData(tabId, context.extension);
+ sessionData[key] = value;
+ },
+ getTabValue(tabId, key) {
+ let sessionData = getSessionData(tabId, context.extension);
+ return sessionData[key];
+ },
+ removeTabValue(tabId, key) {
+ let sessionData = getSessionData(tabId, context.extension);
+ delete sessionData[key];
+ },
+ },
+ };
+ }
+
+ static onUninstall(extensionId) {
+ // Remove session data.
+ let widgetId = makeWidgetId(extensionId);
+ for (let window of Services.wm.getEnumerator("mail:3pane")) {
+ for (let tabInfo of window.gTabmail.tabInfo) {
+ if (
+ tabInfo._ext.extensionSession &&
+ tabInfo._ext.extensionSession[`${widgetId}`]
+ ) {
+ delete tabInfo._ext.extensionSession[`${widgetId}`];
+ }
+ }
+ }
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-spaces.js b/comm/mail/components/extensions/parent/ext-spaces.js
new file mode 100644
index 0000000000..3f2ade0404
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-spaces.js
@@ -0,0 +1,364 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "getIconData",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
+
+var windowURLs = ["chrome://messenger/content/messenger.xhtml"];
+
+/**
+ * Return the paths to the 16px and 32px icons defined in the manifest of this
+ * extension, if any.
+ *
+ * @param {ExtensionData} extension - the extension to retrieve the path object for
+ */
+function getManifestIcons(extension) {
+ if (extension.manifest.icons) {
+ let { icon: icon16 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 16
+ );
+ let { icon: icon32 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 32
+ );
+ return {
+ 16: extension.baseURI.resolve(icon16),
+ 32: extension.baseURI.resolve(icon32),
+ };
+ }
+ return null;
+}
+
+/**
+ * Convert WebExtension SpaceButtonProperties into a NativeButtonProperties
+ * object required by the gSpacesToolbar.* functions.
+ *
+ * @param {SpaceData} spaceData - @see mail/components/extensions/parent/ext-mail.js
+ * @returns {NativeButtonProperties} - @see mail/base/content/spacesToolbar.js
+ */
+function getNativeButtonProperties({
+ extension,
+ defaultUrl,
+ buttonProperties,
+}) {
+ const normalizeColor = color => {
+ if (typeof color == "string") {
+ let col = InspectorUtils.colorToRGBA(color);
+ if (!col) {
+ throw new ExtensionError(`Invalid color value: "${color}"`);
+ }
+ return [col.r, col.g, col.b, Math.round(col.a * 255)];
+ }
+ return color;
+ };
+
+ let hasThemeIcons =
+ buttonProperties.themeIcons && buttonProperties.themeIcons.length > 0;
+
+ // If themeIcons have been defined, ignore manifestIcons as fallback and use
+ // themeIcons for the default theme as well, following the behavior of
+ // WebExtension action buttons.
+ let fallbackManifestIcons = hasThemeIcons
+ ? null
+ : getManifestIcons(extension);
+
+ // Use _normalize() to bypass cache.
+ let icons = ExtensionParent.IconDetails._normalize(
+ {
+ path: buttonProperties.defaultIcons || fallbackManifestIcons,
+ themeIcons: hasThemeIcons ? buttonProperties.themeIcons : null,
+ },
+ extension
+ );
+ let iconStyles = new Map(getIconData(icons, extension).style);
+
+ let badgeStyles = new Map();
+ let bgColor = normalizeColor(buttonProperties.badgeBackgroundColor);
+ if (bgColor) {
+ badgeStyles.set(
+ "--spaces-button-badge-bg-color",
+ `rgba(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]}, ${bgColor[3] / 255})`
+ );
+ }
+
+ return {
+ title: buttonProperties.title || extension.name,
+ url: defaultUrl,
+ badgeText: buttonProperties.badgeText,
+ badgeStyles,
+ iconStyles,
+ };
+}
+
+ExtensionSupport.registerWindowListener("ext-spaces", {
+ chromeURLs: windowURLs,
+ onLoadWindow: async window => {
+ await new Promise(resolve => {
+ if (window.gSpacesToolbar.isLoaded) {
+ resolve();
+ } else {
+ window.addEventListener("spaces-toolbar-ready", resolve, {
+ once: true,
+ });
+ }
+ });
+ // Add buttons of all extension spaces to the toolbar of each newly opened
+ // normal window.
+ for (let spaceData of spaceTracker.getAll()) {
+ if (!spaceData.extension) {
+ continue;
+ }
+ let nativeButtonProperties = getNativeButtonProperties(spaceData);
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ },
+});
+
+this.spaces = class extends ExtensionAPI {
+ /**
+ * Match a WebExtension Space object against the provided queryInfo.
+ *
+ * @param {Space} space - @see mail/components/extensions/schemas/spaces.json
+ * @param {QueryInfo} queryInfo - @see mail/components/extensions/schemas/spaces.json
+ * @returns {boolean}
+ */
+ matchSpace(space, queryInfo) {
+ if (queryInfo.id != null && space.id != queryInfo.id) {
+ return false;
+ }
+ if (queryInfo.name != null && space.name != queryInfo.name) {
+ return false;
+ }
+ if (queryInfo.isBuiltIn != null && space.isBuiltIn != queryInfo.isBuiltIn) {
+ return false;
+ }
+ if (
+ queryInfo.isSelfOwned != null &&
+ space.isSelfOwned != queryInfo.isSelfOwned
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.extensionId != null &&
+ space.extensionId != queryInfo.extensionId
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ async onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+
+ let extensionId = this.extension.id;
+ for (let spaceData of spaceTracker.getAll()) {
+ if (spaceData.extension?.id != extensionId) {
+ continue;
+ }
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ }
+ }
+
+ getAPI(context) {
+ let { tabManager } = context.extension;
+ let self = this;
+
+ return {
+ spaces: {
+ async create(name, defaultUrl, buttonProperties) {
+ if (spaceTracker.fromSpaceName(name, context.extension)) {
+ throw new ExtensionError(
+ `Failed to create space with name ${name}: Space already exists for this extension.`
+ );
+ }
+
+ defaultUrl = context.uri.resolve(defaultUrl);
+ if (!/((^https:)|(^http:)|(^moz-extension:))/i.test(defaultUrl)) {
+ throw new ExtensionError(
+ `Failed to create space with name ${name}: Invalid default url.`
+ );
+ }
+
+ try {
+ let spaceData = await spaceTracker.create(
+ name,
+ defaultUrl,
+ buttonProperties,
+ context.extension
+ );
+
+ let nativeButtonProperties = getNativeButtonProperties(spaceData);
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+
+ return spaceTracker.convert(spaceData, context.extension);
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to create space with name ${name}: ${error}`
+ );
+ }
+ },
+ async remove(spaceId) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to remove space with id ${spaceId}: Unknown id.`
+ );
+ }
+ if (spaceData.extension?.id != context.extension.id) {
+ throw new ExtensionError(
+ `Failed to remove space with id ${spaceId}: Space does not belong to this extension.`
+ );
+ }
+
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Failed to remove space with id ${spaceId}: ${ex.message}`
+ );
+ }
+ },
+ async update(spaceId, updatedDefaultUrl, updatedButtonProperties) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: Unknown id.`
+ );
+ }
+ if (spaceData.extension?.id != context.extension.id) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: Space does not belong to this extension.`
+ );
+ }
+
+ let changes = false;
+ if (updatedDefaultUrl) {
+ updatedDefaultUrl = context.uri.resolve(updatedDefaultUrl);
+ if (
+ !/((^https:)|(^http:)|(^moz-extension:))/i.test(updatedDefaultUrl)
+ ) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: Invalid default url.`
+ );
+ }
+ spaceData.defaultUrl = updatedDefaultUrl;
+ changes = true;
+ }
+
+ if (updatedButtonProperties) {
+ for (let [key, value] of Object.entries(updatedButtonProperties)) {
+ if (value != null) {
+ spaceData.buttonProperties[key] = value;
+ changes = true;
+ }
+ }
+ }
+
+ if (changes) {
+ let nativeButtonProperties = getNativeButtonProperties(spaceData);
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.updateToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+ spaceTracker.update(spaceData);
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: ${error}`
+ );
+ }
+ }
+ },
+ async open(spaceId, windowId) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to open space with id ${spaceId}: Unknown id.`
+ );
+ }
+
+ let window = await getNormalWindowReady(context, windowId);
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.button.id == spaceData.spaceButtonId
+ );
+
+ let tabmail = window.document.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ let nativeTabInfo = window.gSpacesToolbar.openSpace(tabmail, space);
+ return tabManager.convert(nativeTabInfo, currentTab);
+ },
+ async get(spaceId) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to get space with id ${spaceId}: Unknown id.`
+ );
+ }
+ return spaceTracker.convert(spaceData, context.extension);
+ },
+ async query(queryInfo) {
+ let allSpaceData = [...spaceTracker.getAll()];
+ return allSpaceData
+ .map(spaceData =>
+ spaceTracker.convert(spaceData, context.extension)
+ )
+ .filter(space => self.matchSpace(space, queryInfo));
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-spacesToolbar.js b/comm/mail/components/extensions/parent/ext-spacesToolbar.js
new file mode 100644
index 0000000000..1a42aa0a6e
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-spacesToolbar.js
@@ -0,0 +1,308 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "getIconData",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
+
+var { makeWidgetId } = ExtensionCommon;
+
+var windowURLs = ["chrome://messenger/content/messenger.xhtml"];
+
+/**
+ * Return the paths to the 16px and 32px icons defined in the manifest of this
+ * extension, if any.
+ *
+ * @param {ExtensionData} extension - the extension to retrieve the path object for
+ */
+function getManifestIcons(extension) {
+ if (extension.manifest.icons) {
+ let { icon: icon16 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 16
+ );
+ let { icon: icon32 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 32
+ );
+ return {
+ 16: extension.baseURI.resolve(icon16),
+ 32: extension.baseURI.resolve(icon32),
+ };
+ }
+ return null;
+}
+
+/**
+ * Convert WebExtension SpaceButtonProperties into a NativeButtonProperties
+ * object required by the gSpacesToolbar.* functions.
+ *
+ * @param {SpaceData} spaceData - @see mail/components/extensions/parent/ext-mail.js
+ * @returns {NativeButtonProperties} - @see mail/base/content/spacesToolbar.js
+ */
+function convertProperties({ extension, buttonProperties }) {
+ const normalizeColor = color => {
+ if (typeof color == "string") {
+ let col = InspectorUtils.colorToRGBA(color);
+ if (!col) {
+ throw new ExtensionError(`Invalid color value: "${color}"`);
+ }
+ return [col.r, col.g, col.b, Math.round(col.a * 255)];
+ }
+ return color;
+ };
+
+ let hasThemeIcons =
+ buttonProperties.themeIcons && buttonProperties.themeIcons.length > 0;
+
+ // If themeIcons have been defined, ignore manifestIcons as fallback and use
+ // themeIcons for the default theme as well, following the behavior of
+ // WebExtension action buttons.
+ let fallbackManifestIcons = hasThemeIcons
+ ? null
+ : getManifestIcons(extension);
+
+ // Use _normalize() to bypass cache.
+ let icons = ExtensionParent.IconDetails._normalize(
+ {
+ path: buttonProperties.defaultIcons || fallbackManifestIcons,
+ themeIcons: hasThemeIcons ? buttonProperties.themeIcons : null,
+ },
+ extension
+ );
+ let iconStyles = new Map(getIconData(icons, extension).style);
+
+ let badgeStyles = new Map();
+ let bgColor = normalizeColor(buttonProperties.badgeBackgroundColor);
+ if (bgColor) {
+ badgeStyles.set(
+ "--spaces-button-badge-bg-color",
+ `rgba(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]}, ${bgColor[3] / 255})`
+ );
+ }
+
+ return {
+ title: buttonProperties.title || extension.name,
+ url: buttonProperties.url,
+ badgeText: buttonProperties.badgeText,
+ badgeStyles,
+ iconStyles,
+ };
+}
+
+ExtensionSupport.registerWindowListener("ext-spacesToolbar", {
+ chromeURLs: windowURLs,
+ onLoadWindow: async window => {
+ await new Promise(resolve => {
+ if (window.gSpacesToolbar.isLoaded) {
+ resolve();
+ } else {
+ window.addEventListener("spaces-toolbar-ready", resolve, {
+ once: true,
+ });
+ }
+ });
+ // Add buttons of all extension spaces to the toolbar of each newly opened
+ // normal window.
+ for (let spaceData of spaceTracker.getAll()) {
+ if (!spaceData.extension) {
+ continue;
+ }
+ let nativeButtonProperties = convertProperties(spaceData);
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ },
+});
+
+this.spacesToolbar = class extends ExtensionAPI {
+ async onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+
+ let extensionId = this.extension.id;
+ for (let spaceData of spaceTracker.getAll()) {
+ if (spaceData.extension?.id != extensionId) {
+ continue;
+ }
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ }
+ }
+
+ getAPI(context) {
+ this.widgetId = makeWidgetId(context.extension.id);
+ let { tabManager } = context.extension;
+
+ return {
+ spacesToolbar: {
+ async addButton(name, properties) {
+ if (properties.url) {
+ properties.url = context.uri.resolve(properties.url);
+ }
+ let [protocol] = (properties.url || "").split("://");
+ if (
+ !protocol ||
+ !["https", "http", "moz-extension"].includes(protocol)
+ ) {
+ throw new ExtensionError(
+ `Failed to add button to the spaces toolbar: Invalid url.`
+ );
+ }
+
+ if (spaceTracker.fromSpaceName(name, context.extension)) {
+ throw new ExtensionError(
+ `Failed to add button to the spaces toolbar: The id ${name} is already used by this extension.`
+ );
+ }
+ try {
+ let spaceData = await spaceTracker.create(
+ name,
+ properties.url,
+ properties,
+ context.extension
+ );
+
+ let nativeButtonProperties = convertProperties(spaceData);
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+
+ return spaceData.spaceId;
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to add button to the spaces toolbar: ${error}`
+ );
+ }
+ },
+ async removeButton(name) {
+ let spaceData = spaceTracker.fromSpaceName(name, context.extension);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to remove button from the spaces toolbar: A button with id ${name} does not exist for this extension.`
+ );
+ }
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Failed to remove button from the spaces toolbar: ${ex.message}`
+ );
+ }
+ },
+ async updateButton(name, updatedProperties) {
+ let spaceData = spaceTracker.fromSpaceName(name, context.extension);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to update button in the spaces toolbar: A button with id ${name} does not exist for this extension.`
+ );
+ }
+
+ if (updatedProperties.url != null) {
+ updatedProperties.url = context.uri.resolve(updatedProperties.url);
+ let [protocol] = updatedProperties.url.split("://");
+ if (
+ !protocol ||
+ !["https", "http", "moz-extension"].includes(protocol)
+ ) {
+ throw new ExtensionError(
+ `Failed to update button in the spaces toolbar: Invalid url.`
+ );
+ }
+ }
+
+ let changes = false;
+ for (let [key, value] of Object.entries(updatedProperties)) {
+ if (value != null) {
+ if (key == "url") {
+ spaceData.defaultUrl = value;
+ }
+ spaceData.buttonProperties[key] = value;
+ changes = true;
+ }
+ }
+
+ if (changes) {
+ let nativeButtonProperties = convertProperties(spaceData);
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.updateToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+ spaceTracker.update(spaceData);
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to update button in the spaces toolbar: ${error}`
+ );
+ }
+ }
+ },
+ async clickButton(name, windowId) {
+ let spaceData = spaceTracker.fromSpaceName(name, context.extension);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to trigger a click on the spaces toolbar button: A button with id ${name} does not exist for this extension.`
+ );
+ }
+
+ let window = await getNormalWindowReady(context, windowId);
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.button.id == spaceData.spaceButtonId
+ );
+
+ let tabmail = window.document.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ let nativeTabInfo = window.gSpacesToolbar.openSpace(tabmail, space);
+ return tabManager.convert(nativeTabInfo, currentTab);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-tabs.js b/comm/mail/components/extensions/parent/ext-tabs.js
new file mode 100644
index 0000000000..6327743afa
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-tabs.js
@@ -0,0 +1,822 @@
+/* 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/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailE10SUtils",
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+/**
+ * A listener that allows waiting until tabs are fully loaded, e.g. off of about:blank.
+ */
+let tabListener = {
+ tabReadyInitialized: false,
+ tabReadyPromises: new WeakMap(),
+ initializingTabs: new WeakSet(),
+
+ /**
+ * Initialize the progress listener for tab ready changes.
+ */
+ initTabReady() {
+ if (!this.tabReadyInitialized) {
+ windowTracker.addListener("progress", this);
+
+ this.tabReadyInitialized = true;
+ }
+ },
+
+ /**
+ * Web Progress listener method for the location change.
+ *
+ * @param {Element} browser - The browser element that caused the change
+ * @param {nsIWebProgress} webProgress - The web progress for the location change
+ * @param {nsIRequest} request - The xpcom request for this change
+ * @param {nsIURI} locationURI - The target uri
+ * @param {Integer} flags - The web progress flags for this change
+ */
+ onLocationChange(browser, webProgress, request, locationURI, flags) {
+ if (webProgress && webProgress.isTopLevel) {
+ let window = browser.ownerGlobal.top;
+ let tabmail = window.document.getElementById("tabmail");
+ let nativeTabInfo = tabmail ? tabmail.getTabForBrowser(browser) : window;
+
+ // Now we are certain that the first page in the tab was loaded.
+ this.initializingTabs.delete(nativeTabInfo);
+
+ // browser.innerWindowID is now set, resolve the promises if any.
+ let deferred = this.tabReadyPromises.get(nativeTabInfo);
+ if (deferred) {
+ deferred.resolve(nativeTabInfo);
+ this.tabReadyPromises.delete(nativeTabInfo);
+ }
+ }
+ },
+
+ /**
+ * Promise that the given tab completes loading.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - the tabInfo describing the tab
+ * @returns {Promise<NativeTabInfo>} - resolves when the tab completes loading
+ */
+ awaitTabReady(nativeTabInfo) {
+ let deferred = this.tabReadyPromises.get(nativeTabInfo);
+ if (!deferred) {
+ deferred = PromiseUtils.defer();
+ let browser = getTabBrowser(nativeTabInfo);
+ if (
+ !this.initializingTabs.has(nativeTabInfo) &&
+ (browser.innerWindowID ||
+ ["about:blank", "about:blank?compose"].includes(
+ browser.currentURI.spec
+ ))
+ ) {
+ deferred.resolve(nativeTabInfo);
+ } else {
+ this.initTabReady();
+ this.tabReadyPromises.set(nativeTabInfo, deferred);
+ }
+ }
+ return deferred.promise;
+ },
+};
+
+let hasWebHandlerApp = protocol => {
+ let protoInfo = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .getProtocolHandlerInfo(protocol);
+ let appHandlers = protoInfo.possibleApplicationHandlers;
+ for (let i = 0; i < appHandlers.length; i++) {
+ let handler = appHandlers.queryElementAt(i, Ci.nsISupports);
+ if (handler instanceof Ci.nsIWebHandlerApp) {
+ return true;
+ }
+ }
+ return false;
+};
+
+// Attributes and properties used in the TabsUpdateFilterManager.
+const allAttrs = new Set(["favIconUrl", "title"]);
+const allProperties = new Set(["favIconUrl", "status", "title"]);
+const restricted = new Set(["url", "favIconUrl", "title"]);
+
+this.tabs = class extends ExtensionAPIPersistent {
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+ for (let window of Services.wm.getEnumerator("mail:3pane")) {
+ let tabmail = window.document.getElementById("tabmail");
+ for (let i = tabmail.tabInfo.length; i > 0; i--) {
+ let nativeTabInfo = tabmail.tabInfo[i - 1];
+ let uri = nativeTabInfo.browser?.browsingContext.currentURI;
+ if (
+ uri &&
+ uri.scheme == "moz-extension" &&
+ uri.host == this.extension.uuid
+ ) {
+ tabmail.closeTab(nativeTabInfo);
+ }
+ }
+ }
+ }
+
+ tabEventRegistrar({ tabEvent, listener }) {
+ let { extension } = this;
+ let { tabManager } = extension;
+ return ({ context, fire }) => {
+ let listener2 = async (eventName, event, ...args) => {
+ if (!tabManager.canAccessTab(event.nativeTab)) {
+ return;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ listener({ context, fire, event }, ...args);
+ };
+ tabTracker.on(tabEvent, listener2);
+ return {
+ unregister() {
+ tabTracker.off(tabEvent, listener2);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called) (handled by tabEventRegistrar).
+
+ onActivated: this.tabEventRegistrar({
+ tabEvent: "tab-activated",
+ listener: ({ context, fire, event }) => {
+ let { tabId, windowId, previousTabId } = event;
+ fire.async({ tabId, windowId, previousTabId });
+ },
+ }),
+
+ onCreated: this.tabEventRegistrar({
+ tabEvent: "tab-created",
+ listener: ({ context, fire, event }) => {
+ let { extension } = this;
+ let { tabManager } = extension;
+ fire.async(tabManager.convert(event.nativeTabInfo, event.currentTab));
+ },
+ }),
+
+ onAttached: this.tabEventRegistrar({
+ tabEvent: "tab-attached",
+ listener: ({ context, fire, event }) => {
+ fire.async(event.tabId, {
+ newWindowId: event.newWindowId,
+ newPosition: event.newPosition,
+ });
+ },
+ }),
+
+ onDetached: this.tabEventRegistrar({
+ tabEvent: "tab-detached",
+ listener: ({ context, fire, event }) => {
+ fire.async(event.tabId, {
+ oldWindowId: event.oldWindowId,
+ oldPosition: event.oldPosition,
+ });
+ },
+ }),
+
+ onRemoved: this.tabEventRegistrar({
+ tabEvent: "tab-removed",
+ listener: ({ context, fire, event }) => {
+ fire.async(event.tabId, {
+ windowId: event.windowId,
+ isWindowClosing: event.isWindowClosing,
+ });
+ },
+ }),
+
+ onMoved({ context, fire }) {
+ let { tabManager } = this.extension;
+ let moveListener = async event => {
+ let nativeTab = event.target;
+ let nativeTabInfo = event.detail.tabInfo;
+ let tabmail = nativeTab.ownerDocument.getElementById("tabmail");
+ if (tabManager.canAccessTab(nativeTab)) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(tabTracker.getId(nativeTabInfo), {
+ windowId: windowTracker.getId(nativeTab.ownerGlobal),
+ fromIndex: event.detail.idx,
+ toIndex: tabmail.tabInfo.indexOf(nativeTabInfo),
+ });
+ }
+ };
+
+ windowTracker.addListener("TabMove", moveListener);
+ return {
+ unregister() {
+ windowTracker.removeListener("TabMove", moveListener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onUpdated({ context, fire }, [filterProps]) {
+ let filter = { ...filterProps };
+ let scheduledEvents = [];
+
+ if (
+ filter &&
+ filter.urls &&
+ !this.extension.hasPermission("tabs") &&
+ !this.extension.hasPermission("activeTab")
+ ) {
+ console.error(
+ 'Url filtering in tabs.onUpdated requires "tabs" or "activeTab" permission.'
+ );
+ return false;
+ }
+
+ if (filter.urls) {
+ // TODO: Consider following M-C
+ // Use additional parameter { restrictSchemes: false }.
+ filter.urls = new MatchPatternSet(filter.urls);
+ }
+ let needsModified = true;
+ if (filter.properties) {
+ // Default is to listen for all events.
+ needsModified = filter.properties.some(prop => allAttrs.has(prop));
+ filter.properties = new Set(filter.properties);
+ } else {
+ filter.properties = allProperties;
+ }
+
+ function sanitize(tab, changeInfo) {
+ let result = {};
+ let nonempty = false;
+ for (let prop in changeInfo) {
+ // In practice, changeInfo contains at most one property from
+ // restricted. Therefore it is not necessary to cache the value
+ // of tab.hasTabPermission outside the loop.
+ // Unnecessarily accessing tab.hasTabPermission can cause bugs, see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1694699#c21
+ if (tab.hasTabPermission || !restricted.has(prop)) {
+ nonempty = true;
+ result[prop] = changeInfo[prop];
+ }
+ }
+ return nonempty && result;
+ }
+
+ function getWindowID(windowId) {
+ if (windowId === WindowBase.WINDOW_ID_CURRENT) {
+ // TODO: Consider following M-C
+ // Use windowTracker.getTopWindow(context).
+ return windowTracker.getId(windowTracker.topWindow);
+ }
+ return windowId;
+ }
+
+ function matchFilters(tab, changed) {
+ if (!filterProps) {
+ return true;
+ }
+ if (filter.tabId != null && tab.id != filter.tabId) {
+ return false;
+ }
+ if (
+ filter.windowId != null &&
+ tab.windowId != getWindowID(filter.windowId)
+ ) {
+ return false;
+ }
+ if (filter.urls) {
+ // We check permission first because tab.uri is null if !hasTabPermission.
+ return tab.hasTabPermission && filter.urls.matches(tab.uri);
+ }
+ return true;
+ }
+
+ let fireForTab = async (tab, changed) => {
+ if (!matchFilters(tab, changed)) {
+ return;
+ }
+
+ let changeInfo = sanitize(tab, changed);
+ if (changeInfo) {
+ let tabInfo = tab.convert();
+ // TODO: Consider following M-C
+ // Use tabTracker.maybeWaitForTabOpen(nativeTab).then(() => {}).
+
+ // Using a FIFO to keep order of events, in case the last one
+ // gets through without being placed on the async callback stack.
+ scheduledEvents.push([tab.id, changeInfo, tabInfo]);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(...scheduledEvents.shift());
+ }
+ };
+
+ let listener = event => {
+ /* TODO: Consider following M-C
+ // Ignore any events prior to TabOpen and events that are triggered while
+ // tabs are swapped between windows.
+ if (event.originalTarget.initializingTab) {
+ return;
+ }
+ if (!extension.canAccessWindow(event.originalTarget.ownerGlobal)) {
+ return;
+ }
+ */
+
+ let changeInfo = {};
+ let { extension } = this;
+ let { tabManager } = extension;
+ let tab = tabManager.getWrapper(event.detail.tabInfo);
+ let changed = event.detail.changed;
+ if (
+ changed.includes("favIconUrl") &&
+ filter.properties.has("favIconUrl")
+ ) {
+ changeInfo.favIconUrl = tab.favIconUrl;
+ }
+ if (changed.includes("label") && filter.properties.has("title")) {
+ changeInfo.title = tab.title;
+ }
+
+ fireForTab(tab, changeInfo);
+ };
+
+ let statusListener = ({ browser, status, url }) => {
+ let { extension } = this;
+ let { tabManager } = extension;
+ let tabId = tabTracker.getBrowserTabId(browser);
+ if (tabId != -1) {
+ let changed = { status };
+ if (url) {
+ changed.url = url;
+ }
+ fireForTab(tabManager.get(tabId), changed);
+ }
+ };
+
+ if (needsModified) {
+ windowTracker.addListener("TabAttrModified", listener);
+ }
+
+ if (filter.properties.has("status")) {
+ windowTracker.addListener("status", statusListener);
+ }
+
+ return {
+ unregister() {
+ if (needsModified) {
+ windowTracker.removeListener("TabAttrModified", listener);
+ }
+ if (filter.properties.has("status")) {
+ windowTracker.removeListener("status", statusListener);
+ }
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ /**
+ * Gets the tab for the given tab id, or the active tab if the id is null.
+ *
+ * @param {?Integer} tabId - The tab id to get
+ * @returns {Tab} The matching tab, or the active tab
+ */
+ function getTabOrActive(tabId) {
+ if (tabId) {
+ return tabTracker.getTab(tabId);
+ }
+ return tabTracker.activeTab;
+ }
+
+ /**
+ * Promise that the tab with the given tab id is ready.
+ *
+ * @param {Integer} tabId - The tab id to check
+ * @returns {Promise<NativeTabInfo>} Resolved when the loading is complete
+ */
+ async function promiseTabWhenReady(tabId) {
+ let tab;
+ if (tabId === null) {
+ tab = tabManager.getWrapper(tabTracker.activeTab);
+ } else {
+ tab = tabManager.get(tabId);
+ }
+
+ await tabListener.awaitTabReady(tab.nativeTab);
+
+ return tab;
+ }
+
+ return {
+ tabs: {
+ onActivated: new EventManager({
+ context,
+ module: "tabs",
+ event: "onActivated",
+ extensionApi: this,
+ }).api(),
+
+ onCreated: new EventManager({
+ context,
+ module: "tabs",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+
+ onAttached: new EventManager({
+ context,
+ module: "tabs",
+ event: "onAttached",
+ extensionApi: this,
+ }).api(),
+
+ onDetached: new EventManager({
+ context,
+ module: "tabs",
+ event: "onDetached",
+ extensionApi: this,
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ module: "tabs",
+ event: "onRemoved",
+ extensionApi: this,
+ }).api(),
+
+ onMoved: new EventManager({
+ context,
+ module: "tabs",
+ event: "onMoved",
+ extensionApi: this,
+ }).api(),
+
+ onUpdated: new EventManager({
+ context,
+ module: "tabs",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+
+ async create(createProperties) {
+ let window = await getNormalWindowReady(
+ context,
+ createProperties.windowId
+ );
+ let tabmail = window.document.getElementById("tabmail");
+ let url;
+ if (createProperties.url) {
+ url = context.uri.resolve(createProperties.url);
+
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+ }
+
+ let userContextId =
+ Services.scriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
+ if (createProperties.cookieStoreId) {
+ userContextId = getUserContextIdForCookieStoreId(
+ extension,
+ createProperties.cookieStoreId
+ );
+ }
+
+ let currentTab = tabmail.selectedTab;
+ let active = createProperties.active ?? true;
+ tabListener.initTabReady();
+
+ let nativeTabInfo = tabmail.openTab("contentTab", {
+ url: url || "about:blank",
+ linkHandler: "single-site",
+ background: !active,
+ initialBrowsingContextGroupId:
+ context.extension.policy.browsingContextGroupId,
+ principal: context.extension.principal,
+ duplicate: true,
+ userContextId,
+ });
+
+ if (createProperties.index) {
+ tabmail.moveTabTo(nativeTabInfo, createProperties.index);
+ tabmail.updateCurrentTab();
+ }
+
+ if (createProperties.url && createProperties.url !== "about:blank") {
+ // Mark tabs as initializing, so operations like `executeScript` wait until the
+ // requested URL is loaded.
+ tabListener.initializingTabs.add(nativeTabInfo);
+ }
+ return tabManager.convert(nativeTabInfo, currentTab);
+ },
+
+ async remove(tabs) {
+ if (!Array.isArray(tabs)) {
+ tabs = [tabs];
+ }
+
+ for (let tabId of tabs) {
+ let nativeTabInfo = tabTracker.getTab(tabId);
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ nativeTabInfo.close();
+ continue;
+ }
+ let tabmail = getTabTabmail(nativeTabInfo);
+ tabmail.closeTab(nativeTabInfo);
+ }
+ },
+
+ async update(tabId, updateProperties) {
+ let nativeTabInfo = getTabOrActive(tabId);
+ let tab = tabManager.getWrapper(nativeTabInfo);
+ let tabmail = getTabTabmail(nativeTabInfo);
+
+ if (updateProperties.url) {
+ let url = context.uri.resolve(updateProperties.url);
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+
+ let uri;
+ try {
+ uri = Services.io.newURI(url);
+ } catch (e) {
+ throw new ExtensionError(`Url "${url}" seems to be malformed.`);
+ }
+
+ // http(s): urls, moz-extension: urls and self-registered protocol
+ // handlers are actually loaded into the tab (and change its url).
+ // All other urls are forwarded to the external protocol handler and
+ // do not change the current tab.
+ let isContentUrl =
+ /((^blob:)|(^https:)|(^http:)|(^moz-extension:))/i.test(url);
+ let isWebExtProtocolUrl =
+ /((^ext\+[a-z]+:)|(^web\+[a-z]+:))/i.test(url) &&
+ hasWebHandlerApp(uri.scheme);
+
+ if (isContentUrl || isWebExtProtocolUrl) {
+ if (tab.type != "content" && tab.type != "mail") {
+ throw new ExtensionError(
+ isContentUrl
+ ? "Loading a content url is only supported for content tabs and mail tabs."
+ : "Loading a registered WebExtension protocol handler url is only supported for content tabs and mail tabs."
+ );
+ }
+
+ let options = {
+ flags: updateProperties.loadReplace
+ ? Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY
+ : Ci.nsIWebNavigation.LOAD_FLAGS_NONE,
+ triggeringPrincipal: context.principal,
+ };
+
+ if (tab.type == "mail") {
+ // The content browser in about:3pane.
+ nativeTabInfo.chromeBrowser.contentWindow.messagePane.displayWebPage(
+ url,
+ options
+ );
+ } else {
+ let browser = getTabBrowser(nativeTabInfo);
+ if (!browser) {
+ throw new ExtensionError("Cannot set a URL for this tab.");
+ }
+ MailE10SUtils.loadURI(browser, url, options);
+ }
+ } else {
+ // Send unknown URLs schema to the external protocol handler.
+ // This does not change the current tab.
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ }
+ }
+
+ // A tab can only be set to be active. To set it inactive, another tab
+ // has to be set as active.
+ if (tabmail && updateProperties.active) {
+ tabmail.selectedTab = nativeTabInfo;
+ }
+
+ return tabManager.convert(nativeTabInfo);
+ },
+
+ async reload(tabId, reloadProperties) {
+ let nativeTabInfo = getTabOrActive(tabId);
+ let tab = tabManager.getWrapper(nativeTabInfo);
+
+ let isContentMailTab =
+ tab.type == "mail" &&
+ !nativeTabInfo.chromeBrowser.contentWindow.webBrowser.hidden;
+ if (tab.type != "content" && !isContentMailTab) {
+ throw new ExtensionError(
+ "Reloading is only supported for tabs displaying a content page."
+ );
+ }
+
+ let browser = getTabBrowser(nativeTabInfo);
+
+ let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+ if (reloadProperties && reloadProperties.bypassCache) {
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ }
+ browser.reloadWithFlags(flags);
+ },
+
+ async get(tabId) {
+ return tabManager.get(tabId).convert();
+ },
+
+ getCurrent() {
+ let tabData;
+ if (context.tabId) {
+ tabData = tabManager.get(context.tabId).convert();
+ }
+ return Promise.resolve(tabData);
+ },
+
+ async query(queryInfo) {
+ if (!extension.hasPermission("tabs")) {
+ if (queryInfo.url !== null || queryInfo.title !== null) {
+ return Promise.reject({
+ message:
+ 'The "tabs" permission is required to use the query API with the "url" or "title" parameters',
+ });
+ }
+ }
+
+ // Make ext-tabs-base happy since it does a strict check.
+ queryInfo.screen = null;
+
+ return Array.from(tabManager.query(queryInfo, context), tab =>
+ tab.convert()
+ );
+ },
+
+ async executeScript(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.executeScript(context, details);
+ },
+
+ async insertCSS(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.insertCSS(context, details);
+ },
+
+ async removeCSS(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.removeCSS(context, details);
+ },
+
+ async move(tabIds, moveProperties) {
+ let tabsMoved = [];
+ if (!Array.isArray(tabIds)) {
+ tabIds = [tabIds];
+ }
+
+ let destinationWindow = null;
+ if (moveProperties.windowId !== null) {
+ destinationWindow = await getNormalWindowReady(
+ context,
+ moveProperties.windowId
+ );
+ }
+
+ /*
+ Indexes are maintained on a per window basis so that a call to
+ move([tabA, tabB], {index: 0})
+ -> tabA to 0, tabB to 1 if tabA and tabB are in the same window
+ move([tabA, tabB], {index: 0})
+ -> tabA to 0, tabB to 0 if tabA and tabB are in different windows
+ */
+ let indexMap = new Map();
+ let lastInsertion = new Map();
+
+ let tabs = tabIds.map(tabId => ({
+ nativeTabInfo: tabTracker.getTab(tabId),
+ tabId,
+ }));
+ for (let { nativeTabInfo, tabId } of tabs) {
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ return Promise.reject({
+ message: `Tab with ID ${tabId} does not belong to a normal window`,
+ });
+ }
+
+ // If the window is not specified, use the window from the tab.
+ let browser = getTabBrowser(nativeTabInfo);
+
+ let srcwindow = browser.ownerGlobal;
+ let tgtwindow = destinationWindow || browser.ownerGlobal;
+ let tgttabmail = tgtwindow.document.getElementById("tabmail");
+ let srctabmail = srcwindow.document.getElementById("tabmail");
+
+ // If we are not moving the tab to a different window, and the window
+ // only has one tab, do nothing.
+ if (srcwindow == tgtwindow && srctabmail.tabInfo.length === 1) {
+ continue;
+ }
+
+ let insertionPoint =
+ indexMap.get(tgtwindow) || moveProperties.index;
+ // If the index is -1 it should go to the end of the tabs.
+ if (insertionPoint == -1) {
+ insertionPoint = tgttabmail.tabInfo.length;
+ }
+
+ let tabPosition = srctabmail.tabInfo.indexOf(nativeTabInfo);
+
+ // If this is not the first tab to be inserted into this window and
+ // the insertion point is the same as the last insertion and
+ // the tab is further to the right than the current insertion point
+ // then you need to bump up the insertion point. See bug 1323311.
+ if (
+ lastInsertion.has(tgtwindow) &&
+ lastInsertion.get(tgtwindow) === insertionPoint &&
+ tabPosition > insertionPoint
+ ) {
+ insertionPoint++;
+ indexMap.set(tgtwindow, insertionPoint);
+ }
+
+ if (srcwindow == tgtwindow) {
+ // If the window we are moving is the same, just move the tab.
+ tgttabmail.moveTabTo(nativeTabInfo, insertionPoint);
+ } else {
+ // If the window we are moving the tab in is different, then move the tab
+ // to the new window.
+ srctabmail.replaceTabWithWindow(
+ nativeTabInfo,
+ tgtwindow,
+ insertionPoint
+ );
+ nativeTabInfo =
+ tgttabmail.tabInfo[insertionPoint] ||
+ tgttabmail.tabInfo[tgttabmail.tabInfo.length - 1];
+ }
+ lastInsertion.set(tgtwindow, tabPosition);
+ tabsMoved.push(nativeTabInfo);
+ }
+
+ return tabsMoved.map(nativeTabInfo =>
+ tabManager.convert(nativeTabInfo)
+ );
+ },
+
+ duplicate(tabId) {
+ let nativeTabInfo = tabTracker.getTab(tabId);
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ throw new ExtensionError(
+ "tabs.duplicate is not applicable to this tab."
+ );
+ }
+ let browser = getTabBrowser(nativeTabInfo);
+ let tabmail = browser.ownerDocument.getElementById("tabmail");
+
+ // This is our best approximation of duplicating tabs. It might produce unreliable results
+ let state = tabmail.persistTab(nativeTabInfo);
+ let mode = tabmail.tabModes[state.mode];
+ state.state.duplicate = true;
+
+ if (mode.tabs.length && mode.tabs.length == mode.maxTabs) {
+ throw new ExtensionError(
+ `Maximum number of ${state.mode} tabs reached.`
+ );
+ } else {
+ tabmail.restoreTab(state);
+ return tabManager.convert(mode.tabs[mode.tabs.length - 1]);
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-theme.js b/comm/mail/components/extensions/parent/ext-theme.js
new file mode 100644
index 0000000000..1de3501e84
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-theme.js
@@ -0,0 +1,543 @@
+/* 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/. */
+
+"use strict";
+
+/* global windowTracker, EventManager, EventEmitter */
+
+/* eslint-disable complexity */
+
+ChromeUtils.defineESModuleGetters(this, {
+ LightweightThemeManager:
+ "resource://gre/modules/LightweightThemeManager.sys.mjs",
+});
+
+const onUpdatedEmitter = new EventEmitter();
+
+// Represents an empty theme for convenience of use
+const emptyTheme = {
+ details: { colors: null, images: null, properties: null },
+};
+
+let defaultTheme = emptyTheme;
+// Map[windowId -> Theme instance]
+let windowOverrides = new Map();
+
+/**
+ * Class representing either a global theme affecting all windows or an override on a specific window.
+ * Any extension updating the theme with a new global theme will replace the singleton defaultTheme.
+ */
+class Theme {
+ /**
+ * Creates a theme instance.
+ *
+ * @param {string} extension - Extension that created the theme.
+ * @param {Integer} windowId - The windowId where the theme is applied.
+ */
+ constructor({
+ extension,
+ details,
+ darkDetails,
+ windowId,
+ experiment,
+ startupData,
+ }) {
+ this.extension = extension;
+ this.details = details;
+ this.darkDetails = darkDetails;
+ this.windowId = windowId;
+
+ if (startupData && startupData.lwtData) {
+ Object.assign(this, startupData);
+ } else {
+ // TODO: Update this part after bug 1550090.
+ this.lwtStyles = {};
+ this.lwtDarkStyles = null;
+ if (darkDetails) {
+ this.lwtDarkStyles = {};
+ }
+
+ if (experiment) {
+ if (extension.canUseThemeExperiment()) {
+ this.lwtStyles.experimental = {
+ colors: {},
+ images: {},
+ properties: {},
+ };
+ if (this.lwtDarkStyles) {
+ this.lwtDarkStyles.experimental = {
+ colors: {},
+ images: {},
+ properties: {},
+ };
+ }
+
+ if (experiment.stylesheet) {
+ experiment.stylesheet = this.getFileUrl(experiment.stylesheet);
+ }
+ this.experiment = experiment;
+ } else {
+ const { logger } = this.extension;
+ logger.warn("This extension is not allowed to run theme experiments");
+ return;
+ }
+ }
+ }
+ this.load();
+ }
+
+ // The manifest has moz-extension:// urls. Switch to file:// urls to get around
+ // the skin limitation for moz-extension:// urls.
+ getFileUrl(url) {
+ if (url.startsWith("moz-extension://")) {
+ url = url.split("/").slice(3).join("/");
+ }
+ return this.extension.rootURI.resolve(url);
+ }
+
+ /**
+ * Loads a theme by reading the properties from the extension's manifest.
+ * This method will override any currently applied theme.
+ */
+ load() {
+ if (!this.lwtData) {
+ this.loadDetails(this.details, this.lwtStyles);
+ if (this.darkDetails) {
+ this.loadDetails(this.darkDetails, this.lwtDarkStyles);
+ }
+
+ this.lwtData = {
+ theme: this.lwtStyles,
+ darkTheme: this.lwtDarkStyles,
+ };
+
+ if (this.experiment) {
+ this.lwtData.experiment = this.experiment;
+ }
+
+ this.extension.startupData = {
+ lwtData: this.lwtData,
+ lwtStyles: this.lwtStyles,
+ lwtDarkStyles: this.lwtDarkStyles,
+ experiment: this.experiment,
+ };
+ this.extension.saveStartupData();
+ }
+
+ if (this.windowId) {
+ this.lwtData.window = windowTracker.getWindow(
+ this.windowId
+ ).docShell.outerWindowID;
+ windowOverrides.set(this.windowId, this);
+ } else {
+ windowOverrides.clear();
+ defaultTheme = this;
+ LightweightThemeManager.fallbackThemeData = this.lwtData;
+ }
+ onUpdatedEmitter.emit("theme-updated", this.details, this.windowId);
+
+ Services.obs.notifyObservers(
+ this.lwtData,
+ "lightweight-theme-styling-update"
+ );
+ }
+
+ /**
+ * @param {object} details - Details
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadDetails(details, styles) {
+ if (details.colors) {
+ this.loadColors(details.colors, styles);
+ }
+
+ if (details.images) {
+ this.loadImages(details.images, styles);
+ }
+
+ if (details.properties) {
+ this.loadProperties(details.properties, styles);
+ }
+
+ this.loadMetadata(this.extension, styles);
+ }
+
+ /**
+ * Helper method for loading colors found in the extension's manifest.
+ *
+ * @param {object} colors - Dictionary mapping color properties to values.
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadColors(colors, styles) {
+ for (let color of Object.keys(colors)) {
+ let val = colors[color];
+
+ if (!val) {
+ continue;
+ }
+
+ let cssColor = val;
+ if (Array.isArray(val)) {
+ cssColor =
+ "rgb" + (val.length > 3 ? "a" : "") + "(" + val.join(",") + ")";
+ }
+
+ switch (color) {
+ case "frame":
+ styles.accentcolor = cssColor;
+ break;
+ case "frame_inactive":
+ styles.accentcolorInactive = cssColor;
+ break;
+ case "tab_background_text":
+ styles.textcolor = cssColor;
+ break;
+ case "toolbar":
+ styles.toolbarColor = cssColor;
+ break;
+ case "toolbar_text":
+ case "bookmark_text":
+ styles.toolbar_text = cssColor;
+ break;
+ case "icons":
+ styles.icon_color = cssColor;
+ break;
+ case "icons_attention":
+ styles.icon_attention_color = cssColor;
+ break;
+ case "tab_background_separator":
+ case "tab_loading":
+ case "tab_text":
+ case "tab_line":
+ case "tab_selected":
+ case "toolbar_field":
+ case "toolbar_field_text":
+ case "toolbar_field_border":
+ case "toolbar_field_focus":
+ case "toolbar_field_text_focus":
+ case "toolbar_field_border_focus":
+ case "toolbar_top_separator":
+ case "toolbar_bottom_separator":
+ case "toolbar_vertical_separator":
+ case "button_background_hover":
+ case "button_background_active":
+ case "popup":
+ case "popup_text":
+ case "popup_border":
+ case "popup_highlight":
+ case "popup_highlight_text":
+ case "ntp_background":
+ case "ntp_text":
+ case "sidebar":
+ case "sidebar_border":
+ case "sidebar_text":
+ case "sidebar_highlight":
+ case "sidebar_highlight_text":
+ case "sidebar_highlight_border":
+ case "toolbar_field_highlight":
+ case "toolbar_field_highlight_text":
+ styles[color] = cssColor;
+ break;
+ default:
+ if (
+ this.experiment &&
+ this.experiment.colors &&
+ color in this.experiment.colors
+ ) {
+ styles.experimental.colors[color] = cssColor;
+ } else {
+ const { logger } = this.extension;
+ logger.warn(`Unrecognized theme property found: colors.${color}`);
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Helper method for loading images found in the extension's manifest.
+ *
+ * @param {object} images - Dictionary mapping image properties to values.
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadImages(images, styles) {
+ const { logger } = this.extension;
+
+ for (let image of Object.keys(images)) {
+ let val = images[image];
+
+ if (!val) {
+ continue;
+ }
+
+ switch (image) {
+ case "additional_backgrounds": {
+ let backgroundImages = val.map(img => this.getFileUrl(img));
+ styles.additionalBackgrounds = backgroundImages;
+ break;
+ }
+ case "theme_frame": {
+ let resolvedURL = this.getFileUrl(val);
+ styles.headerURL = resolvedURL;
+ break;
+ }
+ default: {
+ if (
+ this.experiment &&
+ this.experiment.images &&
+ image in this.experiment.images
+ ) {
+ styles.experimental.images[image] = this.getFileUrl(val);
+ } else {
+ logger.warn(`Unrecognized theme property found: images.${image}`);
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper method for preparing properties found in the extension's manifest.
+ * Properties are commonly used to specify more advanced behavior of colors,
+ * images or icons.
+ *
+ * @param {object} - properties Dictionary mapping properties to values.
+ * @param {object} - styles Styles object in which to store the colors.
+ */
+ loadProperties(properties, styles) {
+ let additionalBackgroundsCount =
+ (styles.additionalBackgrounds && styles.additionalBackgrounds.length) ||
+ 0;
+ const assertValidAdditionalBackgrounds = (property, valueCount) => {
+ const { logger } = this.extension;
+ if (!additionalBackgroundsCount) {
+ logger.warn(
+ `The '${property}' property takes effect only when one ` +
+ `or more additional background images are specified using the 'additional_backgrounds' property.`
+ );
+ return false;
+ }
+ if (additionalBackgroundsCount !== valueCount) {
+ logger.warn(
+ `The amount of values specified for '${property}' ` +
+ `(${valueCount}) is not equal to the amount of additional background ` +
+ `images (${additionalBackgroundsCount}), which may lead to unexpected results.`
+ );
+ }
+ return true;
+ };
+
+ for (let property of Object.getOwnPropertyNames(properties)) {
+ let val = properties[property];
+
+ if (!val) {
+ continue;
+ }
+
+ switch (property) {
+ case "additional_backgrounds_alignment": {
+ if (!assertValidAdditionalBackgrounds(property, val.length)) {
+ break;
+ }
+
+ styles.backgroundsAlignment = val.join(",");
+ break;
+ }
+ case "additional_backgrounds_tiling": {
+ if (!assertValidAdditionalBackgrounds(property, val.length)) {
+ break;
+ }
+
+ let tiling = [];
+ for (let i = 0, l = styles.additionalBackgrounds.length; i < l; ++i) {
+ tiling.push(val[i] || "no-repeat");
+ }
+ styles.backgroundsTiling = tiling.join(",");
+ break;
+ }
+ case "color_scheme":
+ case "content_color_scheme": {
+ styles[property] = val;
+ break;
+ }
+ default: {
+ if (
+ this.experiment &&
+ this.experiment.properties &&
+ property in this.experiment.properties
+ ) {
+ styles.experimental.properties[property] = val;
+ } else {
+ const { logger } = this.extension;
+ logger.warn(
+ `Unrecognized theme property found: properties.${property}`
+ );
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper method for loading extension metadata required by downstream
+ * consumers.
+ *
+ * @param {object} extension - Extension object.
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadMetadata(extension, styles) {
+ styles.id = extension.id;
+ styles.version = extension.version;
+ }
+
+ static unload(windowId) {
+ let lwtData = {
+ theme: null,
+ };
+
+ if (windowId) {
+ lwtData.window = windowTracker.getWindow(windowId).docShell.outerWindowID;
+ windowOverrides.delete(windowId);
+ } else {
+ windowOverrides.clear();
+ defaultTheme = emptyTheme;
+ LightweightThemeManager.fallbackThemeData = null;
+ }
+ onUpdatedEmitter.emit("theme-updated", {}, windowId);
+
+ Services.obs.notifyObservers(lwtData, "lightweight-theme-styling-update");
+ }
+}
+
+this.theme = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onUpdated({ fire, context }) {
+ let callback = async (event, theme, windowId) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ if (windowId) {
+ // Force access validation for incognito mode by getting the window.
+ if (windowTracker.getWindow(windowId, context, false)) {
+ fire.async({ theme, windowId });
+ }
+ } else {
+ fire.async({ theme });
+ }
+ };
+
+ onUpdatedEmitter.on("theme-updated", callback);
+ return {
+ unregister() {
+ onUpdatedEmitter.off("theme-updated", callback);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ onManifestEntry(entryName) {
+ let { extension } = this;
+ let { manifest } = extension;
+
+ defaultTheme = new Theme({
+ extension,
+ details: manifest.theme,
+ darkDetails: manifest.dark_theme,
+ experiment: manifest.theme_experiment,
+ startupData: extension.startupData,
+ });
+ }
+
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+
+ let { extension } = this;
+ for (let [windowId, theme] of windowOverrides) {
+ if (theme.extension === extension) {
+ Theme.unload(windowId);
+ }
+ }
+
+ if (defaultTheme.extension === extension) {
+ Theme.unload();
+ }
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ theme: {
+ getCurrent: windowId => {
+ // Take last focused window when no ID is supplied.
+ if (!windowId) {
+ windowId = windowTracker.getId(windowTracker.topWindow);
+ }
+ // Force access validation for incognito mode by getting the window.
+ if (!windowTracker.getWindow(windowId, context)) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+
+ if (windowOverrides.has(windowId)) {
+ return Promise.resolve(windowOverrides.get(windowId).details);
+ }
+ return Promise.resolve(defaultTheme.details);
+ },
+ update: (windowId, details) => {
+ if (windowId) {
+ const browserWindow = windowTracker.getWindow(windowId, context);
+ if (!browserWindow) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+ }
+
+ new Theme({
+ extension,
+ details,
+ windowId,
+ experiment: this.extension.manifest.theme_experiment,
+ });
+
+ return Promise.resolve();
+ },
+ reset: windowId => {
+ if (windowId) {
+ const browserWindow = windowTracker.getWindow(windowId, context);
+ if (!browserWindow) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+
+ let theme = windowOverrides.get(windowId) || defaultTheme;
+ if (theme.extension !== extension) {
+ return Promise.resolve();
+ }
+ } else if (defaultTheme.extension !== extension) {
+ return Promise.resolve();
+ }
+
+ Theme.unload(windowId);
+ return Promise.resolve();
+ },
+ onUpdated: new EventManager({
+ context,
+ module: "theme",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-windows.js b/comm/mail/components/extensions/parent/ext-windows.js
new file mode 100644
index 0000000000..6a3078d7d3
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-windows.js
@@ -0,0 +1,555 @@
+/* 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/. */
+
+// The ext-* files are imported into the same scopes.
+/* import-globals-from ext-mail.js */
+
+function sanitizePositionParams(params, window = null, positionOffset = 0) {
+ if (params.left === null && params.top === null) {
+ return;
+ }
+
+ if (params.left === null) {
+ const baseLeft = window ? window.screenX : 0;
+ params.left = baseLeft + positionOffset;
+ }
+ if (params.top === null) {
+ const baseTop = window ? window.screenY : 0;
+ params.top = baseTop + positionOffset;
+ }
+
+ // boundary check: don't put window out of visible area
+ const baseWidth = window ? window.outerWidth : 0;
+ const baseHeight = window ? window.outerHeight : 0;
+ // Secure minimum size of an window should be same to the one
+ // defined at nsGlobalWindowOuter::CheckSecurityWidthAndHeight.
+ const minWidth = 100;
+ const minHeight = 100;
+ const width = Math.max(
+ minWidth,
+ params.width !== null ? params.width : baseWidth
+ );
+ const height = Math.max(
+ minHeight,
+ params.height !== null ? params.height : baseHeight
+ );
+ const screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService(
+ Ci.nsIScreenManager
+ );
+ const screen = screenManager.screenForRect(
+ params.left,
+ params.top,
+ width,
+ height
+ );
+ const availDeviceLeft = {};
+ const availDeviceTop = {};
+ const availDeviceWidth = {};
+ const availDeviceHeight = {};
+ screen.GetAvailRect(
+ availDeviceLeft,
+ availDeviceTop,
+ availDeviceWidth,
+ availDeviceHeight
+ );
+ const factor = screen.defaultCSSScaleFactor;
+ const availLeft = Math.floor(availDeviceLeft.value / factor);
+ const availTop = Math.floor(availDeviceTop.value / factor);
+ const availWidth = Math.floor(availDeviceWidth.value / factor);
+ const availHeight = Math.floor(availDeviceHeight.value / factor);
+ params.left = Math.min(
+ availLeft + availWidth - width,
+ Math.max(availLeft, params.left)
+ );
+ params.top = Math.min(
+ availTop + availHeight - height,
+ Math.max(availTop, params.top)
+ );
+}
+
+/**
+ * Update the geometry of the mail window.
+ *
+ * @param {object} options
+ * An object containing new values for the window's geometry.
+ * @param {integer} [options.left]
+ * The new pixel distance of the left side of the mail window from
+ * the left of the screen.
+ * @param {integer} [options.top]
+ * The new pixel distance of the top side of the mail window from
+ * the top of the screen.
+ * @param {integer} [options.width]
+ * The new pixel width of the window.
+ * @param {integer} [options.height]
+ * The new pixel height of the window.
+ */
+function updateGeometry(window, options) {
+ if (options.left !== null || options.top !== null) {
+ let left = options.left === null ? window.screenX : options.left;
+ let top = options.top === null ? window.screenY : options.top;
+ window.moveTo(left, top);
+ }
+
+ if (options.width !== null || options.height !== null) {
+ let width = options.width === null ? window.outerWidth : options.width;
+ let height = options.height === null ? window.outerHeight : options.height;
+ window.resizeTo(width, height);
+ }
+}
+
+this.windows = class extends ExtensionAPIPersistent {
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+ for (let window of Services.wm.getEnumerator("mail:extensionPopup")) {
+ let uri = window.browser.browsingContext.currentURI;
+ if (uri.scheme == "moz-extension" && uri.host == this.extension.uuid) {
+ window.close();
+ }
+ }
+ }
+
+ windowEventRegistrar({ windowEvent, listener }) {
+ let { extension } = this;
+ return ({ context, fire }) => {
+ let listener2 = async (window, ...args) => {
+ if (!extension.canAccessWindow(window)) {
+ return;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ listener({ context, fire, window }, ...args);
+ };
+ windowTracker.addListener(windowEvent, listener2);
+ return {
+ unregister() {
+ windowTracker.removeListener(windowEvent, listener2);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called) (handled by windowEventRegistrar).
+
+ onCreated: this.windowEventRegistrar({
+ windowEvent: "domwindowopened",
+ listener: async ({ context, fire, window }) => {
+ // Return the window only after it has been fully initialized.
+ if (window.webExtensionWindowCreatePending) {
+ await new Promise(resolve => {
+ window.addEventListener("webExtensionWindowCreateDone", resolve, {
+ once: true,
+ });
+ });
+ }
+ fire.async(this.extension.windowManager.convert(window));
+ },
+ }),
+
+ onRemoved: this.windowEventRegistrar({
+ windowEvent: "domwindowclosed",
+ listener: ({ context, fire, window }) => {
+ fire.async(windowTracker.getId(window));
+ },
+ }),
+
+ onFocusChanged({ context, fire }) {
+ let { extension } = this;
+ // Keep track of the last windowId used to fire an onFocusChanged event
+ let lastOnFocusChangedWindowId;
+ let scheduledEvents = [];
+
+ let listener = async event => {
+ // Wait a tick to avoid firing a superfluous WINDOW_ID_NONE
+ // event when switching focus between two Thunderbird windows.
+ // Note: This is not working for Linux, where we still get the -1
+ await Promise.resolve();
+
+ let windowId = WindowBase.WINDOW_ID_NONE;
+ let window = Services.focus.activeWindow;
+ if (window) {
+ if (!extension.canAccessWindow(window)) {
+ return;
+ }
+ windowId = windowTracker.getId(window);
+ }
+
+ // Using a FIFO to keep order of events, in case the last one
+ // gets through without being placed on the async callback stack.
+ scheduledEvents.push(windowId);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let scheduledWindowId = scheduledEvents.shift();
+
+ if (scheduledWindowId !== lastOnFocusChangedWindowId) {
+ lastOnFocusChangedWindowId = scheduledWindowId;
+ fire.async(scheduledWindowId);
+ }
+ };
+ windowTracker.addListener("focus", listener);
+ windowTracker.addListener("blur", listener);
+ return {
+ unregister() {
+ windowTracker.removeListener("focus", listener);
+ windowTracker.removeListener("blur", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ const { extension } = context;
+ const { windowManager } = extension;
+
+ return {
+ windows: {
+ onCreated: new EventManager({
+ context,
+ module: "windows",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ module: "windows",
+ event: "onRemoved",
+ extensionApi: this,
+ }).api(),
+
+ onFocusChanged: new EventManager({
+ context,
+ module: "windows",
+ event: "onFocusChanged",
+ extensionApi: this,
+ }).api(),
+
+ get(windowId, getInfo) {
+ let window = windowTracker.getWindow(windowId, context);
+ if (!window) {
+ return Promise.reject({
+ message: `Invalid window ID: ${windowId}`,
+ });
+ }
+ return Promise.resolve(windowManager.convert(window, getInfo));
+ },
+
+ async getCurrent(getInfo) {
+ let window = context.currentWindow || windowTracker.topWindow;
+ if (window.document.readyState != "complete") {
+ await new Promise(resolve =>
+ window.addEventListener("load", resolve, { once: true })
+ );
+ }
+ return windowManager.convert(window, getInfo);
+ },
+
+ async getLastFocused(getInfo) {
+ let window = windowTracker.topWindow;
+ if (window.document.readyState != "complete") {
+ await new Promise(resolve =>
+ window.addEventListener("load", resolve, { once: true })
+ );
+ }
+ return windowManager.convert(window, getInfo);
+ },
+
+ getAll(getInfo) {
+ let doNotCheckTypes = !getInfo || !getInfo.windowTypes;
+
+ let windows = Array.from(windowManager.getAll(), win =>
+ win.convert(getInfo)
+ ).filter(
+ win => doNotCheckTypes || getInfo.windowTypes.includes(win.type)
+ );
+ return Promise.resolve(windows);
+ },
+
+ async create(createData) {
+ if (createData.incognito) {
+ throw new ExtensionError("`incognito` is not supported");
+ }
+
+ let needResize =
+ createData.left !== null ||
+ createData.top !== null ||
+ createData.width !== null ||
+ createData.height !== null;
+ if (needResize) {
+ if (createData.state !== null && createData.state != "normal") {
+ throw new ExtensionError(
+ `"state": "${createData.state}" may not be combined with "left", "top", "width", or "height"`
+ );
+ }
+ createData.state = "normal";
+ }
+
+ // 10px offset is same to Chromium
+ sanitizePositionParams(createData, windowTracker.topNormalWindow, 10);
+
+ let userContextId =
+ Services.scriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
+ if (createData.cookieStoreId) {
+ userContextId = getUserContextIdForCookieStoreId(
+ extension,
+ createData.cookieStoreId
+ );
+ }
+ let createWindowArgs = createData => {
+ let allowScriptsToClose = !!createData.allowScriptsToClose;
+ let url = createData.url || "about:blank";
+ let urls = Array.isArray(url) ? url : [url];
+
+ let args = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ let actionData = {
+ action: "open",
+ allowScriptsToClose,
+ tabs: urls.map(url => ({
+ tabType: "contentTab",
+ tabParams: { url, userContextId },
+ })),
+ };
+ actionData.wrappedJSObject = actionData;
+ args.appendElement(null);
+ args.appendElement(actionData);
+ return args;
+ };
+
+ let window;
+ let wantNormalWindow =
+ createData.type === null || createData.type == "normal";
+ let features = ["chrome"];
+ if (wantNormalWindow) {
+ features.push("dialog=no", "all", "status", "toolbar");
+ } else {
+ // All other types create "popup"-type windows by default.
+ // Use dialog=no to get minimize and maximize buttons (as chrome
+ // does) and to allow the API to actually maximize the popup in
+ // Linux.
+ features.push(
+ "dialog=no",
+ "resizable",
+ "minimizable",
+ "titlebar",
+ "close"
+ );
+ if (createData.left === null && createData.top === null) {
+ features.push("centerscreen");
+ }
+ }
+
+ let windowURL = wantNormalWindow
+ ? "chrome://messenger/content/messenger.xhtml"
+ : "chrome://messenger/content/extensionPopup.xhtml";
+ if (createData.tabId) {
+ if (createData.url) {
+ return Promise.reject({
+ message: "`tabId` may not be used in conjunction with `url`",
+ });
+ }
+
+ if (createData.allowScriptsToClose) {
+ return Promise.reject({
+ message:
+ "`tabId` may not be used in conjunction with `allowScriptsToClose`",
+ });
+ }
+
+ if (createData.cookieStoreId) {
+ return Promise.reject({
+ message:
+ "`tabId` may not be used in conjunction with `cookieStoreId`",
+ });
+ }
+
+ let nativeTabInfo = tabTracker.getTab(createData.tabId);
+ let tabmail =
+ getTabBrowser(nativeTabInfo).ownerDocument.getElementById(
+ "tabmail"
+ );
+ let targetType = wantNormalWindow ? null : "popup";
+ window = tabmail.replaceTabWithWindow(nativeTabInfo, targetType)[0];
+ } else {
+ window = Services.ww.openWindow(
+ null,
+ windowURL,
+ "_blank",
+ features.join(","),
+ wantNormalWindow ? null : createWindowArgs(createData)
+ );
+ }
+
+ window.webExtensionWindowCreatePending = true;
+
+ updateGeometry(window, createData);
+
+ // TODO: focused, type
+
+ // Wait till the newly created window is focused. On Linux the initial
+ // "normal" state has been set once the window has been fully focused.
+ // Setting a different state before the window is fully focused may cause
+ // the initial state to be erroneously applied after the custom state has
+ // been set.
+ let focusPromise = new Promise(resolve => {
+ if (Services.focus.activeWindow == window) {
+ resolve();
+ } else {
+ window.addEventListener("focus", resolve, { once: true });
+ }
+ });
+
+ let loadPromise = new Promise(resolve => {
+ window.addEventListener("load", resolve, { once: true });
+ });
+
+ let titlePromise = new Promise(resolve => {
+ window.addEventListener("pagetitlechanged", resolve, {
+ once: true,
+ });
+ });
+
+ await Promise.all([focusPromise, loadPromise, titlePromise]);
+
+ let win = windowManager.getWrapper(window);
+
+ if (
+ [
+ "minimized",
+ "fullscreen",
+ "docked",
+ "normal",
+ "maximized",
+ ].includes(createData.state)
+ ) {
+ await win.setState(createData.state);
+ }
+
+ if (createData.titlePreface !== null) {
+ win.setTitlePreface(createData.titlePreface);
+ }
+
+ // Update the title independently of a createData.titlePreface, to get
+ // the title of the loaded document into the window title.
+ if (win instanceof TabmailWindow) {
+ win.window.document.getElementById("tabmail").setDocumentTitle();
+ } else if (win.window.gBrowser?.updateTitlebar) {
+ await win.window.gBrowser.updateTitlebar();
+ }
+
+ delete window.webExtensionWindowCreatePending;
+ window.dispatchEvent(
+ new window.CustomEvent("webExtensionWindowCreateDone")
+ );
+ return win.convert({ populate: true });
+ },
+
+ async update(windowId, updateInfo) {
+ let needResize =
+ updateInfo.left !== null ||
+ updateInfo.top !== null ||
+ updateInfo.width !== null ||
+ updateInfo.height !== null;
+ if (
+ updateInfo.state !== null &&
+ updateInfo.state != "normal" &&
+ needResize
+ ) {
+ throw new ExtensionError(
+ `"state": "${updateInfo.state}" may not be combined with "left", "top", "width", or "height"`
+ );
+ }
+
+ let win = windowManager.get(windowId, context);
+ if (!win) {
+ throw new ExtensionError(`Invalid window ID: ${windowId}`);
+ }
+
+ // Update the window only after it has been fully initialized.
+ if (win.window.webExtensionWindowCreatePending) {
+ await new Promise(resolve => {
+ win.window.addEventListener(
+ "webExtensionWindowCreateDone",
+ resolve,
+ { once: true }
+ );
+ });
+ }
+
+ if (updateInfo.focused) {
+ win.window.focus();
+ }
+
+ if (updateInfo.state !== null) {
+ await win.setState(updateInfo.state);
+ }
+
+ if (updateInfo.drawAttention) {
+ // Bug 1257497 - Firefox can't cancel attention actions.
+ win.window.getAttention();
+ }
+
+ updateGeometry(win.window, updateInfo);
+
+ if (updateInfo.titlePreface !== null) {
+ win.setTitlePreface(updateInfo.titlePreface);
+ if (win instanceof TabmailWindow) {
+ win.window.document.getElementById("tabmail").setDocumentTitle();
+ } else if (win.window.gBrowser?.updateTitlebar) {
+ await win.window.gBrowser.updateTitlebar();
+ }
+ }
+
+ // TODO: All the other properties, focused=false...
+
+ return win.convert();
+ },
+
+ remove(windowId) {
+ let window = windowTracker.getWindow(windowId, context);
+ window.close();
+
+ return new Promise(resolve => {
+ let listener = () => {
+ windowTracker.removeListener("domwindowclosed", listener);
+ resolve();
+ };
+ windowTracker.addListener("domwindowclosed", listener);
+ });
+ },
+ openDefaultBrowser(url) {
+ let uri = null;
+ try {
+ uri = Services.io.newURI(url);
+ } catch (e) {
+ throw new ExtensionError(`Url "${url}" seems to be malformed.`);
+ }
+ if (!uri.schemeIs("http") && !uri.schemeIs("https")) {
+ throw new ExtensionError(
+ `Url scheme "${uri.scheme}" is not supported.`
+ );
+ }
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/processScript.js b/comm/mail/components/extensions/processScript.js
new file mode 100644
index 0000000000..4b71e651a8
--- /dev/null
+++ b/comm/mail/components/extensions/processScript.js
@@ -0,0 +1,71 @@
+/* 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/. */
+
+// Inject the |messenger| object as an alias to |browser| in all known contexts.
+// This script is injected into all processes.
+
+// This is a bit fragile since it uses monkeypatching. If a test fails, the best
+// way to debug is to search for Schemas.exportLazyGetter where it does the
+// injections, add |messenger| alias to those files until the test passes again,
+// and then find out why the monkeypatching is not catching it.
+
+const { ExtensionContent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionContent.sys.mjs"
+);
+const { ExtensionPageChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPageChild.sys.mjs"
+);
+const { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+);
+
+let getContext = ExtensionContent.getContext;
+let initExtensionContext = ExtensionContent.initExtensionContext;
+let initPageChildExtensionContext = ExtensionPageChild.initExtensionContext;
+
+// This patches constructor of ContentScriptContextChild adding the object to
+// the sandbox.
+ExtensionContent.getContext = function (extension, window) {
+ let context = getContext.apply(ExtensionContent, arguments);
+ if (!("messenger" in context.sandbox)) {
+ Schemas.exportLazyGetter(
+ context.sandbox,
+ "messenger",
+ () => context.chromeObj
+ );
+ }
+ return context;
+};
+
+// This patches extension content within unprivileged pages, so an iframe on a
+// web page that points to a moz-extension:// page exposed via
+// web_accessible_content.
+ExtensionContent.initExtensionContext = function (extension, window) {
+ let context = extension.getContext(window);
+ Schemas.exportLazyGetter(window, "messenger", () => context.chromeObj);
+
+ return initExtensionContext.apply(ExtensionContent, arguments);
+};
+
+// This patches privileged pages such as the background script.
+ExtensionPageChild.initExtensionContext = function (extension, window) {
+ let retval = initPageChildExtensionContext.apply(
+ ExtensionPageChild,
+ arguments
+ );
+
+ let windowId = ExtensionUtils.getInnerWindowID(window);
+ let context = ExtensionPageChild.extensionContexts.get(windowId);
+
+ Schemas.exportLazyGetter(window, "messenger", () => {
+ let messengerObj = Cu.createObjectIn(window);
+ context.childManager.inject(messengerObj);
+ return messengerObj;
+ });
+
+ return retval;
+};
diff --git a/comm/mail/components/extensions/schemas/LICENSE b/comm/mail/components/extensions/schemas/LICENSE
new file mode 100644
index 0000000000..9314092fdc
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/LICENSE
@@ -0,0 +1,27 @@
+// Copyright (c) 2006-2008 The Chromium Authors. All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are
+// met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+// * Redistributions in binary form must reproduce the above
+// copyright notice, this list of conditions and the following disclaimer
+// in the documentation and/or other materials provided with the
+// distribution.
+// * Neither the name of Google Inc. nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/comm/mail/components/extensions/schemas/accounts.json b/comm/mail/components/extensions/schemas/accounts.json
new file mode 100644
index 0000000000..fb325425b2
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/accounts.json
@@ -0,0 +1,235 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["accountsRead"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "accounts",
+ "permissions": ["accountsRead"],
+ "types": [
+ {
+ "id": "MailAccount",
+ "description": "An object describing a mail account, as returned for example by the :ref:`accounts.list` and :ref:`accounts.get` methods. The ``folders`` property is only included if requested.",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique identifier for this account."
+ },
+ "name": {
+ "type": "string",
+ "description": "The human-friendly name of this account."
+ },
+ "type": {
+ "type": "string",
+ "description": "What sort of account this is, e.g. <value>imap</value>, <value>nntp</value>, or <value>pop3</value>."
+ },
+ "folders": {
+ "type": "array",
+ "optional": true,
+ "description": "The folders for this account are only included if requested.",
+ "items": {
+ "$ref": "folders.MailFolder"
+ }
+ },
+ "identities": {
+ "type": "array",
+ "description": "The identities associated with this account. The default identity is listed first, others in no particular order.",
+ "items": {
+ "$ref": "identities.MailIdentity"
+ }
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "list",
+ "type": "function",
+ "description": "Returns all mail accounts. They will be returned in the same order as used in Thunderbird's folder pane.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "includeFolders",
+ "description": "Specifies whether the returned :ref:`accounts.MailAccount` objects should included their account's folders. Defaults to <value>true</value>.",
+ "optional": true,
+ "default": true,
+ "type": "boolean"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "accounts.MailAccount"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Returns details of the requested account, or <value>null</value> if it doesn't exist.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "name": "includeFolders",
+ "description": "Specifies whether the returned :ref:`accounts.MailAccount` object should included the account's folders. Defaults to <value>true</value>.",
+ "optional": true,
+ "default": true,
+ "type": "boolean"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "accounts.MailAccount",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getDefault",
+ "type": "function",
+ "description": "Returns the default account, or <value>null</value> if it is not defined.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "includeFolders",
+ "description": "Specifies whether the returned :ref:`accounts.MailAccount` object should included the account's folders. Defaults to <value>true</value>.",
+ "optional": true,
+ "default": true,
+ "type": "boolean"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "accounts.MailAccount",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setDefaultIdentity",
+ "type": "function",
+ "description": "Sets the default identity for an account.",
+ "async": true,
+ "deprecated": "This will be removed. Use :ref:`identities.setDefault` instead.",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "name": "identityId",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "getDefaultIdentity",
+ "type": "function",
+ "description": "Returns the default identity for an account, or <value>null</value> if it is not defined.",
+ "async": "callback",
+ "deprecated": "This will be removed. Use :ref:`identities.getDefault` instead.",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "identities.MailIdentity"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a new account has been created.",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "account",
+ "$ref": "MailAccount"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when an account has been removed.",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when a property of an account has been modified. Folders and identities of accounts are not monitored by this event, use the dedicated folder and identity events instead. A changed ``defaultIdentity`` is reported only after a different identity has been assigned as default identity, but not after a property of the default identity has been changed.",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "changedValues",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The human-friendly name of this account."
+ },
+ "defaultIdentity": {
+ "$ref": "identities.MailIdentity",
+ "description": "The default identity of this account."
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/addressBook.json b/comm/mail/components/extensions/schemas/addressBook.json
new file mode 100644
index 0000000000..40b0b477fc
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/addressBook.json
@@ -0,0 +1,977 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["addressBooks", "sensitiveDataUpload"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "addressBooks",
+ "permissions": ["addressBooks"],
+ "types": [
+ {
+ "id": "NodeType",
+ "type": "string",
+ "enum": ["addressBook", "contact", "mailingList"],
+ "description": "Indicates the type of a Node."
+ },
+ {
+ "id": "AddressBookNode",
+ "type": "object",
+ "description": "A node representing an address book.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The unique identifier for the node. IDs are unique within the current profile, and they remain valid even after the program is restarted."
+ },
+ "parentId": {
+ "type": "string",
+ "optional": true,
+ "description": "The ``id`` of the parent object."
+ },
+ "type": {
+ "$ref": "NodeType",
+ "description": "Always set to <value>addressBook</value>."
+ },
+ "readOnly": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the object is read-only."
+ },
+ "remote": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the address book is accessed via remote look-up."
+ },
+ "name": {
+ "type": "string"
+ },
+ "contacts": {
+ "type": "array",
+ "optional": true,
+ "items": {
+ "$ref": "contacts.ContactNode"
+ },
+ "description": "A list of contacts held by this node's address book or mailing list."
+ },
+ "mailingLists": {
+ "type": "array",
+ "optional": true,
+ "items": {
+ "$ref": "mailingLists.MailingListNode"
+ },
+ "description": "A list of mailingLists in this node's address book."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "openUI",
+ "type": "function",
+ "async": "callback",
+ "description": "Opens the address book user interface.",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "$ref": "tabs.Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "closeUI",
+ "type": "function",
+ "async": true,
+ "description": "Closes the address book user interface.",
+ "parameters": []
+ },
+ {
+ "name": "list",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "complete",
+ "type": "boolean",
+ "optional": true,
+ "default": false,
+ "description": "If set to true, results will include contacts and mailing lists for each address book."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "AddressBookNode"
+ }
+ }
+ ]
+ }
+ ],
+ "description": "Gets a list of the user's address books, optionally including all contacts and mailing lists."
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "complete",
+ "type": "boolean",
+ "optional": true,
+ "default": false,
+ "description": "If set to true, results will include contacts and mailing lists for this address book."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "AddressBookNode"
+ }
+ ]
+ }
+ ],
+ "description": "Gets a single address book, optionally including all contacts and mailing lists."
+ },
+ {
+ "name": "create",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "properties",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "string",
+ "description": "The id of the new address book."
+ }
+ ]
+ }
+ ],
+ "description": "Creates a new, empty address book."
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "properties",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ }
+ }
+ ],
+ "description": "Renames an address book."
+ },
+ {
+ "name": "delete",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ],
+ "description": "Removes an address book, and all associated contacts and mailing lists."
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when an address book is created.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "AddressBookNode"
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when an address book is renamed.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "AddressBookNode"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when an addressBook is deleted.",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "addressBooks.provider",
+ "permissions": ["addressBooks"],
+ "events": [
+ {
+ "name": "onSearchRequest",
+ "type": "function",
+ "description": "Registering this listener will create and list a read-only address book in Thunderbird's address book window, similar to LDAP address books. When selecting this address book, users will first see no contacts, but they can search for them, which will fire this event. Contacts returned by the listener callback will be displayed as contact cards in the address book. Several listeners can be registered, to create multiple address books.\n\nThe event also fires for each registered listener (for each created read-only address book), when users type something into the mail composer's <em>To:</em> field, or into similar fields like the calendar meeting attendees field. Contacts returned by the listener callback will be added to the autocomplete results in the dropdown of that field.\n\nExample: <literalinclude>includes/addressBooks/onSearchRequest.js<lang>JavaScript</lang></literalinclude>",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "AddressBookNode"
+ },
+ {
+ "name": "searchString",
+ "description": "The search text that the user entered. Not available when invoked from the advanced address book search dialog.",
+ "type": "string",
+ "optional": true
+ },
+ {
+ "name": "query",
+ "type": "string",
+ "description": "The boolean query expression corresponding to the search. **Note:** This parameter may change in future releases of Thunderbird.",
+ "optional": true
+ }
+ ],
+ "extraParameters": [
+ {
+ "name": "parameters",
+ "description": "Descriptions for the address book created by registering this listener.",
+ "type": "object",
+ "properties": {
+ "addressBookName": {
+ "type": "string",
+ "optional": true,
+ "description": "The name of the created address book."
+ },
+ "isSecure": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the address book search queries are using encrypted protocols like HTTPS."
+ },
+ "id": {
+ "type": "string",
+ "optional": true,
+ "description": "The unique ID of the created address book. If several listeners have been added, the ``id`` allows to identify which address book initiated the search request. If not provided, a unique ID will be generated for you."
+ }
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "contacts",
+ "permissions": ["addressBooks"],
+ "types": [
+ {
+ "id": "QueryInfo",
+ "description": "Object defining a query for :ref:`contacts.quickSearch`.",
+ "type": "object",
+ "properties": {
+ "searchString": {
+ "type": "string",
+ "optional": true,
+ "description": "One or more space-separated terms to search for."
+ },
+ "includeLocal": {
+ "type": "boolean",
+ "optional": true,
+ "default": true,
+ "description": "Whether to include results from local address books. Defaults to true."
+ },
+ "includeRemote": {
+ "type": "boolean",
+ "optional": true,
+ "default": true,
+ "description": "Whether to include results from remote address books. Defaults to true."
+ },
+ "includeReadOnly": {
+ "type": "boolean",
+ "optional": true,
+ "default": true,
+ "description": "Whether to include results from read-only address books. Defaults to true."
+ },
+ "includeReadWrite": {
+ "type": "boolean",
+ "optional": true,
+ "default": true,
+ "description": "Whether to include results from read-write address books. Defaults to true."
+ }
+ }
+ },
+ {
+ "id": "ContactNode",
+ "type": "object",
+ "description": "A node representing a contact in an address book.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The unique identifier for the node. IDs are unique within the current profile, and they remain valid even after the program is restarted."
+ },
+ "parentId": {
+ "type": "string",
+ "optional": true,
+ "description": "The ``id`` of the parent object."
+ },
+ "type": {
+ "$ref": "addressBooks.NodeType",
+ "description": "Always set to <value>contact</value>."
+ },
+ "readOnly": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the object is read-only."
+ },
+ "remote": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the object came from a remote address book."
+ },
+ "properties": {
+ "$ref": "ContactProperties"
+ }
+ }
+ },
+ {
+ "id": "ContactProperties",
+ "type": "object",
+ "description": "A set of individual properties for a particular contact, and its vCard string. Further information can be found in :ref:`howto_contacts`.",
+ "patternProperties": {
+ "^\\w+$": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "PropertyChange",
+ "type": "object",
+ "description": "A dictionary of changed properties. Keys are the property name that changed, values are an object containing ``oldValue`` and ``newValue``. Values can be either a string or <value>null</value>.",
+ "patternProperties": {
+ "^\\w+$": {
+ "type": "object",
+ "properties": {
+ "oldValue": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "newValue": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "list",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "ContactNode"
+ }
+ }
+ ]
+ }
+ ],
+ "description": "Gets all the contacts in the address book with the id ``parentId``."
+ },
+ {
+ "name": "quickSearch",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string",
+ "optional": true,
+ "description": "The id of the address book to search. If not specified, all address books are searched."
+ },
+ {
+ "name": "queryInfo",
+ "description": "Either a <em>string</em> with one or more space-separated terms to search for, or a complex :ref:`contacts.QueryInfo` search query.",
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "QueryInfo"
+ }
+ ]
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "ContactNode"
+ }
+ }
+ ]
+ }
+ ],
+ "description": "Gets all contacts matching ``queryInfo`` in the address book with the id ``parentId``."
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ContactNode"
+ }
+ ]
+ }
+ ],
+ "description": "Gets a single contact."
+ },
+ {
+ "name": "getPhoto",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "object",
+ "optional": true,
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ }
+ ]
+ }
+ ],
+ "description": "Gets the photo associated with this contact, if any."
+ },
+ {
+ "name": "setPhoto",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "file",
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ }
+ ],
+ "description": "Sets the photo associated with this contact."
+ },
+ {
+ "name": "create",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "name": "id",
+ "type": "string",
+ "description": "Assigns the contact an id. If an existing contact has this id, an exception is thrown. **Note:** Deprecated, the card's id should be specified in the vCard string instead.",
+ "optional": true
+ },
+ {
+ "name": "properties",
+ "$ref": "ContactProperties",
+ "description": "The properties object for the new contact. If it includes a ``vCard`` member, all specified `legacy properties <|link-legacy-properties|>`__ are ignored and the new contact will be based on the provided vCard string. If a UID is specified in the vCard string, which is already used by another contact, an exception is thrown. **Note:** Using individual properties is deprecated, use the ``vCard`` member instead."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "string",
+ "description": "The ID of the new contact."
+ }
+ ]
+ }
+ ],
+ "description": "Adds a new contact to the address book with the id ``parentId``."
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "properties",
+ "$ref": "ContactProperties",
+ "description": "An object with properties to update the specified contact. Individual properties are removed, if they are set to <value>null</value>. If the provided object includes a ``vCard`` member, all specified `legacy properties <|link-legacy-properties|>`__ are ignored and the details of the contact will be replaced by the provided vCard. Changes to the UID will be ignored. **Note:** Using individual properties is deprecated, use the ``vCard`` member instead. "
+ }
+ ],
+ "description": "Updates a contact."
+ },
+ {
+ "name": "delete",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ],
+ "description": "Removes a contact from the address book. The contact is also removed from any mailing lists it is a member of."
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a contact is created.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "ContactNode"
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when a contact is changed.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "ContactNode"
+ },
+ {
+ "name": "changedProperties",
+ "$ref": "PropertyChange"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when a contact is removed from an address book.",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "mailingLists",
+ "permissions": ["addressBooks"],
+ "types": [
+ {
+ "id": "MailingListNode",
+ "type": "object",
+ "description": "A node representing a mailing list.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The unique identifier for the node. IDs are unique within the current profile, and they remain valid even after the program is restarted."
+ },
+ "parentId": {
+ "type": "string",
+ "optional": true,
+ "description": "The ``id`` of the parent object."
+ },
+ "type": {
+ "$ref": "addressBooks.NodeType",
+ "description": "Always set to <value>mailingList</value>."
+ },
+ "readOnly": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the object is read-only."
+ },
+ "remote": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates if the object came from a remote address book."
+ },
+ "name": {
+ "type": "string"
+ },
+ "nickName": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "contacts": {
+ "type": "array",
+ "optional": true,
+ "items": {
+ "$ref": "contacts.ContactNode"
+ },
+ "description": "A list of contacts held by this node's address book or mailing list."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "list",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "MailingListNode"
+ }
+ }
+ ]
+ }
+ ],
+ "description": "Gets all the mailing lists in the address book with id ``parentId``."
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MailingListNode"
+ }
+ ]
+ }
+ ],
+ "description": "Gets a single mailing list."
+ },
+ {
+ "name": "create",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "name": "properties",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "nickName": {
+ "type": "string",
+ "optional": true
+ },
+ "description": {
+ "type": "string",
+ "optional": true
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "string",
+ "description": "The ID of the new mailing list."
+ }
+ ]
+ }
+ ],
+ "description": "Creates a new mailing list in the address book with id ``parentId``."
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "properties",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "nickName": {
+ "type": "string",
+ "optional": true
+ },
+ "description": {
+ "type": "string",
+ "optional": true
+ }
+ }
+ }
+ ],
+ "description": "Edits the properties of a mailing list."
+ },
+ {
+ "name": "delete",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ],
+ "description": "Removes the mailing list."
+ },
+ {
+ "name": "addMember",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "contactId",
+ "type": "string"
+ }
+ ],
+ "description": "Adds a contact to the mailing list with id ``id``. If the contact and mailing list are in different address books, the contact will also be copied to the list's address book."
+ },
+ {
+ "name": "listMembers",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "contacts.ContactNode"
+ }
+ }
+ ]
+ }
+ ],
+ "description": "Gets all contacts that are members of the mailing list with id ``id``."
+ },
+ {
+ "name": "removeMember",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string"
+ },
+ {
+ "name": "contactId",
+ "type": "string"
+ }
+ ],
+ "description": "Removes a contact from the mailing list with id ``id``. This does not delete the contact from the address book."
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a mailing list is created.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "MailingListNode"
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when a mailing list is changed.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "MailingListNode"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when a mailing list is deleted.",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "onMemberAdded",
+ "type": "function",
+ "description": "Fired when a contact is added to the mailing list.",
+ "parameters": [
+ {
+ "name": "node",
+ "$ref": "contacts.ContactNode"
+ }
+ ]
+ },
+ {
+ "name": "onMemberRemoved",
+ "type": "function",
+ "description": "Fired when a contact is removed from the mailing list.",
+ "parameters": [
+ {
+ "name": "parentId",
+ "type": "string"
+ },
+ {
+ "name": "id",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/browserAction.json b/comm/mail/components/extensions/schemas/browserAction.json
new file mode 100644
index 0000000000..ed1900f1e0
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/browserAction.json
@@ -0,0 +1,848 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "action": {
+ "min_manifest_version": 3,
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "default_label": {
+ "type": "string",
+ "description": "The label of the action button, defaults to its title. Can be set to an empty string to not display any label. If the containing toolbar is configured to display text only, the title will be used as fallback.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_title": {
+ "type": "string",
+ "description": "The title of the action button. This shows up in the tooltip and the label. Defaults to the add-on name.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_icon": {
+ "$ref": "IconPath",
+ "description": "The paths to one or more icons for the action button.",
+ "optional": true
+ },
+ "theme_icons": {
+ "type": "array",
+ "optional": true,
+ "minItems": 1,
+ "items": {
+ "$ref": "ThemeIcons"
+ },
+ "description": "Specifies dark and light icons to be used with themes. The ``light`` icon is used on dark backgrounds and vice versa. **Note:** The default theme uses the ``default_icon`` for light backgrounds (if specified)."
+ },
+ "default_popup": {
+ "type": "string",
+ "format": "relativeUrl",
+ "optional": true,
+ "description": "The html document to be opened as a popup when the user clicks on the action button. Ignored for action buttons with type <value>menu</value>.",
+ "preprocess": "localize"
+ },
+ "browser_style": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Enable browser styles. See the `MDN documentation on browser styles <|link-mdn-browser-styles|>`__ for more information.",
+ "default": false
+ },
+ "default_windows": {
+ "description": "Defines the windows, the action button should appear in. Defaults to showing it only in the <value>normal</value> Thunderbird window, but can also be shown in the <value>messageDisplay</value> window.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["normal", "messageDisplay"]
+ },
+ "default": ["normal"],
+ "optional": true
+ },
+ "allowed_spaces": {
+ "description": "Defines for which spaces the action button will be added to Thunderbird's unified toolbar. Defaults to only allowing the action in the <value>mail</value> space. The <value>default</value> space is for tabs that don't belong to any space. If this is an empty array, the action button is shown in all spaces.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "mail",
+ "addressbook",
+ "calendar",
+ "tasks",
+ "chat",
+ "settings",
+ "default"
+ ]
+ },
+ "default": ["mail"],
+ "optional": true
+ },
+ "type": {
+ "description": "Specifies the type of the button. Default type is <code>button</code>.",
+ "type": "string",
+ "enum": ["button", "menu"],
+ "optional": true,
+ "default": "button"
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "browser_action": {
+ "max_manifest_version": 2,
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "default_label": {
+ "type": "string",
+ "description": "The label of the browserAction button, defaults to its title. Can be set to an empty string to not display any label. If the containing toolbar is configured to display text only, the title will be used as fallback.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_title": {
+ "type": "string",
+ "description": "The title of the browserAction button. This shows up in the tooltip and the label. Defaults to the add-on name.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_icon": {
+ "$ref": "IconPath",
+ "description": "The paths to one or more icons for the browserAction button.",
+ "optional": true
+ },
+ "theme_icons": {
+ "type": "array",
+ "optional": true,
+ "minItems": 1,
+ "items": {
+ "$ref": "ThemeIcons"
+ },
+ "description": "Specifies dark and light icons to be used with themes. The ``light`` icon is used on dark backgrounds and vice versa. **Note:** The default theme uses the ``default_icon`` for light backgrounds (if specified)."
+ },
+ "default_popup": {
+ "type": "string",
+ "format": "relativeUrl",
+ "optional": true,
+ "description": "The html document to be opened as a popup when the user clicks on the browserAction button. Ignored for action buttons with type <value>menu</value>.",
+ "preprocess": "localize"
+ },
+ "browser_style": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Enable browser styles. See the `MDN documentation on browser styles <|link-mdn-browser-styles|>`__ for more information.",
+ "default": false
+ },
+ "default_area": {
+ "description": "Defines the location the browserAction button will appear. Deprecated and ignored. Replaced by ``allowed_spaces``",
+ "type": "string",
+ "enum": ["maintoolbar", "tabstoolbar"],
+ "optional": true
+ },
+ "default_windows": {
+ "description": "Defines the windows, the browserAction button should appear in. Defaults to showing it only in the <value>normal</value> Thunderbird window, but can also be shown in the <value>messageDisplay</value> window.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["normal", "messageDisplay"]
+ },
+ "default": ["normal"],
+ "optional": true
+ },
+ "allowed_spaces": {
+ "description": "Defines for which spaces the browserAction button will be added to Thunderbird's unified toolbar. Defaults to only allowing the browserAction in the <value>mail</value> space. The <value>default</value> space is for tabs that don't belong to any space. If this is an empty array, the browserAction button is shown in all spaces.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "mail",
+ "addressbook",
+ "calendar",
+ "tasks",
+ "chat",
+ "settings",
+ "default"
+ ]
+ },
+ "default": ["mail"],
+ "optional": true
+ },
+ "type": {
+ "description": "Specifies the type of the button. Default type is <code>button</code>.",
+ "type": "string",
+ "enum": ["button", "menu"],
+ "optional": true,
+ "default": "button"
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "action",
+ "description": "Use the action API to add a button to Thunderbird's unified toolbar. In addition to its icon, an action button can also have a tooltip, a badge, and a popup.",
+ "permissions": ["manifest:action", "manifest:browser_action"],
+ "min_manifest_version": 3,
+ "types": [
+ {
+ "id": "ColorArray",
+ "description": "An array of four integers in the range [0,255] that make up the RGBA color. For example, opaque red is <value>[255, 0, 0, 255]</value>.",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ "minItems": 4,
+ "maxItems": 4
+ },
+ {
+ "id": "ImageDataType",
+ "type": "object",
+ "isInstanceOf": "ImageData",
+ "additionalProperties": {
+ "type": "any"
+ },
+ "postprocess": "convertImageDataToURL",
+ "description": "Pixel data for an image. Must be an |ImageData| object (for example, from a |Canvas| element)."
+ },
+ {
+ "id": "ImageDataDictionary",
+ "type": "object",
+ "description": "A <em>dictionary object</em> to specify multiple |ImageData| objects in different sizes, so the icon does not have to be scaled for a device with a different pixel density. Each entry is a <em>name-value</em> pair with <em>value</em> being an |ImageData| object, and <em>name</em> its size. Example: <literalinclude>includes/ImageDataDictionary.json<lang>JavaScript</lang></literalinclude>See the `MDN documentation about choosing icon sizes <|link-mdn-icon-size|>`__ for more information on this.",
+ "patternProperties": {
+ "^[1-9]\\d*$": {
+ "$ref": "ImageDataType"
+ }
+ }
+ },
+ {
+ "id": "OnClickData",
+ "type": "object",
+ "description": "Information sent when an action button is clicked.",
+ "properties": {
+ "modifiers": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"]
+ },
+ "description": "An array of keyboard modifiers that were held while the menu item was clicked."
+ },
+ "button": {
+ "type": "integer",
+ "optional": true,
+ "description": "An integer value of button by which menu item was clicked."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "setTitle",
+ "type": "function",
+ "description": "Sets the title of the action button. Is used as tooltip and as the label.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "title": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the action button should display as its label and when moused over. Cleared by setting it to <value>null</value> or an empty string (title defined the manifest will be used)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the title only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getTitle",
+ "type": "function",
+ "description": "Gets the title of the action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the title should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setLabel",
+ "type": "function",
+ "description": "Sets the label of the action button. Can be used to set different values for the tooltip (defined by the title) and the label. Additionally, the label can be set to an empty string, not showing any label at all.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "label": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the action button should use as its label, overriding the defined title. Can be set to an empty string to not display any label at all. If the containing toolbar is configured to display text only, its title will be used. Cleared by setting it to <value>null</value>."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the label only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getLabel",
+ "type": "function",
+ "description": "Gets the label of the action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the label should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setIcon",
+ "type": "function",
+ "description": "Sets the icon for the action button. Either the ``path`` or the ``imageData`` property must be specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "imageData": {
+ "choices": [
+ {
+ "$ref": "ImageDataType"
+ },
+ {
+ "$ref": "ImageDataDictionary"
+ }
+ ],
+ "optional": true,
+ "description": "The image data for one or more icons for the action button."
+ },
+ "path": {
+ "$ref": "manifest.IconPath",
+ "optional": true,
+ "description": "The paths to one or more icons for the action button."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the icon only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "setPopup",
+ "type": "function",
+ "description": "Sets the html document to be opened as a popup when the user clicks on the action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "popup": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The html file to show in a popup. Can be set to an empty string to not open a popup. Cleared by setting it to <value>null</value> (popup value defined the manifest will be used)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the popup only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getPopup",
+ "type": "function",
+ "description": "Gets the html document set as the popup for this action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the popup document should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeText",
+ "type": "function",
+ "description": "Sets the badge text for the action button. The badge is displayed on top of the icon.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "text": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Any number of characters can be passed, but only about four can fit in the space. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the badge text only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeText",
+ "type": "function",
+ "description": "Gets the badge text of the action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge text should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeBackgroundColor",
+ "type": "function",
+ "description": "Sets the background color for the badge.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "color": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "ColorArray"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The color to use as background in the badge. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the background color for the badge only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeBackgroundColor",
+ "type": "function",
+ "description": "Gets the badge background color of the action button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge background color should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "$ref": "ColorArray"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "enable",
+ "type": "function",
+ "description": "Enables the action button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well. By default, an action button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the action button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "disable",
+ "type": "function",
+ "description": "Disables the action button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the action button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "isEnabled",
+ "type": "function",
+ "description": "Checks whether the action button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the state should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "openPopup",
+ "type": "function",
+ "description": "Opens the action's popup window in the specified window. Defaults to the current window. Returns false if the popup could not be opened because the action has no popup, is of type <value>menu</value>, is disabled or has been removed from the toolbar.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "options",
+ "optional": true,
+ "type": "object",
+ "description": "An object with information about the popup to open.",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "Defaults to the current window."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onClicked",
+ "type": "function",
+ "description": "Fired when an action button is clicked. This event will not fire if the action has a popup. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "info",
+ "$ref": "OnClickData",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "browserAction",
+ "description": "Use the browserAction API to add a button to Thunderbird's unified toolbar. In addition to its icon, a browserAction button can also have a tooltip, a badge, and a popup.",
+ "permissions": ["manifest:action", "manifest:browser_action"],
+ "max_manifest_version": 2,
+ "$import": "action"
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/chrome_settings_overrides.json b/comm/mail/components/extensions/schemas/chrome_settings_overrides.json
new file mode 100644
index 0000000000..4fe67050f3
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/chrome_settings_overrides.json
@@ -0,0 +1,194 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "chrome_settings_overrides": {
+ "type": "object",
+ "optional": true,
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "search_provider": {
+ "type": "object",
+ "optional": true,
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "name": {
+ "type": "string",
+ "preprocess": "localize"
+ },
+ "keyword": {
+ "optional": true,
+ "choices": [
+ {
+ "type": "string",
+ "preprocess": "localize"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "preprocess": "localize"
+ },
+ "minItems": 1
+ }
+ ]
+ },
+ "search_url": {
+ "type": "string",
+ "format": "url",
+ "pattern": "^https://.*$",
+ "preprocess": "localize"
+ },
+ "favicon_url": {
+ "type": "string",
+ "optional": true,
+ "format": "url",
+ "preprocess": "localize"
+ },
+ "suggest_url": {
+ "type": "string",
+ "optional": true,
+ "pattern": "^https://.*$|^$",
+ "preprocess": "localize"
+ },
+ "instant_url": {
+ "type": "string",
+ "optional": true,
+ "format": "url",
+ "preprocess": "localize",
+ "deprecated": "Unsupported on Thunderbird at this time."
+ },
+ "image_url": {
+ "type": "string",
+ "optional": true,
+ "format": "url",
+ "preprocess": "localize",
+ "deprecated": "Unsupported on Thunderbird at this time."
+ },
+ "search_url_get_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "description": "GET parameters to the search_url as a query string."
+ },
+ "search_url_post_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "description": "POST parameters to the search_url as a query string."
+ },
+ "suggest_url_get_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "description": "GET parameters to the suggest_url as a query string."
+ },
+ "suggest_url_post_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "description": "POST parameters to the suggest_url as a query string."
+ },
+ "instant_url_post_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "deprecated": "Unsupported on Thunderbird at this time."
+ },
+ "image_url_post_params": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize",
+ "deprecated": "Unsupported on Thunderbird at this time."
+ },
+ "search_form": {
+ "type": "string",
+ "optional": true,
+ "format": "url",
+ "pattern": "^https://.*$",
+ "preprocess": "localize"
+ },
+ "alternate_urls": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "url",
+ "preprocess": "localize"
+ },
+ "optional": true,
+ "deprecated": "Unsupported on Thunderbird at this time."
+ },
+ "prepopulated_id": {
+ "type": "integer",
+ "optional": true,
+ "deprecated": "Unsupported on Thunderbird."
+ },
+ "encoding": {
+ "type": "string",
+ "optional": true,
+ "description": "Encoding of the search term."
+ },
+ "is_default": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Sets the default engine to a built-in engine only."
+ },
+ "params": {
+ "optional": true,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "A url parameter name"
+ },
+ "condition": {
+ "type": "string",
+ "optional": true,
+ "enum": ["purpose", "pref"],
+ "description": "The type of param can be either \"purpose\" or \"pref\"."
+ },
+ "pref": {
+ "type": "string",
+ "optional": true,
+ "description": "The preference to retrieve the value from."
+ },
+ "purpose": {
+ "type": "string",
+ "optional": true,
+ "enum": [
+ "contextmenu",
+ "searchbar",
+ "homepage",
+ "keyword",
+ "newtab"
+ ],
+ "description": "The context that initiates a search, required if condition is \"purpose\"."
+ },
+ "value": {
+ "type": "string",
+ "optional": true,
+ "description": "A url parameter value.",
+ "preprocess": "localize"
+ }
+ }
+ },
+ "description": "A list of optional search url parameters. This allows the addition of search url parameters based on how the search is performed in Thunderbird."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/cloudFile.json b/comm/mail/components/extensions/schemas/cloudFile.json
new file mode 100644
index 0000000000..41c587881d
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/cloudFile.json
@@ -0,0 +1,501 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "cloud_file": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "browser_style": {
+ "type": "boolean",
+ "description": "Enable browser styles in the ``management_url`` page. See the `MDN documentation on browser styles <|link-mdn-browser-styles|>`__ for more information.",
+ "optional": true,
+ "default": false
+ },
+ "data_format": {
+ "type": "string",
+ "optional": true,
+ "deprecated": true,
+ "description": "This property is no longer used. The only supported data format for the ``data`` argument in :ref:`cloudFile.onFileUpload` is |File|."
+ },
+ "reuse_uploads": {
+ "description": "If a previously uploaded cloud file attachment is reused at a later time in a different message, Thunderbird may use the already known ``url`` and ``templateInfo`` values without triggering the registered :ref:`cloudFile.onFileUpload` listener again. Setting this option to <value>false</value> will always trigger the registered listener, providing the already known values through the ``relatedFileInfo`` parameter of the :ref:`cloudFile.onFileUpload` event, to let the provider decide how to handle these cases.",
+ "type": "boolean",
+ "optional": true,
+ "default": true
+ },
+ "management_url": {
+ "type": "string",
+ "format": "relativeUrl",
+ "preprocess": "localize",
+ "description": "A page for configuring accounts, to be displayed in the preferences UI. **Note:** Within this UI only a limited subset of the WebExtension APIs is available: ``cloudFile``, ``extension``, ``i18n``, ``runtime``, ``storage``, ``test``."
+ },
+ "name": {
+ "type": "string",
+ "preprocess": "localize",
+ "description": "Name of the cloud file service."
+ },
+ "new_account_url": {
+ "type": "string",
+ "optional": true,
+ "deprecated": true,
+ "description": "This property was never used."
+ },
+ "service_url": {
+ "type": "string",
+ "optional": true,
+ "deprecated": true,
+ "description": "This property is no longer used. The ``service_url`` property of the :ref:`cloudFile.CloudFileTemplateInfo` object returned by the :ref:`cloudFile.onFileUpload` event can be used to add a <em>Learn more about</em> link to the footer of the cloud file attachment element."
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "cloudFile",
+ "permissions": ["manifest:cloud_file"],
+ "allowedContexts": ["content"],
+ "events": [
+ {
+ "name": "onFileUpload",
+ "type": "function",
+ "description": "Fired when a file should be uploaded to the cloud file provider.",
+ "parameters": [
+ {
+ "name": "account",
+ "$ref": "CloudFileAccount",
+ "description": "The account used for the file upload."
+ },
+ {
+ "name": "fileInfo",
+ "$ref": "CloudFile",
+ "description": "The file to upload."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The tab where the upload was initiated. Currently only available for the message composer."
+ },
+ {
+ "$ref": "RelatedCloudFile",
+ "name": "relatedFileInfo",
+ "optional": true,
+ "description": "Information about an already uploaded file, which is related to this upload."
+ }
+ ],
+ "returns": {
+ "type": "object",
+ "properties": {
+ "aborted": {
+ "type": "boolean",
+ "description": "Set this to <value>true</value> if the file upload was aborted by the user and an :ref:`cloudFile.onFileUploadAbort` event has been received. No error message will be shown to the user.",
+ "optional": true
+ },
+ "error": {
+ "choices": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "description": "Report an error to the user. Set this to <value>true</value> for showing a generic error message, or set a specific error message.",
+ "optional": true
+ },
+ "url": {
+ "type": "string",
+ "description": "The URL where the uploaded file can be accessed.",
+ "optional": true
+ },
+ "templateInfo": {
+ "$ref": "CloudFileTemplateInfo",
+ "description": "Additional file information used in the cloud file entry added to the message.",
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "onFileUploadAbort",
+ "type": "function",
+ "parameters": [
+ {
+ "name": "account",
+ "$ref": "CloudFileAccount",
+ "description": "The account used for the file upload."
+ },
+ {
+ "type": "integer",
+ "name": "fileId",
+ "minimum": 1,
+ "description": "An identifier for this file."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The tab where the upload was initiated. Currently only available for the message composer."
+ }
+ ]
+ },
+ {
+ "name": "onFileRename",
+ "type": "function",
+ "description": "Fired when a previously uploaded file should be renamed.",
+ "parameters": [
+ {
+ "name": "account",
+ "$ref": "CloudFileAccount",
+ "description": "The account used for the file upload."
+ },
+ {
+ "type": "integer",
+ "name": "fileId",
+ "minimum": 1,
+ "description": "An identifier for the file which should be renamed."
+ },
+ {
+ "type": "string",
+ "name": "newName",
+ "description": "The new name of the file."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The tab where the rename was initiated. Currently only available for the message composer."
+ }
+ ],
+ "returns": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "choices": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "description": "Report an error to the user. Set this to <value>true</value> for showing a generic error message, or set a specific error message.",
+ "optional": true
+ },
+ "url": {
+ "type": "string",
+ "description": "The URL where the renamed file can be accessed.",
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "onFileDeleted",
+ "type": "function",
+ "description": "Fired when a previously uploaded file should be deleted.",
+ "parameters": [
+ {
+ "name": "account",
+ "$ref": "CloudFileAccount",
+ "description": "The account used for the file upload."
+ },
+ {
+ "type": "integer",
+ "name": "fileId",
+ "minimum": 1,
+ "description": "An identifier for this file."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The tab where the upload was initiated. Currently only available for the message composer."
+ }
+ ]
+ },
+ {
+ "name": "onAccountAdded",
+ "type": "function",
+ "description": "Fired when a cloud file account of this add-on was created.",
+ "parameters": [
+ {
+ "name": "account",
+ "$ref": "CloudFileAccount",
+ "description": "The created account."
+ }
+ ]
+ },
+ {
+ "name": "onAccountDeleted",
+ "type": "function",
+ "description": "Fired when a cloud file account of this add-on was deleted.",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string",
+ "description": "The id of the removed account."
+ }
+ ]
+ }
+ ],
+ "types": [
+ {
+ "id": "CloudFileAccount",
+ "type": "object",
+ "description": "Information about a cloud file account.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier of the account."
+ },
+ "configured": {
+ "type": "boolean",
+ "description": "If true, the account is configured and ready to use. Only configured accounts are offered to the user."
+ },
+ "name": {
+ "type": "string",
+ "description": "A user-friendly name for this account."
+ },
+ "uploadSizeLimit": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The maximum size in bytes for a single file to upload. Set to <value>-1</value> if unlimited."
+ },
+ "spaceRemaining": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The amount of remaining space on the cloud provider, in bytes. Set to <value>-1</value> if unsupported."
+ },
+ "spaceUsed": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The amount of space already used on the cloud provider, in bytes. Set to <value>-1</value> if unsupported."
+ },
+ "managementUrl": {
+ "type": "string",
+ "format": "relativeUrl",
+ "description": "A page for configuring accounts, to be displayed in the preferences UI."
+ }
+ }
+ },
+ {
+ "id": "CloudFileTemplateInfo",
+ "type": "object",
+ "description": "Defines information to be used in the cloud file entry added to the message.",
+ "properties": {
+ "service_icon": {
+ "type": "string",
+ "optional": true,
+ "description": "A URL pointing to an icon to represent the used cloud file service. Defaults to the icon of the provider add-on."
+ },
+ "service_name": {
+ "type": "string",
+ "optional": true,
+ "description": "A name to represent the used cloud file service. Defaults to the associated cloud file account name."
+ },
+ "service_url": {
+ "type": "string",
+ "optional": true,
+ "description": "A URL pointing to a web page of the used cloud file service. Will be used in a <em>Learn more about</em> link in the footer of the cloud file attachment element."
+ },
+ "download_password_protected": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If set to true, the cloud file entry for this upload will include a hint, that the download link is password protected."
+ },
+ "download_limit": {
+ "type": "integer",
+ "optional": true,
+ "description": "If set, the cloud file entry for this upload will include a hint, that the file has a download limit."
+ },
+ "download_expiry_date": {
+ "type": "object",
+ "optional": true,
+ "description": "If set, the cloud file entry for this upload will include a hint, that the link will only be available for a limited time.",
+ "properties": {
+ "timestamp": {
+ "type": "integer",
+ "description": "The expiry date of the link as the number of milliseconds since the UNIX epoch."
+ },
+ "format": {
+ "optional": true,
+ "description": "A format options object as used by |DateTimeFormat|. Defaults to: <literalinclude>includes/cloudFile/defaultDateFormat.js<lang>JavaScript</lang></literalinclude>",
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ },
+ {
+ "id": "CloudFile",
+ "type": "object",
+ "description": "Information about a cloud file.",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "An identifier for this file."
+ },
+ "name": {
+ "type": "string",
+ "description": "Filename of the file to be transferred."
+ },
+ "data": {
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true,
+ "description": "Contents of the file to be transferred."
+ }
+ }
+ },
+ {
+ "id": "RelatedCloudFile",
+ "type": "object",
+ "description": "Information about an already uploaded cloud file, which is related to a new upload. For example if the content of a cloud attachment is updated, if a repeatedly used cloud attachment is renamed (and therefore should be re-uploaded to not invalidate existing links) or if the provider has its manifest property ``reuse_uploads`` set to <value>false</value>.",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "minimum": 1,
+ "optional": true,
+ "description": "The identifier for the related file. In some circumstances, the id is unavailable."
+ },
+ "url": {
+ "type": "string",
+ "description": "The URL where the upload of the related file can be accessed.",
+ "optional": true
+ },
+ "templateInfo": {
+ "$ref": "CloudFileTemplateInfo",
+ "description": "Additional information of the related file, used in the cloud file entry added to the message.",
+ "optional": true
+ },
+ "name": {
+ "type": "string",
+ "description": "Filename of the related file."
+ },
+ "dataChanged": {
+ "type": "boolean",
+ "description": "The content of the new upload differs from the related file."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "getAccount",
+ "type": "function",
+ "description": "Retrieve information about a single cloud file account.",
+ "allowedContexts": ["content"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string",
+ "description": "Unique identifier of the account."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "CloudFileAccount"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getAllAccounts",
+ "type": "function",
+ "description": "Retrieve all cloud file accounts for the current add-on.",
+ "allowedContexts": ["content"],
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "CloudFileAccount"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "updateAccount",
+ "type": "function",
+ "description": "Update a cloud file account.",
+ "allowedContexts": ["content"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string",
+ "description": "Unique identifier of the account."
+ },
+ {
+ "name": "updateProperties",
+ "type": "object",
+ "properties": {
+ "configured": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If true, the account is configured and ready to use. Only configured accounts are offered to the user."
+ },
+ "uploadSizeLimit": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The maximum size in bytes for a single file to upload. Set to <value>-1</value> if unlimited."
+ },
+ "spaceRemaining": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The amount of remaining space on the cloud provider, in bytes. Set to <value>-1</value> if unsupported."
+ },
+ "spaceUsed": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The amount of space already used on the cloud provider, in bytes. Set to <value>-1</value> if unsupported."
+ },
+ "managementUrl": {
+ "type": "string",
+ "format": "relativeUrl",
+ "optional": true,
+ "description": "A page for configuring accounts, to be displayed in the preferences UI."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "CloudFileAccount"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/commands.json b/comm/mail/components/extensions/schemas/commands.json
new file mode 100644
index 0000000000..900e1df1a0
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/commands.json
@@ -0,0 +1,279 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "id": "KeyName",
+ "type": "string",
+ "format": "manifestShortcutKey",
+ "description": "Definition of a shortcut, for example <value>Alt+F5</value>. The string must match the shortcut format as defined by the `MDN page of the commands API <|link-commands-shortcuts|>`__."
+ },
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "commands": {
+ "optional": true,
+ "choices": [
+ {
+ "type": "object",
+ "max_manifest_version": 2,
+ "description": "A <em>dictionary object</em> defining one or more commands as <em>name-value</em> pairs, the <em>name</em> being the name of the command and the <em>value</em> being a :ref:`commands.CommandsShortcut`. The <em>name</em> may also be one of the following built-in special shortcuts: \n * <value>_execute_browser_action</value> \n * <value>_execute_compose_action</value> \n * <value>_execute_message_display_action</value>\nExample: <literalinclude>includes/commands/manifest.json<lang>JSON</lang></literalinclude>",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "suggested_key": {
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "default": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "mac": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "linux": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "windows": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "chromeos": {
+ "type": "string",
+ "optional": true
+ },
+ "android": {
+ "type": "string",
+ "optional": true
+ },
+ "ios": {
+ "type": "string",
+ "optional": true
+ },
+ "additionalProperties": {
+ "type": "string",
+ "deprecated": "Unknown platform name",
+ "optional": true
+ }
+ }
+ },
+ "description": {
+ "type": "string",
+ "preprocess": "localize",
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "min_manifest_version": 3,
+ "description": "A <em>dictionary object</em> defining one or more commands as <em>name-value</em> pairs, the <em>name</em> being the name of the command and the <em>value</em> being a :ref:`commands.CommandsShortcut`. The <em>name</em> may also be one of the following built-in special shortcuts: \n * <value>_execute_action</value> \n * <value>_execute_compose_action</value> \n * <value>_execute_message_display_action</value>\nExample: <literalinclude>includes/commands/manifest.json<lang>JSON</lang></literalinclude>",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "suggested_key": {
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "default": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "mac": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "linux": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "windows": {
+ "$ref": "KeyName",
+ "optional": true
+ },
+ "chromeos": {
+ "type": "string",
+ "optional": true
+ },
+ "android": {
+ "type": "string",
+ "optional": true
+ },
+ "ios": {
+ "type": "string",
+ "optional": true
+ },
+ "additionalProperties": {
+ "type": "string",
+ "deprecated": "Unknown platform name",
+ "optional": true
+ }
+ }
+ },
+ "description": {
+ "type": "string",
+ "preprocess": "localize",
+ "optional": true
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "commands",
+ "description": "Use the commands API to add keyboard shortcuts that trigger actions in your extension, for example opening one of the action popups or sending a command to the extension.",
+ "permissions": ["manifest:commands"],
+ "types": [
+ {
+ "id": "Command",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "optional": true,
+ "description": "The name of the Extension Command"
+ },
+ "description": {
+ "type": "string",
+ "optional": true,
+ "description": "The Extension Command description"
+ },
+ "shortcut": {
+ "type": "string",
+ "optional": true,
+ "description": "The shortcut active for this command, or blank if not active."
+ }
+ }
+ }
+ ],
+ "events": [
+ {
+ "name": "onCommand",
+ "description": "Fired when a registered command is activated using a keyboard shortcut. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "type": "function",
+ "parameters": [
+ {
+ "name": "command",
+ "type": "string"
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The details of the active tab while the command occurred."
+ }
+ ]
+ },
+ {
+ "name": "onChanged",
+ "description": "Fired when a registered command's shortcut is changed.",
+ "type": "function",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "changeInfo",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the shortcut."
+ },
+ "newShortcut": {
+ "type": "string",
+ "description": "The new shortcut active for this command, or blank if not active."
+ },
+ "oldShortcut": {
+ "type": "string",
+ "description": "The old shortcut which is no longer active for this command, or blank if the shortcut was previously inactive."
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "update",
+ "type": "function",
+ "async": true,
+ "description": "Update the details of an already defined command.",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "detail",
+ "description": "The new details for the command.",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the command."
+ },
+ "description": {
+ "type": "string",
+ "optional": true,
+ "description": "The description for the command."
+ },
+ "shortcut": {
+ "type": "string",
+ "format": "manifestShortcutKeyOrEmpty",
+ "optional": true,
+ "description": "An empty string to clear the shortcut, or a string matching the format defined by the `MDN page of the commands API <|link-commands-shortcuts|>`__ to set a new shortcut key. If the string does not match this format, the function throws an error."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "reset",
+ "type": "function",
+ "async": true,
+ "description": "Reset a command's details to what is specified in the manifest.",
+ "parameters": [
+ {
+ "type": "string",
+ "name": "name",
+ "description": "The name of the command."
+ }
+ ]
+ },
+ {
+ "name": "getAll",
+ "type": "function",
+ "async": "callback",
+ "description": "Returns all the registered extension commands for this extension and their shortcut (if active).",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "commands",
+ "type": "array",
+ "items": {
+ "$ref": "Command"
+ }
+ }
+ ],
+ "description": "Called to return the registered commands."
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/compose.json b/comm/mail/components/extensions/schemas/compose.json
new file mode 100644
index 0000000000..f6915fc363
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/compose.json
@@ -0,0 +1,937 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["compose", "compose.save", "compose.send"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "compose",
+ "types": [
+ {
+ "id": "ComposeRecipient",
+ "choices": [
+ {
+ "type": "string",
+ "description": "A name and email address in the format <value>Name <email@example.com></value>, or just an email address."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The ID of a contact or mailing list from the :doc:`contacts` and :doc:`mailingLists` APIs."
+ },
+ "type": {
+ "type": "string",
+ "description": "Which sort of object this ID is for.",
+ "enum": ["contact", "mailingList"]
+ }
+ }
+ }
+ ]
+ },
+ {
+ "id": "ComposeRecipientList",
+ "choices": [
+ {
+ "$ref": "ComposeRecipient"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "ComposeRecipient"
+ }
+ }
+ ]
+ },
+ {
+ "id": "ComposeState",
+ "type": "object",
+ "description": "Represent the state of the message composer.",
+ "properties": {
+ "canSendNow": {
+ "type": "boolean",
+ "description": "The message can be send now."
+ },
+ "canSendLater": {
+ "type": "boolean",
+ "description": "The message can be send later."
+ }
+ }
+ },
+ {
+ "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": {
+ "identityId": {
+ "type": "string",
+ "description": "The ID of an identity from the :doc:`accounts` API. The settings from the identity will be used in the composed message. If ``replyTo`` is also specified, the ``replyTo`` property of the identity is overridden. The permission <permission>accountsRead</permission> is required to include the ``identityId``.",
+ "optional": true
+ },
+ "from": {
+ "$ref": "ComposeRecipient",
+ "description": "*Caution*: Setting a value for ``from`` does not change the used identity, it overrides the FROM header. Many email servers do not accept emails where the FROM header does not match the sender identity. Must be set to exactly one valid email address.",
+ "optional": true
+ },
+ "to": {
+ "$ref": "ComposeRecipientList",
+ "optional": true
+ },
+ "cc": {
+ "$ref": "ComposeRecipientList",
+ "optional": true
+ },
+ "bcc": {
+ "$ref": "ComposeRecipientList",
+ "optional": true
+ },
+ "overrideDefaultFcc": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Indicates whether the default fcc setting (defined by the used identity) is being overridden for this message. Setting <value>false</value> will clear the override. Setting <value>true</value> will throw an <em>ExtensionError</em>, if ``overrideDefaultFccFolder`` is not set as well."
+ },
+ "overrideDefaultFccFolder": {
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "type": "string",
+ "enum": [""]
+ }
+ ],
+ "optional": true,
+ "description": " This value overrides the default fcc setting (defined by the used identity) for this message only. Either a :ref:`folders.MailFolder` specifying the folder for the copy of the sent message, or an empty string to not save a copy at all."
+ },
+ "additionalFccFolder": {
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "type": "string",
+ "enum": [""]
+ }
+ ],
+ "description": "An additional fcc folder which can be selected while composing the message, an empty string if not used.",
+ "optional": true
+ },
+ "replyTo": {
+ "$ref": "ComposeRecipientList",
+ "optional": true
+ },
+ "followupTo": {
+ "$ref": "ComposeRecipientList",
+ "optional": true
+ },
+ "newsgroups": {
+ "description": "A single newsgroup name or an array of newsgroup names.",
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ],
+ "optional": true
+ },
+ "relatedMessageId": {
+ "description": "The id of the original message (in case of draft, template, forward or reply). Read-only. Is <value>null</value> in all other cases or if the original message was opened from file.",
+ "type": "integer",
+ "optional": true
+ },
+ "subject": {
+ "type": "string",
+ "optional": true
+ },
+ "type": {
+ "type": "string",
+ "description": "Read-only. The type of the message being composed, depending on how the compose window was opened by the user.",
+ "enum": ["draft", "new", "redirect", "reply", "forward"],
+ "optional": true
+ },
+ "body": {
+ "type": "string",
+ "description": "The HTML content of the message.",
+ "optional": true
+ },
+ "plainTextBody": {
+ "type": "string",
+ "description": "The plain text content of the message.",
+ "optional": true
+ },
+ "isPlainText": {
+ "type": "boolean",
+ "description": "Whether the message is an HTML message or a plain text message.",
+ "optional": true
+ },
+ "deliveryFormat": {
+ "type": "string",
+ "enum": ["auto", "plaintext", "html", "both"],
+ "description": "Defines the mime format of the sent message (ignored on plain text messages). Defaults to <value>auto</value>, which will send html messages as plain text, if they do not include any formatting, and as <value>both</value> otherwise (a multipart/mixed message).",
+ "optional": true
+ },
+ "customHeaders": {
+ "type": "array",
+ "items": {
+ "$ref": "CustomHeader"
+ },
+ "description": "Array of custom headers. Headers will be returned in <em>Http-Header-Case</em> (a.k.a. <em>Train-Case</em>). Set an empty array to clear all custom headers.",
+ "optional": true
+ },
+ "priority": {
+ "type": "string",
+ "enum": ["lowest", "low", "normal", "high", "highest"],
+ "description": "The priority of the message.",
+ "optional": true
+ },
+ "returnReceipt": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Add the <em>Disposition-Notification-To</em> header to the message to requests the recipients email client to send a reply once the message has been received. Recipient server may strip the header and the recipient might ignore the request."
+ },
+ "deliveryStatusNotification": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Let the sender know when the recipient's server received the message. Not supported by all servers."
+ },
+ "attachVCard": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Wether or not the vCard of the used identity will be attached to the message during send. Note: If the value has not been modified, selecting a different identity will load the default value of the new identity."
+ },
+ "attachments": {
+ "type": "array",
+ "items": {
+ "choices": [
+ {
+ "$ref": "FileAttachment"
+ },
+ {
+ "$ref": "ComposeAttachment"
+ }
+ ]
+ },
+ "description": "Only used in the begin* functions. Attachments to add to the message.",
+ "optional": true
+ }
+ }
+ },
+ {
+ "id": "FileAttachment",
+ "type": "object",
+ "description": "Object used to add, update or rename an attachment in a message being composed.",
+ "properties": {
+ "file": {
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true,
+ "description": "The new content for the attachment.",
+ "optional": true
+ },
+ "name": {
+ "type": "string",
+ "description": "The new name for the attachment, as displayed to the user. If not specified, the name of the provided ``file`` object is used.",
+ "optional": true
+ }
+ }
+ },
+ {
+ "id": "ComposeAttachment",
+ "type": "object",
+ "description": "Represents an attachment in a message being composed.",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "A unique identifier for this attachment."
+ },
+ "name": {
+ "type": "string",
+ "optional": true,
+ "description": "The name of this attachment, as displayed to the user."
+ },
+ "size": {
+ "type": "integer",
+ "optional": true,
+ "description": "The size in bytes of this attachment. Read-only."
+ }
+ }
+ },
+ {
+ "id": "CustomHeader",
+ "type": "object",
+ "description": "A custom header definition.",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name of a custom header, must have a <value>X-</value> prefix.",
+ "pattern": "^X-.*$"
+ },
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "id": "ComposeDictionaries",
+ "type": "object",
+ "additionalProperties": {
+ "type": "boolean"
+ },
+ "description": "A <em>dictionary object</em> with entries for all installed dictionaries, having a language identifier as <em>key</em> (for example <value>en-US</value>) and a boolean expression as <em>value</em>, indicating whether that dictionary is enabled for spellchecking or not."
+ }
+ ],
+ "events": [
+ {
+ "name": "onBeforeSend",
+ "type": "function",
+ "description": "Fired when a message is about to be sent from the compose window. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "details",
+ "$ref": "ComposeDetails",
+ "description": "The current state of the compose window. This is functionally the same as calling the :ref:`compose.getComposeDetails` function."
+ }
+ ],
+ "returns": {
+ "type": "object",
+ "properties": {
+ "cancel": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Cancels the send."
+ },
+ "details": {
+ "$ref": "ComposeDetails",
+ "optional": true,
+ "description": "Updates the compose window. This is functionally the same as calling the :ref:`compose.setComposeDetails` function."
+ }
+ }
+ }
+ },
+ {
+ "name": "onAfterSend",
+ "type": "function",
+ "description": "Fired when sending a message succeeded or failed.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "sendInfo",
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "description": "The used send mode.",
+ "enum": ["sendNow", "sendLater"]
+ },
+ "error": {
+ "type": "string",
+ "description": "An error description, if sending the message failed.",
+ "optional": true
+ },
+ "headerMessageId": {
+ "type": "string",
+ "description": "The header messageId of the outgoing message. Only included for actually sent messages.",
+ "optional": true
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ },
+ "description": "Copies of the sent message. The number of created copies depends on the applied file carbon copy configuration (fcc)."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onAfterSave",
+ "type": "function",
+ "description": "Fired when saving a message as draft or template succeeded or failed.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "saveInfo",
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "description": "The used save mode.",
+ "enum": ["draft", "template"]
+ },
+ "error": {
+ "type": "string",
+ "description": "An error description, if saving the message failed.",
+ "optional": true
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ },
+ "description": "The saved message(s). The number of saved messages depends on the applied file carbon copy configuration (fcc)."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onAttachmentAdded",
+ "type": "function",
+ "description": "Fired when an attachment is added to a message being composed.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "attachment",
+ "$ref": "ComposeAttachment"
+ }
+ ]
+ },
+ {
+ "name": "onAttachmentRemoved",
+ "type": "function",
+ "description": "Fired when an attachment is removed from a message being composed.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "attachmentId",
+ "type": "integer"
+ }
+ ]
+ },
+ {
+ "name": "onIdentityChanged",
+ "type": "function",
+ "description": "Fired when the user changes the identity that will be used to send a message being composed.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "identityId",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "onComposeStateChanged",
+ "type": "function",
+ "description": "Fired when the state of the message composer changed.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "state",
+ "$ref": "ComposeState"
+ }
+ ]
+ },
+ {
+ "name": "onActiveDictionariesChanged",
+ "type": "function",
+ "description": "Fired when one or more dictionaries have been activated or deactivated.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "dictionaries",
+ "$ref": "ComposeDictionaries"
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "beginNew",
+ "type": "function",
+ "description": "Open a new message compose window.\n\n**Note:** The compose format can be set by ``details.isPlainText`` or by specifying only one of ``details.body`` or ``details.plainTextBody``. Otherwise the default compose format of the selected identity is used.\n\n**Note:** Specifying ``details.body`` and ``details.plainTextBody`` without also specifying ``details.isPlainText`` threw an exception in Thunderbird up to version 97. Since Thunderbird 98, this combination creates a compose window with the compose format of the selected identity, using the matching ``details.body`` or ``details.plainTextBody`` value.\n\n**Note:** If no identity is specified, this function is using the default identity and not the identity of the referenced message.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "description": "If specified, the message or template to edit as a new message.",
+ "type": "integer",
+ "optional": true,
+ "minimum": 1
+ },
+ {
+ "name": "details",
+ "$ref": "ComposeDetails",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "$ref": "tabs.Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "beginReply",
+ "type": "function",
+ "description": "Open a new message compose window replying to a given message.\n\n**Note:** The compose format can be set by ``details.isPlainText`` or by specifying only one of ``details.body`` or ``details.plainTextBody``. Otherwise the default compose format of the selected identity is used.\n\n**Note:** Specifying ``details.body`` and ``details.plainTextBody`` without also specifying ``details.isPlainText`` threw an exception in Thunderbird up to version 97. Since Thunderbird 98, this combination creates a compose window with the compose format of the selected identity, using the matching ``details.body`` or ``details.plainTextBody`` value.\n\n**Note:** If no identity is specified, this function is using the default identity and not the identity of the referenced message.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "description": "The message to reply to, as retrieved using other APIs.",
+ "type": "integer",
+ "minimum": 1
+ },
+ {
+ "name": "replyType",
+ "type": "string",
+ "enum": ["replyToSender", "replyToList", "replyToAll"],
+ "optional": true
+ },
+ {
+ "name": "details",
+ "$ref": "ComposeDetails",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "$ref": "tabs.Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "beginForward",
+ "type": "function",
+ "description": "Open a new message compose window forwarding a given message.\n\n**Note:** The compose format can be set by ``details.isPlainText`` or by specifying only one of ``details.body`` or ``details.plainTextBody``. Otherwise the default compose format of the selected identity is used.\n\n**Note:** Specifying ``details.body`` and ``details.plainTextBody`` without also specifying ``details.isPlainText`` threw an exception in Thunderbird up to version 97. Since Thunderbird 98, this combination creates a compose window with the compose format of the selected identity, using the matching ``details.body`` or ``details.plainTextBody`` value.\n\n**Note:** If no identity is specified, this function is using the default identity and not the identity of the referenced message.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "description": "The message to forward, as retrieved using other APIs.",
+ "type": "integer",
+ "minimum": 1
+ },
+ {
+ "name": "forwardType",
+ "type": "string",
+ "enum": ["forwardInline", "forwardAsAttachment"],
+ "optional": true
+ },
+ {
+ "name": "details",
+ "$ref": "ComposeDetails",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "$ref": "tabs.Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getComposeDetails",
+ "type": "function",
+ "async": "callback",
+ "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.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ComposeDetails"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setComposeDetails",
+ "type": "function",
+ "async": true,
+ "description": "Updates the compose window. The properties of the given :ref:`compose.ComposeDetails` object will be used to overwrite the current values of the specified compose window, so only properties that are to be changed should be included.\n\nWhen updating any of the array properties (``customHeaders`` and most address fields), make sure to first get the current values to not accidentally remove all existing entries when setting the new value.\n\n**Note:** The compose format of an existing compose window cannot be changed. Since Thunderbird 98, setting conflicting values for ``details.body``, ``details.plainTextBody`` or ``details.isPlaintext`` no longer throws an exception, instead the compose window chooses the matching ``details.body`` or ``details.plainTextBody`` value and ignores the other.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "name": "details",
+ "$ref": "ComposeDetails"
+ }
+ ]
+ },
+ {
+ "name": "getActiveDictionaries",
+ "type": "function",
+ "async": "callback",
+ "description": "Returns a :ref:`compose.ComposeDictionaries` object, listing all installed dictionaries, including the information whether they are currently enabled or not.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ComposeDictionaries"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setActiveDictionaries",
+ "type": "function",
+ "async": true,
+ "description": "Updates the active dictionaries. Throws if the ``activeDictionaries`` array contains unknown or invalid language identifiers.",
+ "permissions": ["compose"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "name": "activeDictionaries"
+ }
+ ]
+ },
+ {
+ "name": "listAttachments",
+ "type": "function",
+ "description": "Lists all of the attachments of the message being composed in the specified tab.",
+ "permissions": ["compose"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "ComposeAttachment"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getAttachmentFile",
+ "type": "function",
+ "description": "Gets the content of a :ref:`compose.ComposeAttachment` as a |File| object.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "integer",
+ "description": "The unique identifier for the attachment."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "addAttachment",
+ "type": "function",
+ "description": "Adds an attachment to the message being composed in the specified tab.",
+ "permissions": ["compose"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "name": "attachment",
+ "choices": [
+ {
+ "$ref": "FileAttachment"
+ },
+ {
+ "$ref": "ComposeAttachment"
+ }
+ ]
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ComposeAttachment"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "updateAttachment",
+ "type": "function",
+ "description": "Updates the name and/or the content of an attachment in the message being composed in the specified tab. If the specified attachment is a cloud file attachment and the associated provider failed to update the attachment, the function will throw an <em>ExtensionError</em>.",
+ "permissions": ["compose"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "name": "attachmentId",
+ "type": "integer"
+ },
+ {
+ "name": "attachment",
+ "$ref": "FileAttachment"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ComposeAttachment"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "removeAttachment",
+ "type": "function",
+ "description": "Removes an attachment from the message being composed in the specified tab.",
+ "permissions": ["compose"],
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "name": "attachmentId",
+ "type": "integer"
+ }
+ ]
+ },
+ {
+ "name": "sendMessage",
+ "permissions": ["compose.send"],
+ "type": "function",
+ "description": "Sends the message currently being composed. If the send mode is not specified or set to <value>default</value>, the message will be send directly if the user is online and placed in the users outbox otherwise. The returned Promise fulfills once the message has been successfully sent or placed in the user's outbox. Throws when the send process has been aborted by the user, by an :ref:`compose.onBeforeSend` event or if there has been an error while sending the message to the outgoing mail server.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "name": "options",
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["default", "sendNow", "sendLater"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "description": "The used send mode.",
+ "enum": ["sendNow", "sendLater"]
+ },
+ "headerMessageId": {
+ "type": "string",
+ "description": "The header messageId of the outgoing message. Only included for actually sent messages.",
+ "optional": true
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ },
+ "description": "Copies of the sent message. The number of created copies depends on the applied file carbon copy configuration (fcc)."
+ }
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "saveMessage",
+ "permissions": ["compose.save"],
+ "type": "function",
+ "description": "Saves the message currently being composed as a draft or as a template. If the save mode is not specified, the message will be saved as a draft. The returned Promise fulfills once the message has been successfully saved.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "name": "options",
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["draft", "template"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "description": "The used save mode.",
+ "enum": ["draft", "template"]
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ },
+ "description": "The saved message(s). The number of saved messages depends on the applied file carbon copy configuration (fcc)."
+ }
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getComposeState",
+ "type": "function",
+ "description": "Returns information about the current state of the message composer.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "return",
+ "$ref": "ComposeState"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/composeAction.json b/comm/mail/components/extensions/schemas/composeAction.json
new file mode 100644
index 0000000000..4220d8d6f1
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/composeAction.json
@@ -0,0 +1,722 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "compose_action": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "default_label": {
+ "type": "string",
+ "description": "The label of the composeAction button, defaults to its title. Can be set to an empty string to not display any label. If the containing toolbar is configured to display text only, the title will be used as fallback.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_title": {
+ "type": "string",
+ "description": "The title of the composeAction button. This shows up in the tooltip and the label. Defaults to the add-on name.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_icon": {
+ "$ref": "IconPath",
+ "description": "The paths to one or more icons for the composeAction button.",
+ "optional": true
+ },
+ "theme_icons": {
+ "type": "array",
+ "optional": true,
+ "minItems": 1,
+ "items": {
+ "$ref": "ThemeIcons"
+ },
+ "description": "Specifies dark and light icons to be used with themes. The ``light`` icon is used on dark backgrounds and vice versa. **Note:** The default theme uses the ``default_icon`` for light backgrounds (if specified)."
+ },
+ "default_popup": {
+ "type": "string",
+ "format": "relativeUrl",
+ "optional": true,
+ "description": "The html document to be opened as a popup when the user clicks on the composeAction button. Ignored for action buttons with type <value>menu</value>.",
+ "preprocess": "localize"
+ },
+ "browser_style": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Enable browser styles. See the `MDN documentation on browser styles <|link-mdn-browser-styles|>`__ for more information.",
+ "default": false
+ },
+ "default_area": {
+ "description": "Defines the location the composeAction button will appear. The default location is <value>maintoolbar</value>.",
+ "type": "string",
+ "enum": ["maintoolbar", "formattoolbar"],
+ "optional": true
+ },
+ "type": {
+ "description": "Specifies the type of the button. Default type is <code>button</code>.",
+ "type": "string",
+ "enum": ["button", "menu"],
+ "optional": true,
+ "default": "button"
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "composeAction",
+ "description": "Use a composeAction to put a button in the message composition toolbars. In addition to its icon, a composeAction button can also have a tooltip, a badge, and a popup.",
+ "permissions": ["manifest:compose_action"],
+ "types": [
+ {
+ "id": "ColorArray",
+ "description": "An array of four integers in the range [0,255] that make up the RGBA color. For example, opaque red is <value>[255, 0, 0, 255]</value>.",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ "minItems": 4,
+ "maxItems": 4
+ },
+ {
+ "id": "ImageDataType",
+ "type": "object",
+ "isInstanceOf": "ImageData",
+ "additionalProperties": {
+ "type": "any"
+ },
+ "postprocess": "convertImageDataToURL",
+ "description": "Pixel data for an image. Must be an |ImageData| object (for example, from a |Canvas| element)."
+ },
+ {
+ "id": "ImageDataDictionary",
+ "type": "object",
+ "description": "A <em>dictionary object</em> to specify multiple |ImageData| objects in different sizes, so the icon does not have to be scaled for a device with a different pixel density. Each entry is a <em>name-value</em> pair with <em>value</em> being an |ImageData| object, and <em>name</em> its size. Example: <literalinclude>includes/ImageDataDictionary.json<lang>JavaScript</lang></literalinclude>See the `MDN documentation about choosing icon sizes <|link-mdn-icon-size|>`__ for more information on this.",
+ "patternProperties": {
+ "^[1-9]\\d*$": {
+ "$ref": "ImageDataType"
+ }
+ }
+ },
+ {
+ "id": "OnClickData",
+ "type": "object",
+ "description": "Information sent when a composeAction button is clicked.",
+ "properties": {
+ "modifiers": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"]
+ },
+ "description": "An array of keyboard modifiers that were held while the menu item was clicked."
+ },
+ "button": {
+ "type": "integer",
+ "optional": true,
+ "description": "An integer value of button by which menu item was clicked."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "setTitle",
+ "type": "function",
+ "description": "Sets the title of the composeAction button. Is used as tooltip and as the label.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "title": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the composeAction button should display as its label and when moused over. Cleared by setting it to <value>null</value> or an empty string (title defined the manifest will be used)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the title only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getTitle",
+ "type": "function",
+ "description": "Gets the title of the composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the title should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setLabel",
+ "type": "function",
+ "description": "Sets the label of the composeAction button. Can be used to set different values for the tooltip (defined by the title) and the label. Additionally, the label can be set to an empty string, not showing any label at all.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "label": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the composeAction button should use as its label, overriding the defined title. Can be set to an empty string to not display any label at all. If the containing toolbar is configured to display text only, its title will be used. Cleared by setting it to <value>null</value>."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the label only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getLabel",
+ "type": "function",
+ "description": "Gets the label of the composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the label should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setIcon",
+ "type": "function",
+ "description": "Sets the icon for the composeAction button. Either the ``path`` or the ``imageData`` property must be specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "imageData": {
+ "choices": [
+ {
+ "$ref": "ImageDataType"
+ },
+ {
+ "$ref": "ImageDataDictionary"
+ }
+ ],
+ "optional": true,
+ "description": "The image data for one or more icons for the composeAction button."
+ },
+ "path": {
+ "$ref": "manifest.IconPath",
+ "optional": true,
+ "description": "The paths to one or more icons for the composeAction button."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the icon only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "setPopup",
+ "type": "function",
+ "description": "Sets the html document to be opened as a popup when the user clicks on the composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "popup": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The html file to show in a popup. Can be set to an empty string to not open a popup. Cleared by setting it to <value>null</value> (action will use the popup value defined in the manifest)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the popup only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getPopup",
+ "type": "function",
+ "description": "Gets the html document set as the popup for this composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the popup document should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeText",
+ "type": "function",
+ "description": "Sets the badge text for the composeAction button. The badge is displayed on top of the icon.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "text": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Any number of characters can be passed, but only about four can fit in the space. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the badge text only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeText",
+ "type": "function",
+ "description": "Gets the badge text of the composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge text should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeBackgroundColor",
+ "type": "function",
+ "description": "Sets the background color for the badge.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "color": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "ColorArray"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The color to use as background in the badge. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the background color for the badge only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeBackgroundColor",
+ "type": "function",
+ "description": "Gets the badge background color of the composeAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge background color should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "$ref": "ColorArray"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "enable",
+ "type": "function",
+ "description": "Enables the composeAction button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well. By default, a composeAction button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the composeAction button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "disable",
+ "type": "function",
+ "description": "Disables the composeAction button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the composeAction button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "isEnabled",
+ "type": "function",
+ "description": "Checks whether the composeAction button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the state should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "openPopup",
+ "type": "function",
+ "description": "Opens the action's popup window in the specified window. Defaults to the current window. Returns false if the popup could not be opened because the action has no popup, is of type <value>menu</value>, is disabled or has been removed from the toolbar.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "options",
+ "optional": true,
+ "type": "object",
+ "description": "An object with information about the popup to open.",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "Defaults to the current window."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onClicked",
+ "type": "function",
+ "description": "Fired when a composeAction button is clicked. This event will not fire if the composeAction has a popup. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "info",
+ "$ref": "OnClickData",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/extensionScripts.json b/comm/mail/components/extensions/schemas/extensionScripts.json
new file mode 100644
index 0000000000..67a734b7fb
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/extensionScripts.json
@@ -0,0 +1,133 @@
+// 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/.
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["messagesModify", "sensitiveDataUpload"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "composeScripts",
+ "permissions": ["compose"],
+ "types": [
+ {
+ "id": "RegisteredComposeScriptOptions",
+ "type": "object",
+ "description": "Details of a compose script registered programmatically",
+ "properties": {
+ "css": {
+ "type": "array",
+ "optional": true,
+ "description": "The list of CSS files to inject",
+ "items": {
+ "$ref": "extensionTypes.ExtensionFileOrCode"
+ }
+ },
+ "js": {
+ "type": "array",
+ "optional": true,
+ "description": "The list of JavaScript files to inject",
+ "items": {
+ "$ref": "extensionTypes.ExtensionFileOrCode"
+ }
+ }
+ }
+ },
+ {
+ "id": "RegisteredComposeScript",
+ "type": "object",
+ "description": "An object that represents a compose script registered programmatically",
+ "functions": [
+ {
+ "name": "unregister",
+ "type": "function",
+ "description": "Unregister a compose script registered programmatically",
+ "async": true,
+ "parameters": []
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "register",
+ "type": "function",
+ "description": "Register a compose script programmatically",
+ "async": true,
+ "parameters": [
+ {
+ "name": "composeScriptOptions",
+ "$ref": "RegisteredComposeScriptOptions"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "messageDisplayScripts",
+ "permissions": ["messagesModify"],
+ "types": [
+ {
+ "id": "RegisteredMessageDisplayScriptOptions",
+ "type": "object",
+ "description": "Details of a message display script registered programmatically",
+ "properties": {
+ "css": {
+ "type": "array",
+ "optional": true,
+ "description": "The list of CSS files to inject",
+ "items": {
+ "$ref": "extensionTypes.ExtensionFileOrCode"
+ }
+ },
+ "js": {
+ "type": "array",
+ "optional": true,
+ "description": "The list of JavaScript files to inject",
+ "items": {
+ "$ref": "extensionTypes.ExtensionFileOrCode"
+ }
+ }
+ }
+ },
+ {
+ "id": "RegisteredMessageDisplayScript",
+ "type": "object",
+ "description": "An object that represents a message display script registered programmatically",
+ "functions": [
+ {
+ "name": "unregister",
+ "type": "function",
+ "description": "Unregister a message display script registered programmatically",
+ "async": true,
+ "parameters": []
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "register",
+ "type": "function",
+ "description": "Register a message display script programmatically",
+ "async": true,
+ "parameters": [
+ {
+ "name": "messageDisplayScriptOptions",
+ "$ref": "RegisteredMessageDisplayScriptOptions"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/folders.json b/comm/mail/components/extensions/schemas/folders.json
new file mode 100644
index 0000000000..307cbcf789
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/folders.json
@@ -0,0 +1,408 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["accountsFolders"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "folders",
+ "permissions": ["accountsRead"],
+ "types": [
+ {
+ "id": "MailFolder",
+ "type": "object",
+ "description": "An object describing a mail folder, as returned for example by the :ref:`folders.getParentFolders` or :ref:`folders.getSubFolders` methods, or part of a :ref:`accounts.MailAccount` object, which is returned for example by the :ref:`accounts.list` and :ref:`accounts.get` methods. The ``subFolders`` property is only included if requested.",
+ "properties": {
+ "accountId": {
+ "type": "string",
+ "description": "The account this folder belongs to."
+ },
+ "name": {
+ "type": "string",
+ "optional": true,
+ "description": "The human-friendly name of this folder."
+ },
+ "path": {
+ "type": "string",
+ "description": "Path to this folder in the account. Although paths look predictable, never guess a folder's path, as there are a number of reasons why it may not be what you think it is. Use :ref:`folders.getParentFolders` or :ref:`folders.getSubFolders` to obtain hierarchy information."
+ },
+ "subFolders": {
+ "type": "array",
+ "description": "Subfolders are only included if requested. They will be returned in the same order as used in Thunderbird's folder pane.",
+ "items": {
+ "$ref": "MailFolder"
+ },
+ "optional": true
+ },
+ "type": {
+ "type": "string",
+ "optional": true,
+ "description": "The type of folder, for several common types.",
+ "enum": [
+ "inbox",
+ "drafts",
+ "sent",
+ "trash",
+ "templates",
+ "archives",
+ "junk",
+ "outbox"
+ ]
+ }
+ }
+ },
+ {
+ "id": "MailFolderInfo",
+ "type": "object",
+ "description": "An object containing additional information about a mail folder.",
+ "properties": {
+ "favorite": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether this folder is a favorite folder."
+ },
+ "totalMessageCount": {
+ "type": "integer",
+ "optional": true,
+ "description": "Number of messages in this folder."
+ },
+ "unreadMessageCount": {
+ "type": "integer",
+ "optional": true,
+ "description": "Number of unread messages in this folder."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "create",
+ "type": "function",
+ "permissions": ["accountsFolders"],
+ "description": "Creates a new subfolder in the specified folder or at the root of the specified account.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "parent",
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "$ref": "accounts.MailAccount"
+ }
+ ]
+ },
+ {
+ "name": "childName",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "rename",
+ "type": "function",
+ "permissions": ["accountsFolders"],
+ "description": "Renames a folder.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "newName",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "move",
+ "type": "function",
+ "permissions": ["accountsFolders"],
+ "description": "Moves the given ``sourceFolder`` into the given ``destination``. Throws if the destination already contains a folder with the name of the source folder.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "sourceFolder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "destination",
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "$ref": "accounts.MailAccount"
+ }
+ ]
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "copy",
+ "type": "function",
+ "permissions": ["accountsFolders"],
+ "description": "Copies the given ``sourceFolder`` into the given ``destination``. Throws if the destination already contains a folder with the name of the source folder.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "sourceFolder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "destination",
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "$ref": "accounts.MailAccount"
+ }
+ ]
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "delete",
+ "permissions": ["accountsFolders", "messagesDelete"],
+ "type": "function",
+ "description": "Deletes a folder.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "getFolderInfo",
+ "type": "function",
+ "description": "Get additional information about a mail folder.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "folders.MailFolderInfo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getParentFolders",
+ "type": "function",
+ "description": "Get all parent folders as a flat ordered array. The first array entry is the direct parent.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "includeSubFolders",
+ "description": "Specifies whether the returned :ref:`folders.MailFolder` object for each parent folder should include its nested subfolders . Defaults to <value>false</value>.",
+ "optional": true,
+ "default": false,
+ "type": "boolean"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "folders.MailFolder"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getSubFolders",
+ "type": "function",
+ "description": "Get the subfolders of the specified folder or account.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "folderOrAccount",
+ "choices": [
+ {
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "$ref": "accounts.MailAccount"
+ }
+ ]
+ },
+ {
+ "name": "includeSubFolders",
+ "description": "Specifies whether the returned :ref:`folders.MailFolder` object for each direct subfolder should also include all its nested subfolders . Defaults to <value>true</value>.",
+ "optional": true,
+ "default": true,
+ "type": "boolean"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "folders.MailFolder"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a folder has been created.",
+ "parameters": [
+ {
+ "name": "createdFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onRenamed",
+ "type": "function",
+ "description": "Fired when a folder has been renamed.",
+ "parameters": [
+ {
+ "name": "originalFolder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "renamedFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onMoved",
+ "type": "function",
+ "description": "Fired when a folder has been moved.",
+ "parameters": [
+ {
+ "name": "originalFolder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "movedFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onCopied",
+ "type": "function",
+ "description": "Fired when a folder has been copied.",
+ "parameters": [
+ {
+ "name": "originalFolder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "copiedFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when a folder has been deleted.",
+ "parameters": [
+ {
+ "name": "deletedFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onFolderInfoChanged",
+ "type": "function",
+ "description": "Fired when certain information of a folder have changed. Bursts of message count changes are collapsed to a single event.",
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "folderInfo",
+ "$ref": "folders.MailFolderInfo"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/identities.json b/comm/mail/components/extensions/schemas/identities.json
new file mode 100644
index 0000000000..f22068abd8
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/identities.json
@@ -0,0 +1,277 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["accountsIdentities"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "identities",
+ "permissions": ["accountsRead"],
+ "types": [
+ {
+ "id": "MailIdentity",
+ "type": "object",
+ "properties": {
+ "accountId": {
+ "type": "string",
+ "optional": true,
+ "description": "The id of the :ref:`accounts.MailAccount` this identity belongs to. The ``accountId`` property is read-only."
+ },
+ "composeHtml": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If the identity uses HTML as the default compose format."
+ },
+ "email": {
+ "type": "string",
+ "optional": true,
+ "description": "The user's email address as used when messages are sent from this identity."
+ },
+ "id": {
+ "type": "string",
+ "optional": true,
+ "description": "A unique identifier for this identity. The ``id`` property is read-only."
+ },
+ "label": {
+ "type": "string",
+ "optional": true,
+ "description": "A user-defined label for this identity."
+ },
+ "name": {
+ "type": "string",
+ "optional": true,
+ "description": "The user's name as used when messages are sent from this identity."
+ },
+ "replyTo": {
+ "type": "string",
+ "optional": true,
+ "description": "The reply-to email address associated with this identity."
+ },
+ "organization": {
+ "type": "string",
+ "optional": true,
+ "description": "The organization associated with this identity."
+ },
+ "signature": {
+ "type": "string",
+ "optional": true,
+ "description": "The signature of the identity."
+ },
+ "signatureIsPlainText": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If the signature should be interpreted as plain text or as HTML."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "list",
+ "type": "function",
+ "description": "Returns the identities of the specified account, or all identities if no account is specified. Do not expect the returned identities to be in any specific order. Use :ref:`identities.getDefault` to get the default identity of an account.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "identities.MailIdentity"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Returns details of the requested identity, or <value>null</value> if it doesn't exist.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "identities.MailIdentity",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "create",
+ "permissions": ["accountsIdentities"],
+ "type": "function",
+ "description": "Create a new identity in the specified account.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "name": "details",
+ "$ref": "identities.MailIdentity"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "identities.MailIdentity"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "delete",
+ "permissions": ["accountsIdentities"],
+ "type": "function",
+ "description": "Attempts to delete the requested identity. Default identities cannot be deleted.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "permissions": ["accountsIdentities"],
+ "type": "function",
+ "description": "Updates the details of an identity.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ },
+ {
+ "name": "details",
+ "$ref": "identities.MailIdentity"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "identities.MailIdentity"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getDefault",
+ "type": "function",
+ "description": "Returns the default identity for the requested account, or <value>null</value> if it is not defined.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "identities.MailIdentity"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setDefault",
+ "type": "function",
+ "description": "Sets the default identity for the requested account.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "accountId",
+ "type": "string"
+ },
+ {
+ "name": "identityId",
+ "type": "string"
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a new identity has been created and added to an account. The event also fires for default identities that are created when a new account is added.",
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ },
+ {
+ "name": "identity",
+ "$ref": "MailIdentity"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when an identity has been removed from an account.",
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when one or more properties of an identity have been modified. The returned :ref:`identities.MailIdentity` includes only the changed values.",
+ "parameters": [
+ {
+ "name": "identityId",
+ "type": "string"
+ },
+ {
+ "name": "changedValues",
+ "$ref": "MailIdentity"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/mailTabs.json b/comm/mail/components/extensions/schemas/mailTabs.json
new file mode 100644
index 0000000000..6346d614be
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/mailTabs.json
@@ -0,0 +1,428 @@
+[
+ {
+ "namespace": "mailTabs",
+ "types": [
+ {
+ "id": "MailTab",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "windowId": {
+ "type": "integer"
+ },
+ "active": {
+ "type": "boolean"
+ },
+ "sortType": {
+ "type": "string",
+ "description": "**Note:** ``sortType`` and ``sortOrder`` depend on each other, so both should be present, or neither.",
+ "optional": true,
+ "enum": [
+ "none",
+ "date",
+ "subject",
+ "author",
+ "id",
+ "thread",
+ "priority",
+ "status",
+ "size",
+ "flagged",
+ "unread",
+ "recipient",
+ "location",
+ "tags",
+ "junkStatus",
+ "attachments",
+ "account",
+ "custom",
+ "received",
+ "correspondent"
+ ]
+ },
+ "sortOrder": {
+ "type": "string",
+ "description": "**Note:** ``sortType`` and ``sortOrder`` depend on each other, so both should be present, or neither.",
+ "optional": true,
+ "enum": ["none", "ascending", "descending"]
+ },
+ "viewType": {
+ "type": "string",
+ "optional": true,
+ "enum": ["ungrouped", "groupedByThread", "groupedBySortType"]
+ },
+ "layout": {
+ "type": "string",
+ "enum": ["standard", "wide", "vertical"]
+ },
+ "folderPaneVisible": {
+ "type": "boolean",
+ "optional": true
+ },
+ "messagePaneVisible": {
+ "type": "boolean",
+ "optional": true
+ },
+ "displayedFolder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "The <permission>accountsRead</permission> permission is required for this property to be included."
+ }
+ }
+ },
+ {
+ "id": "QuickFilterTextDetail",
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string",
+ "description": "String to match against the ``recipients``, ``author``, ``subject``, or ``body``."
+ },
+ "recipients": {
+ "type": "boolean",
+ "description": "Shows messages where ``text`` matches the recipients.",
+ "optional": true
+ },
+ "author": {
+ "type": "boolean",
+ "description": "Shows messages where ``text`` matches the author.",
+ "optional": true
+ },
+ "subject": {
+ "type": "boolean",
+ "description": "Shows messages where ``text`` matches the subject.",
+ "optional": true
+ },
+ "body": {
+ "type": "boolean",
+ "description": "Shows messages where ``text`` matches the message body.",
+ "optional": true
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "query",
+ "type": "function",
+ "description": "Gets all mail tabs that have the specified properties, or all mail tabs if no properties are specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "queryInfo",
+ "optional": true,
+ "default": {},
+ "properties": {
+ "active": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are active in their windows."
+ },
+ "currentWindow": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are in the current window."
+ },
+ "lastFocusedWindow": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are in the last focused window."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "description": "The ID of the parent window, or :ref:`windows.WINDOW_ID_CURRENT` for the current window."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "MailTab"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Get the properties of a mail tab.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "ID of the requested mail tab. Throws if the requested tab is not a mail tab."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MailTab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getCurrent",
+ "type": "function",
+ "description": "Get the properties of the active mail tab, if the active tab is a mail tab. Returns undefined otherwise.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MailTab",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Modifies the properties of a mail tab. Properties that are not specified in ``updateProperties`` are not modified.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "Defaults to the active tab of the current window.",
+ "optional": true,
+ "minimum": 1
+ },
+ {
+ "name": "updateProperties",
+ "type": "object",
+ "properties": {
+ "displayedFolder": {
+ "$ref": "folders.MailFolder",
+ "description": "Sets the folder displayed in the tab. The extension must have the <permission>accountsRead</permission> permission to do this. The previous message selection in the given folder will be restored.",
+ "optional": true
+ },
+ "sortType": {
+ "type": "string",
+ "description": "Sorts the list of messages. ``sortOrder`` must also be given.",
+ "optional": true,
+ "enum": [
+ "none",
+ "date",
+ "subject",
+ "author",
+ "id",
+ "thread",
+ "priority",
+ "status",
+ "size",
+ "flagged",
+ "unread",
+ "recipient",
+ "location",
+ "tags",
+ "junkStatus",
+ "attachments",
+ "account",
+ "custom",
+ "received",
+ "correspondent"
+ ]
+ },
+ "sortOrder": {
+ "type": "string",
+ "description": "Sorts the list of messages. ``sortType`` must also be given.",
+ "optional": true,
+ "enum": ["none", "ascending", "descending"]
+ },
+ "viewType": {
+ "type": "string",
+ "optional": true,
+ "enum": ["ungrouped", "groupedByThread", "groupedBySortType"]
+ },
+ "layout": {
+ "type": "string",
+ "description": "Sets the arrangement of the folder pane, message list pane, and message display pane. Note that setting this applies it to all mail tabs.",
+ "optional": true,
+ "enum": ["standard", "wide", "vertical"]
+ },
+ "folderPaneVisible": {
+ "type": "boolean",
+ "description": "Shows or hides the folder pane.",
+ "optional": true
+ },
+ "messagePaneVisible": {
+ "type": "boolean",
+ "description": "Shows or hides the message display pane.",
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "getSelectedMessages",
+ "type": "function",
+ "description": "Lists the selected messages in the current folder.",
+ "permissions": ["messagesRead"],
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "Defaults to the active tab of the current window.",
+ "optional": true,
+ "minimum": 1
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "messages.MessageList"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setSelectedMessages",
+ "type": "function",
+ "description": "Selects none, one or multiple messages.",
+ "permissions": ["messagesRead", "accountsRead"],
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "Defaults to the active tab of the current window.",
+ "optional": true,
+ "minimum": 1
+ },
+ {
+ "name": "messageIds",
+ "type": "array",
+ "description": "The IDs of the messages, which should be selected. The mailTab will switch to the folder of the selected messages. Throws if they belong to different folders. Array can be empty to deselect any currently selected message.",
+ "items": {
+ "type": "integer"
+ }
+ }
+ ]
+ },
+ {
+ "name": "setQuickFilter",
+ "type": "function",
+ "description": "Sets the Quick Filter user interface based on the options specified.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "Defaults to the active tab of the current window.",
+ "optional": true,
+ "minimum": 1
+ },
+ {
+ "name": "properties",
+ "type": "object",
+ "properties": {
+ "show": {
+ "type": "boolean",
+ "description": "Shows or hides the Quick Filter bar.",
+ "optional": true
+ },
+ "unread": {
+ "type": "boolean",
+ "description": "Shows only unread messages.",
+ "optional": true
+ },
+ "flagged": {
+ "type": "boolean",
+ "description": "Shows only flagged messages.",
+ "optional": true
+ },
+ "contact": {
+ "type": "boolean",
+ "description": "Shows only messages from people in the address book.",
+ "optional": true
+ },
+ "tags": {
+ "optional": true,
+ "choices": [
+ {
+ "type": "boolean"
+ },
+ {
+ "$ref": "messages.TagsDetail"
+ }
+ ],
+ "description": "Shows only messages with tags on them."
+ },
+ "attachment": {
+ "type": "boolean",
+ "description": "Shows only messages with attachments.",
+ "optional": true
+ },
+ "text": {
+ "$ref": "QuickFilterTextDetail",
+ "description": "Shows only messages matching the supplied text.",
+ "optional": true
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onDisplayedFolderChanged",
+ "type": "function",
+ "description": "Fired when the displayed folder changes in any mail tab.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "displayedFolder",
+ "$ref": "folders.MailFolder"
+ }
+ ]
+ },
+ {
+ "name": "onSelectedMessagesChanged",
+ "type": "function",
+ "description": "Fired when the selected messages change in any mail tab.",
+ "permissions": ["messagesRead"],
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "selectedMessages",
+ "$ref": "messages.MessageList"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/menus.json b/comm/mail/components/extensions/schemas/menus.json
new file mode 100644
index 0000000000..34167a87d8
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/menus.json
@@ -0,0 +1,757 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "PermissionNoPrompt",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["menus"]
+ }
+ ]
+ },
+ {
+ "$extend": "OptionalPermissionNoPrompt",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["menus.overrideContext"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "menus",
+ "permissions": ["menus"],
+ "description": "The menus API allows to add items to Thunderbird's menus. You can choose what types of objects your context menu additions apply to, such as images, hyperlinks, and pages.",
+ "properties": {
+ "ACTION_MENU_TOP_LEVEL_LIMIT": {
+ "value": 6,
+ "description": "The maximum number of top level extension items that can be added to an extension action context menu. Any items beyond this limit will be ignored."
+ }
+ },
+ "types": [
+ {
+ "id": "ContextType",
+ "choices": [
+ {
+ "type": "string",
+ "enum": [
+ "all",
+ "all_message_attachments",
+ "audio",
+ "compose_action",
+ "compose_action_menu",
+ "compose_attachments",
+ "compose_body",
+ "editable",
+ "folder_pane",
+ "frame",
+ "image",
+ "link",
+ "message_attachments",
+ "message_display_action",
+ "message_display_action_menu",
+ "message_list",
+ "page",
+ "password",
+ "selection",
+ "tab",
+ "tools_menu",
+ "video"
+ ]
+ },
+ {
+ "type": "string",
+ "max_manifest_version": 2,
+ "enum": ["browser_action", "browser_action_menu"]
+ },
+ {
+ "type": "string",
+ "min_manifest_version": 3,
+ "enum": ["action", "action_menu"]
+ }
+ ],
+ "description": "The different contexts a menu can appear in. Specifying <value>all</value> is equivalent to the combination of all other contexts excluding <value>tab</value> and <value>tools_menu</value>. More information about each context can be found in the `Supported UI Elements <|link-ui-elements|>`__ article on developer.thunderbird.net."
+ },
+ {
+ "id": "ItemType",
+ "type": "string",
+ "enum": ["normal", "checkbox", "radio", "separator"],
+ "description": "The type of menu item."
+ },
+ {
+ "id": "OnShowData",
+ "type": "object",
+ "description": "Information sent when a context menu is being shown. Some properties are only included if the extension has host permission for the given context, for example :permission:`activeTab` for content tabs, :permission:`compose` for compose tabs and :permission:`messagesRead` for message display tabs.",
+ "properties": {
+ "menuIds": {
+ "description": "A list of IDs of the menu items that were shown.",
+ "type": "array",
+ "items": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ }
+ },
+ "contexts": {
+ "description": "A list of all contexts that apply to the menu.",
+ "type": "array",
+ "items": {
+ "$ref": "ContextType"
+ }
+ },
+ "editable": {
+ "type": "boolean",
+ "description": "A flag indicating whether the element is editable (text input, textarea, etc.)."
+ },
+ "mediaType": {
+ "type": "string",
+ "optional": true,
+ "description": "One of <value>image</value>, <value>video</value>, or <value>audio</value> if the context menu was activated on one of these types of elements."
+ },
+ "viewType": {
+ "$ref": "extension.ViewType",
+ "optional": true,
+ "description": "The type of view where the menu is shown. May be unset if the menu is not associated with a view."
+ },
+ "linkText": {
+ "type": "string",
+ "optional": true,
+ "description": "If the element is a link, the text of that link. **Note:** Host permission is required."
+ },
+ "linkUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "If the element is a link, the URL it points to. **Note:** Host permission is required."
+ },
+ "srcUrl": {
+ "type": "string",
+ "description": "Will be present for elements with a <em>src</em> URL. **Note:** Host permission is required.",
+ "optional": true
+ },
+ "pageUrl": {
+ "type": "string",
+ "description": "The URL of the page where the menu item was clicked. This property is not set if the click occurred in a context where there is no current page, such as in a launcher context menu. **Note:** Host permission is required.",
+ "optional": true
+ },
+ "frameUrl": {
+ "type": "string",
+ "description": "The URL of the frame of the element where the context menu was clicked, if it was in a frame. **Note:** Host permission is required.",
+ "optional": true
+ },
+ "selectionText": {
+ "type": "string",
+ "description": "The text for the context selection, if any. **Note:** Host permission is required.",
+ "optional": true
+ },
+ "targetElementId": {
+ "type": "integer",
+ "optional": true,
+ "description": "An identifier of the clicked content element, if any. Use :ref:`menus.getTargetElement` in the page to find the corresponding element."
+ },
+ "fieldId": {
+ "type": "string",
+ "optional": true,
+ "description": "An identifier of the clicked Thunderbird UI element, if any.",
+ "enum": [
+ "composeSubject",
+ "composeTo",
+ "composeCc",
+ "composeBcc",
+ "composeReplyTo",
+ "composeNewsgroupTo"
+ ]
+ },
+ "selectedMessages": {
+ "$ref": "messages.MessageList",
+ "optional": true,
+ "description": "The selected messages, if the context menu was opened in the message list. The <permission>messagesRead</permission> permission is required."
+ },
+ "displayedFolder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "The displayed folder, if the context menu was opened in the message list. The <permission>accountsRead</permission> permission is required."
+ },
+ "selectedFolder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "The selected folder, if the context menu was opened in the folder pane. The <permission>accountsRead</permission> permission is required."
+ },
+ "selectedAccount": {
+ "$ref": "accounts.MailAccount",
+ "optional": true,
+ "description": "The selected account, if the context menu was opened on an account entry in the folder pane. The <permission>accountsRead</permission> permission is required."
+ },
+ "attachments": {
+ "type": "array",
+ "optional": true,
+ "description": "The selected attachments. The <permission>compose</permission> permission is required to return attachments of a message being composed. The <permission>messagesRead</permission> permission is required to return attachments of displayed messages.",
+ "items": {
+ "choices": [
+ {
+ "$ref": "compose.ComposeAttachment"
+ },
+ {
+ "$ref": "messages.MessageAttachment"
+ }
+ ]
+ }
+ }
+ }
+ },
+ {
+ "id": "OnClickData",
+ "type": "object",
+ "description": "Information sent when a context menu item is clicked.",
+ "properties": {
+ "menuItemId": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "description": "The ID of the menu item that was clicked."
+ },
+ "parentMenuItemId": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "optional": true,
+ "description": "The parent ID, if any, for the item clicked."
+ },
+ "editable": {
+ "type": "boolean",
+ "description": "A flag indicating whether the element is editable (text input, textarea, etc.)."
+ },
+ "mediaType": {
+ "type": "string",
+ "optional": true,
+ "description": "One of <value>image</value>, <value>video</value>, or <value>audio</value> if the context menu was activated on one of these types of elements."
+ },
+ "viewType": {
+ "$ref": "extension.ViewType",
+ "optional": true,
+ "description": "The type of view where the menu is clicked. May be unset if the menu is not associated with a view."
+ },
+ "linkText": {
+ "type": "string",
+ "optional": true,
+ "description": "If the element is a link, the text of that link."
+ },
+ "linkUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "If the element is a link, the URL it points to."
+ },
+ "srcUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "Will be present for elements with a <em>src</em> URL."
+ },
+ "pageUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "The URL of the page where the menu item was clicked. This property is not set if the click occurred in a context where there is no current page, such as in a launcher context menu."
+ },
+ "frameId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "The id of the frame of the element where the context menu was clicked."
+ },
+ "frameUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "The URL of the frame of the element where the context menu was clicked, if it was in a frame."
+ },
+ "selectionText": {
+ "type": "string",
+ "optional": true,
+ "description": "The text for the context selection, if any."
+ },
+ "wasChecked": {
+ "type": "boolean",
+ "optional": true,
+ "description": "A flag indicating the state of a checkbox or radio item before it was clicked."
+ },
+ "checked": {
+ "type": "boolean",
+ "optional": true,
+ "description": "A flag indicating the state of a checkbox or radio item after it is clicked."
+ },
+ "modifiers": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"]
+ },
+ "description": "An array of keyboard modifiers that were held while the menu item was clicked."
+ },
+ "button": {
+ "type": "integer",
+ "optional": true,
+ "description": "An integer value of button by which menu item was clicked."
+ },
+ "targetElementId": {
+ "type": "integer",
+ "optional": true,
+ "description": "An identifier of the clicked content element, if any. Use :ref:`menus.getTargetElement` in the page to find the corresponding element."
+ },
+ "fieldId": {
+ "type": "string",
+ "optional": true,
+ "description": "An identifier of the clicked Thunderbird UI element, if any.",
+ "enum": [
+ "composeSubject",
+ "composeTo",
+ "composeCc",
+ "composeBcc",
+ "composeReplyTo",
+ "composeNewsgroupTo"
+ ]
+ },
+ "selectedMessages": {
+ "$ref": "messages.MessageList",
+ "optional": true,
+ "description": "The selected messages, if the context menu was opened in the message list. The <permission>messagesRead</permission> permission is required."
+ },
+ "displayedFolder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "The displayed folder, if the context menu was opened in the message list. The <permission>accountsRead</permission> permission is required."
+ },
+ "selectedFolder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "The selected folder, if the context menu was opened in the folder pane. The <permission>accountsRead</permission> permission is required."
+ },
+ "selectedAccount": {
+ "$ref": "accounts.MailAccount",
+ "optional": true,
+ "description": "The selected account, if the context menu was opened on an account entry in the folder pane. The <permission>accountsRead</permission> permission is required."
+ },
+ "attachments": {
+ "type": "array",
+ "optional": true,
+ "description": "The selected attachments. The <permission>compose</permission> permission is required to return attachments of a message being composed. The <permission>messagesRead</permission> permission is required to return attachments of displayed messages.",
+ "items": {
+ "choices": [
+ {
+ "$ref": "compose.ComposeAttachment"
+ },
+ {
+ "$ref": "messages.MessageAttachment"
+ }
+ ]
+ }
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "create",
+ "type": "function",
+ "description": "Creates a new context menu item. Note that if an error occurs during creation, you may not find out until the creation callback fires (the details will be in `runtime.lastError <|link-runtime-last-error|>`__).",
+ "returns": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "description": "The ID of the newly created item."
+ },
+ "parameters": [
+ {
+ "type": "object",
+ "name": "createProperties",
+ "properties": {
+ "type": {
+ "$ref": "ItemType",
+ "optional": true,
+ "description": "The type of menu item. Defaults to <value>normal</value> if not specified."
+ },
+ "id": {
+ "type": "string",
+ "optional": true,
+ "description": "The unique ID to assign to this item. Mandatory for event pages. Cannot be the same as another ID for this extension."
+ },
+ "icons": {
+ "$ref": "manifest.IconPath",
+ "optional": true,
+ "description": "Custom icons to display next to the menu item. Custom icons can only be set for items appearing in submenus."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "description": "The text to be displayed in the item; this is <em>required</em> unless ``type`` is <value>separator</value>. When the context is <value>selection</value>, you can use <value>%s</value> within the string to show the selected text. For example, if this parameter's value is <value>Translate '%s' to Latin</value> and the user selects the word <value>cool</value>, the context menu item for the selection is <value>Translate 'cool' to Latin</value>. To specify an access key for the new menu entry, include a <value>&</value> before the desired letter in the title. For example <value>&Help</value>."
+ },
+ "checked": {
+ "type": "boolean",
+ "optional": true,
+ "description": "The initial state of a checkbox or radio item: <value>true</value> for selected and <value>false</value> for unselected. Only one radio item can be selected at a time in a given group of radio items."
+ },
+ "contexts": {
+ "type": "array",
+ "items": {
+ "$ref": "ContextType"
+ },
+ "minItems": 1,
+ "optional": true,
+ "description": "List of contexts this menu item will appear in. Defaults to <value>['page']</value> if not specified."
+ },
+ "viewTypes": {
+ "type": "array",
+ "items": {
+ "$ref": "extension.ViewType"
+ },
+ "minItems": 1,
+ "optional": true,
+ "description": "List of view types where the menu item will be shown. Defaults to any view, including those without a viewType."
+ },
+ "visible": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the item is visible in the menu."
+ },
+ "onclick": {
+ "type": "function",
+ "optional": true,
+ "description": "A function that will be called back when the menu item is clicked. Event pages cannot use this.",
+ "parameters": [
+ {
+ "name": "info",
+ "$ref": "OnClickData",
+ "description": "Information about the item clicked and the context where the click happened."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The details of the tab where the click took place."
+ }
+ ]
+ },
+ "parentId": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "optional": true,
+ "description": "The ID of a parent menu item; this makes the item a child of a previously added item."
+ },
+ "documentUrlPatterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "optional": true,
+ "description": "Lets you restrict the item to apply only to documents whose URL matches one of the given patterns. (This applies to frames as well.) For details on the format of a pattern, see `Match Patterns <|link-match-patterns|>`__."
+ },
+ "targetUrlPatterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "optional": true,
+ "description": "Similar to documentUrlPatterns, but lets you filter based on the src attribute of img/audio/video tags and the href of anchor tags."
+ },
+ "enabled": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether this context menu item is enabled or disabled. Defaults to true."
+ },
+ "command": {
+ "optional": true,
+ "choices": [
+ {
+ "type": "string",
+ "max_manifest_version": 2,
+ "description": "Specifies a command to issue for the context click. Currently supports internal commands <value>_execute_browser_action</value>, <value>_execute_compose_action</value> and <value>_execute_message_display_action</value>."
+ },
+ {
+ "type": "string",
+ "min_manifest_version": 3,
+ "description": "Specifies a command to issue for the context click. Currently supports internal commands <value>_execute_action</value>, <value>_execute_compose_action</value> and <value>_execute_message_display_action</value>."
+ }
+ ]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "description": "Called when the item has been created in the browser. If there were any problems creating the item, details will be available in `runtime.lastError <|link-runtime-last-error|>`__.",
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Updates a previously created context menu item.",
+ "async": "callback",
+ "parameters": [
+ {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "name": "id",
+ "description": "The ID of the item to update."
+ },
+ {
+ "type": "object",
+ "name": "updateProperties",
+ "description": "The properties to update. Accepts the same values as the create function.",
+ "properties": {
+ "type": {
+ "$ref": "ItemType",
+ "optional": true
+ },
+ "icons": {
+ "$ref": "manifest.IconPath",
+ "optional": "omit-key-if-missing"
+ },
+ "title": {
+ "type": "string",
+ "optional": true
+ },
+ "checked": {
+ "type": "boolean",
+ "optional": true
+ },
+ "contexts": {
+ "type": "array",
+ "items": {
+ "$ref": "ContextType"
+ },
+ "minItems": 1,
+ "optional": true
+ },
+ "viewTypes": {
+ "type": "array",
+ "items": {
+ "$ref": "extension.ViewType"
+ },
+ "minItems": 1,
+ "optional": true
+ },
+ "visible": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the item is visible in the menu."
+ },
+ "onclick": {
+ "type": "function",
+ "optional": "omit-key-if-missing",
+ "parameters": [
+ {
+ "name": "info",
+ "$ref": "OnClickData"
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The details of the tab where the click took place. **Note:** this parameter only present for extensions."
+ }
+ ]
+ },
+ "parentId": {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "optional": true,
+ "description": "**Note:** You cannot change an item to be a child of one of its own descendants."
+ },
+ "documentUrlPatterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "targetUrlPatterns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "enabled": {
+ "type": "boolean",
+ "optional": true
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [],
+ "description": "Called when the context menu has been updated."
+ }
+ ]
+ },
+ {
+ "name": "remove",
+ "type": "function",
+ "description": "Removes a context menu item.",
+ "async": "callback",
+ "parameters": [
+ {
+ "choices": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "name": "menuItemId",
+ "description": "The ID of the context menu item to remove."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [],
+ "description": "Called when the context menu has been removed."
+ }
+ ]
+ },
+ {
+ "name": "removeAll",
+ "type": "function",
+ "description": "Removes all context menu items added by this extension.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [],
+ "description": "Called when removal is complete."
+ }
+ ]
+ },
+ {
+ "name": "overrideContext",
+ "permissions": ["menus.overrideContext"],
+ "type": "function",
+ "description": "Show the matching menu items from this extension instead of the default menu. This should be called during a `contextmenu <|link-contextmenu-event|>`__ event handler, and only applies to the menu that opens after this event.",
+ "parameters": [
+ {
+ "name": "contextOptions",
+ "type": "object",
+ "properties": {
+ "showDefaults": {
+ "type": "boolean",
+ "optional": true,
+ "default": false,
+ "description": "Whether to also include default menu items in the menu."
+ },
+ "context": {
+ "type": "string",
+ "enum": ["tab"],
+ "optional": true,
+ "description": "ContextType to override, to allow menu items from other extensions in the menu. Currently only <value>tab</value> is supported. ``contextOptions.showDefaults`` cannot be used with this option."
+ },
+ "tabId": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "Required when context is <value>tab</value>. Requires the <permission>tabs</permission> permission."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "refresh",
+ "type": "function",
+ "description": "Updates the extension items in the shown menu, including changes that have been made since the menu was shown. Has no effect if the menu is hidden. Rebuilding a shown menu is an expensive operation, only invoke this method when necessary.",
+ "async": true,
+ "parameters": []
+ }
+ ],
+ "events": [
+ {
+ "name": "onClicked",
+ "type": "function",
+ "description": "Fired when a context menu item is clicked. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "parameters": [
+ {
+ "name": "info",
+ "$ref": "OnClickData",
+ "description": "Information about the item clicked and the context where the click happened."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The details of the tab where the click took place. If the click did not take place in a tab, this parameter will be missing.",
+ "optional": true
+ }
+ ]
+ },
+ {
+ "name": "onShown",
+ "type": "function",
+ "description": "Fired when a menu is shown. The extension can add, modify or remove menu items and call :ref:`menus.refresh` to update the menu.",
+ "parameters": [
+ {
+ "name": "info",
+ "$ref": "OnShowData",
+ "description": "Information about the context of the menu action and the created menu items."
+ },
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "description": "The details of the tab where the menu was opened."
+ }
+ ]
+ },
+ {
+ "name": "onHidden",
+ "type": "function",
+ "description": "Fired when a menu is hidden. This event is only fired if onShown has fired before.",
+ "parameters": []
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/menus_child.json b/comm/mail/components/extensions/schemas/menus_child.json
new file mode 100644
index 0000000000..9bcbbcc7d3
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/menus_child.json
@@ -0,0 +1,31 @@
+[
+ {
+ "namespace": "menus",
+ "permissions": ["menus"],
+ "allowedContexts": ["content", "devtools"],
+ "description": "The part of the menus API that is available in all extension contexts, including content scripts.",
+ "functions": [
+ {
+ "name": "getTargetElement",
+ "type": "function",
+ "allowedContexts": ["content", "devtools"],
+ "description": "Retrieve the element that was associated with a recent `contextmenu <|link-contextmenu-event|>`__ event.",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "The identifier of the clicked element, available as ``info.targetElementId`` in the :ref:`menus.onShown` and :ref:`menus.onClicked` events.",
+ "name": "targetElementId"
+ }
+ ],
+ "returns": {
+ "type": "object",
+ "optional": true,
+ "isInstanceOf": "Element",
+ "additionalProperties": {
+ "type": "any"
+ }
+ }
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/messageDisplay.json b/comm/mail/components/extensions/schemas/messageDisplay.json
new file mode 100644
index 0000000000..f7e3d4ae6d
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/messageDisplay.json
@@ -0,0 +1,159 @@
+[
+ {
+ "namespace": "messageDisplay",
+ "permissions": ["messagesRead"],
+ "events": [
+ {
+ "name": "onMessageDisplayed",
+ "type": "function",
+ "description": "Fired when a message is displayed, whether in a 3-pane tab, a message tab, or a message window.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "message",
+ "$ref": "messages.MessageHeader"
+ }
+ ]
+ },
+ {
+ "name": "onMessagesDisplayed",
+ "type": "function",
+ "description": "Fired when either a single message is displayed or when multiple messages are displayed, whether in a 3-pane tab, a message tab, or a message window.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "messages",
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ }
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "getDisplayedMessage",
+ "type": "function",
+ "description": "Gets the currently displayed message in the specified tab (even if the tab itself is currently not visible). It returns <value>null</value> if no messages are displayed, or if multiple messages are displayed.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "minimum": 1
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "choices": [
+ {
+ "$ref": "messages.MessageHeader"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getDisplayedMessages",
+ "type": "function",
+ "description": "Gets an array of the currently displayed messages in the specified tab (even if the tab itself is currently not visible). The array is empty if no messages are displayed.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "minimum": 1
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "messages.MessageHeader"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "open",
+ "type": "function",
+ "description": "Opens a message in a new tab or in a new window.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "openProperties",
+ "type": "object",
+ "description": "Settings for opening the message. Exactly one of messageId, headerMessageId or file must be specified.",
+ "properties": {
+ "file": {
+ "type": "object",
+ "optional": true,
+ "isInstanceOf": "File",
+ "additionalProperties": true,
+ "description": "The DOM file object of a message to be opened."
+ },
+ "messageId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 1,
+ "description": "The id of a message to be opened. Will throw an <em>ExtensionError</em>, if the provided ``messageId`` is unknown or invalid."
+ },
+ "headerMessageId": {
+ "type": "string",
+ "optional": true,
+ "description": "The headerMessageId of a message to be opened. Will throw an <em>ExtensionError</em>, if the provided ``headerMessageId`` is unknown or invalid. Not supported for external messages."
+ },
+ "location": {
+ "type": "string",
+ "enum": ["tab", "window"],
+ "optional": true,
+ "description": "Where to open the message. If not specified, the users preference is honoured."
+ },
+ "active": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the new tab should become the active tab in the window. Only applicable to messages opened in tabs."
+ },
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "The id of the window, where the new tab should be created. Defaults to the current window. Only applicable to messages opened in tabs."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/messageDisplayAction.json b/comm/mail/components/extensions/schemas/messageDisplayAction.json
new file mode 100644
index 0000000000..9beda1c68e
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/messageDisplayAction.json
@@ -0,0 +1,721 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "message_display_action": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "default_label": {
+ "type": "string",
+ "description": "The label of the messageDisplayAction button, defaults to its title. Can be set to an empty string to not display any label. If the containing toolbar is configured to display text only, the title will be used as fallback.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_title": {
+ "type": "string",
+ "description": "The title of the messageDisplayAction button. This shows up in the tooltip and the label. Defaults to the add-on name.",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "default_icon": {
+ "$ref": "IconPath",
+ "description": "The paths to one or more icons for the messageDisplayAction button.",
+ "optional": true
+ },
+ "theme_icons": {
+ "type": "array",
+ "optional": true,
+ "minItems": 1,
+ "items": {
+ "$ref": "ThemeIcons"
+ },
+ "description": "Specifies dark and light icons to be used with themes. The ``light`` icon is used on dark backgrounds and vice versa. **Note:** The default theme uses the ``default_icon`` for light backgrounds (if specified)."
+ },
+ "default_popup": {
+ "type": "string",
+ "format": "relativeUrl",
+ "optional": true,
+ "description": "The html document to be opened as a popup when the user clicks on the messageDisplayAction button. Ignored for action buttons with type <value>menu</value>.",
+ "preprocess": "localize"
+ },
+ "browser_style": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Enable browser styles. See the `MDN documentation on browser styles <|link-mdn-browser-styles|>`__ for more information.",
+ "default": false
+ },
+ "default_area": {
+ "description": "Currently unused.",
+ "type": "string",
+ "optional": true
+ },
+ "type": {
+ "description": "Specifies the type of the button. Default type is <code>button</code>.",
+ "type": "string",
+ "enum": ["button", "menu"],
+ "optional": true,
+ "default": "button"
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "messageDisplayAction",
+ "description": "Use a messageDisplayAction to put a button in the message display toolbar. In addition to its icon, a messageDisplayAction button can also have a tooltip, a badge, and a popup.",
+ "permissions": ["manifest:message_display_action"],
+ "types": [
+ {
+ "id": "ColorArray",
+ "description": "An array of four integers in the range [0,255] that make up the RGBA color. For example, opaque red is <value>[255, 0, 0, 255]</value>.",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ "minItems": 4,
+ "maxItems": 4
+ },
+ {
+ "id": "ImageDataType",
+ "type": "object",
+ "isInstanceOf": "ImageData",
+ "additionalProperties": {
+ "type": "any"
+ },
+ "postprocess": "convertImageDataToURL",
+ "description": "Pixel data for an image. Must be an |ImageData| object (for example, from a |Canvas| element)."
+ },
+ {
+ "id": "ImageDataDictionary",
+ "type": "object",
+ "description": "A <em>dictionary object</em> to specify multiple |ImageData| objects in different sizes, so the icon does not have to be scaled for a device with a different pixel density. Each entry is a <em>name-value</em> pair with <em>value</em> being an |ImageData| object, and <em>name</em> its size. Example: <literalinclude>includes/ImageDataDictionary.json<lang>JavaScript</lang></literalinclude>See the `MDN documentation about choosing icon sizes <|link-mdn-icon-size|>`__ for more information on this.",
+ "patternProperties": {
+ "^[1-9]\\d*$": {
+ "$ref": "ImageDataType"
+ }
+ }
+ },
+ {
+ "id": "OnClickData",
+ "type": "object",
+ "description": "Information sent when a messageDisplayAction button is clicked.",
+ "properties": {
+ "modifiers": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"]
+ },
+ "description": "An array of keyboard modifiers that were held while the menu item was clicked."
+ },
+ "button": {
+ "type": "integer",
+ "optional": true,
+ "description": "An integer value of button by which menu item was clicked."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "setTitle",
+ "type": "function",
+ "description": "Sets the title of the messageDisplayAction button. Is used as tooltip and as the label.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "title": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the messageDisplayAction button should display as its label and when moused over. Cleared by setting it to <value>null</value> or an empty string (title defined the manifest will be used)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the title only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getTitle",
+ "type": "function",
+ "description": "Gets the title of the messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the title should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setLabel",
+ "type": "function",
+ "description": "Sets the label of the messageDisplayAction button. Can be used to set different values for the tooltip (defined by the title) and the label. Additionally, the label can be set to an empty string, not showing any label at all.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "label": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "A string the messageDisplayAction button should use as its label, overriding the defined title. Can be set to an empty string to not display any label at all. If the containing toolbar is configured to display text only, its title will be used. Cleared by setting it to <value>null</value>."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the label only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getLabel",
+ "type": "function",
+ "description": "Gets the label of the messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the label should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setIcon",
+ "type": "function",
+ "description": "Sets the icon for the messageDisplayAction button. Either the ``path`` or the ``imageData`` property must be specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "imageData": {
+ "choices": [
+ {
+ "$ref": "ImageDataType"
+ },
+ {
+ "$ref": "ImageDataDictionary"
+ }
+ ],
+ "optional": true,
+ "description": "The image data for one or more icons for the composeAction button."
+ },
+ "path": {
+ "$ref": "manifest.IconPath",
+ "optional": true,
+ "description": "The paths to one or more icons for the messageDisplayAction button."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the icon only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "setPopup",
+ "type": "function",
+ "description": "Sets the html document to be opened as a popup when the user clicks on the messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "popup": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The html file to show in a popup. Can be set to an empty string to not open a popup. Cleared by setting it to <value>null</value> (action will use the popup value defined in the manifest)."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the popup only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getPopup",
+ "type": "function",
+ "description": "Gets the html document set as the popup for this messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the popup document should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeText",
+ "type": "function",
+ "description": "Sets the badge text for the messageDisplayAction button. The badge is displayed on top of the icon.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "text": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Any number of characters can be passed, but only about four can fit in the space. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the badge text only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeText",
+ "type": "function",
+ "description": "Gets the badge text of the messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge text should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "setBadgeBackgroundColor",
+ "type": "function",
+ "description": "Sets the background color for the badge.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "color": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "ColorArray"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The color to use as background in the badge. Cleared by setting it to <value>null</value> or an empty string."
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Sets the background color for the badge only for the given tab."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "getBadgeBackgroundColor",
+ "type": "function",
+ "description": "Gets the badge background color of the messageDisplayAction button.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the badge background color should be retrieved. If no tab is specified, the global label is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "$ref": "ColorArray"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "enable",
+ "type": "function",
+ "description": "Enables the messageDisplayAction button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well. By default, a messageDisplayAction button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the messageDisplayAction button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "disable",
+ "type": "function",
+ "description": "Disables the messageDisplayAction button for a specific tab (if a ``tabId`` is provided), or for all tabs which do not have a custom enable state. Once the enable state of a tab has been updated individually, all further changes to its state have to be done individually as well.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "optional": true,
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The id of the tab for which you want to modify the messageDisplayAction button."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "isEnabled",
+ "type": "function",
+ "description": "Checks whether the messageDisplayAction button is enabled.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Specifies for which tab the state should be retrieved. If no tab is specified, the global value is retrieved."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "unsupported": true,
+ "description": "Will throw an error if used."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "openPopup",
+ "type": "function",
+ "description": "Opens the action's popup window in the specified window. Defaults to the current window. Returns false if the popup could not be opened because the action has no popup, is of type <value>menu</value>, is disabled or has been removed from the toolbar.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "options",
+ "optional": true,
+ "type": "object",
+ "description": "An object with information about the popup to open.",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "Defaults to the current window."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onClicked",
+ "type": "function",
+ "description": "Fired when a messageDisplayAction button is clicked. This event will not fire if the messageDisplayAction has a popup. This is a user input event handler. For asynchronous listeners some `restrictions <|link-user-input-restrictions|>`__ apply.",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab"
+ },
+ {
+ "name": "info",
+ "$ref": "OnClickData",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/messages.json b/comm/mail/components/extensions/schemas/messages.json
new file mode 100644
index 0000000000..6a025763a1
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/messages.json
@@ -0,0 +1,933 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": [
+ "messagesDelete",
+ "messagesImport",
+ "messagesMove",
+ "messagesRead",
+ "messagesTags",
+ "sensitiveDataUpload"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "messages",
+ "permissions": ["messagesRead"],
+ "types": [
+ {
+ "id": "MessageHeader",
+ "type": "object",
+ "description": "Basic information about a message.",
+ "properties": {
+ "author": {
+ "type": "string"
+ },
+ "bccList": {
+ "description": "The Bcc recipients. Not populated for news/nntp messages.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "ccList": {
+ "description": "The Cc recipients. Not populated for news/nntp messages.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "date": {
+ "$ref": "extensionTypes.Date"
+ },
+ "external": {
+ "type": "boolean",
+ "description": "Whether this message is a real message or an external message (opened from a file or from an attachment)."
+ },
+ "flagged": {
+ "type": "boolean",
+ "description": "Whether this message is flagged (a.k.a. starred)."
+ },
+ "folder": {
+ "$ref": "folders.MailFolder",
+ "description": "The <permission>accountsRead</permission> permission is required for this property to be included. Not available for external or attached messages.",
+ "optional": true
+ },
+ "headerMessageId": {
+ "type": "string",
+ "description": "The message-id header of the message."
+ },
+ "headersOnly": {
+ "description": "Some account types (for example <value>pop3</value>) allow to download only the headers of the message, but not its body. The body of such messages will not be available.",
+ "type": "boolean"
+ },
+ "id": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "junk": {
+ "description": "Whether the message has been marked as junk. Always <value>false</value> for news/nntp messages and external messages.",
+ "type": "boolean"
+ },
+ "junkScore": {
+ "type": "integer",
+ "description": "The junk score associated with the message. Always <value>0</value> for news/nntp messages and external messages.",
+ "minimum": 0,
+ "maximum": 100
+ },
+ "read": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the message has been marked as read. Not available for external or attached messages."
+ },
+ "new": {
+ "type": "boolean",
+ "description": "Whether the message has been received recently and is marked as new."
+ },
+ "recipients": {
+ "description": "The To recipients. Not populated for news/nntp messages.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "size": {
+ "description": "The total size of the message in bytes.",
+ "type": "integer"
+ },
+ "subject": {
+ "type": "string",
+ "description": "The subject of the message."
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags associated with this message. For a list of available tags, call the listTags method."
+ }
+ }
+ },
+ {
+ "id": "MessageList",
+ "type": "object",
+ "description": "See :doc:`how-to/messageLists` for more information.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "optional": true
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "MessageHeader"
+ }
+ }
+ }
+ },
+ {
+ "id": "MessagePart",
+ "type": "object",
+ "description": "Represents an email message \"part\", which could be the whole message",
+ "properties": {
+ "body": {
+ "type": "string",
+ "description": "The content of the part",
+ "optional": true
+ },
+ "contentType": {
+ "type": "string",
+ "optional": true
+ },
+ "headers": {
+ "type": "object",
+ "description": "A <em>dictionary object</em> of part headers as <em>key-value</em> pairs, with the header name as <em>key</em>, and an array of headers as <em>value</em>",
+ "optional": true,
+ "additionalProperties": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the part, if it is a file",
+ "optional": true
+ },
+ "partName": {
+ "type": "string",
+ "optional": true,
+ "description": "The identifier of this part, used in :ref:`messages.getAttachmentFile`"
+ },
+ "parts": {
+ "type": "array",
+ "items": {
+ "$ref": "MessagePart"
+ },
+ "description": "Any sub-parts of this part",
+ "optional": true
+ },
+ "size": {
+ "type": "integer",
+ "optional": true,
+ "description": "The size of this part. The size of <em>message/*</em> parts is not the actual message size (on disc), but the total size of its decoded body parts, excluding headers."
+ }
+ }
+ },
+ {
+ "id": "MessageProperties",
+ "type": "object",
+ "description": "Message properties used in :ref:`messages.update` and :ref:`messages.import`. They can also be monitored by :ref:`messages.onUpdated`.",
+ "properties": {
+ "flagged": {
+ "type": "boolean",
+ "description": "Whether the message is flagged (a.k.a starred).",
+ "optional": true
+ },
+ "junk": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the message is marked as junk. Only supported in :ref:`messages.update`"
+ },
+ "new": {
+ "type": "boolean",
+ "description": "Whether the message is marked as new. Only supported in :ref:`messages.import`",
+ "optional": true
+ },
+ "read": {
+ "type": "boolean",
+ "description": "Whether the message is marked as read.",
+ "optional": true
+ },
+ "tags": {
+ "type": "array",
+ "description": "Tags associated with this message. For a list of available tags, call the listTags method.",
+ "optional": true,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ {
+ "id": "MessageTag",
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string",
+ "description": "Unique tag identifier."
+ },
+ "tag": {
+ "type": "string",
+ "description": "Human-readable tag name."
+ },
+ "color": {
+ "type": "string",
+ "description": "Tag color."
+ },
+ "ordinal": {
+ "type": "string",
+ "description": "Custom sort string (usually empty)."
+ }
+ }
+ },
+ {
+ "id": "TagsDetail",
+ "type": "object",
+ "description": "Used for filtering messages by tag in various methods. Note that functions using this type may have a partial implementation.",
+ "properties": {
+ "tags": {
+ "type": "object",
+ "description": "A <em>dictionary object</em> with one or more filter condition as <em>key-value</em> pairs, the <em>key</em> being the tag to filter on, and the <em>value</em> being a boolean expression, requesting whether a message must include (<value>true</value>) or exclude (<value>false</value>) the tag. For a list of available tags, call the :ref:`messages.listTags` method.",
+ "patternProperties": {
+ ".*": {
+ "type": "boolean"
+ }
+ }
+ },
+ "mode": {
+ "type": "string",
+ "description": "Whether all of the tag filters must apply, or any of them.",
+ "enum": ["all", "any"]
+ }
+ }
+ },
+ {
+ "id": "MessageAttachment",
+ "type": "object",
+ "description": "Represents an attachment in a message.",
+ "properties": {
+ "contentType": {
+ "type": "string",
+ "description": "The content type of the attachment."
+ },
+ "name": {
+ "type": "string",
+ "description": "The name, as displayed to the user, of this attachment. This is usually but not always the filename of the attached file."
+ },
+ "partName": {
+ "type": "string",
+ "description": "Identifies the MIME part of the message associated with this attachment."
+ },
+ "size": {
+ "type": "integer",
+ "description": "The size in bytes of this attachment."
+ },
+ "message": {
+ "$ref": "messages.MessageHeader",
+ "optional": true,
+ "description": "A MessageHeader, if this attachment is a message."
+ }
+ }
+ }
+ ],
+ "events": [
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when one or more properties of a message have been updated.",
+ "parameters": [
+ {
+ "name": "message",
+ "$ref": "messages.MessageHeader"
+ },
+ {
+ "name": "changedProperties",
+ "$ref": "messages.MessageProperties"
+ }
+ ]
+ },
+ {
+ "name": "onMoved",
+ "type": "function",
+ "description": "Fired when messages have been moved.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "originalMessages",
+ "$ref": "messages.MessageList"
+ },
+ {
+ "name": "movedMessages",
+ "$ref": "messages.MessageList"
+ }
+ ]
+ },
+ {
+ "name": "onCopied",
+ "type": "function",
+ "description": "Fired when messages have been copied.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "originalMessages",
+ "$ref": "messages.MessageList"
+ },
+ {
+ "name": "copiedMessages",
+ "$ref": "messages.MessageList"
+ }
+ ]
+ },
+ {
+ "name": "onDeleted",
+ "type": "function",
+ "description": "Fired when messages have been permanently deleted.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "messages",
+ "$ref": "messages.MessageList"
+ }
+ ]
+ },
+ {
+ "name": "onNewMailReceived",
+ "type": "function",
+ "description": "Fired when a new message is received, and has been through junk classification and message filters.",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "name": "messages",
+ "$ref": "messages.MessageList"
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "list",
+ "type": "function",
+ "description": "Gets all messages in a folder.",
+ "async": "callback",
+ "permissions": ["accountsRead"],
+ "parameters": [
+ {
+ "name": "folder",
+ "$ref": "folders.MailFolder"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MessageList"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "continueList",
+ "type": "function",
+ "description": "Returns the next chunk of messages in a list. See :doc:`how-to/messageLists` for more information.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageListId",
+ "type": "string"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MessageList"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Returns a specified message.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MessageHeader"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getFull",
+ "type": "function",
+ "description": "Returns a specified message, including all headers and MIME parts. Throws if the message could not be read, for example due to network issues.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MessagePart"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getRaw",
+ "type": "function",
+ "description": "Returns the unmodified source of a message. Throws if the message could not be read, for example due to network issues.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "name": "options",
+ "type": "object",
+ "properties": {
+ "data_format": {
+ "choices": [
+ {
+ "max_manifest_version": 2,
+ "description": "The message can either be returned as a DOM File or as a `binary string <|link-binary-string|>`__. The historic default is to return a binary string (kept for backward compatibility). However, it is now recommended to use the ``File`` format, because the DOM File object can be used as-is with the downloads API and has useful methods to access the content, like `File.text() <|link-DOMFile-text|>`__ and `File.arrayBuffer() <|link-DOMFile-arrayBuffer|>`__. Working with binary strings is error prone and needs special handling: <literalinclude>includes/messages/decodeBinaryString.js<lang>JavaScript</lang></literalinclude> (see MDN for `supported input encodings <|link-input-encoding|>`__).",
+ "type": "string",
+ "enum": ["File", "BinaryString"]
+ },
+ {
+ "min_manifest_version": 3,
+ "description": "The message can either be returned as a DOM File (default) or as a `binary string <|link-binary-string|>`__. It is recommended to use the ``File`` format, because the DOM File object can be used as-is with the downloads API and has useful methods to access the content, like `File.text() <|link-DOMFile-text|>`__ and `File.arrayBuffer() <|link-DOMFile-arrayBuffer|>`__. Working with binary strings is error prone and needs special handling: <literalinclude>includes/messages/decodeBinaryString.js<lang>JavaScript</lang></literalinclude> (see MDN for `supported input encodings <|link-input-encoding|>`__).",
+ "type": "string",
+ "enum": ["File", "BinaryString"]
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "listAttachments",
+ "type": "function",
+ "description": "Lists the attachments of a message.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "MessageAttachment"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getAttachmentFile",
+ "type": "function",
+ "description": "Gets the content of a :ref:`messages.MessageAttachment` as a |File| object.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "name": "partName",
+ "type": "string",
+ "pattern": "^\\d+(\\.\\d+)*$"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "openAttachment",
+ "type": "function",
+ "description": "Opens the specified attachment",
+ "async": true,
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer"
+ },
+ {
+ "name": "partName",
+ "type": "string",
+ "pattern": "^\\d+(\\.\\d+)*$"
+ },
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "The ID of the tab associated with the message opening."
+ }
+ ]
+ },
+ {
+ "name": "query",
+ "type": "function",
+ "description": "Gets all messages that have the specified properties, or all messages if no properties are specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "queryInfo",
+ "optional": true,
+ "default": {},
+ "properties": {
+ "attachment": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If specified, returns only messages with or without attachments."
+ },
+ "author": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages with this value matching the author. The search value is a single email address, a name or a combination (e.g.: <value>Name <user@domain.org></value>). The address part of the search value (if provided) must match the author's address completely. The name part of the search value (if provided) must match the author's name partially. All matches are done case-insensitive."
+ },
+ "body": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages with this value in the body of the mail."
+ },
+ "flagged": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Returns only flagged (or unflagged if false) messages."
+ },
+ "folder": {
+ "$ref": "folders.MailFolder",
+ "optional": true,
+ "description": "Returns only messages from the specified folder. The <permission>accountsRead</permission> permission is required."
+ },
+ "fromDate": {
+ "$ref": "extensionTypes.Date",
+ "optional": true,
+ "description": "Returns only messages with a date after this value."
+ },
+ "fromMe": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Returns only messages with the author's address matching any configured identity."
+ },
+ "fullText": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages with this value somewhere in the mail (subject, body or author)."
+ },
+ "headerMessageId": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages with a Message-ID header matching this value."
+ },
+ "includeSubFolders": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Search the folder specified by ``queryInfo.folder`` recursively."
+ },
+ "recipients": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages whose recipients match all specified addresses. The search value is a semicolon separated list of email addresses, names or combinations (e.g.: <value>Name <user@domain.org></value>). For a match, all specified addresses must equal a recipient's address completely and all specified names must match a recipient's name partially. All matches are done case-insensitive."
+ },
+ "subject": {
+ "type": "string",
+ "optional": true,
+ "description": "Returns only messages with this value matching the subject."
+ },
+ "tags": {
+ "$ref": "TagsDetail",
+ "optional": true,
+ "description": "Returns only messages with the specified tags. For a list of available tags, call the :ref:`messages.listTags` method."
+ },
+ "toDate": {
+ "$ref": "extensionTypes.Date",
+ "optional": true,
+ "description": "Returns only messages with a date before this value."
+ },
+ "toMe": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Returns only messages with at least one recipient address matching any configured identity."
+ },
+ "unread": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Returns only unread (or read if false) messages."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "MessageList"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Marks or unmarks a message as junk, read, flagged, or tagged. Updating external messages will throw an <em>ExtensionError</em>.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "messageId",
+ "type": "integer",
+ "minimum": 1
+ },
+ {
+ "name": "newProperties",
+ "$ref": "MessageProperties"
+ }
+ ]
+ },
+ {
+ "name": "move",
+ "type": "function",
+ "description": "Moves messages to a specified folder. If the messages cannot be removed from the source folder, they will be copied instead of moved. Moving external messages will throw an <em>ExtensionError</em>.",
+ "async": true,
+ "permissions": ["accountsRead", "messagesMove"],
+ "parameters": [
+ {
+ "name": "messageIds",
+ "type": "array",
+ "description": "The IDs of the messages to move.",
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ {
+ "name": "destination",
+ "$ref": "folders.MailFolder",
+ "description": "The folder to move the messages to."
+ }
+ ]
+ },
+ {
+ "name": "copy",
+ "type": "function",
+ "description": "Copies messages to a specified folder.",
+ "async": true,
+ "permissions": ["accountsRead", "messagesMove"],
+ "parameters": [
+ {
+ "name": "messageIds",
+ "type": "array",
+ "description": "The IDs of the messages to copy.",
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ {
+ "name": "destination",
+ "$ref": "folders.MailFolder",
+ "description": "The folder to copy the messages to."
+ }
+ ]
+ },
+ {
+ "name": "delete",
+ "type": "function",
+ "description": "Deletes messages permanently, or moves them to the trash folder (honoring the account's deletion behavior settings). Deleting external messages will throw an <em>ExtensionError</em>. The ``skipTrash`` parameter allows immediate permanent deletion, bypassing the trash folder.\n**Note**: Consider using :ref:`messages.move` to manually move messages to the account's trash folder, instead of requesting the overly powerful permission to actually delete messages. The account's trash folder can be extracted as follows: <literalinclude>includes/messages/getTrash.js<lang>JavaScript</lang></literalinclude>",
+ "async": true,
+ "permissions": ["messagesDelete"],
+ "parameters": [
+ {
+ "name": "messageIds",
+ "type": "array",
+ "description": "The IDs of the messages to delete.",
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ {
+ "name": "skipTrash",
+ "type": "boolean",
+ "description": "If true, the message will be deleted permanently, regardless of the account's deletion behavior settings.",
+ "optional": true
+ }
+ ]
+ },
+ {
+ "name": "import",
+ "type": "function",
+ "description": "Imports a message into a local Thunderbird folder. To import a message into an IMAP folder, add it to a local folder first and then move it to the IMAP folder.",
+ "async": "callback",
+ "permissions": ["accountsRead", "messagesImport"],
+ "parameters": [
+ {
+ "name": "file",
+ "type": "object",
+ "isInstanceOf": "File",
+ "additionalProperties": true
+ },
+ {
+ "name": "destination",
+ "$ref": "folders.MailFolder",
+ "description": "The folder to import the messages into."
+ },
+ {
+ "name": "properties",
+ "$ref": "messages.MessageProperties",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "messages.MessageHeader"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "archive",
+ "type": "function",
+ "description": "Archives messages using the current settings. Archiving external messages will throw an <em>ExtensionError</em>.",
+ "async": true,
+ "permissions": ["messagesMove"],
+ "parameters": [
+ {
+ "name": "messageIds",
+ "type": "array",
+ "description": "The IDs of the messages to archive.",
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ ]
+ },
+ {
+ "name": "listTags",
+ "type": "function",
+ "description": "Returns a list of tags that can be set on messages, and their human-friendly name, colour, and sort order.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "MessageTag"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "createTag",
+ "type": "function",
+ "description": "Creates a new message tag. Tagging a message will store the tag's key in the user's message. Throws if the specified tag key is used already.",
+ "async": true,
+ "permissions": ["messagesTags"],
+ "parameters": [
+ {
+ "type": "string",
+ "name": "key",
+ "description": "Unique tag identifier (will be converted to lower case). Must not include <value>()<>{/%*\"</value> or spaces.",
+ "pattern": "^[^ ()/{%*<>\"]+$"
+ },
+ {
+ "type": "string",
+ "name": "tag",
+ "description": "Human-readable tag name."
+ },
+ {
+ "type": "string",
+ "name": "color",
+ "description": "Tag color in hex format (i.e.: #000080 for navy blue)",
+ "pattern": "^#[0-9a-f]{6}"
+ }
+ ]
+ },
+ {
+ "name": "updateTag",
+ "type": "function",
+ "description": "Updates a message tag.",
+ "async": true,
+ "permissions": ["messagesTags"],
+ "parameters": [
+ {
+ "type": "string",
+ "name": "key",
+ "description": "Unique tag identifier (will be converted to lower case). Must not include <value>()<>{/%*\"</value> or spaces.",
+ "pattern": "^[^ ()/{%*<>\"]+$"
+ },
+ {
+ "type": "object",
+ "name": "updateProperties",
+ "properties": {
+ "tag": {
+ "type": "string",
+ "optional": "true",
+ "description": "Human-readable tag name."
+ },
+ "color": {
+ "type": "string",
+ "pattern": "^#[0-9a-f]{6}",
+ "description": "Tag color in hex format (i.e.: #000080 for navy blue).",
+ "optional": "true"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "deleteTag",
+ "type": "function",
+ "description": "Deletes a message tag, removing it from the list of known tags. Its key will not be removed from tagged messages, but they will appear untagged. Recreating a deleted tag, will make all former tagged messages appear tagged again.",
+ "async": true,
+ "permissions": ["messagesTags"],
+ "parameters": [
+ {
+ "type": "string",
+ "name": "key",
+ "description": "Unique tag identifier (will be converted to lower case). Must not include <value>()<>{/%*\"</value> or spaces.",
+ "pattern": "^[^ ()/{%*<>\"]+$"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/sessions.json b/comm/mail/components/extensions/schemas/sessions.json
new file mode 100644
index 0000000000..3c2fdff165
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/sessions.json
@@ -0,0 +1,76 @@
+[
+ {
+ "namespace": "sessions",
+ "functions": [
+ {
+ "name": "setTabValue",
+ "type": "function",
+ "description": "Store a key/value pair associated with a given tab.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "ID of the tab with which you want to associate the data. Error is thrown if ID is invalid."
+ },
+ {
+ "name": "key",
+ "type": "string",
+ "description": "Key that you can later use to retrieve this particular data value."
+ },
+ {
+ "name": "value",
+ "type": "string"
+ }
+ ]
+ },
+ {
+ "name": "getTabValue",
+ "type": "function",
+ "description": "Retrieve a previously stored value for a given tab, given its key.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "ID of the tab whose data you are trying to retrieve. Error is thrown if ID is invalid."
+ },
+ {
+ "name": "key",
+ "type": "string",
+ "description": "Key identifying the particular value to retrieve."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "type": "string",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "removeTabValue",
+ "type": "function",
+ "description": "Remove a key/value pair from a given tab.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabId",
+ "type": "integer",
+ "description": "ID of the tab whose data you are trying to remove. Error is thrown if ID is invalid."
+ },
+ {
+ "name": "key",
+ "type": "string",
+ "description": "Key identifying the particular value to remove."
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/spaces.json b/comm/mail/components/extensions/schemas/spaces.json
new file mode 100644
index 0000000000..e94731f810
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/spaces.json
@@ -0,0 +1,290 @@
+[
+ {
+ "namespace": "spaces",
+ "types": [
+ {
+ "id": "SpaceButtonProperties",
+ "type": "object",
+ "properties": {
+ "badgeBackgroundColor": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "ColorArray"
+ }
+ ],
+ "optional": true,
+ "description": "Sets the background color of the badge. Can be specified as an array of four integers in the range [0,255] that make up the RGBA color of the badge. For example, opaque red is <value>[255, 0, 0, 255]</value>. Can also be a string with an HTML color name (<value>red</value>) or a HEX color value (<value>#FF0000</value> or <value>#F00</value>). Reset when set to an empty string."
+ },
+ "badgeText": {
+ "type": "string",
+ "optional": true,
+ "description": "Sets the badge text for the button in the spaces toolbar. The badge is displayed on top of the icon. Any number of characters can be set, but only about four can fit in the space. Removed when set to an empty string."
+ },
+ "defaultIcons": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "manifest.IconPath"
+ }
+ ],
+ "optional": true,
+ "description": "The paths to one or more icons for the button in the spaces toolbar. Defaults to the extension icon, if set to an empty string."
+ },
+ "themeIcons": {
+ "type": "array",
+ "optional": true,
+ "items": {
+ "$ref": "manifest.ThemeIcons"
+ },
+ "description": "Specifies dark and light icons for the button in the spaces toolbar to be used with themes: The ``light`` icons will be used on dark backgrounds and vice versa. At least the set for <em>16px</em> icons should be specified. The set for <em>32px</em> icons will be used on screens with a very high pixel density, if specified."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "description": "The title for the button in the spaces toolbar, used in the tooltip of the button and as the displayed name in the overflow menu. Defaults to the name of the extension, if set to an empty string."
+ }
+ }
+ },
+ {
+ "id": "ColorArray",
+ "description": "An array of four integers in the range [0,255] that make up the RGBA color. For example, opaque red is <value>[255, 0, 0, 255]</value>.",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ "minItems": 4,
+ "maxItems": 4
+ },
+ {
+ "id": "Space",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1
+ },
+ "name": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_]+$",
+ "description": "The name of the space. Names are unique for a single extension, but different extensions may use the same name."
+ },
+ "isBuiltIn": {
+ "type": "boolean",
+ "description": "Whether this space is one of the default Thunderbird spaces, or an extension space."
+ },
+ "isSelfOwned": {
+ "type": "boolean",
+ "description": "Whether this space was created by this extension."
+ },
+ "extensionId": {
+ "type": "string",
+ "optional": true,
+ "description": "The id of the extension which owns the space. The <permission>management</permission> permission is required to include this property."
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "create",
+ "type": "function",
+ "description": "Creates a new space and adds its button to the spaces toolbar.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_]+$",
+ "description": "The name to assign to this space. May only contain alphanumeric characters and underscores. Must be unique for this extension."
+ },
+ {
+ "name": "defaultUrl",
+ "type": "string",
+ "description": "The default space url, loaded into a tab when the button in the spaces toolbar is clicked. Supported are <value>https://</value> and <value>http://</value> links, as well as links to WebExtension pages."
+ },
+ {
+ "name": "buttonProperties",
+ "description": "Properties of the button for the new space.",
+ "$ref": "spaces.SpaceButtonProperties",
+ "optional": true,
+ "default": {}
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "space",
+ "$ref": "spaces.Space"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Retrieves details about the specified space.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "spaceId",
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "space",
+ "$ref": "spaces.Space"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "query",
+ "type": "function",
+ "description": "Gets all spaces that have the specified properties, or all spaces if no properties are specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "queryInfo",
+ "optional": true,
+ "default": {},
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "The id of the space.",
+ "optional": true,
+ "minimum": 1
+ },
+ "name": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_]+$",
+ "optional": true,
+ "description": "The name of the spaces (names are not unique)."
+ },
+ "isBuiltIn": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Spaces should be default Thunderbird spaces."
+ },
+ "isSelfOwned": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Spaces should have been created by this extension."
+ },
+ "extensionId": {
+ "type": "string",
+ "optional": true,
+ "description": "Id of the extension which should own the spaces. The <permission>management</permission> permission is required to be able to match against extension ids."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "array",
+ "items": {
+ "$ref": "spaces.Space"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "remove",
+ "type": "function",
+ "description": "Removes the specified space, closes all its tabs and removes its button from the spaces toolbar. Throws an exception if the requested space does not exist or was not created by this extension.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "spaceId",
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Updates the specified space. Throws an exception if the requested space does not exist or was not created by this extension.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "spaceId",
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1
+ },
+ {
+ "name": "defaultUrl",
+ "type": "string",
+ "description": "The default space url, loaded into a tab when the button in the spaces toolbar is clicked. Supported are <value>https://</value> and <value>http://</value> links, as well as links to WebExtension pages.",
+ "optional": true
+ },
+ {
+ "name": "buttonProperties",
+ "description": "Only specified button properties will be updated.",
+ "$ref": "spaces.SpaceButtonProperties",
+ "optional": true
+ }
+ ]
+ },
+ {
+ "name": "open",
+ "type": "function",
+ "description": "Opens or switches to the specified space. Throws an exception if the requested space does not exist or was not created by this extension.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "spaceId",
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1
+ },
+ {
+ "name": "windowId",
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "The id of the normal window, where the space should be opened. Defaults to the most recent normal window."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "optional": true,
+ "description": "Details about the opened or activated space tab."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/spacesToolbar.json b/comm/mail/components/extensions/schemas/spacesToolbar.json
new file mode 100644
index 0000000000..50beab1367
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/spacesToolbar.json
@@ -0,0 +1,175 @@
+[
+ {
+ "namespace": "spacesToolbar",
+ "max_manifest_version": 2,
+ "types": [
+ {
+ "id": "ButtonProperties",
+ "type": "object",
+ "properties": {
+ "badgeBackgroundColor": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "ColorArray"
+ }
+ ],
+ "optional": true,
+ "description": "Sets the background color of the badge. Can be specified as an array of four integers in the range [0,255] that make up the RGBA color of the badge. For example, opaque red is <value>[255, 0, 0, 255]</value>. Can also be a string with an HTML color name (<value>red</value>) or a HEX color value (<value>#FF0000</value> or <value>#F00</value>). Reset when set to an empty string."
+ },
+ "badgeText": {
+ "type": "string",
+ "optional": true,
+ "description": "Sets the badge text for the spaces toolbar button. The badge is displayed on top of the icon. Any number of characters can be set, but only about four can fit in the space. Removed when set to an empty string."
+ },
+ "defaultIcons": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "manifest.IconPath"
+ }
+ ],
+ "optional": true,
+ "description": "The paths to one or more icons for the button in the spaces toolbar. Defaults to the extension icon, if set to an empty string."
+ },
+ "themeIcons": {
+ "type": "array",
+ "optional": true,
+ "items": {
+ "$ref": "manifest.ThemeIcons"
+ },
+ "description": "Specifies dark and light icons for the spaces toolbar button to be used with themes: The ``light`` icons will be used on dark backgrounds and vice versa. At least the set for <em>16px</em> icons should be specified. The set for <em>32px</em> icons will be used on screens with a very high pixel density, if specified."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "description": "The title for the spaces toolbar button, used in the tooltip of the button and as the displayed name in the overflow menu. Defaults to the name of the extension, if set to an empty string."
+ },
+ "url": {
+ "type": "string",
+ "optional": true,
+ "description": "The page url, loaded into a tab when the button is clicked. Supported are <value>https://</value> and <value>http://</value> links, as well as links to WebExtension pages."
+ }
+ }
+ },
+ {
+ "id": "ColorArray",
+ "description": "An array of four integers in the range [0,255] that make up the RGBA color. For example, opaque red is <value>[255, 0, 0, 255]</value>.",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ "minItems": 4,
+ "maxItems": 4
+ }
+ ],
+ "functions": [
+ {
+ "name": "addButton",
+ "type": "function",
+ "description": "Adds a new button to the spaces toolbar. Throws an exception, if the used ``id`` is not unique within the extension.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_]+$",
+ "description": "The unique id to assign to this button. May only contain alphanumeric characters and underscores."
+ },
+ {
+ "name": "properties",
+ "description": "Properties of the new button. The ``url`` is mandatory.",
+ "$ref": "spacesToolbar.ButtonProperties"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "spaceId",
+ "type": "integer",
+ "description": "The id of the space belonging to the newly created button, as used by the tabs API.",
+ "minimum": 1,
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "removeButton",
+ "type": "function",
+ "description": "Removes the specified button from the spaces toolbar. Throws an exception if the requested spaces toolbar button does not exist or was not created by this extension. If the tab of this button is currently open, it will be closed.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_]+$",
+ "description": "The id of the spaces toolbar button, which is to be removed. May only contain alphanumeric characters and underscores."
+ }
+ ]
+ },
+ {
+ "name": "updateButton",
+ "type": "function",
+ "description": "Updates properties of the specified spaces toolbar button. Throws an exception if the requested spaces toolbar button does not exist or was not created by this extension.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string",
+ "description": "The id of the spaces toolbar button, which is to be updated. May only contain alphanumeric characters and underscores.",
+ "pattern": "^[a-zA-Z0-9_]+$"
+ },
+ {
+ "name": "properties",
+ "description": "Only specified properties will be updated.",
+ "$ref": "spacesToolbar.ButtonProperties"
+ }
+ ]
+ },
+ {
+ "name": "clickButton",
+ "type": "function",
+ "description": "Trigger a click on the specified spaces toolbar button. Throws an exception if the requested spaces toolbar button does not exist or was not created by this extension.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "id",
+ "type": "string",
+ "description": "The id of the spaces toolbar button. May only contain alphanumeric characters and underscores.",
+ "pattern": "^[a-zA-Z0-9_]+$"
+ },
+ {
+ "name": "windowId",
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "The id of the normal window, where the spaces toolbar button should be clicked. Defaults to the most recent normal window."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "optional": true,
+ "description": "Details about the opened or activated tab."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/tabs.json b/comm/mail/components/extensions/schemas/tabs.json
new file mode 100644
index 0000000000..7d68f01b32
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/tabs.json
@@ -0,0 +1,989 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermissionNoPrompt",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["activeTab"]
+ }
+ ]
+ },
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["tabs", "tabHide"]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "tabs",
+ "description": "The tabs API supports creating, modifying and interacting with tabs in Thunderbird windows.",
+ "types": [
+ {
+ "id": "Tab",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "minimum": -1,
+ "optional": true,
+ "description": "The ID of the tab. Tab IDs are unique within a session. Under some circumstances a Tab may not be assigned an ID. Tab ID can also be set to :ref:`tabs.TAB_ID_NONE` for apps and devtools windows."
+ },
+ "index": {
+ "type": "integer",
+ "minimum": -1,
+ "description": "The zero-based index of the tab within its window."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "The ID of the window the tab is contained within."
+ },
+ "selected": {
+ "type": "boolean",
+ "description": "Whether the tab is selected.",
+ "deprecated": "Please use :ref:`tabs.Tab.highlighted`.",
+ "unsupported": true
+ },
+ "highlighted": {
+ "type": "boolean",
+ "description": "Whether the tab is highlighted. Works as an alias of active"
+ },
+ "active": {
+ "type": "boolean",
+ "description": "Whether the tab is active in its window. (Does not necessarily mean the window is focused.)"
+ },
+ "url": {
+ "type": "string",
+ "optional": true,
+ "permissions": ["tabs"],
+ "description": "The URL the tab is displaying. This property is only present if the extension's manifest includes the <permission>tabs</permission> permission."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "permissions": ["tabs"],
+ "description": "The title of the tab. This property is only present if the extension's manifest includes the <permission>tabs</permission> permission."
+ },
+ "favIconUrl": {
+ "type": "string",
+ "optional": true,
+ "permissions": ["tabs"],
+ "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the <permission>tabs</permission> permission. It may also be an empty string if the tab is loading."
+ },
+ "status": {
+ "type": "string",
+ "optional": true,
+ "description": "Either <value>loading</value> or <value>complete</value>."
+ },
+ "width": {
+ "type": "integer",
+ "optional": true,
+ "description": "The width of the tab in pixels."
+ },
+ "height": {
+ "type": "integer",
+ "optional": true,
+ "description": "The height of the tab in pixels."
+ },
+ "cookieStoreId": {
+ "type": "string",
+ "optional": true,
+ "description": "The `CookieStore <|link-cookieStore|>`__ id used by the tab. Either a custom id created using the `contextualIdentities API <|link-contextualIdentity|>`__, or a built-in one: <value>firefox-default</value>, <value>firefox-container-1</value>, <value>firefox-container-2</value>, <value>firefox-container-3</value>, <value>firefox-container-4</value>, <value>firefox-container-5</value>. **Note:** The naming pattern was deliberately not changed for Thunderbird, but kept for compatibility reasons."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "addressBook",
+ "calendar",
+ "calendarEvent",
+ "calendarTask",
+ "chat",
+ "content",
+ "mail",
+ "messageCompose",
+ "messageDisplay",
+ "special",
+ "tasks"
+ ],
+ "optional": true
+ },
+ "mailTab": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tab is a 3-pane tab."
+ },
+ "spaceId": {
+ "type": "integer",
+ "description": "The id of the space.",
+ "minimum": 1,
+ "optional": true
+ }
+ }
+ },
+ {
+ "id": "TabStatus",
+ "type": "string",
+ "enum": ["loading", "complete"],
+ "description": "Whether the tabs have completed loading."
+ },
+ {
+ "id": "WindowType",
+ "type": "string",
+ "description": "The type of a window. Under some circumstances a Window may not be assigned a type property.",
+ "enum": [
+ "normal",
+ "popup",
+ "panel",
+ "app",
+ "devtools",
+ "messageCompose",
+ "messageDisplay"
+ ]
+ },
+ {
+ "id": "UpdatePropertyName",
+ "type": "string",
+ "enum": ["favIconUrl", "status", "title"],
+ "description": "Event names supported in onUpdated."
+ },
+ {
+ "id": "UpdateFilter",
+ "type": "object",
+ "description": "An object describing filters to apply to tabs.onUpdated events.",
+ "properties": {
+ "urls": {
+ "type": "array",
+ "description": "A list of URLs or URL patterns. Events that cannot match any of the URLs will be filtered out. Filtering with urls requires the <permission>tabs</permission> or <permission>activeTab</permission> permission.",
+ "optional": true,
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ },
+ "properties": {
+ "type": "array",
+ "optional": true,
+ "description": "A list of property names. Events that do not match any of the names will be filtered out.",
+ "items": {
+ "$ref": "UpdatePropertyName"
+ },
+ "minItems": 1
+ },
+ "tabId": {
+ "type": "integer",
+ "optional": true
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true
+ }
+ }
+ }
+ ],
+ "properties": {
+ "TAB_ID_NONE": {
+ "value": -1,
+ "description": "An ID which represents the absence of a tab."
+ }
+ },
+ "functions": [
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Retrieves details about the specified tab.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getCurrent",
+ "type": "function",
+ "description": "Gets the tab that this script call is being made from. May be undefined if called from a non-tab context (for example: a background page or popup view).",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "Tab",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "connect",
+ "type": "function",
+ "description": "Connects to the content script(s) in the specified tab. The `runtime.onConnect <|link-runtime-on-connect|>`__ event is fired in each content script running in the specified tab for the current extension. For more details, see `Content Script Messaging <|link-content-scripts|>`__.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "connectInfo",
+ "properties": {
+ "name": {
+ "type": "string",
+ "optional": true,
+ "description": "Will be passed into onConnect for content scripts that are listening for the connection event."
+ },
+ "frameId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Open a port to a specific frame identified by ``frameId`` instead of all frames in the tab."
+ }
+ },
+ "optional": true
+ }
+ ],
+ "returns": {
+ "$ref": "runtime.Port",
+ "description": "A port that can be used to communicate with the content scripts running in the specified tab."
+ }
+ },
+ {
+ "name": "sendMessage",
+ "type": "function",
+ "description": "Sends a single message to the content script(s) in the specified tab, with an optional callback to run when a response is sent back. The `runtime.onMessage <|link-runtime-on-message|>`__ event is fired in each content script running in the specified tab for the current extension.",
+ "async": "responseCallback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "any",
+ "name": "message"
+ },
+ {
+ "type": "object",
+ "name": "options",
+ "properties": {
+ "frameId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "Send a message to a specific frame identified by ``frameId`` instead of all frames in the tab."
+ }
+ },
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "responseCallback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "response",
+ "type": "any",
+ "description": "The JSON response object sent by the handler of the message. If an error occurs while connecting to the specified tab, the callback will be called with no arguments and `runtime.lastError <|link-runtime-last-error|>`__ will be set to the error message."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "create",
+ "type": "function",
+ "description": "Creates a new content tab. Use the :ref:`messageDisplay_api` to open messages. Only supported in <value>normal</value> windows. Same-site links in the loaded page are opened within Thunderbird, all other links are opened in the user's default browser. To override this behavior, add-ons have to register a `content script <https://bugzilla.mozilla.org/show_bug.cgi?id=1618828#c3>`__ , capture click events and handle them manually.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "createProperties",
+ "description": "Properties for the new tab. Defaults to an empty tab, if no ``url`` is provided.",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "The window to create the new tab in. Defaults to the current window."
+ },
+ "index": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The position the tab should take in the window. The provided value will be clamped to between zero and the number of tabs in the window."
+ },
+ "url": {
+ "type": "string",
+ "optional": true,
+ "description": "The URL to navigate the tab to initially. Fully-qualified URLs must include a scheme (i.e. <value>http://www.google.com</value>, not <value>www.google.com</value>). Relative URLs will be relative to the current page within the extension."
+ },
+ "active": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tab should become the active tab in the window. Does not affect whether the window is focused (see :ref:`windows.update`). Defaults to <value>true</value>."
+ },
+ "cookieStoreId": {
+ "type": "string",
+ "optional": true,
+ "description": "The `CookieStore <|link-cookieStore|>`__ id the new tab should use. Either a custom id created using the `contextualIdentities API <|link-contextualIdentity|>`__, or a built-in one: <value>firefox-default</value>, <value>firefox-container-1</value>, <value>firefox-container-2</value>, <value>firefox-container-3</value>, <value>firefox-container-4</value>, <value>firefox-container-5</value>. **Note:** The naming pattern was deliberately not changed for Thunderbird, but kept for compatibility reasons."
+ },
+ "selected": {
+ "deprecated": "Please use ``createProperties.active``.",
+ "unsupported": true,
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tab should become the selected tab in the window. Defaults to <value>true</value>"
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "Tab",
+ "optional": true,
+ "description": "Details about the created tab. Will contain the ID of the new tab."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "duplicate",
+ "type": "function",
+ "description": "Duplicates a tab.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The ID of the tab which is to be duplicated."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tab",
+ "optional": true,
+ "description": "Details about the duplicated tab. The :ref:`tabs.Tab` object doesn't contain ``url``, ``title`` and ``favIconUrl`` if the <permission>tabs</permission> permission has not been requested.",
+ "$ref": "Tab"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "query",
+ "type": "function",
+ "description": "Gets all tabs that have the specified properties, or all tabs if no properties are specified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "queryInfo",
+ "optional": true,
+ "default": {},
+ "properties": {
+ "mailTab": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tab is a Thunderbird 3-pane tab."
+ },
+ "spaceId": {
+ "type": "integer",
+ "description": "The id of the space the tabs should belong to.",
+ "minimum": 1,
+ "optional": true
+ },
+ "type": {
+ "type": "string",
+ "optional": true,
+ "description": "Match tabs against the given Tab.type (see :ref:`tabs.Tab`). Ignored if ``queryInfo.mailTab`` is specified."
+ },
+ "active": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are active in their windows."
+ },
+ "highlighted": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are highlighted. Works as an alias of active."
+ },
+ "currentWindow": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are in the current window."
+ },
+ "lastFocusedWindow": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether the tabs are in the last focused window."
+ },
+ "status": {
+ "$ref": "TabStatus",
+ "optional": true,
+ "description": "Whether the tabs have completed loading."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "description": "Match page titles against a pattern."
+ },
+ "url": {
+ "choices": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ],
+ "optional": true,
+ "description": "Match tabs against one or more `URL Patterns <|link-match-patterns|>`__. Note that fragment identifiers are not matched."
+ },
+ "windowId": {
+ "type": "integer",
+ "optional": true,
+ "minimum": -2,
+ "description": "The ID of the parent window, or :ref:`windows.WINDOW_ID_CURRENT` for the current window."
+ },
+ "windowType": {
+ "$ref": "WindowType",
+ "optional": true,
+ "description": "The type of window the tabs are in."
+ },
+ "index": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "The position of the tabs within their windows."
+ },
+ "cookieStoreId": {
+ "choices": [
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "optional": true,
+ "description": "The `CookieStore <|link-cookieStore|>`__ id(s) used by the tabs. Either custom ids created using the `contextualIdentities API <|link-contextualIdentity|>`__, or built-in ones: <value>firefox-default</value>, <value>firefox-container-1</value>, <value>firefox-container-2</value>, <value>firefox-container-3</value>, <value>firefox-container-4</value>, <value>firefox-container-5</value>. **Note:** The naming pattern was deliberately not changed for Thunderbird, but kept for compatibility reasons."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "result",
+ "type": "array",
+ "items": {
+ "$ref": "Tab"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Modifies the properties of a tab. Properties that are not specified in ``updateProperties`` are not modified.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "optional": true,
+ "description": "Defaults to the selected tab of the current window."
+ },
+ {
+ "type": "object",
+ "name": "updateProperties",
+ "description": "Properties which should to be updated.",
+ "properties": {
+ "url": {
+ "type": "string",
+ "optional": true,
+ "description": "A URL of a page to load. If the URL points to a content page (a web page, an extension page or a registered WebExtension protocol handler page), the tab will navigate to the requested page. All other URLs will be opened externally without changing the tab. Note: This function will throw an error, if a content page is loaded into a non-content tab (its type must be either <value>content</value> or <value>mail</value>)."
+ },
+ "active": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Set this to <value>true</value>, if the tab should become active. Does not affect whether the window is focused (see :ref:`windows.update`). Setting this to <value>false</value> has no effect."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "Tab",
+ "optional": true,
+ "description": "Details about the updated tab. The :ref:`tabs.Tab` object doesn't contain ``url``, ``title`` and ``favIconUrl`` if the <permission>tabs</permission> permission has not been requested."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "move",
+ "type": "function",
+ "description": "Moves one or more tabs to a new position within its current window, or to a different window. Note that tabs can only be moved to and from windows of type <value>normal</value>.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabIds",
+ "description": "The tab or list of tabs to move.",
+ "choices": [
+ {
+ "type": "integer",
+ "minimum": 0
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ ]
+ },
+ {
+ "type": "object",
+ "name": "moveProperties",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": -2,
+ "optional": true,
+ "description": "Defaults to the window the tab is currently in."
+ },
+ "index": {
+ "type": "integer",
+ "minimum": -1,
+ "description": "The position to move the tab to. <value>-1</value> will place the tab at the end of the window."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tabs",
+ "description": "Details about the moved tabs.",
+ "type": "array",
+ "items": {
+ "$ref": "Tab"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "reload",
+ "type": "function",
+ "description": "Reload a tab. Only applicable for tabs which display a content page.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "optional": true,
+ "description": "The ID of the tab to reload; defaults to the selected tab of the current window."
+ },
+ {
+ "type": "object",
+ "name": "reloadProperties",
+ "optional": true,
+ "properties": {
+ "bypassCache": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Whether using any local cache. Default is false."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "remove",
+ "type": "function",
+ "description": "Closes one or more tabs.",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "tabIds",
+ "description": "The tab or list of tabs to close.",
+ "choices": [
+ {
+ "type": "integer",
+ "minimum": 0
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ ]
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "executeScript",
+ "type": "function",
+ "description": "Injects JavaScript code into a page. For details, see the `programmatic injection <|link-content-scripts|>`__ section of the content scripts doc.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "optional": true,
+ "description": "The ID of the tab in which to run the script; defaults to the active tab of the current window."
+ },
+ {
+ "$ref": "extensionTypes.InjectDetails",
+ "name": "details",
+ "description": "Details of the script to run."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "description": "Called after all the JavaScript has been executed.",
+ "parameters": [
+ {
+ "name": "result",
+ "optional": true,
+ "type": "array",
+ "items": {
+ "type": "any"
+ },
+ "description": "The result of the script in every injected frame."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "insertCSS",
+ "type": "function",
+ "description": "Injects CSS into a page. For details, see the `programmatic injection <|link-content-scripts|>`__ section of the content scripts doc.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "optional": true,
+ "description": "The ID of the tab in which to insert the CSS; defaults to the active tab of the current window."
+ },
+ {
+ "$ref": "extensionTypes.InjectDetails",
+ "name": "details",
+ "description": "Details of the CSS text to insert."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "description": "Called when all the CSS has been inserted.",
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "removeCSS",
+ "type": "function",
+ "description": "Removes injected CSS from a page. For details, see the `programmatic injection <|link-content-scripts|>`__ section of the content scripts doc.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "optional": true,
+ "description": "The ID of the tab from which to remove the injected CSS; defaults to the active tab of the current window."
+ },
+ {
+ "$ref": "extensionTypes.InjectDetails",
+ "name": "details",
+ "description": "Details of the CSS text to remove."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "description": "Called when all the CSS has been removed.",
+ "parameters": []
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a tab is created. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.",
+ "parameters": [
+ {
+ "$ref": "Tab",
+ "name": "tab",
+ "description": "Details of the tab that was created."
+ }
+ ]
+ },
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when a tab is updated.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "changeInfo",
+ "description": "Lists the changes to the state of the tab that was updated.",
+ "properties": {
+ "status": {
+ "type": "string",
+ "optional": true,
+ "description": "The status of the tab. Can be either <value>loading</value> or <value>complete</value>."
+ },
+ "url": {
+ "type": "string",
+ "optional": true,
+ "description": "The tab's URL if it has changed."
+ },
+ "favIconUrl": {
+ "type": "string",
+ "optional": true,
+ "description": "The tab's new favicon URL."
+ }
+ }
+ },
+ {
+ "$ref": "Tab",
+ "name": "tab",
+ "description": "Gives the state of the tab that was updated."
+ }
+ ],
+ "extraParameters": [
+ {
+ "$ref": "UpdateFilter",
+ "name": "filter",
+ "optional": true,
+ "description": "A set of filters that restricts the events that will be sent to this listener."
+ }
+ ]
+ },
+ {
+ "name": "onMoved",
+ "type": "function",
+ "description": "Fired when a tab is moved within a window. Only one move event is fired, representing the tab the user directly moved. Move events are not fired for the other tabs that must move in response. This event is not fired when a tab is moved between windows. For that, see :ref:`tabs.onDetached`.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "moveInfo",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "fromIndex": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "toIndex": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onActivated",
+ "type": "function",
+ "description": "Fires when the active tab in a window changes. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "activeInfo",
+ "properties": {
+ "tabId": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "The ID of the tab that has become active."
+ },
+ "previousTabId": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The ID of the tab that was previously active, if that tab is still open."
+ },
+ "windowId": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "The ID of the window the active tab changed inside of."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onDetached",
+ "type": "function",
+ "description": "Fired when a tab is detached from a window, for example because it is being moved between windows.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "detachInfo",
+ "properties": {
+ "oldWindowId": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "oldPosition": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onAttached",
+ "type": "function",
+ "description": "Fired when a tab is attached to a window, for example because it was moved between windows.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "attachInfo",
+ "properties": {
+ "newWindowId": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "newPosition": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onRemoved",
+ "type": "function",
+ "description": "Fired when a tab is closed.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ },
+ {
+ "type": "object",
+ "name": "removeInfo",
+ "properties": {
+ "windowId": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "The window whose tab is closed."
+ },
+ "isWindowClosing": {
+ "type": "boolean",
+ "description": "Is <value>true</value> when the tab is being closed because its window is being closed."
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/theme.json b/comm/mail/components/extensions/schemas/theme.json
new file mode 100644
index 0000000000..cba8abd780
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/theme.json
@@ -0,0 +1,542 @@
+// 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/.
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "PermissionNoPrompt",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["theme"]
+ }
+ ]
+ },
+ {
+ "id": "ThemeColor",
+ "description": "Defines a color value.",
+ "choices": [
+ {
+ "type": "string",
+ "description": "A string containing a valid `CSS color string <|link-css-color-string|>`__, including hexadecimal or functional representations. For example the color *crimson* can be specified as: <li><value>crimson</value> <li><value>#dc143c</value> <li><value>rgb(220, 20, 60)</value> (or <value>rgba(220, 20, 60, 0.5)</value> to set 50% opacity) <li><value>hsl(348, 83%, 47%)</value> (or <value>hsla(348, 83%, 47%, 0.5)</value> to set 50% opacity)"
+ },
+ {
+ "type": "array",
+ "description": "An RGB array of 3 integers. For example <value>[220, 20, 60]</value> for the color *crimson*.",
+ "minItems": 3,
+ "maxItems": 3,
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ }
+ },
+ {
+ "type": "array",
+ "description": "An RGBA array of 3 integers and a fractional (a float between 0 and 1). For example <value>[220, 20, 60, 0.5]<value> for the color *crimson* with 50% opacity.",
+ "minItems": 4,
+ "maxItems": 4,
+ "items": {
+ "type": "number"
+ }
+ }
+ ]
+ },
+ {
+ "id": "ThemeExperiment",
+ "description": "Defines additional color, image and property keys to be used in :ref:`theme.ThemeType`, extending the theme-able areas of Thunderbird.",
+ "type": "object",
+ "properties": {
+ "stylesheet": {
+ "optional": true,
+ "description": "URL to a stylesheet introducing additional CSS variables, extending the theme-able areas of Thunderbird. The `theme_experiment add-on in our example repository <https://github.com/thunderbird/sample-extensions/tree/master/theme_experiment>`__ is using the stylesheet shown below, to add the <value>--chat-button-color</value> CSS color variable: <literalinclude>includes/theme/theme_experiment_style.css<lang>CSS</lang></literalinclude>The following <em>manifest.json</em> file maps the </value>--chat-button-color</value> CSS color variable to the theme color key <value>exp_chat_button</value> and uses it to set a color for the chat button: <literalinclude>includes/theme/theme_experiment_manifest.json<lang>JSON</lang></literalinclude>",
+ "$ref": "ExtensionURL"
+ },
+ "images": {
+ "type": "object",
+ "optional": true,
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map new theme image keys to internal Thunderbird CSS image variables. The new image key is usable as an image reference in :ref:`theme.ThemeType`. Example: <literalinclude>includes/theme/theme_experiment_image.json<lang>JSON</lang></literalinclude>",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "colors": {
+ "type": "object",
+ "optional": true,
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map new theme color keys to internal Thunderbird CSS color variables. The example shown below maps the theme color key <value>popup_affordance</value> to the CSS color variable </value>--arrowpanel-dimmed</value>. The new color key is usable as a color reference in :ref:`theme.ThemeType`. <literalinclude>includes/theme/theme_experiment_color.json<lang>JSON</lang></literalinclude>",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "properties": {
+ "type": "object",
+ "optional": true,
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map new theme property keys to internal Thunderbird CSS property variables. The new property key is usable as a property reference in :ref:`theme.ThemeType`. Example: <literalinclude>includes/theme/theme_experiment_property.json<lang>JSON</lang></literalinclude>",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ {
+ "id": "ThemeType",
+ "description": "Contains the color, image and property settings of a theme.",
+ "type": "object",
+ "properties": {
+ "images": {
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map images to theme image keys. The following built-in theme image keys are supported:",
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "additional_backgrounds": {
+ "type": "array",
+ "items": {
+ "$ref": "ImageDataOrExtensionURL"
+ },
+ "maxItems": 15,
+ "optional": true,
+ "description": "Additional images added to the header area and displayed behind the ``theme_frame`` image."
+ },
+ "headerURL": {
+ "$ref": "ImageDataOrExtensionURL",
+ "optional": true,
+ "deprecated": "Unsupported images property, use ``theme.images.theme_frame``, this alias is ignored in Thunderbird >= 70."
+ },
+ "theme_frame": {
+ "$ref": "ImageDataOrExtensionURL",
+ "optional": true,
+ "description": "Foreground image on the header area."
+ }
+ },
+ "additionalProperties": {
+ "$ref": "ImageDataOrExtensionURL"
+ }
+ },
+ "colors": {
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map color values to theme color keys. The following built-in theme color keys are supported:",
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "tab_selected": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "Background color of the selected tab. Defaults to the color specified by ``toolbar``."
+ },
+ "accentcolor": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "deprecated": "Unsupported colors property, use ``theme.colors.frame``, this alias is ignored in Thunderbird >= 70."
+ },
+ "frame": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of the header area."
+ },
+ "frame_inactive": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of the header area when the window is inactive."
+ },
+ "textcolor": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "deprecated": "Unsupported color property, use ``theme.colors.tab_background_text``, this alias is ignored in Thunderbird >= 70."
+ },
+ "tab_background_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color of the unselected tabs."
+ },
+ "tab_background_separator": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the vertical separator of the background tabs."
+ },
+ "tab_loading": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the tab loading indicator."
+ },
+ "tab_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color for the selected tab. Defaults to the color specified by ``toolbar_text``."
+ },
+ "tab_line": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the selected tab line."
+ },
+ "toolbar": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of the toolbars. Also used as default value for ``tab_selected``."
+ },
+ "toolbar_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color in the main Thunderbird toolbar. Also used as default value for ``icons`` and ``tab_text``."
+ },
+ "bookmark_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "Not used in Thunderbird."
+ },
+ "toolbar_field": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color for fields in the toolbar, such as the search field."
+ },
+ "toolbar_field_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color for fields in the toolbar."
+ },
+ "toolbar_field_border": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The border color for fields in the toolbar."
+ },
+ "toolbar_field_separator": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "Not used in Thunderbird.",
+ "deprecated": "This color property is ignored in >= 89."
+ },
+ "toolbar_top_separator": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the line separating the top of the toolbar from the region above."
+ },
+ "toolbar_bottom_separator": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the line separating the bottom of the toolbar from the region below."
+ },
+ "toolbar_vertical_separator": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the vertical separators on the toolbars."
+ },
+ "icons": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the toolbar icons. Defaults to the color specified by ``toolbar_text``."
+ },
+ "icons_attention": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the toolbar icons in attention state such as the chat icon with new messages."
+ },
+ "button_background_hover": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the background of the toolbar buttons on hover."
+ },
+ "button_background_active": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color of the background of the pressed toolbar buttons."
+ },
+ "popup": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of popups such as the AppMenu."
+ },
+ "popup_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color of popups."
+ },
+ "popup_border": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The border color of popups."
+ },
+ "toolbar_field_focus": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The focused background color for fields in the toolbar."
+ },
+ "toolbar_field_text_focus": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color in the focused fields in the toolbar."
+ },
+ "toolbar_field_border_focus": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The focused border color for fields in the toolbar."
+ },
+ "popup_highlight": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of items highlighted using the keyboard inside popups."
+ },
+ "popup_highlight_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color of items highlighted using the keyboard inside popups."
+ },
+ "ntp_background": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "Not used in Thunderbird."
+ },
+ "ntp_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "Not used in Thunderbird."
+ },
+ "sidebar": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of the trees."
+ },
+ "sidebar_border": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The border color of the trees."
+ },
+ "sidebar_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color of the trees. Needed to enable the tree theming."
+ },
+ "sidebar_highlight": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color of highlighted rows in trees."
+ },
+ "sidebar_highlight_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The text color of highlighted rows in trees."
+ },
+ "sidebar_highlight_border": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The border color of highlighted rows in trees."
+ },
+ "toolbar_field_highlight": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The background color used to indicate the current selection of text in the search field."
+ },
+ "toolbar_field_highlight_text": {
+ "$ref": "ThemeColor",
+ "optional": true,
+ "description": "The color used to draw text that's currently selected in the search field."
+ }
+ },
+ "additionalProperties": {
+ "$ref": "ThemeColor"
+ }
+ },
+ "properties": {
+ "description": "A <em>dictionary object</em> with one or more <em>key-value</em> pairs to map property values to theme property keys. The following built-in theme property keys are supported:",
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "additional_backgrounds_alignment": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "bottom",
+ "center",
+ "left",
+ "right",
+ "top",
+ "center bottom",
+ "center center",
+ "center top",
+ "left bottom",
+ "left center",
+ "left top",
+ "right bottom",
+ "right center",
+ "right top"
+ ]
+ },
+ "maxItems": 15,
+ "optional": true
+ },
+ "additional_backgrounds_tiling": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["no-repeat", "repeat", "repeat-x", "repeat-y"]
+ },
+ "maxItems": 15,
+ "optional": true
+ },
+ "color_scheme": {
+ "description": "If set, overrides the general theme (context menus, toolbars, content area).",
+ "optional": true,
+ "type": "string",
+ "enum": ["light", "dark", "auto"]
+ },
+ "content_color_scheme": {
+ "description": "If set, overrides the color scheme for the content area.",
+ "optional": true,
+ "type": "string",
+ "enum": ["light", "dark", "auto"]
+ }
+ },
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ }
+ },
+ {
+ "id": "ThemeManifest",
+ "type": "object",
+ "description": "Contents of manifest.json for a static theme",
+ "$import": "manifest.ManifestBase",
+ "properties": {
+ "theme": {
+ "$ref": "ThemeType"
+ },
+ "dark_theme": {
+ "$ref": "ThemeType",
+ "optional": true,
+ "description": "Fallback properties for the dark system theme."
+ },
+ "default_locale": {
+ "type": "string",
+ "optional": true
+ },
+ "theme_experiment": {
+ "$ref": "ThemeExperiment",
+ "optional": true,
+ "description": "CSS file with additional styles."
+ },
+ "icons": {
+ "type": "object",
+ "optional": true,
+ "patternProperties": {
+ "^[1-9]\\d*$": {
+ "type": "string"
+ }
+ },
+ "description": "Icons shown in the Add-ons Manager."
+ }
+ }
+ },
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "theme_experiment": {
+ "$ref": "ThemeExperiment",
+ "optional": true,
+ "description": "A theme experiment allows modifying the user interface of Thunderbird beyond what is currently possible using the built-in color, image and property keys of :ref:`theme.ThemeType`. These experiments are a precursor to proposing new theme features for inclusion in Thunderbird. Experimentation is done by mapping internal CSS color, image and property variables to new theme keys and using them in :ref:`theme.ThemeType` and by loading additional style sheets to add new CSS variables, extending the theme-able areas of Thunderbird. Can be used in static and dynamic themes."
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "theme",
+ "description": "The theme API allows for customization of Thunderbird's visual elements.",
+ "types": [
+ {
+ "id": "ThemeUpdateInfo",
+ "type": "object",
+ "description": "Info provided in the onUpdated listener.",
+ "properties": {
+ "theme": {
+ "$ref": "ThemeType",
+ "description": "The new theme after update"
+ },
+ "windowId": {
+ "type": "integer",
+ "description": "The id of the window the theme has been applied to",
+ "optional": true
+ }
+ }
+ }
+ ],
+ "events": [
+ {
+ "name": "onUpdated",
+ "type": "function",
+ "description": "Fired when a new theme has been applied",
+ "parameters": [
+ {
+ "$ref": "ThemeUpdateInfo",
+ "name": "updateInfo",
+ "description": "Details of the theme update"
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "getCurrent",
+ "type": "function",
+ "async": "callback",
+ "description": "Returns the current theme for the specified window or the last focused window.",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "optional": true,
+ "description": "The window for which we want the theme."
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "$ref": "ThemeType"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "async": true,
+ "description": "Make complete updates to the theme. Resolves when the update has completed.",
+ "permissions": ["theme"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "optional": true,
+ "description": "The id of the window to update. No id updates all windows."
+ },
+ {
+ "name": "details",
+ "$ref": "manifest.ThemeType",
+ "description": "The properties of the theme to update."
+ }
+ ]
+ },
+ {
+ "name": "reset",
+ "type": "function",
+ "async": true,
+ "description": "Removes the updates made to the theme.",
+ "permissions": ["theme"],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "optional": true,
+ "description": "The id of the window to reset. No id resets all windows."
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/schemas/windows.json b/comm/mail/components/extensions/schemas/windows.json
new file mode 100644
index 0000000000..129364e155
--- /dev/null
+++ b/comm/mail/components/extensions/schemas/windows.json
@@ -0,0 +1,511 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+[
+ {
+ "namespace": "windows",
+ "description": "The windows API supports creating, modifying and interacting with Thunderbird windows.",
+ "types": [
+ {
+ "id": "WindowType",
+ "type": "string",
+ "description": "The type of a window. Under some circumstances a window may not be assigned a type property.",
+ "enum": ["normal", "popup", "messageCompose", "messageDisplay"]
+ },
+ {
+ "id": "WindowState",
+ "type": "string",
+ "description": "The state of this window.",
+ "enum": ["normal", "minimized", "maximized", "fullscreen", "docked"]
+ },
+ {
+ "id": "Window",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "description": "The ID of the window. Window IDs are unique within a session."
+ },
+ "focused": {
+ "type": "boolean",
+ "description": "Whether the window is currently the focused window."
+ },
+ "top": {
+ "type": "integer",
+ "optional": true,
+ "description": "The offset of the window from the top edge of the screen in pixels."
+ },
+ "left": {
+ "type": "integer",
+ "optional": true,
+ "description": "The offset of the window from the left edge of the screen in pixels."
+ },
+ "width": {
+ "type": "integer",
+ "optional": true,
+ "description": "The width of the window, including the frame, in pixels."
+ },
+ "height": {
+ "type": "integer",
+ "optional": true,
+ "description": "The height of the window, including the frame, in pixels."
+ },
+ "tabs": {
+ "type": "array",
+ "items": {
+ "$ref": "tabs.Tab"
+ },
+ "optional": true,
+ "description": "Array of :ref:`tabs.Tab` objects representing the current tabs in the window. Only included if requested by :ref:`windows.get`, :ref:`windows.getCurrent`, :ref:`windows.getAll` or :ref:`windows.getLastFocused`, and the optional :ref:`windows.GetInfo` parameter has its ``populate`` member set to <value>true</value>."
+ },
+ "incognito": {
+ "type": "boolean",
+ "description": "Whether the window is incognito. Since Thunderbird does not support the incognito mode, this is always <value>false</value>."
+ },
+ "type": {
+ "$ref": "WindowType",
+ "optional": true,
+ "description": "The type of window this is."
+ },
+ "state": {
+ "$ref": "WindowState",
+ "optional": true,
+ "description": "The state of this window."
+ },
+ "alwaysOnTop": {
+ "type": "boolean",
+ "description": "Whether the window is set to be always on top."
+ },
+ "title": {
+ "type": "string",
+ "optional": true,
+ "description": "The title of the window. Read-only."
+ }
+ }
+ },
+ {
+ "id": "CreateType",
+ "type": "string",
+ "description": "Specifies what type of window to create. Thunderbird does not support <value>panel</value> and <value>detached_panel</value>, they are interpreted as <value>popup</value>.",
+ "enum": ["normal", "popup", "panel", "detached_panel"]
+ },
+ {
+ "id": "GetInfo",
+ "type": "object",
+ "description": "Specifies additional requirements for the returned windows.",
+ "properties": {
+ "populate": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If true, the :ref:`windows.Window` returned will have a ``tabs`` property that contains an array of :ref:`tabs.Tab` objects representing the tabs inside the window. The :ref:`tabs.Tab` objects only contain the ``url``, ``title`` and ``favIconUrl`` properties if the extension's manifest file includes the <permission>tabs</permission> permission."
+ },
+ "windowTypes": {
+ "type": "array",
+ "items": {
+ "$ref": "WindowType"
+ },
+ "optional": true,
+ "description": "If set, the :ref:`windows.Window` returned will be filtered based on its type. Supported by :ref:`windows.getAll` only, ignored in all other functions."
+ }
+ }
+ }
+ ],
+ "properties": {
+ "WINDOW_ID_NONE": {
+ "value": -1,
+ "description": "The windowId value that represents the absence of a window."
+ },
+ "WINDOW_ID_CURRENT": {
+ "value": -2,
+ "description": "The windowId value that represents the current window."
+ }
+ },
+ "functions": [
+ {
+ "name": "get",
+ "type": "function",
+ "description": "Gets details about a window.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "minimum": -2
+ },
+ {
+ "$ref": "GetInfo",
+ "name": "getInfo",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "window",
+ "$ref": "Window"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getCurrent",
+ "type": "function",
+ "description": "Gets the active or topmost window.",
+ "async": "callback",
+ "parameters": [
+ {
+ "$ref": "GetInfo",
+ "name": "getInfo",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "window",
+ "$ref": "Window"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getLastFocused",
+ "type": "function",
+ "description": "Gets the window that was most recently focused &mdash; typically the window 'on top'.",
+ "async": "callback",
+ "parameters": [
+ {
+ "$ref": "GetInfo",
+ "name": "getInfo",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "window",
+ "$ref": "Window"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "getAll",
+ "type": "function",
+ "description": "Gets all windows.",
+ "async": "callback",
+ "parameters": [
+ {
+ "$ref": "GetInfo",
+ "name": "getInfo",
+ "optional": true
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": [
+ {
+ "name": "windows",
+ "type": "array",
+ "items": {
+ "$ref": "Window"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "create",
+ "type": "function",
+ "description": "Creates (opens) a new window with any optional sizing, position or default URL provided. When loading a page into a popup window, same-site links are opened within the same window, all other links are opened in the user's default browser. To override this behavior, add-ons have to register a `content script <https://bugzilla.mozilla.org/show_bug.cgi?id=1618828#c3>`__ , capture click events and handle them manually. Same-site links with targets other than <value>_self</value> are opened in a new tab in the most recent ``normal`` Thunderbird window.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "object",
+ "name": "createData",
+ "optional": true,
+ "default": {},
+ "properties": {
+ "url": {
+ "description": "A URL or array of URLs to open as tabs in the window. Fully-qualified URLs must include a scheme (i.e. <value>http://www.google.com</value>, not <value>www.google.com</value>). Relative URLs will be relative to the current page within the extension. Defaults to the New Tab Page.",
+ "optional": true,
+ "choices": [
+ {
+ "type": "string",
+ "format": "relativeUrl"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "relativeUrl"
+ }
+ }
+ ]
+ },
+ "tabId": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The id of the tab for which you want to adopt to the new window."
+ },
+ "left": {
+ "type": "integer",
+ "optional": true,
+ "description": "The number of pixels to position the new window from the left edge of the screen. If not specified, the new window is offset naturally from the last focused window."
+ },
+ "top": {
+ "type": "integer",
+ "optional": true,
+ "description": "The number of pixels to position the new window from the top edge of the screen. If not specified, the new window is offset naturally from the last focused window."
+ },
+ "width": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The width in pixels of the new window, including the frame. If not specified defaults to a natural width."
+ },
+ "height": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The height in pixels of the new window, including the frame. If not specified defaults to a natural height."
+ },
+ "focused": {
+ "unsupported": true,
+ "type": "boolean",
+ "optional": true,
+ "description": "If true, opens an active window. If false, opens an inactive window."
+ },
+ "incognito": {
+ "unsupported": true,
+ "type": "boolean",
+ "optional": true
+ },
+ "type": {
+ "$ref": "CreateType",
+ "optional": true,
+ "description": "Specifies what type of window to create. Thunderbird does not support <value>panel</value> and <value>detached_panel</value>, they are interpreted as <value>popup</value>."
+ },
+ "state": {
+ "$ref": "WindowState",
+ "optional": true,
+ "description": "The initial state of the window. The ``minimized``, ``maximized`` and ``fullscreen`` states cannot be combined with ``left``, ``top``, ``width`` or ``height``."
+ },
+ "allowScriptsToClose": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Allow scripts running inside the window to close the window by calling <code>window.close()</code>."
+ },
+ "cookieStoreId": {
+ "type": "string",
+ "optional": true,
+ "description": "The CookieStoreId to use for all tabs that were created when the window is opened."
+ },
+ "titlePreface": {
+ "type": "string",
+ "optional": true,
+ "description": "A string to add to the beginning of the window title."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "window",
+ "$ref": "Window",
+ "description": "Contains details about the created window.",
+ "optional": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "update",
+ "type": "function",
+ "description": "Updates the properties of a window. Specify only the properties that you want to change; unspecified properties will be left unchanged.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "minimum": -2
+ },
+ {
+ "type": "object",
+ "name": "updateInfo",
+ "properties": {
+ "left": {
+ "type": "integer",
+ "optional": true,
+ "description": "The offset from the left edge of the screen to move the window to in pixels. This value is ignored for panels."
+ },
+ "top": {
+ "type": "integer",
+ "optional": true,
+ "description": "The offset from the top edge of the screen to move the window to in pixels. This value is ignored for panels."
+ },
+ "width": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The width to resize the window to in pixels."
+ },
+ "height": {
+ "type": "integer",
+ "minimum": 0,
+ "optional": true,
+ "description": "The height to resize the window to in pixels."
+ },
+ "focused": {
+ "type": "boolean",
+ "optional": true,
+ "description": "If true, brings the window to the front. If false, brings the next window in the z-order to the front."
+ },
+ "drawAttention": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Setting this to <value>true</value> will cause the window to be displayed in a manner that draws the user's attention to the window, without changing the focused window. The effect lasts until the user changes focus to the window. This option has no effect if the window already has focus."
+ },
+ "state": {
+ "$ref": "WindowState",
+ "optional": true,
+ "description": "The new state of the window. The ``minimized``, ``maximized`` and ``fullscreen`` states cannot be combined with ``left``, ``top``, ``width`` or ``height``."
+ },
+ "titlePreface": {
+ "type": "string",
+ "optional": true,
+ "description": "A string to add to the beginning of the window title."
+ }
+ }
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "window",
+ "$ref": "Window"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "remove",
+ "type": "function",
+ "description": "Removes (closes) a window, and all the tabs inside it.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "minimum": -2
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "openDefaultBrowser",
+ "type": "function",
+ "description": "Opens the provided URL in the default system browser.",
+ "async": true,
+ "parameters": [
+ {
+ "type": "string",
+ "name": "url"
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onCreated",
+ "type": "function",
+ "description": "Fired when a window is created.",
+ "filters": [
+ {
+ "name": "windowTypes",
+ "type": "array",
+ "items": {
+ "$ref": "WindowType"
+ },
+ "description": "Conditions that the window's type being created must satisfy. By default it will satisfy <value>['app', 'normal', 'panel', 'popup']</value>, with <value>app</value> and <value>panel</value> window types limited to the extension's own windows."
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "Window",
+ "name": "window",
+ "description": "Details of the window that was created."
+ }
+ ]
+ },
+ {
+ "name": "onRemoved",
+ "type": "function",
+ "description": "Fired when a window is removed (closed).",
+ "filters": [
+ {
+ "name": "windowTypes",
+ "type": "array",
+ "items": {
+ "$ref": "WindowType"
+ },
+ "description": "Conditions that the window's type being removed must satisfy. By default it will satisfy <value>['app', 'normal', 'panel', 'popup']</value>, with <value>app</value> and <value>panel</value> window types limited to the extension's own windows."
+ }
+ ],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "minimum": 0,
+ "description": "ID of the removed window."
+ }
+ ]
+ },
+ {
+ "name": "onFocusChanged",
+ "type": "function",
+ "description": "Fired when the currently focused window changes. Will be :ref:`windows.WINDOW_ID_NONE`, if all windows have lost focus. **Note:** On some Linux window managers, WINDOW_ID_NONE will always be sent immediately preceding a switch from one window to another.",
+ "filters": [
+ {
+ "name": "windowTypes",
+ "type": "array",
+ "items": {
+ "$ref": "WindowType"
+ },
+ "description": "Conditions that the window's type being focused must satisfy. By default it will satisfy <value>['app', 'normal', 'panel', 'popup']</value>, with <value>app</value> and <value>panel</value> window types limited to the extension's own windows."
+ }
+ ],
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "windowId",
+ "minimum": -1,
+ "description": "ID of the newly focused window."
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/comm/mail/components/extensions/test/AppUiTestDelegate.sys.mjs b/comm/mail/components/extensions/test/AppUiTestDelegate.sys.mjs
new file mode 100644
index 0000000000..5320b0b6d7
--- /dev/null
+++ b/comm/mail/components/extensions/test/AppUiTestDelegate.sys.mjs
@@ -0,0 +1,6 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// TODO bug 1836863: Implement AppUiTestDelegate.
+
+export var AppUiTestDelegate = {};
diff --git a/comm/mail/components/extensions/test/browser/.eslintrc.js b/comm/mail/components/extensions/test/browser/.eslintrc.js
new file mode 100644
index 0000000000..e57058ecb1
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ env: {
+ webextensions: true,
+ },
+};
diff --git a/comm/mail/components/extensions/test/browser/browser.ini b/comm/mail/components/extensions/test/browser/browser.ini
new file mode 100644
index 0000000000..1bd2925968
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser.ini
@@ -0,0 +1,135 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spellcheck.inline=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.message_display.disable_remote_image=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files =
+ head_menus.js
+ test_browserAction.js
+ ../xpcshell/data/utils.js
+tags = webextensions
+
+[browser_ext_addressBooksUI.js]
+tags = addrbook
+[browser_ext_bug1812530.js]
+support-files = data/content.html
+tags = contextmenu
+[browser_ext_browserAction_customized.js]
+[browser_ext_browserAction_not_customized.js]
+[browser_ext_browserAction_popup_click.js]
+[browser_ext_browserAction_popup_click_mv3_event_pages.js]
+[browser_ext_browserAction_properties.js]
+[browser_ext_clickHandler.js]
+support-files = data/content.html data/linktest.html messages/messageWithLink.eml
+[browser_ext_cloudFile.js]
+support-files = data/cloudFile1.txt data/cloudFile2.txt
+[browser_ext_commands_execute_browser_action.js]
+[browser_ext_commands_execute_compose_action.js]
+[browser_ext_commands_execute_message_display_action.js]
+[browser_ext_commands_getAll.js]
+[browser_ext_commands_onChanged.js]
+[browser_ext_commands_onCommand.js]
+[browser_ext_commands_onCommand_bug1845236.js]
+[browser_ext_commands_update.js]
+[browser_ext_compose_attachments.js]
+[browser_ext_compose_begin_attachments.js]
+[browser_ext_compose_begin_body.js]
+[browser_ext_compose_begin_bug1691254.js]
+[browser_ext_compose_begin_forward.js]
+[browser_ext_compose_begin_headers.js]
+[browser_ext_compose_begin_identity.js]
+[browser_ext_compose_begin_new.js]
+[browser_ext_compose_begin_reply.js]
+[browser_ext_compose_details.js]
+[browser_ext_compose_details_headers.js]
+[browser_ext_compose_details_body.js]
+[browser_ext_compose_bug1692439.js]
+[browser_ext_compose_bug1804796.js]
+[browser_ext_compose_dictionaries.js]
+[browser_ext_compose_onBeforeSend.js]
+[browser_ext_compose_saveDraft.js]
+[browser_ext_compose_saveTemplate.js]
+[browser_ext_compose_sendMessage.js]
+[browser_ext_composeAction.js]
+[browser_ext_composeAction_popup_click.js]
+[browser_ext_composeAction_popup_click_mv3_event_pages.js]
+[browser_ext_composeAction_properties.js]
+[browser_ext_composeScripts.js]
+[browser_ext_content_handler.js]
+[browser_ext_content_tabs_navigation_menu.js]
+support-files = data/content.html
+tags = contextmenu
+[browser_ext_contentScripts.js]
+[browser_ext_mailTabs_mv3.js]
+[browser_ext_mailTabs.js]
+[browser_ext_menus_context_action.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_compose.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_content.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_folder_pane.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_message_panes.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_tabs.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_context_tools_main_menu.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_message_one_attachment.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+[browser_ext_menus_message_two_attachments.js]
+support-files = data/content.html data/content_body.html data/tb-logo.png
+tags = contextmenu
+[browser_ext_menus_popup_action.js]
+[browser_ext_menus_replace_menu.js]
+tags = contextmenu
+[browser_ext_menus_replace_menu_context.js]
+tags = contextmenu
+[browser_ext_message_external.js]
+support-files = messages/attachedMessageSample.eml
+[browser_ext_messageDisplay.js]
+[browser_ext_messageDisplay_bug1827032.js]
+[browser_ext_messageDisplay_bug1828056.js]
+[browser_ext_messageDisplay_open_file.js]
+[browser_ext_messageDisplay_open_headerMessageId.js]
+[browser_ext_messageDisplay_open_messageId.js]
+[browser_ext_messageDisplayAction.js]
+[browser_ext_messageDisplayAction_popup_click.js]
+[browser_ext_messageDisplayAction_popup_click_mv3_event_pages.js]
+[browser_ext_messageDisplayAction_properties.js]
+[browser_ext_messageDisplayScripts.js]
+[browser_ext_messages_open_attachment.js]
+[browser_ext_quickFilter.js]
+[browser_ext_sessions.js]
+[browser_ext_spaces.js]
+[browser_ext_spacesToolbar.js]
+[browser_ext_tabs_content.js]
+[browser_ext_tabs_cookieStoreId.js]
+[browser_ext_tabs_events.js]
+[browser_ext_tabs_onCreated_bug1817872.js]
+[browser_ext_tabs_move.js]
+[browser_ext_tabs_query.js]
+[browser_ext_tabs_update_reload.js]
+[browser_ext_themes_onUpdated.js]
+[browser_ext_tooltip_in_extension_pages.js]
+[browser_ext_windows.js]
+[browser_ext_windows_bug1732559.js]
+[browser_ext_windows_create_normal_cookieStoreId.js]
+[browser_ext_windows_create_popup_cookieStoreId.js]
+[browser_ext_windows_events.js]
+[browser_ext_windows_types.js]
+
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_addressBooksUI.js b/comm/mail/components/extensions/test/browser/browser_ext_addressBooksUI.js
new file mode 100644
index 0000000000..4171bf47bf
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_addressBooksUI.js
@@ -0,0 +1,116 @@
+/* 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 function testUI() {
+ async function background() {
+ async function checkNumberOfAddressBookTabs(expectedNumberOfTabs) {
+ let addressBookTabs = await browser.tabs.query({ type: "addressBook" });
+ browser.test.assertEq(
+ expectedNumberOfTabs,
+ addressBookTabs.length,
+ "Should find the correct number of open address book tabs"
+ );
+ }
+
+ let addedTabs = new Set();
+ let removedTabs = 0;
+ function tabCreateListener(tab) {
+ if (tab.type == "addressBook") {
+ addedTabs.add(tab.id);
+ } else {
+ browser.test.fail(
+ "Should not receive a onTabCreated event for a non address book tab"
+ );
+ }
+ }
+
+ function tabRemoveListener(tabId) {
+ console.log("Remove: " + tabId);
+ if (addedTabs.has(tabId)) {
+ removedTabs++;
+ } else {
+ browser.test.fail(
+ "Should not receive a onTabRemoved event for a non address book tab"
+ );
+ }
+ }
+
+ browser.tabs.onCreated.addListener(tabCreateListener);
+ browser.tabs.onRemoved.addListener(tabRemoveListener);
+
+ await window.sendMessage("checkNumberOfAddressBookTabs", 0);
+ await checkNumberOfAddressBookTabs(0);
+
+ let abTab1 = await browser.addressBooks.openUI();
+ browser.test.log(JSON.stringify(abTab1));
+ browser.test.assertEq(
+ "addressBook",
+ abTab1.type,
+ "Should have found an addressBook tab"
+ );
+ await window.sendMessage("checkNumberOfAddressBookTabs", 1);
+ await checkNumberOfAddressBookTabs(1);
+
+ await browser.addressBooks.openUI();
+ let abTab2 = await browser.addressBooks.openUI();
+ browser.test.log(JSON.stringify(abTab2));
+ browser.test.assertEq(
+ "addressBook",
+ abTab2.type,
+ "Should have found an addressBook tab"
+ );
+ await window.sendMessage("checkNumberOfAddressBookTabs", 1);
+ await checkNumberOfAddressBookTabs(1);
+
+ browser.test.assertEq(
+ abTab1.id,
+ abTab2.id,
+ "addressBook tabs should be identical"
+ );
+
+ await browser.addressBooks.closeUI();
+ await window.sendMessage("checkNumberOfAddressBookTabs", 0);
+ await checkNumberOfAddressBookTabs(0);
+
+ browser.tabs.onCreated.removeListener(tabCreateListener);
+ browser.tabs.onRemoved.removeListener(tabRemoveListener);
+
+ browser.test.assertEq(
+ 1,
+ removedTabs,
+ "Should have seen the correct number of address book tabs being removed"
+ );
+
+ browser.test.assertEq(
+ 1,
+ addedTabs.size,
+ "Should have seen the correct number of address book tabs being added"
+ );
+
+ browser.test.notifyPass("addressBooks");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ extension.onMessage("checkNumberOfAddressBookTabs", count => {
+ let tabmail = document.getElementById("tabmail");
+ let tabs = tabmail.tabInfo.filter(
+ tab => tab.browser?.currentURI.spec == "about:addressbook"
+ );
+ Assert.equal(tabs.length, count, "Right number of address books open");
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_browserAction_customized.js b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_customized.js
new file mode 100644
index 0000000000..056fec372e
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_customized.js
@@ -0,0 +1,29 @@
+/* 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/. */
+
+async function enforceState(state) {
+ const stateChangeObserved = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ storeState(state);
+ await stateChangeObserved;
+}
+
+add_setup(async () => {
+ // Set a customized state for the spaces we are working with in this test.
+ await enforceState({
+ mail: ["spacer", "search-bar", "spacer"],
+ calendar: ["spacer", "search-bar", "spacer"],
+ });
+
+ registerCleanupFunction(async () => {
+ await enforceState({});
+ });
+});
+
+// Load browserAction tests.
+Services.scriptloader.loadSubScript(
+ new URL("test_browserAction.js", gTestPath).href,
+ this
+);
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_browserAction_not_customized.js b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_not_customized.js
new file mode 100644
index 0000000000..755e950a84
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_not_customized.js
@@ -0,0 +1,17 @@
+/* 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_setup(async () => {
+ Assert.equal(
+ 0,
+ Object.keys(getState()).length,
+ "Unified toolbar should not be customized"
+ );
+});
+
+// Load browserAction tests.
+Services.scriptloader.loadSubScript(
+ new URL("test_browserAction.js", gTestPath).href,
+ this
+);
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click.js b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click.js
new file mode 100644
index 0000000000..9b985a2c7a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click.js
@@ -0,0 +1,399 @@
+/* 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/. */
+
+let account;
+let messages;
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ messages = subFolders[0].messages;
+});
+
+// This test clicks on the action button to open the popup.
+add_task(async function test_popup_open_with_click() {
+ info("3-pane tab");
+ {
+ let testConfig = {
+ actionType: "browser_action",
+ testType: "open-with-mouse-click",
+ window,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ }
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ actionType: "browser_action",
+ testType: "open-with-mouse-click",
+ default_windows: ["messageDisplay"],
+ window: messageWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ messageWindow.close();
+ }
+});
+
+// This test uses openPopup() to open the popup in a normal window.
+add_task(async function test_popup_open_with_openPopup_in_normal_window() {
+ let files = {
+ "background.js": async () => {
+ let windows = await browser.windows.getAll();
+ let mailWindow = windows.find(window => window.type == "normal");
+ let messageWindow = windows.find(
+ window => window.type == "messageDisplay"
+ );
+ browser.test.assertTrue(!!mailWindow, "should have found a mailWindow");
+ browser.test.assertTrue(
+ !!messageWindow,
+ "should have found a messageWindow"
+ );
+
+ // The test starts with an opened messageWindow, the browser_action is not
+ // allowed there and should not be visible, openPopup() should fail.
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed while the messageWindow is active"
+ );
+
+ // Specifically open the browser_action of the mailWindow, should become
+ // focused and openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup({ windowId: mailWindow.id }),
+ "openPopup() should have succeeded when explicitly requesting the mailWindow"
+ );
+ await window.waitForMessage();
+ browser.test.assertTrue(
+ (await browser.windows.get(mailWindow.id)).focused,
+ "mailWindow should be focused"
+ );
+
+ // mailWindow is the topmost window now, openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded after the mailWindow has become active"
+ );
+ await window.waitForMessage();
+
+ // Create content tab, the browser_action is not allowed in that space and
+ // should not be visible, openPopup() should fail.
+ let contentTab = await browser.tabs.create({
+ url: "https://www.example.com",
+ });
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed while the content tab is active"
+ );
+
+ // Close the content tab and return to the mail space, the browser_action
+ // should be visible again, openPopup() should succeed.
+ await browser.tabs.remove(contentTab.id);
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded after the content tab was closed"
+ );
+ await window.waitForMessage();
+
+ // Disable the browser_action, openPopup() should fail.
+ await browser.browserAction.disable();
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed after the action_button was disabled"
+ );
+
+ // Enable the browser_action, openPopup() should succeed.
+ await browser.browserAction.enable();
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded after the action_button was enabled again"
+ );
+ await window.waitForMessage();
+
+ // Create a popup window, which does not have a browser_action, openPopup()
+ // should fail.
+ let popupWindow = await browser.windows.create({
+ type: "popup",
+ url: "https://www.example.com",
+ });
+ browser.test.assertTrue(
+ (await browser.windows.get(popupWindow.id)).focused,
+ "popupWindow should be focused"
+ );
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed while the popup window is active"
+ );
+
+ // Specifically open the browser_action of the mailWindow, should become
+ // focused and openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup({ windowId: mailWindow.id }),
+ "openPopup() should have succeeded when explicitly requesting the mailWindow"
+ );
+ await window.waitForMessage();
+ browser.test.assertTrue(
+ (await browser.windows.get(mailWindow.id)).focused,
+ "mailWindow should be focused"
+ );
+
+ // Close the popup window
+ await browser.windows.remove(popupWindow.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ browser.test.sendMessage("popup opened");
+ window.close();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_openPopup@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_action: {
+ default_title: "default",
+ default_popup: "popup.html",
+ },
+ },
+ });
+
+ extension.onMessage("popup opened", async () => {
+ // Wait a moment to make sure the popup has closed.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 150));
+ extension.sendMessage();
+ });
+
+ let messageWindow = await openMessageInWindow(messages.getNext());
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ messageWindow.close();
+});
+
+// This test adds the action button to the message window and not to the mail
+// window (the default_windows manifest property is set to ["messageDisplay"].
+// the test then uses openPopup() to open the popup in a message window.
+add_task(async function test_popup_open_with_openPopup_in_message_window() {
+ let files = {
+ "background.js": async () => {
+ let windows = await browser.windows.getAll();
+ let mailWindow = windows.find(window => window.type == "normal");
+ let messageWindow = windows.find(
+ window => window.type == "messageDisplay"
+ );
+ browser.test.assertTrue(!!mailWindow, "should have found a mailWindow");
+ browser.test.assertTrue(
+ !!messageWindow,
+ "should have found a messageWindow"
+ );
+
+ // The test starts with an opened messageWindow, the browser_action is allowed
+ // there and should be visible, openPopup() should succeed.
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded while the messageWindow is active"
+ );
+ await window.waitForMessage();
+
+ // Collapse the toolbar, openPopup() should fail.
+ await window.sendMessage("collapseToolbar", true);
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed while the toolbar is collapsed"
+ );
+
+ // Restore the toolbar, openPopup() should succeed.
+ await window.sendMessage("collapseToolbar", false);
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded after the toolbar is restored"
+ );
+ await window.waitForMessage();
+
+ // Specifically open the browser_action of the mailWindow, it should not be
+ // allowed there and openPopup() should fail.
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup({ windowId: mailWindow.id }),
+ "openPopup() should have failed when explicitly requesting the mailWindow"
+ );
+
+ // The messageWindow should still have focus, openPopup() should succeed.
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should still have succeeded while the messageWindow is active"
+ );
+ await window.waitForMessage();
+
+ // Disable the browser_action, openPopup() should fail.
+ await browser.browserAction.disable();
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed after the action_button was disabled"
+ );
+
+ // Enable the browser_action, openPopup() should succeed.
+ await browser.browserAction.enable();
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded after the action_button was enabled again"
+ );
+ await window.waitForMessage();
+
+ // Create a popup window, which does not have a browser_action, openPopup()
+ // should fail.
+ let popupWindow = await browser.windows.create({
+ type: "popup",
+ url: "https://www.example.com",
+ });
+ browser.test.assertTrue(
+ await browser.windows.get(popupWindow.id),
+ "popupWindow should be focused"
+ );
+ browser.test.assertFalse(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have failed while the popup window is active"
+ );
+
+ // Specifically open the browser_action of the messageWindow, should become
+ // focused and openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup({ windowId: messageWindow.id }),
+ "openPopup() should have succeeded when explicitly requesting the messageWindow"
+ );
+ await window.waitForMessage();
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+
+ // The messageWindow is focused now, openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.browserAction.openPopup(),
+ "openPopup() should have succeeded while the messageWindow is active"
+ );
+ await window.waitForMessage();
+
+ // Close the popup window and finish
+ await browser.windows.remove(popupWindow.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ browser.test.sendMessage("popup opened");
+ window.close();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_openPopup@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_action: {
+ default_title: "default",
+ default_popup: "popup.html",
+ default_windows: ["messageDisplay"],
+ },
+ },
+ });
+
+ extension.onMessage("popup opened", async () => {
+ // Wait a moment to make sure the popup has closed.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 150));
+ extension.sendMessage();
+ });
+
+ extension.onMessage("collapseToolbar", state => {
+ let window = Services.wm.getMostRecentWindow("mail:messageWindow");
+ let toolbar = window.document.getElementById("mail-bar3");
+ if (state) {
+ toolbar.setAttribute("collapsed", "true");
+ } else {
+ toolbar.removeAttribute("collapsed");
+ }
+ extension.sendMessage();
+ });
+
+ let messageWindow = await openMessageInWindow(messages.getNext());
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ messageWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click_mv3_event_pages.js b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click_mv3_event_pages.js
new file mode 100644
index 0000000000..a58e0077ef
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_popup_click_mv3_event_pages.js
@@ -0,0 +1,82 @@
+/* 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/. */
+
+let account;
+let subFolders;
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ await TestUtils.waitForCondition(
+ () => subFolders[0].messages.hasMoreElements(),
+ "Messages should be added to folder"
+ );
+});
+
+function getMessage() {
+ let messages = subFolders[0].messages;
+ ok(messages.hasMoreElements(), "Should have messages to iterate to");
+ return messages.getNext();
+}
+
+async function subtest_popup_open_with_click_MV3_event_pages(
+ terminateBackground
+) {
+ info("3-pane tab");
+ let testConfig = {
+ actionType: "action",
+ manifest_version: 3,
+ terminateBackground,
+ testType: "open-with-mouse-click",
+ window,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(getMessage());
+ let testConfig = {
+ actionType: "action",
+ manifest_version: 3,
+ terminateBackground,
+ testType: "open-with-mouse-click",
+ default_windows: ["messageDisplay"],
+ window: messageWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ messageWindow.close();
+ }
+}
+// This MV3 test clicks on the action button to open the popup.
+add_task(async function test_event_pages_without_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(false);
+});
+// This MV3 test clicks on the action button to open the popup (background termination).
+add_task(async function test_event_pages_with_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(true);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_browserAction_properties.js b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_properties.js
new file mode 100644
index 0000000000..18633c5715
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_browserAction_properties.js
@@ -0,0 +1,348 @@
+/* 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 () => {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+
+ let files = {
+ "background.js": async () => {
+ async function checkProperty(property, expectedDefault, ...expected) {
+ browser.test.log(
+ `${property}: ${expectedDefault}, ${expected.join(", ")}`
+ );
+
+ browser.test.assertEq(
+ expectedDefault,
+ await browser.browserAction[property]({}),
+ `Default value for ${property} should be correct`
+ );
+ for (let i = 0; i < 3; i++) {
+ browser.test.assertEq(
+ expected[i],
+ await browser.browserAction[property]({ tabId: tabIDs[i] }),
+ `Specific value for ${property} of tab #${i} should be correct`
+ );
+ }
+ }
+
+ async function checkRealState(property, ...expected) {
+ await window.sendMessage(whichTest, property, expected);
+ }
+
+ let tabs = await browser.mailTabs.query({});
+ browser.test.assertEq(3, tabs.length);
+ let tabIDs = tabs.map(t => t.id);
+
+ let whichTest = "checkProperty";
+
+ // Test enable property.
+ await checkProperty("isEnabled", true, true, true, true);
+ await checkRealState("enabled", true, true, true);
+ await browser.browserAction.disable();
+ await checkProperty("isEnabled", false, false, false, false);
+ await checkRealState("enabled", false, false, false);
+ await browser.browserAction.enable(tabIDs[0]);
+ await checkProperty("isEnabled", false, true, false, false);
+ await checkRealState("enabled", true, false, false);
+ await browser.browserAction.enable();
+ await checkProperty("isEnabled", true, true, true, true);
+ await checkRealState("enabled", true, true, true);
+ await browser.browserAction.disable();
+ await checkProperty("isEnabled", false, true, false, false);
+ await checkRealState("enabled", true, false, false);
+ await browser.browserAction.disable(tabIDs[0]);
+ await checkProperty("isEnabled", false, false, false, false);
+ await checkRealState("enabled", false, false, false);
+ await browser.browserAction.enable();
+ await checkProperty("isEnabled", true, false, true, true);
+ await checkRealState("enabled", false, true, true);
+
+ // Test title property (since a label has not been set, this sets the
+ // tooltip and the actual label of the button).
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+ await browser.browserAction.setTitle({ tabId: tabIDs[2], title: "tab2" });
+ await checkProperty("getTitle", "default", "default", "default", "tab2");
+ await checkRealState("tooltip", "default", "default", "tab2");
+ await checkRealState("label", "default", "default", "tab2");
+ await browser.browserAction.setTitle({ title: "new" });
+ await checkProperty("getTitle", "new", "new", "new", "tab2");
+ await checkRealState("tooltip", "new", "new", "tab2");
+ await checkRealState("label", "new", "new", "tab2");
+ await browser.browserAction.setTitle({ tabId: tabIDs[1], title: "tab1" });
+ await checkProperty("getTitle", "new", "new", "tab1", "tab2");
+ await checkRealState("tooltip", "new", "tab1", "tab2");
+ await checkRealState("label", "new", "tab1", "tab2");
+ await browser.browserAction.setTitle({ tabId: tabIDs[2], title: null });
+ await checkProperty("getTitle", "new", "new", "tab1", "new");
+ await checkRealState("tooltip", "new", "tab1", "new");
+ await checkRealState("label", "new", "tab1", "new");
+ await browser.browserAction.setTitle({ title: null });
+ await checkProperty("getTitle", "default", "default", "tab1", "default");
+ await checkRealState("tooltip", "default", "tab1", "default");
+ await checkRealState("label", "default", "tab1", "default");
+ await browser.browserAction.setTitle({ tabId: tabIDs[1], title: null });
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+
+ // Test label property (tooltip should not change).
+ await checkProperty("getLabel", null, null, null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+ await browser.browserAction.setLabel({ tabId: tabIDs[2], label: "" });
+ await checkProperty("getLabel", null, null, null, "");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "");
+ await browser.browserAction.setLabel({ tabId: tabIDs[2], label: "tab2" });
+ await checkProperty("getLabel", null, null, null, "tab2");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "tab2");
+ await browser.browserAction.setLabel({ label: "new" });
+ await checkProperty("getLabel", "new", "new", "new", "tab2");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "new", "new", "tab2");
+ await browser.browserAction.setLabel({ tabId: tabIDs[1], label: "tab1" });
+ await checkProperty("getLabel", "new", "new", "tab1", "tab2");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "new", "tab1", "tab2");
+ await browser.browserAction.setLabel({ tabId: tabIDs[2], label: null });
+ await checkProperty("getLabel", "new", "new", "tab1", "new");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "new", "tab1", "new");
+ await browser.browserAction.setLabel({ label: null });
+ await checkProperty("getLabel", null, null, "tab1", null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "tab1", "default");
+ await browser.browserAction.setLabel({ tabId: tabIDs[1], label: null });
+ await checkProperty("getLabel", null, null, null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+
+ // Check that properties are updated without switching tabs. We might be
+ // relying on the tab switch to update the properties.
+
+ // Tab 0's enabled state doesn't reflect the default any more, so we
+ // can't just run the code above again.
+
+ browser.test.log("checkPropertyCurrent");
+ whichTest = "checkPropertyCurrent";
+
+ // Test enable property.
+ await checkProperty("isEnabled", true, false, true, true);
+ await checkRealState("enabled", false, true, true);
+ await browser.browserAction.disable();
+ await checkProperty("isEnabled", false, false, false, false);
+ await checkRealState("enabled", false, false, false);
+ await browser.browserAction.enable(tabIDs[0]);
+ await checkProperty("isEnabled", false, true, false, false);
+ await checkRealState("enabled", true, false, false);
+ await browser.browserAction.enable();
+ await checkProperty("isEnabled", true, true, true, true);
+ await checkRealState("enabled", true, true, true);
+ await browser.browserAction.disable();
+ await checkProperty("isEnabled", false, true, false, false);
+ await checkRealState("enabled", true, false, false);
+ await browser.browserAction.disable(tabIDs[0]);
+ await checkProperty("isEnabled", false, false, false, false);
+ await checkRealState("enabled", false, false, false);
+ await browser.browserAction.enable();
+ await checkProperty("isEnabled", true, false, true, true);
+ await checkRealState("enabled", false, true, true);
+
+ // Test title property (since a label has not been set, this sets the
+ // tooltip and the actual label of the button).
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+ await browser.browserAction.setTitle({ tabId: tabIDs[0], title: "tab0" });
+ await checkProperty("getTitle", "default", "tab0", "default", "default");
+ await checkRealState("tooltip", "tab0", "default", "default");
+ await checkRealState("label", "tab0", "default", "default");
+ await browser.browserAction.setTitle({ title: "new" });
+ await checkProperty("getTitle", "new", "tab0", "new", "new");
+ await checkRealState("tooltip", "tab0", "new", "new");
+ await checkRealState("label", "tab0", "new", "new");
+ await browser.browserAction.setTitle({ tabId: tabIDs[1], title: "tab1" });
+ await checkProperty("getTitle", "new", "tab0", "tab1", "new");
+ await checkRealState("tooltip", "tab0", "tab1", "new");
+ await checkRealState("label", "tab0", "tab1", "new");
+ await browser.browserAction.setTitle({ tabId: tabIDs[0], title: null });
+ await checkProperty("getTitle", "new", "new", "tab1", "new");
+ await checkRealState("tooltip", "new", "tab1", "new");
+ await checkRealState("label", "new", "tab1", "new");
+ await browser.browserAction.setTitle({ title: null });
+ await checkProperty("getTitle", "default", "default", "tab1", "default");
+ await checkRealState("tooltip", "default", "tab1", "default");
+ await checkRealState("label", "default", "tab1", "default");
+ await browser.browserAction.setTitle({ tabId: tabIDs[1], title: null });
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+
+ // Test label property (tooltip should not change).
+ await checkProperty("getLabel", null, null, null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+ await browser.browserAction.setLabel({ tabId: tabIDs[0], label: "" });
+ await checkProperty("getLabel", null, "", null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "", "default", "default");
+ await browser.browserAction.setLabel({ tabId: tabIDs[0], label: "tab0" });
+ await checkProperty("getLabel", null, "tab0", null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "tab0", "default", "default");
+ await browser.browserAction.setLabel({ label: "new" });
+ await checkProperty("getLabel", "new", "tab0", "new", "new");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "tab0", "new", "new");
+ await browser.browserAction.setLabel({ tabId: tabIDs[1], label: "tab1" });
+ await checkProperty("getLabel", "new", "tab0", "tab1", "new");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "tab0", "tab1", "new");
+ await browser.browserAction.setLabel({ tabId: tabIDs[0], label: null });
+ await checkProperty("getLabel", "new", "new", "tab1", "new");
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "new", "tab1", "new");
+ await browser.browserAction.setLabel({ label: null });
+ await checkProperty("getLabel", null, null, "tab1", null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "tab1", "default");
+ await browser.browserAction.setLabel({ tabId: tabIDs[1], label: null });
+ await checkProperty("getLabel", null, null, null, null);
+ await checkRealState("tooltip", "default", "default", "default");
+ await checkRealState("label", "default", "default", "default");
+
+ await browser.tabs.remove(tabIDs[1]);
+ await browser.tabs.remove(tabIDs[2]);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_properties@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_action: {
+ default_title: "default",
+ },
+ },
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.openTab("mail3PaneTab", {
+ folderURI: rootFolder.URI,
+ background: false,
+ });
+ tabmail.openTab("mail3PaneTab", {
+ folderURI: rootFolder.URI,
+ background: false,
+ });
+
+ let mailTabs = tabmail.tabInfo;
+ is(mailTabs.length, 3, "Expect 3 tabs");
+ tabmail.switchToTab(mailTabs[0]);
+
+ await extension.startup();
+
+ let button = document.querySelector(
+ `.unified-toolbar [extension="browser_action_properties@mochi.test"]`
+ );
+
+ extension.onMessage("checkProperty", async (property, expected) => {
+ for (let i = 0; i < 3; i++) {
+ tabmail.switchToTab(mailTabs[i]);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ switch (property) {
+ case "enabled":
+ is(button.disabled, !expected[i], `button ${i} enabled state`);
+ break;
+ case "tooltip":
+ is(
+ button.getAttribute("title"),
+ expected[i],
+ `button ${i} tooltip title`
+ );
+ break;
+ case "label":
+ if (expected[i] == "") {
+ ok(
+ button.classList.contains("prefer-icon-only"),
+ `button ${i} has hidden label`
+ );
+ } else {
+ is(button.getAttribute("label"), expected[i], `button ${i} label`);
+ }
+ break;
+ }
+ }
+
+ tabmail.switchToTab(mailTabs[0]);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkPropertyCurrent", async (property, expected) => {
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ switch (property) {
+ case "enabled":
+ is(button.disabled, !expected[0], `button 0 enabled state`);
+ break;
+ case "tooltip":
+ is(button.getAttribute("title"), expected[0], `button 0 tooltip title`);
+ break;
+ case "label":
+ if (expected[0] == "") {
+ ok(
+ button.classList.contains("prefer-icon-only"),
+ `button 0 has hidden label`
+ );
+ } else {
+ is(button.getAttribute("label"), expected[0], `button 0 label`);
+ }
+ break;
+ }
+
+ extension.sendMessage();
+ });
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ tabmail.closeTab(mailTabs[2]);
+ tabmail.closeTab(mailTabs[1]);
+ is(tabmail.tabInfo.length, 1);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_bug1812530.js b/comm/mail/components/extensions/test/browser/browser_ext_bug1812530.js
new file mode 100644
index 0000000000..1042ae5bbf
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_bug1812530.js
@@ -0,0 +1,200 @@
+/* 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/. *
+ */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(protocolScheme) {},
+ getApplicationDescription(scheme) {},
+ getProtocolHandlerInfo(protocolScheme) {},
+ getProtocolHandlerInfoFromOS(protocolScheme, found) {},
+ isExposedProtocol(protocolScheme) {},
+ loadURI(uri, windowContext) {
+ this._loadedURLs.push(uri.spec);
+ },
+ setProtocolHandlerDefaults(handlerInfo, osHandlerExists) {},
+ urlLoaded(url) {
+ return this._loadedURLs.includes(url);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+add_setup(async () => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+const subtest_clickOpenInBrowserContextMenu = async (extension, getBrowser) => {
+ async function contextClick(elementSelector, browser) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+
+ let menuId = browser.getAttribute("context");
+ let menu = browser.ownerGlobal.top.document.getElementById(menuId);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ await rightClickOnContent(menu, elementSelector, browser);
+ Assert.ok(
+ menu.querySelector("#browserContext-openInBrowser"),
+ "menu item should exist"
+ );
+ menu.activateItem(menu.querySelector("#browserContext-openInBrowser"));
+ await hiddenPromise;
+ }
+
+ await extension.startup();
+
+ // Wait for click on #description
+ {
+ let { elementSelector, url } = await extension.awaitMessage("contextClick");
+ Assert.equal(
+ "#description",
+ elementSelector,
+ `Test should click on the correct element.`
+ );
+ Assert.equal(
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html",
+ url,
+ `Test should open the correct page.`
+ );
+ await contextClick(elementSelector, getBrowser());
+ Assert.ok(
+ mockExternalProtocolService.urlLoaded(url),
+ `Page should have correctly been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ await extension.awaitFinish();
+ await extension.unload();
+};
+
+add_task(async function test_tabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "utils.js": await getUtilsJS(),
+ "background.js": async () => {
+ // Open remote file and re-open it in the browser.
+ const url =
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html";
+ const elementSelector = "#description";
+
+ let testTab = await browser.tabs.create({ url });
+ await window.sendMessage("contextClick", { elementSelector, url });
+ await browser.tabs.remove(testTab.id);
+
+ browser.test.notifyPass();
+ },
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+});
+
+add_task(async function test_windows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "utils.js": await getUtilsJS(),
+ "background.js": async () => {
+ // Open remote file and re-open it in the browser.
+ const url =
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html";
+ const elementSelector = "#description";
+
+ let testWindow = await browser.windows.create({ type: "popup", url });
+ await window.sendMessage("contextClick", { elementSelector, url });
+ await browser.windows.remove(testWindow.id);
+
+ browser.test.notifyPass();
+ },
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => Services.wm.getMostRecentWindow("mail:extensionPopup").browser
+ );
+});
+
+add_task(async function test_mail3pane() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "utils.js": await getUtilsJS(),
+ "background.js": async () => {
+ // Open remote file and re-open it in the browser.
+ const url =
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html";
+ const elementSelector = "#description";
+
+ let mailTabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(
+ 1,
+ mailTabs.length,
+ "Should find a single mailTab"
+ );
+ await browser.tabs.update(mailTabs[0].id, { url });
+ await window.sendMessage("contextClick", { elementSelector, url });
+
+ browser.test.notifyPass();
+ },
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_clickHandler.js b/comm/mail/components/extensions/test/browser/browser_ext_clickHandler.js
new file mode 100644
index 0000000000..504de75218
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_clickHandler.js
@@ -0,0 +1,614 @@
+/* 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/. */
+
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(protocolScheme) {},
+ getApplicationDescription(scheme) {},
+ getProtocolHandlerInfo(protocolScheme) {},
+ getProtocolHandlerInfoFromOS(protocolScheme, found) {},
+ isExposedProtocol(protocolScheme) {},
+ loadURI(uri, windowContext) {
+ this._loadedURLs.push(uri.spec);
+ },
+ setProtocolHandlerDefaults(handlerInfo, osHandlerExists) {},
+ urlLoaded(url) {
+ let rv = this._loadedURLs.length == 1 && this._loadedURLs[0] == url;
+ this._loadedURLs = [];
+ return rv;
+ },
+ hasAnyUrlLoaded() {
+ let rv = this._loadedURLs.length > 0;
+ this._loadedURLs = [];
+ return rv;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+const getCommonFiles = async () => {
+ return {
+ "utils.js": await getUtilsJS(),
+ "common.js": () => {
+ window.CreateTabPromise = class {
+ constructor() {
+ this.promise = new Promise(resolve => {
+ let createListener = tab => {
+ browser.tabs.onCreated.removeListener(createListener);
+ resolve(tab);
+ };
+ browser.tabs.onCreated.addListener(createListener);
+ });
+ }
+ async done() {
+ return this.promise;
+ }
+ };
+
+ window.UpdateTabPromise = class {
+ constructor() {
+ this.promise = new Promise(resolve => {
+ let log = {};
+ let updateListener = (tabId, changes, tab) => {
+ if (changes.url == "about:blank") {
+ // Reset whatever we have seen so far.
+ log = {};
+ } else {
+ if (changes.url) {
+ log.url = changes.url;
+ }
+ if (changes.status == "loading") {
+ log.loading = true;
+ }
+ // The complete is only valid, if we seen a url (which was not
+ // "about:blank")
+ if (log.url && changes.status == "complete") {
+ log.complete = true;
+ }
+ }
+ if (log.id && log.id != tabId) {
+ browser.test.fail(
+ "Should not receive update events for multiple tabs"
+ );
+ }
+ log.id = tabId;
+
+ if (log.url && log.loading && log.complete) {
+ browser.tabs.onUpdated.removeListener(updateListener);
+ resolve(log);
+ }
+ };
+ browser.tabs.onUpdated.addListener(updateListener);
+ });
+ }
+ async verify(id, url) {
+ // The updatePromise resolves after we have seen both states (loading
+ // and complete) and a url.
+ let updateLog = await this.promise;
+ browser.test.assertEq(
+ id,
+ updateLog.id,
+ "Updates must belong to the current tab"
+ );
+ browser.test.assertEq(
+ url,
+ updateLog.url,
+ "Should have seen the correct url loaded."
+ );
+ }
+ };
+ },
+ "background.js": async () => {
+ let expectedLinkHandler = await window.sendMessage("expectedLinkHandler");
+
+ // Open local file and click link to a different site.
+ await window.expectLinkOpenInExternalBrowser(
+ browser.runtime.getURL("test.html"),
+ "#link1",
+ "https://www.example.de/"
+ );
+
+ // Open local file and click same site link (no target).
+ await window.expectLinkOpenInSameTab(
+ browser.runtime.getURL("test.html"),
+ "#link2",
+ browser.runtime.getURL("example.html")
+ );
+
+ // Open local file and click same site link ("_self" target).
+ await window.expectLinkOpenInSameTab(
+ browser.runtime.getURL("test.html"),
+ "#link3",
+ browser.runtime.getURL("example.html#self")
+ );
+
+ // Open local file and click same site link ("_blank" target).
+ await window.expectLinkOpenInNewTab(
+ browser.runtime.getURL("test.html"),
+ "#link4",
+ browser.runtime.getURL("example.html#blank")
+ );
+
+ // Open local file and click same site link ("_other" target).
+ await window.expectLinkOpenInNewTab(
+ browser.runtime.getURL("test.html"),
+ "#link5",
+ browser.runtime.getURL("example.html#other")
+ );
+
+ // Open a remote page and click link on same site.
+ if (expectedLinkHandler == "single-page") {
+ await window.expectLinkOpenInExternalBrowser(
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/linktest.html",
+ "#linkExt1",
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html"
+ );
+ } else {
+ await window.expectLinkOpenInSameTab(
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/linktest.html",
+ "#linkExt1",
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html"
+ );
+ }
+
+ // Open a remote page and click link to a different site.
+ await window.expectLinkOpenInExternalBrowser(
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/linktest.html",
+ "#linkExt2",
+ "https://mozilla.org/"
+ );
+
+ browser.test.notifyPass();
+ },
+ "example.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>EXAMPLE</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p>This is an example page</p>
+ </body>
+ </html>`,
+ "test.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>TEST</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <ul>
+ <li><a id="link1" href="https://www.example.de/">external</a>
+ <li><a id="link2" href="example.html">no target</a>
+ <li><a id="link3" href="example.html#self" target = "_self">_self target</a>
+ <li><a id="link4" href="example.html#blank" target = "_blank">_blank target</a>
+ <li><a id="link5" href="example.html#other" target = "_other">_other target</a>
+ </ul>
+ </body>
+ </html>`,
+ };
+};
+
+const subtest_clickInBrowser = async (
+ extension,
+ expectedLinkHandler,
+ getBrowser
+) => {
+ async function clickLink(linkId, browser) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+ await synthesizeMouseAtCenterAndRetry(linkId, {}, browser);
+ }
+
+ await extension.startup();
+
+ await extension.awaitMessage("expectedLinkHandler");
+ extension.sendMessage(expectedLinkHandler);
+
+ // Wait for click on #link1 (external)
+ {
+ let { linkId, expectedUrl } = await extension.awaitMessage("click");
+ Assert.equal("#link1", linkId, `Test should click on the correct link.`);
+ Assert.equal(
+ "https://www.example.de/",
+ expectedUrl,
+ `Test should open the correct link.`
+ );
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ mockExternalProtocolService.urlLoaded(expectedUrl),
+ `Link should have correctly been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #link2 (same tab)
+ {
+ let { linkId } = await extension.awaitMessage("click");
+ Assert.equal("#link2", linkId, `Test should click on the correct link.`);
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ !mockExternalProtocolService.hasAnyUrlLoaded(),
+ `Link should not have been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #link3 (same tab)
+ {
+ let { linkId } = await extension.awaitMessage("click");
+ Assert.equal("#link3", linkId, `Test should click on the correct link.`);
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ !mockExternalProtocolService.hasAnyUrlLoaded(),
+ `Link should not have been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #link4 (new tab)
+ {
+ let { linkId } = await extension.awaitMessage("click");
+ Assert.equal("#link4", linkId, `Test should click on the correct link.`);
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ !mockExternalProtocolService.hasAnyUrlLoaded(),
+ `Link should not have been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #link5 (new tab)
+ {
+ let { linkId } = await extension.awaitMessage("click");
+ Assert.equal("#link5", linkId, `Test should click on the correct link.`);
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ !mockExternalProtocolService.hasAnyUrlLoaded(),
+ `Link should not have been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #linkExt1
+ if (expectedLinkHandler == "single-page") {
+ // Should open extern with single-page link handler.
+ let { linkId, expectedUrl } = await extension.awaitMessage("click");
+ Assert.equal("#linkExt1", linkId, `Test should click on the correct link.`);
+ Assert.equal(
+ "https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html",
+ expectedUrl,
+ `Test should open the correct link.`
+ );
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ mockExternalProtocolService.urlLoaded(expectedUrl),
+ `Link should have correctly been opened in external browser.`
+ );
+ await extension.sendMessage();
+ } else {
+ // Should open in same tab with single-site link handler.
+ let { linkId } = await extension.awaitMessage("click");
+ Assert.equal("#linkExt1", linkId, `Test should click on the correct link.`);
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ !mockExternalProtocolService.hasAnyUrlLoaded(),
+ `Link should not have been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ // Wait for click on #linkExt2 (external)
+ {
+ let { linkId, expectedUrl } = await extension.awaitMessage("click");
+ Assert.equal("#linkExt2", linkId, `Test should click on the correct link.`);
+ Assert.equal(
+ "https://mozilla.org/",
+ expectedUrl,
+ `Test should open the correct link.`
+ );
+ await clickLink(linkId, getBrowser());
+ Assert.ok(
+ mockExternalProtocolService.urlLoaded(expectedUrl),
+ `Link should have correctly been opened in external browser.`
+ );
+ await extension.sendMessage();
+ }
+
+ await extension.awaitFinish();
+ await extension.unload();
+};
+
+add_task(async function test_tabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "tabFunctions.js": async () => {
+ let openTestTab = async url => {
+ let createdTestTab = new window.CreateTabPromise();
+ let updatedTestTab = new window.UpdateTabPromise();
+ let testTab = await browser.tabs.create({ url });
+ await createdTestTab.done();
+ await updatedTestTab.verify(testTab.id, url);
+ return testTab;
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+
+ // Click a link in testTab to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ await browser.tabs.remove(testTab.id);
+ };
+
+ window.expectLinkOpenInSameTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+
+ // Click a link in testTab to open in self.
+ let updatedTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ await updatedTab.verify(testTab.id, expectedUrl);
+
+ await browser.tabs.remove(testTab.id);
+ };
+
+ window.expectLinkOpenInExternalBrowser = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+ await window.sendMessage("click", { linkId, expectedUrl });
+ await browser.tabs.remove(testTab.id);
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "common.js", "tabFunctions.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ "single-site",
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+}).skip(AppConstants.DEBUG); // Disabled until Bug 1770105 is fully fixed.
+
+add_task(async function test_windows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "windowFunctions.js": async () => {
+ let openTestTab = async url => {
+ let createdTestTab = new window.CreateTabPromise();
+ let updatedTestTab = new window.UpdateTabPromise();
+ let testWindow = await browser.windows.create({ type: "popup", url });
+ await createdTestTab.done();
+
+ let [testTab] = await browser.tabs.query({ windowId: testWindow.id });
+ await updatedTestTab.verify(testTab.id, url);
+ return testTab;
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+
+ // Click a link in testWindow to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ await browser.tabs.remove(testTab.id);
+ };
+
+ window.expectLinkOpenInSameTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+
+ // Click a link in testWindow to open in self.
+ let updatedTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ await updatedTab.verify(testTab.id, expectedUrl);
+ await browser.tabs.remove(testTab.id);
+ };
+
+ window.expectLinkOpenInExternalBrowser = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+ await window.sendMessage("click", { linkId, expectedUrl });
+ await browser.tabs.remove(testTab.id);
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: [
+ "utils.js",
+ "common.js",
+ "windowFunctions.js",
+ "background.js",
+ ],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ "single-site",
+ () => Services.wm.getMostRecentWindow("mail:extensionPopup").browser
+ );
+}).skip(AppConstants.DEBUG); // Disabled until Bug 1770105 is fully fixed.
+
+add_task(async function test_mail3pane() {
+ let account = createAccount();
+ let subFolders = account.incomingServer.rootFolder.subFolders;
+ createMessages(subFolders[0], 1);
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ Assert.ok(Boolean(about3Pane), "about:3pane should be the current tab");
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+ about3Pane.threadTree.selectedIndex = 0;
+ await loadedPromise;
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "mail3paneFunctions.js": async () => {
+ let updateTestTab = async url => {
+ let updatedTestTab = new window.UpdateTabPromise();
+ let mailTabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(
+ 1,
+ mailTabs.length,
+ "Should find a single mailTab"
+ );
+ await browser.tabs.update(mailTabs[0].id, { url });
+ await updatedTestTab.verify(mailTabs[0].id, url);
+ return mailTabs[0];
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ await updateTestTab(testUrl);
+
+ // Click a link in testTab to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ };
+
+ window.expectLinkOpenInSameTab = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ let testTab = await updateTestTab(testUrl);
+
+ // Click a link in testTab to open in self.
+ let updatedTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkId });
+ await updatedTab.verify(testTab.id, expectedUrl);
+ };
+
+ window.expectLinkOpenInExternalBrowser = async (
+ testUrl,
+ linkId,
+ expectedUrl
+ ) => {
+ await updateTestTab(testUrl);
+ await window.sendMessage("click", { linkId, expectedUrl });
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: [
+ "utils.js",
+ "common.js",
+ "mail3paneFunctions.js",
+ "background.js",
+ ],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ "single-page",
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+}).skip(AppConstants.DEBUG); // Disabled until Bug 1770105 is fully fixed.
+
+// This is actually not an extension test, but everything we need is here already
+// and we only want to simulate a click on a link in a message.
+add_task(async function test_message() {
+ let gAccount = createAccount();
+ let gRootFolder = gAccount.incomingServer.rootFolder;
+ gRootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of gRootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ await createMessageFromFile(
+ subFolders.test0,
+ getTestFilePath("messages/messageWithLink.eml")
+ );
+
+ // Select the message which has a link.
+ let gFolder = subFolders.test0;
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder.URI);
+ let messagePane =
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser();
+ let loadedPromise = BrowserTestUtils.browserLoaded(messagePane);
+ about3Pane.threadTree.selectedIndex = 0;
+ await loadedPromise;
+
+ // Click the link.
+ await synthesizeMouseAtCenterAndRetry("#link", {}, messagePane);
+ Assert.ok(
+ mockExternalProtocolService.urlLoaded(
+ "https://www.example.de/messageLink.html"
+ ),
+ `Link should have correctly been opened in external browser.`
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_cloudFile.js b/comm/mail/components/extensions/test/browser/browser_ext_cloudFile.js
new file mode 100644
index 0000000000..2e9b53916c
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_cloudFile.js
@@ -0,0 +1,1444 @@
+/* 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/. */
+
+"use strict";
+
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+/**
+ * Test cloudfile methods (getAccount, getAllAccounts, updateAccount) and
+ * events (onAccountAdded, onAccountDeleted, onFileUpload, onFileUploadAbort,
+ * onFileDeleted, onFileRename) without UI interaction.
+ */
+add_task(async function test_without_UI() {
+ async function background() {
+ function createCloudfileAccount() {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ }
+
+ function removeCloudfileAccount(id) {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ }
+
+ function assertAccountsMatch(b, a) {
+ browser.test.assertEq(a.id, b.id);
+ browser.test.assertEq(a.name, b.name);
+ browser.test.assertEq(a.configured, b.configured);
+ browser.test.assertEq(a.uploadSizeLimit, b.uploadSizeLimit);
+ browser.test.assertEq(a.spaceRemaining, b.spaceRemaining);
+ browser.test.assertEq(a.spaceUsed, b.spaceUsed);
+ browser.test.assertEq(a.managementUrl, b.managementUrl);
+ }
+
+ async function test_account_creation_removal() {
+ browser.test.log("test_account_creation_removal");
+ // Account creation
+ let [createdAccount] = await createCloudfileAccount();
+ assertAccountsMatch(createdAccount, {
+ id: "account1",
+ name: "mochitest",
+ configured: false,
+ uploadSizeLimit: -1,
+ spaceRemaining: -1,
+ spaceUsed: -1,
+ managementUrl: browser.runtime.getURL("/content/management.html"),
+ });
+
+ // Other account creation
+ await new Promise((resolve, reject) => {
+ function accountListener(account) {
+ browser.cloudFile.onAccountAdded.removeListener(accountListener);
+ browser.test.fail("Got onAccountAdded for account from other addon");
+ reject();
+ }
+
+ browser.cloudFile.onAccountAdded.addListener(accountListener);
+ browser.test.sendMessage("createAccount", "ext-other-addon");
+
+ // Resolve in the next tick
+ setTimeout(() => {
+ browser.cloudFile.onAccountAdded.removeListener(accountListener);
+ resolve();
+ });
+ });
+
+ // Account removal
+ let [removedAccountId] = await removeCloudfileAccount(createdAccount.id);
+ browser.test.assertEq(createdAccount.id, removedAccountId);
+ }
+
+ async function test_getters_update() {
+ browser.test.log("test_getters_update");
+ browser.test.sendMessage("createAccount", "ext-other-addon");
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ // getAccount and getAllAccounts
+ let retrievedAccount = await browser.cloudFile.getAccount(
+ createdAccount.id
+ );
+ assertAccountsMatch(createdAccount, retrievedAccount);
+
+ let retrievedAccounts = await browser.cloudFile.getAllAccounts();
+ browser.test.assertEq(retrievedAccounts.length, 1);
+ assertAccountsMatch(createdAccount, retrievedAccounts[0]);
+
+ // update()
+ let changes = {
+ configured: true,
+ // uploadSizeLimit intentionally left unset
+ spaceRemaining: 456,
+ spaceUsed: 789,
+ managementUrl: "/account.html",
+ };
+
+ let changedAccount = await browser.cloudFile.updateAccount(
+ retrievedAccount.id,
+ changes
+ );
+ retrievedAccount = await browser.cloudFile.getAccount(createdAccount.id);
+
+ let expected = {
+ id: createdAccount.id,
+ name: "mochitest",
+ configured: true,
+ uploadSizeLimit: -1,
+ spaceRemaining: 456,
+ spaceUsed: 789,
+ managementUrl: browser.runtime.getURL("/account.html"),
+ };
+
+ assertAccountsMatch(changedAccount, expected);
+ assertAccountsMatch(retrievedAccount, expected);
+
+ await removeCloudfileAccount(createdAccount.id);
+ }
+
+ async function test_upload_rename_delete() {
+ browser.test.log("test_upload_rename_delete");
+ let [createdAccount] = await createCloudfileAccount();
+
+ let fileId = await new Promise(resolve => {
+ async function fileListener(
+ account,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(name, "cloudFile1.txt");
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(data instanceof File);
+ let content = await data.text();
+ browser.test.assertEq(content, "you got the moves!\n");
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve(id));
+ return { url: "https://example.com/" + name };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage("uploadFile", createdAccount.id, "cloudFile1");
+ });
+
+ browser.test.log("test upload error");
+ await new Promise(resolve => {
+ function fileListener(
+ account,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve(id));
+ return { error: true };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage(
+ "uploadFile",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadErr",
+ "Upload error."
+ );
+ });
+
+ browser.test.log("test upload error with message");
+ await new Promise(resolve => {
+ function fileListener(
+ account,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve(id));
+ return { error: "Service currently unavailable." };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage(
+ "uploadFile",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadErrWithCustomMessage",
+ "Service currently unavailable."
+ );
+ });
+
+ browser.test.log("test upload quota error");
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ spaceRemaining: 1,
+ });
+ await window.sendMessage(
+ "uploadFileError",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadWouldExceedQuota",
+ "Quota error: Can't upload file. Only 1KB left of quota."
+ );
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ spaceRemaining: -1,
+ });
+
+ browser.test.log("test upload file size limit error");
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ uploadSizeLimit: 1,
+ });
+ await window.sendMessage(
+ "uploadFileError",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadExceedsFileLimit",
+ "Upload error: File size is 19KB and exceeds the file size limit of 1KB"
+ );
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ uploadSizeLimit: -1,
+ });
+
+ browser.test.log("test rename with url update");
+ await new Promise(resolve => {
+ function fileListener(account, id, newName) {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(newName, "cloudFile3.txt");
+ setTimeout(() => resolve(id));
+ return { url: "https://example.com/" + newName };
+ }
+
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ browser.test.sendMessage("renameFile", createdAccount.id, fileId, {
+ newName: "cloudFile3.txt",
+ newUrl: "https://example.com/cloudFile3.txt",
+ });
+ });
+
+ browser.test.log("test rename without url update");
+ await new Promise(resolve => {
+ function fileListener(account, id, newName) {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(newName, "cloudFile4.txt");
+ setTimeout(() => resolve(id));
+ }
+
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ browser.test.sendMessage("renameFile", createdAccount.id, fileId, {
+ newName: "cloudFile4.txt",
+ newUrl: "https://example.com/cloudFile3.txt",
+ });
+ });
+
+ browser.test.log("test rename error");
+ await new Promise(resolve => {
+ function fileListener(account, id, newName) {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(newName, "cloudFile5.txt");
+ setTimeout(() => resolve(id));
+ return { error: true };
+ }
+
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ browser.test.sendMessage(
+ "renameFile",
+ createdAccount.id,
+ fileId,
+ { newName: "cloudFile5.txt" },
+ "renameErr",
+ "Rename error."
+ );
+ });
+
+ browser.test.log("test rename error with message");
+ await new Promise(resolve => {
+ function fileListener(account, id, newName) {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(newName, "cloudFile5.txt");
+ setTimeout(() => resolve(id));
+ return { error: "Service currently unavailable." };
+ }
+
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ browser.test.sendMessage(
+ "renameFile",
+ createdAccount.id,
+ fileId,
+ { newName: "cloudFile5.txt" },
+ "renameErrWithCustomMessage",
+ "Service currently unavailable."
+ );
+ });
+
+ browser.test.log("test upload aborted");
+ await new Promise(resolve => {
+ async function fileListener(
+ account,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+
+ // The listener won't return until onFileUploadAbort fires. When that happens,
+ // we return an aborted message, which completes the abort cycle.
+ await new Promise(resolveAbort => {
+ function abortListener(accountAccount, abortId) {
+ browser.cloudFile.onFileUploadAbort.removeListener(abortListener);
+ browser.test.assertEq(account.id, accountAccount.id);
+ browser.test.assertEq(id, abortId);
+ resolveAbort();
+ }
+ browser.cloudFile.onFileUploadAbort.addListener(abortListener);
+ browser.test.sendMessage("cancelUpload", createdAccount.id);
+ });
+
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(resolve);
+ return { aborted: true };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage(
+ "uploadFile",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadCancelled",
+ "Upload cancelled."
+ );
+ });
+
+ browser.test.log("test delete");
+ await new Promise(resolve => {
+ function fileListener(account, id) {
+ browser.cloudFile.onFileDeleted.removeListener(fileListener);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(id, fileId);
+ setTimeout(resolve);
+ }
+
+ browser.cloudFile.onFileDeleted.addListener(fileListener);
+ browser.test.sendMessage("deleteFile", createdAccount.id);
+ });
+
+ await removeCloudfileAccount(createdAccount.id);
+ await new Promise(resolve => setTimeout(resolve));
+ }
+
+ // Tests to run
+ await test_account_creation_removal();
+ await test_getters_update();
+ await test_upload_rename_delete();
+
+ browser.test.notifyPass("cloudFile");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ applications: { gecko: { id: "cloudfile@mochi.test" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ let testFiles = {
+ cloudFile1: new FileUtils.File(getTestFilePath("data/cloudFile1.txt")),
+ cloudFile2: new FileUtils.File(getTestFilePath("data/cloudFile2.txt")),
+ };
+
+ let uploads = {};
+
+ extension.onMessage("createAccount", (id = "ext-cloudfile@mochi.test") => {
+ cloudFileAccounts.createAccount(id);
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage(
+ "uploadFileError",
+ async (id, filename, expectedErrorStatus, expectedErrorMessage) => {
+ let account = cloudFileAccounts.getAccount(id);
+
+ let status;
+ try {
+ await account.uploadFile(null, testFiles[filename]);
+ } catch (ex) {
+ status = ex;
+ }
+
+ Assert.ok(
+ !!status,
+ `Upload should have failed for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.result,
+ cloudFileAccounts.constants[expectedErrorStatus],
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ extension.sendMessage();
+ }
+ );
+
+ extension.onMessage(
+ "uploadFile",
+ (id, filename, expectedErrorStatus = Cr.NS_OK, expectedErrorMessage) => {
+ let account = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ account.uploadFile(null, testFiles[filename]).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ uploads[filename] = upload;
+ },
+ status => {
+ Assert.equal(
+ status.result,
+ expectedErrorStatus,
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ }
+ );
+ }
+ );
+
+ extension.onMessage(
+ "renameFile",
+ (
+ id,
+ uploadId,
+ { newName, newUrl },
+ expectedErrorStatus = Cr.NS_OK,
+ expectedErrorMessage
+ ) => {
+ let account = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ account.renameFile(null, uploadId, newName).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ Assert.equal(upload.name, newName, "New name should match.");
+ Assert.equal(upload.url, newUrl, "New url should match.");
+ },
+ status => {
+ Assert.equal(status.result, expectedErrorStatus);
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct.`
+ );
+ }
+ );
+ }
+ );
+
+ extension.onMessage("cancelUpload", id => {
+ let account = cloudFileAccounts.getAccount(id);
+ account.cancelFileUpload(null, testFiles.cloudFile2);
+ });
+
+ extension.onMessage("deleteFile", id => {
+ let account = cloudFileAccounts.getAccount(id);
+ account.deleteFile(null, uploads.cloudFile1.id);
+ });
+
+ Assert.ok(!cloudFileAccounts.getProviderForType("ext-cloudfile@mochi.test"));
+ await extension.startup();
+ Assert.ok(cloudFileAccounts.getProviderForType("ext-cloudfile@mochi.test"));
+ Assert.equal(cloudFileAccounts.accounts.length, 1);
+
+ await extension.awaitFinish("cloudFile");
+ await extension.unload();
+
+ Assert.ok(!cloudFileAccounts.getProviderForType("ext-cloudfile@mochi.test"));
+ Assert.equal(cloudFileAccounts.accounts.length, 0);
+});
+
+/**
+ * Test the tab parameter in cloudFile.onFileUpload, cloudFile.onFileDeleted,
+ * cloudFile.onFileRename and cloudFile.onFileUploadAbort listeners with UI
+ * interaction.
+ */
+add_task(async function test_compose_window_MV2() {
+ let testFiles = {
+ cloudFile1: new FileUtils.File(getTestFilePath("data/cloudFile1.txt")),
+ cloudFile2: new FileUtils.File(getTestFilePath("data/cloudFile2.txt")),
+ };
+ let uploads = {};
+ let composeWindow;
+
+ async function background() {
+ function createCloudfileAccount(id) {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount", id);
+ return addListener;
+ }
+
+ function removeCloudfileAccount(id) {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ }
+
+ async function test_tab_in_upload_rename_abort_delete_listener(composeTab) {
+ browser.test.log("test_upload_delete");
+ let [createdAccount] = await createCloudfileAccount(
+ "ext-cloudfile@mochi.test"
+ );
+
+ let fileId = await new Promise(resolve => {
+ async function fileListener(
+ uploadAccount,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+
+ browser.test.assertEq(tab.id, composeTab.id);
+ browser.test.assertEq(uploadAccount.id, createdAccount.id);
+ browser.test.assertEq(name, "cloudFile1.txt");
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(data instanceof File);
+ let content = await data.text();
+ browser.test.assertEq(content, "you got the moves!\n");
+
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve(id));
+ return { url: "https://example.com/" + name };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage("uploadFile", createdAccount.id, "cloudFile1");
+ });
+
+ browser.test.log("test rename with Url update");
+ await new Promise(resolve => {
+ function fileListener(account, id, newName, tab) {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ browser.test.assertEq(tab.id, composeTab.id);
+ browser.test.assertEq(account.id, createdAccount.id);
+ browser.test.assertEq(newName, "cloudFile3.txt");
+ setTimeout(() => resolve(id));
+ return { url: "https://example.com/" + newName };
+ }
+
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ browser.test.sendMessage("renameFile", createdAccount.id, fileId, {
+ newName: "cloudFile3.txt",
+ newUrl: "https://example.com/cloudFile3.txt",
+ });
+ });
+
+ browser.test.log("test upload aborted");
+ await new Promise(resolve => {
+ async function fileListener(
+ uploadAccount,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(tab.id, composeTab.id);
+
+ // The listener won't return until onFileUploadAbort fires. When that happens,
+ // we return an aborted message, which completes the abort cycle.
+ await new Promise(resolveAbort => {
+ function abortListener(abortAccount, abortId, tab) {
+ browser.cloudFile.onFileUploadAbort.removeListener(abortListener);
+ browser.test.assertEq(tab.id, composeTab.id);
+ browser.test.assertEq(uploadAccount.id, abortAccount.id);
+ browser.test.assertEq(id, abortId);
+ resolveAbort();
+ }
+ browser.cloudFile.onFileUploadAbort.addListener(abortListener);
+ browser.test.sendMessage("cancelUpload", createdAccount.id);
+ });
+
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(resolve);
+ return { aborted: true };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage(
+ "uploadFile",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadCancelled",
+ "Upload cancelled."
+ );
+ });
+
+ browser.test.log("test delete");
+ await new Promise(resolve => {
+ function fileListener(deleteAccount, id, tab) {
+ browser.cloudFile.onFileDeleted.removeListener(fileListener);
+ browser.test.assertEq(tab.id, composeTab.id);
+ browser.test.assertEq(deleteAccount.id, createdAccount.id);
+ browser.test.assertEq(id, fileId);
+ setTimeout(resolve);
+ }
+
+ browser.cloudFile.onFileDeleted.addListener(fileListener);
+ browser.test.sendMessage("deleteFile", createdAccount.id);
+ });
+
+ await removeCloudfileAccount(createdAccount.id);
+ await new Promise(resolve => setTimeout(resolve));
+ }
+
+ let [composerTab] = await browser.tabs.query({
+ windowType: "messageCompose",
+ });
+ await test_tab_in_upload_rename_abort_delete_listener(composerTab);
+
+ browser.test.notifyPass("finished");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ applications: { gecko: { id: "cloudfile@mochi.test" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("createAccount", id => {
+ cloudFileAccounts.createAccount(id);
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage(
+ "uploadFile",
+ (id, filename, expectedErrorStatus = Cr.NS_OK, expectedErrorMessage) => {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ cloudFileAccount.uploadFile(composeWindow, testFiles[filename]).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ uploads[filename] = upload;
+ },
+ status => {
+ Assert.equal(
+ status.result,
+ expectedErrorStatus,
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ }
+ );
+ }
+ );
+
+ extension.onMessage(
+ "renameFile",
+ (
+ id,
+ uploadId,
+ { newName, newUrl },
+ expectedErrorStatus = Cr.NS_OK,
+ expectedErrorMessage
+ ) => {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ cloudFileAccount.renameFile(composeWindow, uploadId, newName).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ Assert.equal(upload.name, newName, "New name should match.");
+ Assert.equal(upload.url, newUrl, "New url should match.");
+ },
+ status => {
+ Assert.equal(status.result, expectedErrorStatus);
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct.`
+ );
+ }
+ );
+ }
+ );
+
+ extension.onMessage("cancelUpload", id => {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+ cloudFileAccount.cancelFileUpload(composeWindow, testFiles.cloudFile2);
+ });
+
+ extension.onMessage("deleteFile", id => {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+ cloudFileAccount.deleteFile(composeWindow, uploads.cloudFile1.id);
+ });
+
+ let account = createAccount();
+ addIdentity(account);
+
+ composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ composeWindow.close();
+});
+
+/**
+ * Test persistent cloudFile.* events (onFileUpload, onFileDeleted, onFileRename,
+ * onFileUploadAbort, onAccountAdded, onAccountDeleted) with UI interaction and
+ * background terminations and background restarts.
+ */
+add_task(async function test_compose_window_MV3_event_page() {
+ let testFiles = {
+ cloudFile1: new FileUtils.File(getTestFilePath("data/cloudFile1.txt")),
+ cloudFile2: new FileUtils.File(getTestFilePath("data/cloudFile2.txt")),
+ };
+ let uploads = {};
+ let composeWindow;
+
+ async function background() {
+ let abortResolveCallback;
+ // Whenever the extension starts or wakes up, the eventCounter is reset and
+ // allows to observe the order of events fired. In case of a wake-up, the
+ // first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ browser.cloudFile.onFileUpload.addListener(
+ async (uploadAccount, { id, name, data }, tab, relatedFileInfo) => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 1,
+ "onFileUpload should be the wake up event"
+ );
+ let [{ cloudAccountId, composeTabId, aborting }] =
+ await window.sendMessage("getEnvironment");
+ browser.test.assertEq(tab.id, composeTabId);
+ browser.test.assertEq(uploadAccount.id, cloudAccountId);
+ browser.test.assertEq(name, "cloudFile1.txt");
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(data instanceof File);
+ let content = await data.text();
+ browser.test.assertEq(content, "you got the moves!\n");
+ browser.test.assertEq(undefined, relatedFileInfo);
+
+ if (aborting) {
+ let abortPromise = new Promise(resolve => {
+ abortResolveCallback = resolve;
+ });
+ browser.test.sendMessage("uploadStarted", id);
+ await abortPromise;
+ setTimeout(() => {
+ browser.test.sendMessage("uploadAborted");
+ });
+ return { aborted: true };
+ }
+
+ setTimeout(() => {
+ browser.test.sendMessage("uploadFinished", id);
+ });
+ return { url: "https://example.com/" + name };
+ }
+ );
+
+ browser.cloudFile.onFileRename.addListener(
+ async (account, id, newName, tab) => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 1,
+ "onFileRename should be the wake up event"
+ );
+ let [{ cloudAccountId, fileId, composeTabId }] =
+ await window.sendMessage("getEnvironment");
+ browser.test.assertEq(tab.id, composeTabId);
+ browser.test.assertEq(account.id, cloudAccountId);
+ browser.test.assertEq(id, fileId);
+ browser.test.assertEq(newName, "cloudFile3.txt");
+ setTimeout(() => {
+ browser.test.sendMessage("renameFinished", id);
+ });
+ return { url: "https://example.com/" + newName };
+ }
+ );
+
+ browser.cloudFile.onFileDeleted.addListener(async (account, id, tab) => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 1,
+ "onFileDeleted should be the wake up event"
+ );
+ let [{ cloudAccountId, fileId, composeTabId }] = await window.sendMessage(
+ "getEnvironment"
+ );
+ browser.test.assertEq(tab.id, composeTabId);
+ browser.test.assertEq(account.id, cloudAccountId);
+ browser.test.assertEq(id, fileId);
+ setTimeout(() => {
+ browser.test.sendMessage("deleteFinished");
+ });
+ });
+
+ browser.cloudFile.onFileUploadAbort.addListener(
+ async (account, id, tab) => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 2,
+ "onFileUploadAbort should not be the wake up event"
+ );
+ let [{ cloudAccountId, fileId, composeTabId }] =
+ await window.sendMessage("getEnvironment");
+ browser.test.assertEq(tab.id, composeTabId);
+ browser.test.assertEq(account.id, cloudAccountId);
+ browser.test.assertEq(id, fileId);
+ abortResolveCallback();
+ }
+ );
+
+ browser.cloudFile.onAccountAdded.addListener(account => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 1,
+ "onAccountAdded should be the wake up event"
+ );
+ browser.test.sendMessage("accountCreated", account.id);
+ });
+
+ browser.cloudFile.onAccountDeleted.addListener(async accountId => {
+ eventCounter++;
+ browser.test.assertEq(
+ eventCounter,
+ 1,
+ "onAccountDeleted should be the wake up event"
+ );
+ let [{ cloudAccountId }] = await window.sendMessage("getEnvironment");
+ browser.test.assertEq(accountId, cloudAccountId);
+ browser.test.notifyPass("finished");
+ });
+
+ browser.runtime.onInstalled.addListener(async () => {
+ eventCounter++;
+ let [composeTab] = await browser.tabs.query({
+ windowType: "messageCompose",
+ });
+ await window.sendMessage("setEnvironment", {
+ composeTabId: composeTab.id,
+ });
+ browser.test.sendMessage("installed");
+ });
+
+ browser.test.sendMessage("background started");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 3,
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ browser_specific_settings: { gecko: { id: "cloudfile@mochi.test" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ function uploadFile(
+ id,
+ filename,
+ expectedErrorStatus = Cr.NS_OK,
+ expectedErrorMessage
+ ) {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ return cloudFileAccount.uploadFile(composeWindow, testFiles[filename]).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ uploads[filename] = upload;
+ },
+ status => {
+ Assert.equal(
+ status.result,
+ expectedErrorStatus,
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ }
+ );
+ }
+ function startUpload(id, filename) {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+ return cloudFileAccount
+ .uploadFile(composeWindow, testFiles[filename])
+ .catch(() => {});
+ }
+ function cancelUpload(id, filename) {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+ return cloudFileAccount.cancelFileUpload(
+ composeWindow,
+ testFiles[filename]
+ );
+ }
+ function renameFile(
+ id,
+ uploadId,
+ { newName, newUrl },
+ expectedErrorStatus = Cr.NS_OK,
+ expectedErrorMessage
+ ) {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ return cloudFileAccount.renameFile(composeWindow, uploadId, newName).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ Assert.equal(upload.name, newName, "New name should match.");
+ Assert.equal(upload.url, newUrl, "New url should match.");
+ },
+ status => {
+ Assert.equal(status.result, expectedErrorStatus);
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct.`
+ );
+ }
+ );
+ }
+ function deleteFile(id, uploadId) {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+ return cloudFileAccount.deleteFile(composeWindow, uploadId);
+ }
+
+ let environment = {};
+ extension.onMessage("setEnvironment", data => {
+ if (data.composeTabId) {
+ environment.composeTabId = data.composeTabId;
+ }
+ extension.sendMessage();
+ });
+ extension.onMessage("getEnvironment", () => {
+ extension.sendMessage(environment);
+ });
+
+ let account = createAccount();
+ addIdentity(account);
+
+ composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("installed");
+ await extension.awaitMessage("background started");
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "onFileUpload",
+ "onFileRename",
+ "onFileDeleted",
+ "onFileUploadAbort",
+ "onAccountAdded",
+ "onAccountDeleted",
+ ];
+ for (let eventName of persistent_events) {
+ assertPersistentListeners(extension, "cloudFile", eventName, {
+ primed,
+ });
+ }
+ }
+
+ // Verify persistent listener, not yet primed.
+ checkPersistentListeners({ primed: false });
+
+ // Create account.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ cloudFileAccounts.createAccount("ext-cloudfile@mochi.test");
+ await extension.awaitMessage("background started");
+ environment.cloudAccountId = await extension.awaitMessage("accountCreated");
+ checkPersistentListeners({ primed: false });
+
+ // Upload.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ uploadFile(environment.cloudAccountId, "cloudFile1");
+ await extension.awaitMessage("background started");
+ environment.fileId = await extension.awaitMessage("uploadFinished");
+ checkPersistentListeners({ primed: false });
+
+ // Rename.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ renameFile(environment.cloudAccountId, environment.fileId, {
+ newName: "cloudFile3.txt",
+ newUrl: "https://example.com/cloudFile3.txt",
+ });
+ await extension.awaitMessage("background started");
+ await extension.awaitMessage("renameFinished");
+ checkPersistentListeners({ primed: false });
+
+ // Delete.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ deleteFile(environment.cloudAccountId, environment.fileId);
+ await extension.awaitMessage("background started");
+ await extension.awaitMessage("deleteFinished");
+ checkPersistentListeners({ primed: false });
+
+ // Aborted upload.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ environment.aborting = true;
+ startUpload(environment.cloudAccountId, "cloudFile1");
+ await extension.awaitMessage("background started");
+ environment.fileId = await extension.awaitMessage("uploadStarted");
+ cancelUpload(environment.cloudAccountId, "cloudFile1");
+ await extension.awaitMessage("uploadAborted");
+ checkPersistentListeners({ primed: false });
+
+ // Remove account.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ cloudFileAccounts.removeAccount(environment.cloudAccountId);
+ await extension.awaitMessage("background started");
+ checkPersistentListeners({ primed: false });
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ composeWindow.close();
+});
+
+/**
+ * Test cloudFiles without accounts and removed local files.
+ */
+add_task(async function test_incomplete_cloudFiles() {
+ let testFiles = {
+ cloudFile1: new FileUtils.File(getTestFilePath("data/cloudFile1.txt")),
+ cloudFile2: new FileUtils.File(getTestFilePath("data/cloudFile2.txt")),
+ };
+ let uploads = {};
+ let composeWindow;
+ let cloudFileAccount = null;
+
+ async function background() {
+ function createCloudfileAccount(id) {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount", id);
+ return addListener;
+ }
+
+ function removeCloudfileAccount(id) {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ }
+
+ let [composerTab] = await browser.tabs.query({
+ windowType: "messageCompose",
+ });
+
+ let [createdAccount] = await createCloudfileAccount(
+ "ext-cloudfile@mochi.test"
+ );
+
+ await new Promise(resolve => {
+ function fileListener(
+ uploadAccount,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ setTimeout(() => resolve(id));
+ return { url: "https://example.com/" + name };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage("uploadFile", createdAccount.id, "cloudFile1");
+ });
+
+ await window.sendMessage("attachAndInvalidate", "cloudFile1");
+ let attachments = await browser.compose.listAttachments(composerTab.id);
+ let [attachmentId] = attachments
+ .filter(e => e.name == "cloudFile1.txt")
+ .map(e => e.id);
+
+ await browser.test.assertRejects(
+ browser.compose.updateAttachment(composerTab.id, attachmentId, {
+ name: "cloudFile3",
+ }),
+ e => {
+ return (
+ e.message.startsWith(
+ "CloudFile Error: Attachment file not found: "
+ ) && e.message.endsWith("cloudFile1.txt_invalid")
+ );
+ },
+ "browser.compose.updateAttachment() should reject, if the local file does not exist."
+ );
+
+ await removeCloudfileAccount(createdAccount.id);
+ await browser.test.assertRejects(
+ browser.compose.updateAttachment(composerTab.id, attachmentId, {
+ name: "cloudFile3",
+ }),
+ `CloudFile Error: Account not found: ${createdAccount.id}`,
+ "browser.compose.updateAttachment() should reject, if the account does not exist."
+ );
+
+ browser.test.notifyPass("finished");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ permissions: ["compose"],
+ applications: { gecko: { id: "cloudfile@mochi.test" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("createAccount", id => {
+ cloudFileAccount = cloudFileAccounts.createAccount(id);
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage("attachAndInvalidate", async filename => {
+ let upload = uploads[filename];
+ await composeWindow.attachToCloudRepeat(
+ uploads[filename],
+ cloudFileAccount
+ );
+
+ let bucket = composeWindow.document.getElementById("attachmentBucket");
+ let item = [...bucket.children].find(e => e.attachment.name == upload.name);
+ Assert.ok(item, "Should have found the attachment item");
+
+ // Invalidate the cloud attachment, simulating a file move/delete.
+ item.attachment.url = `${item.attachment.url}_invalid`;
+ item.cloudFileAccount.markAsImmutable(item.cloudFileUpload.id);
+ extension.sendMessage();
+ });
+
+ extension.onMessage(
+ "uploadFile",
+ (id, filename, expectedErrorStatus = Cr.NS_OK, expectedErrorMessage) => {
+ let cloudFileAccount = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ cloudFileAccount.uploadFile(composeWindow, testFiles[filename]).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ uploads[filename] = upload;
+ },
+ status => {
+ Assert.equal(
+ status.result,
+ expectedErrorStatus,
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ }
+ );
+ }
+ );
+
+ let account = createAccount();
+ addIdentity(account);
+
+ composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ composeWindow.close();
+});
+
+/** Test data_format "File", which is the default if none is specified in the
+ * manifest. */
+add_task(async function test_file_format() {
+ async function background() {
+ function createCloudfileAccount() {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ }
+
+ function removeCloudfileAccount(id) {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ }
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ browser.test.log("test upload");
+ await new Promise(resolve => {
+ function fileListener(account, { id, name, data }, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(name, "cloudFile1.txt");
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(data instanceof File);
+ let reader = new FileReader();
+ reader.addEventListener("loadend", () => {
+ browser.test.assertEq(reader.result, "you got the moves!\n");
+ setTimeout(() => resolve(id));
+ });
+ reader.readAsText(data);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ return { url: "https://example.com/" + name };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ browser.test.sendMessage("uploadFile", createdAccount.id, "cloudFile1");
+ });
+
+ browser.test.log("test upload quota error");
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ spaceRemaining: 1,
+ });
+ await window.sendMessage(
+ "uploadFileError",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadWouldExceedQuota",
+ "Quota error: Can't upload file. Only 1KB left of quota."
+ );
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ spaceRemaining: -1,
+ });
+
+ browser.test.log("test upload file size limit error");
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ uploadSizeLimit: 1,
+ });
+ await window.sendMessage(
+ "uploadFileError",
+ createdAccount.id,
+ "cloudFile2",
+ "uploadExceedsFileLimit",
+ "Upload error: File size is 19KB and exceeds the file size limit of 1KB"
+ );
+ await browser.cloudFile.updateAccount(createdAccount.id, {
+ uploadSizeLimit: -1,
+ });
+
+ await removeCloudfileAccount(createdAccount.id);
+ browser.test.notifyPass("cloudFile");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ applications: { gecko: { id: "cloudfile@mochi.test" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ let testFiles = {
+ cloudFile1: new FileUtils.File(getTestFilePath("data/cloudFile1.txt")),
+ cloudFile2: new FileUtils.File(getTestFilePath("data/cloudFile2.txt")),
+ };
+
+ extension.onMessage("createAccount", (id = "ext-cloudfile@mochi.test") => {
+ cloudFileAccounts.createAccount(id);
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage(
+ "uploadFileError",
+ async (id, filename, expectedErrorStatus, expectedErrorMessage) => {
+ let account = cloudFileAccounts.getAccount(id);
+
+ let status;
+ try {
+ await account.uploadFile(null, testFiles[filename]);
+ } catch (ex) {
+ status = ex;
+ }
+
+ Assert.ok(
+ !!status,
+ `Upload should have failed for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.result,
+ cloudFileAccounts.constants[expectedErrorStatus],
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ extension.sendMessage();
+ }
+ );
+
+ extension.onMessage(
+ "uploadFile",
+ (id, filename, expectedErrorStatus = Cr.NS_OK, expectedErrorMessage) => {
+ let account = cloudFileAccounts.getAccount(id);
+
+ if (typeof expectedErrorStatus == "string") {
+ expectedErrorStatus = cloudFileAccounts.constants[expectedErrorStatus];
+ }
+
+ account.uploadFile(null, testFiles[filename]).then(
+ upload => {
+ Assert.equal(Cr.NS_OK, expectedErrorStatus);
+ },
+ status => {
+ Assert.equal(
+ status.result,
+ expectedErrorStatus,
+ `Error status should be correct for ${testFiles[filename].leafName}`
+ );
+ Assert.equal(
+ status.message,
+ expectedErrorMessage,
+ `Error message should be correct for ${testFiles[filename].leafName}`
+ );
+ }
+ );
+ }
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("cloudFile");
+ await extension.unload();
+
+ Assert.ok(!cloudFileAccounts.getProviderForType("ext-cloudfile@mochi.test"));
+ Assert.equal(cloudFileAccounts.accounts.length, 0);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js
new file mode 100644
index 0000000000..47b804a763
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js
@@ -0,0 +1,226 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+async function testExecuteBrowserActionWithOptions_mv2(options = {}) {
+ // Make sure the mouse isn't hovering over the browserAction widget.
+ let folderTree = document
+ .getElementById("tabmail")
+ .currentAbout3Pane.document.getElementById("folderTree");
+ EventUtils.synthesizeMouseAtCenter(folderTree, { type: "mouseover" }, window);
+
+ let extensionOptions = {
+ useAddonManager: "temporary",
+ };
+
+ extensionOptions.manifest = {
+ commands: {
+ _execute_browser_action: {
+ suggested_key: {
+ default: "Alt+Shift+J",
+ },
+ },
+ },
+ browser_action: {
+ browser_style: true,
+ },
+ };
+
+ if (options.withPopup) {
+ extensionOptions.manifest.browser_action.default_popup = "popup.html";
+
+ extensionOptions.files = {
+ "popup.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="popup.js"></script>
+ </head>
+ <body>
+ Popup
+ </body>
+ </html>
+ `,
+ "popup.js": function () {
+ browser.runtime.sendMessage("from-browser-action-popup");
+ },
+ };
+ }
+
+ extensionOptions.background = () => {
+ browser.test.onMessage.addListener((message, withPopup) => {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.fail(
+ "The onCommand listener should never fire for a valid _execute_* command."
+ );
+ });
+
+ browser.browserAction.onClicked.addListener(() => {
+ if (withPopup) {
+ browser.test.fail(
+ "The onClick listener should never fire if the browserAction has a popup."
+ );
+ browser.test.notifyFail("execute-browser-action-on-clicked-fired");
+ } else {
+ browser.test.notifyPass("execute-browser-action-on-clicked-fired");
+ }
+ });
+
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "from-browser-action-popup") {
+ browser.test.notifyPass("execute-browser-action-popup-opened");
+ }
+ });
+
+ browser.test.sendMessage("send-keys");
+ });
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionOptions);
+
+ extension.onMessage("send-keys", () => {
+ EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true });
+ });
+
+ await extension.startup();
+
+ await SimpleTest.promiseFocus(window);
+
+ // trigger setup of listeners in background and the send-keys msg
+ extension.sendMessage("withPopup", options.withPopup);
+
+ if (options.withPopup) {
+ await extension.awaitFinish("execute-browser-action-popup-opened");
+
+ if (!getBrowserActionPopup(extension)) {
+ await awaitExtensionPanel(extension);
+ }
+ await closeBrowserAction(extension);
+ } else {
+ await extension.awaitFinish("execute-browser-action-on-clicked-fired");
+ }
+ await extension.unload();
+}
+
+add_task(async function test_execute_browser_action_with_popup_mv2() {
+ await testExecuteBrowserActionWithOptions_mv2({
+ withPopup: true,
+ });
+});
+
+add_task(async function test_execute_browser_action_without_popup_mv2() {
+ await testExecuteBrowserActionWithOptions_mv2();
+});
+
+async function testExecuteActionWithOptions_mv3(options = {}) {
+ // Make sure the mouse isn't hovering over the action widget.
+ let folderTree = document
+ .getElementById("tabmail")
+ .currentAbout3Pane.document.getElementById("folderTree");
+ EventUtils.synthesizeMouseAtCenter(folderTree, { type: "mouseover" }, window);
+
+ let extensionOptions = {
+ useAddonManager: "temporary",
+ };
+
+ extensionOptions.manifest = {
+ manifest_version: 3,
+ commands: {
+ _execute_action: {
+ suggested_key: {
+ default: "Alt+Shift+J",
+ },
+ },
+ },
+ action: {
+ browser_style: true,
+ },
+ };
+
+ if (options.withPopup) {
+ extensionOptions.manifest.action.default_popup = "popup.html";
+
+ extensionOptions.files = {
+ "popup.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="popup.js"></script>
+ </head>
+ <body>
+ Popup
+ </body>
+ </html>
+ `,
+ "popup.js": function () {
+ browser.runtime.sendMessage("from-action-popup");
+ },
+ };
+ }
+
+ extensionOptions.background = () => {
+ browser.test.onMessage.addListener((message, withPopup) => {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.fail(
+ "The onCommand listener should never fire for a valid _execute_* command."
+ );
+ });
+
+ browser.action.onClicked.addListener(() => {
+ if (withPopup) {
+ browser.test.fail(
+ "The onClick listener should never fire if the action has a popup."
+ );
+ browser.test.notifyFail("execute-action-on-clicked-fired");
+ } else {
+ browser.test.notifyPass("execute-action-on-clicked-fired");
+ }
+ });
+
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "from-action-popup") {
+ browser.test.notifyPass("execute-action-popup-opened");
+ }
+ });
+
+ browser.test.sendMessage("send-keys");
+ });
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionOptions);
+
+ extension.onMessage("send-keys", () => {
+ EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true });
+ });
+
+ await extension.startup();
+
+ await SimpleTest.promiseFocus(window);
+
+ // trigger setup of listeners in background and the send-keys msg
+ extension.sendMessage("withPopup", options.withPopup);
+
+ if (options.withPopup) {
+ await extension.awaitFinish("execute-action-popup-opened");
+
+ if (!getBrowserActionPopup(extension)) {
+ await awaitExtensionPanel(extension);
+ }
+ await closeBrowserAction(extension);
+ } else {
+ await extension.awaitFinish("execute-action-on-clicked-fired");
+ }
+ await extension.unload();
+}
+
+add_task(async function test_execute_browser_action_with_popup_mv3() {
+ await testExecuteActionWithOptions_mv3({
+ withPopup: true,
+ });
+});
+
+add_task(async function test_execute_browser_action_without_popup_mv3() {
+ await testExecuteActionWithOptions_mv3();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_compose_action.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_compose_action.js
new file mode 100644
index 0000000000..a84a2cac3c
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_compose_action.js
@@ -0,0 +1,138 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+let gAccount;
+
+async function testExecuteComposeActionWithOptions(options = {}) {
+ info(
+ `--> Running test commands_execute_compose_action with the following options: ${JSON.stringify(
+ options
+ )}`
+ );
+
+ let extensionOptions = {};
+ extensionOptions.manifest = {
+ permissions: ["accountsRead"],
+ commands: {
+ _execute_compose_action: {
+ suggested_key: {
+ default: "Alt+Shift+J",
+ mac: "Ctrl+Shift+J",
+ },
+ },
+ },
+ compose_action: {
+ browser_style: true,
+ },
+ };
+
+ if (options.withFormatToolbar) {
+ extensionOptions.manifest.compose_action.default_area = "formattoolbar";
+ }
+
+ if (options.withPopup) {
+ extensionOptions.manifest.compose_action.default_popup = "popup.html";
+
+ extensionOptions.files = {
+ "popup.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="popup.js"></script>
+ </head>
+ <body>
+ Popup
+ </body>
+ </html>
+ `,
+ "popup.js": function () {
+ browser.test.log("sending from-compose-action-popup");
+ browser.runtime.sendMessage("from-compose-action-popup");
+ },
+ };
+ }
+
+ extensionOptions.background = async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length, "number of accounts");
+
+ browser.test.onMessage.addListener((message, withPopup) => {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.fail(
+ "The onCommand listener should never fire for a valid _execute_* command."
+ );
+ });
+
+ browser.composeAction.onClicked.addListener(() => {
+ if (withPopup) {
+ browser.test.fail(
+ "The onClick listener should never fire if the composeAction has a popup."
+ );
+ browser.test.notifyFail("execute-compose-action-on-clicked-fired");
+ } else {
+ browser.test.notifyPass("execute-compose-action-on-clicked-fired");
+ }
+ });
+
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "from-compose-action-popup") {
+ browser.test.notifyPass("execute-compose-action-popup-opened");
+ }
+ });
+
+ browser.test.log("Sending send-keys");
+ browser.test.sendMessage("send-keys");
+ });
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionOptions);
+ await extension.startup();
+
+ let composeWindow = await openComposeWindow(gAccount);
+ await focusWindow(composeWindow);
+
+ // trigger setup of listeners in background and the send-keys msg
+ extension.sendMessage("withPopup", options.withPopup);
+
+ await extension.awaitMessage("send-keys");
+ info("Simulating ALT+SHIFT+J");
+ let modifiers =
+ AppConstants.platform == "macosx"
+ ? { metaKey: true, shiftKey: true }
+ : { altKey: true, shiftKey: true };
+ EventUtils.synthesizeKey("j", modifiers, composeWindow);
+
+ if (options.withPopup) {
+ await extension.awaitFinish("execute-compose-action-popup-opened");
+
+ if (!getBrowserActionPopup(extension, composeWindow)) {
+ await awaitExtensionPanel(extension, composeWindow);
+ }
+ await closeBrowserAction(extension, composeWindow);
+ } else {
+ await extension.awaitFinish("execute-compose-action-on-clicked-fired");
+ }
+ composeWindow.close();
+ await extension.unload();
+}
+
+add_setup(async () => {
+ gAccount = createAccount();
+ addIdentity(gAccount);
+});
+
+let popupJobs = [true, false];
+let formatToolbarJobs = [true, false];
+
+for (let popupJob of popupJobs) {
+ for (let formatToolbarJob of formatToolbarJobs) {
+ add_task(async () => {
+ await testExecuteComposeActionWithOptions({
+ withPopup: popupJob,
+ withFormatToolbar: formatToolbarJob,
+ });
+ });
+ }
+}
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_message_display_action.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_message_display_action.js
new file mode 100644
index 0000000000..2b35b791ec
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_execute_message_display_action.js
@@ -0,0 +1,168 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+let gMessages;
+
+async function testExecuteMessageDisplayActionWithOptions(msg, options = {}) {
+ info(
+ `--> Running test commands_execute_message_display_action with the following options: ${JSON.stringify(
+ options
+ )}`
+ );
+
+ let extensionOptions = {};
+ extensionOptions.manifest = {
+ commands: {
+ _execute_message_display_action: {
+ suggested_key: {
+ default: "Alt+Shift+J",
+ },
+ },
+ },
+ message_display_action: {
+ browser_style: true,
+ },
+ };
+
+ if (options.withPopup) {
+ extensionOptions.manifest.message_display_action.default_popup =
+ "popup.html";
+
+ extensionOptions.files = {
+ "popup.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="popup.js"></script>
+ </head>
+ <body>
+ Popup
+ </body>
+ </html>
+ `,
+ "popup.js": function () {
+ browser.test.log("sending from-message-display-action-popup");
+ browser.runtime.sendMessage("from-message-display-action-popup");
+ },
+ };
+ }
+
+ extensionOptions.background = () => {
+ browser.test.onMessage.addListener((message, withPopup) => {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.fail(
+ "The onCommand listener should never fire for a valid _execute_* command."
+ );
+ });
+
+ browser.messageDisplayAction.onClicked.addListener(() => {
+ if (withPopup) {
+ browser.test.fail(
+ "The onClick listener should never fire if the messageDisplayAction has a popup."
+ );
+ browser.test.notifyFail(
+ "execute-message-display-action-on-clicked-fired"
+ );
+ } else {
+ browser.test.notifyPass(
+ "execute-message-display-action-on-clicked-fired"
+ );
+ }
+ });
+
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "from-message-display-action-popup") {
+ browser.test.notifyPass(
+ "execute-message-display-action-popup-opened"
+ );
+ }
+ });
+
+ browser.test.log("Sending send-keys");
+ browser.test.sendMessage("send-keys");
+ });
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionOptions);
+
+ extension.onMessage("send-keys", () => {
+ info("Simulating ALT+SHIFT+J");
+ EventUtils.synthesizeKey(
+ "j",
+ { altKey: true, shiftKey: true },
+ messageWindow
+ );
+ });
+
+ await extension.startup();
+
+ let tabmail = document.getElementById("tabmail");
+ let messageWindow = window;
+ let aboutMessage = tabmail.currentAboutMessage;
+ switch (options.displayType) {
+ case "tab":
+ await openMessageInTab(msg);
+ aboutMessage = tabmail.currentAboutMessage;
+ break;
+ case "window":
+ messageWindow = await openMessageInWindow(msg);
+ aboutMessage = messageWindow.messageBrowser.contentWindow;
+ break;
+ }
+ await SimpleTest.promiseFocus(aboutMessage);
+
+ // trigger setup of listeners in background and the send-keys msg
+ extension.sendMessage("withPopup", options.withPopup);
+
+ if (options.withPopup) {
+ await extension.awaitFinish("execute-message-display-action-popup-opened");
+
+ if (!getBrowserActionPopup(extension, aboutMessage)) {
+ await awaitExtensionPanel(extension, aboutMessage);
+ }
+ await closeBrowserAction(extension, aboutMessage);
+ } else {
+ await extension.awaitFinish(
+ "execute-message-display-action-on-clicked-fired"
+ );
+ }
+
+ switch (options.displayType) {
+ case "tab":
+ tabmail.closeTab();
+ break;
+ case "window":
+ messageWindow.close();
+ break;
+ }
+
+ await extension.unload();
+}
+
+add_setup(async () => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ gMessages = [...subFolders[0].messages];
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(subFolders[0].URI);
+ about3Pane.threadTree.selectedIndex = 0;
+});
+
+let popupJobs = [true, false];
+let displayJobs = ["3pane", "tab", "window"];
+
+for (let popupJob of popupJobs) {
+ for (let displayJob of displayJobs) {
+ add_task(async () => {
+ await testExecuteMessageDisplayActionWithOptions(gMessages[1], {
+ withPopup: popupJob,
+ displayType: displayJob,
+ });
+ });
+ }
+}
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_getAll.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_getAll.js
new file mode 100644
index 0000000000..c38fdc291c
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_getAll.js
@@ -0,0 +1,142 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function () {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "_locales/en/messages.json": {
+ with_translation: {
+ message: "The description",
+ description: "A description",
+ },
+ },
+ },
+ manifest: {
+ name: "Commands Extension",
+ default_locale: "en",
+ commands: {
+ "with-desciption": {
+ suggested_key: {
+ default: "Ctrl+Shift+Y",
+ },
+ description: "should have a description",
+ },
+ "without-description": {
+ suggested_key: {
+ default: "Ctrl+Shift+D",
+ },
+ },
+ "with-platform-info": {
+ suggested_key: {
+ mac: "Ctrl+Shift+M",
+ linux: "Ctrl+Shift+L",
+ windows: "Ctrl+Shift+W",
+ android: "Ctrl+Shift+A",
+ },
+ },
+ "with-translation": {
+ description: "__MSG_with_translation__",
+ },
+ "without-suggested-key": {
+ description: "has no suggested_key",
+ },
+ "without-suggested-key-nor-description": {},
+ },
+ },
+
+ background() {
+ browser.test.onMessage.addListener((message, additionalScope) => {
+ browser.commands.getAll(commands => {
+ let errorMessage = "getAll should return an array of commands";
+ browser.test.assertEq(commands.length, 6, errorMessage);
+
+ let command = commands.find(c => c.name == "with-desciption");
+
+ errorMessage =
+ "The description should match what is provided in the manifest";
+ browser.test.assertEq(
+ "should have a description",
+ command.description,
+ errorMessage
+ );
+
+ errorMessage =
+ "The shortcut should match the default shortcut provided in the manifest";
+ browser.test.assertEq("Ctrl+Shift+Y", command.shortcut, errorMessage);
+
+ command = commands.find(c => c.name == "without-description");
+
+ errorMessage =
+ "The description should be empty when it is not provided";
+ browser.test.assertEq(null, command.description, errorMessage);
+
+ errorMessage =
+ "The shortcut should match the default shortcut provided in the manifest";
+ browser.test.assertEq("Ctrl+Shift+D", command.shortcut, errorMessage);
+
+ let platformKeys = {
+ macosx: "M",
+ linux: "L",
+ win: "W",
+ android: "A",
+ };
+
+ command = commands.find(c => c.name == "with-platform-info");
+ let platformKey = platformKeys[additionalScope.platform];
+ let shortcut = `Ctrl+Shift+${platformKey}`;
+ errorMessage = `The shortcut should match the one provided in the manifest for OS='${additionalScope.platform}'`;
+ browser.test.assertEq(shortcut, command.shortcut, errorMessage);
+
+ command = commands.find(c => c.name == "with-translation");
+ browser.test.assertEq(
+ command.description,
+ "The description",
+ "The description can be localized"
+ );
+
+ command = commands.find(c => c.name == "without-suggested-key");
+
+ browser.test.assertEq(
+ "has no suggested_key",
+ command.description,
+ "The description should match what is provided in the manifest"
+ );
+
+ browser.test.assertEq(
+ "",
+ command.shortcut,
+ "The shortcut should be empty if not provided"
+ );
+
+ command = commands.find(
+ c => c.name == "without-suggested-key-nor-description"
+ );
+
+ browser.test.assertEq(
+ null,
+ command.description,
+ "The description should be empty when it is not provided"
+ );
+
+ browser.test.assertEq(
+ "",
+ command.shortcut,
+ "The shortcut should be empty if not provided"
+ );
+
+ browser.test.notifyPass("commands");
+ });
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ extension.sendMessage("additional-scope", {
+ platform: AppConstants.platform,
+ });
+ await extension.awaitFinish("commands");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_onChanged.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_onChanged.js
new file mode 100644
index 0000000000..db90d71f00
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_onChanged.js
@@ -0,0 +1,59 @@
+"use strict";
+
+add_task(async function () {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "commands@mochi.test" } },
+ commands: {
+ foo: {
+ suggested_key: {
+ default: "Ctrl+Shift+V",
+ },
+ description: "The foo command",
+ },
+ },
+ },
+ async background() {
+ const { commands } = browser.runtime.getManifest();
+
+ const originalFoo = commands.foo;
+
+ let resolver = {};
+ resolver.promise = new Promise(resolve => (resolver.resolve = resolve));
+
+ browser.commands.onChanged.addListener(update => {
+ browser.test.assertDeepEq(
+ update,
+ {
+ name: "foo",
+ newShortcut: "Ctrl+Shift+L",
+ oldShortcut: originalFoo.suggested_key.default,
+ },
+ `The name should match what was provided in the manifest.
+ The new shortcut should match what was provided in the update.
+ The old shortcut should match what was provided in the manifest
+ `
+ );
+ browser.test.assertFalse(
+ resolver.hasResolvedAlready,
+ `resolver was not resolved yet`
+ );
+ resolver.resolve();
+ resolver.hasResolvedAlready = true;
+ });
+
+ await browser.commands.update({ name: "foo", shortcut: "Ctrl+Shift+L" });
+ // We're checking that nothing emits when
+ // the new shortcut is identical to the old one
+ await browser.commands.update({ name: "foo", shortcut: "Ctrl+Shift+L" });
+
+ await resolver.promise;
+
+ browser.test.notifyPass("commands");
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("commands");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand.js
new file mode 100644
index 0000000000..82928957f4
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand.js
@@ -0,0 +1,577 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+var testCommands = [
+ // Ctrl Shortcuts
+ {
+ name: "toggle-ctrl-a",
+ shortcut: "Ctrl+A",
+ key: "A",
+ // Does not work in compose window on Linux.
+ skip: ["messageCompose", "content"],
+ modifiers: {
+ accelKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-up",
+ shortcut: "Ctrl+Up",
+ key: "VK_UP",
+ modifiers: {
+ accelKey: true,
+ },
+ },
+ // Alt Shortcuts
+ {
+ name: "toggle-alt-a",
+ shortcut: "Alt+A",
+ key: "A",
+ // Does not work in compose window on Mac.
+ skip: ["messageCompose"],
+ modifiers: {
+ altKey: true,
+ },
+ },
+ {
+ name: "toggle-alt-down",
+ shortcut: "Alt+Down",
+ key: "VK_DOWN",
+ modifiers: {
+ altKey: true,
+ },
+ },
+ // Mac Shortcuts
+ {
+ name: "toggle-command-shift-page-up",
+ shortcutMac: "Command+Shift+PageUp",
+ key: "VK_PAGE_UP",
+ modifiers: {
+ accelKey: true,
+ shiftKey: true,
+ },
+ },
+ {
+ name: "toggle-mac-control-shift+period",
+ shortcut: "Ctrl+Shift+Period",
+ shortcutMac: "MacCtrl+Shift+Period",
+ key: "VK_PERIOD",
+ modifiers: {
+ ctrlKey: true,
+ shiftKey: true,
+ },
+ },
+ // Ctrl+Shift Shortcuts
+ {
+ name: "toggle-ctrl-shift-left",
+ shortcut: "Ctrl+Shift+Left",
+ key: "VK_LEFT",
+ modifiers: {
+ accelKey: true,
+ shiftKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-shift-1",
+ shortcut: "Ctrl+Shift+1",
+ key: "1",
+ modifiers: {
+ accelKey: true,
+ shiftKey: true,
+ },
+ },
+ // Alt+Shift Shortcuts
+ {
+ name: "toggle-alt-shift-1",
+ shortcut: "Alt+Shift+1",
+ key: "1",
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ },
+ // TODO: This results in multiple events fired. See bug 1805375.
+ /*
+ {
+ name: "toggle-alt-shift-a",
+ shortcut: "Alt+Shift+A",
+ key: "A",
+ // Does not work in compose window on Mac.
+ skip: ["messageCompose"],
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ },
+ */
+ {
+ name: "toggle-alt-shift-right",
+ shortcut: "Alt+Shift+Right",
+ key: "VK_RIGHT",
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ },
+ // Function keys
+ {
+ name: "function-keys-Alt+Shift+F3",
+ shortcut: "Alt+Shift+F3",
+ key: "VK_F3",
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ },
+ {
+ name: "function-keys-F2",
+ shortcut: "F2",
+ key: "VK_F2",
+ modifiers: {
+ altKey: false,
+ shiftKey: false,
+ },
+ },
+ // Misc Shortcuts
+ {
+ name: "valid-command-with-unrecognized-property-name",
+ shortcut: "Alt+Shift+3",
+ key: "3",
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ unrecognized_property: "with-a-random-value",
+ },
+ {
+ name: "spaces-in-shortcut-name",
+ shortcut: " Alt + Shift + 2 ",
+ key: "2",
+ modifiers: {
+ altKey: true,
+ shiftKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-space",
+ shortcut: "Ctrl+Space",
+ key: "VK_SPACE",
+ modifiers: {
+ accelKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-comma",
+ shortcut: "Ctrl+Comma",
+ key: "VK_COMMA",
+ modifiers: {
+ accelKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-period",
+ shortcut: "Ctrl+Period",
+ key: "VK_PERIOD",
+ modifiers: {
+ accelKey: true,
+ },
+ },
+ {
+ name: "toggle-ctrl-alt-v",
+ shortcut: "Ctrl+Alt+V",
+ key: "V",
+ modifiers: {
+ accelKey: true,
+ altKey: true,
+ },
+ },
+];
+
+requestLongerTimeout(2);
+
+add_task(async function test_user_defined_commands() {
+ let win1 = await openNewMailWindow();
+
+ let commands = {};
+ let isMac = AppConstants.platform == "macosx";
+ let totalMacOnlyCommands = 0;
+ let numberNumericCommands = 4;
+
+ for (let testCommand of testCommands) {
+ let command = {
+ suggested_key: {},
+ };
+
+ if (testCommand.shortcut) {
+ command.suggested_key.default = testCommand.shortcut;
+ }
+
+ if (testCommand.shortcutMac) {
+ command.suggested_key.mac = testCommand.shortcutMac;
+ }
+
+ if (testCommand.shortcutMac && !testCommand.shortcut) {
+ totalMacOnlyCommands++;
+ }
+
+ if (testCommand.unrecognized_property) {
+ command.unrecognized_property = testCommand.unrecognized_property;
+ }
+
+ commands[testCommand.name] = command;
+ }
+
+ function background() {
+ browser.commands.onCommand.addListener((commandName, activeTab) => {
+ browser.test.sendMessage("oncommand event received", {
+ commandName,
+ activeTab,
+ });
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands,
+ },
+ background,
+ });
+
+ SimpleTest.waitForExplicitFinish();
+ let waitForConsole = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, [
+ {
+ message:
+ /Reading manifest: Warning processing commands.*.unrecognized_property: An unexpected property was found/,
+ },
+ ]);
+ });
+
+ // Unrecognized_property in manifest triggers warning.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ await extension.awaitMessage("ready");
+
+ async function runTest(window, expectedTabType) {
+ for (let testCommand of testCommands) {
+ if (testCommand.skip && testCommand.skip.includes(expectedTabType)) {
+ continue;
+ }
+ if (testCommand.shortcutMac && !testCommand.shortcut && !isMac) {
+ continue;
+ }
+ await BrowserTestUtils.synthesizeKey(
+ testCommand.key,
+ testCommand.modifiers,
+ window.browsingContext
+ );
+ let message = await extension.awaitMessage("oncommand event received");
+ is(
+ message.commandName,
+ testCommand.name,
+ `Expected onCommand listener to fire with the correct name: ${testCommand.name}`
+ );
+ is(
+ message.activeTab.type,
+ expectedTabType,
+ `Expected onCommand listener to fire with the correct tab type: ${expectedTabType}`
+ );
+ }
+ }
+
+ // Create another window after the extension is loaded.
+ let win2 = await openNewMailWindow();
+
+ let totalTestCommands =
+ Object.keys(testCommands).length + numberNumericCommands;
+ let expectedCommandsRegistered = isMac
+ ? totalTestCommands
+ : totalTestCommands - totalMacOnlyCommands;
+
+ let account = createAccount();
+ addIdentity(account);
+ let win3 = await openComposeWindow(account);
+ // Some key combinations do not work if the TO field has focus.
+ win3.document.querySelector("editor").focus();
+
+ // Confirm the keysets have been added to all windows.
+ let keysetID = `ext-keyset-id-${makeWidgetId(extension.id)}`;
+
+ let keyset = win1.document.getElementById(keysetID);
+ ok(keyset != null, "Expected keyset to exist");
+ is(
+ keyset.children.length,
+ expectedCommandsRegistered,
+ "Expected keyset of window #1 to have the correct number of children"
+ );
+
+ keyset = win2.document.getElementById(keysetID);
+ ok(keyset != null, "Expected keyset to exist");
+ is(
+ keyset.children.length,
+ expectedCommandsRegistered,
+ "Expected keyset of window #2 to have the correct number of children"
+ );
+
+ keyset = win3.document.getElementById(keysetID);
+ ok(keyset != null, "Expected keyset to exist");
+ is(
+ keyset.children.length,
+ expectedCommandsRegistered,
+ "Expected keyset of window #3 to have the correct number of children"
+ );
+
+ // Confirm that the commands are registered to all windows.
+ await focusWindow(win1);
+ await runTest(win1, "mail");
+
+ await focusWindow(win2);
+ await runTest(win2, "mail");
+
+ await focusWindow(win3);
+ await runTest(win3, "messageCompose");
+
+ // Unload the extension and confirm that the keysets have been removed from all windows.
+ await extension.unload();
+
+ keyset = win1.document.getElementById(keysetID);
+ is(keyset, null, "Expected keyset to be removed from the window #1");
+
+ keyset = win2.document.getElementById(keysetID);
+ is(keyset, null, "Expected keyset to be removed from the window #2");
+
+ keyset = win3.document.getElementById(keysetID);
+ is(keyset, null, "Expected keyset to be removed from the window #3");
+
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+ await BrowserTestUtils.closeWindow(win3);
+
+ SimpleTest.endMonitorConsole();
+ await waitForConsole;
+});
+
+add_task(async function test_commands_MV3_event_page() {
+ let win1 = await openNewMailWindow();
+
+ let commands = {};
+ let isMac = AppConstants.platform == "macosx";
+ let totalMacOnlyCommands = 0;
+ let numberNumericCommands = 4;
+
+ for (let testCommand of testCommands) {
+ let command = {
+ suggested_key: {},
+ };
+
+ if (testCommand.shortcut) {
+ command.suggested_key.default = testCommand.shortcut;
+ }
+
+ if (testCommand.shortcutMac) {
+ command.suggested_key.mac = testCommand.shortcutMac;
+ }
+
+ if (testCommand.shortcutMac && !testCommand.shortcut) {
+ totalMacOnlyCommands++;
+ }
+
+ if (testCommand.unrecognized_property) {
+ command.unrecognized_property = testCommand.unrecognized_property;
+ }
+
+ commands[testCommand.name] = command;
+ }
+
+ function background() {
+ // Whenever the extension starts or wakes up, the eventCounter is reset and
+ // allows to observe the order of events fired. In case of a wake-up, the
+ // first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ browser.test.onMessage.addListener(async message => {
+ if (message == "createPopup") {
+ let popup = await browser.windows.create({
+ type: "popup",
+ url: "example.html",
+ });
+ browser.test.sendMessage("popupCreated", popup);
+ }
+ });
+
+ browser.commands.onCommand.addListener(async (commandName, activeTab) => {
+ browser.test.sendMessage("oncommand event received", {
+ eventCount: ++eventCounter,
+ commandName,
+ activeTab,
+ });
+ });
+ browser.test.sendMessage("ready");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ "example.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>EXAMPLE</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p>This is an example page</p>
+ </body>
+ </html>`,
+ },
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_specific_settings: { gecko: { id: "commands@mochi.test" } },
+ commands,
+ },
+ });
+
+ SimpleTest.waitForExplicitFinish();
+ let waitForConsole = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, [
+ {
+ message:
+ /Reading manifest: Warning processing commands.*.unrecognized_property: An unexpected property was found/,
+ },
+ ]);
+ });
+
+ // Unrecognized_property in manifest triggers warning.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ await extension.awaitMessage("ready");
+
+ // Check for persistent listener.
+ assertPersistentListeners(extension, "commands", "onCommand", {
+ primed: false,
+ });
+
+ let gEventCounter = 0;
+ async function runTest(window, expectedTabType) {
+ // The second run will terminate the background script before each keypress,
+ // verifying that the background script is waking up correctly.
+ for (let terminateBackground of [false, true]) {
+ for (let testCommand of testCommands) {
+ if (testCommand.skip && testCommand.skip.includes(expectedTabType)) {
+ continue;
+ }
+ if (testCommand.shortcutMac && !testCommand.shortcut && !isMac) {
+ continue;
+ }
+
+ if (terminateBackground) {
+ gEventCounter = 0;
+ }
+
+ if (terminateBackground) {
+ // Terminate the background and verify the primed persistent listener.
+ await extension.terminateBackground({
+ disableResetIdleForTest: true,
+ });
+ assertPersistentListeners(extension, "commands", "onCommand", {
+ primed: true,
+ });
+ await BrowserTestUtils.synthesizeKey(
+ testCommand.key,
+ testCommand.modifiers,
+ window.browsingContext
+ );
+ // Wait for background restart.
+ await extension.awaitMessage("ready");
+ } else {
+ await BrowserTestUtils.synthesizeKey(
+ testCommand.key,
+ testCommand.modifiers,
+ window.browsingContext
+ );
+ }
+
+ let message = await extension.awaitMessage("oncommand event received");
+ is(
+ message.commandName,
+ testCommand.name,
+ `onCommand listener should fire with the correct command name`
+ );
+ is(
+ message.activeTab.type,
+ expectedTabType,
+ `onCommand listener should fire with the correct tab type`
+ );
+ is(
+ message.eventCount,
+ ++gEventCounter,
+ `Event counter should be correct`
+ );
+ }
+ }
+ }
+
+ // Create another window after the extension is loaded.
+ let win2 = await openNewMailWindow();
+
+ let totalTestCommands =
+ Object.keys(testCommands).length + numberNumericCommands;
+ let expectedCommandsRegistered = isMac
+ ? totalTestCommands
+ : totalTestCommands - totalMacOnlyCommands;
+
+ let account = createAccount();
+ addIdentity(account);
+ let win3 = await openComposeWindow(account);
+ // Some key combinations do not work if the TO field has focus.
+ win3.document.querySelector("editor").focus();
+
+ // Open a popup window.
+ let popupPromise = extension.awaitMessage("popupCreated");
+ extension.sendMessage("createPopup");
+ let popup = await popupPromise;
+ let win4 = Services.wm.getOuterWindowWithId(popup.id);
+
+ // Confirm the keysets have been added to all windows.
+ let keysetID = `ext-keyset-id-${makeWidgetId(extension.id)}`;
+
+ let windows = [
+ { window: win1, autoRemove: false, type: "mail" },
+ { window: win2, autoRemove: false, type: "mail" },
+ { window: win3, autoRemove: false, type: "messageCompose" },
+ { window: win4, autoRemove: true, type: "content" },
+ ];
+ for (let i in windows) {
+ let keyset = windows[i].window.document.getElementById(keysetID);
+ ok(keyset != null, "Expected keyset to exist");
+ is(
+ keyset.children.length,
+ expectedCommandsRegistered,
+ `Expected keyset of window #${i} to have the correct number of children`
+ );
+
+ // Confirm that the commands are registered to all windows.
+ await focusWindow(windows[i].window);
+ await runTest(windows[i].window, windows[i].type);
+ }
+
+ // Unload the extension and confirm that the keysets have been removed from
+ // all windows.
+ await extension.unload();
+ for (let i in windows) {
+ // Extension popup windows are removed/closed on extension unload, so they
+ // have to skip this part of the test.
+ if (windows[i].autoRemove) {
+ continue;
+ }
+ let keyset = windows[i].window.document.getElementById(keysetID);
+ is(keyset, null, `Expected keyset to be removed from the window #${i}`);
+ await BrowserTestUtils.closeWindow(windows[i].window);
+ }
+
+ SimpleTest.endMonitorConsole();
+ await waitForConsole;
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand_bug1845236.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand_bug1845236.js
new file mode 100644
index 0000000000..3a240cc1ce
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_onCommand_bug1845236.js
@@ -0,0 +1,74 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_multiple_messages_selected() {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 2);
+ await TestUtils.waitForCondition(
+ () => subFolders[0].messages.hasMoreElements(),
+ "Messages should be added to folder"
+ );
+
+ async function background() {
+ browser.commands.onCommand.addListener((commandName, activeTab) => {
+ browser.test.sendMessage("oncommand event received", {
+ commandName,
+ activeTab,
+ });
+ });
+
+ let { messages } = await browser.messages.query({});
+ await browser.mailTabs.setSelectedMessages(messages.map(m => m.id));
+ let { messages: selectedMessages } =
+ await browser.mailTabs.getSelectedMessages();
+ browser.test.assertEq(
+ selectedMessages.length,
+ 2,
+ "Should have two messages selected"
+ );
+
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["accountsRead", "messagesRead"],
+ commands: {
+ "test-multi-message": {
+ suggested_key: {
+ default: "Ctrl+Up",
+ },
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // Trigger the registered command.
+ await BrowserTestUtils.synthesizeKey(
+ "VK_UP",
+ {
+ accelKey: true,
+ },
+ window.browsingContext
+ );
+ let message = await extension.awaitMessage("oncommand event received");
+ is(
+ message.commandName,
+ "test-multi-message",
+ `Expected onCommand listener to fire with the correct name: test-multi-message`
+ );
+ is(
+ message.activeTab.type,
+ "mail",
+ `Expected onCommand listener to fire with the correct tab type: mail`
+ );
+
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_commands_update.js b/comm/mail/components/extensions/test/browser/browser_ext_commands_update.js
new file mode 100644
index 0000000000..1d57585ca6
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_commands_update.js
@@ -0,0 +1,357 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+});
+
+function enableAddon(addon) {
+ return new Promise(resolve => {
+ AddonManager.addAddonListener({
+ onEnabled(enabledAddon) {
+ if (enabledAddon.id == addon.id) {
+ resolve();
+ AddonManager.removeAddonListener(this);
+ }
+ },
+ });
+ addon.enable();
+ });
+}
+
+function disableAddon(addon) {
+ return new Promise(resolve => {
+ AddonManager.addAddonListener({
+ onDisabled(disabledAddon) {
+ if (disabledAddon.id == addon.id) {
+ resolve();
+ AddonManager.removeAddonListener(this);
+ }
+ },
+ });
+ addon.disable();
+ });
+}
+
+add_task(async function test_update_defined_command() {
+ let extension;
+ let updatedExtension;
+
+ registerCleanupFunction(async () => {
+ await extension.unload();
+
+ // updatedExtension might not have started up if we didn't make it that far.
+ if (updatedExtension) {
+ await updatedExtension.unload();
+ }
+
+ // Check that ESS is cleaned up on uninstall.
+ let storedCommands = ExtensionSettingsStore.getAllForExtension(
+ extension.id,
+ "commands"
+ );
+ is(storedCommands.length, 0, "There are no stored commands after unload");
+ });
+
+ extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: { gecko: { id: "commands_update@mochi.test" } },
+ commands: {
+ foo: {
+ suggested_key: {
+ default: "Ctrl+Shift+I",
+ },
+ description: "The foo command",
+ },
+ },
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, data) => {
+ if (msg == "update") {
+ await browser.commands.update(data);
+ browser.test.sendMessage("updateDone");
+ return;
+ } else if (msg == "reset") {
+ await browser.commands.reset(data);
+ browser.test.sendMessage("resetDone");
+ return;
+ } else if (msg != "run") {
+ return;
+ }
+ // Test initial manifest command.
+ let commands = await browser.commands.getAll();
+ browser.test.assertEq(1, commands.length, "There is 1 command");
+ let command = commands[0];
+ browser.test.assertEq("foo", command.name, "The name is right");
+ browser.test.assertEq(
+ "The foo command",
+ command.description,
+ "The description is right"
+ );
+ browser.test.assertEq(
+ "Ctrl+Shift+I",
+ command.shortcut,
+ "The shortcut is right"
+ );
+
+ // Update the shortcut.
+ await browser.commands.update({
+ name: "foo",
+ shortcut: "Ctrl+Shift+L",
+ });
+
+ // Test the updated shortcut.
+ commands = await browser.commands.getAll();
+ browser.test.assertEq(1, commands.length, "There is still 1 command");
+ command = commands[0];
+ browser.test.assertEq("foo", command.name, "The name is unchanged");
+ browser.test.assertEq(
+ "The foo command",
+ command.description,
+ "The description is unchanged"
+ );
+ browser.test.assertEq(
+ "Ctrl+Shift+L",
+ command.shortcut,
+ "The shortcut is updated"
+ );
+
+ // Update the description.
+ await browser.commands.update({
+ name: "foo",
+ description: "The only command",
+ });
+
+ // Test the updated shortcut.
+ commands = await browser.commands.getAll();
+ browser.test.assertEq(1, commands.length, "There is still 1 command");
+ command = commands[0];
+ browser.test.assertEq("foo", command.name, "The name is unchanged");
+ browser.test.assertEq(
+ "The only command",
+ command.description,
+ "The description is updated"
+ );
+ browser.test.assertEq(
+ "Ctrl+Shift+L",
+ command.shortcut,
+ "The shortcut is unchanged"
+ );
+
+ // Clear the shortcut.
+ await browser.commands.update({
+ name: "foo",
+ shortcut: "",
+ });
+ commands = await browser.commands.getAll();
+ browser.test.assertEq(1, commands.length, "There is still 1 command");
+ command = commands[0];
+ browser.test.assertEq("foo", command.name, "The name is unchanged");
+ browser.test.assertEq(
+ "The only command",
+ command.description,
+ "The description is unchanged"
+ );
+ browser.test.assertEq("", command.shortcut, "The shortcut is empty");
+
+ // Update the description and shortcut.
+ await browser.commands.update({
+ name: "foo",
+ description: "The new command",
+ shortcut: " Alt+ Shift +9",
+ });
+
+ // Test the updated shortcut.
+ commands = await browser.commands.getAll();
+ browser.test.assertEq(1, commands.length, "There is still 1 command");
+ command = commands[0];
+ browser.test.assertEq("foo", command.name, "The name is unchanged");
+ browser.test.assertEq(
+ "The new command",
+ command.description,
+ "The description is updated"
+ );
+ browser.test.assertEq(
+ "Alt+Shift+9",
+ command.shortcut,
+ "The shortcut is updated"
+ );
+
+ // Test a bad shortcut update.
+ browser.test.assertThrows(
+ () =>
+ browser.commands.update({ name: "foo", shortcut: "Ctl+Shift+L" }),
+ /Type error for parameter detail .+ primary modifier and a key/,
+ "It rejects for a bad shortcut"
+ );
+
+ // Try to update a command that doesn't exist.
+ await browser.test.assertRejects(
+ browser.commands.update({ name: "bar", shortcut: "Ctrl+Shift+L" }),
+ 'Unknown command "bar"',
+ "It rejects for an unknown command"
+ );
+
+ browser.test.notifyPass("commands");
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await extension.startup();
+
+ function extensionKeyset(extensionId) {
+ return document.getElementById(
+ makeWidgetId(`ext-keyset-id-${extensionId}`)
+ );
+ }
+
+ function checkKey(extensionId, shortcutKey, modifiers) {
+ let keyset = extensionKeyset(extensionId);
+ is(keyset.children.length, 1, "There is 1 key in the keyset");
+ let key = keyset.children[0];
+ is(key.getAttribute("key"), shortcutKey, "The key is correct");
+ is(key.getAttribute("modifiers"), modifiers, "The modifiers are correct");
+ }
+
+ function checkNumericKey(extensionId, key, modifiers) {
+ let keyset = extensionKeyset(extensionId);
+ is(
+ keyset.children.length,
+ 2,
+ "There are 2 keys in the keyset now, 1 of which contains a keycode."
+ );
+ let numpadKey = keyset.children[0];
+ is(
+ numpadKey.getAttribute("keycode"),
+ `VK_NUMPAD${key}`,
+ "The numpad keycode is correct."
+ );
+ is(
+ numpadKey.getAttribute("modifiers"),
+ modifiers,
+ "The modifiers are correct"
+ );
+
+ let originalNumericKey = keyset.children[1];
+ is(
+ originalNumericKey.getAttribute("keycode"),
+ `VK_${key}`,
+ "The original key is correct."
+ );
+ is(
+ originalNumericKey.getAttribute("modifiers"),
+ modifiers,
+ "The modifiers are correct"
+ );
+ }
+
+ // Check that the <key> is set for the original shortcut.
+ checkKey(extension.id, "I", "accel,shift");
+
+ await extension.awaitMessage("ready");
+ extension.sendMessage("run");
+ await extension.awaitFinish("commands");
+
+ // Check that the <keycode> has been updated.
+ checkNumericKey(extension.id, "9", "alt,shift");
+
+ // Check that the updated command is stored in ExtensionSettingsStore.
+ let storedCommands = ExtensionSettingsStore.getAllForExtension(
+ extension.id,
+ "commands"
+ );
+ is(storedCommands.length, 1, "There is only one stored command");
+ let command = ExtensionSettingsStore.getSetting(
+ "commands",
+ "foo",
+ extension.id
+ ).value;
+ is(command.description, "The new command", "The description is stored");
+ is(command.shortcut, "Alt+Shift+9", "The shortcut is stored");
+
+ // Check that the key is updated immediately.
+ extension.sendMessage("update", { name: "foo", shortcut: "Ctrl+Shift+M" });
+ await extension.awaitMessage("updateDone");
+ checkKey(extension.id, "M", "accel,shift");
+
+ // Ensure all successive updates are stored.
+ // Force the command to only have a description saved.
+ await ExtensionSettingsStore.addSetting(extension.id, "commands", "foo", {
+ description: "description only",
+ });
+ // This command now only has a description set in storage, also update the shortcut.
+ extension.sendMessage("update", { name: "foo", shortcut: "Alt+Shift+9" });
+ await extension.awaitMessage("updateDone");
+ let storedCommand = await ExtensionSettingsStore.getSetting(
+ "commands",
+ "foo",
+ extension.id
+ );
+ is(
+ storedCommand.value.shortcut,
+ "Alt+Shift+9",
+ "The shortcut is saved correctly"
+ );
+ is(
+ storedCommand.value.description,
+ "description only",
+ "The description is saved correctly"
+ );
+
+ // Calling browser.commands.reset("foo") should reset to manifest version.
+ extension.sendMessage("reset", "foo");
+ await extension.awaitMessage("resetDone");
+
+ checkKey(extension.id, "I", "accel,shift");
+
+ // Check that enable/disable removes the keyset and reloads the saved command.
+ let addon = await AddonManager.getAddonByID(extension.id);
+ await disableAddon(addon);
+ let keyset = extensionKeyset(extension.id);
+ is(keyset, null, "The extension keyset is removed when disabled");
+ // Add some commands to storage, only "foo" should get loaded.
+ await ExtensionSettingsStore.addSetting(extension.id, "commands", "foo", {
+ shortcut: "Alt+Shift+9",
+ });
+ await ExtensionSettingsStore.addSetting(extension.id, "commands", "unknown", {
+ shortcut: "Ctrl+Shift+P",
+ });
+ storedCommands = ExtensionSettingsStore.getAllForExtension(
+ extension.id,
+ "commands"
+ );
+ is(storedCommands.length, 2, "There are now 2 commands stored");
+ await enableAddon(addon);
+ // Wait for the keyset to appear (it's async on enable).
+ await TestUtils.waitForCondition(() => extensionKeyset(extension.id));
+ // The keyset is back with the value from ExtensionSettingsStore.
+ checkNumericKey(extension.id, "9", "alt,shift");
+
+ // Check that an update to a shortcut in the manifest is mapped correctly.
+ updatedExtension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: { gecko: { id: "commands_update@mochi.test" } },
+ commands: {
+ foo: {
+ suggested_key: {
+ default: "Ctrl+Shift+L",
+ },
+ description: "The foo command",
+ },
+ },
+ },
+ });
+ await updatedExtension.startup();
+
+ await TestUtils.waitForCondition(() => extensionKeyset(extension.id));
+ // Shortcut is unchanged since it was previously updated.
+ checkNumericKey(extension.id, "9", "alt,shift");
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_composeAction.js b/comm/mail/components/extensions/test/browser/browser_ext_composeAction.js
new file mode 100644
index 0000000000..aba352edf1
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_composeAction.js
@@ -0,0 +1,268 @@
+/* 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/. */
+
+let account;
+
+add_setup(async () => {
+ account = createAccount();
+ addIdentity(account);
+});
+
+// This test uses a command from the menus API to open the popup.
+add_task(async function test_popup_open_with_menu_command() {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ for (let area of ["maintoolbar", "formattoolbar"]) {
+ let testConfig = {
+ actionType: "compose_action",
+ testType: "open-with-menu-command",
+ default_area: area,
+ window: composeWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ }
+
+ composeWindow.close();
+});
+
+add_task(async function test_theme_icons() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "compose_action@mochi.test",
+ },
+ },
+ compose_action: {
+ default_title: "default",
+ default_icon: "default.png",
+ theme_icons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ let uuid = extension.uuid;
+ let button = composeWindow.document.getElementById(
+ "compose_action_mochi_test-composeAction-toolbarbutton"
+ );
+
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ Assert.equal(
+ composeWindow.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/light.png")`,
+ `Dark theme should use light icon.`
+ );
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ Assert.equal(
+ composeWindow.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/dark.png")`,
+ `Light theme should use dark icon.`
+ );
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ Assert.equal(
+ composeWindow.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/default.png")`,
+ `Default theme should use default icon.`
+ );
+
+ composeWindow.close();
+ await extension.unload();
+});
+
+add_task(async function test_button_order() {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ area: "maintoolbar",
+ toolbar: "composeToolbar2",
+ },
+ {
+ name: "addon2",
+ area: "formattoolbar",
+ toolbar: "FormatToolbar",
+ },
+ {
+ name: "addon3",
+ area: "maintoolbar",
+ toolbar: "composeToolbar2",
+ },
+ {
+ name: "addon4",
+ area: "formattoolbar",
+ toolbar: "FormatToolbar",
+ },
+ ],
+ composeWindow,
+ "compose_action"
+ );
+
+ composeWindow.close();
+});
+
+add_task(async function test_upgrade() {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ // Add a compose_action, to make sure the currentSet has been initialized.
+ let extension1 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension1",
+ applications: { gecko: { id: "Extension1@mochi.test" } },
+ compose_action: {
+ default_title: "Extension1",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension1 ready");
+ },
+ });
+ await extension1.startup();
+ await extension1.awaitMessage("Extension1 ready");
+
+ // Add extension without a compose_action.
+ let extension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 ready");
+ },
+ });
+ await extension2.startup();
+ await extension2.awaitMessage("Extension2 ready");
+
+ // Update the extension, now including a compose_action.
+ let updatedExtension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "2.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ compose_action: {
+ default_title: "Extension2",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 updated");
+ },
+ });
+ await updatedExtension2.startup();
+ await updatedExtension2.awaitMessage("Extension2 updated");
+
+ let button = composeWindow.document.getElementById(
+ "extension2_mochi_test-composeAction-toolbarbutton"
+ );
+
+ Assert.ok(button, "Button should exist");
+
+ await extension1.unload();
+ await extension2.unload();
+ await updatedExtension2.unload();
+
+ composeWindow.close();
+});
+
+add_task(async function test_iconPath() {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ // String values for the default_icon manifest entry have been tested in the
+ // theme_icons test already. Here we test imagePath objects for the manifest key
+ // and string values as well as objects for the setIcons() function.
+ let files = {
+ "background.js": async () => {
+ await window.sendMessage("checkState", "icon1.png");
+
+ await browser.composeAction.setIcon({ path: "icon2.png" });
+ await window.sendMessage("checkState", "icon2.png");
+
+ await browser.composeAction.setIcon({ path: { 16: "icon3.png" } });
+ await window.sendMessage("checkState", "icon3.png");
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ applications: {
+ gecko: {
+ id: "compose_action@mochi.test",
+ },
+ },
+ compose_action: {
+ default_title: "default",
+ default_icon: { 16: "icon1.png" },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("checkState", async expected => {
+ let uuid = extension.uuid;
+ let button = composeWindow.document.getElementById(
+ "compose_action_mochi_test-composeAction-toolbarbutton"
+ );
+
+ Assert.equal(
+ window.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/${expected}")`,
+ `Icon path should be correct.`
+ );
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click.js b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click.js
new file mode 100644
index 0000000000..2c858cf8ab
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click.js
@@ -0,0 +1,266 @@
+/* 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/. */
+
+let account;
+
+add_setup(async () => {
+ account = createAccount();
+ addIdentity(account);
+});
+
+// This test clicks on the action button to open the popup.
+add_task(async function test_popup_open_with_click() {
+ for (let area of [null, "formattoolbar"]) {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await run_popup_test({
+ actionType: "compose_action",
+ testType: "open-with-mouse-click",
+ window: composeWindow,
+ default_area: area,
+ });
+
+ await run_popup_test({
+ actionType: "compose_action",
+ testType: "open-with-mouse-click",
+ window: composeWindow,
+ default_area: area,
+ disable_button: true,
+ });
+
+ await run_popup_test({
+ actionType: "compose_action",
+ testType: "open-with-mouse-click",
+ window: composeWindow,
+ default_area: area,
+ use_default_popup: true,
+ });
+
+ composeWindow.close();
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml"
+ );
+ }
+});
+
+let background_for_openPopup_tests = async () => {
+ let composeTab = await browser.compose.beginNew();
+ browser.test.assertTrue(!!composeTab, "should have found a compose tab");
+
+ let windows = await browser.windows.getAll();
+ let composeWindow = windows.find(window => window.type == "messageCompose");
+ browser.test.assertTrue(
+ !!composeWindow,
+ "should have found a compose window"
+ );
+
+ // The test starts with an opened composeWindow, the compose_action
+ // is allowed there and should be visible, openPopup() should succeed.
+ browser.test.assertTrue(
+ (await browser.windows.get(composeWindow.id)).focused,
+ "composeWindow should be focused"
+ );
+ browser.test.assertTrue(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have succeeded while the compose window is active"
+ );
+ await window.waitForMessage();
+
+ // Disable the compose_action, openPopup() should fail.
+ await browser.composeAction.disable();
+ browser.test.assertFalse(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have failed after the action button was disabled"
+ );
+
+ // Enable the compose_action, openPopup() should succeed.
+ await browser.composeAction.enable();
+ browser.test.assertTrue(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have succeeded after the action button was enabled again"
+ );
+ await window.waitForMessage();
+
+ // Create a popup window, which does not have a compose_action, openPopup()
+ // should fail.
+ let popupWindow = await browser.windows.create({
+ type: "popup",
+ url: "https://www.example.com",
+ });
+ browser.test.assertTrue(
+ (await browser.windows.get(popupWindow.id)).focused,
+ "popupWindow should be focused"
+ );
+ browser.test.assertFalse(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have failed while the popup window is active"
+ );
+
+ // Specifically open the compose_action of the compose window, should become
+ // focused and openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.composeAction.openPopup({
+ windowId: composeWindow.id,
+ }),
+ "openPopup() should have succeeded when explicitly requesting the compose window"
+ );
+ await window.waitForMessage();
+ browser.test.assertTrue(
+ (await browser.windows.get(composeWindow.id)).focused,
+ "composeWindow should be focused"
+ );
+
+ // The compose window is focused now, openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have succeeded while the compose window is active"
+ );
+ await window.waitForMessage();
+
+ // Collapse the toolbar, openPopup() should fail.
+ await window.sendMessage("collapseToolbar", true);
+ browser.test.assertFalse(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have failed while the toolbar is collapsed"
+ );
+
+ // Restore the toolbar, openPopup() should succeed.
+ await window.sendMessage("collapseToolbar", false);
+ browser.test.assertTrue(
+ await browser.composeAction.openPopup(),
+ "openPopup() should have succeeded after the toolbar is restored"
+ );
+ await window.waitForMessage();
+
+ // Close the popup window and finish
+ await browser.windows.remove(popupWindow.id);
+ await browser.windows.remove(composeWindow.id);
+ browser.test.notifyPass("finished");
+};
+
+// This test uses openPopup() to open the popup in a compose window.
+add_task(
+ async function test_popup_open_with_openPopup_in_compose_maintoolbar() {
+ let files = {
+ "background.js": background_for_openPopup_tests,
+ "utils.js": await getUtilsJS(),
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ browser.test.sendMessage("popup opened");
+ window.close();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "compose_action_openPopup@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ compose_action: {
+ default_title: "default",
+ default_popup: "popup.html",
+ },
+ },
+ });
+
+ extension.onMessage("popup opened", async () => {
+ // Wait a moment to make sure the popup has closed.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 150));
+ extension.sendMessage();
+ });
+
+ extension.onMessage("collapseToolbar", state => {
+ let window = Services.wm.getMostRecentWindow("msgcompose");
+ let toolbar = window.document.getElementById("composeToolbar2");
+ if (state) {
+ toolbar.setAttribute("collapsed", "true");
+ } else {
+ toolbar.removeAttribute("collapsed");
+ }
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+// This test uses openPopup() to open the popup in a compose window.
+add_task(
+ async function test_popup_open_with_openPopup_in_compose_formatoolbar() {
+ let files = {
+ "background.js": background_for_openPopup_tests,
+ "utils.js": await getUtilsJS(),
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ browser.test.sendMessage("popup opened");
+ window.close();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "compose_action_openPopup@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ compose_action: {
+ default_title: "default",
+ default_popup: "popup.html",
+ default_area: "formattoolbar",
+ },
+ },
+ });
+
+ extension.onMessage("popup opened", async () => {
+ // Wait a moment to make sure the popup has closed.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 150));
+ extension.sendMessage();
+ });
+
+ extension.onMessage("collapseToolbar", state => {
+ let window = Services.wm.getMostRecentWindow("msgcompose");
+ let toolbar = window.document.getElementById("FormatToolbar");
+ if (state) {
+ toolbar.setAttribute("collapsed", "true");
+ } else {
+ toolbar.removeAttribute("collapsed");
+ }
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click_mv3_event_pages.js b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click_mv3_event_pages.js
new file mode 100644
index 0000000000..2a5cca1e12
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_popup_click_mv3_event_pages.js
@@ -0,0 +1,52 @@
+/* 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/. */
+
+let account;
+
+add_setup(async () => {
+ account = createAccount();
+ addIdentity(account);
+});
+
+async function subtest_popup_open_with_click_MV3_event_pages(
+ terminateBackground
+) {
+ for (let area of [null, "formattoolbar"]) {
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+ let testConfig = {
+ manifest_version: 3,
+ terminateBackground,
+ actionType: "compose_action",
+ testType: "open-with-mouse-click",
+ window: composeWindow,
+ default_area: area,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ composeWindow.close();
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml"
+ );
+ }
+}
+// This MV3 test clicks on the action button to open the popup.
+add_task(async function test_event_pages_without_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(false);
+});
+// This MV3 test clicks on the action button to open the popup (background termination).
+add_task(async function test_event_pages_with_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(true);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_composeAction_properties.js b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_properties.js
new file mode 100644
index 0000000000..517dae8c46
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_composeAction_properties.js
@@ -0,0 +1,125 @@
+/* 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 () => {
+ let account = createAccount();
+ addIdentity(account);
+
+ let files = {
+ "background.js": async () => {
+ async function checkProperty(property, expectedDefault, ...expected) {
+ browser.test.log(
+ `${property}: ${expectedDefault}, ${expected.join(", ")}`
+ );
+
+ browser.test.assertEq(
+ expectedDefault,
+ await browser.composeAction[property]({})
+ );
+ for (let i = 0; i < 3; i++) {
+ browser.test.assertEq(
+ expected[i],
+ await browser.composeAction[property]({ tabId: tabIDs[i] })
+ );
+ }
+
+ await window.sendMessage("checkProperty", property, expected);
+ }
+
+ await browser.compose.beginNew();
+ await browser.compose.beginNew();
+ await browser.compose.beginNew();
+ let windows = await browser.windows.getAll({
+ populate: true,
+ windowTypes: ["messageCompose"],
+ });
+ let tabIDs = windows.map(w => w.tabs[0].id);
+
+ await checkProperty("isEnabled", true, true, true, true);
+ await browser.composeAction.disable();
+ await checkProperty("isEnabled", false, false, false, false);
+ await browser.composeAction.enable(tabIDs[0]);
+ await checkProperty("isEnabled", false, true, false, false);
+ await browser.composeAction.enable();
+ await checkProperty("isEnabled", true, true, true, true);
+ await browser.composeAction.disable();
+ await checkProperty("isEnabled", false, true, false, false);
+ await browser.composeAction.disable(tabIDs[0]);
+ await checkProperty("isEnabled", false, false, false, false);
+ await browser.composeAction.enable();
+ await checkProperty("isEnabled", true, false, true, true);
+
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await browser.composeAction.setTitle({ tabId: tabIDs[2], title: "tab2" });
+ await checkProperty("getTitle", "default", "default", "default", "tab2");
+ await browser.composeAction.setTitle({ title: "new" });
+ await checkProperty("getTitle", "new", "new", "new", "tab2");
+ await browser.composeAction.setTitle({ tabId: tabIDs[1], title: "tab1" });
+ await checkProperty("getTitle", "new", "new", "tab1", "tab2");
+ await browser.composeAction.setTitle({ tabId: tabIDs[2], title: null });
+ await checkProperty("getTitle", "new", "new", "tab1", "new");
+ await browser.composeAction.setTitle({ title: null });
+ await checkProperty("getTitle", "default", "default", "tab1", "default");
+ await browser.composeAction.setTitle({ tabId: tabIDs[1], title: null });
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+
+ await browser.tabs.remove(tabIDs[0]);
+ await browser.tabs.remove(tabIDs[1]);
+ await browser.tabs.remove(tabIDs[2]);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ applications: {
+ gecko: {
+ id: "compose_action_properties@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ compose_action: {
+ default_title: "default",
+ },
+ },
+ });
+
+ extension.onMessage("checkProperty", async (property, expected) => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 3);
+
+ for (let i = 0; i < 3; i++) {
+ let button = composeWindows[i].document.getElementById(
+ "compose_action_properties_mochi_test-composeAction-toolbarbutton"
+ );
+ switch (property) {
+ case "isEnabled":
+ is(button.disabled, !expected[i], `button ${i} enabled state`);
+ break;
+ case "getTitle":
+ is(button.getAttribute("label"), expected[i], `button ${i} label`);
+ break;
+ }
+ }
+
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_composeScripts.js b/comm/mail/components/extensions/test/browser/browser_ext_composeScripts.js
new file mode 100644
index 0000000000..b642a5654d
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_composeScripts.js
@@ -0,0 +1,531 @@
+/* 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/. */
+
+addIdentity(createAccount());
+
+async function checkComposeBody(expected, waitForEvent) {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ Assert.equal(composeWindows.length, 1);
+
+ let composeWindow = composeWindows[0];
+ if (waitForEvent) {
+ await BrowserTestUtils.waitForEvent(
+ composeWindow,
+ "extension-scripts-added"
+ );
+ }
+
+ let composeEditor = composeWindow.GetCurrentEditorElement();
+
+ await checkContent(composeEditor, expected);
+}
+
+/** Tests browser.tabs.insertCSS and browser.tabs.removeCSS. */
+add_task(async function testInsertRemoveCSS() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+ await window.sendMessage();
+
+ await browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.insertCSS(tab.id, { file: "test.css" });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, { file: "test.css" });
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ backgroundColor: "rgba(0, 0, 0, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ backgroundColor: "rgb(0, 255, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ backgroundColor: "rgba(0, 0, 0, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ backgroundColor: "rgb(0, 128, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ backgroundColor: "rgba(0, 0, 0, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/** Tests browser.tabs.insertCSS fails without the "compose" permission. */
+add_task(async function testInsertRemoveCSSNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: darkred; }",
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, { file: "test.css" }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ file: "test.css",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/** Tests browser.tabs.executeScript. */
+add_task(async function testExecuteScript() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ });
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, { file: "test.js" });
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ textContent: "" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ foo: "bar" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/** Tests browser.tabs.executeScript fails without the "compose" permission. */
+add_task(async function testExecuteScriptNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, { file: "test.js" }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ file: "test.js",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ foo: null, textContent: "" });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/** Tests the messenger alias is available. */
+add_task(async function testExecuteScriptAlias() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.textContent = messenger.runtime.getManifest().applications.gecko.id;`,
+ });
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ applications: { gecko: { id: "compose_scripts@mochitest" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ textContent: "" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({ textContent: "compose_scripts@mochitest" });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Tests browser.composeScripts.register correctly adds CSS and JavaScript to
+ * message composition windows opened after it was called. Also tests calling
+ * `unregister` on the returned object.
+ */
+add_task(async function testRegisterBeforeCompose() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let registeredScript = await browser.composeScripts.register({
+ css: [{ code: "body { color: white }" }, { file: "test.css" }],
+ js: [
+ { code: `document.body.setAttribute("foo", "bar");` },
+ { file: "test.js" },
+ ],
+ });
+
+ await browser.compose.beginNew();
+ await window.sendMessage();
+
+ await registeredScript.unregister();
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody(
+ {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ true
+ );
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await checkComposeBody({
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ await extension.unload();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ await BrowserTestUtils.closeWindow(
+ Services.wm.getMostRecentWindow("msgcompose")
+ );
+});
+
+/**
+ * Tests browser.composeScripts.register correctly adds CSS and JavaScript to
+ * message composition windows already open when it was called. Also tests
+ * calling `unregister` on the returned object.
+ */
+add_task(async function testRegisterDuringCompose() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+ await window.sendMessage();
+
+ let registeredScript = await browser.composeScripts.register({
+ css: [{ code: "body { color: white }" }, { file: "test.css" }],
+ js: [
+ { code: `document.body.setAttribute("foo", "bar");` },
+ { file: "test.js" },
+ ],
+ });
+
+ await window.sendMessage();
+
+ await registeredScript.unregister();
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/** Tests content_scripts in the manifest do not affect compose windows. */
+async function subtestContentScriptManifest(...permissions) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.compose.beginNew();
+
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions,
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ css: ["test.css"],
+ js: ["test.js"],
+ match_about_blank: true,
+ match_origin_as_fallback: true,
+ },
+ ],
+ },
+ });
+
+ // match_origin_as_fallback is not implemented yet. Bug 1475831.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+}
+
+add_task(async function testContentScriptManifestNoPermission() {
+ await subtestContentScriptManifest();
+});
+add_task(async function testContentScriptManifest() {
+ await subtestContentScriptManifest("compose");
+});
+
+/** Tests registered content scripts do not affect compose windows. */
+async function subtestContentScriptRegister(...permissions) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ await browser.contentScripts.register({
+ matches: ["<all_urls>"],
+ css: [{ file: "test.css" }],
+ js: [{ file: "test.js" }],
+ matchAboutBlank: true,
+ });
+
+ let tab = await browser.compose.beginNew();
+
+ await window.sendMessage();
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkComposeBody({
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+}
+
+add_task(async function testContentScriptRegisterNoPermission() {
+ await subtestContentScriptRegister("<all_urls>");
+});
+add_task(async function testContentScriptRegister() {
+ await subtestContentScriptRegister("<all_urls>", "compose");
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_attachments.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_attachments.js
new file mode 100644
index 0000000000..2b66b5a200
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_attachments.js
@@ -0,0 +1,2268 @@
+/* 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/. */
+
+"use strict";
+
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+const { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+
+function findWindow(subject) {
+ let windows = Array.from(Services.wm.getEnumerator("msgcompose"));
+ return windows.find(win => {
+ let composeFields = win.GetComposeDetails();
+ return composeFields.subject == subject;
+ });
+}
+
+var MockCompleteGenericSendMessage = {
+ register() {
+ // For every compose window that opens, replace the function which does the
+ // actual sending with one that only records when it has been called.
+ MockCompleteGenericSendMessage._didTryToSendMessage = false;
+ ExtensionSupport.registerWindowListener("MockCompleteGenericSendMessage", {
+ chromeURLs: [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ],
+ onLoadWindow(window) {
+ window.CompleteGenericSendMessage = function (msgType) {
+ let items = [...window.gAttachmentBucket.itemChildren];
+ for (let item of items) {
+ if (item.attachment.sendViaCloud && item.cloudFileAccount) {
+ item.cloudFileAccount.markAsImmutable(item.cloudFileUpload.id);
+ }
+ }
+ Services.obs.notifyObservers(
+ {
+ composeWindow: window,
+ },
+ "mail:composeSendProgressStop"
+ );
+ };
+ },
+ });
+ },
+
+ unregister() {
+ ExtensionSupport.unregisterWindowListener("MockCompleteGenericSendMessage");
+ },
+};
+
+add_task(async function test_file_attachments() {
+ let files = {
+ "background.js": async () => {
+ let listener = {
+ events: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ this.events.push(args);
+ if (this.currentPromise) {
+ let p = this.currentPromise;
+ this.currentPromise = null;
+ p.resolve();
+ }
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ if (this.events.length == 0) {
+ await new Promise(resolve => (this.currentPromise = { resolve }));
+ }
+ let [actualEvent, ...actualArgs] = this.events.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(typeof expectedArgs[i], typeof actualArgs[i]);
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(expectedArgs[i][key], actualArgs[i][key]);
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+
+ return actualArgs;
+ },
+ };
+ browser.compose.onAttachmentAdded.addListener((...args) =>
+ listener.pushEvent("onAttachmentAdded", ...args)
+ );
+ browser.compose.onAttachmentRemoved.addListener((...args) =>
+ listener.pushEvent("onAttachmentRemoved", ...args)
+ );
+
+ let checkData = async (attachment, size) => {
+ let data = await browser.compose.getAttachmentFile(attachment.id);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(data instanceof File);
+ browser.test.assertEq(size, data.size);
+ };
+
+ let checkUI = async (composeTab, ...expected) => {
+ let attachments = await browser.compose.listAttachments(composeTab.id);
+ browser.test.assertEq(expected.length, attachments.length);
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(expected[i].id, attachments[i].id);
+ browser.test.assertEq(expected[i].size, attachments[i].size);
+ }
+ let details = await browser.compose.getComposeDetails(composeTab.id);
+ return window.sendMessage("checkUI", details, expected);
+ };
+
+ let createCloudfileAccount = () => {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ };
+
+ let removeCloudfileAccount = id => {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ };
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ let file1 = new File(["File number one!"], "file1.txt", {
+ type: "application/vnd.regify",
+ });
+ let file2 = new File(
+ ["File number two? Yes, this is number two."],
+ "file2.txt"
+ );
+ let file3 = new File(["I'm pretending to be file two."], "file3.txt");
+ let composeTab = await browser.compose.beginNew({
+ subject: "Message #1",
+ });
+
+ await checkUI(composeTab);
+
+ // Add an attachment.
+
+ let attachment1 = await browser.compose.addAttachment(composeTab.id, {
+ file: file1,
+ });
+ browser.test.assertEq("file1.txt", attachment1.name);
+ browser.test.assertEq(16, attachment1.size);
+ await checkData(attachment1, file1.size);
+
+ let [, added1] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab.id },
+ { id: attachment1.id, name: "file1.txt" }
+ );
+ await checkData(added1, file1.size);
+
+ await checkUI(composeTab, {
+ id: attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ contentType: "application/vnd.regify",
+ });
+
+ // Add another attachment.
+
+ let attachment2 = await browser.compose.addAttachment(composeTab.id, {
+ file: file2,
+ name: "this is file2.txt",
+ });
+ browser.test.assertEq("this is file2.txt", attachment2.name);
+ browser.test.assertEq(41, attachment2.size);
+ await checkData(attachment2, file2.size);
+
+ let [, added2] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab.id },
+ { id: attachment2.id, name: "this is file2.txt" }
+ );
+ await checkData(added2, file2.size);
+
+ await checkUI(
+ composeTab,
+ {
+ id: attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ contentType: "application/vnd.regify",
+ },
+ { id: attachment2.id, name: "this is file2.txt", size: file2.size }
+ );
+
+ // Change an attachment.
+
+ let changed2 = await browser.compose.updateAttachment(
+ composeTab.id,
+ attachment2.id,
+ {
+ name: "file2 with a new name.txt",
+ }
+ );
+ browser.test.assertEq("file2 with a new name.txt", changed2.name);
+ browser.test.assertEq(41, changed2.size);
+ await checkData(changed2, file2.size);
+
+ await checkUI(
+ composeTab,
+ {
+ id: attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ contentType: "application/vnd.regify",
+ },
+ {
+ id: attachment2.id,
+ name: "file2 with a new name.txt",
+ size: file2.size,
+ }
+ );
+
+ let changed3 = await browser.compose.updateAttachment(
+ composeTab.id,
+ attachment2.id,
+ { file: file3 }
+ );
+ browser.test.assertEq("file2 with a new name.txt", changed3.name);
+ browser.test.assertEq(30, changed3.size);
+ await checkData(changed3, file3.size);
+
+ await checkUI(
+ composeTab,
+ {
+ id: attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ contentType: "application/vnd.regify",
+ },
+ {
+ id: attachment2.id,
+ name: "file2 with a new name.txt",
+ size: file3.size,
+ }
+ );
+
+ // Remove the first/local attachment.
+
+ await browser.compose.removeAttachment(composeTab.id, attachment1.id);
+ await listener.checkEvent(
+ "onAttachmentRemoved",
+ { id: composeTab.id },
+ attachment1.id
+ );
+
+ await checkUI(composeTab, {
+ id: attachment2.id,
+ name: "file2 with a new name.txt",
+ size: file3.size,
+ });
+
+ // Convert the second attachment to a cloudFile attachment.
+
+ await new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(1, fileInfo.id);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve());
+ return {
+ url: "https://cloud.provider.net/1",
+ templateInfo: {
+ download_limit: "2",
+ service_name: "Superior Mochitest Service",
+ },
+ };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ // Conversion/upload is not yet supported via WebExt API.
+ browser.test.sendMessage(
+ "convertFile",
+ createdAccount.id,
+ "file2 with a new name.txt"
+ );
+ });
+
+ // File retrieved by WebExt API should still be the real file.
+ await checkData(attachment2, 30);
+
+ // UI should show both file size.
+ await checkUI(composeTab, {
+ id: attachment2.id,
+ name: "file2 with a new name.txt",
+ size: file3.size,
+ htmlSize: 4536,
+ });
+
+ // Rename the second/cloud attachment.
+
+ await browser.test.assertRejects(
+ browser.compose.updateAttachment(composeTab.id, attachment2.id, {
+ name: "cloud file2 with a new name.txt",
+ }),
+ "Rename error: Missing cloudFile.onFileRename listener for compose.attachments@mochi.test",
+ "Provider should reject for missing rename support"
+ );
+
+ function cloudFileRenameListener(account, id) {
+ browser.cloudFile.onFileRename.removeListener(cloudFileRenameListener);
+ browser.test.assertEq(1, id);
+ return { url: "https://cloud.provider.net/2" };
+ }
+ browser.cloudFile.onFileRename.addListener(cloudFileRenameListener);
+
+ let changed4 = await browser.compose.updateAttachment(
+ composeTab.id,
+ attachment2.id,
+ {
+ name: "cloud file2 with a new name.txt",
+ }
+ );
+ browser.test.assertEq("cloud file2 with a new name.txt", changed4.name);
+ browser.test.assertEq(30, changed4.size);
+
+ await checkUI(composeTab, {
+ id: attachment2.id,
+ name: "cloud file2 with a new name.txt",
+ size: file3.size,
+ htmlSize: 4554,
+ });
+
+ // File retrieved by WebExt API should still be the real file.
+ await checkData(changed4, 30);
+
+ // Update the second/cloud attachment.
+
+ await browser.test.assertRejects(
+ browser.compose.updateAttachment(composeTab.id, attachment2.id, {
+ file: file2,
+ }),
+ "Upload error: Missing cloudFile.onFileUpload listener for compose.attachments@mochi.test (or it is not returning url or aborted)",
+ "Provider should reject due to upload errors"
+ );
+
+ function cloudFileUploadListener(
+ account,
+ fileInfo,
+ tab,
+ relatedFileInfo
+ ) {
+ browser.cloudFile.onFileUpload.removeListener(cloudFileUploadListener);
+ browser.test.assertEq(3, fileInfo.id);
+ browser.test.assertEq("cloud file2 with a new name.txt", fileInfo.name);
+ browser.test.assertEq(1, relatedFileInfo.id);
+ browser.test.assertEq(
+ "cloud file2 with a new name.txt",
+ relatedFileInfo.name
+ );
+ browser.test.assertTrue(
+ relatedFileInfo.dataChanged,
+ `data should have changed`
+ );
+ browser.test.assertEq(
+ "2",
+ relatedFileInfo.templateInfo.download_limit,
+ "templateInfo download_limit should be correct"
+ );
+ browser.test.assertEq(
+ "Superior Mochitest Service",
+ relatedFileInfo.templateInfo.service_name,
+ "templateInfo service_name should be correct"
+ );
+ return { url: "https://cloud.provider.net/3" };
+ }
+ browser.cloudFile.onFileUpload.addListener(cloudFileUploadListener);
+
+ let changed5 = await browser.compose.updateAttachment(
+ composeTab.id,
+ attachment2.id,
+ { file: file2 }
+ );
+
+ browser.test.assertEq("cloud file2 with a new name.txt", changed5.name);
+ browser.test.assertEq(41, changed5.size);
+ await checkData(changed5, file2.size);
+
+ // Remove the second/cloud attachment.
+
+ await browser.compose.removeAttachment(composeTab.id, attachment2.id);
+
+ await listener.checkEvent(
+ "onAttachmentRemoved",
+ { id: composeTab.id },
+ attachment2.id
+ );
+
+ await checkUI(composeTab);
+
+ await browser.tabs.remove(composeTab.id);
+ browser.test.assertEq(0, listener.events.length);
+
+ await removeCloudfileAccount(createdAccount.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ applications: { gecko: { id: "compose.attachments@mochi.test" } },
+ },
+ });
+
+ extension.onMessage("checkUI", (details, expected) => {
+ let composeWindow = findWindow(details.subject);
+ let composeDocument = composeWindow.document;
+
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ Assert.equal(bucket.itemCount, expected.length);
+
+ let totalSize = 0;
+ for (let i = 0; i < expected.length; i++) {
+ let item = bucket.itemChildren[i];
+ let { name, size, htmlSize, contentType } = expected[i];
+ totalSize += htmlSize ? htmlSize : size;
+
+ let displaySize = messenger.formatFileSize(size);
+ if (htmlSize) {
+ displaySize = `${messenger.formatFileSize(htmlSize)} (${displaySize})`;
+ Assert.equal(
+ item.cloudHtmlFileSize,
+ htmlSize,
+ "htmlSize should be correct."
+ );
+ }
+
+ if (contentType) {
+ Assert.equal(
+ item.attachment.contentType,
+ contentType,
+ "contentType should be correct."
+ );
+ }
+
+ Assert.equal(
+ item.querySelector(".attachmentcell-name").textContent +
+ item.querySelector(".attachmentcell-extension").textContent,
+ name,
+ "Displayed name should be correct."
+ );
+ Assert.equal(
+ item.querySelector(".attachmentcell-size").textContent,
+ displaySize,
+ "Displayed size should be correct."
+ );
+ }
+
+ let bucketTotal = composeDocument.getElementById("attachmentBucketSize");
+ if (totalSize == 0) {
+ Assert.equal(bucketTotal.textContent, "");
+ } else {
+ Assert.equal(
+ bucketTotal.textContent,
+ messenger.formatFileSize(totalSize),
+ "Total size should match."
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("createAccount", () => {
+ cloudFileAccounts.createAccount("ext-compose.attachments@mochi.test");
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage("convertFile", (cloudFileAccountId, attachmentName) => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let composeDocument = composeWindow.document;
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ let account = cloudFileAccounts.getAccount(cloudFileAccountId);
+
+ let attachmentItem = bucket.itemChildren.find(
+ item => item.attachment && item.attachment.name == attachmentName
+ );
+
+ composeWindow.convertListItemsToCloudAttachment([attachmentItem], account);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_compose_attachments() {
+ let files = {
+ "background.js": async () => {
+ let listener = {
+ events: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ this.events.push(args);
+ if (this.currentPromise) {
+ let p = this.currentPromise;
+ this.currentPromise = null;
+ p.resolve();
+ }
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ if (this.events.length == 0) {
+ await new Promise(resolve => (this.currentPromise = { resolve }));
+ }
+ let [actualEvent, ...actualArgs] = this.events.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(typeof expectedArgs[i], typeof actualArgs[i]);
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(expectedArgs[i][key], actualArgs[i][key]);
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+
+ return actualArgs;
+ },
+ };
+ browser.compose.onAttachmentAdded.addListener((...args) =>
+ listener.pushEvent("onAttachmentAdded", ...args)
+ );
+ browser.compose.onAttachmentRemoved.addListener((...args) =>
+ listener.pushEvent("onAttachmentRemoved", ...args)
+ );
+
+ let checkData = async (attachment, size) => {
+ let data = await browser.compose.getAttachmentFile(attachment.id);
+ browser.test.assertTrue(
+ // eslint-disable-next-line mozilla/use-isInstance
+ data instanceof File,
+ "Returned file obj should be a File instance."
+ );
+ browser.test.assertEq(
+ size,
+ data.size,
+ "Reported size should be correct."
+ );
+ browser.test.assertEq(
+ attachment.name,
+ data.name,
+ "Name of the File object should match the name of the attachment."
+ );
+ };
+
+ let checkUI = async (composeTab, ...expected) => {
+ let attachments = await browser.compose.listAttachments(composeTab.id);
+ browser.test.assertEq(
+ expected.length,
+ attachments.length,
+ "Number of found attachments should be correct."
+ );
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(expected[i].id, attachments[i].id);
+ browser.test.assertEq(expected[i].size, attachments[i].size);
+ }
+ let details = await browser.compose.getComposeDetails(composeTab.id);
+ return window.sendMessage("checkUI", details, expected);
+ };
+
+ let createCloudfileAccount = () => {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ };
+
+ let removeCloudfileAccount = id => {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ };
+
+ async function cloneAttachment(
+ attachment,
+ composeTab,
+ name = attachment.name
+ ) {
+ let clone;
+
+ // If the name is not changed, try to pass in the full object.
+ if (name == attachment.name) {
+ clone = await browser.compose.addAttachment(
+ composeTab.id,
+ attachment
+ );
+ } else {
+ clone = await browser.compose.addAttachment(composeTab.id, {
+ id: attachment.id,
+ name,
+ });
+ }
+
+ browser.test.assertEq(name, clone.name);
+ browser.test.assertEq(attachment.size, clone.size);
+ await checkData(clone, attachment.size);
+
+ let [, added] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab.id },
+ { id: clone.id, name }
+ );
+ await checkData(added, attachment.size);
+ return clone;
+ }
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ let file1 = new File(["File number one!"], "file1.txt");
+ let file2 = new File(
+ ["File number two? Yes, this is number two."],
+ "file2.txt"
+ );
+
+ // -----------------------------------------------------------------------
+
+ let composeTab1 = await browser.compose.beginNew({
+ subject: "Message #2",
+ });
+ await checkUI(composeTab1);
+
+ // Add an attachment to composeTab1.
+
+ let tab1_attachment1 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file1,
+ }
+ );
+ browser.test.assertEq("file1.txt", tab1_attachment1.name);
+ browser.test.assertEq(16, tab1_attachment1.size);
+ await checkData(tab1_attachment1, file1.size);
+
+ let [, tab1_added1] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment1.id, name: "file1.txt" }
+ );
+ await checkData(tab1_added1, file1.size);
+
+ await checkUI(composeTab1, {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ // Add another attachment to composeTab1.
+
+ let tab1_attachment2 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file2,
+ name: "this is file2.txt",
+ }
+ );
+ browser.test.assertEq("this is file2.txt", tab1_attachment2.name);
+ browser.test.assertEq(41, tab1_attachment2.size);
+ await checkData(tab1_attachment2, file2.size);
+
+ let [, tab1_added2] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment2.id, name: "this is file2.txt" }
+ );
+ await checkData(tab1_added2, file2.size);
+
+ await checkUI(
+ composeTab1,
+ { id: tab1_attachment1.id, name: "file1.txt", size: file1.size },
+ { id: tab1_attachment2.id, name: "this is file2.txt", size: file2.size }
+ );
+
+ // Convert the second attachment to a cloudFile attachment.
+
+ await new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(1, fileInfo.id);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/1" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ // Conversion/upload is not yet supported via WebExt API.
+ browser.test.sendMessage(
+ "convertFile",
+ createdAccount.id,
+ "this is file2.txt"
+ );
+ });
+
+ await checkUI(
+ composeTab1,
+ {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab1_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/1",
+ }
+ );
+
+ // -----------------------------------------------------------------------
+
+ // Create a second compose window and clone both attachments from tab1. The
+ // second one should be cloned as a cloud attachment, having no size and the
+ // correct contentLocation. Both attachments will be renamed while cloning.
+
+ // The cloud file rename should be handled as a new file upload, because
+ // the same url is used in tab1. The original attachment should be passed
+ // as relatedFileInfo.
+ let tab2_uploadPromise = new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(2, fileInfo.id);
+ browser.test.assertEq("this is renamed file2.txt", fileInfo.name);
+ browser.test.assertEq(1, relatedFileInfo.id);
+ browser.test.assertEq("this is file2.txt", relatedFileInfo.name);
+ browser.test.assertFalse(
+ relatedFileInfo.dataChanged,
+ `data should not have changed`
+ );
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/2" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ });
+
+ let composeTab2 = await browser.compose.beginNew({
+ subject: "Message #3",
+ });
+ let tab2_attachment1 = await cloneAttachment(
+ tab1_attachment1,
+ composeTab2,
+ "I want to be called file3.txt"
+ );
+ await checkUI(composeTab2, {
+ id: tab2_attachment1.id,
+ name: "I want to be called file3.txt",
+ size: file1.size,
+ });
+
+ let tab2_attachment2 = await cloneAttachment(
+ tab1_attachment2,
+ composeTab2,
+ "this is renamed file2.txt"
+ );
+
+ await checkUI(
+ composeTab2,
+ {
+ id: tab2_attachment1.id,
+ name: "I want to be called file3.txt",
+ size: file1.size,
+ },
+ {
+ id: tab2_attachment2.id,
+ name: "this is renamed file2.txt",
+ size: 41,
+ htmlSize: 4324,
+ contentLocation: "https://cloud.provider.net/2",
+ }
+ );
+
+ await tab2_uploadPromise;
+
+ // -----------------------------------------------------------------------
+
+ // Create a 3rd compose window and clone both attachments from tab1. The
+ // second one should be cloned as cloud attachment, having no size and the
+ // correct contentLocation. Files are not renamed this time, so there should
+ // not be an upload request (which would fail without upload listener), as
+ // we simply re-attach the cloudFileUpload data.
+
+ let composeTab3 = await browser.compose.beginNew({
+ subject: "Message #4",
+ });
+ let tab3_attachment1 = await cloneAttachment(
+ tab1_attachment1,
+ composeTab3
+ );
+ await checkUI(composeTab3, {
+ id: tab3_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ let tab3_attachment2 = await cloneAttachment(
+ tab1_attachment2,
+ composeTab3
+ );
+
+ await checkUI(
+ composeTab3,
+ {
+ id: tab3_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab3_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/1",
+ }
+ );
+
+ // Rename the cloned cloud attachments of tab3. It should trigger a new
+ // upload, to not invalidate the original url still used in tab1.
+
+ let tab3_uploadPromise = new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(3, fileInfo.id);
+ browser.test.assertEq(
+ "That is going to be interesting.txt",
+ fileInfo.name
+ );
+ browser.test.assertEq(1, relatedFileInfo.id);
+ browser.test.assertEq("this is file2.txt", relatedFileInfo.name);
+ browser.test.assertFalse(
+ relatedFileInfo.dataChanged,
+ `data should not have changed`
+ );
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/3" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ });
+
+ let tab3_changed2 = await browser.compose.updateAttachment(
+ composeTab3.id,
+ tab3_attachment2.id,
+ {
+ name: "That is going to be interesting.txt",
+ }
+ );
+ browser.test.assertEq(
+ "That is going to be interesting.txt",
+ tab3_changed2.name
+ );
+ browser.test.assertEq(41, tab3_changed2.size);
+ await checkData(tab3_changed2, file2.size);
+
+ await checkUI(
+ composeTab3,
+ {
+ id: tab3_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab3_attachment2.id,
+ name: "That is going to be interesting.txt",
+ size: 41,
+ htmlSize: 4354,
+ contentLocation: "https://cloud.provider.net/3",
+ }
+ );
+
+ await tab3_uploadPromise;
+
+ // -----------------------------------------------------------------------
+
+ // Open a 4th compose window and directly clone attachment1 and attachment2,
+ // renaming both. This should trigger a new file upload.
+
+ let tab4_uploadPromise = new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(4, fileInfo.id);
+ browser.test.assertEq(
+ "I got renamed too, how crazy is that!.txt",
+ fileInfo.name
+ );
+ browser.test.assertEq(1, relatedFileInfo.id);
+ browser.test.assertEq("this is file2.txt", relatedFileInfo.name);
+ browser.test.assertFalse(
+ relatedFileInfo.dataChanged,
+ `data should not have changed`
+ );
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/4" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ });
+
+ let tab4_details = { subject: "Message #5" };
+ tab4_details.attachments = [
+ Object.assign({}, tab1_attachment1),
+ Object.assign({}, tab1_attachment2),
+ ];
+ tab4_details.attachments[0].name = "I got renamed.txt";
+ tab4_details.attachments[1].name =
+ "I got renamed too, how crazy is that!.txt";
+ let composeTab4 = await browser.compose.beginNew(tab4_details);
+
+ // In this test we need to manually request the id of the added attachments.
+ let [tab4_attachment1, tab4_attachment2] =
+ await browser.compose.listAttachments(composeTab4.id);
+
+ let [, addedReClone1] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab4.id },
+ { id: tab4_attachment1.id, name: "I got renamed.txt" }
+ );
+ await checkData(addedReClone1, file1.size);
+ let [, addedReClone2] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab4.id },
+ {
+ id: tab4_attachment2.id,
+ name: "I got renamed too, how crazy is that!.txt",
+ }
+ );
+ await checkData(addedReClone2, file2.size);
+
+ await checkUI(
+ composeTab4,
+ {
+ id: tab4_attachment1.id,
+ name: "I got renamed.txt",
+ size: file1.size,
+ },
+ {
+ id: tab4_attachment2.id,
+ name: "I got renamed too, how crazy is that!.txt",
+ size: 41,
+ htmlSize: 4372,
+ contentLocation: "https://cloud.provider.net/4",
+ }
+ );
+
+ await tab4_uploadPromise;
+
+ // -----------------------------------------------------------------------
+
+ // Open a 5th compose window and directly clone attachment1 and attachment2
+ // from tab1.
+
+ let tab5_details = { subject: "Message #6" };
+ tab5_details.attachments = [tab1_attachment1, tab1_attachment2];
+ let composeTab5 = await browser.compose.beginNew(tab5_details);
+
+ // In this test we need to manually request the id of the added attachments.
+ let [tab5_attachment1, tab5_attachment2] =
+ await browser.compose.listAttachments(composeTab5.id);
+
+ await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab5.id },
+ { id: tab5_attachment1.id, name: "file1.txt" }
+ );
+ await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab5.id },
+ { id: tab5_attachment2.id, name: "this is file2.txt" }
+ );
+
+ // Delete the cloud attachment2 in tab1, which should not trigger a cloud
+ // delete, as the url is still used in tab5.
+
+ function fileListener(account, id, tab) {
+ browser.test.fail(
+ `The onFileDeleted listener should not fire for deleting a cloud file which is still used in another tab.`
+ );
+ }
+ browser.cloudFile.onFileDeleted.addListener(fileListener);
+
+ await browser.compose.removeAttachment(
+ composeTab1.id,
+ tab1_attachment2.id
+ );
+ await listener.checkEvent(
+ "onAttachmentRemoved",
+ { id: composeTab1.id },
+ tab1_attachment2.id
+ );
+ browser.cloudFile.onFileDeleted.removeListener(fileListener);
+
+ // Renaming cloud attachment2 in tab5 should now be a simple rename, as the
+ // url is not used anywhere anymore.
+
+ let tab5_renamePromise = new Promise(resolve => {
+ function fileListener() {
+ browser.cloudFile.onFileRename.removeListener(fileListener);
+ setTimeout(() => resolve());
+ }
+ browser.cloudFile.onFileRename.addListener(fileListener);
+ });
+
+ await browser.compose.updateAttachment(
+ composeTab5.id,
+ tab5_attachment2.id,
+ {
+ name: "I am the only one left.txt",
+ }
+ );
+ await tab5_renamePromise;
+
+ // Delete the cloud attachment2 in tab5, which now should trigger a cloud
+ // delete.
+
+ let tab5_deletePromise = new Promise(resolve => {
+ function fileListener(account, id, tab) {
+ browser.cloudFile.onFileDeleted.removeListener(fileListener);
+ setTimeout(() => resolve(id));
+ }
+ browser.cloudFile.onFileDeleted.addListener(fileListener);
+ });
+
+ await browser.compose.removeAttachment(
+ composeTab5.id,
+ tab5_attachment2.id
+ );
+ await listener.checkEvent(
+ "onAttachmentRemoved",
+ { id: composeTab5.id },
+ tab5_attachment2.id
+ );
+ await tab5_deletePromise;
+
+ // Clean up
+
+ await browser.tabs.remove(composeTab5.id);
+ await browser.tabs.remove(composeTab4.id);
+ await browser.tabs.remove(composeTab3.id);
+ await browser.tabs.remove(composeTab2.id);
+ await browser.tabs.remove(composeTab1.id);
+ browser.test.assertEq(0, listener.events.length);
+
+ await removeCloudfileAccount(createdAccount.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ applications: { gecko: { id: "compose.attachments@mochi.test" } },
+ },
+ });
+
+ extension.onMessage("checkUI", (details, expected) => {
+ let composeWindow = findWindow(details.subject);
+ let composeDocument = composeWindow.document;
+
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ Assert.equal(bucket.itemCount, expected.length);
+
+ let totalSize = 0;
+ for (let i = 0; i < expected.length; i++) {
+ let item = bucket.itemChildren[i];
+ let { name, size, htmlSize, contentLocation } = expected[i];
+ totalSize += htmlSize ? htmlSize : size;
+
+ let displaySize = messenger.formatFileSize(size);
+ if (htmlSize) {
+ displaySize = `${messenger.formatFileSize(htmlSize)} (${displaySize})`;
+ Assert.equal(
+ item.cloudHtmlFileSize,
+ htmlSize,
+ "htmlSize should be correct."
+ );
+ }
+
+ // size and name are checked against the displayed values, contentLocation
+ // is checked against the associated attachment.
+
+ if (contentLocation) {
+ Assert.equal(
+ item.attachment.contentLocation,
+ contentLocation,
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ true,
+ "sendViaCloud for cloud files should be correct."
+ );
+ } else {
+ Assert.equal(
+ item.attachment.contentLocation,
+ "",
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ false,
+ "sendViaCloud for cloud files should be correct."
+ );
+ }
+
+ Assert.equal(
+ item.querySelector(".attachmentcell-name").textContent +
+ item.querySelector(".attachmentcell-extension").textContent,
+ name,
+ "Name should be correct."
+ );
+ Assert.equal(
+ item.querySelector(".attachmentcell-size").textContent,
+ displaySize,
+ "Displayed size should be correct."
+ );
+ }
+
+ let bucketTotal = composeDocument.getElementById("attachmentBucketSize");
+ if (totalSize == 0) {
+ Assert.equal(bucketTotal.textContent, "");
+ } else {
+ Assert.equal(
+ bucketTotal.textContent,
+ messenger.formatFileSize(totalSize)
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("createAccount", () => {
+ cloudFileAccounts.createAccount("ext-compose.attachments@mochi.test");
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage("convertFile", (cloudFileAccountId, attachmentName) => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let composeDocument = composeWindow.document;
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ let account = cloudFileAccounts.getAccount(cloudFileAccountId);
+
+ let attachmentItem = bucket.itemChildren.find(
+ item => item.attachment && item.attachment.name == attachmentName
+ );
+
+ composeWindow.convertListItemsToCloudAttachment([attachmentItem], account);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_compose_attachments_immutable() {
+ MockCompleteGenericSendMessage.register();
+
+ let files = {
+ "background.js": async () => {
+ let listener = {
+ events: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ this.events.push(args);
+ if (this.currentPromise) {
+ let p = this.currentPromise;
+ this.currentPromise = null;
+ p.resolve();
+ }
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ if (this.events.length == 0) {
+ await new Promise(resolve => (this.currentPromise = { resolve }));
+ }
+ let [actualEvent, ...actualArgs] = this.events.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(typeof expectedArgs[i], typeof actualArgs[i]);
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(expectedArgs[i][key], actualArgs[i][key]);
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+
+ return actualArgs;
+ },
+ };
+ browser.compose.onAttachmentAdded.addListener((...args) =>
+ listener.pushEvent("onAttachmentAdded", ...args)
+ );
+ browser.compose.onAttachmentRemoved.addListener((...args) =>
+ listener.pushEvent("onAttachmentRemoved", ...args)
+ );
+
+ let checkData = async (attachment, size) => {
+ let data = await browser.compose.getAttachmentFile(attachment.id);
+ browser.test.assertTrue(
+ // eslint-disable-next-line mozilla/use-isInstance
+ data instanceof File,
+ "Returned file obj should be a File instance."
+ );
+ browser.test.assertEq(
+ size,
+ data.size,
+ "Reported size should be correct."
+ );
+ browser.test.assertEq(
+ attachment.name,
+ data.name,
+ "Name of the File object should match the name of the attachment."
+ );
+ };
+
+ let checkUI = async (composeTab, ...expected) => {
+ let attachments = await browser.compose.listAttachments(composeTab.id);
+ browser.test.assertEq(
+ expected.length,
+ attachments.length,
+ "Number of found attachments should be correct."
+ );
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(expected[i].id, attachments[i].id);
+ browser.test.assertEq(expected[i].size, attachments[i].size);
+ }
+ let details = await browser.compose.getComposeDetails(composeTab.id);
+ return window.sendMessage("checkUI", details, expected);
+ };
+
+ let createCloudfileAccount = () => {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ };
+
+ let removeCloudfileAccount = id => {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ };
+
+ async function cloneAttachment(
+ attachment,
+ composeTab,
+ name = attachment.name
+ ) {
+ let clone;
+
+ // If the name is not changed, try to pass in the full object.
+ if (name == attachment.name) {
+ clone = await browser.compose.addAttachment(
+ composeTab.id,
+ attachment
+ );
+ } else {
+ clone = await browser.compose.addAttachment(composeTab.id, {
+ id: attachment.id,
+ name,
+ });
+ }
+
+ browser.test.assertEq(name, clone.name);
+ browser.test.assertEq(attachment.size, clone.size);
+ await checkData(clone, attachment.size);
+
+ let [, added] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab.id },
+ { id: clone.id, name }
+ );
+ await checkData(added, attachment.size);
+ return clone;
+ }
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ let file1 = new File(["File number one!"], "file1.txt");
+ let file2 = new File(
+ ["File number two? Yes, this is number two."],
+ "file2.txt"
+ );
+
+ // -----------------------------------------------------------------------
+
+ let composeTab1 = await browser.compose.beginNew({
+ to: "user@inter.net",
+ subject: "Test",
+ });
+ await checkUI(composeTab1);
+
+ // Add an attachment to composeTab1.
+
+ let tab1_attachment1 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file1,
+ }
+ );
+ browser.test.assertEq("file1.txt", tab1_attachment1.name);
+ browser.test.assertEq(16, tab1_attachment1.size);
+ await checkData(tab1_attachment1, file1.size);
+
+ let [, tab1_added1] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment1.id, name: "file1.txt" }
+ );
+ await checkData(tab1_added1, file1.size);
+
+ await checkUI(composeTab1, {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ // Add another attachment to composeTab1.
+
+ let tab1_attachment2 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file2,
+ name: "this is file2.txt",
+ }
+ );
+ browser.test.assertEq("this is file2.txt", tab1_attachment2.name);
+ browser.test.assertEq(41, tab1_attachment2.size);
+ await checkData(tab1_attachment2, file2.size);
+
+ let [, tab1_added2] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment2.id, name: "this is file2.txt" }
+ );
+ await checkData(tab1_added2, file2.size);
+
+ await checkUI(
+ composeTab1,
+ { id: tab1_attachment1.id, name: "file1.txt", size: file1.size },
+ { id: tab1_attachment2.id, name: "this is file2.txt", size: file2.size }
+ );
+
+ // Convert the second attachment to a cloudFile attachment.
+
+ await new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(1, fileInfo.id);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/1" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ // Conversion/upload is not yet supported via WebExt API.
+ browser.test.sendMessage(
+ "convertFile",
+ createdAccount.id,
+ "this is file2.txt"
+ );
+ });
+
+ await checkUI(
+ composeTab1,
+ {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab1_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/1",
+ }
+ );
+
+ // -----------------------------------------------------------------------
+
+ // Create a second compose window and clone both attachments from tab1. The
+ // second one should be cloned as a cloud attachment, having no size and the
+ // correct contentLocation.
+
+ let composeTab2 = await browser.compose.beginNew({
+ subject: "Message #7",
+ });
+ let tab2_attachment1 = await cloneAttachment(
+ tab1_attachment1,
+ composeTab2
+ );
+ await checkUI(composeTab2, {
+ id: tab2_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ let tab2_attachment2 = await cloneAttachment(
+ tab1_attachment2,
+ composeTab2,
+ "this is file2.txt"
+ );
+
+ await checkUI(
+ composeTab2,
+ {
+ id: tab2_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab2_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/1",
+ }
+ );
+
+ // Send the message and have its attachment marked as immutable.
+ await browser.compose.sendMessage(composeTab1.id, { mode: "sendNow" });
+ await browser.tabs.remove(composeTab1.id);
+
+ // Delete the cloud attachment2 in tab2, which should not trigger a cloud
+ // delete, as the url has been marked as immutable by sending the message
+ // in tab1.
+
+ function fileListener(account, id, tab) {
+ browser.test.fail(
+ `The onFileDeleted listener should not fire for deleting a cloud file marked as immutable.`
+ );
+ }
+ browser.cloudFile.onFileDeleted.addListener(fileListener);
+
+ await browser.compose.removeAttachment(
+ composeTab2.id,
+ tab2_attachment2.id
+ );
+ await listener.checkEvent(
+ "onAttachmentRemoved",
+ { id: composeTab2.id },
+ tab2_attachment2.id
+ );
+
+ // Clean up
+
+ await browser.tabs.remove(composeTab2.id);
+ browser.test.assertEq(0, listener.events.length);
+
+ await removeCloudfileAccount(createdAccount.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send"],
+ applications: { gecko: { id: "compose.attachments@mochi.test" } },
+ },
+ });
+
+ extension.onMessage("checkUI", (details, expected) => {
+ let composeWindow = findWindow(details.subject);
+ let composeDocument = composeWindow.document;
+
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ Assert.equal(bucket.itemCount, expected.length);
+
+ let totalSize = 0;
+ for (let i = 0; i < expected.length; i++) {
+ let item = bucket.itemChildren[i];
+ let { name, size, htmlSize, contentLocation } = expected[i];
+ totalSize += htmlSize ? htmlSize : size;
+
+ let displaySize = messenger.formatFileSize(size);
+ if (htmlSize) {
+ displaySize = `${messenger.formatFileSize(htmlSize)} (${displaySize})`;
+ Assert.equal(
+ item.cloudHtmlFileSize,
+ htmlSize,
+ "htmlSize should be correct."
+ );
+ }
+
+ // size and name are checked against the displayed values, contentLocation
+ // is checked against the associated attachment.
+
+ if (contentLocation) {
+ Assert.equal(
+ item.attachment.contentLocation,
+ contentLocation,
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ true,
+ "sendViaCloud for cloud files should be correct."
+ );
+ } else {
+ Assert.equal(
+ item.attachment.contentLocation,
+ "",
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ false,
+ "sendViaCloud for cloud files should be correct."
+ );
+ }
+
+ Assert.equal(
+ item.querySelector(".attachmentcell-name").textContent +
+ item.querySelector(".attachmentcell-extension").textContent,
+ name,
+ "Name should be correct."
+ );
+ Assert.equal(
+ item.querySelector(".attachmentcell-size").textContent,
+ displaySize,
+ "Displayed size should be correct."
+ );
+ }
+
+ let bucketTotal = composeDocument.getElementById("attachmentBucketSize");
+ if (totalSize == 0) {
+ Assert.equal(bucketTotal.textContent, "");
+ } else {
+ Assert.equal(
+ bucketTotal.textContent,
+ messenger.formatFileSize(totalSize)
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("createAccount", () => {
+ cloudFileAccounts.createAccount("ext-compose.attachments@mochi.test");
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage("convertFile", (cloudFileAccountId, attachmentName) => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let composeDocument = composeWindow.document;
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ let account = cloudFileAccounts.getAccount(cloudFileAccountId);
+
+ let attachmentItem = bucket.itemChildren.find(
+ item => item.attachment && item.attachment.name == attachmentName
+ );
+
+ composeWindow.convertListItemsToCloudAttachment([attachmentItem], account);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ MockCompleteGenericSendMessage.unregister();
+});
+
+add_task(async function test_compose_attachments_no_reuse() {
+ let files = {
+ "background.js": async () => {
+ let listener = {
+ events: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ this.events.push(args);
+ if (this.currentPromise) {
+ let p = this.currentPromise;
+ this.currentPromise = null;
+ p.resolve();
+ }
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ if (this.events.length == 0) {
+ await new Promise(resolve => (this.currentPromise = { resolve }));
+ }
+ let [actualEvent, ...actualArgs] = this.events.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(typeof expectedArgs[i], typeof actualArgs[i]);
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(expectedArgs[i][key], actualArgs[i][key]);
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+
+ return actualArgs;
+ },
+ };
+ browser.compose.onAttachmentAdded.addListener((...args) =>
+ listener.pushEvent("onAttachmentAdded", ...args)
+ );
+ browser.compose.onAttachmentRemoved.addListener((...args) =>
+ listener.pushEvent("onAttachmentRemoved", ...args)
+ );
+
+ let checkData = async (attachment, size) => {
+ let data = await browser.compose.getAttachmentFile(attachment.id);
+ browser.test.assertTrue(
+ // eslint-disable-next-line mozilla/use-isInstance
+ data instanceof File,
+ "Returned file obj should be a File instance."
+ );
+ browser.test.assertEq(
+ size,
+ data.size,
+ "Reported size should be correct."
+ );
+ browser.test.assertEq(
+ attachment.name,
+ data.name,
+ "Name of the File object should match the name of the attachment."
+ );
+ };
+
+ let checkUI = async (composeTab, ...expected) => {
+ let attachments = await browser.compose.listAttachments(composeTab.id);
+ browser.test.assertEq(
+ expected.length,
+ attachments.length,
+ "Number of found attachments should be correct."
+ );
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(expected[i].id, attachments[i].id);
+ browser.test.assertEq(expected[i].size, attachments[i].size);
+ }
+ let details = await browser.compose.getComposeDetails(composeTab.id);
+ return window.sendMessage("checkUI", details, expected);
+ };
+
+ let createCloudfileAccount = () => {
+ let addListener = window.waitForEvent("cloudFile.onAccountAdded");
+ browser.test.sendMessage("createAccount");
+ return addListener;
+ };
+
+ let removeCloudfileAccount = id => {
+ let deleteListener = window.waitForEvent("cloudFile.onAccountDeleted");
+ browser.test.sendMessage("removeAccount", id);
+ return deleteListener;
+ };
+
+ async function cloneAttachment(
+ attachment,
+ composeTab,
+ name = attachment.name
+ ) {
+ let clone;
+
+ // If the name is not changed, try to pass in the full object.
+ if (name == attachment.name) {
+ clone = await browser.compose.addAttachment(
+ composeTab.id,
+ attachment
+ );
+ } else {
+ clone = await browser.compose.addAttachment(composeTab.id, {
+ id: attachment.id,
+ name,
+ });
+ }
+
+ browser.test.assertEq(name, clone.name);
+ browser.test.assertEq(attachment.size, clone.size);
+ await checkData(clone, attachment.size);
+
+ let [, added] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab.id },
+ { id: clone.id, name }
+ );
+ await checkData(added, attachment.size);
+ return clone;
+ }
+
+ let [createdAccount] = await createCloudfileAccount();
+
+ let file1 = new File(["File number one!"], "file1.txt");
+ let file2 = new File(
+ ["File number two? Yes, this is number two."],
+ "file2.txt"
+ );
+
+ // -----------------------------------------------------------------------
+
+ let composeTab1 = await browser.compose.beginNew({
+ subject: "Message #8",
+ });
+ await checkUI(composeTab1);
+
+ // Add an attachment to composeTab1.
+
+ let tab1_attachment1 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file1,
+ }
+ );
+ browser.test.assertEq("file1.txt", tab1_attachment1.name);
+ browser.test.assertEq(16, tab1_attachment1.size);
+ await checkData(tab1_attachment1, file1.size);
+
+ let [, tab1_added1] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment1.id, name: "file1.txt" }
+ );
+ await checkData(tab1_added1, file1.size);
+
+ await checkUI(composeTab1, {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ // Add another attachment to composeTab1.
+
+ let tab1_attachment2 = await browser.compose.addAttachment(
+ composeTab1.id,
+ {
+ file: file2,
+ name: "this is file2.txt",
+ }
+ );
+ browser.test.assertEq("this is file2.txt", tab1_attachment2.name);
+ browser.test.assertEq(41, tab1_attachment2.size);
+ await checkData(tab1_attachment2, file2.size);
+
+ let [, tab1_added2] = await listener.checkEvent(
+ "onAttachmentAdded",
+ { id: composeTab1.id },
+ { id: tab1_attachment2.id, name: "this is file2.txt" }
+ );
+ await checkData(tab1_added2, file2.size);
+
+ await checkUI(
+ composeTab1,
+ { id: tab1_attachment1.id, name: "file1.txt", size: file1.size },
+ { id: tab1_attachment2.id, name: "this is file2.txt", size: file2.size }
+ );
+
+ // Convert the second attachment to a cloudFile attachment.
+
+ await new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(1, fileInfo.id);
+ browser.test.assertEq(undefined, relatedFileInfo);
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/1" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ // Conversion/upload is not yet supported via WebExt API.
+ browser.test.sendMessage(
+ "convertFile",
+ createdAccount.id,
+ "this is file2.txt"
+ );
+ });
+
+ await checkUI(
+ composeTab1,
+ {
+ id: tab1_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab1_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/1",
+ }
+ );
+
+ // -----------------------------------------------------------------------
+
+ // Create a second compose window and clone both attachments from tab1. The
+ // second one should be cloned as a cloud attachment, having no size and the
+ // correct contentLocation.
+ // Attachments are not renamed, but since reuse_uploads is disabled, a new
+ // upload request must be issued. The original attachment should be passed
+ // as relatedFileInfo.
+ let tab2_uploadPromise = new Promise(resolve => {
+ function fileListener(account, fileInfo, tab, relatedFileInfo) {
+ browser.cloudFile.onFileUpload.removeListener(fileListener);
+ browser.test.assertEq(2, fileInfo.id);
+ browser.test.assertEq("this is file2.txt", fileInfo.name);
+ browser.test.assertEq(1, relatedFileInfo.id);
+ browser.test.assertEq("this is file2.txt", relatedFileInfo.name);
+ browser.test.assertFalse(
+ relatedFileInfo.dataChanged,
+ `data should not have changed`
+ );
+ setTimeout(() => resolve());
+ return { url: "https://cloud.provider.net/2" };
+ }
+
+ browser.cloudFile.onFileUpload.addListener(fileListener);
+ });
+
+ let composeTab2 = await browser.compose.beginNew({
+ subject: "Message #9",
+ });
+ let tab2_attachment1 = await cloneAttachment(
+ tab1_attachment1,
+ composeTab2
+ );
+ await checkUI(composeTab2, {
+ id: tab2_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ });
+
+ let tab2_attachment2 = await cloneAttachment(
+ tab1_attachment2,
+ composeTab2,
+ "this is file2.txt"
+ );
+
+ await checkUI(
+ composeTab2,
+ {
+ id: tab2_attachment1.id,
+ name: "file1.txt",
+ size: file1.size,
+ },
+ {
+ id: tab2_attachment2.id,
+ name: "this is file2.txt",
+ size: 41,
+ htmlSize: 4300,
+ contentLocation: "https://cloud.provider.net/2",
+ }
+ );
+
+ await tab2_uploadPromise;
+
+ await browser.tabs.remove(composeTab2.id);
+ await browser.tabs.remove(composeTab1.id);
+ browser.test.assertEq(0, listener.events.length);
+
+ await removeCloudfileAccount(createdAccount.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ cloud_file: {
+ name: "mochitest",
+ management_url: "/content/management.html",
+ reuse_uploads: false,
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ applications: { gecko: { id: "compose.attachments@mochi.test" } },
+ },
+ });
+
+ extension.onMessage("checkUI", (details, expected) => {
+ let composeWindow = findWindow(details.subject);
+ let composeDocument = composeWindow.document;
+
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ Assert.equal(bucket.itemCount, expected.length);
+
+ let totalSize = 0;
+ for (let i = 0; i < expected.length; i++) {
+ let item = bucket.itemChildren[i];
+ let { name, size, htmlSize, contentLocation } = expected[i];
+ totalSize += htmlSize ? htmlSize : size;
+
+ let displaySize = messenger.formatFileSize(size);
+ if (htmlSize) {
+ displaySize = `${messenger.formatFileSize(htmlSize)} (${displaySize})`;
+ Assert.equal(
+ item.cloudHtmlFileSize,
+ htmlSize,
+ "htmlSize should be correct."
+ );
+ }
+
+ // size and name are checked against the displayed values, contentLocation
+ // is checked against the associated attachment.
+
+ if (contentLocation) {
+ Assert.equal(
+ item.attachment.contentLocation,
+ contentLocation,
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ true,
+ "sendViaCloud for cloud files should be correct."
+ );
+ } else {
+ Assert.equal(
+ item.attachment.contentLocation,
+ "",
+ "contentLocation for cloud files should be correct."
+ );
+ Assert.equal(
+ item.attachment.sendViaCloud,
+ false,
+ "sendViaCloud for cloud files should be correct."
+ );
+ }
+
+ Assert.equal(
+ item.querySelector(".attachmentcell-name").textContent +
+ item.querySelector(".attachmentcell-extension").textContent,
+ name,
+ "Name should be correct."
+ );
+ Assert.equal(
+ item.querySelector(".attachmentcell-size").textContent,
+ displaySize,
+ "Displayed size should be correct."
+ );
+ }
+
+ let bucketTotal = composeDocument.getElementById("attachmentBucketSize");
+ if (totalSize == 0) {
+ Assert.equal(bucketTotal.textContent, "");
+ } else {
+ Assert.equal(
+ bucketTotal.textContent,
+ messenger.formatFileSize(totalSize)
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("createAccount", () => {
+ cloudFileAccounts.createAccount("ext-compose.attachments@mochi.test");
+ });
+
+ extension.onMessage("removeAccount", id => {
+ cloudFileAccounts.removeAccount(id);
+ });
+
+ extension.onMessage("convertFile", (cloudFileAccountId, attachmentName) => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let composeDocument = composeWindow.document;
+ let bucket = composeDocument.getElementById("attachmentBucket");
+ let account = cloudFileAccounts.getAccount(cloudFileAccountId);
+
+ let attachmentItem = bucket.itemChildren.find(
+ item => item.attachment && item.attachment.name == attachmentName
+ );
+
+ composeWindow.convertListItemsToCloudAttachment([attachmentItem], account);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_without_permission() {
+ let files = {
+ "background.js": async () => {
+ // Try to use onAttachmentAdded.
+ await browser.test.assertThrows(
+ () => browser.compose.onAttachmentAdded.addListener(),
+ /browser\.compose\.onAttachmentAdded is undefined/,
+ "Should reject listener without proper permission"
+ );
+
+ // Try to use onAttachmentRemoved.
+ await browser.test.assertThrows(
+ () => browser.compose.onAttachmentRemoved.addListener(),
+ /browser\.compose\.onAttachmentRemoved is undefined/,
+ "Should reject listener without proper permission"
+ );
+
+ // Try to use listAttachments.
+ await browser.test.assertThrows(
+ () => browser.compose.listAttachments(),
+ `browser.compose.listAttachments is not a function`,
+ "Should reject function without proper permission"
+ );
+
+ // Try to use addAttachment.
+ await browser.test.assertThrows(
+ () => browser.compose.addAttachment(),
+ `browser.compose.addAttachment is not a function`,
+ "Should reject function without proper permission"
+ );
+
+ // Try to use updateAttachment.
+ await browser.test.assertThrows(
+ () => browser.compose.updateAttachment(),
+ `browser.compose.updateAttachment is not a function`,
+ "Should reject function without proper permission"
+ );
+
+ // Try to use removeAttachment.
+ await browser.test.assertThrows(
+ () => browser.compose.removeAttachment(),
+ `browser.compose.removeAttachment is not a function`,
+ "Should reject function without proper permission"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_attachment_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, the eventCounter is reset and
+ // allows to observe the order of events fired. In case of a wake-up, the
+ // first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ browser.compose.onAttachmentAdded.addListener(async (tab, attachment) => {
+ browser.test.sendMessage("attachment added", {
+ eventCount: ++eventCounter,
+ attachment,
+ });
+ });
+
+ browser.compose.onAttachmentRemoved.addListener(
+ async (tab, attachmentId) => {
+ browser.test.sendMessage("attachment removed", {
+ eventCount: ++eventCounter,
+ attachmentId,
+ });
+ }
+ );
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose", "messagesRead"],
+ browser_specific_settings: {
+ gecko: { id: "compose.attachment@mochi.test" },
+ },
+ },
+ });
+
+ async function addAttachment(ordinal) {
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ attachment.name = `${ordinal}.txt`;
+ attachment.url = `data:text/plain,I'm the ${ordinal} attachment!`;
+ attachment.size = attachment.url.length - 16;
+
+ await composeWindow.AddAttachments([attachment]);
+ return attachment;
+ }
+
+ async function removeAttachment(attachment) {
+ let item =
+ composeWindow.gAttachmentBucket.findItemForAttachment(attachment);
+ await composeWindow.RemoveAttachments([item]);
+ }
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "compose.onAttachmentAdded",
+ "compose.onAttachmentRemoved",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger events without terminating the background first.
+
+ let rawFirstAttachment = await addAttachment("first");
+ let addedFirst = await extension.awaitMessage("attachment added");
+ Assert.equal(
+ "first.txt",
+ rawFirstAttachment.name,
+ "Created attachment should be correct"
+ );
+ Assert.equal(
+ "first.txt",
+ addedFirst.attachment.name,
+ "Attachment returned by onAttachmentAdded should be correct"
+ );
+ Assert.equal(1, addedFirst.eventCount, "Event counter should be correct");
+
+ await removeAttachment(rawFirstAttachment);
+
+ let removedFirst = await extension.awaitMessage("attachment removed");
+ Assert.equal(
+ addedFirst.attachment.id,
+ removedFirst.attachmentId,
+ "Attachment id returned by onAttachmentRemoved should be correct"
+ );
+ Assert.equal(2, removedFirst.eventCount, "Event counter should be correct");
+
+ // Terminate background and re-trigger onAttachmentAdded event.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ let rawSecondAttachment = await addAttachment("second");
+ let addedSecond = await extension.awaitMessage("attachment added");
+ Assert.equal(
+ "second.txt",
+ rawSecondAttachment.name,
+ "Created attachment should be correct"
+ );
+ Assert.equal(
+ "second.txt",
+ addedSecond.attachment.name,
+ "Attachment returned by onAttachmentAdded should be correct"
+ );
+ Assert.equal(1, addedSecond.eventCount, "Event counter should be correct");
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listeners should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ // Terminate background and re-trigger onAttachmentRemoved event.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ await removeAttachment(rawSecondAttachment);
+ let removedSecond = await extension.awaitMessage("attachment removed");
+ Assert.equal(
+ addedSecond.attachment.id,
+ removedSecond.attachmentId,
+ "Attachment id returned by onAttachmentRemoved should be correct"
+ );
+ Assert.equal(1, removedSecond.eventCount, "Event counter should be correct");
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listeners should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_attachments.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_attachments.js
new file mode 100644
index 0000000000..26f4d0ab5e
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_attachments.js
@@ -0,0 +1,116 @@
+/* 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 { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+addIdentity(account);
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+add_task(async function testAttachments() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+
+ let newTab = await browser.compose.beginNew({
+ attachments: [
+ { file: new File(["one"], "attachment1.txt") },
+ { file: new File(["two"], "attachment-två.txt") },
+ ],
+ });
+
+ let attachments = await browser.compose.listAttachments(newTab.id);
+ browser.test.assertEq(2, attachments.length);
+ browser.test.assertEq("attachment1.txt", attachments[0].name);
+ browser.test.assertEq("attachment-två.txt", attachments[1].name);
+
+ let replyTab = await browser.compose.beginReply(messages[0].id, {
+ attachments: [
+ { file: new File(["three"], "attachment3.txt") },
+ { file: new File(["four"], "attachment4.txt") },
+ ],
+ });
+
+ attachments = await browser.compose.listAttachments(replyTab.id);
+ browser.test.assertEq(2, attachments.length);
+ browser.test.assertEq("attachment3.txt", attachments[0].name);
+ browser.test.assertEq("attachment4.txt", attachments[1].name);
+
+ let forwardTab = await browser.compose.beginForward(
+ messages[1].id,
+ "forwardAsAttachment",
+ {
+ attachments: [
+ { file: new File(["five"], "attachment5.txt") },
+ { file: new File(["six"], "attachment6.txt") },
+ ],
+ }
+ );
+
+ attachments = await browser.compose.listAttachments(forwardTab.id);
+ browser.test.assertEq(3, attachments.length);
+ browser.test.assertEq(`${messages[1].subject}.eml`, attachments[0].name);
+ browser.test.assertEq("attachment5.txt", attachments[1].name);
+ browser.test.assertEq("attachment6.txt", attachments[2].name);
+
+ // Forward inline adds attachments differently, so check it works too.
+
+ let forwardTab2 = await browser.compose.beginForward(
+ messages[2].id,
+ "forwardInline",
+ {
+ attachments: [
+ { file: new File(["seven"], "attachment7.txt") },
+ { file: new File(["eight"], "attachment-åtta.txt") },
+ ],
+ }
+ );
+
+ attachments = await browser.compose.listAttachments(forwardTab2.id);
+ browser.test.assertEq(2, attachments.length);
+ browser.test.assertEq("attachment7.txt", attachments[0].name);
+ browser.test.assertEq("attachment-åtta.txt", attachments[1].name);
+
+ let newTab2 = await browser.compose.beginNew(messages[3].id, {
+ attachments: [
+ { file: new File(["nine"], "attachment9.txt") },
+ { file: new File(["ten"], "attachment10.txt") },
+ ],
+ });
+
+ attachments = await browser.compose.listAttachments(newTab2.id);
+ browser.test.assertEq(2, attachments.length);
+ browser.test.assertEq("attachment9.txt", attachments[0].name);
+ browser.test.assertEq("attachment10.txt", attachments[1].name);
+
+ await browser.tabs.remove(newTab.id);
+ await browser.tabs.remove(replyTab.id);
+ await browser.tabs.remove(forwardTab.id);
+ await browser.tabs.remove(forwardTab2.id);
+ await browser.tabs.remove(newTab2.id);
+
+ browser.test.notifyPass();
+ },
+ manifest: {
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_body.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_body.js
new file mode 100644
index 0000000000..3b454a9c8b
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_body.js
@@ -0,0 +1,397 @@
+/* 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/. */
+
+requestLongerTimeout(2);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+let defaultIdentity = addIdentity(account);
+defaultIdentity.composeHtml = true;
+let nonDefaultIdentity = addIdentity(account);
+nonDefaultIdentity.composeHtml = false;
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+add_task(async function testBody() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ browser.test.assertEq(
+ 2,
+ popAccount.identities.length,
+ "number of identities"
+ );
+ let [htmlIdentity, plainTextIdentity] = popAccount.identities;
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let message0 = await browser.messages.getFull(messages[0].id);
+ let message0body = message0.parts[0].body;
+
+ // Editor content of a newly opened composeWindow without setting a body.
+ let defaultHTML = "<body><p><br></p></body>";
+ // Editor content after composeWindow.SetComposeDetails() has been used
+ // to clear the body.
+ let setEmptyHTML = "<body><br></body>";
+ let plainTextBodyTag =
+ '<body style="font-family: -moz-fixed; white-space: pre-wrap; width: 72ch;">';
+ let tests = [
+ {
+ // No arguments.
+ funcName: "beginNew",
+ arguments: [],
+ expected: {
+ isHTML: true,
+ htmlIncludes: defaultHTML,
+ plainTextIs: "\n",
+ },
+ },
+ {
+ // Empty arguments.
+ funcName: "beginNew",
+ arguments: [{}],
+ expected: {
+ isHTML: true,
+ htmlIncludes: defaultHTML,
+ plainTextIs: "\n",
+ },
+ },
+ {
+ // Empty HTML.
+ funcName: "beginNew",
+ arguments: [{ body: "" }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: setEmptyHTML,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty plain text.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "" }],
+ expected: {
+ isHTML: false,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty enforced plain text with default identity.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "", isPlainText: true }],
+ expected: {
+ isHTML: false,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty HTML for plaintext identity.
+ funcName: "beginNew",
+ arguments: [{ body: "", identityId: plainTextIdentity.id }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: setEmptyHTML,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty plain text for plaintext identity.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "", identityId: plainTextIdentity.id }],
+ expected: {
+ isHTML: false,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty HTML for plaintext identity enforcing HTML.
+ funcName: "beginNew",
+ arguments: [
+ { body: "", identityId: plainTextIdentity.id, isPlainText: false },
+ ],
+ expected: {
+ isHTML: true,
+ htmlIncludes: setEmptyHTML,
+ plainTextIs: "",
+ },
+ },
+ {
+ // Empty plain text and isPlainText.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "", isPlainText: true }],
+ expected: { isHTML: false, plainTextIs: "" },
+ },
+ {
+ // Non-empty HTML.
+ funcName: "beginNew",
+ arguments: [{ body: "<p>I'm an HTML message!</p>" }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: "<body><p>I'm an HTML message!</p></body>",
+ plainTextIs: "I'm an HTML message!",
+ },
+ },
+ {
+ // Non-empty plain text.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "I'm a plain text message!" }],
+ expected: {
+ isHTML: false,
+ htmlIncludes: plainTextBodyTag + "I'm a plain text message!</body>",
+ plainTextIs: "I'm a plain text message!",
+ },
+ },
+ {
+ // Non-empty plain text and isPlainText.
+ funcName: "beginNew",
+ arguments: [
+ {
+ plainTextBody: "I'm a plain text message!",
+ isPlainText: true,
+ },
+ ],
+ expected: {
+ isHTML: false,
+ htmlIncludes: plainTextBodyTag + "I'm a plain text message!</body>",
+ plainTextIs: "I'm a plain text message!",
+ },
+ },
+ {
+ // HTML body and plain text body without isPlainText. Use default format.
+ funcName: "beginNew",
+ arguments: [{ body: "I am HTML", plainTextBody: "I am TEXT" }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: "I am HTML",
+ plainTextIs: "I am HTML",
+ },
+ },
+ {
+ // HTML body and plain text body with isPlainText. Use the specified
+ // format.
+ funcName: "beginNew",
+ arguments: [
+ {
+ body: "I am HTML",
+ plainTextBody: "I am TEXT",
+ isPlainText: true,
+ },
+ ],
+ expected: {
+ isHTML: false,
+ plainTextIs: "I am TEXT",
+ },
+ },
+ {
+ // Providing an HTML body only and isPlainText = true. Conflicting and
+ // thus invalid.
+ funcName: "beginNew",
+ arguments: [{ body: "I am HTML", isPlainText: true }],
+ throws: true,
+ },
+ {
+ // Providing a plain text body only and isPlainText = false. Conflicting
+ // and thus invalid.
+ funcName: "beginNew",
+ arguments: [{ plainTextBody: "I am TEXT", isPlainText: false }],
+ throws: true,
+ },
+ {
+ // HTML body only and isPlainText false.
+ funcName: "beginNew",
+ arguments: [{ body: "I am HTML", isPlainText: false }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: "I am HTML",
+ plainTextIs: "I am HTML",
+ },
+ },
+ {
+ // Edit as new.
+ funcName: "beginNew",
+ arguments: [messages[0].id],
+ expected: {
+ isHTML: true,
+ htmlIncludes: message0body.trim(),
+ },
+ },
+ {
+ // Edit as new with plaintext identity
+ funcName: "beginNew",
+ arguments: [messages[0].id, { identityId: plainTextIdentity.id }],
+ expected: {
+ isHTML: false,
+ plainTextIs: message0body,
+ },
+ },
+ {
+ // Edit as new with default identity enforcing HTML
+ funcName: "beginNew",
+ arguments: [messages[0].id, { isPlainText: false }],
+ expected: {
+ isHTML: true,
+ htmlIncludes: message0body.trim(),
+ },
+ },
+ {
+ // Edit as new with plaintext identity enforcing HTML by setting a body.
+ funcName: "beginNew",
+ arguments: [
+ messages[0].id,
+ {
+ body: "<p>This is some HTML text</p>",
+ identityId: plainTextIdentity.id,
+ },
+ ],
+ expected: {
+ isHTML: true,
+ htmlIncludes: "<p>This is some HTML text</p>",
+ },
+ },
+ {
+ // Edit as new with html identity enforcing plain text by setting a plainTextBody.
+ funcName: "beginNew",
+ arguments: [
+ messages[0].id,
+ {
+ plainTextBody: "This is some plain text",
+ identityId: htmlIdentity.id,
+ },
+ ],
+ expected: {
+ isHTML: false,
+ plainText: "This is some plain text",
+ },
+ },
+ {
+ // ForwardInline with plaintext identity enforcing HTML
+ funcName: "beginForward",
+ arguments: [
+ messages[0].id,
+ { identityId: plainTextIdentity.id, isPlainText: false },
+ ],
+ expected: {
+ isHTML: true,
+ htmlIncludes: message0body.trim(),
+ },
+ },
+ {
+ // Reply.
+ funcName: "beginReply",
+ arguments: [messages[0].id],
+ expected: {
+ isHTML: true,
+ htmlIncludes: message0body.trim(),
+ },
+ },
+ {
+ // Forward inline.
+ funcName: "beginForward",
+ arguments: [messages[0].id],
+ expected: {
+ isHTML: true,
+ htmlIncludes: message0body.trim(),
+ },
+ },
+ {
+ // Forward as attachment.
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardAsAttachment"],
+ expected: {
+ isHTML: true,
+ htmlIncludes: defaultHTML,
+ plainText: "",
+ },
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ try {
+ await browser.compose[test.funcName](...test.arguments);
+ if (test.throws) {
+ browser.test.fail(
+ "calling beginNew with these arguments should throw"
+ );
+ }
+ } catch (ex) {
+ if (test.throws) {
+ browser.test.succeed("expected exception thrown");
+ } else {
+ browser.test.fail(`unexpected exception thrown: ${ex.message}`);
+ }
+ }
+
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+ if (test.expected) {
+ browser.test.sendMessage("checkBody", test.expected);
+ await window.waitForMessage();
+ }
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+ extension.onMessage("checkBody", async expected => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ await new Promise(resolve => composeWindows[0].setTimeout(resolve));
+
+ is(composeWindows[0].IsHTMLEditor(), expected.isHTML, "composition mode");
+
+ let editor = composeWindows[0].GetCurrentEditor();
+ // Get the actual message body. Fold Windows line-endings \r\n to \n.
+ let actualHTML = editor
+ .outputToString("text/html", Ci.nsIDocumentEncoder.OutputRaw)
+ .replace(/\r/g, "");
+ let actualPlainText = editor
+ .outputToString("text/plain", Ci.nsIDocumentEncoder.OutputRaw)
+ .replace(/\r/g, "");
+ if ("htmlIncludes" in expected) {
+ info(actualHTML);
+ ok(
+ actualHTML.includes(expected.htmlIncludes.replace(/\r/g, "")),
+ `HTML content is correct (${actualHTML} vs ${expected.htmlIncludes})`
+ );
+ }
+ if ("plainTextIs" in expected) {
+ is(
+ actualPlainText,
+ expected.plainTextIs.replace(/\r/g, ""),
+ "plainText content is correct"
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_bug1691254.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_bug1691254.js
new file mode 100644
index 0000000000..6a763d5c43
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_bug1691254.js
@@ -0,0 +1,141 @@
+/* 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/. */
+
+requestLongerTimeout(2);
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+let defaultIdentity = addIdentity(account);
+defaultIdentity.composeHtml = true;
+let nonDefaultIdentity = addIdentity(account);
+nonDefaultIdentity.composeHtml = false;
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+/* Test if line breaks in HTML are ignored (see bug 1691254). */
+add_task(async function testBR() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let body = `<html><head>\r\n\r\n \r\n<meta http-equiv="content-type" content="text/html; charset=UTF-8">\r\n\r\n </head><body>\r\n \r\n<p><font face="monospace">This is some <br> HTML text</font><br>\r\n </p>\r\n\r\n \r\n\r\n\r\n</body></html>\r\n\r\n\r\n`;
+ let tests = [
+ {
+ description: "Begin new.",
+ funcName: "beginNew",
+ arguments: [{ body }],
+ },
+ {
+ description: "Edit as new.",
+ funcName: "beginNew",
+ arguments: [messages[0].id, { body }],
+ },
+ {
+ description: "Reply default.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, { body }],
+ },
+ {
+ description: "Reply as replyToSender.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToSender", { body }],
+ },
+ {
+ description: "Reply as replyToList.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToList", { body }],
+ },
+ {
+ description: "Reply as replyToAll.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToAll", { body }],
+ },
+ {
+ description: "Forward default.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, { body }],
+ },
+ {
+ description: "Forward inline.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardInline", { body }],
+ },
+ {
+ description: "Forward as attachment.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardAsAttachment", { body }],
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose[test.funcName](...test.arguments);
+
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+ browser.test.sendMessage("checkBody", test);
+ await window.waitForMessage();
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+ extension.onMessage("checkBody", async test => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ await new Promise(resolve => composeWindows[0].setTimeout(resolve));
+
+ is(composeWindows[0].IsHTMLEditor(), true, "composition mode");
+
+ let editor = composeWindows[0].GetCurrentEditor();
+ let actualHTML = editor.outputToString(
+ "text/html",
+ Ci.nsIDocumentEncoder.OutputRaw
+ );
+ let brCounts = (actualHTML.match(/<br>/g) || []).length;
+ is(
+ brCounts,
+ 2,
+ `[${test.description}] Number of br tags in html is correct (${actualHTML}).`
+ );
+
+ let eqivCounts = (actualHTML.match(/http-equiv/g) || []).length;
+ is(
+ eqivCounts,
+ 1,
+ `[${test.description}] Number of http-equiv meta tags in html is correct (${actualHTML}).`
+ );
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_forward.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_forward.js
new file mode 100644
index 0000000000..621947609d
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_forward.js
@@ -0,0 +1,339 @@
+/* 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 { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_setup(() => {
+ let account = createAccount("pop3");
+ createAccount("local");
+ MailServices.accounts.defaultAccount = account;
+
+ addIdentity(account);
+
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test", null);
+ let folder = rootFolder.getChildNamed("test");
+ createMessages(folder, 4);
+});
+
+/* Test if getComposeDetails() is waiting until the entire init procedure of
+ * the composeWindow has finished, before returning values. */
+add_task(async function testComposerIsReady() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let details = {
+ plainTextBody: "This is Text",
+ to: ["Mr. Holmes <holmes@bakerstreet.invalid>"],
+ subject: "Test Email",
+ };
+
+ let tests = [
+ {
+ description: "Forward default.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, details],
+ },
+ {
+ description: "Forward inline.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardInline", details],
+ },
+ {
+ description: "Forward as attachment.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardAsAttachment", details],
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+ let expectedDetails = test.arguments[test.arguments.length - 1];
+
+ // Test with windows.onCreated
+ {
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdWindow] = await createdWindowPromise;
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let actualDetails = await browser.compose.getComposeDetails(tab.id);
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After windows.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ // Test the windows API being able to return the messageCompose window as
+ // the current one.
+ await window.waitForCondition(async () => {
+ let win = await browser.windows.get(createdWindow.id);
+ return win.focused;
+ }, `Window should have received focus.`);
+
+ let composeWindow = await browser.windows.get(tab.windowId);
+ browser.test.assertEq(composeWindow.type, "messageCompose");
+ let curWindow = await browser.windows.getCurrent();
+ browser.test.assertEq(tab.windowId, curWindow.id);
+ // Test the tabs API being able to return the correct current tab.
+ let [currentTab] = await browser.tabs.query({
+ currentWindow: true,
+ active: true,
+ });
+ browser.test.assertEq(tab.id, currentTab.id);
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ // Test with tabs.onCreated
+ {
+ let createdTabPromise = window.waitForEvent("tabs.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdTab] = await createdTabPromise;
+ let actualDetails = await browser.compose.getComposeDetails(
+ createdTab.id
+ );
+
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After tabs.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let createdWindow = await browser.windows.get(createdTab.windowId);
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/* Test the compose API accessing the forwarded message added by beginForward. */
+add_task(async function testBeginForward() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let details = {
+ plainTextBody: "This is Text",
+ to: ["Mr. Holmes <holmes@bakerstreet.invalid>"],
+ subject: "Test Email",
+ };
+
+ let tests = [
+ {
+ description: "Forward as attachment.",
+ funcName: "beginForward",
+ arguments: [messages[0].id, "forwardAsAttachment", details],
+ expectedAttachments: [
+ {
+ name: "Big Meeting Today.eml",
+ type: "message/rfc822",
+ size: 281,
+ content: "Hello Bob Bell!",
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+
+ let tab = await browser.compose[test.funcName](...test.arguments);
+ let attachments = await browser.compose.listAttachments(tab.id);
+ browser.test.assertEq(
+ test.expectedAttachments.length,
+ attachments.length,
+ `Should have the expected number of attachments`
+ );
+ for (let i = 0; i < attachments.length; i++) {
+ let file = await browser.compose.getAttachmentFile(attachments[i].id);
+ for (let [property, value] of Object.entries(
+ test.expectedAttachments[i]
+ )) {
+ if (property == "content") {
+ let content = await file.text();
+ browser.test.assertTrue(
+ content.includes(value),
+ `Attachment body should include ${value}`
+ );
+ } else {
+ browser.test.assertEq(
+ value,
+ file[property],
+ `Attachment should have the correct value for ${property}`
+ );
+ }
+ }
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(tab.windowId);
+ await removedWindowPromise;
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/* The forward inline code path uses a hacky way to identify the correct window
+ * after it has been opened via MailServices.compose.OpenComposeWindow. Test it.*/
+add_task(async function testBeginForwardInlineMixUp() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ // Test opening different messages.
+ {
+ let promisedTabs = [];
+ promisedTabs.push(
+ browser.compose.beginForward(messages[0].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[1].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[2].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[3].id, "forwardInline")
+ );
+
+ let foundIds = new Set();
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < 4; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened compose window should have been fulfilled for message ${i}`
+ );
+
+ browser.test.assertTrue(
+ !foundIds.has(openedTabs[i].value.id),
+ `Tab ${i} should have a unique id ${openedTabs[i].value.id}`
+ );
+ foundIds.add(openedTabs[i].value.id);
+
+ let details = await browser.compose.getComposeDetails(
+ openedTabs[i].value.id
+ );
+ browser.test.assertEq(
+ messages[i].id,
+ details.relatedMessageId,
+ `Should see the correct message in compose window ${i}`
+ );
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ // Test opening identical messages.
+ {
+ let promisedTabs = [];
+ promisedTabs.push(
+ browser.compose.beginForward(messages[0].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[0].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[0].id, "forwardInline")
+ );
+ promisedTabs.push(
+ browser.compose.beginForward(messages[0].id, "forwardInline")
+ );
+
+ let foundIds = new Set();
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < 4; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened compose window should have been fulfilled for message ${i}`
+ );
+
+ browser.test.assertTrue(
+ !foundIds.has(openedTabs[i].value.id),
+ `Tab ${i} should have a unique id ${openedTabs[i].value.id}`
+ );
+ foundIds.add(openedTabs[i].value.id);
+
+ let details = await browser.compose.getComposeDetails(
+ openedTabs[i].value.id
+ );
+ browser.test.assertEq(
+ messages[0].id,
+ details.relatedMessageId,
+ `Should see the correct message in compose window ${i}`
+ );
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_headers.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_headers.js
new file mode 100644
index 0000000000..7d360b7920
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_headers.js
@@ -0,0 +1,178 @@
+/* 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 { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+addIdentity(account);
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+add_task(async function testHeaders() {
+ let files = {
+ "background.js": async () => {
+ async function checkHeaders(expected) {
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+ browser.test.sendMessage("checkHeaders", expected);
+ await window.waitForMessage();
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ 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);
+
+ let createdWindowPromise;
+
+ // Start a new message.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ await checkHeaders({});
+
+ // Start a new message, with a subject and recipients as strings.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: "Sherlock Holmes <sherlock@bakerstreet.invalid>",
+ cc: "John Watson <john@bakerstreet.invalid>",
+ subject: "Did you miss me?",
+ });
+ await checkHeaders({
+ 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 = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: ["Sherlock Holmes <sherlock@bakerstreet.invalid>"],
+ cc: ["John Watson <john@bakerstreet.invalid>"],
+ subject: "Did you miss me?",
+ });
+ await checkHeaders({
+ 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 = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: [{ id: contacts.sherlock, type: "contact" }],
+ cc: [{ id: contacts.john, type: "contact" }],
+ subject: "Did you miss me?",
+ });
+ await checkHeaders({
+ 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 = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: [{ id: list, type: "mailingList" }],
+ subject: "Did you miss me?",
+ });
+ await checkHeaders({
+ to: ["Holmes and Watson <Tenants221B>"],
+ subject: "Did you miss me?",
+ });
+
+ // Reply to a message.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginReply(messages[0].id);
+ await checkHeaders({
+ to: [messages[0].author.replace(/"/g, "")],
+ subject: `Re: ${messages[0].subject}`,
+ });
+
+ // Forward a message.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginForward(
+ messages[1].id,
+ "forwardAsAttachment",
+ {
+ to: ["Mycroft Holmes <mycroft@bakerstreet.invalid>"],
+ }
+ );
+ await checkHeaders({
+ to: ["Mycroft Holmes <mycroft@bakerstreet.invalid>"],
+ subject: `Fwd: ${messages[1].subject}`,
+ });
+
+ // Forward a message inline. This uses a different code path.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginForward(messages[2].id, "forwardInline", {
+ to: ["Mycroft Holmes <mycroft@bakerstreet.invalid>"],
+ });
+ await checkHeaders({
+ to: ["Mycroft Holmes <mycroft@bakerstreet.invalid>"],
+ subject: `Fwd: ${messages[2].subject}`,
+ });
+
+ await browser.addressBooks.delete(addressBook);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "addressBooks", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkHeaders", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_identity.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_identity.js
new file mode 100644
index 0000000000..34ee180582
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_identity.js
@@ -0,0 +1,102 @@
+/* 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 { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+let defaultIdentity = addIdentity(account);
+defaultIdentity.composeHtml = true;
+let nonDefaultIdentity = addIdentity(account);
+nonDefaultIdentity.composeHtml = false;
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+add_task(async function testIdentity() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ browser.test.assertEq(
+ 2,
+ popAccount.identities.length,
+ "number of identities"
+ );
+ let [defaultIdentity, nonDefaultIdentity] = popAccount.identities;
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ browser.test.log(defaultIdentity.id);
+ browser.test.log(nonDefaultIdentity.id);
+
+ let funcs = [
+ { name: "beginNew", args: [] },
+ { name: "beginReply", args: [messages[0].id] },
+ { name: "beginForward", args: [messages[1].id, "forwardAsAttachment"] },
+ // Uses a different code path.
+ { name: "beginForward", args: [messages[2].id, "forwardInline"] },
+ { name: "beginNew", args: [messages[3].id] },
+ ];
+ let tests = [
+ { args: [], isDefault: true },
+ {
+ args: [{ identityId: defaultIdentity.id }],
+ isDefault: true,
+ },
+ {
+ args: [{ identityId: nonDefaultIdentity.id }],
+ isDefault: false,
+ },
+ ];
+ for (let func of funcs) {
+ browser.test.log(func.name);
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test.args));
+ let tab = await browser.compose[func.name](
+ ...func.args.concat(test.args)
+ );
+ browser.test.assertEq("object", typeof tab);
+ browser.test.assertEq("number", typeof tab.id);
+ await window.sendMessage("checkIdentity", test.isDefault);
+ }
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkIdentity", async isDefault => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ await new Promise(resolve => composeWindows[0].setTimeout(resolve));
+
+ is(
+ composeWindows[0].getCurrentIdentityKey(),
+ isDefault ? defaultIdentity.key : nonDefaultIdentity.key
+ );
+ composeWindows[0].close();
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_new.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_new.js
new file mode 100644
index 0000000000..298da47578
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_new.js
@@ -0,0 +1,136 @@
+/* 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 { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_setup(() => {
+ let account = createAccount("pop3");
+ createAccount("local");
+ MailServices.accounts.defaultAccount = account;
+
+ addIdentity(account);
+
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test", null);
+ let folder = rootFolder.getChildNamed("test");
+ createMessages(folder, 4);
+});
+
+/* Test if getComposeDetails() is waiting until the entire init procedure of
+ * the composeWindow has finished, before returning values. */
+add_task(async function testComposerIsReady() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let details = {
+ plainTextBody: "This is Text",
+ to: ["Mr. Holmes <holmes@bakerstreet.invalid>"],
+ subject: "Test Email",
+ };
+
+ let tests = [
+ {
+ description: "Begin new.",
+ funcName: "beginNew",
+ arguments: [details],
+ },
+ {
+ description: "Edit as new.",
+ funcName: "beginNew",
+ arguments: [messages[0].id, details],
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+ let expectedDetails = test.arguments[test.arguments.length - 1];
+
+ // Test with windows.onCreated
+ {
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdWindow] = await createdWindowPromise;
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let actualDetails = await browser.compose.getComposeDetails(tab.id);
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After windows.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ // Test the windows API being able to return the messageCompose window as
+ // the current one.
+ await window.waitForCondition(async () => {
+ let win = await browser.windows.get(createdWindow.id);
+ return win.focused;
+ }, `Window should have received focus.`);
+
+ let composeWindow = await browser.windows.get(tab.windowId);
+ browser.test.assertEq(composeWindow.type, "messageCompose");
+ let curWindow = await browser.windows.getCurrent();
+ browser.test.assertEq(tab.windowId, curWindow.id);
+ // Test the tabs API being able to return the correct current tab.
+ let [currentTab] = await browser.tabs.query({
+ currentWindow: true,
+ active: true,
+ });
+ browser.test.assertEq(tab.id, currentTab.id);
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ // Test with tabs.onCreated
+ {
+ let createdTabPromise = window.waitForEvent("tabs.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdTab] = await createdTabPromise;
+ let actualDetails = await browser.compose.getComposeDetails(
+ createdTab.id
+ );
+
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After tabs.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let createdWindow = await browser.windows.get(createdTab.windowId);
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_reply.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_reply.js
new file mode 100644
index 0000000000..979901d2a5
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_begin_reply.js
@@ -0,0 +1,146 @@
+/* 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 { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+add_setup(() => {
+ let account = createAccount("pop3");
+ createAccount("local");
+ MailServices.accounts.defaultAccount = account;
+
+ addIdentity(account);
+
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test", null);
+ let folder = rootFolder.getChildNamed("test");
+ createMessages(folder, 4);
+});
+
+/* Test if getComposeDetails() is waiting until the entire init procedure of
+ * the composeWindow has finished, before returning values. */
+add_task(async function testComposerIsReady() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let details = {
+ plainTextBody: "This is Text",
+ to: ["Mr. Holmes <holmes@bakerstreet.invalid>"],
+ subject: "Test Email",
+ };
+
+ let tests = [
+ {
+ description: "Reply default.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, details],
+ },
+ {
+ description: "Reply as replyToSender.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToSender", details],
+ },
+ {
+ description: "Reply as replyToList.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToList", details],
+ },
+ {
+ description: "Reply as replyToAll.",
+ funcName: "beginReply",
+ arguments: [messages[0].id, "replyToAll", details],
+ },
+ ];
+
+ for (let test of tests) {
+ browser.test.log(JSON.stringify(test));
+ let expectedDetails = test.arguments[test.arguments.length - 1];
+
+ // Test with windows.onCreated
+ {
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdWindow] = await createdWindowPromise;
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let actualDetails = await browser.compose.getComposeDetails(tab.id);
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After windows.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ // Test the windows API being able to return the messageCompose window as
+ // the current one.
+ await window.waitForCondition(async () => {
+ let win = await browser.windows.get(createdWindow.id);
+ return win.focused;
+ }, `Window should have received focus.`);
+
+ let composeWindow = await browser.windows.get(tab.windowId);
+ browser.test.assertEq(composeWindow.type, "messageCompose");
+ let curWindow = await browser.windows.getCurrent();
+ browser.test.assertEq(tab.windowId, curWindow.id);
+ // Test the tabs API being able to return the correct current tab.
+ let [currentTab] = await browser.tabs.query({
+ currentWindow: true,
+ active: true,
+ });
+ browser.test.assertEq(tab.id, currentTab.id);
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+
+ // Test with tabs.onCreated
+ {
+ let createdTabPromise = window.waitForEvent("tabs.onCreated");
+ // Explicitly do not await this call.
+ browser.compose[test.funcName](...test.arguments);
+ let [createdTab] = await createdTabPromise;
+ let actualDetails = await browser.compose.getComposeDetails(
+ createdTab.id
+ );
+
+ for (let detail of Object.keys(expectedDetails)) {
+ browser.test.assertEq(
+ expectedDetails[detail].toString(),
+ actualDetails[detail].toString(),
+ `After tabs.OnCreated: Detail ${detail} is correct for ${test.description}`
+ );
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let createdWindow = await browser.windows.get(createdTab.windowId);
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ }
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1692439.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1692439.js
new file mode 100644
index 0000000000..17d6a968ed
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1692439.js
@@ -0,0 +1,160 @@
+/* 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 { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+// Import the smtp server scripts
+var {
+ nsMailServer,
+ gThreadManager,
+ fsDebugNone,
+ fsDebugAll,
+ fsDebugRecv,
+ fsDebugRecvSend,
+} = ChromeUtils.import("resource://testing-common/mailnews/Maild.jsm");
+var { SmtpDaemon, SMTP_RFC2821_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Smtpd.jsm"
+);
+var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Auth.jsm"
+);
+
+// Setup the daemon and server
+function setupServerDaemon(handler) {
+ if (!handler) {
+ handler = function (d) {
+ return new SMTP_RFC2821_handler(d);
+ };
+ }
+ var server = new nsMailServer(handler, new SmtpDaemon());
+ return server;
+}
+
+function getBasicSmtpServer(port = 1, hostname = "localhost") {
+ let server = localAccountUtils.create_outgoing_server(
+ port,
+ "user",
+ "password",
+ hostname
+ );
+
+ // Override the default greeting so we get something predictable
+ // in the ELHO message
+ Services.prefs.setCharPref("mail.smtpserver.default.hello_argument", "test");
+
+ return server;
+}
+
+function getSmtpIdentity(senderName, smtpServer) {
+ // Set up the identity.
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = senderName;
+ identity.smtpServerKey = smtpServer.key;
+
+ return identity;
+}
+
+var gServer;
+var gOutbox;
+
+add_setup(() => {
+ gServer = setupServerDaemon();
+ gServer.start();
+
+ // Test needs a non-local default account to be able to send messages.
+ let popAccount = createAccount("pop3");
+ let localAccount = createAccount("local");
+ MailServices.accounts.defaultAccount = popAccount;
+
+ let identity = getSmtpIdentity(
+ "identity@foo.invalid",
+ getBasicSmtpServer(gServer.port)
+ );
+ popAccount.addIdentity(identity);
+ popAccount.defaultIdentity = identity;
+
+ // Test is using the Sent folder and Outbox folder of the local account.
+ let rootFolder = localAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("Sent", null);
+ MailServices.accounts.setSpecialFolders();
+ gOutbox = rootFolder.getChildNamed("Outbox");
+
+ registerCleanupFunction(() => {
+ gServer.stop();
+ });
+});
+
+add_task(async function testIsReflexive() {
+ let files = {
+ "background.js": async () => {
+ function trimContent(content) {
+ let data = content.replaceAll("\r\n", "\n").split("\n");
+ while (data[data.length - 1] == "") {
+ data.pop();
+ }
+ return data.join("\n");
+ }
+
+ // Create a plain text message.
+ let createdTextWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ plainTextBody: "This is some PLAIN text.",
+ isPlainText: true,
+ to: "rcpt@invalid.foo",
+ subject: "Test message",
+ });
+ let [createdTextWindow] = await createdTextWindowPromise;
+ let [createdTextTab] = await browser.tabs.query({
+ windowId: createdTextWindow.id,
+ });
+
+ // Call getComposeDetails() to trigger the actual bug.
+ let details = await browser.compose.getComposeDetails(createdTextTab.id);
+ browser.test.assertEq("This is some PLAIN text.", details.plainTextBody);
+
+ // Send the message.
+ let removedTextWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.compose.sendMessage(createdTextTab.id);
+ await removedTextWindowPromise;
+
+ // Find the message in the send folder.
+ let accounts = await browser.accounts.list();
+ let account = accounts.find(a => a.folders.find(f => f.type == "sent"));
+ let { messages } = await browser.messages.list(
+ account.folders.find(f => f.type == "sent")
+ );
+
+ // Read the message.
+ browser.test.assertEq(
+ "Test message",
+ messages[0].subject,
+ "Should find the sent message"
+ );
+ let message = await browser.messages.getFull(messages[0].id);
+ let content = trimContent(message.parts[0].body);
+
+ // Test that the first line is not an empty line.
+ browser.test.assertEq(
+ "This is some PLAIN text.",
+ content,
+ "The content should not start with an empty line"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose", "compose.send", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1804796.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1804796.js
new file mode 100644
index 0000000000..394a7906c4
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_bug1804796.js
@@ -0,0 +1,80 @@
+/* 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 { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+let account = createAccount("pop3");
+createAccount("local");
+MailServices.accounts.defaultAccount = account;
+
+addIdentity(account);
+
+let rootFolder = account.incomingServer.rootFolder;
+rootFolder.createSubfolder("test", null);
+let folder = rootFolder.getChildNamed("test");
+createMessages(folder, 4);
+
+add_task(async function test_update_plaintext_before_send() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let popAccount = accounts.find(a => a.type == "pop3");
+ let folder = popAccount.folders.find(f => f.name == "test");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ // Setup onBeforeSend listener.
+
+ let listener = async tab => {
+ let details1 = await browser.compose.getComposeDetails(tab.id);
+ details1.plainTextBody =
+ "Pre Text\n\n" + details1.plainTextBody + "\n\nPost Text";
+ await browser.compose.setComposeDetails(tab.id, details1);
+ await new Promise(resolve => window.setTimeout(resolve));
+
+ let details2 = await browser.compose.getComposeDetails(tab.id);
+ browser.test.assertEq(
+ details1.plainTextBody,
+ details2.plainTextBody,
+ "PlainTextBody should be correct after updated in onBeforeSend"
+ );
+
+ return {};
+ };
+ browser.compose.onBeforeSend.addListener(listener);
+
+ // Reply to a message.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ let tab = await browser.compose.beginReply(messages[0].id, {
+ isPlainText: true,
+ });
+ await createdWindowPromise;
+
+ // Send message and trigger onBeforeSend event.
+
+ await new Promise(resolve => window.setTimeout(resolve));
+ let closedWindowPromise = window.waitForEvent("windows.onRemoved");
+ await browser.compose.sendMessage(tab.id, { mode: "sendLater" });
+ await closedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead", "compose.send"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_details.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_details.js
new file mode 100644
index 0000000000..3eb16102c5
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_details.js
@@ -0,0 +1,725 @@
+/* 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/. */
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+let nonDefaultIdentity = addIdentity(account);
+defaultIdentity.attachVCard = false;
+nonDefaultIdentity.attachVCard = true;
+
+let gRootFolder = account.incomingServer.rootFolder;
+
+gRootFolder.createSubfolder("test", null);
+let gTestFolder = gRootFolder.getChildNamed("test");
+createMessages(gTestFolder, 4);
+
+// TODO: Figure out why naming this folder drafts is problematic.
+gRootFolder.createSubfolder("something", null);
+let gDraftsFolder = gRootFolder.getChildNamed("something");
+gDraftsFolder.flags = Ci.nsMsgFolderFlags.Drafts;
+createMessages(gDraftsFolder, 2);
+let gDrafts = [...gDraftsFolder.messages];
+
+// Verifies ComposeDetails of a given composer can be applied to a different
+// composer, even if they have different compose formats. The composer should pick
+// the matching body/plaintextBody value, if both are specified. The value for
+// isPlainText is ignored by setComposeDetails.
+add_task(async function testIsReflexive() {
+ let files = {
+ "background.js": async () => {
+ // Start a new TEXT message.
+ let createdTextWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ plainTextBody: "This is some PLAIN text.",
+ isPlainText: true,
+ });
+ let [createdTextWindow] = await createdTextWindowPromise;
+ let [createdTextTab] = await browser.tabs.query({
+ windowId: createdTextWindow.id,
+ });
+
+ // Get details, TEXT message.
+ let textDetails = await browser.compose.getComposeDetails(
+ createdTextTab.id
+ );
+ browser.test.assertTrue(textDetails.isPlainText);
+ browser.test.assertTrue(
+ textDetails.body.includes("This is some PLAIN text")
+ );
+ browser.test.assertEq(
+ "This is some PLAIN text.",
+ textDetails.plainTextBody
+ );
+
+ // Start a new HTML message.
+ let createdHtmlWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ body: "<p>This is some <i>HTML</i> text.</p>",
+ isPlainText: false,
+ });
+ let [createdHtmlWindow] = await createdHtmlWindowPromise;
+ let [createdHtmlTab] = await browser.tabs.query({
+ windowId: createdHtmlWindow.id,
+ });
+
+ // Get details, HTML message.
+ let htmlDetails = await browser.compose.getComposeDetails(
+ createdHtmlTab.id
+ );
+ browser.test.assertFalse(htmlDetails.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails.body.includes("<p>This is some <i>HTML</i> text.</p>")
+ );
+ browser.test.assertEq(
+ "This is some /HTML/ text.",
+ htmlDetails.plainTextBody
+ );
+
+ // Set HTML details on HTML composer. It should not throw.
+ await browser.compose.setComposeDetails(createdHtmlTab.id, htmlDetails);
+
+ // Set TEXT details on TEXT composer. It should not throw.
+ await browser.compose.setComposeDetails(createdTextTab.id, textDetails);
+
+ // Set TEXT details on HTML composer and verify the changed content.
+ await browser.compose.setComposeDetails(createdHtmlTab.id, textDetails);
+ let htmlDetails2 = await browser.compose.getComposeDetails(
+ createdHtmlTab.id
+ );
+ browser.test.assertFalse(htmlDetails2.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails2.body.includes("This is some PLAIN text")
+ );
+ browser.test.assertEq(
+ "This is some PLAIN text.",
+ htmlDetails2.plainTextBody
+ );
+
+ // Set HTML details on TEXT composer and verify the changed content.
+ await browser.compose.setComposeDetails(createdTextTab.id, htmlDetails);
+ let textDetails2 = await browser.compose.getComposeDetails(
+ createdTextTab.id
+ );
+ browser.test.assertTrue(textDetails2.isPlainText);
+ browser.test.assertTrue(
+ textDetails2.body.includes("This is some /HTML/ text.")
+ );
+ browser.test.assertEq(
+ "This is some /HTML/ text.",
+ textDetails2.plainTextBody
+ );
+
+ // Clean up.
+
+ let removedHtmlWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdHtmlWindow.id);
+ await removedHtmlWindowPromise;
+
+ let removedTextWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdTextWindow.id);
+ await removedTextWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function testType() {
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length, "number of accounts");
+
+ let testFolder = accounts[0].folders.find(f => f.name == "test");
+ let messages = (await browser.messages.list(testFolder)).messages;
+ browser.test.assertEq(4, messages.length, "number of messages");
+
+ let draftFolder = accounts[0].folders.find(f => f.name == "something");
+ let drafts = (await browser.messages.list(draftFolder)).messages;
+ browser.test.assertEq(2, drafts.length, "number of drafts");
+
+ async function checkComposer(tab, expected) {
+ browser.test.assertEq("object", typeof tab, "type of tab");
+ browser.test.assertEq("number", typeof tab.id, "type of tab ID");
+ browser.test.assertEq(
+ "number",
+ typeof tab.windowId,
+ "type of window ID"
+ );
+
+ let details = await browser.compose.getComposeDetails(tab.id);
+ browser.test.assertEq(expected.type, details.type, "type of composer");
+ browser.test.assertEq(
+ expected.relatedMessageId,
+ details.relatedMessageId,
+ `related message id (${details.type})`
+ );
+ await browser.windows.remove(tab.windowId);
+ }
+
+ let tests = [
+ {
+ funcName: "beginNew",
+ args: [],
+ expected: { type: "new", relatedMessageId: null },
+ },
+ {
+ funcName: "beginReply",
+ args: [messages[0].id],
+ expected: { type: "reply", relatedMessageId: messages[0].id },
+ },
+ {
+ funcName: "beginReply",
+ args: [messages[1].id, "replyToAll"],
+ expected: { type: "reply", relatedMessageId: messages[1].id },
+ },
+ {
+ funcName: "beginReply",
+ args: [messages[2].id, "replyToList"],
+ expected: { type: "reply", relatedMessageId: messages[2].id },
+ },
+ {
+ funcName: "beginReply",
+ args: [messages[3].id, "replyToSender"],
+ expected: { type: "reply", relatedMessageId: messages[3].id },
+ },
+ {
+ funcName: "beginForward",
+ args: [messages[0].id],
+ expected: { type: "forward", relatedMessageId: messages[0].id },
+ },
+ {
+ funcName: "beginForward",
+ args: [messages[1].id, "forwardAsAttachment"],
+ expected: { type: "forward", relatedMessageId: messages[1].id },
+ },
+ // Uses a different code path.
+ {
+ funcName: "beginForward",
+ args: [messages[2].id, "forwardInline"],
+ expected: { type: "forward", relatedMessageId: messages[2].id },
+ },
+ {
+ funcName: "beginNew",
+ args: [messages[3].id],
+ expected: { type: "new", relatedMessageId: messages[3].id },
+ },
+ ];
+ for (let test of tests) {
+ browser.test.log(test.funcName);
+ let tab = await browser.compose[test.funcName](...test.args);
+ await checkComposer(tab, test.expected);
+ }
+
+ browser.tabs.onCreated.addListener(async tab => {
+ // Bug 1702957, if composeWindow.GetComposeDetails() is not delayed
+ // until the compose window is ready, it will overwrite the compose
+ // fields.
+ let details = await browser.compose.getComposeDetails(tab.id);
+ browser.test.assertEq(
+ "Johnny Jones <johnny@jones.invalid>",
+ details.to.pop(),
+ "Check Recipients in draft after calling getComposeDetails()"
+ );
+
+ let window = await browser.windows.get(tab.windowId);
+ if (window.type == "messageCompose") {
+ await checkComposer(tab, {
+ type: "draft",
+ relatedMessageId: drafts[0].id,
+ });
+ browser.test.notifyPass("Finish");
+ }
+ });
+ browser.test.sendMessage("openDrafts");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+
+ // The first part of the test is done in the background script using the
+ // compose API to open compose windows. For the second part we need to open
+ // a draft, which is not possible with the compose API.
+ await extension.awaitMessage("openDrafts");
+ window.ComposeMessage(
+ Ci.nsIMsgCompType.Draft,
+ Ci.nsIMsgCompFormat.Default,
+ gDraftsFolder,
+ [gDraftsFolder.generateMessageURI(gDrafts[0].messageKey)]
+ );
+
+ await extension.awaitFinish("Finish");
+ await extension.unload();
+});
+
+add_task(async function testFcc() {
+ let files = {
+ "background.js": async () => {
+ async function checkWindow(createdTab, expected) {
+ let state = await browser.compose.getComposeDetails(createdTab.id);
+
+ browser.test.assertEq(
+ expected.overrideDefaultFcc,
+ state.overrideDefaultFcc,
+ "overrideDefaultFcc should be correct"
+ );
+
+ if (expected.overrideDefaultFccFolder) {
+ window.assertDeepEqual(
+ state.overrideDefaultFccFolder,
+ expected.overrideDefaultFccFolder,
+ "overrideDefaultFccFolder should be correct"
+ );
+ } else {
+ browser.test.assertEq(
+ expected.overrideDefaultFccFolder,
+ state.overrideDefaultFccFolder,
+ "overrideDefaultFccFolder should be correct"
+ );
+ }
+
+ if (expected.additionalFccFolder) {
+ window.assertDeepEqual(
+ state.additionalFccFolder,
+ expected.additionalFccFolder,
+ "additionalFccFolder should be correct"
+ );
+ } else {
+ browser.test.assertEq(
+ expected.additionalFccFolder,
+ state.additionalFccFolder,
+ "additionalFccFolder should be correct"
+ );
+ }
+
+ await window.sendMessage("checkWindow", expected);
+ }
+
+ let [account] = await browser.accounts.list();
+ let folder1 = account.folders.find(f => f.name == "Trash");
+ let folder2 = account.folders.find(f => f.name == "something");
+
+ // Start a new message.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: false,
+ overrideDefaultFccFolder: null,
+ additionalFccFolder: "",
+ });
+
+ await browser.test.assertRejects(
+ browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFcc: true,
+ }),
+ "Setting overrideDefaultFcc to true requires setting overrideDefaultFccFolder as well",
+ "browser.compose.setComposeDetails() should reject setting overrideDefaultFcc to true."
+ );
+
+ // Set folders.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFccFolder: folder1,
+ additionalFccFolder: folder2,
+ });
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: true,
+ overrideDefaultFccFolder: folder1,
+ additionalFccFolder: folder2,
+ });
+
+ // Setting overrideDefaultFcc true while it is already true should not change any values.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFcc: true,
+ });
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: true,
+ overrideDefaultFccFolder: folder1,
+ additionalFccFolder: folder2,
+ });
+
+ // A no-op should not change any values.
+ await browser.compose.setComposeDetails(createdTab.id, {});
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: true,
+ overrideDefaultFccFolder: folder1,
+ additionalFccFolder: folder2,
+ });
+
+ // Disable fcc.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFccFolder: "",
+ });
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: true,
+ overrideDefaultFccFolder: "",
+ additionalFccFolder: folder2,
+ });
+
+ // Disable additional fcc.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ additionalFccFolder: "",
+ });
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: true,
+ overrideDefaultFccFolder: "",
+ additionalFccFolder: "",
+ });
+
+ // Clear override.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFcc: false,
+ });
+ await checkWindow(createdTab, {
+ overrideDefaultFcc: false,
+ overrideDefaultFccFolder: null,
+ additionalFccFolder: "",
+ });
+
+ await browser.test.assertRejects(
+ browser.compose.setComposeDetails(createdTab.id, {
+ overrideDefaultFccFolder: {
+ path: "/bad",
+ accountId: folder1.accountId,
+ },
+ }),
+ `Invalid MailFolder: {accountId:${folder1.accountId}, path:/bad}`,
+ "browser.compose.setComposeDetails() should reject, if an invalid folder is set as overrideDefaultFccFolder."
+ );
+
+ await browser.test.assertRejects(
+ browser.compose.setComposeDetails(createdTab.id, {
+ additionalFccFolder: { path: "/bad", accountId: folder1.accountId },
+ }),
+ `Invalid MailFolder: {accountId:${folder1.accountId}, path:/bad}`,
+ "browser.compose.setComposeDetails() should reject, if an invalid folder is set as additionalFccFolder."
+ );
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function testSimpleDetails() {
+ let files = {
+ "background.js": async () => {
+ async function checkWindow(createdTab, expected) {
+ let state = await browser.compose.getComposeDetails(createdTab.id);
+
+ if (expected.priority) {
+ browser.test.assertEq(
+ expected.priority,
+ state.priority,
+ "priority should be correct"
+ );
+ }
+
+ if (expected.hasOwnProperty("returnReceipt")) {
+ browser.test.assertEq(
+ expected.returnReceipt,
+ state.returnReceipt,
+ "returnReceipt should be correct"
+ );
+ }
+
+ if (expected.hasOwnProperty("deliveryStatusNotification")) {
+ browser.test.assertEq(
+ expected.deliveryStatusNotification,
+ state.deliveryStatusNotification,
+ "deliveryStatusNotification should be correct"
+ );
+ }
+
+ if (expected.hasOwnProperty("attachVCard")) {
+ browser.test.assertEq(
+ expected.attachVCard,
+ state.attachVCard,
+ "attachVCard should be correct"
+ );
+ }
+
+ if (expected.deliveryFormat) {
+ browser.test.assertEq(
+ expected.deliveryFormat,
+ state.deliveryFormat,
+ "deliveryFormat should be correct"
+ );
+ }
+
+ await window.sendMessage("checkWindow", expected);
+ }
+
+ // Start a new message.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length, "number of accounts");
+ let localAccount = accounts.find(a => a.type == "none");
+ browser.test.assertEq(
+ 2,
+ localAccount.identities.length,
+ "number of identities"
+ );
+ let [defaultIdentity, nonDefaultIdentity] = localAccount.identities;
+
+ let expected = {
+ priority: "normal",
+ returnReceipt: false,
+ deliveryStatusNotification: false,
+ deliveryFormat: "auto",
+ attachVCard: false,
+ identityId: defaultIdentity.id,
+ };
+
+ async function changeDetail(key, value, _expected = {}) {
+ await browser.compose.setComposeDetails(createdTab.id, {
+ [key]: value,
+ });
+ expected[key] = value;
+ for (let [k, v] of Object.entries(_expected)) {
+ expected[k] = v;
+ }
+ await checkWindow(createdTab, expected);
+ }
+
+ // Confirm initial condition.
+ await checkWindow(createdTab, expected);
+
+ // Changing the identity without having made any changes, should load the
+ // defaults of the second identity.
+ await changeDetail("identityId", nonDefaultIdentity.id, {
+ attachVCard: true,
+ });
+
+ // Switching back should restore the defaults of the first identity.
+ await changeDetail("identityId", defaultIdentity.id, {
+ attachVCard: false,
+ });
+
+ await changeDetail("priority", "highest");
+ await changeDetail("deliveryFormat", "html");
+ await changeDetail("returnReceipt", true);
+ await changeDetail("deliveryFormat", "plaintext");
+ await changeDetail("priority", "lowest");
+ await changeDetail("attachVCard", true);
+ await changeDetail("priority", "high");
+ await changeDetail("deliveryFormat", "both");
+ await changeDetail("deliveryStatusNotification", true);
+ await changeDetail("priority", "low");
+
+ await changeDetail("priority", "normal");
+ await changeDetail("deliveryFormat", "auto");
+ await changeDetail("attachVCard", false);
+ await changeDetail("returnReceipt", false);
+ await changeDetail("deliveryStatusNotification", false);
+
+ // Changing the identity should not load the defaults of the second identity,
+ // after the values had been changed.
+ await changeDetail("identityId", nonDefaultIdentity.id);
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function testAutoComplete() {
+ let files = {
+ "background.js": async () => {
+ async function checkWindow(createdTab, expected) {
+ let state = await browser.compose.getComposeDetails(createdTab.id);
+
+ for (let [id, value] of Object.entries(expected.pills)) {
+ browser.test.assertEq(
+ value,
+ state[id].length ? state[id][0] : "",
+ `value for ${id} should be correct`
+ );
+ }
+
+ await window.sendMessage("checkWindow", expected);
+ }
+
+ // Start a new message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ // Create a test contact.
+ let [addressBook] = await browser.addressBooks.list(true);
+ let contactId = await browser.contacts.create(addressBook.id, {
+ PrimaryEmail: "autocomplete@invalid",
+ DisplayName: "Autocomplete Test",
+ });
+
+ // Confirm the addrTo field has focus and addrTo and replyTo fields are empty.
+ await checkWindow(createdTab, {
+ activeElement: "toAddrInput",
+ pills: { to: "", replyTo: "" },
+ values: { toAddrInput: "", replyAddrInput: "" },
+ });
+
+ // Set the replyTo field, which should not break autocomplete for the currently active addrTo
+ // field.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ replyTo: "test@user.net",
+ });
+
+ // Confirm the addrTo field has focus and replyTo field is set.
+ await checkWindow(createdTab, {
+ activeElement: "toAddrInput",
+ pills: { to: "", replyTo: "test@user.net" },
+ values: { toAddrInput: "", replyAddrInput: "" },
+ });
+
+ // Manually type "Autocomplete" into the active field, which should be the toAddr field and it
+ // should autocomplete.
+ await window.sendMessage("typeIntoActiveAddrField", "Autocomplete");
+
+ // Confirm the addrTo field has focus and replyTo field is set and the addrTo field has been
+ // autocompleted.
+ await checkWindow(createdTab, {
+ activeElement: "toAddrInput",
+ pills: { to: "", replyTo: "test@user.net" },
+ values: {
+ toAddrInput: "Autocomplete Test <autocomplete@invalid>",
+ replyAddrInput: "",
+ },
+ });
+
+ // Clean up.
+ await browser.contacts.delete(contactId);
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose", "addressBooks"],
+ },
+ });
+
+ extension.onMessage("typeIntoActiveAddrField", async value => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+
+ for (const s of value) {
+ EventUtils.synthesizeKey(s, {}, composeWindows[0]);
+ await new Promise(r => composeWindows[0].setTimeout(r));
+ }
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ let composeDocument = composeWindows[0].document;
+ await new Promise(resolve => composeWindows[0].setTimeout(resolve));
+
+ Assert.equal(
+ composeDocument.activeElement.id,
+ expected.activeElement,
+ `Active element should be correct`
+ );
+
+ for (let [id, value] of Object.entries(expected.values)) {
+ await TestUtils.waitForCondition(
+ () => composeDocument.getElementById(id).value == value,
+ `Value of field ${id} should be correct`
+ );
+ }
+
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_details_body.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_details_body.js
new file mode 100644
index 0000000000..84b4b22019
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_details_body.js
@@ -0,0 +1,469 @@
+/* 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/. */
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+let nonDefaultIdentity = addIdentity(account);
+let gRootFolder = account.incomingServer.rootFolder;
+
+gRootFolder.createSubfolder("test", null);
+let gTestFolder = gRootFolder.getChildNamed("test");
+createMessages(gTestFolder, 4);
+
+add_task(async function testPlainTextBody() {
+ let files = {
+ "background.js": async () => {
+ async function checkWindow(expected) {
+ let state = await browser.compose.getComposeDetails(createdTab.id);
+ for (let field of ["isPlainText"]) {
+ if (field in expected) {
+ browser.test.assertEq(
+ expected[field],
+ state[field],
+ `Check value for ${field}`
+ );
+ }
+ }
+ for (let field of ["plainTextBody"]) {
+ if (field in expected) {
+ browser.test.assertEq(
+ JSON.stringify(expected[field]),
+ JSON.stringify(state[field]),
+ `Check value for ${field}`
+ );
+ }
+ }
+ }
+
+ // Start a new message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({ isPlainText: true });
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ await checkWindow({ isPlainText: true });
+
+ let tests = [
+ {
+ // Set plaintextBody with Windows style newlines. The return value of
+ // the API is independent of the used OS and only returns LF endings.
+ input: { isPlainText: true, plainTextBody: "123\r\n456\r\n789" },
+ expected: { isPlainText: true, plainTextBody: "123\n456\n789" },
+ },
+ {
+ // Set plaintextBody with Linux style newlines. The return value of
+ // the API is independent of the used OS and only returns LF endings.
+ input: { isPlainText: true, plainTextBody: "ABC\nDEF\nGHI" },
+ expected: { isPlainText: true, plainTextBody: "ABC\nDEF\nGHI" },
+ },
+ {
+ // Bug 1792551 without newline at the end.
+ input: { isPlainText: true, plainTextBody: "123456 \n Hello " },
+ expected: { isPlainText: true, plainTextBody: "123456 \n Hello " },
+ },
+ {
+ // Bug 1792551 without newline at the end.
+ input: { isPlainText: true, plainTextBody: "123456 &nbsp; \n " },
+ expected: { isPlainText: true, plainTextBody: "123456 &nbsp; \n " },
+ },
+ {
+ // Bug 1792551 with a newline at the end.
+ input: { isPlainText: true, plainTextBody: "123456 \n Hello \n" },
+ expected: { isPlainText: true, plainTextBody: "123456 \n Hello \n" },
+ },
+ ];
+ 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);
+ }
+
+ browser.test.log("Replace plainTextBody with empty string");
+ await browser.compose.setComposeDetails(createdTab.id, {
+ isPlainText: true,
+ plainTextBody: "Lorem ipsum",
+ });
+ await checkWindow({ isPlainText: true, plainTextBody: "Lorem ipsum" });
+ await browser.compose.setComposeDetails(createdTab.id, {
+ isPlainText: true,
+ plainTextBody: "",
+ });
+ await checkWindow({ isPlainText: true, plainTextBody: "" });
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function testBody() {
+ // Open an compose window with HTML body.
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ params.composeFields.body = "<p>This is some <i>HTML</i> text.</p>";
+
+ let htmlWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let htmlWindow = await htmlWindowPromise;
+ await BrowserTestUtils.waitForEvent(htmlWindow, "load");
+
+ // Open another compose window with plain text body.
+
+ params = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance(
+ Ci.nsIMsgComposeParams
+ );
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ params.format = Ci.nsIMsgCompFormat.PlainText;
+ params.composeFields.body = "This is some plain text.";
+
+ let plainTextComposeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let plainTextWindow = await plainTextComposeWindowPromise;
+ await BrowserTestUtils.waitForEvent(plainTextWindow, "load");
+
+ // Run the extension.
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let windows = await browser.windows.getAll({
+ populate: true,
+ windowTypes: ["messageCompose"],
+ });
+ let [htmlTabId, plainTextTabId] = windows.map(w => w.tabs[0].id);
+
+ let plainTextBodyTag =
+ '<body style="font-family: -moz-fixed; white-space: pre-wrap; width: 72ch;">';
+
+ // Get details, HTML message.
+
+ let htmlDetails = await browser.compose.getComposeDetails(htmlTabId);
+ browser.test.log(JSON.stringify(htmlDetails));
+ browser.test.assertTrue(!htmlDetails.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails.body.includes("<p>This is some <i>HTML</i> text.</p>")
+ );
+ browser.test.assertEq(
+ "This is some /HTML/ text.",
+ htmlDetails.plainTextBody
+ );
+
+ // Set details, HTML message.
+
+ await browser.compose.setComposeDetails(htmlTabId, {
+ body: htmlDetails.body.replace("<i>HTML</i>", "<code>HTML</code>"),
+ });
+ htmlDetails = await browser.compose.getComposeDetails(htmlTabId);
+ browser.test.log(JSON.stringify(htmlDetails));
+ browser.test.assertTrue(!htmlDetails.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails.body.includes("<p>This is some <code>HTML</code> text.</p>")
+ );
+ browser.test.assertTrue(
+ "This is some HTML text.",
+ htmlDetails.plainTextBody
+ );
+
+ // Get details, plain text message.
+
+ let plainTextDetails = await browser.compose.getComposeDetails(
+ plainTextTabId
+ );
+ browser.test.log(JSON.stringify(plainTextDetails));
+ browser.test.assertTrue(plainTextDetails.isPlainText);
+ browser.test.assertTrue(
+ plainTextDetails.body.includes(
+ plainTextBodyTag + "This is some plain text.</body>"
+ )
+ );
+ browser.test.assertEq(
+ "This is some plain text.",
+ plainTextDetails.plainTextBody
+ );
+
+ // Set details, plain text message.
+
+ await browser.compose.setComposeDetails(plainTextTabId, {
+ plainTextBody:
+ plainTextDetails.plainTextBody + "\nIndeed, it is plain.",
+ });
+ plainTextDetails = await browser.compose.getComposeDetails(
+ plainTextTabId
+ );
+ browser.test.log(JSON.stringify(plainTextDetails));
+ browser.test.assertTrue(plainTextDetails.isPlainText);
+ browser.test.assertTrue(
+ plainTextDetails.body.includes(
+ plainTextBodyTag +
+ "This is some plain text.<br>Indeed, it is plain.</body>"
+ )
+ );
+ browser.test.assertEq(
+ "This is some plain text.\nIndeed, it is plain.",
+ // Fold Windows line-endings \r\n to \n.
+ plainTextDetails.plainTextBody.replace(/\r/g, "")
+ );
+
+ // Some things that should fail.
+
+ try {
+ await browser.compose.setComposeDetails(plainTextTabId, {
+ body: "Providing conflicting format settings.",
+ isPlainText: true,
+ });
+ browser.test.fail(
+ "calling setComposeDetails with these arguments should throw"
+ );
+ } catch (ex) {
+ browser.test.succeed(`expected exception thrown: ${ex.message}`);
+ }
+ try {
+ await browser.compose.setComposeDetails(htmlTabId, {
+ plainTextBody: "Providing conflicting format settings.",
+ isPlainText: false,
+ });
+ browser.test.fail(
+ "calling setComposeDetails with these arguments should throw"
+ );
+ } catch (ex) {
+ browser.test.succeed(`expected exception thrown: ${ex.message}`);
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: {
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ // Check the HTML message was edited.
+
+ ok(htmlWindow.gMsgCompose.composeHTML);
+ let htmlDocument = htmlWindow.GetCurrentEditor().document;
+ info(htmlDocument.body.innerHTML);
+ is(htmlDocument.querySelectorAll("i").length, 0, "<i> was removed");
+ is(htmlDocument.querySelectorAll("code").length, 1, "<code> was added");
+
+ // Close the HTML message.
+
+ let closePromises = [
+ // If the window is not marked as dirty, this Promise will never resolve.
+ BrowserTestUtils.promiseAlertDialog("extra1"),
+ BrowserTestUtils.domWindowClosed(htmlWindow),
+ ];
+ Assert.ok(
+ htmlWindow.ComposeCanClose(),
+ "compose window should be allowed to close"
+ );
+ htmlWindow.close();
+ await Promise.all(closePromises);
+
+ // Check the plain text message was edited.
+
+ ok(!plainTextWindow.gMsgCompose.composeHTML);
+ let plainTextDocument = plainTextWindow.GetCurrentEditor().document;
+ info(plainTextDocument.body.innerHTML);
+ ok(/Indeed, it is plain\./.test(plainTextDocument.body.innerHTML));
+
+ // Close the plain text message.
+
+ closePromises = [
+ // If the window is not marked as dirty, this Promise will never resolve.
+ BrowserTestUtils.promiseAlertDialog("extra1"),
+ BrowserTestUtils.domWindowClosed(plainTextWindow),
+ ];
+ Assert.ok(
+ plainTextWindow.ComposeCanClose(),
+ "compose window should be allowed to close"
+ );
+ plainTextWindow.close();
+ await Promise.all(closePromises);
+});
+
+add_task(async function testCJK() {
+ let longCJKString = "안".repeat(400);
+
+ // Open an compose window with HTML body.
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ params.composeFields.body = longCJKString;
+
+ let htmlWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let htmlWindow = await htmlWindowPromise;
+ await BrowserTestUtils.waitForEvent(htmlWindow, "load");
+
+ // Open another compose window with plain text body.
+
+ params = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance(
+ Ci.nsIMsgComposeParams
+ );
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ params.format = Ci.nsIMsgCompFormat.PlainText;
+ params.composeFields.body = longCJKString;
+
+ let plainTextComposeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let plainTextWindow = await plainTextComposeWindowPromise;
+ await BrowserTestUtils.waitForEvent(plainTextWindow, "load");
+
+ // Run the extension.
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let longCJKString = "안".repeat(400);
+ let windows = await browser.windows.getAll({
+ populate: true,
+ windowTypes: ["messageCompose"],
+ });
+ let [htmlTabId, plainTextTabId] = windows.map(w => w.tabs[0].id);
+
+ let plainTextBodyTag =
+ '<body style="font-family: -moz-fixed; white-space: pre-wrap; width: 72ch;">';
+
+ // Get details, HTML message.
+
+ let htmlDetails = await browser.compose.getComposeDetails(htmlTabId);
+ browser.test.log(JSON.stringify(htmlDetails));
+ browser.test.assertTrue(!htmlDetails.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails.body.includes(longCJKString),
+ "getComposeDetails.body from html composer returned CJK correctly"
+ );
+ browser.test.assertEq(
+ longCJKString,
+ htmlDetails.plainTextBody,
+ "getComposeDetails.plainTextBody from html composer returned CJK correctly"
+ );
+
+ // Set details, HTML message.
+
+ await browser.compose.setComposeDetails(htmlTabId, {
+ body: longCJKString,
+ });
+ htmlDetails = await browser.compose.getComposeDetails(htmlTabId);
+ browser.test.log(JSON.stringify(htmlDetails));
+ browser.test.assertTrue(!htmlDetails.isPlainText);
+ browser.test.assertTrue(
+ htmlDetails.body.includes(longCJKString),
+ "getComposeDetails.body from html composer returned CJK correctly as set by setComposeDetails"
+ );
+ browser.test.assertTrue(
+ longCJKString,
+ htmlDetails.plainTextBody,
+ "getComposeDetails.plainTextBody from html composer returned CJK correctly as set by setComposeDetails"
+ );
+
+ // Get details, plain text message.
+
+ let plainTextDetails = await browser.compose.getComposeDetails(
+ plainTextTabId
+ );
+ browser.test.log(JSON.stringify(plainTextDetails));
+ browser.test.assertTrue(plainTextDetails.isPlainText);
+ browser.test.assertTrue(
+ plainTextDetails.body.includes(plainTextBodyTag + longCJKString),
+ "getComposeDetails.body from text composer returned CJK correctly"
+ );
+ browser.test.assertEq(
+ longCJKString,
+ plainTextDetails.plainTextBody,
+ "getComposeDetails.plainTextBody from text composer returned CJK correctly"
+ );
+
+ // Set details, plain text message.
+
+ await browser.compose.setComposeDetails(plainTextTabId, {
+ plainTextBody: longCJKString,
+ });
+ plainTextDetails = await browser.compose.getComposeDetails(
+ plainTextTabId
+ );
+ browser.test.log(JSON.stringify(plainTextDetails));
+ browser.test.assertTrue(plainTextDetails.isPlainText);
+ browser.test.assertTrue(
+ plainTextDetails.body.includes(plainTextBodyTag + longCJKString),
+ "getComposeDetails.body from text composer returned CJK correctly as set by setComposeDetails"
+ );
+ browser.test.assertEq(
+ longCJKString,
+ // Fold Windows line-endings \r\n to \n.
+ plainTextDetails.plainTextBody.replace(/\r/g, ""),
+ "getComposeDetails.plainTextBody from text composer returned CJK correctly as set by setComposeDetails"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: {
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ // Close the HTML message.
+
+ let closePromises = [
+ // If the window is not marked as dirty, this Promise will never resolve.
+ BrowserTestUtils.promiseAlertDialog("extra1"),
+ BrowserTestUtils.domWindowClosed(htmlWindow),
+ ];
+ Assert.ok(
+ htmlWindow.ComposeCanClose(),
+ "compose window should be allowed to close"
+ );
+ htmlWindow.close();
+ await Promise.all(closePromises);
+
+ // Close the plain text message.
+
+ closePromises = [
+ // If the window is not marked as dirty, this Promise will never resolve.
+ BrowserTestUtils.promiseAlertDialog("extra1"),
+ BrowserTestUtils.domWindowClosed(plainTextWindow),
+ ];
+ Assert.ok(
+ plainTextWindow.ComposeCanClose(),
+ "compose window should be allowed to close"
+ );
+ plainTextWindow.close();
+ await Promise.all(closePromises);
+}).__skipMe = AppConstants.platform == "linux" && AppConstants.DEBUG; // Permanent failure on CI, bug 1766758.
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_details_headers.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_details_headers.js
new file mode 100644
index 0000000000..c5a60f307a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_details_headers.js
@@ -0,0 +1,727 @@
+/* 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/. */
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+let nonDefaultIdentity = addIdentity(account);
+let gRootFolder = account.incomingServer.rootFolder;
+
+gRootFolder.createSubfolder("test", null);
+let gTestFolder = gRootFolder.getChildNamed("test");
+createMessages(gTestFolder, 4);
+
+add_task(async function testHeaders() {
+ let files = {
+ "background.js": async () => {
+ 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.from) {
+ // From will always return a value, only check if explicitly requested.
+ browser.test.assertEq(expected.from, state.from, "from is correct");
+ }
+
+ if (expected.subject) {
+ browser.test.assertEq(
+ expected.subject,
+ state.subject,
+ "subject is correct"
+ );
+ } else {
+ browser.test.assertTrue(!state.subject, "subject is empty");
+ }
+
+ await window.sendMessage("checkWindow", expected);
+ }
+
+ let [account] = await browser.accounts.list();
+ let [defaultIdentity, nonDefaultIdentity] = account.identities;
+
+ 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",
+ }),
+ empty: await browser.contacts.create(addressBook, {
+ DisplayName: "Jim Moriarty",
+ PrimaryEmail: "",
+ }),
+ };
+ 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);
+
+ let identityChanged = null;
+ browser.compose.onIdentityChanged.addListener((tab, identityId) => {
+ identityChanged = identityId;
+ });
+
+ // Start a new message.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ await checkWindow({ identityId: defaultIdentity.id });
+
+ let tests = [
+ {
+ // Change the identity and check default from.
+ input: { identityId: nonDefaultIdentity.id },
+ expected: {
+ identityId: nonDefaultIdentity.id,
+ from: "mochitest@localhost",
+ },
+ expectIdentityChanged: nonDefaultIdentity.id,
+ },
+ {
+ // Don't change the identity.
+ input: {},
+ expected: {
+ identityId: nonDefaultIdentity.id,
+ from: "mochitest@localhost",
+ },
+ },
+ {
+ // Change the identity back again.
+ input: { identityId: defaultIdentity.id },
+ expected: {
+ identityId: defaultIdentity.id,
+ from: "mochitest@localhost",
+ },
+ expectIdentityChanged: defaultIdentity.id,
+ },
+ {
+ // 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>"] },
+ },
+ {
+ // Name with a comma, not quoted per RFC 822. This is how
+ // getComposeDetails returns names with a comma.
+ input: { to: ["Holmes, Mycroft <mycroft@bakerstreet.invalid>"] },
+ expected: { to: ["Holmes, Mycroft <mycroft@bakerstreet.invalid>"] },
+ },
+ {
+ // Name with a comma, quoted per RFC 822. This should work too.
+ input: { to: [`"Holmes, Mycroft" <mycroft@bakerstreet.invalid>`] },
+ expected: { to: ["Holmes, Mycroft <mycroft@bakerstreet.invalid>"] },
+ },
+ {
+ // Name and address with non-ASCII characters.
+ input: { to: ["Jïm Morïarty <morïarty@bakerstreet.invalid>"] },
+ expected: { to: ["Jïm Morïarty <morïarty@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: {},
+ },
+ {
+ // Override from with string address
+ input: { from: "Mycroft Holmes <mycroft@bakerstreet.invalid>" },
+ expected: { from: "Mycroft Holmes <mycroft@bakerstreet.invalid>" },
+ },
+ {
+ // Override from with contact id
+ input: { from: { id: contacts.sherlock, type: "contact" } },
+ expected: { from: "Sherlock Holmes <sherlock@bakerstreet.invalid>" },
+ },
+ {
+ // Override from with multiple string address
+ input: {
+ from: "Mycroft Holmes <mycroft@bakerstreet.invalid>, Mary Watson <mary@bakerstreet.invalid>",
+ },
+ expected: {
+ errorDescription:
+ "Setting from to multiple addresses should throw.",
+ errorRejected:
+ "ComposeDetails.from: Exactly one address instead of 2 is required.",
+ },
+ },
+ {
+ // Override from with empty string address 1
+ input: { from: "Mycroft Holmes <>" },
+ expected: {
+ errorDescription:
+ "Setting from to a display name without address should throw (#1).",
+ errorRejected: "ComposeDetails.from: Invalid address: ",
+ },
+ },
+ {
+ // Override from with empty string address 2
+ input: { from: "Mycroft Holmes" },
+ expected: {
+ errorDescription:
+ "Setting from to a display name without address should throw (#2).",
+ errorRejected:
+ "ComposeDetails.from: Invalid address: Mycroft Holmes",
+ },
+ },
+ {
+ // Override from with contact id with empty address
+ input: { from: { id: contacts.empty, type: "contact" } },
+ expected: {
+ errorDescription:
+ "Setting from to a contact with an empty PrimaryEmail should throw.",
+ errorRejected: `ComposeDetails.from: Contact does not have a valid email address: ${contacts.empty}`,
+ },
+ },
+ {
+ // Override from with invalid contact id
+ input: { from: { id: "1234", type: "contact" } },
+ expected: {
+ errorDescription:
+ "Setting from to a contact with an invalid contact id should throw.",
+ errorRejected:
+ "ComposeDetails.from: contact with id=1234 could not be found.",
+ },
+ },
+ {
+ // Override from with mailinglist id
+ input: { from: { id: list, type: "mailingList" } },
+ expected: {
+ errorDescription: "Setting from to a mailing list should throw.",
+ errorRejected: "ComposeDetails.from: Mailing list not allowed.",
+ },
+ },
+ {
+ // From may not be cleared.
+ input: { from: "" },
+ expected: {
+ errorDescription: "Setting from to an empty string should throw.",
+ errorRejected:
+ "ComposeDetails.from: Address must not be set to an empty string.",
+ },
+ },
+ ];
+ for (let test of tests) {
+ browser.test.log(`Checking input: ${JSON.stringify(test.input)}`);
+
+ if (test.expected.errorRejected) {
+ await browser.test.assertRejects(
+ browser.compose.setComposeDetails(createdTab.id, test.input),
+ test.expected.errorRejected,
+ test.expected.errorDescription
+ );
+ continue;
+ }
+
+ await browser.compose.setComposeDetails(createdTab.id, test.input);
+ await checkWindow(test.expected);
+
+ if (test.expectIdentityChanged) {
+ browser.test.assertEq(
+ test.expectIdentityChanged,
+ identityChanged,
+ "onIdentityChanged fired"
+ );
+ } else {
+ browser.test.assertEq(
+ null,
+ identityChanged,
+ "onIdentityChanged not fired"
+ );
+ }
+ identityChanged = null;
+ }
+
+ // Change the identity through the UI to check onIdentityChanged works.
+
+ browser.test.log("Checking external identity change");
+ await window.sendMessage("changeIdentity", nonDefaultIdentity.id);
+ browser.test.assertEq(
+ nonDefaultIdentity.id,
+ identityChanged,
+ "onIdentityChanged fired"
+ );
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ await browser.addressBooks.delete(addressBook);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "addressBooks", "compose", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("changeIdentity", newIdentity => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ let composeDocument = composeWindows[0].document;
+
+ let identityList = composeDocument.getElementById("msgIdentity");
+ let identityItem = identityList.querySelector(
+ `[identitykey="${newIdentity}"]`
+ );
+ ok(identityItem);
+ identityList.selectedItem = identityItem;
+ composeWindows[0].LoadIdentity(false);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_onIdentityChanged_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, the eventCounter is reset and
+ // allows to observe the order of events fired. In case of a wake-up, the
+ // first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ browser.compose.onIdentityChanged.addListener(async (tab, identityId) => {
+ browser.test.sendMessage("identity changed", {
+ eventCount: ++eventCounter,
+ identityId,
+ });
+ });
+
+ browser.compose.onComposeStateChanged.addListener(async (tab, state) => {
+ browser.test.sendMessage("compose state changed", {
+ eventCount: ++eventCounter,
+ state,
+ });
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "addressBooks", "compose", "messagesRead"],
+ browser_specific_settings: { gecko: { id: "compose@mochi.test" } },
+ },
+ });
+
+ function changeIdentity(newIdentity) {
+ let composeDocument = composeWindow.document;
+
+ let identityList = composeDocument.getElementById("msgIdentity");
+ let identityItem = identityList.querySelector(
+ `[identitykey="${newIdentity}"]`
+ );
+ ok(identityItem);
+ identityList.selectedItem = identityItem;
+ composeWindow.LoadIdentity(false);
+ }
+
+ function setToAddr(to) {
+ composeWindow.SetComposeDetails({ to });
+ }
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "compose.onIdentityChanged",
+ "compose.onComposeStateChanged",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger events without terminating the background first.
+
+ changeIdentity(nonDefaultIdentity.key);
+ {
+ let rv = await extension.awaitMessage("identity changed");
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ identityId: nonDefaultIdentity.key,
+ },
+ rv,
+ "The non-primed onIdentityChanged event should return the correct values"
+ );
+ }
+
+ setToAddr("user@invalid.net");
+ {
+ let rv = await extension.awaitMessage("compose state changed");
+ Assert.deepEqual(
+ {
+ eventCount: 2,
+ state: {
+ canSendNow: true,
+ canSendLater: true,
+ },
+ },
+ rv,
+ "The non-primed onComposeStateChanged should return the correct values"
+ );
+ }
+
+ // Terminate background and re-trigger onIdentityChanged event.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ changeIdentity(defaultIdentity.key);
+ {
+ let rv = await extension.awaitMessage("identity changed");
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ identityId: defaultIdentity.key,
+ },
+ rv,
+ "The primed onIdentityChanged event should return the correct values"
+ );
+ }
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listeners should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ // Terminate background and re-trigger onComposeStateChanged event.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ setToAddr("invalid");
+ {
+ let rv = await extension.awaitMessage("compose state changed");
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ state: {
+ canSendNow: false,
+ canSendLater: false,
+ },
+ },
+ rv,
+ "The primed onComposeStateChanged should return the correct values"
+ );
+ }
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listeners should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
+
+add_task(async function testCustomHeaders() {
+ let files = {
+ "background.js": async () => {
+ async function checkCustomHeaders(tab, expectedCustomHeaders) {
+ let [testHeader] = await window.sendMessage("getTestHeader");
+ browser.test.assertEq(
+ "CannotTouchThis",
+ testHeader,
+ "Should include the test header."
+ );
+
+ let details = await browser.compose.getComposeDetails(tab.id);
+
+ browser.test.assertEq(
+ expectedCustomHeaders.length,
+ details.customHeaders.length,
+ "Should have the correct number of custom headers"
+ );
+ for (let i = 0; i < expectedCustomHeaders.length; i++) {
+ browser.test.assertEq(
+ expectedCustomHeaders[i].name,
+ details.customHeaders[i].name,
+ "Should have the correct header name"
+ );
+ browser.test.assertEq(
+ expectedCustomHeaders[i].value,
+ details.customHeaders[i].value,
+ "Should have the correct header value"
+ );
+ }
+ }
+
+ // Start a new message with custom headers.
+ let customHeaders = [{ name: "X-TEST1", value: "some header" }];
+ let tab = await browser.compose.beginNew(null, { customHeaders });
+
+ // Add a header which does not start with X- and should not be touched by
+ // the API.
+ await window.sendMessage("addTestHeader");
+
+ let expectedHeaders = [{ name: "X-Test1", value: "some header" }];
+ await checkCustomHeaders(tab, expectedHeaders);
+
+ // Update details without changing headers.
+ await browser.compose.setComposeDetails(tab.id, {});
+ await checkCustomHeaders(tab, expectedHeaders);
+
+ // Update existing header and add a new one.
+ customHeaders = [
+ { name: "X-TEST1", value: "this is header #1" },
+ { name: "X-TEST2", value: "this is header #2" },
+ { name: "X-TEST3", value: "this is header #3" },
+ { name: "X-TEST4", value: "this is header #4" },
+ ];
+ await browser.compose.setComposeDetails(tab.id, { customHeaders });
+ expectedHeaders = [
+ { name: "X-Test1", value: "this is header #1" },
+ { name: "X-Test2", value: "this is header #2" },
+ { name: "X-Test3", value: "this is header #3" },
+ { name: "X-Test4", value: "this is header #4" },
+ ];
+ await checkCustomHeaders(tab, expectedHeaders);
+
+ // Update existing header and remove some of the others. Test support for
+ // empty headers.
+ customHeaders = [
+ { name: "X-TEST2", value: "this is a header" },
+ { name: "X-TEST3", value: "" },
+ ];
+ await browser.compose.setComposeDetails(tab.id, { customHeaders });
+ expectedHeaders = [
+ { name: "X-Test2", value: "this is a header" },
+ { name: "X-Test3", value: "" },
+ ];
+ await checkCustomHeaders(tab, expectedHeaders);
+
+ // Clear headers.
+ customHeaders = [];
+ await browser.compose.setComposeDetails(tab.id, { customHeaders });
+ await checkCustomHeaders(tab, []);
+
+ // Should throw for invalid custom headers.
+ customHeaders = [
+ { name: "TEST2", value: "this is an invalid custom header" },
+ ];
+ await browser.test.assertThrows(
+ () => browser.compose.setComposeDetails(tab.id, { customHeaders }),
+ 'Type error for parameter details (Error processing customHeaders.0.name: String "TEST2" must match /^X-.*$/) for compose.setComposeDetails.',
+ "Should throw for invalid custom headers"
+ );
+
+ // Clean up.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(tab.windowId);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "addressBooks", "compose", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("addTestHeader", () => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ composeWindow.gMsgCompose.compFields.setHeader(
+ "ATestHeader",
+ "CannotTouchThis"
+ );
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getTestHeader", () => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let value = composeWindow.gMsgCompose.compFields.getHeader("ATestHeader");
+ extension.sendMessage(value);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_dictionaries.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_dictionaries.js
new file mode 100644
index 0000000000..e77e5f47bf
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_dictionaries.js
@@ -0,0 +1,214 @@
+/* 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/. */
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+
+add_task(async function test_dictionaries() {
+ let files = {
+ "background.js": async () => {
+ function verifyDictionaries(dictionaries, expected) {
+ browser.test.assertEq(
+ Object.values(expected).length,
+ Object.values(dictionaries).length,
+ "Should find the correct number of installed dictionaries"
+ );
+ browser.test.assertEq(
+ Object.values(expected).filter(active => active).length,
+ Object.values(dictionaries).filter(active => active).length,
+ "Should find the correct number of active dictionaries"
+ );
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(
+ Object.keys(expected)[i],
+ Object.keys(dictionaries)[i],
+ "Should find the correct dictionary"
+ );
+ }
+ }
+ async function setDictionaries(newActiveDictionaries, expected) {
+ let changes = new Promise(resolve => {
+ let listener = (tab, dictionaries) => {
+ browser.compose.onActiveDictionariesChanged.removeListener(
+ listener
+ );
+ resolve({ tab, dictionaries });
+ };
+ browser.compose.onActiveDictionariesChanged.addListener(listener);
+ });
+
+ await browser.compose.setActiveDictionaries(
+ createdTab.id,
+ newActiveDictionaries
+ );
+ let eventData = await changes;
+ verifyDictionaries(expected.dictionaries, eventData.dictionaries);
+
+ browser.test.assertEq(
+ expected.tab.id,
+ eventData.tab.id,
+ "Should find the correct tab"
+ );
+
+ let dictionaries = await browser.compose.getActiveDictionaries(
+ createdTab.id
+ );
+ verifyDictionaries(expected.dictionaries, dictionaries);
+ }
+
+ // Start a new message.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ let [createdTab] = await browser.tabs.query({
+ windowId: createdWindow.id,
+ });
+
+ await browser.test.assertRejects(
+ browser.compose.setActiveDictionaries(createdTab.id, ["invalid"]),
+ `Dictionary not found: invalid`,
+ "should reject for invalid dictionaries"
+ );
+
+ await setDictionaries([], {
+ dictionaries: { "en-US": false },
+ tab: createdTab,
+ });
+ await setDictionaries(["en-US"], {
+ dictionaries: { "en-US": true },
+ tab: createdTab,
+ });
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_onActiveDictionariesChanged_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.compose.onActiveDictionariesChanged.addListener(
+ async (tab, dictionaries) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(
+ "onActiveDictionariesChanged received",
+ dictionaries
+ );
+ }
+ }
+ );
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ browser_specific_settings: {
+ gecko: { id: "compose.dictionary@xpcshell.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["compose.onActiveDictionariesChanged"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ async function setActiveDictionaries(activeDictionaries) {
+ let installedDictionaries = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine)
+ .getDictionaryList();
+
+ for (let dict of activeDictionaries) {
+ if (!installedDictionaries.includes(dict)) {
+ throw new Error(`Dictionary not found: ${dict}`);
+ }
+ }
+
+ await composeWindow.ComposeChangeLanguage(activeDictionaries);
+ }
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger onActiveDictionariesChanged without terminating the background first.
+
+ setActiveDictionaries(["en-US"]);
+ let newActiveDictionary1 = await extension.awaitMessage(
+ "onActiveDictionariesChanged received"
+ );
+ Assert.equal(
+ newActiveDictionary1["en-US"],
+ true,
+ "Returned active dictionary should be correct"
+ );
+
+ // Terminate background and re-trigger onActiveDictionariesChanged.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ setActiveDictionaries([]);
+ let newActiveDictionary2 = await extension.awaitMessage(
+ "onActiveDictionariesChanged received"
+ );
+ Assert.equal(
+ newActiveDictionary2["en-US"],
+ false,
+ "Returned active dictionary should be correct"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js
new file mode 100644
index 0000000000..285c4df33f
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_onBeforeSend.js
@@ -0,0 +1,1010 @@
+/* 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"
+);
+
+let account = createAccount();
+let defaultIdentity = addIdentity(account);
+let nonDefaultIdentity = addIdentity(account, "nondefault@invalid");
+
+// A local outbox is needed so we can use "send later".
+let localAccount = createAccount("local");
+let outbox = localAccount.incomingServer.rootFolder.getChildNamed("outbox");
+
+function messagesInOutbox(count) {
+ info(`Checking for ${count} messages in outbox`);
+
+ count -= [...outbox.messages].length;
+ if (count <= 0) {
+ return Promise.resolve();
+ }
+
+ info(`Waiting for ${count} messages in outbox`);
+ return new Promise(resolve => {
+ MailServices.mfn.addListener(
+ {
+ msgAdded(msgHdr) {
+ if (--count == 0) {
+ MailServices.mfn.removeListener(this);
+ resolve();
+ }
+ },
+ },
+ MailServices.mfn.msgAdded
+ );
+ });
+}
+
+add_task(async function testCancel() {
+ let files = {
+ "background.js": async () => {
+ async function beginSend(sendExpected, lockExpected) {
+ await window.sendMessage("beginSend");
+ return checkIfSent(sendExpected, lockExpected);
+ }
+
+ function checkIfSent(sendExpected, lockExpected = null) {
+ return window.sendMessage("checkIfSent", sendExpected, lockExpected);
+ }
+
+ function checkWindow(expected) {
+ return window.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 = window.waitForEvent("windows.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" });
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ // Send the message. No listeners exist, so sending should continue.
+
+ await beginSend(true);
+
+ // Add a non-cancelling listener. Sending should continue.
+
+ let listener1 = tab => {
+ listener1.tab = tab;
+ return {};
+ };
+ browser.compose.onBeforeSend.addListener(listener1);
+ await beginSend(true);
+ browser.test.assertEq(tab.id, listener1.tab.id, "listener1 was fired");
+ browser.compose.onBeforeSend.removeListener(listener1);
+ delete listener1.tab;
+
+ // Add a cancelling listener. Sending should not continue.
+
+ let listener2 = tab => {
+ listener2.tab = tab;
+ return { cancel: true };
+ };
+ browser.compose.onBeforeSend.addListener(listener2);
+ await beginSend(false, false);
+ browser.test.assertEq(tab.id, listener2.tab.id, "listener2 was fired");
+ browser.compose.onBeforeSend.removeListener(listener2);
+ delete listener2.tab;
+ await beginSend(true); // Removing the listener worked.
+
+ // Add a listener returning a Promise. Resolve the Promise to unblock.
+ // Sending should continue.
+
+ let listener3 = tab => {
+ listener3.tab = tab;
+ return new Promise(resolve => {
+ listener3.resolve = resolve;
+ });
+ };
+ browser.compose.onBeforeSend.addListener(listener3);
+ await beginSend(false, true);
+ browser.test.assertEq(tab.id, listener3.tab.id, "listener3 was fired");
+ listener3.resolve({ cancel: false });
+ await checkIfSent(true);
+ browser.compose.onBeforeSend.removeListener(listener3);
+ delete listener3.tab;
+
+ // Add a listener returning a Promise. Resolve the Promise to cancel.
+ // Sending should not continue.
+
+ let listener4 = tab => {
+ listener4.tab = tab;
+ return new Promise(resolve => {
+ listener4.resolve = resolve;
+ });
+ };
+ browser.compose.onBeforeSend.addListener(listener4);
+ await beginSend(false, true);
+ browser.test.assertEq(tab.id, listener4.tab.id, "listener4 was fired");
+ listener4.resolve({ cancel: true });
+ await checkIfSent(false, false);
+ browser.compose.onBeforeSend.removeListener(listener4);
+ delete listener4.tab;
+ await beginSend(true); // Removing the listener worked.
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.assertTrue(
+ !listener1.tab,
+ "listener1 was not fired after removal"
+ );
+ browser.test.assertTrue(
+ !listener2.tab,
+ "listener2 was not fired after removal"
+ );
+ browser.test.assertTrue(
+ !listener3.tab,
+ "listener3 was not fired after removal"
+ );
+ browser.test.assertTrue(
+ !listener4.tab,
+ "listener4 was not fired after removal"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ // 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;
+ let windowListenerRemoved = false;
+ ExtensionSupport.registerWindowListener("mochitest", {
+ chromeURLs: [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ],
+ onLoadWindow(window) {
+ window.CompleteGenericSendMessage = function (msgType) {
+ didTryToSendMessage = true;
+ Services.obs.notifyObservers(
+ {
+ composeWindow: window,
+ },
+ "mail:composeSendProgressStop"
+ );
+ };
+ },
+ });
+ registerCleanupFunction(() => {
+ if (!windowListenerRemoved) {
+ ExtensionSupport.unregisterWindowListener("mochitest");
+ }
+ });
+
+ extension.onMessage("beginSend", async () => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+
+ composeWindows[0]
+ .GenericSendMessage(Ci.nsIMsgCompDeliverMode.Now)
+ .catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkIfSent", async (sendExpected, lockExpected) => {
+ // Wait a moment to see if send happens asynchronously.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ is(didTryToSendMessage, sendExpected, "did try to send a message");
+
+ if (lockExpected !== null) {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+ is(composeWindows[0].gWindowLocked, lockExpected, "window is locked");
+ }
+
+ didTryToSendMessage = false;
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ ExtensionSupport.unregisterWindowListener("mochitest");
+ windowListenerRemoved = true;
+});
+
+add_task(async function testChangeDetails() {
+ let files = {
+ "background.js": async () => {
+ function beginSend() {
+ return window.sendMessage("beginSend");
+ }
+
+ function checkWindow(expected) {
+ return window.sendMessage("checkWindow", expected);
+ }
+
+ let accounts = await browser.accounts.list();
+ // If this test is run alone, the order of accounts is different compared
+ // to running all tests. We need the account with the 2 added identities.
+ let account = accounts.find(a => a.identities.length == 2);
+ let [defaultIdentity, nonDefaultIdentity] = account.identities;
+
+ // Add a listener that changes the headers and body. Sending should
+ // continue and the headers should change. This is largely the same code
+ // as tested in browser_ext_compose_details.js, so just test that the
+ // changes happen.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: ["test@test.invalid"],
+ subject: "Test",
+ body: "Original body.",
+ });
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await checkWindow({
+ to: ["test@test.invalid"],
+ subject: "Test",
+ body: "Original body.",
+ });
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let listener5 = (tab, details) => {
+ listener5.tab = tab;
+ listener5.details = details;
+ return {
+ details: {
+ identityId: nonDefaultIdentity.id,
+ to: ["to@test5.invalid"],
+ cc: ["cc@test5.invalid"],
+ subject: "Changed by listener5",
+ body: "New body from listener5.",
+ },
+ };
+ };
+ browser.compose.onBeforeSend.addListener(listener5);
+ await beginSend();
+ browser.test.assertEq(tab.id, listener5.tab.id, "listener5 was fired");
+ browser.test.assertEq(defaultIdentity.id, listener5.details.identityId);
+ 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"
+ );
+ browser.compose.onBeforeSend.removeListener(listener5);
+ delete listener5.tab;
+
+ // Do the same thing, but this time with a Promise.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: ["test@test.invalid"],
+ subject: "Test",
+ body: "Original body.",
+ });
+ [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await checkWindow({
+ to: ["test@test.invalid"],
+ subject: "Test",
+ body: "Original body.",
+ });
+
+ [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let listener6 = (tab, details) => {
+ listener6.tab = tab;
+ listener6.details = details;
+ return new Promise(resolve => {
+ listener6.resolve = resolve;
+ });
+ };
+ browser.compose.onBeforeSend.addListener(listener6);
+ await beginSend();
+ browser.test.assertEq(tab.id, listener6.tab.id, "listener6 was fired");
+ browser.test.assertEq(defaultIdentity.id, listener6.details.identityId);
+ browser.test.assertEq(1, listener6.details.to.length);
+ browser.test.assertEq(
+ "test@test.invalid",
+ listener6.details.to[0],
+ "listener6 recipient correct"
+ );
+ browser.test.assertEq(
+ "Test",
+ listener6.details.subject,
+ "listener6 subject correct"
+ );
+ listener6.resolve({
+ details: {
+ identityId: nonDefaultIdentity.id,
+ to: ["to@test6.invalid"],
+ cc: ["cc@test6.invalid"],
+ subject: "Changed by listener6",
+ body: "New body from listener6.",
+ },
+ });
+ browser.compose.onBeforeSend.removeListener(listener6);
+ delete listener6.tab;
+
+ browser.test.assertTrue(
+ !listener5.tab,
+ "listener5 was not fired after removal"
+ );
+ browser.test.assertTrue(
+ !listener6.tab,
+ "listener6 was not fired after removal"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose"],
+ },
+ });
+
+ extension.onMessage("beginSend", async () => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+
+ composeWindows[0]
+ .GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later)
+ .catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let body = composeWindow
+ .GetCurrentEditor()
+ .outputToString("text/plain", Ci.nsIDocumentEncoder.OutputRaw);
+ is(body, expected.body);
+
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ await messagesInOutbox(2);
+
+ let outboxMessages = [...outbox.messages];
+ ok(outboxMessages.length > 0);
+ let sentMessage5 = outboxMessages.shift();
+ is(sentMessage5.author, "nondefault@invalid", "author was changed");
+ is(sentMessage5.subject, "Changed by listener5", "subject was changed");
+ is(sentMessage5.recipients, "to@test5.invalid", "to was changed");
+ is(sentMessage5.ccList, "cc@test5.invalid", "cc was changed");
+
+ await new Promise(resolve => {
+ window.MsgHdrToMimeMessage(sentMessage5, null, (msgHdr, mimeMessage) => {
+ is(
+ // Fold Windows line-endings \r\n to \n.
+ mimeMessage.parts[0].body.replace(/\r/g, ""),
+ "New body from listener5.\n"
+ );
+ resolve();
+ });
+ });
+
+ ok(outboxMessages.length > 0);
+ let sentMessage6 = outboxMessages.shift();
+ is(sentMessage6.author, "nondefault@invalid", "author was changed");
+ is(sentMessage6.subject, "Changed by listener6", "subject was changed");
+ is(sentMessage6.recipients, "to@test6.invalid", "to was changed");
+ is(sentMessage6.ccList, "cc@test6.invalid", "cc was changed");
+
+ await new Promise(resolve => {
+ window.MsgHdrToMimeMessage(sentMessage6, null, (msgHdr, mimeMessage) => {
+ is(
+ // Fold Windows line-endings \r\n to \n.
+ mimeMessage.parts[0].body.replace(/\r/g, ""),
+ "New body from listener6.\n"
+ );
+ resolve();
+ });
+ });
+
+ ok(outboxMessages.length == 0);
+
+ await new Promise(resolve => {
+ outbox.deleteMessages(
+ [sentMessage5, sentMessage6],
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+});
+
+add_task(async function testChangeAttachments() {
+ let files = {
+ "background.js": async () => {
+ // Add a listener that changes attachments. Sending should continue and
+ // the attachments should change.
+
+ let tab = await browser.compose.beginNew({
+ to: ["test@test.invalid"],
+ subject: "Test",
+ body: "Original body.",
+ attachments: [
+ { file: new File(["remove"], "remove.txt") },
+ { file: new File(["change"], "change.txt") },
+ ],
+ });
+
+ let listener12 = async (tab, details) => {
+ let attachments = await browser.compose.listAttachments(tab.id);
+ browser.test.assertEq("remove.txt", attachments[0].name);
+ browser.test.assertEq("change.txt", attachments[1].name);
+
+ await browser.compose.removeAttachment(tab.id, attachments[0].id);
+ await browser.compose.updateAttachment(tab.id, attachments[1].id, {
+ name: "changed.txt",
+ });
+ await browser.compose.addAttachment(tab.id, {
+ file: new File(["added"], "added.txt"),
+ });
+
+ attachments = await browser.compose.listAttachments(tab.id);
+ browser.test.assertEq("changed.txt", attachments[0].name);
+ browser.test.assertEq("added.txt", attachments[1].name);
+
+ listener12.tab = tab;
+ };
+ browser.compose.onBeforeSend.addListener(listener12);
+
+ await window.sendMessage("beginSend");
+ browser.test.assertEq(tab.id, listener12.tab.id, "listener12 completed");
+ browser.compose.onBeforeSend.removeListener(listener12);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "compose"],
+ },
+ });
+
+ extension.onMessage("beginSend", async () => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+
+ let sendPromise = BrowserTestUtils.waitForEvent(
+ composeWindows[0],
+ "aftersend"
+ );
+ composeWindows[0]
+ .GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later)
+ .catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+ await sendPromise;
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ await messagesInOutbox(1);
+
+ let outboxMessages = [...outbox.messages];
+ ok(outboxMessages.length > 0);
+ let sentMessage12 = outboxMessages.shift();
+
+ await new Promise(resolve => {
+ window.MsgHdrToMimeMessage(sentMessage12, null, (msgHdr, mimeMessage) => {
+ Assert.equal(mimeMessage.parts.length, 1);
+ Assert.equal(mimeMessage.parts[0].parts.length, 3);
+ Assert.equal(mimeMessage.parts[0].parts[1].name, "changed.txt");
+ Assert.equal(mimeMessage.parts[0].parts[2].name, "added.txt");
+ resolve();
+ });
+ });
+
+ ok(outboxMessages.length == 0);
+
+ await new Promise(resolve => {
+ outbox.deleteMessages(
+ [sentMessage12],
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+});
+
+add_task(async function testListExpansion() {
+ let files = {
+ "background.js": async () => {
+ function beginSend() {
+ return window.sendMessage("beginSend");
+ }
+
+ function checkWindow(expected) {
+ return window.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);
+
+ // Add a listener that changes the headers. Sending should continue and
+ // the headers should change. The mailing list should be expanded in both
+ // the To: and Bcc: headers.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: [{ id: list, type: "mailingList" }],
+ subject: "Test",
+ });
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await checkWindow({
+ to: ["Holmes and Watson <Tenants221B>"],
+ subject: "Test",
+ });
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let listener7 = (tab, details) => {
+ listener7.tab = tab;
+ listener7.details = details;
+ return {
+ details: {
+ bcc: details.to,
+ subject: "Changed by listener7",
+ },
+ };
+ };
+ browser.compose.onBeforeSend.addListener(listener7);
+ await beginSend();
+ browser.test.assertEq(tab.id, listener7.tab.id, "listener7 was fired");
+ browser.test.assertEq(1, listener7.details.to.length);
+ browser.test.assertEq(
+ "Holmes and Watson <Tenants221B>",
+ listener7.details.to[0],
+ "listener7 recipient correct"
+ );
+ browser.test.assertEq(
+ "Test",
+ listener7.details.subject,
+ "listener7 subject correct"
+ );
+ browser.compose.onBeforeSend.removeListener(listener7);
+
+ // Return nothing from the listener. The mailing list should be expanded.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew({
+ to: [{ id: list, type: "mailingList" }],
+ subject: "Test",
+ });
+ [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await checkWindow({
+ to: ["Holmes and Watson <Tenants221B>"],
+ subject: "Test",
+ });
+
+ [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ let listener8 = (tab, details) => {
+ listener8.tab = tab;
+ listener8.details = details;
+ };
+ browser.compose.onBeforeSend.addListener(listener8);
+ await beginSend();
+ browser.test.assertEq(tab.id, listener8.tab.id, "listener8 was fired");
+ browser.test.assertEq(1, listener8.details.to.length);
+ browser.test.assertEq(
+ "Holmes and Watson <Tenants221B>",
+ listener8.details.to[0],
+ "listener8 recipient correct"
+ );
+ browser.test.assertEq(
+ "Test",
+ listener8.details.subject,
+ "listener8 subject correct"
+ );
+ browser.compose.onBeforeSend.removeListener(listener8);
+
+ await browser.addressBooks.delete(addressBook);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks", "compose"],
+ },
+ });
+
+ extension.onMessage("beginSend", async () => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ is(composeWindows.length, 1);
+
+ composeWindows[0]
+ .GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later)
+ .catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ await messagesInOutbox(2);
+
+ let outboxMessages = [...outbox.messages];
+ ok(outboxMessages.length > 0);
+ let sentMessage7 = outboxMessages.shift();
+ is(sentMessage7.subject, "Changed by listener7", "subject was changed");
+ is(
+ sentMessage7.recipients,
+ "Sherlock Holmes <sherlock@bakerstreet.invalid>, John Watson <john@bakerstreet.invalid>",
+ "list in unchanged field was expanded"
+ );
+ is(
+ sentMessage7.bccList,
+ "Sherlock Holmes <sherlock@bakerstreet.invalid>, John Watson <john@bakerstreet.invalid>",
+ "list in changed field was expanded"
+ );
+
+ ok(outboxMessages.length > 0);
+ let sentMessage8 = outboxMessages.shift();
+ is(sentMessage8.subject, "Test", "subject was not changed");
+ is(
+ sentMessage8.recipients,
+ "Sherlock Holmes <sherlock@bakerstreet.invalid>, John Watson <john@bakerstreet.invalid>",
+ "list in unchanged field was expanded"
+ );
+
+ ok(outboxMessages.length == 0);
+
+ await new Promise(resolve => {
+ outbox.deleteMessages(
+ [sentMessage7, sentMessage8],
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+});
+
+add_task(async function testMultipleListeners() {
+ let extensionA = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let listener9 = (tab, details) => {
+ browser.test.log("listener9 was fired");
+ browser.test.sendMessage("listener9", details);
+ browser.compose.onBeforeSend.removeListener(listener9);
+ return {
+ details: {
+ to: ["recipient2@invalid"],
+ subject: "Changed by listener9",
+ },
+ };
+ };
+ browser.compose.onBeforeSend.addListener(listener9);
+
+ await browser.compose.beginNew({
+ to: "recipient1@invalid",
+ subject: "Initial subject",
+ });
+ browser.test.sendMessage("ready");
+ },
+ manifest: { permissions: ["compose"] },
+ });
+
+ let extensionB = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let listener10 = (tab, details) => {
+ browser.test.log("listener10 was fired");
+ browser.test.sendMessage("listener10", details);
+ browser.compose.onBeforeSend.removeListener(listener10);
+ return {
+ details: {
+ to: ["recipient3@invalid"],
+ subject: "Changed by listener10",
+ },
+ };
+ };
+ browser.compose.onBeforeSend.addListener(listener10);
+
+ let listener11 = (tab, details) => {
+ browser.test.log("listener11 was fired");
+ browser.test.sendMessage("listener11", details);
+ browser.compose.onBeforeSend.removeListener(listener11);
+ return {
+ details: {
+ to: ["recipient4@invalid"],
+ subject: "Changed by listener11",
+ },
+ };
+ };
+ browser.compose.onBeforeSend.addListener(listener11);
+ browser.test.sendMessage("ready");
+ },
+ manifest: { permissions: ["compose"] },
+ });
+
+ await extensionA.startup();
+ await extensionB.startup();
+
+ await extensionA.awaitMessage("ready");
+ await extensionB.awaitMessage("ready");
+
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ Assert.equal(composeWindows.length, 1);
+ Assert.equal(composeWindows[0].document.readyState, "complete");
+ composeWindows[0]
+ .GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later)
+ .catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+
+ let listener9Details = await extensionA.awaitMessage("listener9");
+ Assert.equal(listener9Details.to.length, 1);
+ Assert.equal(
+ listener9Details.to[0],
+ "recipient1@invalid",
+ "listener9 recipient correct"
+ );
+ Assert.equal(
+ listener9Details.subject,
+ "Initial subject",
+ "listener9 subject correct"
+ );
+
+ let listener10Details = await extensionB.awaitMessage("listener10");
+ Assert.equal(listener10Details.to.length, 1);
+ Assert.equal(
+ listener10Details.to[0],
+ "recipient2@invalid",
+ "listener10 recipient correct"
+ );
+ Assert.equal(
+ listener10Details.subject,
+ "Changed by listener9",
+ "listener10 subject correct"
+ );
+
+ let listener11Details = await extensionB.awaitMessage("listener11");
+ Assert.equal(listener11Details.to.length, 1);
+ Assert.equal(
+ listener11Details.to[0],
+ "recipient3@invalid",
+ "listener11 recipient correct"
+ );
+ Assert.equal(
+ listener11Details.subject,
+ "Changed by listener10",
+ "listener11 subject correct"
+ );
+
+ await extensionA.unload();
+ await extensionB.unload();
+
+ await messagesInOutbox(1);
+
+ let outboxMessages = [...outbox.messages];
+ Assert.ok(outboxMessages.length > 0);
+ let sentMessage = outboxMessages.shift();
+ Assert.equal(
+ sentMessage.subject,
+ "Changed by listener11",
+ "subject was changed"
+ );
+ Assert.equal(
+ sentMessage.recipients,
+ "recipient4@invalid",
+ "recipient was changed"
+ );
+
+ Assert.ok(outboxMessages.length == 0);
+
+ await new Promise(resolve => {
+ outbox.deleteMessages(
+ [sentMessage],
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+});
+
+add_task(async function test_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.compose.onBeforeSend.addListener((tab, details) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onBeforeSend received", details);
+ }
+
+ // Let us abort, so we do not have to re-open the compose window for
+ // multiple tests.
+ return {
+ cancel: true,
+ };
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ browser_specific_settings: {
+ gecko: { id: "compose.onBeforeSend@xpcshell.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["compose.onBeforeSend"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ function beginSend() {
+ composeWindow.GenericSendMessage(Ci.nsIMsgCompDeliverMode.Now).catch(() => {
+ // This test is ignoring errors thrown by GenericSendMessage, but looks
+ // at didTryToSendMessage of the mocked CompleteGenericSendMessage to
+ // check if onBeforeSend aborted the send process.
+ });
+ }
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger onBeforeSend without terminating the background first.
+
+ composeWindow.SetComposeDetails({ to: "first@invalid.net" });
+ beginSend();
+ let firstDetails = await extension.awaitMessage("onBeforeSend received");
+ Assert.equal(
+ "first@invalid.net",
+ firstDetails.to,
+ "Returned details should be correct"
+ );
+
+ // Terminate background and re-trigger onBeforeSend.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ composeWindow.SetComposeDetails({ to: "second@invalid.net" });
+ beginSend();
+ let secondDetails = await extension.awaitMessage("onBeforeSend received");
+ Assert.equal(
+ "second@invalid.net",
+ secondDetails.to,
+ "Returned details should be correct"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_saveDraft.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_saveDraft.js
new file mode 100644
index 0000000000..7e779e5798
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_saveDraft.js
@@ -0,0 +1,416 @@
+/* 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"
+);
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+// Import the smtp server scripts
+var {
+ nsMailServer,
+ gThreadManager,
+ fsDebugNone,
+ fsDebugAll,
+ fsDebugRecv,
+ fsDebugRecvSend,
+} = ChromeUtils.import("resource://testing-common/mailnews/Maild.jsm");
+var { SmtpDaemon, SMTP_RFC2821_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Smtpd.jsm"
+);
+var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Auth.jsm"
+);
+
+// Setup the daemon and server
+function setupServerDaemon(handler) {
+ if (!handler) {
+ handler = function (d) {
+ return new SMTP_RFC2821_handler(d);
+ };
+ }
+ var server = new nsMailServer(handler, new SmtpDaemon());
+ return server;
+}
+
+function getBasicSmtpServer(port = 1, hostname = "localhost") {
+ let server = localAccountUtils.create_outgoing_server(
+ port,
+ "user",
+ "password",
+ hostname
+ );
+
+ // Override the default greeting so we get something predictable
+ // in the ELHO message
+ Services.prefs.setCharPref("mail.smtpserver.default.hello_argument", "test");
+
+ return server;
+}
+
+function getSmtpIdentity(senderName, smtpServer) {
+ // Set up the identity.
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = senderName;
+ identity.smtpServerKey = smtpServer.key;
+
+ return identity;
+}
+
+var gServer;
+var gLocalRootFolder;
+let gPopAccount;
+let gLocalAccount;
+
+add_setup(() => {
+ gServer = setupServerDaemon();
+ gServer.start();
+
+ // Test needs a non-local default account to be able to send messages.
+ gPopAccount = createAccount("pop3");
+ gLocalAccount = createAccount("local");
+ MailServices.accounts.defaultAccount = gPopAccount;
+
+ let identity = getSmtpIdentity(
+ "identity@foo.invalid",
+ getBasicSmtpServer(gServer.port)
+ );
+ gPopAccount.addIdentity(identity);
+ gPopAccount.defaultIdentity = identity;
+
+ // Test is using the Sent folder and Outbox folder of the local account.
+ gLocalRootFolder = gLocalAccount.incomingServer.rootFolder;
+ gLocalRootFolder.createSubfolder("Sent", null);
+ gLocalRootFolder.createSubfolder("Drafts", null);
+ gLocalRootFolder.createSubfolder("Fcc", null);
+ MailServices.accounts.setSpecialFolders();
+
+ requestLongerTimeout(4);
+
+ registerCleanupFunction(() => {
+ gServer.stop();
+ });
+});
+
+// Helper function to test saving messages.
+async function runTest(config) {
+ let files = {
+ "background.js": async () => {
+ let [config] = await window.sendMessage("getConfig");
+
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let localAccount = accounts.find(a => a.type == "none");
+ let fccFolder = localAccount.folders.find(f => f.name == "Fcc");
+ browser.test.assertTrue(
+ !!fccFolder,
+ "should find the additional fcc folder"
+ );
+
+ // Prepare test data.
+ let allDetails = [];
+ for (let i = 0; i < 5; i++) {
+ allDetails.push({
+ to: [`test${i}@test.invalid`],
+ subject: `Test${i} save as ${config.expected.mode}`,
+ additionalFccFolder:
+ config.expected.fcc.length > 1 ? fccFolder : null,
+ });
+ }
+
+ // Open multiple compose windows.
+ for (let details of allDetails) {
+ details.tab = await browser.compose.beginNew(details);
+ }
+
+ // Add onAfterSave listener
+ let collectedEventsMap = new Map();
+ function onAfterSaveListener(tab, info) {
+ collectedEventsMap.set(tab.id, info);
+ }
+ browser.compose.onAfterSave.addListener(onAfterSaveListener);
+
+ // Initiate saving of all compose windows at the same time.
+ let allPromises = [];
+ for (let details of allDetails) {
+ allPromises.push(
+ browser.compose.saveMessage(details.tab.id, config.mode)
+ );
+ }
+
+ // Wait until all messages have been saved.
+ let allRv = await Promise.all(allPromises);
+
+ for (let i = 0; i < allDetails.length; i++) {
+ let rv = allRv[i];
+ let details = allDetails[i];
+ // Find the message with a matching headerMessageId.
+
+ browser.test.assertEq(
+ config.expected.mode,
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ config.expected.fcc.length,
+ rv.messages.length,
+ "Should find the correct number of saved messages for this save operation."
+ );
+
+ // Check expected FCC folders.
+ for (let i = 0; i < config.expected.fcc.length; i++) {
+ // Read the actual messages in the fcc folder.
+ let savedMessages = await window.sendMessage(
+ "getMessagesInFolder",
+ `${config.expected.fcc[i]}`
+ );
+ // Find the currently processed message.
+ let savedMessage = savedMessages.find(
+ m => m.messageId == rv.messages[i].headerMessageId
+ );
+ // Compare saved message to original message.
+ browser.test.assertEq(
+ details.subject,
+ savedMessage.subject,
+ "The subject of the message in the fcc folder should be correct."
+ );
+
+ // Check returned details.
+ browser.test.assertEq(
+ details.subject,
+ rv.messages[i].subject,
+ "The subject of the saved message should be correct."
+ );
+ browser.test.assertEq(
+ details.to[0],
+ rv.messages[i].recipients[0],
+ "The recipients of the saved message should be correct."
+ );
+ browser.test.assertEq(
+ `/${config.expected.fcc[i]}`,
+ rv.messages[i].folder.path,
+ "The saved message should be in the correct folder."
+ );
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.tabs.remove(details.tab.id);
+ await removedWindowPromise;
+ }
+
+ // Check onAfterSave listener
+ browser.compose.onAfterSave.removeListener(onAfterSaveListener);
+ browser.test.assertEq(
+ allDetails.length,
+ collectedEventsMap.size,
+ "Should have received the correct number of onAfterSave events"
+ );
+ let collectedEvents = [...collectedEventsMap.values()];
+ for (let detail of allDetails) {
+ let msg = collectedEvents.find(
+ e => e.messages[0].subject == detail.subject
+ );
+ browser.test.assertTrue(
+ msg,
+ "Should have received an onAfterSave event for every single message"
+ );
+ }
+ browser.test.assertEq(
+ collectedEventsMap.size,
+ collectedEvents.filter(e => e.mode == config.expected.mode).length,
+ "All events should have the correct mode."
+ );
+
+ // Remove all saved messages.
+ for (let fcc of config.expected.fcc) {
+ await window.sendMessage("clearMessagesInFolder", fcc);
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.save", "messagesRead", "accountsRead"],
+ },
+ });
+
+ extension.onMessage("getConfig", async () => {
+ extension.sendMessage(config);
+ });
+
+ extension.onMessage("getMessagesInFolder", async folderName => {
+ let folder = gLocalRootFolder.getChildNamed(folderName);
+ let messages = [...folder.messages].map(m => {
+ let { subject, messageId, recipients } = m;
+ return { subject, messageId, recipients };
+ });
+ extension.sendMessage(...messages);
+ });
+
+ extension.onMessage("clearMessagesInFolder", async folderName => {
+ let folder = gLocalRootFolder.getChildNamed(folderName);
+ let messages = [...folder.messages];
+ await new Promise(resolve => {
+ folder.deleteMessages(
+ messages,
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+
+ Assert.equal(0, [...folder.messages].length, "folder should be empty");
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ gServer.resetTest();
+}
+
+// Test with default save mode.
+add_task(async function test_default() {
+ await runTest({
+ mode: null,
+ expected: {
+ mode: "draft",
+ fcc: ["Drafts"],
+ },
+ });
+});
+
+// Test with default save mode and additional fcc.
+add_task(async function test_default_with_additional_fcc() {
+ await runTest({
+ mode: null,
+ expected: {
+ mode: "draft",
+ fcc: ["Drafts", "Fcc"],
+ },
+ });
+});
+
+// Test with draft save mode.
+add_task(async function test_saveAsDraft() {
+ await runTest({
+ mode: { mode: "draft" },
+ expected: {
+ mode: "draft",
+ fcc: ["Drafts"],
+ },
+ });
+});
+
+// Test with draft save mode and additional fcc.
+add_task(async function test_saveAsDraft_with_additional_fcc() {
+ await runTest({
+ mode: { mode: "draft" },
+ expected: {
+ mode: "draft",
+ fcc: ["Drafts", "Fcc"],
+ },
+ });
+});
+
+// Test onAfterSave when saving drafts for MV3
+add_task(async function test_onAfterSave_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.compose.onAfterSave.addListener((tab, saveInfo) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onAfterSave received", saveInfo);
+ }
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ browser_specific_settings: {
+ gecko: { id: "compose.onAfterSave@xpcshell.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["compose.onAfterSave"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ let composeWindow = await openComposeWindow(gPopAccount);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger onAfterSave without terminating the background first.
+
+ composeWindow.SetComposeDetails({ to: "first@invalid.net" });
+ composeWindow.SaveAsDraft();
+ let firstSaveInfo = await extension.awaitMessage("onAfterSave received");
+ Assert.equal(
+ "draft",
+ firstSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // Terminate background and re-trigger onAfterSave.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ composeWindow.SetComposeDetails({ to: "second@invalid.net" });
+ composeWindow.SaveAsDraft();
+ let secondSaveInfo = await extension.awaitMessage("onAfterSave received");
+ Assert.equal(
+ "draft",
+ secondSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_saveTemplate.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_saveTemplate.js
new file mode 100644
index 0000000000..d9ce180011
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_saveTemplate.js
@@ -0,0 +1,432 @@
+/* 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"
+);
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+// Import the smtp server scripts
+var {
+ nsMailServer,
+ gThreadManager,
+ fsDebugNone,
+ fsDebugAll,
+ fsDebugRecv,
+ fsDebugRecvSend,
+} = ChromeUtils.import("resource://testing-common/mailnews/Maild.jsm");
+var { SmtpDaemon, SMTP_RFC2821_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Smtpd.jsm"
+);
+var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Auth.jsm"
+);
+
+// Setup the daemon and server
+function setupServerDaemon(handler) {
+ if (!handler) {
+ handler = function (d) {
+ return new SMTP_RFC2821_handler(d);
+ };
+ }
+ var server = new nsMailServer(handler, new SmtpDaemon());
+ return server;
+}
+
+function getBasicSmtpServer(port = 1, hostname = "localhost") {
+ let server = localAccountUtils.create_outgoing_server(
+ port,
+ "user",
+ "password",
+ hostname
+ );
+
+ // Override the default greeting so we get something predictable
+ // in the ELHO message
+ Services.prefs.setCharPref("mail.smtpserver.default.hello_argument", "test");
+
+ return server;
+}
+
+function getSmtpIdentity(senderName, smtpServer) {
+ // Set up the identity.
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = senderName;
+ identity.smtpServerKey = smtpServer.key;
+
+ return identity;
+}
+
+var gServer;
+var gLocalRootFolder;
+let gPopAccount;
+let gLocalAccount;
+
+add_setup(() => {
+ gServer = setupServerDaemon();
+ gServer.start();
+
+ // Test needs a non-local default account to be able to send messages.
+ gPopAccount = createAccount("pop3");
+ gLocalAccount = createAccount("local");
+ MailServices.accounts.defaultAccount = gPopAccount;
+
+ let identity = getSmtpIdentity(
+ "identity@foo.invalid",
+ getBasicSmtpServer(gServer.port)
+ );
+ gPopAccount.addIdentity(identity);
+ gPopAccount.defaultIdentity = identity;
+
+ // Test is using the Sent folder and Outbox folder of the local account.
+ gLocalRootFolder = gLocalAccount.incomingServer.rootFolder;
+ gLocalRootFolder.createSubfolder("Sent", null);
+ gLocalRootFolder.createSubfolder("Templates", null);
+ gLocalRootFolder.createSubfolder("Fcc", null);
+ MailServices.accounts.setSpecialFolders();
+
+ registerCleanupFunction(() => {
+ gServer.stop();
+ });
+});
+
+add_task(async function test_no_permission() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["send@test.invalid"],
+ subject: "Test send",
+ };
+
+ // Open a compose window with a message.
+ let tab = await browser.compose.beginNew(details);
+
+ // Send now. It should fail due to the missing compose.send permission.
+ await browser.test.assertThrows(
+ () => browser.compose.saveMessage(tab.id),
+ /browser.compose.saveMessage is not a function/,
+ "browser.compose.saveMessage() should reject, if the permission compose.save is not granted."
+ );
+
+ // Clean up.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.tabs.remove(tab.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+// Helper function to test saving messages.
+async function runTest(config) {
+ let files = {
+ "background.js": async () => {
+ let [config] = await window.sendMessage("getConfig");
+
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length, "number of accounts");
+ let localAccount = accounts.find(a => a.type == "none");
+ let fccFolder = localAccount.folders.find(f => f.name == "Fcc");
+ browser.test.assertTrue(
+ !!fccFolder,
+ "should find the additional fcc folder"
+ );
+
+ // Prepare test data.
+ let allDetails = [];
+ for (let i = 0; i < 5; i++) {
+ allDetails.push({
+ to: [`test${i}@test.invalid`],
+ subject: `Test${i} save as ${config.expected.mode}`,
+ additionalFccFolder:
+ config.expected.fcc.length > 1 ? fccFolder : null,
+ });
+ }
+
+ // Open multiple compose windows.
+ for (let details of allDetails) {
+ details.tab = await browser.compose.beginNew(details);
+ }
+
+ // Add onAfterSave listener
+ let collectedEventsMap = new Map();
+ function onAfterSaveListener(tab, info) {
+ collectedEventsMap.set(tab.id, info);
+ }
+ browser.compose.onAfterSave.addListener(onAfterSaveListener);
+
+ // Initiate saving of all compose windows at the same time.
+ let allPromises = [];
+ for (let details of allDetails) {
+ allPromises.push(
+ browser.compose.saveMessage(details.tab.id, config.mode)
+ );
+ }
+
+ // Wait until all messages have been saved.
+ let allRv = await Promise.all(allPromises);
+
+ for (let i = 0; i < allDetails.length; i++) {
+ let rv = allRv[i];
+ let details = allDetails[i];
+ // Find the message with a matching headerMessageId.
+
+ browser.test.assertEq(
+ config.expected.mode,
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ config.expected.fcc.length,
+ rv.messages.length,
+ "Should find the correct number of saved messages for this save operation."
+ );
+
+ // Check expected FCC folders.
+ for (let i = 0; i < config.expected.fcc.length; i++) {
+ // Read the actual messages in the fcc folder.
+ let savedMessages = await window.sendMessage(
+ "getMessagesInFolder",
+ `${config.expected.fcc[i]}`
+ );
+ // Find the currently processed message.
+ let savedMessage = savedMessages.find(
+ m => m.messageId == rv.messages[i].headerMessageId
+ );
+ // Compare saved message to original message.
+ browser.test.assertEq(
+ details.subject,
+ savedMessage.subject,
+ "The subject of the message in the fcc folder should be correct."
+ );
+
+ // Check returned details.
+ browser.test.assertEq(
+ details.subject,
+ rv.messages[i].subject,
+ "The subject of the saved message should be correct."
+ );
+ browser.test.assertEq(
+ details.to[0],
+ rv.messages[i].recipients[0],
+ "The recipients of the saved message should be correct."
+ );
+ browser.test.assertEq(
+ `/${config.expected.fcc[i]}`,
+ rv.messages[i].folder.path,
+ "The saved message should be in the correct folder."
+ );
+ }
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.tabs.remove(details.tab.id);
+ await removedWindowPromise;
+ }
+
+ // Check onAfterSave listener
+ browser.compose.onAfterSave.removeListener(onAfterSaveListener);
+ browser.test.assertEq(
+ allDetails.length,
+ collectedEventsMap.size,
+ "Should have received the correct number of onAfterSave events"
+ );
+ let collectedEvents = [...collectedEventsMap.values()];
+ for (let detail of allDetails) {
+ let msg = collectedEvents.find(
+ e => e.messages[0].subject == detail.subject
+ );
+ browser.test.assertTrue(
+ msg,
+ "Should have received an onAfterSave event for every single message"
+ );
+ }
+ browser.test.assertEq(
+ collectedEventsMap.size,
+ collectedEvents.filter(e => e.mode == config.expected.mode).length,
+ "All events should have the correct mode."
+ );
+
+ // Remove all saved messages.
+ for (let fcc of config.expected.fcc) {
+ await window.sendMessage("clearMessagesInFolder", fcc);
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.save", "messagesRead", "accountsRead"],
+ },
+ });
+
+ extension.onMessage("getConfig", async () => {
+ extension.sendMessage(config);
+ });
+
+ extension.onMessage("getMessagesInFolder", async folderName => {
+ let folder = gLocalRootFolder.getChildNamed(folderName);
+ let messages = [...folder.messages].map(m => {
+ let { subject, messageId, recipients } = m;
+ return { subject, messageId, recipients };
+ });
+ extension.sendMessage(...messages);
+ });
+
+ extension.onMessage("clearMessagesInFolder", async folderName => {
+ let folder = gLocalRootFolder.getChildNamed(folderName);
+ let messages = [...folder.messages];
+ await new Promise(resolve => {
+ folder.deleteMessages(
+ messages,
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+
+ Assert.equal(0, [...folder.messages].length, "folder should be empty");
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ gServer.resetTest();
+}
+
+// Test with template save mode.
+add_task(async function test_saveAsTemplate() {
+ await runTest({
+ mode: { mode: "template" },
+ expected: {
+ mode: "template",
+ fcc: ["Templates"],
+ },
+ });
+});
+
+// Test with template save mode and additional fcc
+add_task(async function test_saveAsTemplate_with_additional_fcc() {
+ await runTest({
+ mode: { mode: "template" },
+ expected: {
+ mode: "template",
+ fcc: ["Templates", "Fcc"],
+ },
+ });
+});
+
+// Test onAfterSave when saving templates for MV3
+add_task(async function test_onAfterSave_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.compose.onAfterSave.addListener((tab, saveInfo) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onAfterSave received", saveInfo);
+ }
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ browser_specific_settings: {
+ gecko: { id: "compose.onAfterSave@xpcshell.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["compose.onAfterSave"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ let composeWindow = await openComposeWindow(gPopAccount);
+ await focusWindow(composeWindow);
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger onAfterSave without terminating the background first.
+
+ composeWindow.SetComposeDetails({ to: "first@invalid.net" });
+ composeWindow.SaveAsTemplate();
+ let firstSaveInfo = await extension.awaitMessage("onAfterSave received");
+ Assert.equal(
+ "template",
+ firstSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // Terminate background and re-trigger onAfterSave.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+
+ composeWindow.SetComposeDetails({ to: "second@invalid.net" });
+ composeWindow.SaveAsTemplate();
+ let secondSaveInfo = await extension.awaitMessage("onAfterSave received");
+ Assert.equal(
+ "template",
+ secondSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+ composeWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_compose_sendMessage.js b/comm/mail/components/extensions/test/browser/browser_ext_compose_sendMessage.js
new file mode 100644
index 0000000000..4fd983e8e5
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_compose_sendMessage.js
@@ -0,0 +1,733 @@
+/* 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"
+);
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+// Import the smtp server scripts
+var {
+ nsMailServer,
+ gThreadManager,
+ fsDebugNone,
+ fsDebugAll,
+ fsDebugRecv,
+ fsDebugRecvSend,
+} = ChromeUtils.import("resource://testing-common/mailnews/Maild.jsm");
+var { SmtpDaemon, SMTP_RFC2821_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Smtpd.jsm"
+);
+var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Auth.jsm"
+);
+
+// Setup the daemon and server
+function setupServerDaemon(handler) {
+ if (!handler) {
+ handler = function (d) {
+ return new SMTP_RFC2821_handler(d);
+ };
+ }
+ var server = new nsMailServer(handler, new SmtpDaemon());
+ return server;
+}
+
+function getBasicSmtpServer(port = 1, hostname = "localhost") {
+ let server = localAccountUtils.create_outgoing_server(
+ port,
+ "user",
+ "password",
+ hostname
+ );
+
+ // Override the default greeting so we get something predictable
+ // in the ELHO message
+ Services.prefs.setCharPref("mail.smtpserver.default.hello_argument", "test");
+
+ return server;
+}
+
+function getSmtpIdentity(senderName, smtpServer) {
+ // Set up the identity.
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = senderName;
+ identity.smtpServerKey = smtpServer.key;
+
+ return identity;
+}
+
+function tracksentMessages(aSubject, aTopic, aMsgID) {
+ // The aMsgID starts with < and ends with > which is not used by the API.
+ let headerMessageId = aMsgID.replace(/^<|>$/g, "");
+ gSentMessages.push(headerMessageId);
+}
+
+var gServer;
+var gOutbox;
+var gSentMessages = [];
+let gPopAccount;
+let gLocalAccount;
+
+add_setup(() => {
+ gServer = setupServerDaemon();
+ gServer.start();
+
+ // Test needs a non-local default account to be able to send messages.
+ gPopAccount = createAccount("pop3");
+ gLocalAccount = createAccount("local");
+ MailServices.accounts.defaultAccount = gPopAccount;
+
+ let identity = getSmtpIdentity(
+ "identity@foo.invalid",
+ getBasicSmtpServer(gServer.port)
+ );
+ gPopAccount.addIdentity(identity);
+ gPopAccount.defaultIdentity = identity;
+
+ // Test is using the Sent folder and Outbox folder of the local account.
+ let rootFolder = gLocalAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("Sent", null);
+ MailServices.accounts.setSpecialFolders();
+ gOutbox = rootFolder.getChildNamed("Outbox");
+
+ Services.obs.addObserver(tracksentMessages, "mail:composeSendSucceeded");
+
+ registerCleanupFunction(() => {
+ gServer.stop();
+ Services.obs.removeObserver(tracksentMessages, "mail:composeSendSucceeded");
+ });
+});
+
+add_task(async function test_no_permission() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["send@test.invalid"],
+ subject: "Test send",
+ };
+
+ // Open a compose window with a message.
+ let tab = await browser.compose.beginNew(details);
+
+ // Send now. It should fail due to the missing compose.send permission.
+ await browser.test.assertThrows(
+ () => browser.compose.sendMessage(tab.id),
+ /browser.compose.sendMessage is not a function/,
+ "browser.compose.sendMessage() should reject, if the permission compose.send is not granted."
+ );
+
+ // Clean up.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.tabs.remove(tab.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_fail() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["send@test.invalid"],
+ subject: "Test send",
+ };
+
+ // Open a compose window with a message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew(details);
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await window.sendMessage("checkWindow", details);
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ browser.compose.onBeforeSend.addListener(() => {
+ return { cancel: true };
+ });
+
+ // Add onAfterSend listener
+ let collectedEventsMap = new Map();
+ function onAfterSendListener(tab, info) {
+ collectedEventsMap.set(tab.id, info);
+ }
+ browser.compose.onAfterSend.addListener(onAfterSendListener);
+
+ // Send now. It should fail due to the aborting onBeforeSend listener.
+ await browser.test.assertRejects(
+ browser.compose.sendMessage(tab.id),
+ /Send aborted by an onBeforeSend event/,
+ "browser.compose.sendMessage() should reject, if the message could not be send."
+ );
+
+ // Check onAfterSend listener
+ browser.compose.onAfterSend.removeListener(onAfterSendListener);
+ browser.test.assertEq(
+ 1,
+ collectedEventsMap.size,
+ "Should have received the correct number of onAfterSend events"
+ );
+ browser.test.assertEq(
+ "Send aborted by an onBeforeSend event",
+ collectedEventsMap.get(tab.id).error,
+ "Should have received the correct error"
+ );
+
+ // Clean up.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getSentMessages", async () => {
+ extension.sendMessage(gSentMessages);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_send() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["send@test.invalid"],
+ subject: "Test send",
+ };
+
+ // Open a compose window with a message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew(details);
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await window.sendMessage("checkWindow", details);
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ // Add onAfterSend listener
+ let collectedEventsMap = new Map();
+ function onAfterSendListener(tab, info) {
+ collectedEventsMap.set(tab.id, info);
+ }
+ browser.compose.onAfterSend.addListener(onAfterSendListener);
+
+ // Send now.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let rv = await browser.compose.sendMessage(tab.id);
+ let [sentMessages] = await window.sendMessage("getSentMessages");
+
+ browser.test.assertEq(
+ 1,
+ sentMessages.length,
+ "Number of total messages sent should be correct."
+ );
+ browser.test.assertEq(
+ "sendNow",
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[0],
+ rv.headerMessageId,
+ "The headerMessageId of last message sent should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[0],
+ rv.messages[0].headerMessageId,
+ "The headerMessageId in the copy of last message sent should be correct."
+ );
+
+ // Window should have closed after send.
+ await removedWindowPromise;
+
+ // Check onAfterSend listener
+ browser.compose.onAfterSend.removeListener(onAfterSendListener);
+ browser.test.assertEq(
+ 1,
+ collectedEventsMap.size,
+ "Should have received the correct number of onAfterSend events"
+ );
+ browser.test.assertTrue(
+ collectedEventsMap.has(tab.id),
+ "The received event should belong to the correct tab."
+ );
+ browser.test.assertEq(
+ "sendNow",
+ collectedEventsMap.get(tab.id).mode,
+ "The received event should have the correct mode."
+ );
+ browser.test.assertEq(
+ rv.headerMessageId,
+ collectedEventsMap.get(tab.id).headerMessageId,
+ "The received event should have the correct headerMessageId."
+ );
+ browser.test.assertEq(
+ rv.headerMessageId,
+ collectedEventsMap.get(tab.id).messages[0].headerMessageId,
+ "The message in the received event should have the correct headerMessageId."
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getSentMessages", async () => {
+ extension.sendMessage(gSentMessages);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_sendDefault() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["sendDefault@test.invalid"],
+ subject: "Test sendDefault",
+ };
+
+ // Open a compose window with a message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew(details);
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await window.sendMessage("checkWindow", details);
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ // Send via default mode, which should be sendNow.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let rv = await browser.compose.sendMessage(tab.id, { mode: "default" });
+ let [sentMessages] = await window.sendMessage("getSentMessages");
+
+ browser.test.assertEq(
+ 2,
+ sentMessages.length,
+ "Number of total messages sent should be correct."
+ );
+ browser.test.assertEq(
+ "sendNow",
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[1],
+ rv.headerMessageId,
+ "The headerMessageId of last message sent should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[1],
+ rv.messages[0].headerMessageId,
+ "The headerMessageId in the copy of last message sent should be correct."
+ );
+
+ // Window should have closed after send.
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getSentMessages", async () => {
+ extension.sendMessage(gSentMessages);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ gServer.resetTest();
+});
+
+add_task(async function test_sendNow() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["sendNow@test.invalid"],
+ subject: "Test sendNow",
+ };
+
+ // Open a compose window with a message.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew(details);
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await window.sendMessage("checkWindow", details);
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ // Send via sendNow mode.
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ let rv = await browser.compose.sendMessage(tab.id, { mode: "sendNow" });
+ let [sentMessages] = await window.sendMessage("getSentMessages");
+
+ browser.test.assertEq(
+ 3,
+ sentMessages.length,
+ "Number of total messages sent should be correct."
+ );
+ browser.test.assertEq(
+ "sendNow",
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[2],
+ rv.headerMessageId,
+ "The headerMessageId of last message sent should be correct."
+ );
+ browser.test.assertEq(
+ sentMessages[2],
+ rv.messages[0].headerMessageId,
+ "The headerMessageId in the copy of last message sent should be correct."
+ );
+
+ // Window should have closed after send.
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send"],
+ },
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getSentMessages", async () => {
+ extension.sendMessage(gSentMessages);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_sendLater() {
+ let files = {
+ "background.js": async () => {
+ let details = {
+ to: ["sendLater@test.invalid"],
+ subject: "Test sendLater",
+ };
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew(details);
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ await window.sendMessage("checkWindow", details);
+
+ let [tab] = await browser.tabs.query({ windowId: createdWindow.id });
+
+ // Send Later.
+
+ let rv = await browser.compose.sendMessage(tab.id, { mode: "sendLater" });
+ let [outboxMessage] = await window.sendMessage(
+ "checkMessagesInOutbox",
+ details
+ );
+
+ browser.test.assertEq(
+ "sendLater",
+ rv.mode,
+ "The mode of the last message operation should be correct."
+ );
+ browser.test.assertEq(
+ outboxMessage,
+ rv.messages[0].headerMessageId,
+ "The headerMessageId in the copy of last message sent should be correct."
+ );
+
+ await window.sendMessage("clearMessagesInOutbox");
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "compose.send", "messagesRead", "accountsRead"],
+ },
+ });
+
+ extension.onMessage("checkMessagesInOutbox", async expected => {
+ // Check if the sendLater request did put the message in the outbox.
+ let outboxMessages = [...gOutbox.messages];
+ Assert.ok(outboxMessages.length == 1);
+ let sentMessage = outboxMessages.shift();
+ Assert.equal(sentMessage.subject, expected.subject, "subject is correct");
+ Assert.equal(sentMessage.recipients, expected.to, "recipient is correct");
+ extension.sendMessage(sentMessage.messageId);
+ });
+
+ extension.onMessage("clearMessagesInOutbox", async () => {
+ let outboxMessages = [...gOutbox.messages];
+ await new Promise(resolve => {
+ gOutbox.deleteMessages(
+ outboxMessages,
+ null,
+ true,
+ false,
+ { OnStopCopy: resolve },
+ false
+ );
+ });
+
+ Assert.equal(0, [...gOutbox.messages].length, "outbox should be empty");
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkWindow", async expected => {
+ await checkComposeHeaders(expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_onComposeStateChanged() {
+ let files = {
+ "background.js": async () => {
+ let numberOfEvents = 0;
+ browser.compose.onComposeStateChanged.addListener(async (tab, state) => {
+ numberOfEvents++;
+ browser.test.log(`State #${numberOfEvents}: ${JSON.stringify(state)}`);
+ switch (numberOfEvents) {
+ case 1:
+ // The fresh created composer has no recipient, send is disabled.
+ browser.test.assertEq(false, state.canSendNow);
+ browser.test.assertEq(false, state.canSendLater);
+ break;
+
+ case 2:
+ // The composer updated its initial details data, send is enabled.
+ browser.test.assertEq(true, state.canSendNow);
+ browser.test.assertEq(true, state.canSendLater);
+ break;
+
+ case 3:
+ // The recipient has been invalidated, send is disabled.
+ browser.test.assertEq(false, state.canSendNow);
+ browser.test.assertEq(false, state.canSendLater);
+ break;
+
+ case 4:
+ // The recipient has been reverted, send is enabled.
+ browser.test.assertEq(true, state.canSendNow);
+ browser.test.assertEq(true, state.canSendLater);
+
+ // Clean up.
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.windows.remove(createdWindow.id);
+ await removedWindowPromise;
+
+ browser.test.notifyPass("finished");
+ break;
+ }
+ });
+
+ // The call to beginNew should create two onComposeStateChanged events,
+ // one after the empty window has been created and one after the initial
+ // details have been set.
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ let createdTab = await browser.compose.beginNew({
+ to: ["test@test.invalid"],
+ subject: "Test part 1",
+ body: "Original body.",
+ });
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ // Trigger an onComposeStateChanged event by invalidating the recipient.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ to: ["test"],
+ subject: "Test part 2",
+ });
+
+ // Trigger an onComposeStateChanged event by reverting the recipient.
+ await browser.compose.setComposeDetails(createdTab.id, {
+ to: ["test@test.invalid"],
+ subject: "Test part 3",
+ });
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+// Test onAfterSend for MV3
+add_task(async function test_onAfterSend_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.compose.onAfterSend.addListener(async (tab, sendInfo) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onAfterSend received", sendInfo);
+ }
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ browser_specific_settings: {
+ gecko: { id: "compose.onAfterSend@xpcshell.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["compose.onAfterSend"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+
+ // Trigger onAfterSend without terminating the background first.
+
+ let firstComposeWindow = await openComposeWindow(gPopAccount);
+ await focusWindow(firstComposeWindow);
+ firstComposeWindow.SetComposeDetails({ to: "first@invalid.net" });
+ firstComposeWindow.SetComposeDetails({ subject: "First message" });
+ firstComposeWindow.SendMessage();
+ let firstSaveInfo = await extension.awaitMessage("onAfterSend received");
+ Assert.equal(
+ "sendNow",
+ firstSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // Terminate background and re-trigger onAfterSend.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // The listeners should be primed.
+ checkPersistentListeners({ primed: true });
+ let secondComposeWindow = await openComposeWindow(gPopAccount);
+ await focusWindow(secondComposeWindow);
+ secondComposeWindow.SetComposeDetails({ to: "second@invalid.net" });
+ secondComposeWindow.SetComposeDetails({ subject: "Second message" });
+ secondComposeWindow.SendMessage();
+ let secondSaveInfo = await extension.awaitMessage("onAfterSend received");
+ Assert.equal(
+ "sendNow",
+ secondSaveInfo.mode,
+ "Returned SaveInfo should be correct"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_contentScripts.js b/comm/mail/components/extensions/test/browser/browser_ext_contentScripts.js
new file mode 100644
index 0000000000..50ae11ffab
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_contentScripts.js
@@ -0,0 +1,438 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const CONTENT_PAGE =
+ "http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html";
+const UNCHANGED_VALUES = {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: null,
+ textContent: "\n This is text.\n This is a link with text.\n \n\n\n",
+};
+
+/** Tests browser.tabs.insertCSS and browser.tabs.removeCSS. */
+add_task(async function testInsertRemoveCSS() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ active: true });
+
+ await browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.insertCSS(tab.id, { file: "test.css" });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, { file: "test.css" });
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["*://mochi.test/*"],
+ },
+ });
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+
+ await extension.startup();
+
+ await extension.awaitMessage(); // insertCSS with code
+ await checkContent(tab.browser, { backgroundColor: "rgb(0, 255, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage(); // removeCSS with code
+ await checkContent(tab.browser, UNCHANGED_VALUES);
+ extension.sendMessage();
+
+ await extension.awaitMessage(); // insertCSS with file
+ await checkContent(tab.browser, { backgroundColor: "rgb(0, 128, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished"); // removeCSS with file
+ await checkContent(tab.browser, UNCHANGED_VALUES);
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+/** Tests browser.tabs.insertCSS fails without the host permission. */
+add_task(async function testInsertRemoveCSSNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ active: true });
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: darkred; }",
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, { file: "test.css" }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ file: "test.css",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkContent(tab.browser, UNCHANGED_VALUES);
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+/** Tests browser.tabs.executeScript. */
+add_task(async function testExecuteScript() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.tabs.query({ active: true });
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ });
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, { file: "test.js" });
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["*://mochi.test/*"],
+ },
+ });
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+
+ await extension.startup();
+
+ await extension.awaitMessage(); // executeScript with code
+ await checkContent(tab.browser, { foo: "bar" });
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished"); // executeScript with file
+ await checkContent(tab.browser, {
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+/** Tests browser.tabs.executeScript fails without the host permission. */
+add_task(async function testExecuteScriptNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.tabs.query({ active: true });
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, { file: "test.js" }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ file: "test.js",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkContent(tab.browser, UNCHANGED_VALUES);
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+/** Tests the messenger alias is available. */
+add_task(async function testExecuteScriptAlias() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tab = await browser.tabs.query({ active: true });
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.textContent = messenger.runtime.getManifest().applications.gecko.id;`,
+ });
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ applications: { gecko: { id: "content_scripts@mochitest" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["*://mochi.test/*"],
+ },
+ });
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkContent(tab.browser, { textContent: "content_scripts@mochitest" });
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
+
+/**
+ * Tests browser.contentScripts.register correctly adds CSS and JavaScript to
+ * message composition windows opened after it was called. Also tests calling
+ * `unregister` on the returned object.
+ */
+add_task(async function testRegister() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let registeredScript = await browser.contentScripts.register({
+ css: [{ code: "body { color: white }" }, { file: "test.css" }],
+ js: [
+ { code: `document.body.setAttribute("foo", "bar");` },
+ { file: "test.js" },
+ ],
+ matches: ["*://mochi.test/*"],
+ });
+ await window.sendMessage();
+
+ await registeredScript.unregister();
+ await window.sendMessage();
+
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["*://mochi.test/*"],
+ },
+ });
+
+ // Tab 1: loads before the script is registered.
+ let tab1 = window.openContentTab(CONTENT_PAGE + "?tab1");
+ await awaitBrowserLoaded(tab1.browser, CONTENT_PAGE + "?tab1");
+
+ await extension.startup();
+
+ await extension.awaitMessage(); // register
+ await checkContent(tab1.browser, UNCHANGED_VALUES);
+
+ // Tab 2: loads after the script is registered.
+ let tab2 = window.openContentTab(CONTENT_PAGE + "?tab2");
+ await awaitBrowserLoaded(tab2.browser, CONTENT_PAGE + "?tab2");
+ // Despite the fact we've just waited for the page to load, sometimes the
+ // content script mechanism gets triggered late. Wait a moment.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+ await checkContent(tab2.browser, {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ extension.sendMessage();
+ await extension.awaitMessage(); // unregister
+
+ await checkContent(tab2.browser, {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ // Tab 3: loads after the script is unregistered.
+ let tab3 = window.openContentTab(CONTENT_PAGE + "?tab3");
+ await awaitBrowserLoaded(tab3.browser, CONTENT_PAGE + "?tab3");
+ await checkContent(tab3.browser, UNCHANGED_VALUES);
+
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ // Tab 2 should have the CSS removed.
+ await checkContent(tab2.browser, {
+ backgroundColor: UNCHANGED_VALUES.backgroundColor,
+ color: UNCHANGED_VALUES.color,
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+
+/** Tests content_scripts in the manifest with permission work. */
+add_task(async function testManifest() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "test.css": "body { background-color: lime; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ },
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ css: ["test.css"],
+ js: ["test.js"],
+ },
+ ],
+ },
+ });
+
+ // Tab 1: loads before the script is registered.
+ let tab1 = window.openContentTab(CONTENT_PAGE + "?tab1");
+ await awaitBrowserLoaded(tab1.browser, CONTENT_PAGE + "?tab1");
+ // Despite the fact we've just waited for the page to load, sometimes the
+ // content script mechanism gets triggered late. Wait a moment.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+
+ await extension.startup();
+
+ await checkContent(tab1.browser, UNCHANGED_VALUES);
+
+ // Tab 2: loads after the script is registered.
+ let tab2 = window.openContentTab(CONTENT_PAGE + "?tab2");
+ await awaitBrowserLoaded(tab2.browser, CONTENT_PAGE + "?tab2");
+ // Despite the fact we've just waited for the page to load, sometimes the
+ // content script mechanism gets triggered late. Wait a moment.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+
+ await checkContent(tab2.browser, {
+ backgroundColor: "rgb(0, 255, 0)",
+ textContent: "Hey look, the script ran!",
+ });
+
+ await extension.unload();
+
+ // Tab 2 should have the CSS removed.
+ await checkContent(tab2.browser, {
+ backgroundColor: UNCHANGED_VALUES.backgroundColor,
+ textContent: "Hey look, the script ran!",
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+
+/** Tests content_scripts match patterns in the manifest. */
+add_task(async function testManifestNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "test.css": "body { background-color: red; }",
+ "test.js": () => {
+ document.body.textContent = "Hey look, the script ran!";
+ },
+ },
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["*://example.org/*"],
+ css: ["test.css"],
+ js: ["test.js"],
+ },
+ ],
+ },
+ });
+
+ await extension.startup();
+
+ let tab = window.openContentTab(CONTENT_PAGE);
+ await awaitBrowserLoaded(tab.browser, CONTENT_PAGE);
+ await checkContent(tab.browser, UNCHANGED_VALUES);
+
+ await extension.unload();
+
+ document.getElementById("tabmail").closeTab(tab);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_content_handler.js b/comm/mail/components/extensions/test/browser/browser_ext_content_handler.js
new file mode 100644
index 0000000000..bfd4b1e787
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_content_handler.js
@@ -0,0 +1,334 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const getCommonFiles = async () => {
+ return {
+ "utils.js": await getUtilsJS(),
+ "common.js": () => {
+ window.CreateTabPromise = class {
+ constructor() {
+ this.promise = new Promise(resolve => {
+ let createListener = tab => {
+ browser.tabs.onCreated.removeListener(createListener);
+ resolve(tab);
+ };
+ browser.tabs.onCreated.addListener(createListener);
+ });
+ }
+ async done() {
+ return this.promise;
+ }
+ };
+
+ window.UpdateTabPromise = class {
+ constructor(options) {
+ this.logWindowId = options?.logWindowId;
+ this.promise = new Promise(resolve => {
+ let updateLog = new Map();
+ let updateListener = (tabId, changes, tab) => {
+ let id = this.logWindowId ? tab.windowId : tabId;
+
+ if (changes?.url != "about:blank") {
+ let log = updateLog.get(id) || {};
+
+ if (changes.url) {
+ log.url = changes.url;
+ }
+ // The complete is only valid, if we have seen a url (which was
+ // not "about:blank")
+ if (log.url && changes?.status == "complete") {
+ log.complete = true;
+ }
+
+ updateLog.set(id, log);
+ if (log.url && log.complete) {
+ browser.tabs.onUpdated.removeListener(updateListener);
+ resolve(updateLog);
+ }
+ }
+ };
+ browser.tabs.onUpdated.addListener(updateListener);
+ });
+ }
+ async verify(id, url) {
+ // The updatePromise resolves after we have seen the "complete" state
+ // and a url.
+ let updateLog = await this.promise;
+ browser.test.assertEq(
+ 1,
+ updateLog.size,
+ `Should have seen exactly one tab being updated - ${JSON.stringify(
+ Array.from(updateLog)
+ )}`
+ );
+ browser.test.assertTrue(
+ updateLog.has(id),
+ `Updates must belong to the current tab ${id}`
+ );
+ browser.test.assertEq(
+ url,
+ updateLog.get(id).url,
+ "Should have seen the correct url loaded."
+ );
+ }
+ };
+ },
+ "background.js": async () => {
+ // Open a local extension page and click a handler link. They are all
+ // expected to open in a new tab.
+ let testSelectors = ["#link1", "#link2", "#link3", "#link4"];
+ for (let linkSelector of testSelectors) {
+ await window.expectLinkOpenInNewTab(
+ browser.runtime.getURL("test.html"),
+ linkSelector,
+ browser.runtime.getURL("handler.html#ext%2Btest%3Apayload")
+ );
+ }
+ browser.test.notifyPass();
+ },
+ "handler.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>EXAMPLE</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p>This is an example page</p>
+ </body>
+ </html>`,
+ "test.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>TEST</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <ul>
+ <li><a id="link1" href="ext+test:payload">extension handler without target</a>
+ <li><a id="link2" href="ext+test:payload" target = "_self">extension handler with _self target</a>
+ <li><a id="link3" href="ext+test:payload" target = "_blank">extension handler with _blank target</a>
+ <li><a id="link4" href="ext+test:payload" target = "_other">extension handler with _other target</a>
+ </ul>
+ </body>
+ </html>`,
+ };
+};
+
+const subtest_clickInBrowser = async (extension, getBrowser) => {
+ async function clickLink(linkSelector, browser) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+ await synthesizeMouseAtCenterAndRetry(linkSelector, {}, browser);
+ }
+
+ await extension.startup();
+
+ let testSelectors = ["#link1", "#link2", "#link3", "#link4"];
+
+ for (let expectedSelector of testSelectors) {
+ // Wait for click on link (new tab)
+ let { linkSelector } = await extension.awaitMessage("click");
+ Assert.equal(
+ expectedSelector,
+ linkSelector,
+ `Test should click on the correct link.`
+ );
+ await clickLink(linkSelector, getBrowser());
+ await extension.sendMessage();
+ }
+
+ await extension.awaitFinish();
+ await extension.unload();
+};
+
+add_setup(async () => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(subFolders.test0.URI);
+});
+
+add_task(async function test_tabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "tabFunctions.js": async () => {
+ let openTestTab = async url => {
+ let createdTestTab = new window.CreateTabPromise();
+ let updatedTestTab = new window.UpdateTabPromise();
+ let testTab = await browser.tabs.create({ url });
+ await createdTestTab.done();
+ await updatedTestTab.verify(testTab.id, url);
+ return testTab;
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkSelector,
+ expectedUrl
+ ) => {
+ let testTab = await openTestTab(testUrl);
+
+ // Click a link in testTab to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkSelector });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ await browser.tabs.remove(testTab.id);
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "common.js", "tabFunctions.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ protocol_handlers: [
+ {
+ protocol: "ext+test",
+ name: "Protocol Handler Example",
+ uriTemplate: "/handler.html#%s",
+ },
+ ],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+});
+
+add_task(async function test_windows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "windowFunctions.js": async () => {
+ let openTestWin = async url => {
+ let createdTestTab = new window.CreateTabPromise();
+ let updatedTestTab = new window.UpdateTabPromise({
+ logWindowId: true,
+ });
+ let testWindow = await browser.windows.create({ type: "popup", url });
+ await createdTestTab.done();
+ await updatedTestTab.verify(testWindow.id, url);
+ return testWindow;
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkSelector,
+ expectedUrl
+ ) => {
+ let testWindow = await openTestWin(testUrl);
+
+ // Click a link in testWindow to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkSelector });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ await browser.windows.remove(testWindow.id);
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: [
+ "utils.js",
+ "common.js",
+ "windowFunctions.js",
+ "background.js",
+ ],
+ },
+ permissions: ["tabs"],
+ protocol_handlers: [
+ {
+ protocol: "ext+test",
+ name: "Protocol Handler Example",
+ uriTemplate: "/handler.html#%s",
+ },
+ ],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ () => Services.wm.getMostRecentWindow("mail:extensionPopup").browser
+ );
+});
+
+add_task(async function test_mail3pane() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "mail3paneFunctions.js": async () => {
+ let updateTestTab = async url => {
+ let updatedTestTab = new window.UpdateTabPromise();
+ let mailTabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(
+ 1,
+ mailTabs.length,
+ "Should find a single mailTab"
+ );
+ await browser.tabs.update(mailTabs[0].id, { url });
+ await updatedTestTab.verify(mailTabs[0].id, url);
+ return mailTabs[0];
+ };
+
+ window.expectLinkOpenInNewTab = async (
+ testUrl,
+ linkSelector,
+ expectedUrl
+ ) => {
+ await updateTestTab(testUrl);
+
+ // Click a link in testTab to open a new tab.
+ let createdNewTab = new window.CreateTabPromise();
+ let updatedNewTab = new window.UpdateTabPromise();
+ await window.sendMessage("click", { linkSelector });
+ let createdTab = await createdNewTab.done();
+ await updatedNewTab.verify(createdTab.id, expectedUrl);
+
+ await browser.tabs.remove(createdTab.id);
+ };
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: [
+ "utils.js",
+ "common.js",
+ "mail3paneFunctions.js",
+ "background.js",
+ ],
+ },
+ permissions: ["tabs"],
+ protocol_handlers: [
+ {
+ protocol: "ext+test",
+ name: "Protocol Handler Example",
+ uriTemplate: "/handler.html#%s",
+ },
+ ],
+ },
+ });
+
+ await subtest_clickInBrowser(
+ extension,
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_content_tabs_navigation_menu.js b/comm/mail/components/extensions/test/browser/browser_ext_content_tabs_navigation_menu.js
new file mode 100644
index 0000000000..49d9340b3b
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_content_tabs_navigation_menu.js
@@ -0,0 +1,250 @@
+/* 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/. *
+ */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+const getCommonFiles = async () => {
+ return {
+ "utils.js": await getUtilsJS(),
+ "example.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>EXAMPLE</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p id="description">This is text.</p>
+ </body>
+ </html>`,
+ "test.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>TEST</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p id="description">This is text.</p>
+ <ul>
+ <li><a id="link" href="example.html">link to example page</a>
+ </ul>
+ </body>
+ </html>`,
+ };
+};
+
+const subtest_clickOpenInBrowserContextMenu = async (extension, getBrowser) => {
+ function waitForLoad(browser, expectedUrl) {
+ return awaitBrowserLoaded(browser, url => url.endsWith(expectedUrl));
+ }
+
+ async function testMenuNavItems(description, browser, expected) {
+ let menuId = browser.getAttribute("context");
+ let menu = browser.ownerGlobal.top.document.getElementById(menuId);
+ await rightClickOnContent(menu, "#description", browser);
+ for (let [key, value] of Object.entries(expected)) {
+ Assert.ok(
+ menu.querySelector(key),
+ `[${description}] ${key} menu item should exist`
+ );
+ switch (value) {
+ case "disabled":
+ case "enabled":
+ Assert.ok(
+ menu.querySelector(key).hasAttribute("disabled") ==
+ (value == "disabled"),
+ `[${description}] ${key} menu item should have the correct disabled state`
+ );
+ break;
+ case "hidden":
+ case "shown":
+ Assert.ok(
+ menu.querySelector(key).hidden == (value == "hidden"),
+ `[${description}] ${key} menu item should have the correct hidden state`
+ );
+ break;
+ }
+ }
+ // Wait a moment to make the test not fail.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 125));
+ menu.hidePopup();
+ }
+
+ async function clickLink(browser) {
+ await synthesizeMouseAtCenterAndRetry("#link", {}, browser);
+ }
+
+ await extension.startup();
+
+ await extension.awaitMessage("contextClick");
+ let browser = getBrowser();
+
+ // Wait till test.html is fully loaded and check the state of the nav items.
+ await waitForLoad(browser, "test.html");
+ await testMenuNavItems("after initial load", browser, {
+ "#browserContext-back": browser.webNavigation.canGoBack
+ ? "enabled"
+ : "disabled",
+ "#browserContext-forward": "disabled",
+ "#browserContext-reload": "shown",
+ "#browserContext-stop": "hidden",
+ });
+
+ // Click on a link to load example.html and wait till page load has started.
+ // The navigation items should have the stop item shown.
+ let startLoadPromise = BrowserTestUtils.browserStarted(browser);
+ await clickLink(browser);
+ await startLoadPromise;
+ await testMenuNavItems("before link load", browser, {
+ "#browserContext-back": browser.webNavigation.canGoBack
+ ? "enabled"
+ : "disabled",
+ "#browserContext-forward": "disabled",
+ "#browserContext-reload": "hidden",
+ "#browserContext-stop": "shown",
+ });
+
+ // Wait till example.html is fully loaded and check the state of the nav
+ // items.
+ await waitForLoad(browser, "example.html");
+ await testMenuNavItems("after link load", browser, {
+ "#browserContext-back": "enabled",
+ "#browserContext-forward": "disabled",
+ "#browserContext-reload": "shown",
+ "#browserContext-stop": "hidden",
+ });
+
+ // Navigate back and wait till the load of test.html has started. The
+ // navigation items should have the stop item shown.
+ startLoadPromise = BrowserTestUtils.browserStarted(browser);
+ browser.webNavigation.goBack();
+ await startLoadPromise;
+ await testMenuNavItems("before navigate back load", browser, {
+ "#browserContext-back": "enabled",
+ "#browserContext-forward": "disabled",
+ "#browserContext-reload": "hidden",
+ "#browserContext-stop": "shown",
+ });
+
+ // Wait till test.html is fully loaded and check the state of the nav items.
+ await waitForLoad(browser, "test.html");
+ await testMenuNavItems("after navigate back load", browser, {
+ "#browserContext-back": browser.webNavigation.canGoBack
+ ? "enabled"
+ : "disabled",
+ "#browserContext-forward": "enabled",
+ "#browserContext-reload": "shown",
+ "#browserContext-stop": "hidden",
+ });
+
+ await extension.sendMessage();
+ await extension.awaitFinish();
+ await extension.unload();
+};
+
+add_setup(() => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(subFolders.test0.URI);
+});
+
+add_task(async function test_tabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ const url = "test.html";
+ let testTab = await browser.tabs.create({ url });
+ await window.sendMessage("contextClick");
+ await browser.tabs.remove(testTab.id);
+
+ browser.test.notifyPass();
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => document.getElementById("tabmail").currentTabInfo.browser
+ );
+});
+
+add_task(async function test_windows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ const url = "test.html";
+ let testWindow = await browser.windows.create({ type: "popup", url });
+ await window.sendMessage("contextClick");
+ await browser.windows.remove(testWindow.id);
+
+ browser.test.notifyPass();
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => Services.wm.getMostRecentWindow("mail:extensionPopup").browser
+ );
+});
+
+add_task(async function test_mail3pane() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ const url = "test.html";
+ let mailTabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(
+ 1,
+ mailTabs.length,
+ "Should find a single mailTab"
+ );
+ await browser.tabs.update(mailTabs[0].id, { url });
+ await window.sendMessage("contextClick");
+
+ browser.test.notifyPass();
+ },
+ ...(await getCommonFiles()),
+ },
+ manifest: {
+ background: {
+ scripts: ["utils.js", "background.js"],
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await subtest_clickOpenInBrowserContextMenu(
+ extension,
+ () => document.getElementById("tabmail").currentAbout3Pane.webBrowser
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_mailTabs.js b/comm/mail/components/extensions/test/browser/browser_ext_mailTabs.js
new file mode 100644
index 0000000000..7d0cf00e12
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_mailTabs.js
@@ -0,0 +1,898 @@
+/* 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/. */
+
+let account, rootFolder, subFolders;
+let tabmail = document.getElementById("tabmail");
+
+add_setup(async () => {
+ account = createAccount();
+ rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test1", null);
+ rootFolder.createSubfolder("test2", null);
+ subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test1, 10);
+ createMessages(subFolders.test2, 50);
+
+ tabmail.currentTabInfo.folder = rootFolder;
+ tabmail.currentAbout3Pane.displayFolder(subFolders.test1.URI);
+ await ensure_table_view();
+
+ Services.prefs.setIntPref("extensions.webextensions.messagesPerPage", 10);
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ Services.prefs.clearUserPref("extensions.webextensions.messagesPerPage");
+ });
+ await new Promise(resolve => executeSoon(resolve));
+});
+
+add_task(async function test_update() {
+ async function background() {
+ async function checkCurrent(expected) {
+ let [current] = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ window.assertDeepEqual(expected, current);
+
+ // Check if getCurrent() returns the same.
+ let current2 = await browser.mailTabs.getCurrent();
+ window.assertDeepEqual(expected, current2);
+ }
+
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+
+ await browser.mailTabs.update({ displayedFolder: folders[0] });
+ let expected = {
+ sortType: "date",
+ sortOrder: "ascending",
+ viewType: "groupedByThread",
+ layout: "standard",
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ displayedFolder: folders[0],
+ };
+ delete expected.displayedFolder.subFolders;
+
+ await checkCurrent(expected);
+ await window.sendMessage("checkRealLayout", expected);
+ await window.sendMessage("checkRealSort", expected);
+ await window.sendMessage("checkRealView", expected);
+
+ expected.sortOrder = "descending";
+ for (let value of ["date", "subject", "author"]) {
+ await browser.mailTabs.update({
+ sortType: value,
+ sortOrder: "descending",
+ });
+ expected.sortType = value;
+ await window.sendMessage("checkRealSort", expected);
+ await window.sendMessage("checkRealView", expected);
+ }
+ expected.sortOrder = "ascending";
+ for (let value of ["author", "subject", "date"]) {
+ await browser.mailTabs.update({
+ sortType: value,
+ sortOrder: "ascending",
+ });
+ expected.sortType = value;
+ await window.sendMessage("checkRealSort", expected);
+ await window.sendMessage("checkRealView", expected);
+ }
+
+ for (let key of ["folderPaneVisible", "messagePaneVisible"]) {
+ for (let value of [false, true]) {
+ await browser.mailTabs.update({ [key]: value });
+ expected[key] = value;
+ await checkCurrent(expected);
+ await window.sendMessage("checkRealLayout", expected);
+ await window.sendMessage("checkRealView", expected);
+ }
+ }
+ for (let value of ["wide", "vertical", "standard"]) {
+ await browser.mailTabs.update({ layout: value });
+ expected.layout = value;
+ await checkCurrent(expected);
+ await window.sendMessage("checkRealLayout", expected);
+ await window.sendMessage("checkRealView", expected);
+ }
+
+ // Test all possible switch combination.
+ for (let viewType of [
+ "ungrouped",
+ "groupedByThread",
+ "ungrouped",
+ "groupedBySortType",
+ "groupedByThread",
+ "groupedBySortType",
+ "ungrouped",
+ ]) {
+ await browser.mailTabs.update({ viewType });
+ expected.viewType = viewType;
+ await checkCurrent(expected);
+ await window.sendMessage("checkRealLayout", expected);
+ await window.sendMessage("checkRealSort", expected);
+ await window.sendMessage("checkRealView", expected);
+ }
+
+ let selectedMessages = await browser.mailTabs.getSelectedMessages();
+ browser.test.assertEq(null, selectedMessages.id);
+ browser.test.assertEq(0, selectedMessages.messages.length);
+
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkRealLayout", async expected => {
+ let intValue = ["standard", "wide", "vertical"].indexOf(expected.layout);
+ is(Services.prefs.getIntPref("mail.pane_config.dynamic"), intValue);
+ await check3PaneState(
+ expected.folderPaneVisible,
+ expected.messagePaneVisible
+ );
+ Assert.equal(
+ "/" + (tabmail.currentTabInfo.folder.URI || "").split("/").pop(),
+ expected.displayedFolder.path,
+ "Should display the correct folder"
+ );
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkRealSort", expected => {
+ const sortTypes = {
+ date: Ci.nsMsgViewSortType.byDate,
+ subject: Ci.nsMsgViewSortType.bySubject,
+ author: Ci.nsMsgViewSortType.byAuthor,
+ };
+
+ let { primarySortType, primarySortOrder } =
+ tabmail.currentAbout3Pane.gViewWrapper;
+
+ Assert.equal(
+ primarySortOrder,
+ Ci.nsMsgViewSortOrder[expected.sortOrder],
+ `sort order should be ${expected.sortOrder}`
+ );
+ Assert.equal(
+ primarySortType,
+ sortTypes[expected.sortType],
+ `sort type should be ${expected.sortType}`
+ );
+
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkRealView", expected => {
+ const viewTypes = {
+ groupedBySortType: {
+ showGroupedBySort: true,
+ showThreaded: false,
+ showUnthreaded: false,
+ },
+ groupedByThread: {
+ showGroupedBySort: false,
+ showThreaded: true,
+ showUnthreaded: false,
+ },
+ ungrouped: {
+ showGroupedBySort: false,
+ showThreaded: false,
+ showUnthreaded: true,
+ },
+ };
+
+ let { showThreaded, showUnthreaded, showGroupedBySort } =
+ tabmail.currentAbout3Pane.gViewWrapper;
+
+ Assert.equal(
+ showThreaded,
+ viewTypes[expected.viewType].showThreaded,
+ `Correct value for showThreaded for viewType <${expected.viewType}>`
+ );
+ Assert.equal(
+ showUnthreaded,
+ viewTypes[expected.viewType].showUnthreaded,
+ `Correct value for showUnthreaded for viewType <${expected.viewType}>`
+ );
+ Assert.equal(
+ showGroupedBySort,
+ viewTypes[expected.viewType].showGroupedBySort,
+ `Correct value for showGroupedBySort for viewType <${expected.viewType}>`
+ );
+ extension.sendMessage();
+ });
+
+ await check3PaneState(true, true);
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ tabmail.currentTabInfo.folder = rootFolder;
+});
+
+add_task(async function test_displayedFolderChanged() {
+ async function background() {
+ let [accountId] = await window.waitForMessage();
+
+ let [current] = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(accountId, current.displayedFolder.accountId);
+ browser.test.assertEq("/", current.displayedFolder.path);
+
+ async function selectFolder(newFolderPath) {
+ let changeListener = window.waitForEvent(
+ "mailTabs.onDisplayedFolderChanged"
+ );
+ browser.test.sendMessage("selectFolder", newFolderPath);
+ let [tab, folder] = await changeListener;
+ browser.test.assertEq(current.id, tab.id);
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq(newFolderPath, folder.path);
+ }
+ await selectFolder("/test1");
+ await selectFolder("/test2");
+ await selectFolder("/");
+
+ async function selectFolderByUpdate(newFolderPath) {
+ let changeListener = window.waitForEvent(
+ "mailTabs.onDisplayedFolderChanged"
+ );
+ browser.mailTabs.update({
+ displayedFolder: { accountId, path: newFolderPath },
+ });
+ let [tab, folder] = await changeListener;
+ browser.test.assertEq(current.id, tab.id);
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq(newFolderPath, folder.path);
+ }
+ await selectFolderByUpdate("/test1");
+ await selectFolderByUpdate("/test2");
+ await selectFolderByUpdate("/");
+ await selectFolderByUpdate("/test1");
+
+ await new Promise(resolve => setTimeout(resolve));
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let folderMap = new Map([
+ ["/", rootFolder],
+ ["/test1", subFolders.test1],
+ ["/test2", subFolders.test2],
+ ]);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("selectFolder", async newFolderPath => {
+ tabmail.currentTabInfo.folder = folderMap.get(newFolderPath);
+ await new Promise(resolve => executeSoon(resolve));
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ tabmail.currentTabInfo.folder = rootFolder;
+});
+
+add_task(async function test_selectedMessagesChanged() {
+ async function background() {
+ function checkMessageList(expectedId, expectedCount, actual) {
+ if (expectedId) {
+ browser.test.assertEq(36, actual.id.length);
+ } else {
+ browser.test.assertEq(null, actual.id);
+ }
+ browser.test.assertEq(expectedCount, actual.messages.length);
+ }
+
+ // Because of bad design, we must wait for the WebExtensions mechanism to load ext-mailTabs.js,
+ // or when we call addListener below, it won't happen before the event is fired.
+ // This only applies if none of the earlier tests are run, but I'm saving you from wasting
+ // time figuring out what's going on like I did.
+ await browser.mailTabs.query({});
+
+ async function selectMessages(...newMessages) {
+ let selectPromise = window.waitForEvent(
+ "mailTabs.onSelectedMessagesChanged"
+ );
+ browser.test.sendMessage("selectMessage", newMessages);
+ let [, messageList] = await selectPromise;
+ return messageList;
+ }
+
+ let messageList;
+ messageList = await selectMessages(3);
+ checkMessageList(false, 1, messageList);
+ messageList = await selectMessages(7);
+ checkMessageList(false, 1, messageList);
+ messageList = await selectMessages(4, 6);
+ checkMessageList(false, 2, messageList);
+ messageList = await selectMessages();
+ checkMessageList(false, 0, messageList);
+ messageList = await selectMessages(
+ 2,
+ 3,
+ 5,
+ 7,
+ 11,
+ 13,
+ 17,
+ 19,
+ 23,
+ 29,
+ 31,
+ 37
+ );
+ checkMessageList(true, 10, messageList);
+ messageList = await browser.messages.continueList(messageList.id);
+ checkMessageList(false, 2, messageList);
+ messageList = await browser.mailTabs.getSelectedMessages();
+ checkMessageList(true, 10, messageList);
+ messageList = await browser.messages.continueList(messageList.id);
+ checkMessageList(false, 2, messageList);
+
+ await new Promise(resolve => setTimeout(resolve));
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ tabmail.currentTabInfo.folder = subFolders.test2;
+ tabmail.currentTabInfo.messagePaneVisible = true;
+
+ extension.onMessage("selectMessage", newMessages => {
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = newMessages;
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ tabmail.currentTabInfo.folder = rootFolder;
+});
+
+add_task(async function test_background_tab() {
+ async function background() {
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+ let allTabs = await browser.tabs.query({});
+ let queryTabs = await browser.tabs.query({ mailTab: true });
+ let allMailTabs = await browser.mailTabs.query({});
+
+ browser.test.assertEq(4, allTabs.length);
+ browser.test.assertEq(2, queryTabs.length);
+ browser.test.assertEq(2, allMailTabs.length);
+
+ browser.test.assertEq(accountId, allMailTabs[0].displayedFolder.accountId);
+ browser.test.assertEq("/", allMailTabs[0].displayedFolder.path);
+
+ browser.test.assertEq(accountId, allMailTabs[1].displayedFolder.accountId);
+ browser.test.assertEq("/test1", allMailTabs[1].displayedFolder.path);
+ browser.test.assertTrue(allMailTabs[1].active);
+
+ // Check the initial state.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+
+ await browser.mailTabs.update(allMailTabs[0].id, {
+ folderPaneVisible: false,
+ messagePaneVisible: false,
+ displayedFolder: folders.find(f => f.name == "test2"),
+ });
+
+ // Should be in the same state, since we're updating a background tab.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+
+ allMailTabs = await browser.mailTabs.query({});
+ browser.test.assertEq(2, allMailTabs.length);
+
+ browser.test.assertEq(accountId, allMailTabs[0].displayedFolder.accountId);
+ browser.test.assertEq("/test2", allMailTabs[0].displayedFolder.path);
+
+ browser.test.assertEq(accountId, allMailTabs[1].displayedFolder.accountId);
+ browser.test.assertEq("/test1", allMailTabs[1].displayedFolder.path);
+ browser.test.assertTrue(allMailTabs[1].active);
+
+ // Switch to the other mail tab.
+ await browser.tabs.update(allMailTabs[0].id, { active: true });
+
+ // Should have changed to the updated state.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: false,
+ folderPaneVisible: false,
+ displayedFolder: "/test2",
+ });
+
+ await browser.mailTabs.update(allMailTabs[0].id, {
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ });
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test2",
+ });
+
+ // Switch back to the first mail tab.
+ await browser.tabs.update(allMailTabs[1].id, { active: true });
+
+ // Should be in the same state it was in.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead"],
+ },
+ });
+
+ extension.onMessage("checkRealLayout", async expected => {
+ await check3PaneState(
+ expected.folderPaneVisible,
+ expected.messagePaneVisible
+ );
+ Assert.equal(
+ "/" + (tabmail.currentTabInfo.folder.URI || "").split("/").pop(),
+ expected.displayedFolder,
+ "Should display the correct folder"
+ );
+ extension.sendMessage();
+ });
+
+ window.openContentTab("about:buildconfig");
+ window.openContentTab("about:mozilla");
+ tabmail.openTab("mail3PaneTab", { folderURI: subFolders.test1.URI });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.currentTabInfo.chromeBrowser,
+ "folderURIChanged",
+ false,
+ event => event.detail == subFolders.test1.URI
+ );
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ tabmail.closeOtherTabs(0);
+ tabmail.currentTabInfo.folder = rootFolder;
+});
+
+add_task(async function test_get_and_query() {
+ async function background() {
+ async function checkTab(expected) {
+ // Check mailTabs.get().
+ let mailTab = await browser.mailTabs.get(expected.tab.id);
+ browser.test.assertEq(expected.tab.id, mailTab.id);
+
+ // Check if a query for all tabs in the same window included the expected tab.
+ let mailTabs = await browser.mailTabs.query({
+ windowId: expected.tab.windowId,
+ });
+ let filteredMailTabs = mailTabs.filter(e => e.id == expected.tab.id);
+ browser.test.assertEq(1, filteredMailTabs.length);
+
+ // Check if a query for the current tab in the given window returns the current tab.
+ if (expected.isCurrentTab) {
+ let currentTabs = await browser.mailTabs.query({
+ active: true,
+ windowId: expected.tab.windowId,
+ });
+ browser.test.assertEq(1, currentTabs.length);
+ browser.test.assertEq(expected.tab.id, currentTabs[0].id);
+ }
+
+ // Check if a query for all tabs in the currentWindow includes the expected tab.
+ if (expected.isCurrentWindow) {
+ let mailTabsCurrentWindow = await browser.mailTabs.query({
+ currentWindow: true,
+ });
+ let filteredMailTabsCurrentWindow = mailTabsCurrentWindow.filter(
+ e => e.id == expected.tab.id
+ );
+ browser.test.assertEq(1, filteredMailTabsCurrentWindow.length);
+ }
+
+ // Check mailTabs.getCurrent() and mailTabs.query({ active: true, currentWindow: true })
+ if (expected.isCurrentTab && expected.isCurrentWindow) {
+ let currentTab = await browser.mailTabs.getCurrent();
+ browser.test.assertEq(expected.tab.id, currentTab.id);
+
+ let currentTabs = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(1, currentTabs.length);
+ browser.test.assertEq(expected.tab.id, currentTabs[0].id);
+ }
+ }
+
+ let [accountId] = await window.waitForMessage();
+ let allTabs = await browser.tabs.query({});
+ let queryMailTabs = await browser.tabs.query({ mailTab: true });
+ let allMailTabs = await browser.mailTabs.query({});
+
+ browser.test.assertEq(8, allTabs.length);
+ browser.test.assertEq(6, queryMailTabs.length);
+ browser.test.assertEq(6, allMailTabs.length);
+
+ // Each window has an active tab.
+ browser.test.assertTrue(allMailTabs[2].active);
+ browser.test.assertTrue(allMailTabs[5].active);
+
+ // Check tabs of window #1.
+ browser.test.assertEq(accountId, allMailTabs[0].displayedFolder.accountId);
+ browser.test.assertEq("/", allMailTabs[0].displayedFolder.path);
+ browser.test.assertEq(accountId, allMailTabs[1].displayedFolder.accountId);
+ browser.test.assertEq("/test1", allMailTabs[1].displayedFolder.path);
+ browser.test.assertEq(accountId, allMailTabs[2].displayedFolder.accountId);
+ browser.test.assertEq("/test2", allMailTabs[2].displayedFolder.path);
+ // Check tabs of window #2 (active).
+ browser.test.assertEq(accountId, allMailTabs[3].displayedFolder.accountId);
+ browser.test.assertEq("/", allMailTabs[3].displayedFolder.path);
+ browser.test.assertEq(accountId, allMailTabs[4].displayedFolder.accountId);
+ browser.test.assertEq("/test1", allMailTabs[4].displayedFolder.path);
+ browser.test.assertEq(accountId, allMailTabs[5].displayedFolder.accountId);
+ browser.test.assertEq("/test2", allMailTabs[5].displayedFolder.path);
+
+ for (let mailTab of allMailTabs) {
+ await checkTab({
+ tab: mailTab,
+ isCurrentTab: [allMailTabs[2].id, allMailTabs[5].id].includes(
+ mailTab.id
+ ),
+ isCurrentWindow: mailTab.windowId == allMailTabs[5].windowId,
+ });
+ }
+
+ // get(id) should throw if id does not belong to a mail tab.
+ for (let tab of [allTabs[1], allTabs[5]]) {
+ await browser.test.assertRejects(
+ browser.mailTabs.get(tab.id),
+ `Invalid mail tab ID: ${tab.id}`,
+ "It rejects for invalid mail tab ID."
+ );
+ }
+
+ // Switch to the second mail tab in both windows.
+ for (let tab of [allMailTabs[1], allMailTabs[4]]) {
+ await browser.tabs.update(tab.id, { active: true });
+ // Check if the new active tab is returned.
+ await checkTab({
+ tab,
+ isCurrentTab: true,
+ isCurrentWindow: tab.id == allMailTabs[5].id,
+ });
+ }
+
+ // Switch active window to a non-mailtab, getCurrent() and a query for active tab should not return anything.
+ await browser.tabs.update(allTabs[5].id, { active: true });
+ let activeMailTab = await browser.mailTabs.getCurrent();
+ browser.test.assertEq(undefined, activeMailTab);
+ let activeMailTabs = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(0, activeMailTabs.length);
+
+ // A query over all windows should still return the active tab from the inactive window.
+ activeMailTabs = await browser.mailTabs.query({
+ active: true,
+ });
+ browser.test.assertEq(1, activeMailTabs.length);
+ browser.test.assertEq(allMailTabs[1].id, activeMailTabs[0].id);
+
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead"],
+ },
+ });
+
+ let window2 = await openNewMailWindow();
+ for (let win of [window, window2]) {
+ let winTabmail = win.document.getElementById("tabmail");
+ winTabmail.currentTabInfo.folder = rootFolder;
+ win.openContentTab("about:mozilla");
+ winTabmail.openTab("mail3PaneTab", { folderURI: subFolders.test1.URI });
+ await BrowserTestUtils.waitForEvent(
+ winTabmail.currentTabInfo.chromeBrowser,
+ "folderURIChanged",
+ false,
+ event => event.detail == subFolders.test1.URI
+ );
+ winTabmail.openTab("mail3PaneTab", { folderURI: subFolders.test2.URI });
+ await BrowserTestUtils.waitForEvent(
+ winTabmail.currentTabInfo.chromeBrowser,
+ "folderURIChanged",
+ false,
+ event => event.detail == subFolders.test2.URI
+ );
+ }
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ await BrowserTestUtils.closeWindow(window2);
+
+ tabmail.closeOtherTabs(0);
+ tabmail.currentTabInfo.folder = rootFolder;
+});
+
+add_task(async function test_setSelectedMessages() {
+ async function background() {
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+ let allTabs = await browser.tabs.query({});
+ let queryTabs = await browser.tabs.query({ mailTab: true });
+ let allMailTabs = await browser.mailTabs.query({});
+
+ let { messages: messages1 } = await browser.messages.list(
+ folders.find(f => f.path == "/test1")
+ );
+ browser.test.assertTrue(
+ messages1.length > 7,
+ "There should be more than 7 messages in /test1"
+ );
+
+ let { messages: messages2 } = await browser.messages.list(
+ folders.find(f => f.path == "/test2")
+ );
+ browser.test.assertTrue(
+ messages2.length > 4,
+ "There should be more than 4 messages in /test2"
+ );
+
+ browser.test.assertEq(3, allMailTabs.length);
+ browser.test.assertEq(5, allTabs.length);
+ browser.test.assertEq(3, queryTabs.length);
+
+ let foregroundTab = allMailTabs[1].id;
+ browser.test.assertEq(accountId, allMailTabs[1].displayedFolder.accountId);
+ browser.test.assertEq("/test1", allMailTabs[1].displayedFolder.path);
+ browser.test.assertTrue(allMailTabs[1].active);
+
+ let backgroundTab = allMailTabs[2].id;
+ browser.test.assertEq(accountId, allMailTabs[2].displayedFolder.accountId);
+ browser.test.assertEq("/", allMailTabs[2].displayedFolder.path);
+
+ // Check the initial real state.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+
+ // Change the selection in the foreground tab.
+ await browser.mailTabs.setSelectedMessages(foregroundTab, [
+ messages1[6].id,
+ messages1[7].id,
+ ]);
+ // Check the current real state.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+ // Check API return value of the foreground tab.
+ let { messages: readMessagesA } =
+ await browser.mailTabs.getSelectedMessages(foregroundTab);
+ window.assertDeepEqual(
+ [messages1[6].id, messages1[7].id],
+ readMessagesA.map(m => m.id)
+ );
+
+ // Change the selection in the background tab.
+ await browser.mailTabs.setSelectedMessages(backgroundTab, [
+ messages2[0].id,
+ messages2[3].id,
+ ]);
+ // Real state should be the same, since we're updating a background tab.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test1",
+ });
+ // Check unchanged API return value of the foreground tab.
+ let { messages: readMessagesB } =
+ await browser.mailTabs.getSelectedMessages(foregroundTab);
+ window.assertDeepEqual(
+ [messages1[6].id, messages1[7].id],
+ readMessagesB.map(m => m.id)
+ );
+ // Check API return value of the inactive background tab.
+ let { messages: readMessagesC } =
+ await browser.mailTabs.getSelectedMessages(backgroundTab);
+ window.assertDeepEqual(
+ [messages2[0].id, messages2[3].id],
+ readMessagesC.map(m => m.id)
+ );
+ // Switch to the background tab.
+ await browser.tabs.update(backgroundTab, { active: true });
+ // Check API return value of the background tab (now active).
+ let { messages: readMessagesD } =
+ await browser.mailTabs.getSelectedMessages(backgroundTab);
+ window.assertDeepEqual(
+ [messages2[0].id, messages2[3].id],
+ readMessagesD.map(m => m.id)
+ );
+ // Check real state, should now match the active background tab.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test2",
+ });
+ // Check unchanged API return value of the foreground tab (now inactive).
+ let { messages: readMessagesE } =
+ await browser.mailTabs.getSelectedMessages(foregroundTab);
+ window.assertDeepEqual(
+ [messages1[6].id, messages1[7].id],
+ readMessagesE.map(m => m.id)
+ );
+ // Switch back to the foreground tab.
+ await browser.tabs.update(foregroundTab, { active: true });
+
+ // Change the selection in the foreground tab.
+ await browser.mailTabs.setSelectedMessages(foregroundTab, [
+ messages2[2].id,
+ messages2[4].id,
+ ]);
+ // Check API return value of the foreground tab.
+ let { messages: readMessagesF } =
+ await browser.mailTabs.getSelectedMessages(foregroundTab);
+ window.assertDeepEqual(
+ [messages2[2].id, messages2[4].id],
+ readMessagesF.map(m => m.id)
+ );
+ // Check real state.
+ await window.sendMessage("checkRealLayout", {
+ messagePaneVisible: true,
+ folderPaneVisible: true,
+ displayedFolder: "/test2",
+ });
+ // Check API return value of the inactive background tab.
+ let { messages: readMessagesG } =
+ await browser.mailTabs.getSelectedMessages(backgroundTab);
+ window.assertDeepEqual(
+ [messages2[0].id, messages2[3].id],
+ readMessagesG.map(m => m.id)
+ );
+
+ // Clear selection in background tab.
+ await browser.mailTabs.setSelectedMessages(backgroundTab, []);
+ // Check API return value of the inactive background tab.
+ let { messages: readMessagesH } =
+ await browser.mailTabs.getSelectedMessages(backgroundTab);
+ browser.test.assertEq(0, readMessagesH.length);
+
+ // Clear selection in foreground tab.
+ await browser.mailTabs.setSelectedMessages(foregroundTab, []);
+ // Check API return value of the foreground tab.
+ let { messages: readMessagesI } =
+ await browser.mailTabs.getSelectedMessages(foregroundTab);
+ browser.test.assertEq(0, readMessagesI.length);
+
+ // Should throw if messages belong to different folders.
+ await browser.test.assertRejects(
+ browser.mailTabs.setSelectedMessages(foregroundTab, [
+ messages2[2].id,
+ messages1[4].id,
+ ]),
+ `Message ${messages2[2].id} and message ${messages1[4].id} are not in the same folder, cannot select them both.`,
+ "browser.mailTabs.setSelectedMessages() should reject, if the requested message do not belong to the same folder."
+ );
+
+ browser.test.notifyPass("mailTabs");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ extension.onMessage("checkRealLayout", async expected => {
+ await check3PaneState(
+ expected.folderPaneVisible,
+ expected.messagePaneVisible
+ );
+ Assert.equal(
+ "/" + (tabmail.currentTabInfo.folder.URI || "").split("/").pop(),
+ expected.displayedFolder,
+ "Should display the correct folder"
+ );
+ extension.sendMessage();
+ });
+
+ window.openContentTab("about:buildconfig");
+ window.openContentTab("about:mozilla");
+ tabmail.openTab("mail3PaneTab", { folderURI: subFolders.test1.URI });
+ tabmail.openTab("mail3PaneTab", {
+ folderURI: rootFolder.URI,
+ background: true,
+ });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.currentTabInfo.chromeBrowser,
+ "folderURIChanged",
+ false,
+ event => event.detail == subFolders.test1.URI
+ );
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("mailTabs");
+ await extension.unload();
+
+ tabmail.closeOtherTabs(0);
+ tabmail.currentTabInfo.folder = rootFolder;
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_mailTabs_mv3.js b/comm/mail/components/extensions/test/browser/browser_ext_mailTabs_mv3.js
new file mode 100644
index 0000000000..5cacd6e771
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_mailTabs_mv3.js
@@ -0,0 +1,162 @@
+/* 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/. */
+
+let account, rootFolder, subFolders;
+let tabmail = document.getElementById("tabmail");
+
+add_setup(async () => {
+ account = createAccount();
+ rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test1", null);
+ rootFolder.createSubfolder("test2", null);
+ subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test1, 10);
+ createMessages(subFolders.test2, 50);
+
+ tabmail.currentTabInfo.folder = rootFolder;
+
+ Services.prefs.setIntPref("extensions.webextensions.messagesPerPage", 10);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextensions.messagesPerPage");
+ });
+ await new Promise(resolve => executeSoon(resolve));
+});
+
+add_task(async function test_MV3_event_pages() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ for (let eventName of [
+ "onDisplayedFolderChanged",
+ "onSelectedMessagesChanged",
+ ]) {
+ browser.mailTabs[eventName].addListener((...args) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${eventName} received`, args);
+ }
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ browser_specific_settings: {
+ gecko: { id: "mailtabs@mochi.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "mailTabs.onDisplayedFolderChanged",
+ "mailTabs.onSelectedMessagesChanged",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Select a folder.
+
+ {
+ tabmail.currentTabInfo.folder = subFolders.test1;
+ let displayInfo = await extension.awaitMessage(
+ "onDisplayedFolderChanged received"
+ );
+ Assert.deepEqual(
+ [
+ {
+ active: true,
+ type: "mail",
+ },
+ { name: "test1", path: "/test1" },
+ ],
+ [
+ {
+ active: displayInfo[0].active,
+ type: displayInfo[0].type,
+ },
+ { name: displayInfo[1].name, path: displayInfo[1].path },
+ ],
+ "The primed onDisplayedFolderChanged event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+ }
+
+ // Select multiple messages.
+
+ {
+ let messages = [...subFolders.test1.messages].slice(0, 5);
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = messages.map(m =>
+ tabmail.currentAbout3Pane.gDBView.findIndexOfMsgHdr(m, false)
+ );
+ let displayInfo = await extension.awaitMessage(
+ "onSelectedMessagesChanged received"
+ );
+ Assert.deepEqual(
+ [
+ "Big Meeting Today",
+ "Small Party Tomorrow",
+ "Huge Shindig Yesterday",
+ "Tiny Wedding In a Fortnight",
+ "Red Document Needs Attention",
+ ],
+ displayInfo[1].messages.map(e => e.subject),
+ "The primed onSelectedMessagesChanged event should return the correct values"
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "mail",
+ },
+ {
+ active: displayInfo[0].active,
+ type: displayInfo[0].type,
+ },
+ "The primed onSelectedMessagesChanged event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_action.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_action.js
new file mode 100644
index 0000000000..03e255e5eb
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_action.js
@@ -0,0 +1,424 @@
+/* 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/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+
+ await enforceState({
+ mail: ["write-message", "spacer", "search-bar", "spacer"],
+ });
+ registerCleanupFunction(async () => {
+ await enforceState({});
+ });
+});
+
+async function subtest_action_menu(
+ testWindow,
+ target,
+ expectedInfo,
+ expectedTab,
+ manifest
+) {
+ function checkVisibility(menu, visible) {
+ let removeExtension = menu.querySelector(
+ ".customize-context-removeExtension"
+ );
+ let manageExtension = menu.querySelector(
+ ".customize-context-manageExtension"
+ );
+
+ info(`Check visibility: ${visible}`);
+ is(!removeExtension.hidden, visible, "Remove Extension should be visible");
+ is(!manageExtension.hidden, visible, "Manage Extension should be visible");
+ }
+
+ async function testContextMenuRemoveExtension(extension, menu, element) {
+ let name = "Generated extension";
+ let brand = Services.strings
+ .createBundle("chrome://branding/locale/brand.properties")
+ .GetStringFromName("brandShorterName");
+
+ info(
+ `Choosing 'Remove Extension' in ${menu.id} should show confirm dialog.`
+ );
+ await rightClick(menu, element);
+ await extension.awaitMessage("onShown");
+ let removeExtension = menu.querySelector(
+ ".customize-context-removeExtension"
+ );
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let promptPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ undefined,
+ {
+ async callback(promptWindow) {
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == promptWindow,
+ "waiting for prompt to become active"
+ );
+
+ let promptDocument = promptWindow.document;
+ // Check if the correct add-on is being removed.
+ is(promptDocument.title, `Remove ${name}?`);
+ if (
+ !Services.prefs.getBoolPref("prompts.windowPromptSubDialog", false)
+ ) {
+ is(
+ promptDocument.getElementById("infoBody").textContent,
+ `Remove ${name} as well as its configuration and data from ${brand}?`
+ );
+ }
+ let acceptButton = promptDocument
+ .querySelector("dialog")
+ .getButton("accept");
+ is(acceptButton.label, "Remove");
+ EventUtils.synthesizeMouseAtCenter(acceptButton, {}, promptWindow);
+ },
+ }
+ );
+ menu.activateItem(removeExtension);
+ await hiddenPromise;
+ await promptPromise;
+ }
+
+ async function testContextMenuManageExtension(extension, menu, element) {
+ let id = "menus@mochi.test";
+ let tabmail = window.document.getElementById("tabmail");
+
+ info(
+ `Choosing 'Manage Extension' in ${menu.id} should load the management page.`
+ );
+ await rightClick(menu, element);
+ await extension.awaitMessage("onShown");
+ let manageExtension = menu.querySelector(
+ ".customize-context-manageExtension"
+ );
+ let addonManagerPromise = contentTabOpenPromise(tabmail, "about:addons");
+ menu.activateItem(manageExtension);
+ let managerTab = await addonManagerPromise;
+
+ // Check the UI to make sure that the correct view is loaded.
+ let managerWindow = managerTab.linkedBrowser.contentWindow;
+ is(
+ managerWindow.gViewController.currentViewId,
+ `addons://detail/${encodeURIComponent(id)}`,
+ "Expected extension details view in about:addons"
+ );
+ // In HTML about:addons, the default view does not show the inline
+ // options browser, so we should not receive an "options-loaded" event.
+ // (if we do, the test will fail due to the unexpected message).
+
+ is(managerTab.linkedBrowser.currentURI.spec, "about:addons");
+ tabmail.closeTab(managerTab);
+ }
+
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let element = testWindow.document.querySelector(target.elementSelector);
+ let menu = testWindow.document.getElementById(target.menuId);
+
+ await rightClick(menu, element);
+ await checkVisibility(menu, true);
+ await checkShownEvent(
+ extension,
+ { menuIds: [target.context], contexts: [target.context, "all"] },
+ expectedTab
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let clickedPromise = checkClickedEvent(extension, expectedInfo, expectedTab);
+ menu.activateItem(
+ menu.querySelector(`#menus_mochi_test-menuitem-_${target.context}`)
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Test the non actionButton element for visibility of the management menu entries.
+ if (target.nonActionButtonSelector) {
+ let nonActionButtonElement = testWindow.document.querySelector(
+ target.nonActionButtonSelector
+ );
+ await rightClick(menu, nonActionButtonElement);
+ await checkVisibility(menu, false);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ }
+
+ await testContextMenuManageExtension(extension, menu, element);
+ await testContextMenuRemoveExtension(extension, menu, element);
+ await extension.unload();
+}
+add_task(async function test_browser_action_menu_mv2() {
+ await subtest_action_menu(
+ window,
+ {
+ menuId: "unifiedToolbarMenu",
+ elementSelector: `.unified-toolbar [extension="menus@mochi.test"]`,
+ context: "browser_action",
+ nonActionButtonSelector: `.unified-toolbar .write-message button`,
+ },
+ {
+ menuItemId: "browser_action",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 2,
+ browser_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+});
+add_task(async function test_message_display_action_menu_pane_mv2() {
+ let tab = await openMessageInTab(gMessage);
+ // No check for menu entries in nonActionButtonElements as the header-toolbar
+ // does not have a context menu associated.
+ await subtest_action_menu(
+ tab.chromeBrowser.contentWindow,
+ {
+ menuId: "header-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action",
+ },
+ { active: true, index: 1, mailTab: false },
+ {
+ manifest_version: 2,
+ message_display_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ window.document.getElementById("tabmail").closeTab(tab);
+});
+add_task(async function test_message_display_action_menu_window_mv2() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ // No check for menu entries in nonActionButtonElements as the header-toolbar
+ // does not have a context menu associated.
+ await subtest_action_menu(
+ testWindow.messageBrowser.contentWindow,
+ {
+ menuId: "header-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ message_display_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_mv2() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_menu(
+ testWindow,
+ {
+ menuId: "toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action",
+ nonActionButtonSelector: "#button-attach",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ compose_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_formattoolbar_mv2() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_menu(
+ testWindow,
+ {
+ menuId: "format-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ compose_action: {
+ default_title: "This is a test",
+ default_area: "formattoolbar",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+
+add_task(async function test_browser_action_menu_mv3() {
+ await subtest_action_menu(
+ window,
+ {
+ menuId: "unifiedToolbarMenu",
+ elementSelector: `.unified-toolbar [extension="menus@mochi.test"]`,
+ context: "action",
+ nonActionButtonSelector: `.unified-toolbar .write-message button`,
+ },
+ {
+ menuItemId: "action",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 3,
+ action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+});
+add_task(async function test_message_display_action_menu_pane_mv3() {
+ let tab = await openMessageInTab(gMessage);
+ // No check for menu entries in nonActionButtonElements as the header-toolbar
+ // does not have a context menu associated.
+ await subtest_action_menu(
+ tab.chromeBrowser.contentWindow,
+ {
+ menuId: "header-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action",
+ },
+ { active: true, index: 1, mailTab: false },
+ {
+ manifest_version: 3,
+ message_display_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ window.document.getElementById("tabmail").closeTab(tab);
+});
+add_task(async function test_message_display_action_menu_window_mv3() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ // No check for menu entries in nonActionButtonElements as the header-toolbar
+ // does not have a context menu associated.
+ await subtest_action_menu(
+ testWindow.messageBrowser.contentWindow,
+ {
+ menuId: "header-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ message_display_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_mv3() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_menu(
+ testWindow,
+ {
+ menuId: "toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action",
+ nonActionButtonSelector: "#button-attach",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ compose_action: {
+ default_title: "This is a test",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_formattoolbar_mv3() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_menu(
+ testWindow,
+ {
+ menuId: "format-toolbar-context-menu",
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ compose_action: {
+ default_title: "This is a test",
+ default_area: "formattoolbar",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_compose.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_compose.js
new file mode 100644
index 0000000000..6768f5dd60
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_compose.js
@@ -0,0 +1,179 @@
+/* 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/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+});
+
+async function subtest_compose(manifest) {
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ params.composeFields.body = await fetch(`${URL_BASE}/content_body.html`).then(
+ r => r.text()
+ );
+
+ for (let ordinal of ["first", "second", "third", "fourth"]) {
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+ attachment.name = `${ordinal}.txt`;
+ attachment.url = `data:text/plain,I'm the ${ordinal} attachment!`;
+ attachment.size = attachment.url.length - 16;
+ params.composeFields.addAttachment(attachment);
+ }
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "compose-editor-ready");
+ let composeDocument = composeWindow.document;
+ await focusWindow(composeWindow);
+
+ info("Test the message being composed.");
+
+ let messagePane = composeWindow.GetCurrentEditorElement();
+
+ await subtest_compose_body(
+ extension,
+ manifest.permissions?.includes("compose"),
+ messagePane,
+ "about:blank?compose",
+ {
+ active: true,
+ index: 0,
+ mailTab: false,
+ }
+ );
+
+ const chromeElementsMap = {
+ msgSubject: "composeSubject",
+ toAddrInput: "composeTo",
+ };
+ for (let elementId of Object.keys(chromeElementsMap)) {
+ info(`Test element ${elementId}.`);
+ await subtest_element(
+ extension,
+ manifest.permissions?.includes("compose"),
+ composeWindow.document.getElementById(elementId),
+ "about:blank?compose",
+ {
+ active: true,
+ index: 0,
+ mailTab: false,
+ fieldId: chromeElementsMap[elementId],
+ }
+ );
+ }
+
+ info("Test the attachments context menu.");
+
+ composeWindow.toggleAttachmentPane("show");
+ let menu = composeDocument.getElementById("msgComposeAttachmentItemContext");
+ let attachmentBucket = composeDocument.getElementById("attachmentBucket");
+
+ EventUtils.synthesizeMouseAtCenter(
+ attachmentBucket.itemChildren[0],
+ {},
+ composeWindow
+ );
+ await rightClick(menu, attachmentBucket.itemChildren[0], composeWindow);
+ Assert.ok(
+ menu.querySelector("#menus_mochi_test-menuitem-_compose_attachments")
+ );
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["compose_attachments"],
+ contexts: ["compose_attachments", "all"],
+ attachments: manifest.permissions?.includes("compose")
+ ? [{ name: "first.txt", size: 25 }]
+ : undefined,
+ },
+ { active: true, index: 0, mailTab: false }
+ );
+
+ attachmentBucket.addItemToSelection(attachmentBucket.itemChildren[3]);
+ await rightClick(menu, attachmentBucket.itemChildren[0], composeWindow);
+ Assert.ok(
+ menu.querySelector("#menus_mochi_test-menuitem-_compose_attachments")
+ );
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["compose_attachments"],
+ contexts: ["compose_attachments", "all"],
+ attachments: manifest.permissions?.includes("compose")
+ ? [
+ { name: "first.txt", size: 25 },
+ { name: "fourth.txt", size: 26 },
+ ]
+ : undefined,
+ },
+ { active: true, index: 0, mailTab: false }
+ );
+
+ await extension.unload();
+
+ await BrowserTestUtils.closeWindow(composeWindow);
+}
+add_task(async function test_compose_mv2() {
+ return subtest_compose({
+ manifest_version: 2,
+ permissions: ["compose"],
+ });
+});
+add_task(async function test_compose_no_permissions_mv2() {
+ return subtest_compose({
+ manifest_version: 2,
+ });
+});
+add_task(async function test_compose_mv3() {
+ return subtest_compose({
+ manifest_version: 3,
+ permissions: ["compose"],
+ });
+});
+add_task(async function test_compose_no_permissions_mv3() {
+ return subtest_compose({
+ manifest_version: 3,
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_content.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_content.js
new file mode 100644
index 0000000000..27aac6d5d7
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_content.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/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+ await ensure_table_view();
+});
+
+add_task(async function test_content_mv2() {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: gFolders[0].URI,
+ });
+
+ let oldPref = Services.prefs.getStringPref("mailnews.start_page.url");
+ Services.prefs.setStringPref(
+ "mailnews.start_page.url",
+ `${URL_BASE}/content.html`
+ );
+
+ let loadPromise = BrowserTestUtils.browserLoaded(about3Pane.webBrowser);
+ window.goDoCommand("cmd_goStartPage");
+ await loadPromise;
+
+ let extension = await getMenuExtension({
+ manifest_version: 2,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("menus-created");
+ await subtest_content(
+ extension,
+ true,
+ about3Pane.webBrowser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 0,
+ mailTab: true,
+ }
+ );
+
+ await extension.unload();
+
+ Services.prefs.setStringPref("mailnews.start_page.url", oldPref);
+});
+add_task(async function test_content_tab_mv2() {
+ let tab = window.openContentTab(`${URL_BASE}/content.html`);
+ await awaitBrowserLoaded(tab.browser);
+
+ let extension = await getMenuExtension({
+ manifest_version: 2,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ await subtest_content(
+ extension,
+ true,
+ tab.browser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 1,
+ mailTab: false,
+ }
+ );
+
+ await extension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(0);
+});
+add_task(async function test_content_window_mv2() {
+ let extensionWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.openDialog(
+ "chrome://messenger/content/extensionPopup.xhtml",
+ "_blank",
+ "width=800,height=500,resizable",
+ `${URL_BASE}/content.html`
+ );
+ let extensionWindow = await extensionWindowPromise;
+ await focusWindow(extensionWindow);
+ await awaitBrowserLoaded(
+ extensionWindow.browser,
+ url => url != "about:blank"
+ );
+
+ let extension = await getMenuExtension({
+ manifest_version: 2,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ await subtest_content(
+ extension,
+ true,
+ extensionWindow.browser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 0,
+ mailTab: false,
+ }
+ );
+
+ await extension.unload();
+
+ await BrowserTestUtils.closeWindow(extensionWindow);
+});
+add_task(async function test_content_mv3() {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: gFolders[0].URI,
+ });
+
+ let oldPref = Services.prefs.getStringPref("mailnews.start_page.url");
+ Services.prefs.setStringPref(
+ "mailnews.start_page.url",
+ `${URL_BASE}/content.html`
+ );
+
+ let loadPromise = BrowserTestUtils.browserLoaded(about3Pane.webBrowser);
+ window.goDoCommand("cmd_goStartPage");
+ await loadPromise;
+
+ let extension = await getMenuExtension({
+ manifest_version: 3,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("menus-created");
+ await subtest_content(
+ extension,
+ true,
+ about3Pane.webBrowser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 0,
+ mailTab: true,
+ }
+ );
+
+ await extension.unload();
+
+ Services.prefs.setStringPref("mailnews.start_page.url", oldPref);
+});
+add_task(async function test_content_tab_mv3() {
+ let tab = window.openContentTab(`${URL_BASE}/content.html`);
+ await awaitBrowserLoaded(tab.browser);
+
+ let extension = await getMenuExtension({
+ manifest_version: 3,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ await subtest_content(
+ extension,
+ true,
+ tab.browser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 1,
+ mailTab: false,
+ }
+ );
+
+ await extension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(0);
+});
+add_task(async function test_content_window_mv3() {
+ let extensionWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.openDialog(
+ "chrome://messenger/content/extensionPopup.xhtml",
+ "_blank",
+ "width=800,height=500,resizable",
+ `${URL_BASE}/content.html`
+ );
+ let extensionWindow = await extensionWindowPromise;
+ await focusWindow(extensionWindow);
+ await awaitBrowserLoaded(
+ extensionWindow.browser,
+ url => url != "about:blank"
+ );
+
+ let extension = await getMenuExtension({
+ manifest_version: 3,
+ host_permissions: ["<all_urls>"],
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ await subtest_content(
+ extension,
+ true,
+ extensionWindow.browser,
+ `${URL_BASE}/content.html`,
+ {
+ active: true,
+ index: 0,
+ mailTab: false,
+ }
+ );
+
+ await extension.unload();
+
+ await BrowserTestUtils.closeWindow(extensionWindow);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_folder_pane.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_folder_pane.js
new file mode 100644
index 0000000000..7f55394473
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_folder_pane.js
@@ -0,0 +1,97 @@
+/* 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/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+});
+
+async function subtest_folder_pane(manifest) {
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ let folderTree = about3Pane.document.getElementById("folderTree");
+ let menu = about3Pane.document.getElementById("folderPaneContext");
+ await rightClick(menu, folderTree.rows[1].querySelector(".container"));
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_folder_pane"));
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["folder_pane"],
+ contexts: ["folder_pane", "all"],
+ selectedFolder: manifest?.permissions?.includes("accountsRead")
+ ? { accountId: gAccount.key, path: "/Trash" }
+ : undefined,
+ },
+ { active: true, index: 0, mailTab: true }
+ );
+
+ await rightClick(menu, folderTree.rows[0].querySelector(".container"));
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_folder_pane"));
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["folder_pane"],
+ contexts: ["folder_pane", "all"],
+ selectedAccount: manifest?.permissions?.includes("accountsRead")
+ ? { id: gAccount.key, type: "none" }
+ : undefined,
+ },
+ { active: true, index: 0, mailTab: true }
+ );
+
+ await extension.unload();
+}
+add_task(async function test_folder_pane_mv2() {
+ return subtest_folder_pane({
+ manifest_version: 2,
+ permissions: ["accountsRead"],
+ });
+});
+add_task(async function test_folder_pane_no_permissions_mv2() {
+ return subtest_folder_pane({
+ manifest_version: 2,
+ });
+});
+add_task(async function test_folder_pane_mv3() {
+ return subtest_folder_pane({
+ manifest_version: 3,
+ permissions: ["accountsRead"],
+ });
+});
+add_task(async function test_folder_pane_no_permissions_mv3() {
+ return subtest_folder_pane({
+ manifest_version: 3,
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_message_panes.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_message_panes.js
new file mode 100644
index 0000000000..fc851aa09d
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_message_panes.js
@@ -0,0 +1,180 @@
+/* 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/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+ await ensure_table_view();
+});
+
+async function subtest_message_panes(manifest) {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: gFolders[0].URI,
+ });
+
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ info("Test the thread pane in the 3-pane tab.");
+
+ let threadTree = about3Pane.document.getElementById("threadTree");
+ let menu = about3Pane.document.getElementById("mailContext");
+ threadTree.selectedIndex = 0;
+ await rightClick(menu, threadTree.getRowAtIndex(0));
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_message_list"));
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["message_list"],
+ contexts: ["message_list", "all"],
+ displayedFolder: manifest?.permissions?.includes("accountsRead")
+ ? { accountId: gAccount.key, path: "/Trash" }
+ : undefined,
+ selectedMessages: manifest?.permissions?.includes("messagesRead")
+ ? { id: null, messages: [{ subject: gMessage.subject }] }
+ : undefined,
+ },
+ { active: true, index: 0, mailTab: true }
+ );
+
+ info("Test the message pane in the 3-pane tab.");
+
+ let messagePane =
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ await subtest_content(
+ extension,
+ manifest?.permissions?.includes("messagesRead"),
+ messagePane,
+ /^mailbox\:/,
+ {
+ active: true,
+ index: 0,
+ mailTab: true,
+ }
+ );
+
+ about3Pane.threadTree.selectedIndices = [];
+ await awaitBrowserLoaded(messagePane, "about:blank");
+
+ info("Test the message pane in a tab.");
+
+ await openMessageInTab(gMessage);
+ messagePane = tabmail.currentAboutMessage.getMessagePaneBrowser();
+
+ await subtest_content(
+ extension,
+ manifest?.permissions?.includes("messagesRead"),
+ messagePane,
+ /^mailbox\:/,
+ {
+ active: true,
+ index: 1,
+ mailTab: false,
+ }
+ );
+
+ tabmail.closeOtherTabs(0);
+
+ info("Test the message pane in a separate window.");
+
+ let displayWindow = await openMessageInWindow(gMessage);
+ let displayDocument = displayWindow.document;
+ menu = displayDocument.getElementById("mailContext");
+ messagePane = displayDocument
+ .getElementById("messageBrowser")
+ .contentWindow.getMessagePaneBrowser();
+
+ await subtest_content(
+ extension,
+ manifest?.permissions?.includes("messagesRead"),
+ messagePane,
+ /^mailbox\:/,
+ {
+ active: true,
+ index: 0,
+ mailTab: false,
+ }
+ );
+
+ await extension.unload();
+
+ await BrowserTestUtils.closeWindow(displayWindow);
+}
+add_task(async function test_message_panes_mv2() {
+ return subtest_message_panes({
+ manifest_version: 2,
+ permissions: ["accountsRead", "messagesRead"],
+ });
+});
+add_task(async function test_message_panes_no_accounts_permission_mv2() {
+ return subtest_message_panes({
+ manifest_version: 2,
+ permissions: ["messagesRead"],
+ });
+});
+add_task(async function test_message_panes_no_messages_permission_mv2() {
+ return subtest_message_panes({
+ manifest_version: 2,
+ permissions: ["accountsRead"],
+ });
+});
+add_task(async function test_message_panes_no_permissions_mv2() {
+ return subtest_message_panes({
+ manifest_version: 2,
+ });
+});
+add_task(async function test_message_panes_mv3() {
+ return subtest_message_panes({
+ manifest_version: 3,
+ permissions: ["accountsRead", "messagesRead"],
+ });
+});
+add_task(async function test_message_panes_no_accounts_permission_mv3() {
+ return subtest_message_panes({
+ manifest_version: 3,
+ permissions: ["messagesRead"],
+ });
+});
+add_task(async function test_message_panes_no_messages_permission_mv3() {
+ return subtest_message_panes({
+ manifest_version: 3,
+ permissions: ["accountsRead"],
+ });
+});
+add_task(async function test_message_panes_no_permissions_mv3() {
+ return subtest_message_panes({
+ manifest_version: 3,
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tabs.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tabs.js
new file mode 100644
index 0000000000..b957548d92
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tabs.js
@@ -0,0 +1,77 @@
+/* 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/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+});
+
+async function subtest_tab(manifest) {
+ async function checkTabEvent(index, active, mailTab) {
+ await rightClick(menu, tabs[index]);
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_tab"));
+ menu.hidePopup();
+
+ await checkShownEvent(
+ extension,
+ { menuIds: ["tab"], contexts: ["tab"] },
+ { active, index, mailTab }
+ );
+ }
+
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let tabmail = document.getElementById("tabmail");
+ window.openContentTab("about:config");
+ window.openContentTab("about:mozilla");
+ tabmail.openTab("mail3PaneTab", { folderURI: gFolders[0].URI });
+
+ let tabs = document.getElementById("tabmail-tabs").allTabs;
+ let menu = document.getElementById("tabContextMenu");
+
+ await checkTabEvent(0, false, true);
+ await checkTabEvent(1, false, false);
+ await checkTabEvent(2, false, false);
+ await checkTabEvent(3, true, true);
+
+ await extension.unload();
+
+ tabmail.closeOtherTabs(0);
+}
+add_task(async function test_tab_mv2() {
+ await subtest_tab({
+ manifest_version: 2,
+ });
+});
+add_task(async function test_tab_mv3() {
+ await subtest_tab({
+ manifest_version: 3,
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tools_main_menu.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tools_main_menu.js
new file mode 100644
index 0000000000..d9aed69ba3
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_context_tools_main_menu.js
@@ -0,0 +1,156 @@
+/* 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/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ document.getElementById("tabmail").currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.URI,
+ });
+});
+
+async function subtest_tools_menu(
+ testWindow,
+ expectedInfo,
+ expectedTab,
+ manifest
+) {
+ let extension = await getMenuExtension(manifest);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let element = testWindow.document.getElementById("tasksMenu");
+ let menu = testWindow.document.getElementById("taskPopup");
+ await leftClick(menu, element);
+ await checkShownEvent(
+ extension,
+ { menuIds: ["tools_menu"], contexts: ["tools_menu"] },
+ expectedTab
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let clickedPromise = checkClickedEvent(extension, expectedInfo, expectedTab);
+ menu.activateItem(
+ menu.querySelector("#menus_mochi_test-menuitem-_tools_menu")
+ );
+ await clickedPromise;
+ await hiddenPromise;
+ await extension.unload();
+}
+add_task(async function test_tools_menu_mv2() {
+ let toolbar = window.document.getElementById("toolbar-menubar");
+ let initialState = toolbar.getAttribute("inactive");
+ toolbar.setAttribute("inactive", "false");
+
+ await subtest_tools_menu(
+ window,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 2,
+ }
+ );
+
+ toolbar.setAttribute("inactive", initialState);
+}).__skipMe = AppConstants.platform == "macosx";
+add_task(async function test_compose_tools_menu_mv2() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_tools_menu(
+ testWindow,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+}).__skipMe = AppConstants.platform == "macosx";
+add_task(async function test_messagewindow_tools_menu_mv2() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_tools_menu(
+ testWindow,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+}).__skipMe = AppConstants.platform == "macosx";
+add_task(async function test_tools_menu_mv3() {
+ let toolbar = window.document.getElementById("toolbar-menubar");
+ let initialState = toolbar.getAttribute("inactive");
+ toolbar.setAttribute("inactive", "false");
+
+ await subtest_tools_menu(
+ window,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 3,
+ }
+ );
+
+ toolbar.setAttribute("inactive", initialState);
+}).__skipMe = AppConstants.platform == "macosx";
+add_task(async function test_compose_tools_menu_mv3() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_tools_menu(
+ testWindow,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+}).__skipMe = AppConstants.platform == "macosx";
+add_task(async function test_messagewindow_tools_menu_mv3() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_tools_menu(
+ testWindow,
+ {
+ menuItemId: "tools_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+}).__skipMe = AppConstants.platform == "macosx";
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_message_one_attachment.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_message_one_attachment.js
new file mode 100644
index 0000000000..7c5612ffc6
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_message_one_attachment.js
@@ -0,0 +1,395 @@
+/* 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/. */
+
+let gAccount, gFolders, gMessage, gExpectedAttachments;
+
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+const URL_BASE =
+ "http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data";
+
+var tabmail = document.getElementById("tabmail");
+var about3Pane = tabmail.currentAbout3Pane;
+var messagePane =
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser();
+
+/**
+ * Right-click on something and wait for the context menu to appear.
+ * For elements in the parent process only.
+ *
+ * @param {Element} menu - The <menu> that should appear.
+ * @param {Element} element - The element to be clicked on.
+ * @returns {Promise} A promise that resolves when the menu appears.
+ */
+function rightClick(menu, element, win) {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(element, { type: "contextmenu" }, win);
+ return shownPromise;
+}
+
+/**
+ * Check the parameters of a browser.onShown event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {Array} expectedInfo.menuIds
+ * @param {Array} expectedInfo.contexts
+ * @param {Array?} expectedInfo.attachments
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkShownEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onShown");
+ Assert.deepEqual(info.menuIds, expectedInfo.menuIds);
+ Assert.deepEqual(info.contexts, expectedInfo.contexts);
+
+ Assert.equal(
+ !!info.attachments,
+ !!expectedInfo.attachments,
+ "attachments in info"
+ );
+ if (expectedInfo.attachments) {
+ for (let i = 0; i < expectedInfo.attachments.length; i++) {
+ Assert.equal(info.attachments[i].name, expectedInfo.attachments[i].name);
+ Assert.equal(info.attachments[i].size, expectedInfo.attachments[i].size);
+ Assert.equal(
+ info.attachments[i].partName,
+ expectedInfo.attachments[i].partName
+ );
+ Assert.equal(
+ info.attachments[i].contentType,
+ expectedInfo.attachments[i].contentType
+ );
+ }
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+/**
+ * Check the parameters of a browser.onClicked event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {string?} expectedInfo.menuItemId
+ * @param {Array?} expectedInfo.attachments
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkClickedEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onClicked");
+
+ Assert.equal(
+ !!info.attachments,
+ !!expectedInfo.attachments,
+ "attachments in info"
+ );
+ if (expectedInfo.attachments) {
+ for (let i = 0; i < expectedInfo.attachments.length; i++) {
+ Assert.equal(info.attachments[i].name, expectedInfo.attachments[i].name);
+ Assert.equal(info.attachments[i].size, expectedInfo.attachments[i].size);
+ Assert.equal(
+ info.attachments[i].partName,
+ expectedInfo.attachments[i].partName
+ );
+ Assert.equal(
+ info.attachments[i].contentType,
+ expectedInfo.attachments[i].contentType
+ );
+ }
+ }
+
+ if (expectedInfo.menuItemId) {
+ Assert.equal(info.menuItemId, expectedInfo.menuItemId, "menuItemId");
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+function getExtensionDetails(...permissions) {
+ return {
+ files: {
+ "background.js": async () => {
+ for (let context of [
+ "message_attachments",
+ "all_message_attachments",
+ ]) {
+ await new Promise(resolve => {
+ browser.menus.create(
+ {
+ id: context,
+ title: context,
+ contexts: [context],
+ },
+ resolve
+ );
+ });
+ }
+
+ browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ browser.menus.onClicked.addListener((...args) => {
+ browser.test.sendMessage("onClicked", args);
+ });
+ browser.test.sendMessage("menus-created");
+ },
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "menus@mochi.test",
+ },
+ },
+ background: { scripts: ["background.js"] },
+ permissions: [...permissions, "menus"],
+ },
+ useAddonManager: "temporary",
+ };
+}
+
+add_setup(async function () {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ await createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ attachments: [
+ {
+ body: "I am an text attachment.",
+ filename: "test1.txt",
+ contentType: "text/plain",
+ },
+ ],
+ });
+ await createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ attachments: [
+ {
+ body: "I am an text attachment.",
+ filename: "test1.txt",
+ contentType: "text/plain",
+ },
+ {
+ body: "I am another but larger attachment. ",
+ filename: "test2.txt",
+ contentType: "text/plain",
+ },
+ ],
+ });
+
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gFolders[0].URI,
+ messagePaneVisible: true,
+ });
+
+ gExpectedAttachments = [
+ {
+ name: "test1.txt",
+ size: 24,
+ contentType: "text/plain",
+ partName: "1.2",
+ },
+ {
+ name: "test2.txt",
+ size: 36,
+ contentType: "text/plain",
+ partName: "1.3",
+ },
+ ];
+});
+
+// Test a click on an attachment item.
+async function subtest_attachmentItem(
+ extension,
+ win,
+ element,
+ expectedContext,
+ expectedAttachments
+) {
+ let menu = element.ownerGlobal.document.getElementById(
+ expectedContext == "message_attachments"
+ ? "attachmentItemContext"
+ : "attachmentListContext"
+ );
+
+ let expectedShowData = {
+ menuIds: [expectedContext],
+ contexts: [expectedContext, "all"],
+ attachments: expectedAttachments,
+ };
+ let expectedClickData = {
+ attachments: expectedAttachments,
+ };
+ let expectedTab = { active: true, index: 0, mailTab: false };
+
+ let showEventPromise = checkShownEvent(
+ extension,
+ expectedShowData,
+ expectedTab
+ );
+ await rightClick(menu, element, win);
+ let menuItem = menu.querySelector(
+ `#menus_mochi_test-menuitem-_${expectedContext}`
+ );
+ await showEventPromise;
+ Assert.ok(menuItem);
+
+ let clickEventPromise = checkClickedEvent(
+ extension,
+ expectedClickData,
+ expectedTab
+ );
+ menu.activateItem(menuItem);
+ await clickEventPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+}
+
+async function subtest_attachments(
+ extension,
+ win,
+ expectedContext,
+ expectedAttachments
+) {
+ // Test clicking on the attachmentInfo element.
+ let attachmentInfo = win.document.getElementById("attachmentInfo");
+ await subtest_attachmentItem(
+ extension,
+ win,
+ attachmentInfo,
+ expectedContext,
+ expectedAttachments
+ );
+
+ if (expectedAttachments) {
+ win.toggleAttachmentList(true);
+ let attachmentList = win.document.getElementById("attachmentList");
+ Assert.equal(
+ attachmentList.children.length,
+ expectedAttachments.length,
+ "Should see the expected number of attachments."
+ );
+
+ // Test clicking on the individual attachment elements.
+ for (let i = 0; i < attachmentList.children.length; i++) {
+ // Select the attachment.
+ attachmentList.selectItem(attachmentList.children[i]);
+
+ // Run context click check.
+ await subtest_attachmentItem(
+ extension,
+ win,
+ attachmentList.children[i],
+ "message_attachments",
+ [expectedAttachments[i]]
+ );
+ }
+ }
+}
+
+async function subtest_message_panes(
+ permissions,
+ expectedContext,
+ expectedAttachments = null
+) {
+ let extensionDetails = getExtensionDetails(...permissions);
+
+ info("Test the message pane in the 3-pane tab.");
+
+ let extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ tabmail.currentAboutMessage,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+
+ info("Test the message pane in a tab.");
+
+ await openMessageInTab(gMessage);
+ extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ tabmail.currentAboutMessage,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+ tabmail.closeOtherTabs(0);
+
+ info("Test the message pane in a separate window.");
+
+ let displayWindow = await openMessageInWindow(gMessage);
+ extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ displayWindow.messageBrowser.contentWindow,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(displayWindow);
+}
+
+// Tests using a message with one attachment.
+add_task(async function test_message_panes() {
+ gMessage = [...gFolders[0].messages][0];
+ about3Pane.threadTree.selectedIndex = 0;
+ await promiseMessageLoaded(messagePane, gMessage);
+
+ await subtest_message_panes(
+ ["accountsRead", "messagesRead"],
+ "message_attachments",
+ [gExpectedAttachments[0]]
+ );
+});
+add_task(async function test_message_panes_no_accounts_permission() {
+ return subtest_message_panes(["messagesRead"], "message_attachments", [
+ gExpectedAttachments[0],
+ ]);
+});
+add_task(async function test_message_panes_no_messages_permission() {
+ return subtest_message_panes(["accountsRead"], "message_attachments");
+});
+add_task(async function test_message_panes_no_permissions() {
+ return subtest_message_panes([], "message_attachments");
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_message_two_attachments.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_message_two_attachments.js
new file mode 100644
index 0000000000..7ae4d72a29
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_message_two_attachments.js
@@ -0,0 +1,397 @@
+/* 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/. */
+
+let gAccount, gFolders, gMessage, gExpectedAttachments;
+
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+const URL_BASE =
+ "http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data";
+
+var tabmail = document.getElementById("tabmail");
+var about3Pane = tabmail.currentAbout3Pane;
+var messagePane =
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser();
+
+/**
+ * Right-click on something and wait for the context menu to appear.
+ * For elements in the parent process only.
+ *
+ * @param {Element} menu - The <menu> that should appear.
+ * @param {Element} element - The element to be clicked on.
+ * @returns {Promise} A promise that resolves when the menu appears.
+ */
+function rightClick(menu, element, win) {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(element, { type: "contextmenu" }, win);
+ return shownPromise;
+}
+
+/**
+ * Check the parameters of a browser.onShown event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {Array} expectedInfo.menuIds
+ * @param {Array} expectedInfo.contexts
+ * @param {Array?} expectedInfo.attachments
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkShownEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onShown");
+ Assert.deepEqual(info.menuIds, expectedInfo.menuIds);
+ Assert.deepEqual(info.contexts, expectedInfo.contexts);
+
+ Assert.equal(
+ !!info.attachments,
+ !!expectedInfo.attachments,
+ "attachments in info"
+ );
+ if (expectedInfo.attachments) {
+ for (let i = 0; i < expectedInfo.attachments.length; i++) {
+ Assert.equal(info.attachments[i].name, expectedInfo.attachments[i].name);
+ Assert.equal(info.attachments[i].size, expectedInfo.attachments[i].size);
+ Assert.equal(
+ info.attachments[i].partName,
+ expectedInfo.attachments[i].partName
+ );
+ Assert.equal(
+ info.attachments[i].contentType,
+ expectedInfo.attachments[i].contentType
+ );
+ }
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+/**
+ * Check the parameters of a browser.onClicked event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {string?} expectedInfo.menuItemId
+ * @param {Array?} expectedInfo.attachments
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkClickedEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onClicked");
+
+ Assert.equal(
+ !!info.attachments,
+ !!expectedInfo.attachments,
+ "attachments in info"
+ );
+ if (expectedInfo.attachments) {
+ for (let i = 0; i < expectedInfo.attachments.length; i++) {
+ Assert.equal(info.attachments[i].name, expectedInfo.attachments[i].name);
+ Assert.equal(info.attachments[i].size, expectedInfo.attachments[i].size);
+ Assert.equal(
+ info.attachments[i].partName,
+ expectedInfo.attachments[i].partName
+ );
+ Assert.equal(
+ info.attachments[i].contentType,
+ expectedInfo.attachments[i].contentType
+ );
+ }
+ }
+
+ if (expectedInfo.menuItemId) {
+ Assert.equal(info.menuItemId, expectedInfo.menuItemId, "menuItemId");
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+function getExtensionDetails(...permissions) {
+ return {
+ files: {
+ "background.js": async () => {
+ for (let context of [
+ "message_attachments",
+ "all_message_attachments",
+ ]) {
+ await new Promise(resolve => {
+ browser.menus.create(
+ {
+ id: context,
+ title: context,
+ contexts: [context],
+ },
+ resolve
+ );
+ });
+ }
+
+ browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ browser.menus.onClicked.addListener((...args) => {
+ browser.test.sendMessage("onClicked", args);
+ });
+ browser.test.sendMessage("menus-created");
+ },
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "menus@mochi.test",
+ },
+ },
+ background: { scripts: ["background.js"] },
+ permissions: [...permissions, "menus"],
+ },
+ useAddonManager: "temporary",
+ };
+}
+
+add_setup(async () => {
+ await Services.search.init();
+
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ await createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ attachments: [
+ {
+ body: "I am an text attachment.",
+ filename: "test1.txt",
+ contentType: "text/plain",
+ },
+ ],
+ });
+ await createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ attachments: [
+ {
+ body: "I am an text attachment.",
+ filename: "test1.txt",
+ contentType: "text/plain",
+ },
+ {
+ body: "I am another but larger attachment. ",
+ filename: "test2.txt",
+ contentType: "text/plain",
+ },
+ ],
+ });
+
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gFolders[0].URI,
+ messagePaneVisible: true,
+ });
+
+ gExpectedAttachments = [
+ {
+ name: "test1.txt",
+ size: 24,
+ contentType: "text/plain",
+ partName: "1.2",
+ },
+ {
+ name: "test2.txt",
+ size: 36,
+ contentType: "text/plain",
+ partName: "1.3",
+ },
+ ];
+});
+
+// Test a click on an attachment item.
+async function subtest_attachmentItem(
+ extension,
+ win,
+ element,
+ expectedContext,
+ expectedAttachments
+) {
+ let menu = element.ownerGlobal.document.getElementById(
+ expectedContext == "message_attachments"
+ ? "attachmentItemContext"
+ : "attachmentListContext"
+ );
+
+ let expectedShowData = {
+ menuIds: [expectedContext],
+ contexts: [expectedContext, "all"],
+ attachments: expectedAttachments,
+ };
+ let expectedClickData = {
+ attachments: expectedAttachments,
+ };
+ let expectedTab = { active: true, index: 0, mailTab: false };
+
+ let showEventPromise = checkShownEvent(
+ extension,
+ expectedShowData,
+ expectedTab
+ );
+ await rightClick(menu, element, win);
+ let menuItem = menu.querySelector(
+ `#menus_mochi_test-menuitem-_${expectedContext}`
+ );
+ await showEventPromise;
+ Assert.ok(menuItem);
+
+ let clickEventPromise = checkClickedEvent(
+ extension,
+ expectedClickData,
+ expectedTab
+ );
+ menu.activateItem(menuItem);
+ await clickEventPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+}
+
+async function subtest_attachments(
+ extension,
+ win,
+ expectedContext,
+ expectedAttachments
+) {
+ // Test clicking on the attachmentInfo element.
+ let attachmentInfo = win.document.getElementById("attachmentInfo");
+ await subtest_attachmentItem(
+ extension,
+ win,
+ attachmentInfo,
+ expectedContext,
+ expectedAttachments
+ );
+
+ if (expectedAttachments) {
+ win.toggleAttachmentList(true);
+ let attachmentList = win.document.getElementById("attachmentList");
+ Assert.equal(
+ attachmentList.children.length,
+ expectedAttachments.length,
+ "Should see the expected number of attachments."
+ );
+
+ // Test clicking on the individual attachment elements.
+ for (let i = 0; i < attachmentList.children.length; i++) {
+ // Select the attachment.
+ attachmentList.selectItem(attachmentList.children[i]);
+
+ // Run context click check.
+ await subtest_attachmentItem(
+ extension,
+ win,
+ attachmentList.children[i],
+ "message_attachments",
+ [expectedAttachments[i]]
+ );
+ }
+ }
+}
+
+async function subtest_message_panes(
+ permissions,
+ expectedContext,
+ expectedAttachments = null
+) {
+ let extensionDetails = getExtensionDetails(...permissions);
+
+ info("Test the message pane in the 3-pane tab.");
+
+ let extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ tabmail.currentAboutMessage,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+
+ info("Test the message pane in a tab.");
+
+ await openMessageInTab(gMessage);
+ extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ tabmail.currentAboutMessage,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+ tabmail.closeOtherTabs(0);
+
+ info("Test the message pane in a separate window.");
+
+ let displayWindow = await openMessageInWindow(gMessage);
+ extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+ await subtest_attachments(
+ extension,
+ displayWindow.messageBrowser.contentWindow,
+ expectedContext,
+ expectedAttachments
+ );
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(displayWindow);
+}
+
+// Tests using a message with two attachment.
+add_task(async function test_message_panes() {
+ gMessage = [...gFolders[0].messages][1];
+ about3Pane.threadTree.selectedIndex = 1;
+ await promiseMessageLoaded(messagePane, gMessage);
+
+ await subtest_message_panes(
+ ["accountsRead", "messagesRead"],
+ "all_message_attachments",
+ gExpectedAttachments
+ );
+});
+add_task(async function test_message_panes_no_accounts_permission() {
+ return subtest_message_panes(
+ ["messagesRead"],
+ "all_message_attachments",
+ gExpectedAttachments
+ );
+});
+add_task(async function test_message_panes_no_messages_permission() {
+ return subtest_message_panes(["accountsRead"], "all_message_attachments");
+});
+add_task(async function test_message_panes_no_permissions() {
+ return subtest_message_panes([], "all_message_attachments");
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_popup_action.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_popup_action.js
new file mode 100644
index 0000000000..4d0660597a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_popup_action.js
@@ -0,0 +1,405 @@
+/* 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/. */
+
+// Load subscript shared with all menu tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_menus.js", gTestPath).href,
+ this
+);
+
+let gAccount, gFolders, gMessage;
+add_setup(async () => {
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ gFolders = gAccount.incomingServer.rootFolder.subFolders;
+ createMessages(gFolders[0], {
+ count: 1,
+ body: {
+ contentType: "text/html",
+ body: await fetch(`${URL_BASE}/content.html`).then(r => r.text()),
+ },
+ });
+ gMessage = [...gFolders[0].messages][0];
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+async function subtest_action_popup_menu(
+ testWindow,
+ target,
+ expectedInfo,
+ expectedTab,
+ manifest
+) {
+ let extension = await getMenuExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("menus-created");
+
+ let element = testWindow.document.querySelector(target.elementSelector);
+ let menu = element.querySelector("menupopup");
+
+ await leftClick(menu, element);
+ await checkShownEvent(
+ extension,
+ { menuIds: [target.context], contexts: [target.context, "all"] },
+ expectedTab
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let clickedPromise = checkClickedEvent(extension, expectedInfo, expectedTab);
+ menu.activateItem(
+ menu.querySelector(`#menus_mochi_test-menuitem-_${target.context}`)
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ await extension.unload();
+}
+
+add_task(async function test_browser_action_menu_popup_mv2() {
+ await subtest_action_popup_menu(
+ window,
+ {
+ elementSelector: `.unified-toolbar [extension="menus@mochi.test"]`,
+ context: "browser_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "browser_action_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 2,
+ browser_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+});
+add_task(async function test_browser_action_menu_popup_message_window_mv2() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-browserAction-toolbarbutton",
+ context: "browser_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "browser_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ browser_action: {
+ default_title: "This is a test",
+ default_windows: ["messageDisplay"],
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_message_display_action_menu_popup_pane_mv2() {
+ let tabmail = document.getElementById("tabmail");
+ let aboutMessage = tabmail.currentAboutMessage;
+ await SimpleTest.promiseFocus(aboutMessage);
+
+ await subtest_action_popup_menu(
+ aboutMessage,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 2,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+});
+add_task(async function test_message_display_action_menu_popup_tab_mv2() {
+ let tab = await openMessageInTab(gMessage);
+ await subtest_action_popup_menu(
+ tab.chromeBrowser.contentWindow,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 1, mailTab: false },
+ {
+ manifest_version: 2,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ window.document.getElementById("tabmail").closeTab(tab);
+});
+add_task(async function test_message_display_action_menu_popup_window_mv2() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow.messageBrowser.contentWindow,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_popup_mv2() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action_menu",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ compose_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_popup_formattoolbar_mv2() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action_menu",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 2,
+ compose_action: {
+ default_title: "This is a test",
+ default_area: "formattoolbar",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+
+add_task(async function test_browser_action_menu_popup_mv3() {
+ await subtest_action_popup_menu(
+ window,
+ {
+ elementSelector: `.unified-toolbar [extension="menus@mochi.test"]`,
+ context: "action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "action_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 3,
+ action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+});
+add_task(async function test_browser_action_menu_popup_message_window_mv3() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-browserAction-toolbarbutton",
+ context: "action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ action: {
+ default_title: "This is a test",
+ default_windows: ["messageDisplay"],
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_message_display_action_menu_popup_pane_mv3() {
+ let tabmail = document.getElementById("tabmail");
+ let aboutMessage = tabmail.currentAboutMessage;
+ await SimpleTest.promiseFocus(aboutMessage);
+
+ await subtest_action_popup_menu(
+ aboutMessage,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 0, mailTab: true },
+ {
+ manifest_version: 3,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+});
+add_task(async function test_message_display_action_menu_popup_tab_mv3() {
+ let tab = await openMessageInTab(gMessage);
+ await subtest_action_popup_menu(
+ tab.chromeBrowser.contentWindow,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 1, mailTab: false },
+ {
+ manifest_version: 3,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ window.document.getElementById("tabmail").closeTab(tab);
+});
+add_task(async function test_message_display_action_menu_popup_window_mv3() {
+ let testWindow = await openMessageInWindow(gMessage);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow.messageBrowser.contentWindow,
+ {
+ elementSelector: "#menus_mochi_test-messageDisplayAction-toolbarbutton",
+ context: "message_display_action_menu",
+ },
+ {
+ pageUrl: /^mailbox\:/,
+ menuItemId: "message_display_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ message_display_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_popup_mv3() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action_menu",
+ nonActionButtonSelector: "#button-attach",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ compose_action: {
+ default_title: "This is a test",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
+add_task(async function test_compose_action_menu_popup_formattoolbar_mv3() {
+ let testWindow = await openComposeWindow(gAccount);
+ await focusWindow(testWindow);
+ await subtest_action_popup_menu(
+ testWindow,
+ {
+ elementSelector: "#menus_mochi_test-composeAction-toolbarbutton",
+ context: "compose_action_menu",
+ },
+ {
+ pageUrl: "about:blank?compose",
+ menuItemId: "compose_action_menu",
+ },
+ { active: true, index: 0, mailTab: false },
+ {
+ manifest_version: 3,
+ compose_action: {
+ default_title: "This is a test",
+ default_area: "formattoolbar",
+ type: "menu",
+ },
+ }
+ );
+ await BrowserTestUtils.closeWindow(testWindow);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu.js
new file mode 100644
index 0000000000..bc773afa44
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu.js
@@ -0,0 +1,582 @@
+/* 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/. */
+"use strict";
+
+function getVisibleChildrenIds(menuElem) {
+ return Array.from(menuElem.children)
+ .filter(elem => !elem.hidden)
+ .map(elem => elem.id || elem.tagName);
+}
+
+function checkIsDefaultMenuItemVisible(visibleMenuItemIds) {
+ // In this whole test file, we open a menu on a link. Assume that all
+ // default menu items are shown if one link-specific menu item is shown.
+ ok(
+ visibleMenuItemIds.includes("browserContext-copylink"),
+ `The default 'Copy Link Location' menu item should be in ${visibleMenuItemIds}.`
+ );
+}
+
+// Tests the following:
+// - Calling overrideContext({}) during oncontextmenu forces the menu to only
+// show an extension's own items.
+// - These menu items all appear in the root menu.
+// - The usual extension filtering behavior (e.g. documentUrlPatterns and
+// targetUrlPatterns) is still applied; some menu items are therefore hidden.
+// - Calling overrideContext({showDefaults:true}) causes the default menu items
+// to be shown, but only after the extension's.
+// - overrideContext expires after the menu is opened once.
+// - overrideContext can be called from shadow DOM.
+add_task(async function overrideContext_in_extension_tab() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.allow_eval_with_system_principal", true]],
+ });
+
+ function extensionTabScript() {
+ document.addEventListener(
+ "contextmenu",
+ () => {
+ browser.menus.overrideContext({});
+ browser.test.sendMessage("oncontextmenu_in_dom_part_1");
+ },
+ { once: true }
+ );
+
+ let shadowRoot = document
+ .getElementById("shadowHost")
+ .attachShadow({ mode: "open" });
+ shadowRoot.innerHTML = `<a href="http://example.com/">Link</a>`;
+ shadowRoot.firstChild.addEventListener(
+ "contextmenu",
+ () => {
+ browser.menus.overrideContext({});
+ browser.test.sendMessage("oncontextmenu_in_shadow_dom");
+ },
+ { once: true }
+ );
+
+ browser.menus.create({
+ id: "tab_1",
+ title: "tab_1",
+ documentUrlPatterns: [document.URL],
+ onclick() {
+ document.addEventListener(
+ "contextmenu",
+ () => {
+ // Verifies that last call takes precedence.
+ browser.menus.overrideContext({ showDefaults: false });
+ browser.menus.overrideContext({ showDefaults: true });
+ browser.test.sendMessage("oncontextmenu_in_dom_part_2");
+ },
+ { once: true }
+ );
+ browser.test.sendMessage("onClicked_tab_1");
+ },
+ });
+ browser.menus.create(
+ {
+ id: "tab_2",
+ title: "tab_2",
+ onclick() {
+ browser.test.sendMessage("onClicked_tab_2");
+ },
+ },
+ () => {
+ browser.test.sendMessage("menu-registered");
+ }
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["menus", "menus.overrideContext"],
+ },
+ files: {
+ "tab.html": `
+ <!DOCTYPE html><meta charset="utf-8">
+ <a href="http://example.com/">Link</a>
+ <div id="shadowHost"></div>
+ <script src="tab.js"></script>
+ `,
+ "tab.js": extensionTabScript,
+ },
+ background() {
+ // Expected to match and thus be visible.
+ browser.menus.create({ id: "bg_1", title: "bg_1" });
+ browser.menus.create({
+ id: "bg_2",
+ title: "bg_2",
+ targetUrlPatterns: ["*://example.com/*"],
+ });
+
+ // Expected to not match and be hidden.
+ browser.menus.create({
+ id: "bg_3",
+ title: "bg_3",
+ targetUrlPatterns: ["*://nomatch/*"],
+ });
+ browser.menus.create({
+ id: "bg_4",
+ title: "bg_4",
+ documentUrlPatterns: [document.URL],
+ });
+
+ browser.menus.onShown.addListener(info => {
+ browser.test.assertEq("tab", info.viewType, "Expected viewType");
+ browser.test.assertEq(
+ "bg_1,bg_2,tab_1,tab_2",
+ info.menuIds.join(","),
+ "Expected menu items."
+ );
+ browser.test.assertEq(
+ "all,link",
+ info.contexts.sort().join(","),
+ "Expected menu contexts"
+ );
+ browser.test.sendMessage("onShown");
+ });
+
+ browser.tabs.create({ url: "tab.html" });
+ },
+ });
+
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["menus"],
+ },
+ background() {
+ browser.menus.create(
+ { id: "other_extension_item", title: "other_extension_item" },
+ () => {
+ browser.test.sendMessage("other_extension_item_created");
+ }
+ );
+ },
+ });
+ await otherExtension.startup();
+ await otherExtension.awaitMessage("other_extension_item_created");
+
+ await extension.startup();
+ await extension.awaitMessage("menu-registered");
+
+ const EXPECTED_EXTENSION_MENU_IDS = [
+ `${makeWidgetId(extension.id)}-menuitem-_bg_1`,
+ `${makeWidgetId(extension.id)}-menuitem-_bg_2`,
+ `${makeWidgetId(extension.id)}-menuitem-_tab_1`,
+ `${makeWidgetId(extension.id)}-menuitem-_tab_2`,
+ ];
+ const OTHER_EXTENSION_MENU_ID = `${makeWidgetId(
+ otherExtension.id
+ )}-menuitem-_other_extension_item`;
+
+ {
+ // Tests overrideContext({})
+ info("Expecting the menu to be replaced by overrideContext.");
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("oncontextmenu_in_dom_part_1");
+ await extension.awaitMessage("onShown");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(menu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected only extension menu items"
+ );
+
+ let menuItems = menu.getElementsByAttribute("label", "tab_1");
+ await closeExtensionContextMenu(menuItems[0]);
+ await extension.awaitMessage("onClicked_tab_1");
+ }
+
+ {
+ // Tests overrideContext({showDefaults:true}))
+ info(
+ "Expecting the menu to be replaced by overrideContext, including default menu items."
+ );
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("oncontextmenu_in_dom_part_2");
+ await extension.awaitMessage("onShown");
+
+ let visibleMenuItemIds = getVisibleChildrenIds(menu);
+ Assert.deepEqual(
+ visibleMenuItemIds.slice(0, EXPECTED_EXTENSION_MENU_IDS.length),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected extension menu items at the start."
+ );
+
+ checkIsDefaultMenuItemVisible(visibleMenuItemIds);
+
+ is(
+ visibleMenuItemIds[visibleMenuItemIds.length - 1],
+ OTHER_EXTENSION_MENU_ID,
+ "Other extension menu item should be at the end."
+ );
+
+ let menuItems = menu.getElementsByAttribute("label", "tab_2");
+ await closeExtensionContextMenu(menuItems[0]);
+ await extension.awaitMessage("onClicked_tab_2");
+ }
+
+ {
+ // Tests that previous overrideContext call has been forgotten,
+ // so the default behavior should occur (=move items into submenu).
+ info(
+ "Expecting the default menu to be used when overrideContext is not called."
+ );
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("onShown");
+
+ checkIsDefaultMenuItemVisible(getVisibleChildrenIds(menu));
+
+ let menuItems = menu.getElementsByAttribute("ext-type", "top-level-menu");
+ is(menuItems.length, 1, "Expected top-level menu element for extension.");
+ let topLevelExtensionMenuItem = menuItems[0];
+ is(
+ topLevelExtensionMenuItem.nextSibling,
+ null,
+ "Extension menu should be the last element."
+ );
+
+ const submenu = await openSubmenu(topLevelExtensionMenuItem);
+ is(submenu, topLevelExtensionMenuItem.menupopup, "Correct submenu opened");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(submenu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Extension menu items should be in the submenu by default."
+ );
+
+ await closeContextMenu();
+ }
+
+ {
+ info(
+ "Expecting the menu to be replaced by overrideContext from a listener inside shadow DOM."
+ );
+ // Tests that overrideContext({}) can be used from a listener inside shadow DOM.
+ let menu = await openContextMenu(
+ () => this.document.getElementById("shadowHost").shadowRoot.firstChild
+ );
+ await extension.awaitMessage("oncontextmenu_in_shadow_dom");
+ await extension.awaitMessage("onShown");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(menu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected only extension menu items after overrideContext({}) in shadow DOM"
+ );
+
+ await closeContextMenu();
+ }
+
+ // Unloading the extension will automatically close the extension's tab.html
+ await extension.unload();
+ await otherExtension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+});
+
+async function run_overrideContext_test_in_popup(testWindow, buttonSelector) {
+ function extensionPopupScript() {
+ document.addEventListener(
+ "contextmenu",
+ () => {
+ browser.menus.overrideContext({});
+ browser.test.sendMessage("oncontextmenu_in_dom_part_1");
+ },
+ { once: true }
+ );
+
+ let shadowRoot = document
+ .getElementById("shadowHost")
+ .attachShadow({ mode: "open" });
+ shadowRoot.innerHTML = `<a href="http://example.com/">Link2</a>`;
+ shadowRoot.firstChild.addEventListener(
+ "contextmenu",
+ () => {
+ browser.menus.overrideContext({});
+ browser.test.sendMessage("oncontextmenu_in_shadow_dom");
+ },
+ { once: true }
+ );
+
+ browser.menus.create({
+ id: "popup_1",
+ title: "popup_1",
+ documentUrlPatterns: [document.URL],
+ onclick() {
+ document.addEventListener(
+ "contextmenu",
+ () => {
+ // Verifies that last call takes precedence.
+ browser.menus.overrideContext({ showDefaults: false });
+ browser.menus.overrideContext({ showDefaults: true });
+ browser.test.sendMessage("oncontextmenu_in_dom_part_2");
+ },
+ { once: true }
+ );
+ browser.test.sendMessage("onClicked_popup_1");
+ },
+ });
+ browser.menus.create(
+ {
+ id: "popup_2",
+ title: "popup_2",
+ onclick() {
+ browser.test.sendMessage("onClicked_popup_2");
+ },
+ },
+ () => {
+ browser.test.sendMessage("menu-registered");
+ }
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: `overrideContext@mochi.test`,
+ },
+ },
+ permissions: ["menus", "menus.overrideContext"],
+ browser_action: {
+ default_popup: "popup.html",
+ default_title: "Popup",
+ },
+ compose_action: {
+ default_popup: "popup.html",
+ default_title: "Popup",
+ },
+ message_display_action: {
+ default_popup: "popup.html",
+ default_title: "Popup",
+ },
+ },
+ files: {
+ "popup.html": `
+ <!DOCTYPE html><meta charset="utf-8">
+ <a id="link1" href="http://example.com/">Link1</a>
+ <div id="shadowHost"></div>
+ <script src="popup.js"></script>
+ `,
+ "popup.js": extensionPopupScript,
+ },
+ background() {
+ // Expected to match and thus be visible.
+ browser.menus.create({
+ id: "bg_1",
+ title: "bg_1",
+ viewTypes: ["popup"],
+ });
+ // Expected to not match and be hidden.
+ browser.menus.create({
+ id: "bg_2",
+ title: "bg_2",
+ viewTypes: ["tab"],
+ });
+ browser.menus.onShown.addListener(info => {
+ browser.test.assertEq("popup", info.viewType, "Expected viewType");
+ browser.test.assertEq(
+ "bg_1,popup_1,popup_2",
+ info.menuIds.join(","),
+ "Expected menu items."
+ );
+ browser.test.sendMessage("onShown");
+ });
+
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ const EXPECTED_EXTENSION_MENU_IDS = [
+ `${makeWidgetId(extension.id)}-menuitem-_bg_1`,
+ `${makeWidgetId(extension.id)}-menuitem-_popup_1`,
+ `${makeWidgetId(extension.id)}-menuitem-_popup_2`,
+ ];
+ const button = testWindow.document.querySelector(buttonSelector);
+ Assert.ok(button, "Button created");
+ EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, testWindow);
+ await extension.awaitMessage("menu-registered");
+
+ {
+ // Tests overrideContext({})
+ info("Expecting the menu to be replaced by overrideContext.");
+
+ let menu = await openContextMenuInPopup(extension, "#link1", testWindow);
+ await extension.awaitMessage("oncontextmenu_in_dom_part_1");
+ await extension.awaitMessage("onShown");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(menu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected only extension menu items"
+ );
+
+ let menuItems = menu.getElementsByAttribute("label", "popup_1");
+
+ await closeExtensionContextMenu(menuItems[0], {}, testWindow);
+ await extension.awaitMessage("onClicked_popup_1");
+ }
+
+ {
+ // Tests overrideContext({showDefaults:true}))
+ info(
+ "Expecting the menu to be replaced by overrideContext, including default menu items."
+ );
+ let menu = await openContextMenuInPopup(extension, "#link1", testWindow);
+ await extension.awaitMessage("oncontextmenu_in_dom_part_2");
+ await extension.awaitMessage("onShown");
+ let visibleMenuItemIds = getVisibleChildrenIds(menu);
+ Assert.deepEqual(
+ visibleMenuItemIds.slice(0, EXPECTED_EXTENSION_MENU_IDS.length),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected extension menu items at the start."
+ );
+ checkIsDefaultMenuItemVisible(visibleMenuItemIds);
+
+ let menuItems = menu.getElementsByAttribute("label", "popup_2");
+ await closeExtensionContextMenu(menuItems[0], {}, testWindow);
+ await extension.awaitMessage("onClicked_popup_2");
+ }
+
+ {
+ // Tests that previous overrideContext call has been forgotten,
+ // so the default behavior should occur (=move items into submenu).
+ info(
+ "Expecting the default menu to be used when overrideContext is not called."
+ );
+ let menu = await openContextMenuInPopup(extension, "#link1", testWindow);
+ await extension.awaitMessage("onShown");
+
+ checkIsDefaultMenuItemVisible(getVisibleChildrenIds(menu));
+
+ let menuItems = menu.getElementsByAttribute("ext-type", "top-level-menu");
+ is(menuItems.length, 1, "Expected top-level menu element for extension.");
+ let topLevelExtensionMenuItem = menuItems[0];
+ is(
+ topLevelExtensionMenuItem.nextSibling,
+ null,
+ "Extension menu should be the last element."
+ );
+
+ const submenu = await openSubmenu(topLevelExtensionMenuItem);
+ is(submenu, topLevelExtensionMenuItem.menupopup, "Correct submenu opened");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(submenu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Extension menu items should be in the submenu by default."
+ );
+
+ await closeContextMenu(menu);
+ }
+
+ {
+ info("Testing overrideContext from a listener inside a shadow DOM.");
+ // Tests that overrideContext({}) can be used from a listener inside shadow DOM.
+ let menu = await openContextMenuInPopup(
+ extension,
+ () => this.document.getElementById("shadowHost").shadowRoot.firstChild,
+ testWindow
+ );
+ await extension.awaitMessage("oncontextmenu_in_shadow_dom");
+ await extension.awaitMessage("onShown");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(menu),
+ EXPECTED_EXTENSION_MENU_IDS,
+ "Expected only extension menu items after overrideContext({}) in shadow DOM"
+ );
+
+ await closeContextMenu(menu);
+ }
+
+ await closeBrowserAction(extension, testWindow);
+ await extension.unload();
+}
+
+add_task(async function overrideContext_in_extension_browser_action_popup() {
+ await run_overrideContext_test_in_popup(
+ window,
+ `.unified-toolbar [extension="overrideContext@mochi.test"]`
+ );
+});
+
+add_task(async function overrideContext_in_extension_compose_action_popup() {
+ let account = createAccount();
+ addIdentity(account);
+
+ let composeWindow = await openComposeWindow(account);
+ await focusWindow(composeWindow);
+ await run_overrideContext_test_in_popup(
+ composeWindow,
+ "#overridecontext_mochi_test-composeAction-toolbarbutton"
+ );
+ composeWindow.close();
+});
+
+add_task(
+ async function overrideContext_in_extension_message_display_action_popup_of_mail3pane() {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(subFolders[0]);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await run_overrideContext_test_in_popup(
+ about3Pane.messageBrowser.contentWindow,
+ "#overridecontext_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+
+ about3Pane.displayFolder(rootFolder);
+ }
+);
+
+add_task(
+ async function overrideContext_in_extension_message_display_action_popup_of_window() {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ let messages = subFolders[0].messages;
+
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ await focusWindow(messageWindow);
+ await run_overrideContext_test_in_popup(
+ messageWindow.messageBrowser.contentWindow,
+ "#overridecontext_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+ messageWindow.close();
+ }
+);
+
+add_task(
+ async function overrideContext_in_extension_message_display_action_popup_of_tab() {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ let messages = subFolders[0].messages;
+
+ await openMessageInTab(messages.getNext());
+
+ let tabmail = document.getElementById("tabmail");
+ await run_overrideContext_test_in_popup(
+ tabmail.currentAboutMessage,
+ "#overridecontext_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+ tabmail.closeOtherTabs(0);
+ }
+);
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js b/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js
new file mode 100644
index 0000000000..2a7192fe70
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js
@@ -0,0 +1,375 @@
+/* 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/. */
+"use strict";
+
+function getVisibleChildrenIds(menuElem) {
+ return Array.from(menuElem.children)
+ .filter(elem => !elem.hidden)
+ .map(elem => elem.id || elem.tagName);
+}
+
+function checkIsDefaultMenuItemVisible(visibleMenuItemIds) {
+ // In this whole test file, we open a menu on a link. Assume that all
+ // default menu items are shown if one link-specific menu item is shown.
+ ok(
+ visibleMenuItemIds.includes("browserContext-copylink"),
+ `The default 'Copy Link Location' menu item should be in ${visibleMenuItemIds}.`
+ );
+}
+
+// Tests that the context of an extension menu can be changed to:
+// - tab
+add_task(async function overrideContext_with_context() {
+ // Background script of the main test extension and the auxiliary other extension.
+ function background() {
+ const HTTP_URL = "https://example.com/?SomeTab";
+ browser.test.onMessage.addListener(async (msg, tabId) => {
+ browser.test.assertEq(
+ "testTabAccess",
+ msg,
+ `Expected message in ${browser.runtime.id}`
+ );
+ let tab = await browser.tabs.get(tabId);
+ if (!tab.url) {
+ // tabs or activeTab not active.
+ browser.test.sendMessage("testTabAccessDone", "tab_no_url");
+ return;
+ }
+ try {
+ let [url] = await browser.tabs.executeScript(tabId, {
+ code: "document.URL",
+ });
+ browser.test.assertEq(
+ HTTP_URL,
+ url,
+ "Expected successful executeScript"
+ );
+ browser.test.sendMessage("testTabAccessDone", "executeScript_ok");
+ return;
+ } catch (e) {
+ browser.test.assertEq(
+ "Missing host permission for the tab",
+ e.message,
+ "Expected error message"
+ );
+ browser.test.sendMessage("testTabAccessDone", "executeScript_failed");
+ }
+ });
+ browser.menus.onShown.addListener((info, tab) => {
+ browser.test.assertEq(
+ "tab",
+ info.viewType,
+ "Expected viewType at onShown"
+ );
+ browser.test.assertEq(
+ undefined,
+ info.linkUrl,
+ "Expected linkUrl at onShown"
+ );
+ browser.test.assertEq(
+ undefined,
+ info.srckUrl,
+ "Expected srcUrl at onShown"
+ );
+ browser.test.sendMessage("onShown", {
+ menuIds: info.menuIds.sort(),
+ contexts: info.contexts,
+ bookmarkId: info.bookmarkId,
+ pageUrl: info.pageUrl,
+ frameUrl: info.frameUrl,
+ tabId: tab && tab.id,
+ });
+ });
+ browser.menus.onClicked.addListener((info, tab) => {
+ browser.test.assertEq(
+ "tab",
+ info.viewType,
+ "Expected viewType at onClicked"
+ );
+ browser.test.assertEq(
+ undefined,
+ info.linkUrl,
+ "Expected linkUrl at onClicked"
+ );
+ browser.test.assertEq(
+ undefined,
+ info.srckUrl,
+ "Expected srcUrl at onClicked"
+ );
+ browser.test.sendMessage("onClicked", {
+ menuItemId: info.menuItemId,
+ bookmarkId: info.bookmarkId,
+ pageUrl: info.pageUrl,
+ frameUrl: info.frameUrl,
+ tabId: tab && tab.id,
+ });
+ });
+
+ // Minimal properties to define menu items for a specific context.
+ browser.menus.create({
+ id: "tab_context",
+ title: "tab_context",
+ contexts: ["tab"],
+ });
+
+ // documentUrlPatterns in the tab context applies to the tab's URL.
+ browser.menus.create({
+ id: "tab_context_http",
+ title: "tab_context_http",
+ contexts: ["tab"],
+ documentUrlPatterns: [HTTP_URL],
+ });
+ browser.menus.create({
+ id: "tab_context_moz_unexpected",
+ title: "tab_context_moz",
+ contexts: ["tab"],
+ documentUrlPatterns: ["moz-extension://*/tab.html"],
+ });
+ // When viewTypes is present, the document's URL is matched instead.
+ browser.menus.create({
+ id: "tab_context_viewType_http_unexpected",
+ title: "tab_context_viewType_http",
+ contexts: ["tab"],
+ viewTypes: ["tab"],
+ documentUrlPatterns: [HTTP_URL],
+ });
+ browser.menus.create({
+ id: "tab_context_viewType_moz",
+ title: "tab_context_viewType_moz",
+ contexts: ["tab"],
+ viewTypes: ["tab"],
+ documentUrlPatterns: ["moz-extension://*/tab.html"],
+ });
+
+ browser.menus.create({ id: "link_context", title: "link_context" }, () => {
+ browser.test.sendMessage("menu_items_registered");
+ });
+
+ if (browser.runtime.id === "@menu-test-extension") {
+ browser.tabs.create({ url: "tab.html" });
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "@menu-test-extension" } },
+ permissions: ["menus", "menus.overrideContext", "tabs"],
+ },
+ files: {
+ "tab.html": `
+ <!DOCTYPE html><meta charset="utf-8">
+ <a href="http://example.com/">Link</a>
+ <script src="tab.js"></script>
+ `,
+ "tab.js": async () => {
+ let [tab] = await browser.tabs.query({
+ url: "https://example.com/?SomeTab",
+ });
+ let testCases = [
+ {
+ context: "tab",
+ tabId: tab.id,
+ },
+ {
+ context: "tab",
+ tabId: tab.id,
+ },
+ {
+ context: "tab",
+ tabId: 123456789, // Some invalid tabId.
+ },
+ ];
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ document.addEventListener("contextmenu", () => {
+ browser.menus.overrideContext(testCases.shift());
+ browser.test.sendMessage("oncontextmenu_in_dom");
+ });
+
+ browser.test.sendMessage("setup_ready", {
+ tabId: tab.id,
+ httpUrl: tab.url,
+ extensionUrl: document.URL,
+ });
+ },
+ },
+ background,
+ });
+
+ let { browser } = window.openContentTab("https://example.com/?SomeTab");
+ await awaitBrowserLoaded(browser);
+
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "@other-test-extension" } },
+ permissions: ["menus", "activeTab"],
+ },
+ background,
+ });
+ await otherExtension.startup();
+ await otherExtension.awaitMessage("menu_items_registered");
+
+ await extension.startup();
+ await extension.awaitMessage("menu_items_registered");
+
+ let { tabId, httpUrl, extensionUrl } = await extension.awaitMessage(
+ "setup_ready"
+ );
+ info(`Set up test with tabId=${tabId}.`);
+
+ {
+ // Test case 1: context=tab
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("oncontextmenu_in_dom");
+ for (let ext of [extension, otherExtension]) {
+ info(`Testing menu from ${ext.id} after changing context to tab`);
+ Assert.deepEqual(
+ await ext.awaitMessage("onShown"),
+ {
+ menuIds: [
+ "tab_context",
+ "tab_context_http",
+ "tab_context_viewType_moz",
+ ],
+ contexts: ["tab"],
+ bookmarkId: undefined,
+ pageUrl: undefined, // because extension has no host permissions.
+ frameUrl: extensionUrl,
+ tabId,
+ },
+ "Expected onShown details after changing context to tab"
+ );
+ }
+ let topLevels = menu.getElementsByAttribute("ext-type", "top-level-menu");
+ is(topLevels.length, 1, "Expected top-level menu for otherExtension");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(menu),
+ [
+ `${makeWidgetId(extension.id)}-menuitem-_tab_context`,
+ `${makeWidgetId(extension.id)}-menuitem-_tab_context_http`,
+ `${makeWidgetId(extension.id)}-menuitem-_tab_context_viewType_moz`,
+ `menuseparator`,
+ topLevels[0].id,
+ ],
+ "Expected menu items after changing context to tab"
+ );
+
+ let submenu = await openSubmenu(topLevels[0]);
+ is(submenu, topLevels[0].menupopup, "Correct submenu opened");
+
+ Assert.deepEqual(
+ getVisibleChildrenIds(submenu),
+ [
+ `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context`,
+ `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context_http`,
+ `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context_viewType_moz`,
+ ],
+ "Expected menu items in submenu after changing context to tab"
+ );
+
+ extension.sendMessage("testTabAccess", tabId);
+ is(
+ await extension.awaitMessage("testTabAccessDone"),
+ "executeScript_failed",
+ "executeScript should fail due to the lack of permissions."
+ );
+
+ otherExtension.sendMessage("testTabAccess", tabId);
+ is(
+ await otherExtension.awaitMessage("testTabAccessDone"),
+ "tab_no_url",
+ "Other extension should not have activeTab permissions yet."
+ );
+
+ // Click on the menu item of the other extension to unlock host permissions.
+ let menuItems = menu.getElementsByAttribute("label", "tab_context");
+ is(
+ menuItems.length,
+ 2,
+ "There are two menu items with label 'tab_context'"
+ );
+ await closeExtensionContextMenu(menuItems[1]);
+
+ Assert.deepEqual(
+ await otherExtension.awaitMessage("onClicked"),
+ {
+ menuItemId: "tab_context",
+ bookmarkId: undefined,
+ pageUrl: httpUrl,
+ frameUrl: extensionUrl,
+ tabId,
+ },
+ "Expected onClicked details after changing context to tab"
+ );
+
+ extension.sendMessage("testTabAccess", tabId);
+ is(
+ await extension.awaitMessage("testTabAccessDone"),
+ "executeScript_failed",
+ "executeScript of extension that created the menu should still fail."
+ );
+
+ otherExtension.sendMessage("testTabAccess", tabId);
+ is(
+ await otherExtension.awaitMessage("testTabAccessDone"),
+ "executeScript_ok",
+ "Other extension should have activeTab permissions."
+ );
+ }
+
+ {
+ // Test case 2: context=tab, click on menu item of extension..
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("oncontextmenu_in_dom");
+
+ // The previous test has already verified the visible menu items,
+ // so we skip checking the onShown result and only test clicking.
+ await extension.awaitMessage("onShown");
+ await otherExtension.awaitMessage("onShown");
+ let menuItems = menu.getElementsByAttribute("label", "tab_context");
+ is(
+ menuItems.length,
+ 2,
+ "There are two menu items with label 'tab_context'"
+ );
+ await closeExtensionContextMenu(menuItems[0]);
+
+ Assert.deepEqual(
+ await extension.awaitMessage("onClicked"),
+ {
+ menuItemId: "tab_context",
+ bookmarkId: undefined,
+ pageUrl: httpUrl,
+ frameUrl: extensionUrl,
+ tabId,
+ },
+ "Expected onClicked details after changing context to tab"
+ );
+
+ extension.sendMessage("testTabAccess", tabId);
+ is(
+ await extension.awaitMessage("testTabAccessDone"),
+ "executeScript_failed",
+ "activeTab permission should not be available to the extension that created the menu."
+ );
+ }
+
+ {
+ // Test case 4: context=tab, invalid tabId.
+ let menu = await openContextMenu("a");
+ await extension.awaitMessage("oncontextmenu_in_dom");
+ // When an invalid tabId is used, all extension menu logic is skipped and
+ // the default menu is shown.
+ checkIsDefaultMenuItemVisible(getVisibleChildrenIds(menu));
+ await closeContextMenu(menu);
+ }
+
+ await extension.unload();
+ await otherExtension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+ tabmail.closeTab(tabmail.currentTabInfo);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay.js
new file mode 100644
index 0000000000..c99ea52440
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay.js
@@ -0,0 +1,1016 @@
+/* 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 gAccount;
+var gMessages;
+var gFolder;
+
+add_setup(() => {
+ gAccount = createAccount();
+ let rootFolder = gAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+ rootFolder.createSubfolder("test1", null);
+ rootFolder.createSubfolder("test2", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+ createMessages(subFolders.test1, 5);
+ createMessages(subFolders.test2, 6);
+
+ gFolder = subFolders.test0;
+ gMessages = [...subFolders.test0.messages];
+});
+
+add_task(async function testGetDisplayedMessage() {
+ let files = {
+ "background.js": async () => {
+ let [{ id: firstTabId, displayedFolder }] = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+
+ let { messages } = await browser.messages.list(displayedFolder);
+
+ async function checkResults(action, expectedMessages, sameTab) {
+ let msgListener = window.waitForEvent(
+ "messageDisplay.onMessageDisplayed"
+ );
+ let msgsListener = window.waitForEvent(
+ "messageDisplay.onMessagesDisplayed"
+ );
+
+ if (typeof action == "string") {
+ await window.sendMessage(action);
+ } else {
+ action();
+ }
+
+ let tab;
+ let message;
+ if (expectedMessages.length == 1) {
+ [tab, message] = await msgListener;
+ let [msgsTab, msgs] = await msgsListener;
+ // Check listener results.
+ if (sameTab) {
+ browser.test.assertEq(firstTabId, tab.id);
+ browser.test.assertEq(firstTabId, msgsTab.id);
+ } else {
+ browser.test.assertTrue(firstTabId != tab.id);
+ browser.test.assertTrue(firstTabId != msgsTab.id);
+ }
+ browser.test.assertEq(
+ messages[expectedMessages[0]].subject,
+ message.subject
+ );
+ browser.test.assertEq(
+ messages[expectedMessages[0]].subject,
+ msgs[0].subject
+ );
+
+ // Check displayed message result.
+ message = await browser.messageDisplay.getDisplayedMessage(tab.id);
+ browser.test.assertEq(
+ messages[expectedMessages[0]].subject,
+ message.subject
+ );
+ } else {
+ // onMessageDisplayed doesn't fire for the multi-message case.
+ let msgs;
+ [tab, msgs] = await msgsListener;
+
+ for (let [i, expected] of expectedMessages.entries()) {
+ browser.test.assertEq(messages[expected].subject, msgs[i].subject);
+ }
+
+ // More than one selected, so getDisplayMessage returns null.
+ message = await browser.messageDisplay.getDisplayedMessage(tab.id);
+ browser.test.assertEq(null, message);
+ }
+
+ let displayMsgs = await browser.messageDisplay.getDisplayedMessages(
+ tab.id
+ );
+ browser.test.assertEq(expectedMessages.length, displayMsgs.length);
+ for (let [i, expected] of expectedMessages.entries()) {
+ browser.test.assertEq(
+ messages[expected].subject,
+ displayMsgs[i].subject
+ );
+ }
+ return tab;
+ }
+
+ async function testGetDisplayedMessageFunctions(tabId, expected) {
+ let messages = await browser.messageDisplay.getDisplayedMessages(tabId);
+ if (expected) {
+ browser.test.assertEq(1, messages.length);
+ browser.test.assertEq(expected.subject, messages[0].subject);
+ } else {
+ browser.test.assertEq(0, messages.length);
+ }
+
+ let message = await browser.messageDisplay.getDisplayedMessage(tabId);
+ if (expected) {
+ browser.test.assertEq(expected.subject, message.subject);
+ } else {
+ browser.test.assertEq(null, message);
+ }
+ }
+
+ // Test that selecting a different message fires the event.
+ await checkResults("show message 1", [1], true);
+
+ // ... and again, for good measure.
+ await checkResults("show message 2", [2], true);
+
+ // Test that opening a message in a new tab fires the event.
+ let tab = await checkResults("open message 0 in tab", [0], false);
+
+ // The opened tab should return message #0.
+ await testGetDisplayedMessageFunctions(tab.id, messages[0]);
+
+ // The first tab should return message #2, even if it is currently not displayed.
+ await testGetDisplayedMessageFunctions(firstTabId, messages[2]);
+
+ // Closing the tab should return us to the first tab.
+ await browser.tabs.remove(tab.id);
+
+ // Test that opening a message in a new window fires the event.
+ tab = await checkResults("open message 1 in window", [1], false);
+
+ // Test the windows API being able to return the messageDisplay window as
+ // the current one.
+ let msgWindow = await browser.windows.get(tab.windowId);
+ browser.test.assertEq(msgWindow.type, "messageDisplay");
+ let curWindow = await browser.windows.getCurrent();
+ browser.test.assertEq(tab.windowId, curWindow.id);
+ // Test the tabs API being able to return the correct current tab.
+ let [currentTab] = await browser.tabs.query({
+ currentWindow: true,
+ active: true,
+ });
+ browser.test.assertEq(tab.id, currentTab.id);
+
+ // Close the window.
+ browser.tabs.remove(tab.id);
+
+ // Test that selecting a multiple messages fires the event.
+ await checkResults("show messages 1 and 2", [1, 2], true);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+
+ await extension.awaitMessage("show message 1");
+ about3Pane.threadTree.selectedIndex = 1;
+ extension.sendMessage();
+
+ await extension.awaitMessage("show message 2");
+ about3Pane.threadTree.selectedIndex = 2;
+ extension.sendMessage();
+
+ await extension.awaitMessage("open message 0 in tab");
+ await openMessageInTab(gMessages[0]);
+ extension.sendMessage();
+
+ await extension.awaitMessage("open message 1 in window");
+ await openMessageInWindow(gMessages[1]);
+ extension.sendMessage();
+
+ await extension.awaitMessage("show messages 1 and 2");
+ about3Pane.threadTree.selectedIndices = [1, 2];
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function testOpenMessagesInTabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Helper class to keep track of expected tab states and cycle though all
+ // tabs after each test to enure the returned values are as expected under
+ // different active/inactive scenarios.
+ class TabTest {
+ constructor() {
+ this.expectedTabs = new Map();
+ }
+
+ // Check the given tab to match the expected values, update the internal
+ // tracker Map, and cycle through all tabs to make sure they still match
+ // the expected values.
+ async check(description, tabId, expected) {
+ browser.test.log(`TabTest: ${description}`);
+ if (expected.active) {
+ // Mark all other tabs inactive.
+ this.expectedTabs.forEach((v, k) => {
+ v.active = k == tabId;
+ });
+ }
+ // When we call this.check() to cycle thru all tabs, we do not specify
+ // an expected value. Do not update the tracker map in this case.
+ if (!expected.skip) {
+ this.expectedTabs.set(tabId, expected);
+ }
+
+ // Wait till the loaded url is as expected. Only checking the last part,
+ // since running this test with --verify causes multiple accounts to
+ // be created, changing the expected first part of message urls.
+ await window.waitForCondition(async () => {
+ let tab = await browser.tabs.get(tabId);
+ let expected = this.expectedTabs.get(tabId);
+ return tab.status == "complete" && tab.url.endsWith(expected.url);
+ }, `Should have loaded the correct URL in tab ${tabId}`);
+
+ // Check if all existing tabs match their expected values.
+ await this._verify();
+
+ // Cycle though all tabs, if there is more than one and run the check
+ // for each active tab.
+ if (!expected.skip && this.expectedTabs.size > 1) {
+ // Loop over all tabs, activate each and verify all of them. Test the currently active
+ // tab last, so we end up with the original condition.
+ let currentActiveTab = this._toArray().find(tab => tab.active);
+ let tabsToVerify = this._toArray()
+ .filter(tab => tab.id != currentActiveTab.id)
+ .concat(currentActiveTab);
+ for (let tab of tabsToVerify) {
+ await browser.tabs.update(tab.id, { active: true });
+ await this.check("Activating tab " + tab.id, tab.id, {
+ active: true,
+ skip: true,
+ });
+ }
+ }
+ }
+
+ // Return the expectedTabs Map as an array.
+ _toArray() {
+ return Array.from(this.expectedTabs.entries(), tab => {
+ return { id: tab[0], ...tab[1] };
+ });
+ }
+
+ // Verify that all tabs match their currently expected values.
+ async _verify() {
+ let tabs = await browser.tabs.query({});
+ browser.test.assertEq(
+ this.expectedTabs.size,
+ tabs.length,
+ `number of tabs should be correct`
+ );
+
+ for (let [tabId, expectedTab] of this.expectedTabs) {
+ let tab = await browser.tabs.get(tabId);
+ browser.test.assertEq(
+ expectedTab.active,
+ tab.active,
+ `${tab.type} tab (id:${tabId}) should have the correct active setting`
+ );
+
+ if (expectedTab.hasOwnProperty("message")) {
+ // Getthe currently displayed message.
+ let message = await browser.messageDisplay.getDisplayedMessage(
+ tabId
+ );
+
+ // Test message either being correct or not displayed if not
+ // expected.
+ if (expectedTab.message) {
+ browser.test.assertTrue(
+ !!message,
+ `${tab.type} tab (id:${tabId}) should have a message`
+ );
+ if (message) {
+ browser.test.assertEq(
+ expectedTab.message.id,
+ message.id,
+ `${tab.type} tab (id:${tabId}) should have the correct message`
+ );
+ }
+ } else {
+ browser.test.assertEq(
+ null,
+ message,
+ `${tab.type} tab (id:${tabId}) should not display a message`
+ );
+ }
+ }
+
+ // Testing url parameter.
+ if (expectedTab.url) {
+ browser.test.assertTrue(
+ tab.url.endsWith(expectedTab.url),
+ `${tab.type} tab (id:${tabId}) should display the correct url`
+ );
+ }
+ }
+ }
+ }
+
+ // Verify startup conditions.
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(
+ 1,
+ accounts.length,
+ `number of accounts should be correct`
+ );
+
+ let folder1 = accounts[0].folders.find(f => f.name == "test1");
+ browser.test.assertTrue(!!folder1, "folder should exist");
+ let { messages: messages1 } = await browser.messages.list(folder1);
+ browser.test.assertEq(
+ 5,
+ messages1.length,
+ `number of messages should be correct`
+ );
+
+ let folder2 = accounts[0].folders.find(f => f.name == "test2");
+ browser.test.assertTrue(!!folder2, "folder should exist");
+ let { messages: messages2 } = await browser.messages.list(folder2);
+ browser.test.assertEq(
+ 6,
+ messages2.length,
+ `number of messages should be correct`
+ );
+
+ // Test reject on invalid openProperties.
+ await browser.test.assertRejects(
+ browser.messageDisplay.open({ messageId: 578 }),
+ `Unknown or invalid messageId: 578.`,
+ "browser.messageDisplay.open() should reject, if invalid messageId is specified"
+ );
+
+ await browser.test.assertRejects(
+ browser.messageDisplay.open({ headerMessageId: "1" }),
+ `Unknown or invalid headerMessageId: 1.`,
+ "browser.messageDisplay.open() should reject, if invalid headerMessageId is specified"
+ );
+
+ await browser.test.assertRejects(
+ browser.messageDisplay.open({}),
+ "Exactly one of messageId, headerMessageId or file must be specified.",
+ "browser.messageDisplay.open() should reject, if no messageId and no headerMessageId is specified"
+ );
+
+ await browser.test.assertRejects(
+ browser.messageDisplay.open({ messageId: 578, headerMessageId: "1" }),
+ "Exactly one of messageId, headerMessageId or file must be specified.",
+ "browser.messageDisplay.open() should reject, if messageId and headerMessageId are specified"
+ );
+
+ // Create a TabTest to cycle through all existing tabs after each test to
+ // verify returned values under different active/inactive scenarios.
+ let tabTest = new TabTest();
+
+ // Load a content tab into the primary mail tab, to have a known startup
+ // condition.
+ let tabs = await browser.tabs.query({});
+ browser.test.assertEq(1, tabs.length);
+ let mailTab = tabs[0];
+ await browser.tabs.update(mailTab.id, {
+ url: "https://www.example.com/mailTab/1",
+ });
+ await tabTest.check(
+ "Load a url into the default mail tab.",
+ mailTab.id,
+ {
+ active: true,
+ url: "https://www.example.com/mailTab/1",
+ }
+ );
+
+ // Create an active content tab.
+ let tab1 = await browser.tabs.create({
+ url: "https://www.example.com/contentTab1/1",
+ });
+ await tabTest.check("Create a content tab #1.", tab1.id, {
+ active: true,
+ url: "https://www.example.com/contentTab1/1",
+ });
+
+ // Open an inactive message tab.
+ let tab2 = await browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "tab",
+ active: false,
+ });
+ await tabTest.check("messageDisplay.open with active: false", tab2.id, {
+ active: false,
+ message: messages1[0],
+ // To be able to run this test with --verify, specify only the last part
+ // of the expected message url, which is independent of the associated
+ // account.
+ url: "/localhost/test1?number=1",
+ });
+
+ // Open an active message tab.
+ let tab3 = await browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "tab",
+ active: true,
+ });
+ await tabTest.check(
+ "Opening the same message again should create a new tab.",
+ tab3.id,
+ {
+ active: true,
+ message: messages1[0],
+ url: "/localhost/test1?number=1",
+ }
+ );
+
+ // Open another content tab.
+ let tab4 = await browser.tabs.create({
+ url: "https://www.example.com/contentTab1/2",
+ });
+ await tabTest.check("Create a content tab #2.", tab4.id, {
+ active: true,
+ url: "https://www.example.com/contentTab1/2",
+ });
+
+ await browser.tabs.remove(tab1.id);
+ await browser.tabs.remove(tab2.id);
+ await browser.tabs.remove(tab3.id);
+ await browser.tabs.remove(tab4.id);
+
+ // Test opening multiple tabs.
+ let promisedTabs = [];
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "tab",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[1].id,
+ location: "tab",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[2].id,
+ location: "tab",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[3].id,
+ location: "tab",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[4].id,
+ location: "tab",
+ })
+ );
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < 5; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for tab ${i}`
+ );
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ browser.test.assertEq(
+ messages1[i].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function testOpenMessagesInWindows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Verify startup conditions.
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(
+ 1,
+ accounts.length,
+ `number of accounts should be correct`
+ );
+
+ let folder1 = accounts[0].folders.find(f => f.name == "test1");
+ browser.test.assertTrue(!!folder1, "folder should exist");
+ let { messages: messages1 } = await browser.messages.list(folder1);
+ browser.test.assertEq(
+ 5,
+ messages1.length,
+ `number of messages should be correct`
+ );
+
+ // Open multiple different windows.
+ {
+ let promisedTabs = [];
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[1].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[2].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[3].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[4].id,
+ location: "window",
+ })
+ );
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ let foundIds = new Set();
+ for (let i = 0; i < 5; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for window ${i}`
+ );
+
+ browser.test.assertTrue(
+ !foundIds.has(openedTabs[i].value.id),
+ `Tab ${i} should have a unique id ${openedTabs[i].value.id}`
+ );
+ foundIds.add(openedTabs[i].value.id);
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ browser.test.assertEq(
+ messages1[i].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ // Open multiple identical windows.
+ {
+ let promisedTabs = [];
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ promisedTabs.push(
+ browser.messageDisplay.open({
+ messageId: messages1[0].id,
+ location: "window",
+ })
+ );
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ let foundIds = new Set();
+ for (let i = 0; i < 5; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for window ${i}`
+ );
+
+ browser.test.assertTrue(
+ !foundIds.has(openedTabs[i].value.id),
+ `Tab ${i} should have a unique id ${openedTabs[i].value.id}`
+ );
+ foundIds.add(openedTabs[i].value.id);
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ browser.test.assertEq(
+ messages1[0].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_MV3_event_pages_onMessageDisplayed() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.messageDisplay.onMessageDisplayed.addListener((tab, message) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onMessageDisplayed received", {
+ tab,
+ message,
+ });
+ }
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ browser_specific_settings: {
+ gecko: { id: "onMessageDisplayed@mochi.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["messageDisplay.onMessageDisplayed"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Select a message.
+
+ {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 2;
+
+ let displayInfo = await extension.awaitMessage(
+ "onMessageDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.message.subject,
+ "Huge Shindig Yesterday",
+ "The primed onMessageDisplayed event should return the correct message."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "mail",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessageDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Open a message in a window.
+
+ {
+ let messageWindow = await openMessageInWindow(gMessages[0]);
+ let displayInfo = await extension.awaitMessage(
+ "onMessageDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.message.subject,
+ "Big Meeting Today",
+ "The primed onMessageDisplayed event should return the correct message."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "messageDisplay",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessageDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ messageWindow.close();
+ }
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Open a message in a tab.
+
+ {
+ await openMessageInTab(gMessages[1]);
+ let displayInfo = await extension.awaitMessage(
+ "onMessageDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.message.subject,
+ "Small Party Tomorrow",
+ "The primed onMessageDisplayed event should return the correct message."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "messageDisplay",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessageDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ document.getElementById("tabmail").closeTab();
+ }
+
+ await extension.unload();
+});
+
+add_task(async function test_MV3_event_pages_onMessagesDisplayed() {
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ browser.messageDisplay.onMessagesDisplayed.addListener(
+ (tab, messages) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onMessagesDisplayed received", {
+ tab,
+ messages,
+ });
+ }
+ }
+ );
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ browser_specific_settings: {
+ gecko: { id: "onMessagesDisplayed@mochi.test" },
+ },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = ["messageDisplay.onMessagesDisplayed"];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Select multiple messages.
+
+ {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndices = [0, 1, 2, 3, 4];
+
+ let displayInfo = await extension.awaitMessage(
+ "onMessagesDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.messages.length,
+ 5,
+ "The primed onMessagesDisplayed event should return the correct number of messages."
+ );
+ Assert.deepEqual(
+ [
+ "Big Meeting Today",
+ "Small Party Tomorrow",
+ "Huge Shindig Yesterday",
+ "Tiny Wedding In a Fortnight",
+ "Red Document Needs Attention",
+ ],
+ displayInfo.messages.map(e => e.subject),
+ "The primed onMessagesDisplayed event should return the correct messages."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "mail",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessagesDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Open a message in a window.
+
+ {
+ let messageWindow = await openMessageInWindow(gMessages[0]);
+ let displayInfo = await extension.awaitMessage(
+ "onMessagesDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.messages.length,
+ 1,
+ "The primed onMessagesDisplayed event should return the correct number of messages."
+ );
+ Assert.equal(
+ displayInfo.messages[0].subject,
+ "Big Meeting Today",
+ "The primed onMessagesDisplayed event should return the correct message."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "messageDisplay",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessagesDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ messageWindow.close();
+ }
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Open a message in a tab.
+
+ {
+ await openMessageInTab(gMessages[1]);
+ let displayInfo = await extension.awaitMessage(
+ "onMessagesDisplayed received"
+ );
+ Assert.equal(
+ displayInfo.messages.length,
+ 1,
+ "The primed onMessagesDisplayed event should return the correct number of messages."
+ );
+ Assert.equal(
+ displayInfo.messages[0].subject,
+ "Small Party Tomorrow",
+ "The primed onMessagesDisplayed event should return the correct message."
+ );
+ Assert.deepEqual(
+ {
+ active: true,
+ type: "messageDisplay",
+ },
+ {
+ active: displayInfo.tab.active,
+ type: displayInfo.tab.type,
+ },
+ "The primed onMessagesDisplayed event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // The listeners should be persistent, but not primed.
+ checkPersistentListeners({ primed: false });
+ document.getElementById("tabmail").closeTab();
+ }
+
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction.js
new file mode 100644
index 0000000000..4c48d835b4
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction.js
@@ -0,0 +1,337 @@
+/* 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/. */
+
+let account;
+let messages;
+let tabmail = document.getElementById("tabmail");
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ messages = subFolders[0].messages;
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+// This test uses a command from the menus API to open the popup.
+add_task(async function test_popup_open_with_menu_command() {
+ info("3-pane tab");
+ {
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-menu-command",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ }
+
+ info("Message tab");
+ {
+ await openMessageInTab(messages.getNext());
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-menu-command",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+
+ document.getElementById("tabmail").closeTab();
+ }
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-menu-command",
+ window: messageWindow.messageBrowser.contentWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+
+ messageWindow.close();
+ }
+});
+
+add_task(async function test_theme_icons() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "message_display_action@mochi.test",
+ },
+ },
+ message_display_action: {
+ default_title: "default",
+ default_icon: "default.png",
+ theme_icons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ let uuid = extension.uuid;
+ let button = aboutMessage.document.getElementById(
+ "message_display_action_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ Assert.equal(
+ aboutMessage.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/light.png")`,
+ `Dark theme should use light icon.`
+ );
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ Assert.equal(
+ aboutMessage.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/dark.png")`,
+ `Light theme should use dark icon.`
+ );
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ Assert.equal(
+ aboutMessage.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/default.png")`,
+ `Default theme should use default icon.`
+ );
+
+ await extension.unload();
+}).skip(); // TODO (Bug 1828322)
+
+add_task(async function test_button_order() {
+ info("3-pane tab");
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ toolbar: "header-view-toolbar",
+ },
+ {
+ name: "addon2",
+ toolbar: "header-view-toolbar",
+ },
+ ],
+ tabmail.currentAboutMessage,
+ "message_display_action"
+ );
+
+ info("Message tab");
+ await openMessageInTab(messages.getNext());
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ toolbar: "header-view-toolbar",
+ },
+ {
+ name: "addon2",
+ toolbar: "header-view-toolbar",
+ },
+ ],
+ tabmail.currentAboutMessage,
+ "message_display_action"
+ );
+ tabmail.closeTab();
+
+ info("Message window");
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ toolbar: "header-view-toolbar",
+ },
+ {
+ name: "addon2",
+ toolbar: "header-view-toolbar",
+ },
+ ],
+ messageWindow.messageBrowser.contentWindow,
+ "message_display_action"
+ );
+ messageWindow.close();
+});
+
+add_task(async function test_upgrade() {
+ // Add a message_display_action, to make sure the currentSet has been initialized.
+ let extension1 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension1",
+ applications: { gecko: { id: "Extension1@mochi.test" } },
+ message_display_action: {
+ default_title: "Extension1",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension1 ready");
+ },
+ });
+ await extension1.startup();
+ await extension1.awaitMessage("Extension1 ready");
+
+ // Add extension without a message_display_action.
+ let extension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 ready");
+ },
+ });
+ await extension2.startup();
+ await extension2.awaitMessage("Extension2 ready");
+
+ // Update the extension, now including a message_display_action.
+ let updatedExtension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "2.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ message_display_action: {
+ default_title: "Extension2",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 updated");
+ },
+ });
+ await updatedExtension2.startup();
+ await updatedExtension2.awaitMessage("Extension2 updated");
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ let button = aboutMessage.document.getElementById(
+ "extension2_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+
+ Assert.ok(button, "Button should exist");
+ await extension1.unload();
+ await extension2.unload();
+ await updatedExtension2.unload();
+});
+
+add_task(async function test_iconPath() {
+ // String values for the default_icon manifest entry have been tested in the
+ // theme_icons test already. Here we test imagePath objects for the manifest key
+ // and string values as well as objects for the setIcons() function.
+ let files = {
+ "background.js": async () => {
+ await window.sendMessage("checkState", "icon1.png");
+
+ // TODO: Figure out why this isn't working properly.
+ // await browser.messageDisplayAction.setIcon({ path: "icon2.png" });
+ // await window.sendMessage("checkState", "icon2.png");
+
+ // await browser.messageDisplayAction.setIcon({ path: { 16: "icon3.png" } });
+ // await window.sendMessage("checkState", "icon3.png");
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ applications: {
+ gecko: {
+ id: "message_display_action@mochi.test",
+ },
+ },
+ message_display_action: {
+ default_title: "default",
+ default_icon: { 16: "icon1.png" },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ extension.onMessage("checkState", async expected => {
+ let uuid = extension.uuid;
+ let button = aboutMessage.document.getElementById(
+ "message_display_action_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+
+ Assert.equal(
+ aboutMessage.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/${expected}")`,
+ `Icon path should be correct.`
+ );
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click.js
new file mode 100644
index 0000000000..96493c475a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click.js
@@ -0,0 +1,294 @@
+/* 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/. */
+
+let account;
+let messages;
+let tabmail = document.getElementById("tabmail");
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ messages = subFolders[0].messages;
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+// This test clicks on the action button to open the popup.
+add_task(async function test_popup_open_with_click() {
+ info("3-pane tab");
+ {
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ }
+
+ info("Message tab");
+ {
+ await openMessageInTab(messages.getNext());
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ document.getElementById("tabmail").closeTab();
+ }
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: messageWindow.messageBrowser.contentWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ messageWindow.close();
+ }
+});
+
+// This test uses openPopup() to open the popup in a message window.
+add_task(async function test_popup_open_with_openPopup_in_message_window() {
+ let files = {
+ "background.js": async () => {
+ let windows = await browser.windows.getAll();
+ let mailWindow = windows.find(window => window.type == "normal");
+ let messageWindow = windows.find(
+ window => window.type == "messageDisplay"
+ );
+ browser.test.assertTrue(!!mailWindow, "should have found a mailWindow");
+ browser.test.assertTrue(
+ !!messageWindow,
+ "should have found a messageWindow"
+ );
+
+ let tabs = await browser.tabs.query({});
+ let mailTab = tabs.find(tab => tab.type == "mail");
+ browser.test.assertTrue(!!mailTab, "should have found a mailTab");
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(mailTab.id);
+ browser.test.assertTrue(!!msg, "should display a message");
+
+ // The test starts with an opened messageWindow, the message_display_action
+ // is allowed there and should be visible, openPopup() should succeed.
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have succeeded while the messageWindow is active"
+ );
+ await window.waitForMessage();
+
+ // Specifically open the message_display_action of the mailWindow, since we
+ // loaded a message, openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup({
+ windowId: mailWindow.id,
+ }),
+ "openPopup() should have succeeded when explicitly requesting the mailWindow"
+ );
+ await window.waitForMessage();
+ // Mail window should have focus now.
+ browser.test.assertTrue(
+ (await browser.windows.get(mailWindow.id)).focused,
+ "mailWindow should be focused"
+ );
+
+ // Disable the message_display_action, openPopup() should fail.
+ await browser.messageDisplayAction.disable();
+ browser.test.assertFalse(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have failed after the action_button was disabled"
+ );
+
+ // Enable the message_display_action, openPopup() should succeed.
+ await browser.messageDisplayAction.enable();
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have succeeded after the action_button was enabled again"
+ );
+ await window.waitForMessage();
+
+ // Create content tab, the message_display_action is not allowed there and
+ // should not be visible, openPopup() should fail.
+ let contentTab = await browser.tabs.create({
+ url: "https://www.example.com",
+ });
+ browser.test.assertFalse(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have failed while the content tab is active"
+ );
+
+ // Close the content tab and return to the mail space, the message_display_action
+ // should be visible again, openPopup() should succeed.
+ await browser.tabs.remove(contentTab.id);
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have succeeded after the content tab was closed"
+ );
+ await window.waitForMessage();
+
+ // Load a webpage into the mailTab, the message_display_action should not
+ // be shown and openPopup() should fail
+ await browser.tabs.update(mailTab.id, { url: "https://www.example.com" });
+ browser.test.assertFalse(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have failed while the mail tab shows a webpage"
+ );
+
+ // Open a message in a tab, the message_display_action should be shown and
+ // openPopup() should succeed.
+ let messageTab = await browser.messageDisplay.open({
+ active: true,
+ location: "tab",
+ messageId: msg.id,
+ windowId: mailWindow.id,
+ });
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have succeeded in a message tab"
+ );
+ await window.waitForMessage();
+
+ // Create a popup window, which does not have a message_display_action, openPopup()
+ // should fail.
+ let popupWindow = await browser.windows.create({
+ type: "popup",
+ url: "https://www.example.com",
+ });
+ browser.test.assertTrue(
+ (await browser.windows.get(popupWindow.id)).focused,
+ "popupWindow should be focused"
+ );
+ browser.test.assertFalse(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have failed while the popup window is active"
+ );
+
+ // Specifically open the message_display_action of the messageWindow, should become
+ // focused and openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup({
+ windowId: messageWindow.id,
+ }),
+ "openPopup() should have succeeded when explicitly requesting the messageWindow"
+ );
+ await window.waitForMessage();
+ browser.test.assertTrue(
+ (await browser.windows.get(messageWindow.id)).focused,
+ "messageWindow should be focused"
+ );
+
+ // The messageWindow is focused now, openPopup() should succeed.
+ browser.test.assertTrue(
+ await browser.messageDisplayAction.openPopup(),
+ "openPopup() should have succeeded while the messageWindow is active"
+ );
+ await window.waitForMessage();
+
+ // Close the popup window, the extra message tab and finish
+ await browser.windows.remove(popupWindow.id);
+ await browser.tabs.remove(messageTab.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ browser.test.sendMessage("popup opened");
+ window.close();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "message_display_action_openPopup@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead"],
+ message_display_action: {
+ default_title: "default",
+ default_popup: "popup.html",
+ },
+ },
+ });
+
+ extension.onMessage("popup opened", async () => {
+ // Wait a moment to make sure the popup has closed.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 150));
+ extension.sendMessage();
+ });
+
+ let messageWindow = await openMessageInWindow(messages.getNext());
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ messageWindow.close();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click_mv3_event_pages.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click_mv3_event_pages.js
new file mode 100644
index 0000000000..9f72bf4c99
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_popup_click_mv3_event_pages.js
@@ -0,0 +1,113 @@
+/* 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/. */
+
+let account;
+let messages;
+let tabmail = document.getElementById("tabmail");
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ messages = subFolders[0].messages;
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+async function subtest_popup_open_with_click_MV3_event_pages(
+ terminateBackground
+) {
+ info("3-pane tab");
+ {
+ let testConfig = {
+ manifest_version: 3,
+ terminateBackground,
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ }
+
+ info("Message tab");
+ {
+ await openMessageInTab(messages.getNext());
+ let testConfig = {
+ manifest_version: 3,
+ terminateBackground,
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: tabmail.currentAboutMessage,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ tabmail.closeTab();
+ }
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ manifest_version: 3,
+ terminateBackground,
+ actionType: "message_display_action",
+ testType: "open-with-mouse-click",
+ window: messageWindow.messageBrowser.contentWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+
+ messageWindow.close();
+ }
+}
+// This MV3 test clicks on the action button to open the popup.
+add_task(async function test_event_pages_without_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(false);
+});
+// This MV3 test clicks on the action button to open the popup (background termination).
+add_task(async function test_event_pages_with_background_termination() {
+ await subtest_popup_open_with_click_MV3_event_pages(true);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_properties.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_properties.js
new file mode 100644
index 0000000000..694d352090
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_properties.js
@@ -0,0 +1,184 @@
+/* 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 () => {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test", null);
+ let folder = rootFolder.getChildNamed("test");
+ createMessages(folder, 1);
+ let [message] = [...folder.messages];
+
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: folder.URI,
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+
+ await openMessageInTab(message);
+ await openMessageInWindow(message);
+ await new Promise(resolve => executeSoon(resolve));
+
+ let files = {
+ "background.js": async () => {
+ async function checkProperty(property, expectedDefault, ...expected) {
+ browser.test.log(
+ `${property}: ${expectedDefault}, ${expected.join(", ")}`
+ );
+
+ browser.test.assertEq(
+ expectedDefault,
+ await browser.messageDisplayAction[property]({})
+ );
+ for (let i = 0; i < 3; i++) {
+ browser.test.assertEq(
+ expected[i],
+ await browser.messageDisplayAction[property]({ tabId: tabIDs[i] })
+ );
+ }
+
+ await window.sendMessage("checkProperty", property, expected);
+ }
+
+ let tabs = await browser.tabs.query({});
+ browser.test.assertEq(3, tabs.length);
+ let tabIDs = tabs.map(t => t.id);
+
+ await checkProperty("isEnabled", true, true, true, true);
+ await browser.messageDisplayAction.disable();
+ await checkProperty("isEnabled", false, false, false, false);
+ await browser.messageDisplayAction.enable(tabIDs[0]);
+ await checkProperty("isEnabled", false, true, false, false);
+ await browser.messageDisplayAction.enable();
+ await checkProperty("isEnabled", true, true, true, true);
+ await browser.messageDisplayAction.disable();
+ await checkProperty("isEnabled", false, true, false, false);
+ await browser.messageDisplayAction.disable(tabIDs[0]);
+ await checkProperty("isEnabled", false, false, false, false);
+ await browser.messageDisplayAction.enable();
+ await checkProperty("isEnabled", true, false, true, true);
+
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+ await browser.messageDisplayAction.setTitle({
+ tabId: tabIDs[2],
+ title: "tab2",
+ });
+ await checkProperty("getTitle", "default", "default", "default", "tab2");
+ await browser.messageDisplayAction.setTitle({ title: "new" });
+ await checkProperty("getTitle", "new", "new", "new", "tab2");
+ await browser.messageDisplayAction.setTitle({
+ tabId: tabIDs[1],
+ title: "tab1",
+ });
+ await checkProperty("getTitle", "new", "new", "tab1", "tab2");
+ await browser.messageDisplayAction.setTitle({
+ tabId: tabIDs[2],
+ title: null,
+ });
+ await checkProperty("getTitle", "new", "new", "tab1", "new");
+ await browser.messageDisplayAction.setTitle({ title: null });
+ await checkProperty("getTitle", "default", "default", "tab1", "default");
+ await browser.messageDisplayAction.setTitle({
+ tabId: tabIDs[1],
+ title: null,
+ });
+ await checkProperty(
+ "getTitle",
+ "default",
+ "default",
+ "default",
+ "default"
+ );
+
+ await browser.tabs.remove(tabIDs[0]);
+ await browser.tabs.remove(tabIDs[1]);
+ await browser.tabs.remove(tabIDs[2]);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ applications: {
+ gecko: {
+ id: "message_display_action_properties@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ message_display_action: {
+ default_title: "default",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let mainWindowTabs = tabmail.tabInfo;
+ is(mainWindowTabs.length, 2);
+
+ let messageWindow = Services.wm.getMostRecentWindow("mail:messageWindow");
+ let messageWindowButton =
+ messageWindow.messageBrowser.contentDocument.getElementById(
+ "message_display_action_properties_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+
+ extension.onMessage("checkProperty", async (property, expected) => {
+ function checkButton(button, expectedIndex) {
+ switch (property) {
+ case "isEnabled":
+ is(
+ button.disabled,
+ !expected[expectedIndex],
+ `button ${expectedIndex} enabled state`
+ );
+ break;
+ case "getTitle":
+ is(
+ button.getAttribute("label"),
+ expected[expectedIndex],
+ `button ${expectedIndex} label`
+ );
+ break;
+ }
+ }
+
+ for (let i = 0; i < 2; i++) {
+ tabmail.switchToTab(mainWindowTabs[i]);
+ let aboutMessage = mainWindowTabs[i].chromeBrowser.contentWindow;
+ if (aboutMessage.location.href == "about:3pane") {
+ aboutMessage = aboutMessage.messageBrowser.contentWindow;
+ }
+ await new Promise(resolve => aboutMessage.requestAnimationFrame(resolve));
+ checkButton(
+ aboutMessage.document.getElementById(
+ "message_display_action_properties_mochi_test-messageDisplayAction-toolbarbutton"
+ ),
+ i
+ );
+ }
+ checkButton(messageWindowButton, 2);
+
+ extension.sendMessage();
+ });
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ messageWindow.close();
+ tabmail.closeOtherTabs(0);
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayScripts.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayScripts.js
new file mode 100644
index 0000000000..3f75bcb61c
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplayScripts.js
@@ -0,0 +1,636 @@
+/* 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/. */
+
+let account, messages;
+let tabmail, about3Pane, messagePane;
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("messageDisplayScripts", null);
+ let folder = rootFolder.getChildNamed("messageDisplayScripts");
+ createMessages(folder, 11);
+ messages = [...folder.messages];
+
+ tabmail = document.getElementById("tabmail");
+ about3Pane = tabmail.currentTabInfo.chromeBrowser.contentWindow;
+ about3Pane.displayFolder(folder.URI);
+ messagePane =
+ about3Pane.messageBrowser.contentDocument.getElementById("messagepane");
+});
+
+async function checkMessageBody(expected, message, browser) {
+ if (message && "textContent" in expected) {
+ let body = await new Promise(resolve => {
+ window.MsgHdrToMimeMessage(message, null, (msgHdr, mimeMessage) => {
+ resolve(mimeMessage.parts[0].body);
+ });
+ });
+ // Ignore Windows line-endings, they're not important here.
+ body = body.replace(/\r/g, "");
+ expected.textContent = body + expected.textContent;
+ }
+ if (!browser) {
+ browser = messagePane;
+ }
+
+ await checkContent(browser, expected);
+}
+
+/** Tests browser.tabs.insertCSS and browser.tabs.removeCSS. */
+add_task(async function testInsertRemoveCSS() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ mailTab: true });
+ await window.sendMessage();
+
+ await browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, {
+ code: "body { background-color: lime; }",
+ });
+ await window.sendMessage();
+
+ await browser.tabs.insertCSS(tab.id, { file: "test.css" });
+ await window.sendMessage();
+
+ await browser.tabs.removeCSS(tab.id, { file: "test.css" });
+
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: green; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesModify"],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(messagePane);
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ backgroundColor: "rgba(0, 0, 0, 0)" }, messages[0]);
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ backgroundColor: "rgb(0, 255, 0)" }, messages[0]);
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ backgroundColor: "rgba(0, 0, 0, 0)" }, messages[0]);
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ backgroundColor: "rgb(0, 128, 0)" }, messages[0]);
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody({ backgroundColor: "rgba(0, 0, 0, 0)" }, messages[0]);
+
+ await extension.unload();
+});
+
+/** Tests browser.tabs.insertCSS fails without the "messagesModify" permission. */
+add_task(async function testInsertRemoveCSSNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ mailTab: true });
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ code: "body { background-color: darkred; }",
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, { file: "test.css" }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.insertCSS(tab.id, {
+ file: "test.css",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "insertCSS without permission should throw"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 1;
+ await awaitBrowserLoaded(messagePane);
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ },
+ messages[1]
+ );
+
+ await extension.unload();
+});
+
+/** Tests browser.tabs.executeScript. */
+add_task(async function testExecuteScript() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ mailTab: true });
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ });
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, { file: "test.js" });
+
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.querySelector(".moz-text-flowed").textContent +=
+ "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesModify"],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 2;
+ await awaitBrowserLoaded(messagePane);
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ textContent: "" }, messages[2]);
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ foo: "bar" }, messages[2]);
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody(
+ {
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[2]
+ );
+
+ await extension.unload();
+});
+
+/** Tests browser.tabs.executeScript fails without the "messagesModify" permission. */
+add_task(async function testExecuteScriptNoPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ mailTab: true });
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ code: `document.body.setAttribute("foo", "bar");`,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, { file: "test.js" }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, {
+ file: "test.js",
+ matchAboutBlank: true,
+ }),
+ /Missing host permission for the tab/,
+ "executeScript without permission should throw"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "test.js": () => {
+ document.body.querySelector(".moz-text-flowed").textContent +=
+ "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 3;
+ await awaitBrowserLoaded(messagePane);
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody({ foo: null, textContent: "" }, messages[3]);
+
+ await extension.unload();
+});
+
+/** Tests the messenger alias is available. */
+add_task(async function testExecuteScriptAlias() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [tab] = await browser.tabs.query({ mailTab: true });
+ await window.sendMessage();
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `document.body.querySelector(".moz-text-flowed").textContent +=
+ messenger.runtime.getManifest().applications.gecko.id;`,
+ });
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ applications: { gecko: { id: "message_display_scripts@mochitest" } },
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesModify"],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 4;
+ await awaitBrowserLoaded(messagePane);
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ await checkMessageBody({ textContent: "" }, messages[4]);
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody(
+ { textContent: "message_display_scripts@mochitest" },
+ messages[4]
+ );
+
+ await extension.unload();
+});
+
+/**
+ * Tests browser.messageDisplayScripts.register correctly adds CSS and
+ * JavaScript to message display windows. Also tests calling `unregister`
+ * on the returned object.
+ */
+add_task(async function testRegister() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Keep track of registered scrips being executed and ready.
+ browser.runtime.onMessage.addListener((message, sender) => {
+ if (message == "LOADED") {
+ window.sendMessage("ScriptLoaded", sender.tab.id);
+ }
+ });
+
+ let registeredScript = await browser.messageDisplayScripts.register({
+ css: [{ code: "body { color: white }" }, { file: "test.css" }],
+ js: [
+ { code: `document.body.setAttribute("foo", "bar");` },
+ { file: "test.js" },
+ ],
+ });
+
+ browser.test.onMessage.addListener(async (message, data) => {
+ switch (message) {
+ case "Unregister":
+ await registeredScript.unregister();
+ browser.test.notifyPass("finished");
+ break;
+
+ case "RuntimeMessageTest":
+ try {
+ browser.test.assertEq(
+ `Received: ${data.tabId}`,
+ await browser.tabs.sendMessage(data.tabId, data.tabId)
+ );
+ } catch (ex) {
+ browser.test.fail(
+ `Failed to send message to messageDisplayScript: ${ex}`
+ );
+ }
+ browser.test.sendMessage("RuntimeMessageTestDone");
+ break;
+ }
+ });
+
+ window.sendMessage("Ready");
+ },
+ "test.css": "body { background-color: green; }",
+ "test.js": () => {
+ document.body.querySelector(".moz-text-flowed").textContent +=
+ "Hey look, the script ran!";
+ browser.runtime.onMessage.addListener(async message => {
+ return `Received: ${message}`;
+ });
+ browser.runtime.sendMessage("LOADED");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesModify", "<all_urls>"],
+ },
+ });
+
+ about3Pane.threadTree.selectedIndex = 5;
+ await awaitBrowserLoaded(messagePane);
+
+ extension.startup();
+ await extension.awaitMessage("Ready");
+
+ // Check a message that was already loaded. This tab has not loaded the
+ // registered scripts.
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ },
+ messages[5]
+ );
+
+ // Load a new message and check it is modified.
+ let loadPromise = extension.awaitMessage("ScriptLoaded");
+ about3Pane.threadTree.selectedIndex = 6;
+ let tabId = await loadPromise;
+
+ await checkMessageBody(
+ {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[6]
+ );
+ // Check runtime messaging.
+ let testDonePromise = extension.awaitMessage("RuntimeMessageTestDone");
+ extension.sendMessage("RuntimeMessageTest", { tabId });
+ await testDonePromise;
+
+ // Open the message in a new tab.
+ loadPromise = extension.awaitMessage("ScriptLoaded");
+ let messageTab = await openMessageInTab(messages[6]);
+ let messageTabId = await loadPromise;
+ Assert.equal(tabmail.tabInfo.length, 2);
+
+ await checkMessageBody(
+ {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[6],
+ messageTab.browser
+ );
+ // Check runtime messaging.
+ testDonePromise = extension.awaitMessage("RuntimeMessageTestDone");
+ extension.sendMessage("RuntimeMessageTest", { tabId: messageTabId });
+ await testDonePromise;
+
+ // Open a content tab. The CSS and script shouldn't apply.
+ let contentTab = window.openContentTab("http://mochi.test:8888/");
+ // Let's wait a while and see if anything happens:
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: null,
+ },
+ undefined,
+ contentTab.browser
+ );
+
+ // Closing this tab should bring us back to the message in a tab.
+ tabmail.closeTab(contentTab);
+ Assert.equal(tabmail.currentTabInfo, messageTab);
+ await checkMessageBody(
+ {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[6],
+ messageTab.browser
+ );
+ // Check runtime messaging.
+ testDonePromise = extension.awaitMessage("RuntimeMessageTestDone");
+ extension.sendMessage("RuntimeMessageTest", { tabId: messageTabId });
+ await testDonePromise;
+
+ // Open the message in a new window.
+ loadPromise = extension.awaitMessage("ScriptLoaded");
+ let newWindow = await openMessageInWindow(messages[7]);
+ let newWindowMessagePane = newWindow.getBrowser();
+ let windowTabId = await loadPromise;
+
+ await checkMessageBody(
+ {
+ backgroundColor: "rgb(0, 128, 0)",
+ color: "rgb(255, 255, 255)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[7],
+ newWindowMessagePane
+ );
+ // Check runtime messaging.
+ testDonePromise = extension.awaitMessage("RuntimeMessageTestDone");
+ extension.sendMessage("RuntimeMessageTest", { tabId: windowTabId });
+ await testDonePromise;
+
+ // Unregister.
+ extension.sendMessage("Unregister");
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ // Check the CSS is unloaded from the message in a tab.
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[6],
+ messageTab.browser
+ );
+
+ // Close the new tab.
+ tabmail.closeTab(messageTab);
+
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[6]
+ );
+
+ // Check the CSS is unloaded from the message in a window.
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ color: "rgb(0, 0, 0)",
+ foo: "bar",
+ textContent: "Hey look, the script ran!",
+ },
+ messages[7],
+ newWindowMessagePane
+ );
+
+ await BrowserTestUtils.closeWindow(newWindow);
+});
+
+/** Tests content_scripts in the manifest do not affect message display. */
+async function subtestContentScriptManifest(message, ...permissions) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "test.css": "body { background-color: red; }",
+ "test.js": () => {
+ document.body.textContent += "Hey look, the script ran!";
+ },
+ },
+ manifest: {
+ permissions,
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ css: ["test.css"],
+ js: ["test.js"],
+ match_about_blank: true,
+ match_origin_as_fallback: true,
+ },
+ ],
+ },
+ });
+
+ // match_origin_as_fallback is not implemented yet. Bug 1475831.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ },
+ message
+ );
+
+ await extension.unload();
+}
+
+add_task(async function testContentScriptManifestNoPermission() {
+ about3Pane.threadTree.selectedIndex = 7;
+ await awaitBrowserLoaded(messagePane);
+ await subtestContentScriptManifest(messages[7]);
+});
+add_task(async function testContentScriptManifest() {
+ about3Pane.threadTree.selectedIndex = 8;
+ await awaitBrowserLoaded(messagePane);
+ await subtestContentScriptManifest(messages[8], "messagesModify");
+});
+
+/** Tests registered content scripts do not affect message display. */
+async function subtestContentScriptRegister(message, ...permissions) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ await browser.contentScripts.register({
+ matches: ["<all_urls>"],
+ css: [{ file: "test.css" }],
+ js: [{ file: "test.js" }],
+ matchAboutBlank: true,
+ });
+
+ browser.test.notifyPass("finished");
+ },
+ "test.css": "body { background-color: red; }",
+ "test.js": () => {
+ document.body.querySelector(".moz-text-flowed").textContent +=
+ "Hey look, the script ran!";
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitFinish("finished");
+ await checkMessageBody(
+ {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "",
+ },
+ message
+ );
+
+ await extension.unload();
+}
+
+add_task(async function testContentScriptRegisterNoPermission() {
+ about3Pane.threadTree.selectedIndex = 9;
+ await awaitBrowserLoaded(messagePane);
+ await subtestContentScriptRegister(messages[9], "<all_urls>");
+});
+add_task(async function testContentScriptRegister() {
+ about3Pane.threadTree.selectedIndex = 10;
+ await awaitBrowserLoaded(messagePane);
+ await subtestContentScriptRegister(
+ messages[10],
+ "<all_urls>",
+ "messagesModify"
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1827032.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1827032.js
new file mode 100644
index 0000000000..ebae544585
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1827032.js
@@ -0,0 +1,38 @@
+/* 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/. */
+
+/**
+ * Test to make sure messageDisplay.getDisplayedMessage() returns null for
+ * non-message tabs.
+ */
+add_task(async function testGetDisplayedMessageInComposeTab() {
+ let files = {
+ "background.js": async () => {
+ let composeTab = await browser.compose.beginNew();
+ browser.test.assertEq(
+ composeTab.type,
+ "messageCompose",
+ "Should have found a compose tab"
+ );
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(composeTab.id);
+ browser.test.assertTrue(!msg, "Should not have found a message");
+
+ await browser.tabs.remove(composeTab.id);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1828056.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1828056.js
new file mode 100644
index 0000000000..70b9670ac1
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_bug1828056.js
@@ -0,0 +1,212 @@
+var gAccount;
+var gMessages;
+var gFolder;
+
+add_setup(() => {
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ let rootFolder = gAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+
+ gFolder = subFolders.test0;
+ gMessages = [...subFolders.test0.messages];
+});
+
+async function getTestExtension_open_msg() {
+ let files = {
+ "background.js": async () => {
+ let [location] = await window.waitForMessage();
+
+ let [mailTab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(
+ "mail",
+ mailTab.type,
+ "Should have found a mail tab."
+ );
+
+ // Get displayed message.
+ let message1 = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(
+ !!message1,
+ "We should have a displayed message."
+ );
+
+ // Open a message in the specified location and request the displayed
+ // message immediately.
+ let { message: message2, tab: messageTab } = await new Promise(
+ resolve => {
+ let createListener = async tab => {
+ browser.tabs.onCreated.removeListener(createListener);
+ let message = await browser.messageDisplay.getDisplayedMessage(
+ tab.id
+ );
+ resolve({ tab, message });
+ };
+ browser.tabs.onCreated.addListener(createListener);
+ browser.messageDisplay.open({
+ location,
+ messageId: message1.id,
+ });
+ }
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2?.id,
+ "We should see the same message."
+ );
+ browser.tabs.remove(messageTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ return ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+}
+
+/**
+ * Open a message tab and request its message immediately.
+ */
+add_task(async function test_message_tab() {
+ let extension = await getTestExtension_open_msg();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+ extension.sendMessage("tab");
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Open a message window and request its message immediately.
+ */
+add_task(async function test_message_window() {
+ let extension = await getTestExtension_open_msg();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+ extension.sendMessage("window");
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+async function getTestExtension_select_msg() {
+ let files = {
+ "background.js": async () => {
+ let [expected] = await window.waitForMessage();
+
+ let [mailTab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(
+ "mail",
+ mailTab.type,
+ "Should have found a mail tab."
+ );
+
+ // Get displayed message.
+ let message = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(!!message, "We should have a displayed message.");
+
+ await window.sendMessage("select");
+ let messages = await browser.messageDisplay.getDisplayedMessages(
+ mailTab.id
+ );
+ browser.test.assertEq(
+ expected,
+ messages.length,
+ "The returned number of messages should be correct."
+ );
+ for (let msg of messages) {
+ browser.test.assertTrue(
+ message.id != msg.id,
+ "The returned message must not be the original selected message."
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ return ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+}
+
+/**
+ * Select a single message in a mail tab and request it immediately.
+ */
+add_task(async function test_single_message() {
+ let extension = await getTestExtension_select_msg();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ extension.onMessage("select", () => {
+ about3Pane.threadTree.selectedIndex = 1;
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ extension.sendMessage(1);
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Select multiple messages in a mail tab and request them immediately.
+ */
+add_task(async function test_multiple_message() {
+ let extension = await getTestExtension_select_msg();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ extension.onMessage("select", () => {
+ about3Pane.threadTree.selectedIndices = [2, 3];
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ extension.sendMessage(2);
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_file.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_file.js
new file mode 100644
index 0000000000..a3b5c8cf0f
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_file.js
@@ -0,0 +1,221 @@
+/* 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/. */
+
+requestLongerTimeout(4);
+
+let gRootFolder;
+add_setup(async () => {
+ let account = createAccount();
+ gRootFolder = account.incomingServer.rootFolder;
+ gRootFolder.createSubfolder("testFolder", null);
+ gRootFolder.createSubfolder("otherFolder", null);
+ await createMessages(gRootFolder.getChildNamed("testFolder"), 5);
+});
+
+async function testOpenMessages(testConfig) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Verify startup conditions.
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(
+ 1,
+ accounts.length,
+ `number of accounts should be correct`
+ );
+
+ let testFolder = accounts[0].folders.find(f => f.name == "testFolder");
+ browser.test.assertTrue(!!testFolder, "folder should exist");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(
+ 5,
+ messages.length,
+ `number of messages should be correct`
+ );
+
+ // Get test properties.
+ let [testConfig] = await window.sendMessage("getTestConfig");
+
+ async function open(message, testConfig) {
+ let properties = { ...testConfig };
+ if (properties.headerMessageId) {
+ properties.headerMessageId = message.headerMessageId;
+ } else if (properties.messageId) {
+ properties.messageId = message.id;
+ } else if (properties.file) {
+ properties.file = new File(
+ [await browser.messages.getRaw(message.id)],
+ "msgfile.eml"
+ );
+ }
+ return browser.messageDisplay.open(properties);
+ }
+
+ let expectedFail;
+ let additionalWindowIdToBeRemoved;
+ if (testConfig.windowType) {
+ switch (testConfig.windowType) {
+ case "normal":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ }
+ break;
+ case "popup":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ expectedFail = `Window with ID ${secondWindow.id} is not a normal window`;
+ }
+ break;
+ case "invalid":
+ testConfig.windowId = 1234;
+ expectedFail = `Invalid window ID: 1234`;
+ break;
+ }
+ delete testConfig.windowType;
+ }
+
+ if (expectedFail) {
+ await browser.test.assertRejects(
+ open(messages[0], testConfig),
+ `${expectedFail}`,
+ "browser.messageDisplay.open() should fail with invalid windowId"
+ );
+ } else {
+ // Open multiple messages.
+ let promisedTabs = [];
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < openedTabs.length; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for message ${i}`
+ );
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ if (testConfig.file) {
+ browser.test.assertTrue(
+ messages[Math.floor(i / 2)].id != msg.id,
+ `Opened file msg should have a new message id (${
+ msg.id
+ }) and should not equal the id of the source message (${
+ messages[Math.floor(i / 2)].id
+ }) in window ${i}`
+ );
+ } else {
+ browser.test.assertEq(
+ messages[Math.floor(i / 2)].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ }
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ if (additionalWindowIdToBeRemoved) {
+ await browser.windows.remove(additionalWindowIdToBeRemoved);
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gRootFolder.getChildNamed("otherFolder"));
+
+ extension.onMessage("getTestConfig", async () => {
+ extension.sendMessage(testConfig);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function testMessageFileActiveDefault() {
+ await testOpenMessages({ file: true, active: true });
+});
+add_task(async function testMessageFileInactiveDefault() {
+ await testOpenMessages({ file: true, active: false });
+});
+add_task(async function testMessageFileActiveWindow() {
+ await testOpenMessages({
+ file: true,
+ active: true,
+ location: "window",
+ });
+});
+add_task(async function testMessageFileInactiveWindow() {
+ await testOpenMessages({
+ file: true,
+ active: false,
+ location: "window",
+ });
+});
+add_task(async function testMessageFileActiveTab() {
+ await testOpenMessages({
+ file: true,
+ active: true,
+ location: "tab",
+ });
+});
+add_task(async function testMessageFileInactiveTab() {
+ await testOpenMessages({
+ file: true,
+ active: false,
+ location: "tab",
+ });
+});
+add_task(async function testMessageFileOtherNormalWindowActiveTab() {
+ await testOpenMessages({
+ file: true,
+ active: true,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testMessageFileOtherNormalWindowInactiveTab() {
+ await testOpenMessages({
+ file: true,
+ active: false,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testMessageFileOtherPopupWindowFail() {
+ await testOpenMessages({
+ file: true,
+ location: "tab",
+ windowType: "popup",
+ });
+});
+add_task(async function testMessageFileInvalidWindowFail() {
+ await testOpenMessages({
+ file: true,
+ location: "tab",
+ windowType: "invalid",
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_headerMessageId.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_headerMessageId.js
new file mode 100644
index 0000000000..8be6aa4c2b
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_headerMessageId.js
@@ -0,0 +1,221 @@
+/* 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/. */
+
+requestLongerTimeout(4);
+
+let gRootFolder;
+add_setup(async () => {
+ let account = createAccount();
+ gRootFolder = account.incomingServer.rootFolder;
+ gRootFolder.createSubfolder("testFolder", null);
+ gRootFolder.createSubfolder("otherFolder", null);
+ await createMessages(gRootFolder.getChildNamed("testFolder"), 5);
+});
+
+async function testOpenMessages(testConfig) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Verify startup conditions.
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(
+ 1,
+ accounts.length,
+ `number of accounts should be correct`
+ );
+
+ let testFolder = accounts[0].folders.find(f => f.name == "testFolder");
+ browser.test.assertTrue(!!testFolder, "folder should exist");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(
+ 5,
+ messages.length,
+ `number of messages should be correct`
+ );
+
+ // Get test properties.
+ let [testConfig] = await window.sendMessage("getTestConfig");
+
+ async function open(message, testConfig) {
+ let properties = { ...testConfig };
+ if (properties.headerMessageId) {
+ properties.headerMessageId = message.headerMessageId;
+ } else if (properties.messageId) {
+ properties.messageId = message.id;
+ } else if (properties.file) {
+ properties.file = new File(
+ [await browser.messages.getRaw(message.id)],
+ "msgfile.eml"
+ );
+ }
+ return browser.messageDisplay.open(properties);
+ }
+
+ let expectedFail;
+ let additionalWindowIdToBeRemoved;
+ if (testConfig.windowType) {
+ switch (testConfig.windowType) {
+ case "normal":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ }
+ break;
+ case "popup":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ expectedFail = `Window with ID ${secondWindow.id} is not a normal window`;
+ }
+ break;
+ case "invalid":
+ testConfig.windowId = 1234;
+ expectedFail = `Invalid window ID: 1234`;
+ break;
+ }
+ delete testConfig.windowType;
+ }
+
+ if (expectedFail) {
+ await browser.test.assertRejects(
+ open(messages[0], testConfig),
+ `${expectedFail}`,
+ "browser.messageDisplay.open() should fail with invalid windowId"
+ );
+ } else {
+ // Open multiple messages.
+ let promisedTabs = [];
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < openedTabs.length; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for message ${i}`
+ );
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ if (testConfig.file) {
+ browser.test.assertTrue(
+ messages[Math.floor(i / 2)].id != msg.id,
+ `Opened file msg should have a new message id (${
+ msg.id
+ }) and should not equal the id of the source message (${
+ messages[Math.floor(i / 2)].id
+ }) in window ${i}`
+ );
+ } else {
+ browser.test.assertEq(
+ messages[Math.floor(i / 2)].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ }
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ if (additionalWindowIdToBeRemoved) {
+ await browser.windows.remove(additionalWindowIdToBeRemoved);
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gRootFolder.getChildNamed("otherFolder"));
+
+ extension.onMessage("getTestConfig", async () => {
+ extension.sendMessage(testConfig);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function testHeaderMessageIdActiveDefault() {
+ await testOpenMessages({ headerMessageId: true, active: true });
+});
+add_task(async function testHeaderMessageIdInactiveDefault() {
+ await testOpenMessages({ headerMessageId: true, active: false });
+});
+add_task(async function testHeaderMessageIdActiveWindow() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: true,
+ location: "window",
+ });
+});
+add_task(async function testHeaderMessageIdInactiveWindow() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: false,
+ location: "window",
+ });
+});
+add_task(async function testHeaderMessageIdActiveTab() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: true,
+ location: "tab",
+ });
+});
+add_task(async function testHeaderMessageIdInactiveTab() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: false,
+ location: "tab",
+ });
+});
+add_task(async function testHeaderMessageIdOtherNormalWindowActiveTab() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: true,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testHeaderMessageIdOtherNormalWindowInactiveTab() {
+ await testOpenMessages({
+ headerMessageId: true,
+ active: false,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testHeaderMessageIdOtherPopupWindowFail() {
+ await testOpenMessages({
+ headerMessageId: true,
+ location: "tab",
+ windowType: "popup",
+ });
+});
+add_task(async function testHeaderMessageIdInvalidWindowFail() {
+ await testOpenMessages({
+ headerMessageId: true,
+ location: "tab",
+ windowType: "invalid",
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_messageId.js b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_messageId.js
new file mode 100644
index 0000000000..47995d9ecd
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messageDisplay_open_messageId.js
@@ -0,0 +1,221 @@
+/* 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/. */
+
+requestLongerTimeout(4);
+
+let gRootFolder;
+add_setup(async () => {
+ let account = createAccount();
+ gRootFolder = account.incomingServer.rootFolder;
+ gRootFolder.createSubfolder("testFolder", null);
+ gRootFolder.createSubfolder("otherFolder", null);
+ await createMessages(gRootFolder.getChildNamed("testFolder"), 5);
+});
+
+async function testOpenMessages(testConfig) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Verify startup conditions.
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(
+ 1,
+ accounts.length,
+ `number of accounts should be correct`
+ );
+
+ let testFolder = accounts[0].folders.find(f => f.name == "testFolder");
+ browser.test.assertTrue(!!testFolder, "folder should exist");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(
+ 5,
+ messages.length,
+ `number of messages should be correct`
+ );
+
+ // Get test properties.
+ let [testConfig] = await window.sendMessage("getTestConfig");
+
+ async function open(message, testConfig) {
+ let properties = { ...testConfig };
+ if (properties.headerMessageId) {
+ properties.headerMessageId = message.headerMessageId;
+ } else if (properties.messageId) {
+ properties.messageId = message.id;
+ } else if (properties.file) {
+ properties.file = new File(
+ [await browser.messages.getRaw(message.id)],
+ "msgfile.eml"
+ );
+ }
+ return browser.messageDisplay.open(properties);
+ }
+
+ let expectedFail;
+ let additionalWindowIdToBeRemoved;
+ if (testConfig.windowType) {
+ switch (testConfig.windowType) {
+ case "normal":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ }
+ break;
+ case "popup":
+ {
+ let secondWindow = await browser.windows.create({
+ type: testConfig.windowType,
+ });
+ testConfig.windowId = secondWindow.id;
+ additionalWindowIdToBeRemoved = secondWindow.id;
+ expectedFail = `Window with ID ${secondWindow.id} is not a normal window`;
+ }
+ break;
+ case "invalid":
+ testConfig.windowId = 1234;
+ expectedFail = `Invalid window ID: 1234`;
+ break;
+ }
+ delete testConfig.windowType;
+ }
+
+ if (expectedFail) {
+ await browser.test.assertRejects(
+ open(messages[0], testConfig),
+ `${expectedFail}`,
+ "browser.messageDisplay.open() should fail with invalid windowId"
+ );
+ } else {
+ // Open multiple messages.
+ let promisedTabs = [];
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[0], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[1], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ promisedTabs.push(open(messages[2], testConfig));
+ let openedTabs = await Promise.allSettled(promisedTabs);
+ for (let i = 0; i < openedTabs.length; i++) {
+ browser.test.assertEq(
+ "fulfilled",
+ openedTabs[i].status,
+ `Promise for the opened message should have been fulfilled for message ${i}`
+ );
+
+ let msg = await browser.messageDisplay.getDisplayedMessage(
+ openedTabs[i].value.id
+ );
+ if (testConfig.file) {
+ browser.test.assertTrue(
+ messages[Math.floor(i / 2)].id != msg.id,
+ `Opened file msg should have a new message id (${
+ msg.id
+ }) and should not equal the id of the source message (${
+ messages[Math.floor(i / 2)].id
+ }) in window ${i}`
+ );
+ } else {
+ browser.test.assertEq(
+ messages[Math.floor(i / 2)].id,
+ msg.id,
+ `Should see the correct message in window ${i}`
+ );
+ }
+ await browser.tabs.remove(openedTabs[i].value.id);
+ }
+ }
+
+ if (additionalWindowIdToBeRemoved) {
+ await browser.windows.remove(additionalWindowIdToBeRemoved);
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gRootFolder.getChildNamed("otherFolder"));
+
+ extension.onMessage("getTestConfig", async () => {
+ extension.sendMessage(testConfig);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function testMessageIdActiveDefault() {
+ await testOpenMessages({ messageId: true, active: true });
+});
+add_task(async function testMessageIdInactiveDefault() {
+ await testOpenMessages({ messageId: true, active: false });
+});
+add_task(async function testMessageIdActiveWindow() {
+ await testOpenMessages({
+ messageId: true,
+ active: true,
+ location: "window",
+ });
+});
+add_task(async function testMessageIdInactiveWindow() {
+ await testOpenMessages({
+ messageId: true,
+ active: false,
+ location: "window",
+ });
+});
+add_task(async function testMessageIdActiveTab() {
+ await testOpenMessages({
+ messageId: true,
+ active: true,
+ location: "tab",
+ });
+});
+add_task(async function testMessageIdInActiveTab() {
+ await testOpenMessages({
+ messageId: true,
+ active: false,
+ location: "tab",
+ });
+});
+add_task(async function testMessageIdOtherNormalWindowActiveTab() {
+ await testOpenMessages({
+ messageId: true,
+ active: true,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testMessageIdOtherNormalWindowInactiveTab() {
+ await testOpenMessages({
+ messageId: true,
+ active: false,
+ location: "tab",
+ windowType: "normal",
+ });
+});
+add_task(async function testMessageIdOtherPopupWindowFail() {
+ await testOpenMessages({
+ messageId: true,
+ location: "tab",
+ windowType: "popup",
+ });
+});
+add_task(async function testMessageIdInvalidWindowFail() {
+ await testOpenMessages({
+ messageId: true,
+ location: "tab",
+ windowType: "invalid",
+ });
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_message_external.js b/comm/mail/components/extensions/test/browser/browser_ext_message_external.js
new file mode 100644
index 0000000000..8a4cf7ea30
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_message_external.js
@@ -0,0 +1,427 @@
+/* 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 gAccount;
+var gFolder;
+
+add_setup(() => {
+ gAccount = createAccount();
+ let rootFolder = gAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+ gFolder = rootFolder.getChildNamed("test0");
+ createMessages(gFolder, 5);
+});
+
+add_task(async function testExternalMessage() {
+ // Copy eml file into the profile folder, where we can delete it during the test.
+ let profileDir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ profileDir.initWithPath(PathUtils.profileDir);
+ let messageFile = new FileUtils.File(
+ getTestFilePath("messages/attachedMessageSample.eml")
+ );
+ messageFile.copyTo(profileDir, "attachedMessageSample.eml");
+
+ let files = {
+ "background.js": async () => {
+ let platformInfo = await browser.runtime.getPlatformInfo();
+
+ const emlData = {
+ openExternalFileMessage: {
+ headerMessageId: "sample.eml@mime.sample",
+ author: "Batman <bruce@wayne-enterprises.com>",
+ ccList: ["Robin <damian@wayne-enterprises.com>"],
+ subject: "Attached message with attachments",
+ attachments: 2,
+ size: 9754,
+ external: true,
+ read: null,
+ recipients: ["Heinz <mueller@example.com>"],
+ date: 958796995000,
+ body: "This message has one normal attachment and one email attachment",
+ },
+ openExternalAttachedMessage: {
+ headerMessageId: "sample-attached.eml@mime.sample",
+ author: "Superman <clark.kent@dailyplanet.com>",
+ ccList: ["Jimmy <jimmy.Olsen@dailyplanet.com>"],
+ subject: "Test message",
+ attachments: 3,
+ size: platformInfo.os == "win" ? 6947 : 6825, // Line endings.
+ external: true,
+ read: null,
+ recipients: ["Heinz Müller <mueller@examples.com>"],
+ date: 958606367000,
+ body: "Die Hasen und die Frösche",
+ },
+ };
+
+ let [{ displayedFolder, windowId: mainWindowId }] =
+ await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+
+ // Open an external file, either from file or via API.
+ async function openAndVerifyExternalMessage(
+ actionOrMessageId,
+ location,
+ expected
+ ) {
+ let tabPromise = window.waitForEvent("tabs.onCreated");
+ let messagePromise = window.waitForEvent(
+ "messageDisplay.onMessageDisplayed"
+ );
+
+ let returnedMsgTab;
+ if (Number.isInteger(actionOrMessageId)) {
+ returnedMsgTab = await browser.messageDisplay.open({
+ messageId: actionOrMessageId,
+ location,
+ });
+ } else {
+ await window.sendMessage(actionOrMessageId, location);
+ }
+ let [msgTab] = await tabPromise;
+ let [openedMsgTab, message] = await messagePromise;
+
+ if ("windowId" in expected) {
+ browser.test.assertEq(
+ expected.windowId,
+ msgTab.windowId,
+ "The opened tab should belong to the correct window"
+ );
+ } else {
+ browser.test.assertTrue(
+ msgTab.windowId != mainWindowId,
+ "The opened tab should not belong to the main window"
+ );
+ }
+ browser.test.assertEq(
+ msgTab.id,
+ openedMsgTab.id,
+ "The opened tab should match the onMessageDisplayed event tab"
+ );
+
+ if (Number.isInteger(actionOrMessageId)) {
+ browser.test.assertEq(
+ msgTab.id,
+ returnedMsgTab.id,
+ "The returned tab should match the onMessageDisplayed event tab"
+ );
+ }
+
+ if ("messageId" in expected) {
+ browser.test.assertEq(
+ expected.messageId,
+ message.id,
+ "The message should have the same ID as it did previously"
+ );
+ }
+
+ // Test the received message and the re-queried message.
+ for (let msg of [message, await browser.messages.get(message.id)]) {
+ browser.test.assertEq(
+ message.id,
+ msg.id,
+ "`The opened message should be correct."
+ );
+ browser.test.assertEq(
+ expected.author,
+ msg.author,
+ "The author should be correct"
+ );
+ browser.test.assertEq(
+ expected.headerMessageId,
+ msg.headerMessageId,
+ "The headerMessageId should be correct"
+ );
+ browser.test.assertEq(
+ expected.subject,
+ msg.subject,
+ "The subject should be correct"
+ );
+ browser.test.assertEq(
+ expected.size,
+ msg.size,
+ "The size should be correct"
+ );
+ browser.test.assertEq(
+ expected.external,
+ msg.external,
+ "The external flag should be correct"
+ );
+ browser.test.assertEq(
+ expected.date,
+ msg.date.getTime(),
+ "The date should be correct"
+ );
+ window.assertDeepEqual(
+ expected.recipients,
+ msg.recipients,
+ "The recipients should be correct"
+ );
+ window.assertDeepEqual(
+ expected.ccList,
+ msg.ccList,
+ "The carbon copy recipients should be correct"
+ );
+ }
+
+ let raw = await browser.messages.getRaw(message.id);
+ browser.test.assertTrue(
+ raw.startsWith(`Message-ID: <${expected.headerMessageId}>`),
+ "Raw msg should be correct"
+ );
+
+ let full = await browser.messages.getFull(message.id);
+ browser.test.assertTrue(
+ full.headers["message-id"].includes(`<${expected.headerMessageId}>`),
+ "Message-ID of full msg should be correct"
+ );
+ browser.test.assertTrue(
+ full.parts[0].parts[0].body.includes(expected.body),
+ "Body of full msg should be correct"
+ );
+
+ let attachments = await browser.messages.listAttachments(message.id);
+ browser.test.assertEq(
+ expected.attachments,
+ attachments.length,
+ "Should find the correct number of attachments"
+ );
+
+ await browser.tabs.remove(msgTab.id);
+ return message;
+ }
+
+ // Check API operations on the given message.
+ async function testMessageOperations(message) {
+ // Test copying a file message into Thunderbird.
+ let { messages: messagesBeforeCopy } = await browser.messages.list(
+ displayedFolder
+ );
+ await browser.messages.copy([message.id], displayedFolder);
+ let { messages: messagesAfterCopy } = await browser.messages.list(
+ displayedFolder
+ );
+ browser.test.assertEq(
+ messagesBeforeCopy.length + 1,
+ messagesAfterCopy.length,
+ "The file message should have been copied into the current folder"
+ );
+ let { messages } = await browser.messages.query({
+ folder: displayedFolder,
+ headerMessageId: message.headerMessageId,
+ });
+ browser.test.assertTrue(
+ messages.length == 1,
+ "A query should find the new copied file message in the current folder"
+ );
+
+ // All other operations should fail.
+ await browser.test.assertRejects(
+ browser.messages.update(message.id, {}),
+ `Error updating message: Operation not permitted for external messages`,
+ "Updating external messages should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.delete([message.id]),
+ `Error deleting message: Operation not permitted for external messages`,
+ "Deleting external messages should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.archive([message.id]),
+ `Error archiving message: Operation not permitted for external messages`,
+ "Archiving external messages should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.move([message.id], displayedFolder),
+ `Error moving message: Operation not permitted for external messages`,
+ "Moving external messages should throw."
+ );
+
+ return messages[0];
+ }
+
+ // Open an external message in a tab and check its details.
+ let externalMessage = await openAndVerifyExternalMessage(
+ "openExternalFileMessage",
+ "tab",
+ { ...emlData.openExternalFileMessage, windowId: mainWindowId }
+ );
+ // Open and check the same message in a window.
+ await openAndVerifyExternalMessage("openExternalFileMessage", "window", {
+ ...emlData.openExternalFileMessage,
+ messageId: externalMessage.id,
+ });
+ // Open and check the same message in a tab, using the API.
+ await openAndVerifyExternalMessage(externalMessage.id, "tab", {
+ ...emlData.openExternalFileMessage,
+ messageId: externalMessage.id,
+ windowId: mainWindowId,
+ });
+ // Open and check the same message in a window, using the API.
+ await openAndVerifyExternalMessage(externalMessage.id, "window", {
+ ...emlData.openExternalFileMessage,
+ messageId: externalMessage.id,
+ });
+
+ // Test operations on the external message. This will put a copy in a
+ // folder that we can use for the next step.
+ let copiedMessage = await testMessageOperations(externalMessage);
+ let messagePromise = window.waitForEvent(
+ "messageDisplay.onMessageDisplayed"
+ );
+ await browser.mailTabs.setSelectedMessages([copiedMessage.id]);
+ await messagePromise;
+
+ // Open an attached message in a tab and check its details.
+ let attachedMessage = await openAndVerifyExternalMessage(
+ "openExternalAttachedMessage",
+ "tab",
+ { ...emlData.openExternalAttachedMessage, windowId: mainWindowId }
+ );
+ // Open and check the same message in a window.
+ await openAndVerifyExternalMessage(
+ "openExternalAttachedMessage",
+ "window",
+ {
+ ...emlData.openExternalAttachedMessage,
+ messageId: attachedMessage.id,
+ }
+ );
+ // Open and check the same message in a tab, using the API.
+ await openAndVerifyExternalMessage(attachedMessage.id, "tab", {
+ ...emlData.openExternalAttachedMessage,
+ messageId: attachedMessage.id,
+ windowId: mainWindowId,
+ });
+ // Open and check the same message in a window, using the API.
+ await openAndVerifyExternalMessage(attachedMessage.id, "window", {
+ ...emlData.openExternalAttachedMessage,
+ messageId: attachedMessage.id,
+ });
+
+ // Test operations on the attached message.
+ await testMessageOperations(attachedMessage);
+
+ // Delete the local eml file to trigger access errors.
+ await window.sendMessage(`deleteExternalMessage`);
+
+ await browser.test.assertRejects(
+ browser.messages.update(externalMessage.id, {}),
+ `Error updating message: Message not found: ${externalMessage.id}.`,
+ "Updating a missing message should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.delete([externalMessage.id]),
+ `Error deleting message: Message not found: ${externalMessage.id}.`,
+ "Deleting a missing message should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.archive([externalMessage.id]),
+ `Error archiving message: Message not found: ${externalMessage.id}.`,
+ "Archiving a missing message should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.move([externalMessage.id], displayedFolder),
+ `Error moving message: Message not found: ${externalMessage.id}.`,
+ "Moving a missing message should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.copy([externalMessage.id], displayedFolder),
+ `Error copying message: Message not found: ${externalMessage.id}.`,
+ "Copying a missing message should throw."
+ );
+
+ await browser.test.assertRejects(
+ browser.messageDisplay.open({ messageId: externalMessage.id }),
+ `Unknown or invalid messageId: ${externalMessage.id}.`,
+ "Opening a missing message should throw."
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [
+ "accountsRead",
+ "messagesRead",
+ "messagesMove",
+ "messagesDelete",
+ ],
+ },
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.displayFolder(gFolder.URI);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ extension.onMessage("openExternalFileMessage", async location => {
+ let messagePath = PathUtils.join(
+ PathUtils.profileDir,
+ "attachedMessageSample.eml"
+ );
+ let messageFile = new FileUtils.File(messagePath);
+ let url = Services.io
+ .newFileURI(messageFile)
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior[
+ location == "window" ? "NEW_WINDOW" : "NEW_TAB"
+ ]
+ );
+
+ MailUtils.openEMLFile(window, messageFile, url);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("openExternalAttachedMessage", async location => {
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior[
+ location == "window" ? "NEW_WINDOW" : "NEW_TAB"
+ ]
+ );
+
+ // The message with attachment should be loaded in the 3-pane tab.
+ let aboutMessage = tabmail.currentAboutMessage;
+ aboutMessage.toggleAttachmentList(true);
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.querySelector(".attachmentItem"),
+ { clickCount: 2 },
+ aboutMessage
+ );
+ extension.sendMessage();
+ });
+
+ extension.onMessage("deleteExternalMessage", async () => {
+ let messagePath = PathUtils.join(
+ PathUtils.profileDir,
+ "attachedMessageSample.eml"
+ );
+ let messageFile = new FileUtils.File(messagePath);
+ messageFile.remove(false);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_messages_open_attachment.js b/comm/mail/components/extensions/test/browser/browser_ext_messages_open_attachment.js
new file mode 100644
index 0000000000..c4e38465f7
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_messages_open_attachment.js
@@ -0,0 +1,107 @@
+/* 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_setup(async () => {
+ MailServices.accounts.createLocalMailAccount();
+ let localRoot =
+ MailServices.accounts.localFoldersServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+ let folder = localRoot.createLocalSubfolder("AttachmentA");
+ await createMessageFromFile(
+ folder,
+ getTestFilePath("messages/attachedMessageSample.eml")
+ );
+});
+
+add_task(async function testOpenAttachment() {
+ let files = {
+ "background.js": async () => {
+ let { messages } = await browser.messages.query({
+ headerMessageId: "sample.eml@mime.sample",
+ });
+
+ async function testTab(tab) {
+ let tabPromise = window.waitForEvent("tabs.onCreated");
+ let messagePromise = window.waitForEvent(
+ "messageDisplay.onMessageDisplayed"
+ );
+ await browser.messages.openAttachment(
+ messages[0].id,
+ // Open the eml attachment.
+ "1.2",
+ tab.id
+ );
+
+ let [msgTab] = await tabPromise;
+ let [openedMsgTab, message] = await messagePromise;
+
+ browser.test.assertEq(
+ msgTab.id,
+ openedMsgTab.id,
+ "The opened tab should match the onMessageDisplayed event tab"
+ );
+ browser.test.assertEq(
+ message.headerMessageId,
+ "sample-attached.eml@mime.sample",
+ "Should have opened the correct message"
+ );
+
+ await browser.tabs.remove(msgTab.id);
+ }
+
+ // Test using a mail tab.
+ let mailTab = await browser.mailTabs.getCurrent();
+ await testTab(mailTab);
+
+ // Test using a content tab.
+ let contentTab = await browser.tabs.create({ url: "test.html" });
+ await testTab(contentTab);
+ await browser.tabs.remove(contentTab.id);
+
+ // Test using a content window.
+ let contentWindow = await browser.windows.create({
+ type: "popup",
+ url: "test.html",
+ });
+ await testTab(contentWindow.tabs[0]);
+ await browser.windows.remove(contentWindow.id);
+
+ // Test using a message tab.
+ let messageTab = await browser.messageDisplay.open({
+ messageId: messages[0].id,
+ location: "tab",
+ });
+ await testTab(messageTab);
+ await browser.tabs.remove(messageTab.id);
+
+ // Test using a message window.
+ let messageWindowTab = await browser.messageDisplay.open({
+ messageId: messages[0].id,
+ location: "window",
+ });
+ await testTab(messageWindowTab);
+ await browser.tabs.remove(messageWindowTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [
+ "accountsRead",
+ "messagesRead",
+ "messagesMove",
+ "messagesDelete",
+ ],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_quickFilter.js b/comm/mail/components/extensions/test/browser/browser_ext_quickFilter.js
new file mode 100644
index 0000000000..302486e31f
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_quickFilter.js
@@ -0,0 +1,132 @@
+/* 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/. */
+
+let messages;
+let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+
+add_setup(async () => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+
+ // Modify the messages so the filters can be checked against them.
+
+ messages = [...subFolders[0].messages];
+ messages[0].markRead(true);
+ messages[2].markRead(true);
+ messages[4].markRead(true);
+ messages[6].markRead(true);
+ messages[8].markRead(true);
+ messages[1].markFlagged(true);
+ messages[6].markFlagged(true);
+ messages[0].setStringProperty("keywords", "$label1");
+ messages[1].setStringProperty("keywords", "$label2");
+ messages[3].setStringProperty("keywords", "$label1 $label2");
+ messages[5].setStringProperty("keywords", "$label2");
+ messages[6].setStringProperty("keywords", "$label1");
+ messages[7].setStringProperty("keywords", "$label2 $label3");
+ messages[8].setStringProperty("keywords", "$label3");
+ messages[9].setStringProperty("keywords", "$label1 $label2 $label3");
+ messages[9].markHasAttachments(true);
+
+ // Add an author to the address book.
+
+ let author = messages[7].author.replace(/["<>]/g, "").split(" ");
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.setProperty("FirstName", author[0]);
+ card.setProperty("LastName", author[1]);
+ card.setProperty("DisplayName", `${author[0]} ${author[1]}`);
+ card.setProperty("PrimaryEmail", author[2]);
+ let ab = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+ let addedCard = ab.addCard(card);
+
+ about3Pane.displayFolder(subFolders[0]);
+
+ registerCleanupFunction(() => {
+ ab.deleteCards([addedCard]);
+ });
+});
+
+add_task(async () => {
+ async function background() {
+ browser.mailTabs.setQuickFilter({ unread: true });
+ await window.sendMessage("checkVisible", 1, 3, 5, 7, 9);
+
+ browser.mailTabs.setQuickFilter({ flagged: true });
+ await window.sendMessage("checkVisible", 1, 6);
+
+ browser.mailTabs.setQuickFilter({ flagged: true, unread: true });
+ await window.sendMessage("checkVisible", 1);
+
+ browser.mailTabs.setQuickFilter({ tags: true });
+ await window.sendMessage("checkVisible", 0, 1, 3, 5, 6, 7, 8, 9);
+
+ browser.mailTabs.setQuickFilter({
+ tags: { mode: "any", tags: { $label1: true } },
+ });
+ await window.sendMessage("checkVisible", 0, 3, 6, 9);
+
+ browser.mailTabs.setQuickFilter({
+ tags: { mode: "any", tags: { $label2: true } },
+ });
+ await window.sendMessage("checkVisible", 1, 3, 5, 7, 9);
+
+ browser.mailTabs.setQuickFilter({
+ tags: { mode: "any", tags: { $label1: true, $label2: true } },
+ });
+ await window.sendMessage("checkVisible", 0, 1, 3, 5, 6, 7, 9);
+
+ browser.mailTabs.setQuickFilter({
+ tags: { mode: "all", tags: { $label1: true, $label2: true } },
+ });
+ await window.sendMessage("checkVisible", 3, 9);
+
+ browser.mailTabs.setQuickFilter({
+ tags: { mode: "all", tags: { $label1: true, $label2: false } },
+ });
+ await window.sendMessage("checkVisible", 0, 6);
+
+ browser.mailTabs.setQuickFilter({ attachment: true });
+ await window.sendMessage("checkVisible", 9);
+
+ browser.mailTabs.setQuickFilter({ attachment: false });
+ await window.sendMessage("checkVisible", 0, 1, 2, 3, 4, 5, 6, 7, 8);
+
+ browser.mailTabs.setQuickFilter({ contact: true });
+ await window.sendMessage("checkVisible", 7);
+
+ browser.mailTabs.setQuickFilter({ contact: false });
+ await window.sendMessage("checkVisible", 0, 1, 2, 3, 4, 5, 6, 8, 9);
+
+ browser.test.notifyPass("quickFilter");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("checkVisible", async (...expected) => {
+ let actual = [];
+ let dbView = about3Pane.gDBView;
+ for (let i = 0; i < dbView.numMsgsInView; i++) {
+ actual.push(messages.indexOf(dbView.getMsgHdrAt(i)));
+ }
+
+ Assert.deepEqual(actual, expected);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("quickFilter");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_sessions.js b/comm/mail/components/extensions/test/browser/browser_ext_sessions.js
new file mode 100644
index 0000000000..a77739c145
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_sessions.js
@@ -0,0 +1,90 @@
+/* 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 function test_sessions_data() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let [mailTab] = await browser.tabs.query({ mailTab: true });
+ let contentTab = await browser.tabs.create({
+ url: "https://www.example.com",
+ });
+
+ // Check that there is no data at the beginning.
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(mailTab.id, "aKey"),
+ undefined,
+ "Value for aKey should not exist"
+ );
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(contentTab.id, "aKey"),
+ undefined,
+ "Value for aKey should not exist"
+ );
+
+ // Set some data.
+ await browser.sessions.setTabValue(mailTab.id, "aKey", "1234");
+ await browser.sessions.setTabValue(contentTab.id, "aKey", "4321");
+
+ // Check the data is correct.
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(mailTab.id, "aKey"),
+ "1234",
+ "Value for aKey should exist"
+ );
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(contentTab.id, "aKey"),
+ "4321",
+ "Value for aKey should exist"
+ );
+
+ // Update data.
+ await browser.sessions.setTabValue(mailTab.id, "aKey", "12345");
+ await browser.sessions.setTabValue(contentTab.id, "aKey", "54321");
+
+ // Check the data is correct.
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(mailTab.id, "aKey"),
+ "12345",
+ "Value for aKey should exist"
+ );
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(contentTab.id, "aKey"),
+ "54321",
+ "Value for aKey should exist"
+ );
+
+ // Clear data.
+ await browser.sessions.removeTabValue(mailTab.id, "aKey");
+ await browser.sessions.removeTabValue(contentTab.id, "aKey");
+
+ // Check the data is removed.
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(mailTab.id, "aKey"),
+ undefined,
+ "Value for aKey should not exist"
+ );
+ browser.test.assertEq(
+ await browser.sessions.getTabValue(contentTab.id, "aKey"),
+ undefined,
+ "Value for aKey should not exist"
+ );
+
+ await browser.tabs.remove(contentTab.id);
+ browser.test.notifyPass();
+ },
+ manifest: {
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: "sessions@mochi.test",
+ },
+ },
+ permissions: ["tabs"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_spaces.js b/comm/mail/components/extensions/test/browser/browser_ext_spaces.js
new file mode 100644
index 0000000000..16f6f4770e
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_spaces.js
@@ -0,0 +1,1047 @@
+/* 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/. */
+
+/**
+ * Helper Function, creates a test extension to verify expected button states.
+ *
+ * @param {Function} background - The background script executed by the test.
+ * @param {object} config - Additional config data for the test. Tests can
+ * include arbitrary data, but the following have a dedicated purpose:
+ * @param {string} selectedTheme - The selected theme (default, light or dark),
+ * used to select the expected button/menuitem icon.
+ * @param {?object} manifestIcons - The icons entry of the extension manifest.
+ * @param {?object} permissions - Permissions assigned to the extension.
+ */
+async function test_space(background, config = {}) {
+ let manifest = {
+ manifest_version: 3,
+ browser_specific_settings: {
+ gecko: {
+ id: "spaces_toolbar@mochi.test",
+ },
+ },
+ permissions: ["tabs"],
+ background: { scripts: ["utils.js", "background.js"] },
+ };
+
+ if (config.manifestIcons) {
+ manifest.icons = config.manifestIcons;
+ }
+
+ if (config.permissions) {
+ manifest.permissions = config.permissions;
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest,
+ });
+
+ extension.onMessage("checkTabs", async test => {
+ let tabmail = document.getElementById("tabmail");
+
+ if (test.action && test.spaceName && test.url) {
+ let tabPromise =
+ test.action == "switch"
+ ? BrowserTestUtils.waitForEvent(tabmail.tabContainer, "TabSelect")
+ : contentTabOpenPromise(tabmail, test.url);
+ let button = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${test.spaceName}`
+ );
+ button.click();
+ await tabPromise;
+ }
+
+ let tabs = tabmail.tabInfo.filter(tabInfo => !!tabInfo.spaceButtonId);
+ Assert.equal(
+ test.openSpacesUrls.length,
+ tabs.length,
+ `Should have found the correct number of open add-on spaces tabs.`
+ );
+ for (let expectedUrl of test.openSpacesUrls) {
+ Assert.ok(
+ tabmail.tabInfo.find(
+ tabInfo =>
+ !!tabInfo.spaceButtonId &&
+ tabInfo.browser.currentURI.spec == expectedUrl
+ ),
+ `Should have found a spaces tab with the expected url.`
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkUI", async expected => {
+ let addonButtons = document.querySelectorAll(".spaces-addon-button");
+ Assert.equal(
+ expected.length,
+ addonButtons.length,
+ `Should have found the correct number of buttons.`
+ );
+
+ for (let {
+ name,
+ url,
+ title,
+ icons,
+ badgeText,
+ badgeBackgroundColor,
+ } of expected) {
+ // Check button.
+ let button = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${name}`
+ );
+ Assert.ok(button, `Button for space ${name} should exist.`);
+ Assert.equal(
+ title,
+ button.title,
+ `Title of button for space ${name} should be correct.`
+ );
+
+ // Check button icon.
+ let imgStyles = window.getComputedStyle(button.querySelector("img"));
+ Assert.equal(
+ icons[config.selectedTheme],
+ imgStyles.content,
+ `Icon for button of space ${name} with theme ${config.selectedTheme} should be correct.`
+ );
+
+ // Check badge.
+ let badge = button.querySelector(".spaces-badge-container");
+ let badgeStyles = window.getComputedStyle(badge);
+ if (badgeText) {
+ Assert.equal(
+ "block",
+ badgeStyles.display,
+ `Button of space ${name} should have a badge.`
+ );
+ Assert.equal(
+ badgeText,
+ badge.textContent,
+ `Badge of button of space ${name} should have the correct content.`
+ );
+ if (badgeBackgroundColor) {
+ Assert.equal(
+ badgeBackgroundColor,
+ badgeStyles.backgroundColor,
+ `Badge of button of space ${name} should have the correct backgroundColor.`
+ );
+ }
+ } else {
+ Assert.equal(
+ "none",
+ badgeStyles.display,
+ `Button of space ${name} should not have a badge.`
+ );
+ }
+
+ let collapseButton = document.getElementById("collapseButton");
+ let revealButton = document.getElementById("spacesToolbarReveal");
+ let pinnedButton = document.getElementById("spacesPinnedButton");
+ let pinnedPopup = document.getElementById("spacesButtonMenuPopup");
+
+ Assert.ok(revealButton.hidden, "The status bar toggle button is hidden");
+ Assert.ok(pinnedButton.hidden, "The pinned titlebar button is hidden");
+ collapseButton.click();
+ Assert.ok(
+ !revealButton.hidden,
+ "The status bar toggle button is not hidden"
+ );
+ Assert.ok(
+ !pinnedButton.hidden,
+ "The pinned titlebar button is not hidden"
+ );
+ pinnedPopup.openPopup();
+
+ // Check menuitem.
+ let menuitem = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${name}-menuitem`
+ );
+ Assert.ok(menuitem, `Menuitem for id ${name} should exist.`);
+ Assert.equal(
+ title,
+ menuitem.label,
+ `Label of menuitem of space ${name} should be correct.`
+ );
+
+ // Check menuitem icon.
+ let menuitemStyles = window.getComputedStyle(menuitem);
+ Assert.equal(
+ icons[config.selectedTheme],
+ menuitemStyles.listStyleImage,
+ `Icon of menuitem for space ${name} with theme ${config.selectedTheme} should be correct.`
+ );
+
+ pinnedPopup.hidePopup();
+ revealButton.click();
+ Assert.ok(revealButton.hidden, "The status bar toggle button is hidden");
+ Assert.ok(pinnedButton.hidden, "The pinned titlebar button is hidden");
+
+ //Check space and url.
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == `spaces_toolbar_mochi_test-spacesButton-${name}`
+ );
+ Assert.ok(space, "The space of this button should exists");
+ Assert.equal(
+ url,
+ space.url,
+ "The stored url of the space should be correct"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getConfig", async () => {
+ extension.sendMessage(config);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function test_add_update_remove() {
+ async function background() {
+ let manifest = browser.runtime.getManifest();
+ let extensionIcon = manifest.icons
+ ? browser.runtime.getURL(manifest.icons[16])
+ : "chrome://messenger/content/extension.svg";
+
+ await window.sendMessage("checkUI", []);
+
+ // Test create().
+ browser.test.log("create(): Without id.");
+ await browser.test.assertThrows(
+ () => browser.spaces.create(),
+ /Incorrect argument types for spaces.create./,
+ "create() without name should throw."
+ );
+
+ browser.test.log("create(): Without default url.");
+ await browser.test.assertThrows(
+ () => browser.spaces.create("space_1"),
+ /Incorrect argument types for spaces.create./,
+ "create() without default url should throw."
+ );
+
+ browser.test.log("create(): With invalid default url.");
+ await browser.test.assertRejects(
+ browser.spaces.create("space_1", "invalid://url"),
+ /Failed to create space with name space_1: Invalid default url./,
+ "create() with an invalid default url should throw."
+ );
+
+ browser.test.log("create(): With default url only.");
+ let space_1 = await browser.spaces.create(
+ "space_1",
+ "https://test.invalid"
+ );
+ let expected_space_1 = {
+ name: "space_1",
+ title: "Generated extension",
+ url: "https://test.invalid",
+ icons: {
+ default: `url("${extensionIcon}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ browser.test.log("create(): With default url only, but existing id.");
+ await browser.test.assertRejects(
+ browser.spaces.create("space_1", "https://test.invalid"),
+ /Failed to create space with name space_1: Space already exists for this extension./,
+ "create() with existing id should throw."
+ );
+
+ browser.test.log("create(): With most properties.");
+ let space_2 = await browser.spaces.create("space_2", "/local/file.html", {
+ title: "Google",
+ defaultIcons: "default.png",
+ badgeText: "12",
+ badgeBackgroundColor: [50, 100, 150, 255],
+ });
+ let expected_space_2 = {
+ name: "space_2",
+ title: "Google",
+ url: browser.runtime.getURL("/local/file.html"),
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ },
+ badgeText: "12",
+ badgeBackgroundColor: "rgb(50, 100, 150)",
+ };
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ // Test update().
+ browser.test.log("update(): Without id.");
+ await browser.test.assertThrows(
+ () => browser.spaces.update(),
+ /Incorrect argument types for spaces.update./,
+ "update() without id should throw."
+ );
+
+ browser.test.log("update(): With invalid id.");
+ await browser.test.assertRejects(
+ browser.spaces.update(1234),
+ /Failed to update space with id 1234: Unknown id./,
+ "update() with invalid id should throw."
+ );
+
+ browser.test.log("update(): Without properties.");
+ await browser.spaces.update(space_1.id);
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ browser.test.log("update(): Updating the badge.");
+ await browser.spaces.update(space_2.id, {
+ badgeText: "ok",
+ badgeBackgroundColor: "green",
+ });
+ expected_space_2.badgeText = "ok";
+ expected_space_2.badgeBackgroundColor = "rgb(0, 128, 0)";
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ browser.test.log("update(): Removing the badge.");
+ await browser.spaces.update(space_2.id, {
+ badgeText: "",
+ });
+ delete expected_space_2.badgeText;
+ delete expected_space_2.badgeBackgroundColor;
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ browser.test.log("update(): Changing the title.");
+ await browser.spaces.update(space_2.id, {
+ title: "Some other title",
+ });
+ expected_space_2.title = "Some other title";
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ browser.test.log("update(): Removing the title.");
+ await browser.spaces.update(space_2.id, {
+ title: "",
+ });
+ expected_space_2.title = "Generated extension";
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ browser.test.log("update(): Setting invalid default url.");
+ await browser.test.assertRejects(
+ browser.spaces.update(space_2.id, "invalid://url"),
+ `Failed to update space with id ${space_2.id}: Invalid default url.`,
+ "update() with invalid default url should throw."
+ );
+
+ await browser.spaces.update(space_2.id, "https://test.more.invalid", {
+ title: "Bing",
+ });
+ expected_space_2.title = "Bing";
+ expected_space_2.url = "https://test.more.invalid";
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ // Test remove().
+ browser.test.log("remove(): Removing without id.");
+ await browser.test.assertThrows(
+ () => browser.spaces.remove(),
+ /Incorrect argument types for spaces.remove./,
+ "remove() without id should throw."
+ );
+
+ browser.test.log("remove(): Removing with invalid id.");
+ await browser.test.assertRejects(
+ browser.spaces.remove(1234),
+ /Failed to remove space with id 1234: Unknown id./,
+ "remove() with invalid id should throw."
+ );
+
+ browser.test.log("remove(): Removing space_1.");
+ await browser.spaces.remove(space_1.id);
+ await window.sendMessage("checkUI", [expected_space_2]);
+
+ browser.test.notifyPass();
+ }
+ await test_space(background, { selectedTheme: "default" });
+ await test_space(background, {
+ selectedTheme: "default",
+ manifestIcons: { 16: "manifest.png" },
+ });
+});
+
+add_task(async function test_open_reload_close() {
+ async function background() {
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ // Add spaces.
+ let url1 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ let space_1 = await browser.spaces.create("space_1", url1);
+ let url2 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content_body.html`;
+ let space_2 = await browser.spaces.create("space_2", url2);
+
+ // Open spaces.
+ await window.sendMessage("checkTabs", {
+ action: "open",
+ url: url1,
+ spaceName: "space_1",
+ openSpacesUrls: [url1],
+ });
+ await window.sendMessage("checkTabs", {
+ action: "open",
+ url: url2,
+ spaceName: "space_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Switch to open spaces tab.
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url1,
+ spaceName: "space_1",
+ openSpacesUrls: [url1, url2],
+ });
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url2,
+ spaceName: "space_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // TODO: Add test for tab reloading, once this has been implemented.
+
+ // Remove spaces and check that related spaces tab are closed.
+ await browser.spaces.remove(space_1.id);
+ await window.sendMessage("checkTabs", { openSpacesUrls: [url2] });
+ await browser.spaces.remove(space_2.id);
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ browser.test.notifyPass();
+ }
+ await test_space(background, { selectedTheme: "default" });
+});
+
+add_task(async function test_icons() {
+ async function background() {
+ let manifest = browser.runtime.getManifest();
+ let extensionIcon = manifest.icons
+ ? browser.runtime.getURL(manifest.icons[16])
+ : "chrome://messenger/content/extension.svg";
+
+ // Test 1: Setting defaultIcons and themeIcons.
+ browser.test.log("create(): Setting defaultIcons and themeIcons.");
+ let space_1 = await browser.spaces.create(
+ "space_1",
+ "https://test.invalid",
+ {
+ title: "Google",
+ defaultIcons: "default.png",
+ themeIcons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ }
+ );
+ let expected_space_1 = {
+ name: "space_1",
+ title: "Google",
+ url: "https://test.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ // Clearing defaultIcons.
+ await browser.spaces.update(space_1.id, {
+ defaultIcons: "",
+ });
+ expected_space_1.icons = {
+ default: `url("${browser.runtime.getURL("dark.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ // Setting other defaultIcons.
+ await browser.spaces.update(space_1.id, {
+ defaultIcons: "other.png",
+ });
+ expected_space_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ // Clearing themeIcons.
+ await browser.spaces.update(space_1.id, {
+ themeIcons: [],
+ });
+ expected_space_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("other.png")}")`,
+ light: `url("${browser.runtime.getURL("other.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ // Setting other themeIcons.
+ await browser.spaces.update(space_1.id, {
+ themeIcons: [
+ {
+ dark: "dark2.png",
+ light: "light2.png",
+ size: 16,
+ },
+ ],
+ });
+ expected_space_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark2.png")}")`,
+ light: `url("${browser.runtime.getURL("light2.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_space_1]);
+
+ // Test 2: Setting themeIcons only.
+ browser.test.log("create(): Setting themeIcons only.");
+ let space_2 = await browser.spaces.create(
+ "space_2",
+ "https://test.other.invalid",
+ {
+ title: "Wikipedia",
+ themeIcons: [
+ {
+ dark: "dark2.png",
+ light: "light2.png",
+ size: 16,
+ },
+ ],
+ }
+ );
+ // Not specifying defaultIcons but only themeIcons should always use the
+ // theme icons, even for the default theme (and not the extension icon).
+ let expected_space_2 = {
+ name: "space_2",
+ title: "Wikipedia",
+ url: "https://test.other.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("dark2.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark2.png")}")`,
+ light: `url("${browser.runtime.getURL("light2.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ // Clearing themeIcons.
+ await browser.spaces.update(space_2.id, {
+ themeIcons: [],
+ });
+ expected_space_2.icons = {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ };
+ await window.sendMessage("checkUI", [expected_space_1, expected_space_2]);
+
+ // Test 3: Setting defaultIcons only.
+ browser.test.log("create(): Setting defaultIcons only.");
+ let space_3 = await browser.spaces.create(
+ "space_3",
+ "https://test.more.invalid",
+ {
+ title: "Bing",
+ defaultIcons: "default.png",
+ }
+ );
+ let expected_space_3 = {
+ name: "space_3",
+ title: "Bing",
+ url: "https://test.more.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("default.png")}")`,
+ light: `url("${browser.runtime.getURL("default.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [
+ expected_space_1,
+ expected_space_2,
+ expected_space_3,
+ ]);
+
+ // Clearing defaultIcons and setting themeIcons.
+ await browser.spaces.update(space_3.id, {
+ defaultIcons: "",
+ themeIcons: [
+ {
+ dark: "dark3.png",
+ light: "light3.png",
+ size: 16,
+ },
+ ],
+ });
+ expected_space_3.icons = {
+ default: `url("${browser.runtime.getURL("dark3.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark3.png")}")`,
+ light: `url("${browser.runtime.getURL("light3.png")}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_space_1,
+ expected_space_2,
+ expected_space_3,
+ ]);
+
+ // Test 4: Setting no icons.
+ browser.test.log("create(): Setting no icons.");
+ let space_4 = await browser.spaces.create(
+ "space_4",
+ "https://duckduckgo.com",
+ {
+ title: "DuckDuckGo",
+ }
+ );
+ let expected_space_4 = {
+ name: "space_4",
+ title: "DuckDuckGo",
+ url: "https://duckduckgo.com",
+ icons: {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [
+ expected_space_1,
+ expected_space_2,
+ expected_space_3,
+ expected_space_4,
+ ]);
+
+ // Setting and clearing default icons.
+ await browser.spaces.update(space_4.id, {
+ defaultIcons: "default.png",
+ });
+ expected_space_4.icons = {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("default.png")}")`,
+ light: `url("${browser.runtime.getURL("default.png")}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_space_1,
+ expected_space_2,
+ expected_space_3,
+ expected_space_4,
+ ]);
+ await browser.spaces.update(space_4.id, {
+ defaultIcons: "",
+ });
+ expected_space_4.icons = {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_space_1,
+ expected_space_2,
+ expected_space_3,
+ expected_space_4,
+ ]);
+
+ browser.test.notifyPass();
+ }
+
+ // Test with and without icons defined in the manifest.
+ for (let manifestIcons of [null, { 16: "manifest16.png" }]) {
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ await test_space(background, { selectedTheme: "light", manifestIcons });
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ await test_space(background, { selectedTheme: "dark", manifestIcons });
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ await test_space(background, { selectedTheme: "default", manifestIcons });
+ }
+});
+
+add_task(async function test_open_programmatically() {
+ async function background() {
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ // Add spaces.
+ let url1 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ let space_1 = await browser.spaces.create("space_1", url1);
+ let url2 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content_body.html`;
+ let space_2 = await browser.spaces.create("space_2", url2);
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ async function openSpace(space, url) {
+ let loadPromise = new Promise(resolve => {
+ let urlSeen = false;
+ let listener = (tabId, changeInfo) => {
+ if (changeInfo.url && changeInfo.url == url) {
+ urlSeen = true;
+ }
+ if (changeInfo.status == "complete" && urlSeen) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(listener);
+ });
+ let tab = await browser.spaces.open(space.id);
+ await loadPromise;
+
+ browser.test.assertEq(
+ space.id,
+ tab.spaceId,
+ "The opened tab should belong to the correct space"
+ );
+
+ let queriedTabs = await browser.tabs.query({ spaceId: space.id });
+ browser.test.assertEq(
+ 1,
+ queriedTabs.length,
+ "browser.tabs.query() should find exactly one tab belonging to the opened space"
+ );
+ browser.test.assertEq(
+ tab.id,
+ queriedTabs[0].id,
+ "browser.tabs.query() should find the correct tab belonging to the opened space"
+ );
+ }
+
+ // Open space #1.
+ await openSpace(space_1, url1);
+ await window.sendMessage("checkTabs", {
+ spaceName: "space_1",
+ openSpacesUrls: [url1],
+ });
+
+ // Open space #2.
+ await openSpace(space_2, url2);
+ await window.sendMessage("checkTabs", {
+ spaceName: "space_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Switch to open space tab.
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url1,
+ spaceName: "space_1",
+ openSpacesUrls: [url1, url2],
+ });
+
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url2,
+ spaceName: "space_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Remove spaces and check that related spaces tab are closed.
+ await browser.spaces.remove(space_1.id);
+ await window.sendMessage("checkTabs", { openSpacesUrls: [url2] });
+ await browser.spaces.remove(space_2.id);
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ browser.test.notifyPass();
+ }
+ await test_space(background, { selectedTheme: "default" });
+});
+
+// Load a second extension parallel to the standard space test, which creates
+// two additional spaces.
+async function test_query({ permissions }) {
+ async function query_background() {
+ function verify(description, expected, spaces) {
+ browser.test.assertEq(
+ expected.length,
+ spaces.length,
+ `${description}: Should find the correct number of spaces`
+ );
+ window.assertDeepEqual(
+ spaces,
+ expected,
+ `${description}: Should find the correct spaces`
+ );
+ }
+
+ async function query(queryInfo, expected) {
+ let spaces =
+ queryInfo === null
+ ? await browser.spaces.query()
+ : await browser.spaces.query(queryInfo);
+ verify(`Query ${JSON.stringify(queryInfo)}`, expected, spaces);
+ }
+
+ let builtIn = [
+ {
+ id: 1,
+ name: "mail",
+ isBuiltIn: true,
+ isSelfOwned: false,
+ },
+ {
+ id: 2,
+ isBuiltIn: true,
+ isSelfOwned: false,
+ name: "addressbook",
+ },
+ {
+ id: 3,
+ isBuiltIn: true,
+ isSelfOwned: false,
+ name: "calendar",
+ },
+ {
+ id: 4,
+ isBuiltIn: true,
+ isSelfOwned: false,
+ name: "tasks",
+ },
+ {
+ id: 5,
+ isBuiltIn: true,
+ isSelfOwned: false,
+ name: "chat",
+ },
+ {
+ id: 6,
+ isBuiltIn: true,
+ isSelfOwned: false,
+ name: "settings",
+ },
+ ];
+
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+ let [{ other_1, other_11, permissions }] = await window.sendMessage(
+ "getConfig"
+ );
+ let hasManagement = permissions && permissions.includes("management");
+
+ // Verify space_1 from other extension.
+ let expected_other_1 = {
+ name: "space_1",
+ isBuiltIn: false,
+ isSelfOwned: true,
+ };
+ if (hasManagement) {
+ expected_other_1.extensionId = "spaces_toolbar_other@mochi.test";
+ }
+ verify("Check space_1 from other extension", other_1, expected_other_1);
+
+ // Verify space_11 from other extension.
+ let expected_other_11 = {
+ name: "space_11",
+ isBuiltIn: false,
+ isSelfOwned: true,
+ };
+ if (hasManagement) {
+ expected_other_11.extensionId = "spaces_toolbar_other@mochi.test";
+ }
+ verify("Check space_11 from other extension", other_11, expected_other_11);
+
+ // Manipulate isSelfOwned, because we got those from the other extension.
+ other_1.isSelfOwned = false;
+ other_11.isSelfOwned = false;
+
+ await query(null, [...builtIn, other_1, other_11]);
+ await query({}, [...builtIn, other_1, other_11]);
+ await query({ isSelfOwned: false }, [...builtIn, other_1, other_11]);
+ await query({ isBuiltIn: true }, [...builtIn]);
+ await query({ isBuiltIn: false }, [other_1, other_11]);
+ await query({ isSelfOwned: true }, []);
+ await query(
+ { extensionId: "spaces_toolbar_other@mochi.test" },
+ hasManagement ? [other_1, other_11] : []
+ );
+
+ // Add spaces.
+ let url1 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ let space_1 = await browser.spaces.create("space_1", url1);
+ let url2 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content_body.html`;
+ let space_2 = await browser.spaces.create("space_2", url2);
+
+ // Verify returned space_1
+ let expected_space_1 = {
+ name: "space_1",
+ isBuiltIn: false,
+ isSelfOwned: true,
+ };
+ if (hasManagement) {
+ expected_space_1.extensionId = "spaces_toolbar@mochi.test";
+ }
+ verify("Check space_1", space_1, expected_space_1);
+
+ // Verify returned space_2
+ let expected_space_2 = {
+ name: "space_2",
+ isBuiltIn: false,
+ isSelfOwned: true,
+ };
+ if (hasManagement) {
+ expected_space_2.extensionId = "spaces_toolbar@mochi.test";
+ }
+ verify("Check space_2", space_2, expected_space_2);
+
+ await query(null, [...builtIn, other_1, other_11, space_1, space_2]);
+ await query({ isSelfOwned: false }, [...builtIn, other_1, other_11]);
+ await query({ isBuiltIn: true }, [...builtIn]);
+ await query({ isBuiltIn: false }, [other_1, other_11, space_1, space_2]);
+ await query({ isSelfOwned: true }, [space_1, space_2]);
+ await query(
+ { extensionId: "spaces_toolbar_other@mochi.test" },
+ hasManagement ? [other_1, other_11] : []
+ );
+ await query(
+ { extensionId: "spaces_toolbar@mochi.test" },
+ hasManagement ? [space_1, space_2] : []
+ );
+
+ await query({ id: space_1.id }, [space_1]);
+ await query({ id: other_1.id }, [other_1]);
+ await query({ id: space_2.id }, [space_2]);
+ await query({ id: other_11.id }, [other_11]);
+ await query({ name: "space_1" }, [other_1, space_1]);
+ await query({ name: "space_2" }, [space_2]);
+ await query({ name: "space_11" }, [other_11]);
+
+ browser.test.notifyPass();
+ }
+
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let url = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ let other_1 = await browser.spaces.create("space_1", url);
+ let other_11 = await browser.spaces.create("space_11", url);
+ browser.test.sendMessage("Done", { other_1, other_11 });
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ manifest_version: 3,
+ browser_specific_settings: {
+ gecko: {
+ id: "spaces_toolbar_other@mochi.test",
+ },
+ },
+ permissions,
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ await otherExtension.startup();
+ let { other_1, other_11 } = await otherExtension.awaitMessage("Done");
+
+ await test_space(query_background, {
+ selectedTheme: "default",
+ other_1,
+ other_11,
+ permissions,
+ });
+
+ await otherExtension.awaitFinish();
+ await otherExtension.unload();
+}
+
+add_task(async function test_query_no_management_permission() {
+ await test_query({ permissions: [] });
+});
+
+add_task(async function test_query_management_permission() {
+ await test_query({ permissions: ["management"] });
+});
+
+// Test built-in spaces to make sure the space definition of the spaceTracker in
+// ext-mails.js is matching the actual space definition in spacesToolbar.js
+add_task(async function test_builtIn_spaces() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ const checkSpace = async (spaceId, spaceName) => {
+ let spaces = await browser.spaces.query({ id: spaceId });
+ browser.test.assertEq(spaces.length, 1, "Should find a single space");
+ browser.test.assertEq(
+ spaces[0].isBuiltIn,
+ true,
+ "Should find a built-in space"
+ );
+ browser.test.assertEq(
+ spaces[0].name,
+ spaceName,
+ "Should find the correct space"
+ );
+ };
+
+ // Test the already open mail space.
+
+ let mailTabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(
+ mailTabs.length,
+ 1,
+ "Should find a single mail tab"
+ );
+ await checkSpace(mailTabs[0].spaceId, "mail");
+
+ // Test all other spaces.
+
+ let builtInSpaces = [
+ "addressbook",
+ "calendar",
+ "tasks",
+ "chat",
+ "settings",
+ ];
+
+ for (let spaceName of builtInSpaces) {
+ await new Promise(resolve => {
+ const listener = async tab => {
+ await checkSpace(tab.spaceId, spaceName);
+ browser.tabs.remove(tab.id);
+ browser.tabs.onCreated.removeListener(listener);
+ resolve();
+ };
+ browser.tabs.onCreated.addListener(listener);
+ browser.test.sendMessage("openSpace", spaceName);
+ });
+ }
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: "built-in-spaces@mochi.test",
+ },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("openSpace", async spaceName => {
+ window.gSpacesToolbar.openSpace(
+ window.document.getElementById("tabmail"),
+ window.gSpacesToolbar.spaces.find(space => space.name == spaceName)
+ );
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_spacesToolbar.js b/comm/mail/components/extensions/test/browser/browser_ext_spacesToolbar.js
new file mode 100644
index 0000000000..15a2b2b999
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_spacesToolbar.js
@@ -0,0 +1,755 @@
+/* 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/. */
+
+/**
+ * Helper Function, creates a test extension to verify expected button states.
+ *
+ * @param {Function} background - The background script executed by the test.
+ * @param {string} selectedTheme - The selected theme (default, light or dark),
+ * used to select the expected button/menuitem icon.
+ * @param {?object} manifestIcons - The icons entry of the extension manifest.
+ */
+async function test_spaceToolbar(background, selectedTheme, manifestIcons) {
+ let manifest = {
+ manifest_version: 2,
+ applications: {
+ gecko: {
+ id: "spaces_toolbar@mochi.test",
+ },
+ },
+ permissions: ["tabs"],
+ background: { scripts: ["utils.js", "background.js"] },
+ };
+
+ if (manifestIcons) {
+ manifest.icons = manifestIcons;
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest,
+ });
+
+ extension.onMessage("checkTabs", async test => {
+ let tabmail = document.getElementById("tabmail");
+
+ if (test.action && test.buttonId && test.url) {
+ let tabPromise =
+ test.action == "switch"
+ ? BrowserTestUtils.waitForEvent(tabmail.tabContainer, "TabSelect")
+ : contentTabOpenPromise(tabmail, test.url);
+ let button = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${test.buttonId}`
+ );
+ button.click();
+ await tabPromise;
+ }
+
+ let tabs = tabmail.tabInfo.filter(tabInfo => !!tabInfo.spaceButtonId);
+ Assert.equal(
+ test.openSpacesUrls.length,
+ tabs.length,
+ `Should have found the correct number of open add-on spaces tabs.`
+ );
+ for (let expectedUrl of test.openSpacesUrls) {
+ Assert.ok(
+ tabmail.tabInfo.find(
+ tabInfo =>
+ !!tabInfo.spaceButtonId &&
+ tabInfo.browser.currentURI.spec == expectedUrl
+ ),
+ `Should have found a spaces tab with the expected url.`
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("checkUI", async expected => {
+ let addonButtons = document.querySelectorAll(".spaces-addon-button");
+ Assert.equal(
+ expected.length,
+ addonButtons.length,
+ `Should have found the correct number of buttons.`
+ );
+
+ for (let {
+ id,
+ url,
+ title,
+ icons,
+ badgeText,
+ badgeBackgroundColor,
+ } of expected) {
+ // Check button.
+ let button = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${id}`
+ );
+ Assert.ok(button, `Button for id ${id} should exist.`);
+ Assert.equal(
+ title,
+ button.title,
+ `Title of button ${id} should be correct.`
+ );
+
+ // Check button icon.
+ let imgStyles = window.getComputedStyle(button.querySelector("img"));
+ Assert.equal(
+ icons[selectedTheme],
+ imgStyles.content,
+ `Icon of button ${id} with theme ${selectedTheme} should be correct.`
+ );
+
+ // Check badge.
+ let badge = button.querySelector(".spaces-badge-container");
+ let badgeStyles = window.getComputedStyle(badge);
+ if (badgeText) {
+ Assert.equal(
+ "block",
+ badgeStyles.display,
+ `Button ${id} should have a badge.`
+ );
+ Assert.equal(
+ badgeText,
+ badge.textContent,
+ `Badge of button ${id} should have the correct content.`
+ );
+ if (badgeBackgroundColor) {
+ Assert.equal(
+ badgeBackgroundColor,
+ badgeStyles.backgroundColor,
+ `Badge of button ${id} should have the correct backgroundColor.`
+ );
+ }
+ } else {
+ Assert.equal(
+ "none",
+ badgeStyles.display,
+ `Button ${id} should not have a badge.`
+ );
+ }
+
+ let collapseButton = document.getElementById("collapseButton");
+ let revealButton = document.getElementById("spacesToolbarReveal");
+ let pinnedButton = document.getElementById("spacesPinnedButton");
+ let pinnedPopup = document.getElementById("spacesButtonMenuPopup");
+
+ Assert.ok(revealButton.hidden, "The status bar toggle button is hidden");
+ Assert.ok(pinnedButton.hidden, "The pinned titlebar button is hidden");
+ collapseButton.click();
+ Assert.ok(
+ !revealButton.hidden,
+ "The status bar toggle button is not hidden"
+ );
+ Assert.ok(
+ !pinnedButton.hidden,
+ "The pinned titlebar button is not hidden"
+ );
+ pinnedPopup.openPopup();
+
+ // Check menuitem.
+ let menuitem = window.document.getElementById(
+ `spaces_toolbar_mochi_test-spacesButton-${id}-menuitem`
+ );
+ Assert.ok(menuitem, `Menuitem for id ${id} should exist.`);
+ Assert.equal(
+ title,
+ menuitem.label,
+ `Label of menuitem ${id} should be correct.`
+ );
+
+ // Check menuitem icon.
+ let menuitemStyles = window.getComputedStyle(menuitem);
+ Assert.equal(
+ icons[selectedTheme],
+ menuitemStyles.listStyleImage,
+ `Icon of menuitem ${id} with theme ${selectedTheme} should be correct.`
+ );
+
+ pinnedPopup.hidePopup();
+ revealButton.click();
+ Assert.ok(revealButton.hidden, "The status bar toggle button is hidden");
+ Assert.ok(pinnedButton.hidden, "The pinned titlebar button is hidden");
+
+ //Check space and url.
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == `spaces_toolbar_mochi_test-spacesButton-${id}`
+ );
+ Assert.ok(space, "The space of this button should exists");
+ Assert.equal(
+ url,
+ space.url,
+ "The stored url of the space should be correct"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function test_add_update_remove() {
+ async function background() {
+ let manifest = browser.runtime.getManifest();
+ let extensionIcon = manifest.icons
+ ? browser.runtime.getURL(manifest.icons[16])
+ : "chrome://messenger/content/extension.svg";
+
+ // Test addButton().
+ browser.test.log("addButton(): Without id.");
+ await browser.test.assertThrows(
+ () => browser.spacesToolbar.addButton(),
+ /Incorrect argument types for spacesToolbar.addButton./,
+ "addButton() without id should throw."
+ );
+
+ browser.test.log("addButton(): Without properties.");
+ await browser.test.assertThrows(
+ () => browser.spacesToolbar.addButton("button_1"),
+ /Incorrect argument types for spacesToolbar.addButton./,
+ "addButton() without properties should throw."
+ );
+
+ browser.test.log("addButton(): With empty properties.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.addButton("button_1", {}),
+ /Failed to add button to the spaces toolbar: Invalid url./,
+ "addButton() without a url should throw."
+ );
+
+ browser.test.log("addButton(): With invalid url.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.addButton("button_1", {
+ url: "invalid://url",
+ }),
+ /Failed to add button to the spaces toolbar: Invalid url./,
+ "addButton() with an invalid url should throw."
+ );
+
+ browser.test.log("addButton(): With url only.");
+ await browser.spacesToolbar.addButton("button_1", {
+ url: "https://test.invalid",
+ });
+ let expected_button_1 = {
+ id: "button_1",
+ title: "Generated extension",
+ url: "https://test.invalid",
+ icons: {
+ default: `url("${extensionIcon}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ browser.test.log("addButton(): With url only, but existing id.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.addButton("button_1", {
+ url: "https://test.invalid",
+ }),
+ /Failed to add button to the spaces toolbar: The id button_1 is already used by this extension./,
+ "addButton() with existing id should throw."
+ );
+
+ browser.test.log("addButton(): With most properties.");
+ await browser.spacesToolbar.addButton("button_2", {
+ title: "Google",
+ url: "/local/file.html",
+ defaultIcons: "default.png",
+ badgeText: "12",
+ badgeBackgroundColor: [50, 100, 150, 255],
+ });
+ let expected_button_2 = {
+ id: "button_2",
+ title: "Google",
+ url: browser.runtime.getURL("/local/file.html"),
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ },
+ badgeText: "12",
+ badgeBackgroundColor: "rgb(50, 100, 150)",
+ };
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ // Test updateButton().
+ browser.test.log("updateButton(): Without id.");
+ await browser.test.assertThrows(
+ () => browser.spacesToolbar.updateButton(),
+ /Incorrect argument types for spacesToolbar.updateButton./,
+ "updateButton() without id should throw."
+ );
+
+ browser.test.log("updateButton(): Without properties.");
+ await browser.test.assertThrows(
+ () => browser.spacesToolbar.updateButton("InvalidId"),
+ /Incorrect argument types for spacesToolbar.updateButton./,
+ "updateButton() without properties should throw."
+ );
+
+ browser.test.log("updateButton(): With empty properties but invalid id.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.updateButton("InvalidId", {}),
+ /Failed to update button in the spaces toolbar: A button with id InvalidId does not exist for this extension./,
+ "updateButton() with invalid id should throw."
+ );
+
+ browser.test.log("updateButton(): With empty properties.");
+ await browser.spacesToolbar.updateButton("button_1", {});
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ browser.test.log("updateButton(): Updating the badge.");
+ await browser.spacesToolbar.updateButton("button_2", {
+ badgeText: "ok",
+ badgeBackgroundColor: "green",
+ });
+ expected_button_2.badgeText = "ok";
+ expected_button_2.badgeBackgroundColor = "rgb(0, 128, 0)";
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ browser.test.log("updateButton(): Removing the badge.");
+ await browser.spacesToolbar.updateButton("button_2", {
+ badgeText: "",
+ });
+ delete expected_button_2.badgeText;
+ delete expected_button_2.badgeBackgroundColor;
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ browser.test.log("updateButton(): Changing the title.");
+ await browser.spacesToolbar.updateButton("button_2", {
+ title: "Some other title",
+ });
+ expected_button_2.title = "Some other title";
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ browser.test.log("updateButton(): Removing the title.");
+ await browser.spacesToolbar.updateButton("button_2", {
+ title: "",
+ });
+ expected_button_2.title = "Generated extension";
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ browser.test.log("updateButton(): Settings an invalid url.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.updateButton("button_2", {
+ url: "invalid://url",
+ }),
+ /Failed to update button in the spaces toolbar: Invalid url./,
+ "updateButton() with invalid url should throw."
+ );
+
+ await browser.spacesToolbar.updateButton("button_2", {
+ title: "Bing",
+ url: "https://test.more.invalid",
+ });
+ expected_button_2.title = "Bing";
+ expected_button_2.url = "https://test.more.invalid";
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ // Test removeButton().
+ browser.test.log("removeButton(): Removing without id.");
+ await browser.test.assertThrows(
+ () => browser.spacesToolbar.removeButton(),
+ /Incorrect argument types for spacesToolbar.removeButton./,
+ "removeButton() without id should throw."
+ );
+
+ browser.test.log("removeButton(): Removing with invalid id.");
+ await browser.test.assertRejects(
+ browser.spacesToolbar.removeButton("InvalidId"),
+ /Failed to remove button from the spaces toolbar: A button with id InvalidId does not exist for this extension./,
+ "removeButton() with invalid id should throw."
+ );
+
+ browser.test.log("removeButton(): Removing button_1.");
+ await browser.spacesToolbar.removeButton("button_1");
+ await window.sendMessage("checkUI", [expected_button_2]);
+
+ browser.test.notifyPass();
+ }
+ await test_spaceToolbar(background, "default");
+ await test_spaceToolbar(background, "default", { 16: "manifest.png" });
+});
+
+add_task(async function test_open_reload_close() {
+ async function background() {
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ // Add buttons.
+ let url1 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ await browser.spacesToolbar.addButton("button_1", {
+ url: url1,
+ });
+ let url2 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content_body.html`;
+ await browser.spacesToolbar.addButton("button_2", {
+ url: url2,
+ });
+
+ // Open spaces.
+ await window.sendMessage("checkTabs", {
+ action: "open",
+ url: url1,
+ buttonId: "button_1",
+ openSpacesUrls: [url1],
+ });
+ await window.sendMessage("checkTabs", {
+ action: "open",
+ url: url2,
+ buttonId: "button_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Switch to open spaces tab.
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url1,
+ buttonId: "button_1",
+ openSpacesUrls: [url1, url2],
+ });
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url2,
+ buttonId: "button_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // TODO: Add test for tab reloading, once this has been implemented.
+
+ // Remove buttons and check that related spaces tab are closed.
+ await browser.spacesToolbar.removeButton("button_1");
+ await window.sendMessage("checkTabs", { openSpacesUrls: [url2] });
+ await browser.spacesToolbar.removeButton("button_2");
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ browser.test.notifyPass();
+ }
+ await test_spaceToolbar(background, "default");
+});
+
+add_task(async function test_icons() {
+ async function background() {
+ let manifest = browser.runtime.getManifest();
+ let extensionIcon = manifest.icons
+ ? browser.runtime.getURL(manifest.icons[16])
+ : "chrome://messenger/content/extension.svg";
+
+ // Test 1: Setting defaultIcons and themeIcons.
+ browser.test.log("addButton(): Setting defaultIcons and themeIcons.");
+ await browser.spacesToolbar.addButton("button_1", {
+ title: "Google",
+ url: "https://test.invalid",
+ defaultIcons: "default.png",
+ themeIcons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ });
+ let expected_button_1 = {
+ id: "button_1",
+ title: "Google",
+ url: "https://test.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ // Clearing defaultIcons.
+ await browser.spacesToolbar.updateButton("button_1", {
+ defaultIcons: "",
+ });
+ expected_button_1.icons = {
+ default: `url("${browser.runtime.getURL("dark.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ // Setting other defaultIcons.
+ await browser.spacesToolbar.updateButton("button_1", {
+ defaultIcons: "other.png",
+ });
+ expected_button_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark.png")}")`,
+ light: `url("${browser.runtime.getURL("light.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ // Clearing themeIcons.
+ await browser.spacesToolbar.updateButton("button_1", {
+ themeIcons: [],
+ });
+ expected_button_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("other.png")}")`,
+ light: `url("${browser.runtime.getURL("other.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ // Setting other themeIcons.
+ await browser.spacesToolbar.updateButton("button_1", {
+ themeIcons: [
+ {
+ dark: "dark2.png",
+ light: "light2.png",
+ size: 16,
+ },
+ ],
+ });
+ expected_button_1.icons = {
+ default: `url("${browser.runtime.getURL("other.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark2.png")}")`,
+ light: `url("${browser.runtime.getURL("light2.png")}")`,
+ };
+ await window.sendMessage("checkUI", [expected_button_1]);
+
+ // Test 2: Setting themeIcons only.
+ browser.test.log("addButton(): Setting themeIcons only.");
+ await browser.spacesToolbar.addButton("button_2", {
+ title: "Wikipedia",
+ url: "https://test.other.invalid",
+ themeIcons: [
+ {
+ dark: "dark2.png",
+ light: "light2.png",
+ size: 16,
+ },
+ ],
+ });
+ // Not specifying defaultIcons but only themeIcons should always use the
+ // theme icons, even for the default theme (and not the extension icon).
+ let expected_button_2 = {
+ id: "button_2",
+ title: "Wikipedia",
+ url: "https://test.other.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("dark2.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark2.png")}")`,
+ light: `url("${browser.runtime.getURL("light2.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ // Clearing themeIcons.
+ await browser.spacesToolbar.updateButton("button_2", {
+ themeIcons: [],
+ });
+ expected_button_2.icons = {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ };
+ await window.sendMessage("checkUI", [expected_button_1, expected_button_2]);
+
+ // Test 3: Setting defaultIcons only.
+ browser.test.log("addButton(): Setting defaultIcons only.");
+ await browser.spacesToolbar.addButton("button_3", {
+ title: "Bing",
+ url: "https://test.more.invalid",
+ defaultIcons: "default.png",
+ });
+ let expected_button_3 = {
+ id: "button_3",
+ title: "Bing",
+ url: "https://test.more.invalid",
+ icons: {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("default.png")}")`,
+ light: `url("${browser.runtime.getURL("default.png")}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [
+ expected_button_1,
+ expected_button_2,
+ expected_button_3,
+ ]);
+
+ // Clearing defaultIcons and setting themeIcons.
+ await browser.spacesToolbar.updateButton("button_3", {
+ defaultIcons: "",
+ themeIcons: [
+ {
+ dark: "dark3.png",
+ light: "light3.png",
+ size: 16,
+ },
+ ],
+ });
+ expected_button_3.icons = {
+ default: `url("${browser.runtime.getURL("dark3.png")}")`,
+ dark: `url("${browser.runtime.getURL("dark3.png")}")`,
+ light: `url("${browser.runtime.getURL("light3.png")}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_button_1,
+ expected_button_2,
+ expected_button_3,
+ ]);
+
+ // Test 4: Setting no icons.
+ browser.test.log("addButton(): Setting no icons.");
+ await browser.spacesToolbar.addButton("button_4", {
+ title: "DuckDuckGo",
+ url: "https://duckduckgo.com",
+ });
+ let expected_button_4 = {
+ id: "button_4",
+ title: "DuckDuckGo",
+ url: "https://duckduckgo.com",
+ icons: {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ },
+ };
+ await window.sendMessage("checkUI", [
+ expected_button_1,
+ expected_button_2,
+ expected_button_3,
+ expected_button_4,
+ ]);
+
+ // Setting and clearing default icons.
+ await browser.spacesToolbar.updateButton("button_4", {
+ defaultIcons: "default.png",
+ });
+ expected_button_4.icons = {
+ default: `url("${browser.runtime.getURL("default.png")}")`,
+ dark: `url("${browser.runtime.getURL("default.png")}")`,
+ light: `url("${browser.runtime.getURL("default.png")}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_button_1,
+ expected_button_2,
+ expected_button_3,
+ expected_button_4,
+ ]);
+ await browser.spacesToolbar.updateButton("button_4", {
+ defaultIcons: "",
+ });
+ expected_button_4.icons = {
+ default: `url("${extensionIcon}")`,
+ dark: `url("${extensionIcon}")`,
+ light: `url("${extensionIcon}")`,
+ };
+ await window.sendMessage("checkUI", [
+ expected_button_1,
+ expected_button_2,
+ expected_button_3,
+ expected_button_4,
+ ]);
+
+ browser.test.notifyPass();
+ }
+
+ // Test with and without icons defined in the manifest.
+ for (let manifestIcons of [null, { 16: "manifest16.png" }]) {
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ await test_spaceToolbar(background, "light", manifestIcons);
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ await test_spaceToolbar(background, "dark", manifestIcons);
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ await test_spaceToolbar(background, "default", manifestIcons);
+ }
+});
+
+add_task(async function test_open_programmatically() {
+ async function background() {
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ // Add buttons.
+ let url1 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content.html`;
+ await browser.spacesToolbar.addButton("button_1", {
+ url: url1,
+ });
+ let url2 = `http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/content_body.html`;
+ await browser.spacesToolbar.addButton("button_2", {
+ url: url2,
+ });
+
+ async function clickSpaceButton(buttonId, url) {
+ let loadPromise = new Promise(resolve => {
+ let urlSeen = false;
+ let listener = (tabId, changeInfo) => {
+ if (changeInfo.url && changeInfo.url == url) {
+ urlSeen = true;
+ }
+ if (changeInfo.status == "complete" && urlSeen) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(listener);
+ });
+ let tab = await browser.spacesToolbar.clickButton(buttonId);
+ await loadPromise;
+
+ let queriedTabs = await browser.tabs.query({ spaceId: tab.spaceId });
+ browser.test.assertEq(
+ 1,
+ queriedTabs.length,
+ "browser.tabs.query() should find exactly one tab belonging to the opened space"
+ );
+ browser.test.assertEq(
+ tab.id,
+ queriedTabs[0].id,
+ "browser.tabs.query() should find the correct tab belonging to the opened space"
+ );
+ }
+
+ // Open space #1.
+ await clickSpaceButton("button_1", url1);
+ await window.sendMessage("checkTabs", {
+ buttonId: "button_1",
+ openSpacesUrls: [url1],
+ });
+
+ // Open space #2.
+ await clickSpaceButton("button_2", url2);
+ await window.sendMessage("checkTabs", {
+ buttonId: "button_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Switch to open space tab.
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url1,
+ buttonId: "button_1",
+ openSpacesUrls: [url1, url2],
+ });
+
+ await window.sendMessage("checkTabs", {
+ action: "switch",
+ url: url2,
+ buttonId: "button_2",
+ openSpacesUrls: [url1, url2],
+ });
+
+ // Remove spaces and check that related spaces tab are closed.
+ await browser.spacesToolbar.removeButton("button_1");
+ await window.sendMessage("checkTabs", { openSpacesUrls: [url2] });
+ await browser.spacesToolbar.removeButton("button_2");
+ await window.sendMessage("checkTabs", { openSpacesUrls: [] });
+
+ browser.test.notifyPass();
+ }
+ await test_spaceToolbar(background, "default");
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_content.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_content.js
new file mode 100644
index 0000000000..fbc98ff09e
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_content.js
@@ -0,0 +1,336 @@
+/* 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/. */
+
+/**
+ * Common core of the test. This is complicated by how WebExtensions tests work.
+ *
+ * @param {Function} createTab - The code of this function is copied into the
+ * extension. It should assign a function to `window.createTab` that opens
+ * the tab to be tested and return the id of the tab.
+ * @param {Function} getBrowser - A function to get the <browser> associated
+ * with the tab.
+ */
+async function subTest(createTab, getBrowser, shouldRemove = true) {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "createTab.js": createTab,
+ "background.js": async () => {
+ // Open the tab to be tested.
+
+ let tabId = await window.createTab();
+
+ // Test insertCSS, removeCSS, and executeScript.
+
+ await window.sendMessage();
+ await browser.tabs.insertCSS(tabId, {
+ code: "body { background: lime }",
+ });
+ await window.sendMessage();
+ await browser.tabs.removeCSS(tabId, {
+ code: "body { background: lime }",
+ });
+ await window.sendMessage();
+ await browser.tabs.executeScript(tabId, {
+ code: `
+ document.body.textContent = "Hey look, the script ran!";
+ browser.runtime.onConnect.addListener(port =>
+ port.onMessage.addListener(message => {
+ browser.test.assertEq(message, "Sending a message.");
+ port.postMessage("Got your message.");
+ })
+ );
+ browser.runtime.onMessage.addListener(
+ (message, sender, sendResponse) => {
+ browser.test.assertEq(message, "Sending a message.");
+ sendResponse("Got your message.");
+ }
+ );
+ `,
+ });
+ await window.sendMessage();
+
+ // Test connect and sendMessage. The receivers were set up above.
+
+ let port = await browser.tabs.connect(tabId);
+ port.onMessage.addListener(message =>
+ browser.test.assertEq(message, "Got your message.")
+ );
+ port.postMessage("Sending a message.");
+
+ let response = await browser.tabs.sendMessage(
+ tabId,
+ "Sending a message."
+ );
+ browser.test.assertEq(response, "Got your message.");
+
+ // Remove the tab if required.
+
+ let [shouldRemove] = await window.sendMessage();
+ if (shouldRemove) {
+ await browser.tabs.remove(tabId);
+ }
+ browser.test.notifyPass();
+ },
+ "test.html": "<html><body>I'm a real page!</body></html>",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "createTab.js", "background.js"] },
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage();
+ let browser = getBrowser();
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+
+ await checkContent(browser, {
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ textContent: "I'm a real page!",
+ });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkContent(browser, { backgroundColor: "rgb(0, 255, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkContent(browser, { backgroundColor: "rgba(0, 0, 0, 0)" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ await checkContent(browser, { textContent: "Hey look, the script ran!" });
+ extension.sendMessage();
+
+ await extension.awaitMessage();
+ extension.sendMessage(shouldRemove);
+
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function testFirstTab() {
+ let createTab = async () => {
+ window.createTab = async function () {
+ let tabs = await browser.tabs.query({});
+ browser.test.assertEq(1, tabs.length);
+ await browser.tabs.update(tabs[0].id, { url: "test.html" });
+ return tabs[0].id;
+ };
+ };
+
+ let tabmail = document.getElementById("tabmail");
+ function getBrowser(expected) {
+ return tabmail.currentTabInfo.browser;
+ }
+
+ let gAccount = createAccount();
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: gAccount.incomingServer.rootFolder.subFolders[0].URI,
+ });
+
+ return subTest(createTab, getBrowser, false);
+});
+
+add_task(async function testContentTab() {
+ let createTab = async () => {
+ window.createTab = async function () {
+ let tab = await browser.tabs.create({ url: "test.html" });
+ return tab.id;
+ };
+ };
+
+ function getBrowser(expected) {
+ let tabmail = document.getElementById("tabmail");
+ return tabmail.currentTabInfo.browser;
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "Should find the correct number of tabs before the test."
+ );
+ // Run the subtest without removing the created tab, to check if extension tabs
+ // are removed automatically, when the extension is removed.
+ let rv = await subTest(createTab, getBrowser, false);
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "Should find the correct number of tabs after the test."
+ );
+ return rv;
+});
+
+add_task(async function testPopupWindow() {
+ let createTab = async () => {
+ window.createTab = async function () {
+ let popup = await browser.windows.create({
+ url: "test.html",
+ type: "popup",
+ });
+ browser.test.assertEq(1, popup.tabs.length);
+ return popup.tabs[0].id;
+ };
+ };
+
+ function getBrowser(expected) {
+ let popups = [...Services.wm.getEnumerator("mail:extensionPopup")];
+ Assert.equal(popups.length, 1);
+
+ let popup = popups[0];
+
+ let popupBrowser = popup.getBrowser();
+ Assert.ok(popupBrowser);
+
+ return popupBrowser;
+ }
+ let popups = [...Services.wm.getEnumerator("mail:extensionPopup")];
+ Assert.equal(
+ popups.length,
+ 0,
+ "Should find the no extension windows before the test."
+ );
+ // Run the subtest without removing the created window, to check if extension
+ // windows are removed automatically, when the extension is removed.
+ let rv = await subTest(createTab, getBrowser, false);
+ Assert.equal(
+ popups.length,
+ 0,
+ "Should find the no extension windows after the test."
+ );
+ return rv;
+});
+
+add_task(async function testMultipleContentTabs() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let tabs = [];
+ let tests = [
+ {
+ url: "test.html",
+ expectedUrl: browser.runtime.getURL("test.html"),
+ },
+ {
+ url: "test.html",
+ expectedUrl: browser.runtime.getURL("test.html"),
+ },
+ {
+ url: "https://www.example.com",
+ expectedUrl: "https://www.example.com/",
+ },
+ {
+ url: "https://www.example.com",
+ expectedUrl: "https://www.example.com/",
+ },
+ {
+ url: "https://www.example.com/",
+ expectedUrl: "https://www.example.com/",
+ },
+ {
+ url: "https://www.example.com/",
+ expectedUrl: "https://www.example.com/",
+ },
+ {
+ url: "https://www.example.com/",
+ expectedUrl: "https://www.example.com/",
+ },
+ ];
+
+ async function create(url, expectedUrl) {
+ let tabDonePromise = new Promise(resolve => {
+ let changeInfoStatus = false;
+ let changeInfoUrl = false;
+
+ let listener = (tabId, changeInfo) => {
+ if (!tab || tab.id != tabId) {
+ return;
+ }
+ // Looks like "complete" is reached sometimes before the url is done,
+ // so check for both.
+ if (changeInfo.status == "complete") {
+ changeInfoStatus = true;
+ }
+ if (changeInfo.url) {
+ changeInfoUrl = changeInfo.url;
+ }
+
+ if (changeInfoStatus && changeInfoUrl) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve(changeInfoUrl);
+ }
+ };
+ browser.tabs.onUpdated.addListener(listener);
+ });
+
+ let tab = await browser.tabs.create({ url });
+ for (let otherTab of tabs) {
+ browser.test.assertTrue(
+ tab.id != otherTab.id,
+ "Id of created tab should be unique."
+ );
+ }
+ tabs.push(tab);
+
+ let changeInfoUrl = await tabDonePromise;
+ browser.test.assertEq(
+ expectedUrl,
+ changeInfoUrl,
+ "Should have seen the correct url."
+ );
+ }
+
+ for (let { url, expectedUrl } of tests) {
+ await create(url, expectedUrl);
+ }
+
+ browser.test.notifyPass();
+ },
+ "test.html": "<html><body>I'm a real page!</body></html>",
+ },
+ manifest: {
+ background: { scripts: ["background.js"] },
+ permissions: ["tabs"],
+ },
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "Should find the correct number of tabs before the test."
+ );
+
+ await extension.startup();
+ await extension.awaitFinish();
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 8,
+ "Should find the correct number of tabs after the test."
+ );
+
+ await extension.unload();
+ // After unload, the two extension tabs should be closed.
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 6,
+ "Should find the correct number of tabs after extension unload."
+ );
+
+ for (let i = tabmail.tabInfo.length; i > 0; i--) {
+ let nativeTabInfo = tabmail.tabInfo[i - 1];
+ let uri = nativeTabInfo.browser?.browsingContext.currentURI;
+ if (uri && ["https", "http"].includes(uri.scheme)) {
+ tabmail.closeTab(nativeTabInfo);
+ }
+ }
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "Should find the correct number of tabs after test has finished."
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js
new file mode 100644
index 0000000000..48afe44ad7
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js
@@ -0,0 +1,275 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_setup(async function () {
+ // make sure userContext is enabled.
+ return SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+});
+
+add_task(async function () {
+ info("Start testing tabs.create with cookieStoreId");
+
+ let testCases = [
+ {
+ cookieStoreId: null,
+ expectedCookieStoreId: "firefox-default",
+ },
+ {
+ cookieStoreId: "firefox-default",
+ expectedCookieStoreId: "firefox-default",
+ },
+ {
+ cookieStoreId: "firefox-container-1",
+ expectedCookieStoreId: "firefox-container-1",
+ },
+ {
+ cookieStoreId: "firefox-container-2",
+ expectedCookieStoreId: "firefox-container-2",
+ },
+ { cookieStoreId: "firefox-container-42", failure: "exist" },
+ { cookieStoreId: "firefox-private", failure: "defaultToPrivate" },
+ { cookieStoreId: "wow", failure: "illegal" },
+ ];
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+
+ background() {
+ function testTab(data, tab) {
+ browser.test.assertTrue(!data.failure, "we want a success");
+ browser.test.assertTrue(!!tab, "we have a tab");
+ browser.test.assertEq(
+ data.expectedCookieStoreId,
+ tab.cookieStoreId,
+ "tab should have the correct cookieStoreId"
+ );
+ }
+
+ async function runTest(data) {
+ try {
+ // Tab Creation
+ let tab;
+ try {
+ tab = await browser.tabs.create({
+ windowId: this.defaultWindowId,
+ cookieStoreId: data.cookieStoreId,
+ });
+
+ browser.test.assertTrue(!data.failure, "we want a success");
+ } catch (error) {
+ browser.test.assertTrue(!!data.failure, "we want a failure");
+ if (data.failure == "illegal") {
+ browser.test.assertEq(
+ `Illegal cookieStoreId: ${data.cookieStoreId}`,
+ error.message,
+ "runtime.lastError should report the expected error message"
+ );
+ } else if (data.failure == "defaultToPrivate") {
+ browser.test.assertEq(
+ "Illegal to set private cookieStoreId in a non-private window",
+ error.message,
+ "runtime.lastError should report the expected error message"
+ );
+ } else if (data.failure == "privateToDefault") {
+ browser.test.assertEq(
+ "Illegal to set non-private cookieStoreId in a private window",
+ error.message,
+ "runtime.lastError should report the expected error message"
+ );
+ } else if (data.failure == "exist") {
+ browser.test.assertEq(
+ `No cookie store exists with ID ${data.cookieStoreId}`,
+ error.message,
+ "runtime.lastError should report the expected error message"
+ );
+ } else {
+ browser.test.fail("The test is broken");
+ }
+
+ browser.test.sendMessage("test-done");
+ return;
+ }
+
+ // Tests for tab creation
+ testTab(data, tab);
+
+ {
+ // Tests for tab querying
+ let [tab] = await browser.tabs.query({
+ windowId: this.defaultWindowId,
+ cookieStoreId: data.cookieStoreId,
+ });
+
+ browser.test.assertTrue(tab != undefined, "Tab found!");
+ testTab(data, tab);
+ }
+
+ let stores = await browser.cookies.getAllCookieStores();
+
+ let store = stores.find(store => store.id === tab.cookieStoreId);
+ browser.test.assertTrue(!!store, "We have a store for this tab.");
+ browser.test.assertTrue(
+ store.tabIds.includes(tab.id),
+ "tabIds includes this tab."
+ );
+
+ await browser.tabs.remove(tab.id);
+
+ browser.test.sendMessage("test-done");
+ } catch (e) {
+ browser.test.fail("An exception has been thrown");
+ }
+ }
+
+ async function initialize() {
+ let win = await browser.windows.getCurrent();
+ this.defaultWindowId = win.id;
+
+ browser.test.sendMessage("ready");
+ }
+
+ async function shutdown() {
+ browser.test.sendMessage("gone");
+ }
+
+ // Waiting for messages
+ browser.test.onMessage.addListener((msg, data) => {
+ if (msg == "be-ready") {
+ initialize();
+ } else if (msg == "test") {
+ runTest(data);
+ } else {
+ browser.test.assertTrue("finish", msg, "Shutting down");
+ shutdown();
+ }
+ });
+ },
+ });
+
+ await extension.startup();
+
+ info("Tests must be ready...");
+ extension.sendMessage("be-ready");
+ await extension.awaitMessage("ready");
+ info("Tests are ready to run!");
+
+ for (let test of testCases) {
+ info(`test tab.create with cookieStoreId: "${test.cookieStoreId}"`);
+ extension.sendMessage("test", test);
+ await extension.awaitMessage("test-done");
+ }
+
+ info("Waiting for shutting down...");
+ extension.sendMessage("finish");
+ await extension.awaitMessage("gone");
+
+ await extension.unload();
+});
+
+add_task(async function userContext_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", false]],
+ });
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.tabs.create({ cookieStoreId: "firefox-container-1" }),
+ /Contextual identities are currently disabled/,
+ "should refuse to open container tab when contextual identities are disabled"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function tabs_query_cookiestoreid_nocookiepermission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let tab = await browser.tabs.create({});
+ browser.test.assertEq(
+ "firefox-default",
+ tab.cookieStoreId,
+ "Expecting cookieStoreId for new tab"
+ );
+ let query = await browser.tabs.query({
+ index: tab.index,
+ cookieStoreId: tab.cookieStoreId,
+ });
+ browser.test.assertEq(
+ "firefox-default",
+ query[0].cookieStoreId,
+ "Expecting cookieStoreId for new tab through browser.tabs.query"
+ );
+ await browser.tabs.remove(tab.id);
+ browser.test.sendMessage("done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function tabs_query_multiple_cookiestoreId() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies"],
+ },
+
+ async background() {
+ let tab1 = await browser.tabs.create({
+ cookieStoreId: "firefox-container-1",
+ });
+ browser.test.log(`Tab created for cookieStoreId:${tab1.cookieStoreId}`);
+
+ let tab2 = await browser.tabs.create({
+ cookieStoreId: "firefox-container-2",
+ });
+ browser.test.log(`Tab created for cookieStoreId:${tab2.cookieStoreId}`);
+
+ let tab3 = await browser.tabs.create({
+ cookieStoreId: "firefox-container-3",
+ });
+ browser.test.log(`Tab created for cookieStoreId:${tab3.cookieStoreId}`);
+
+ let tabs = await browser.tabs.query({
+ cookieStoreId: ["firefox-container-1", "firefox-container-2"],
+ });
+
+ browser.test.assertEq(
+ 2,
+ tabs.length,
+ "Expecting tabs for firefox-container-1 and firefox-container-2"
+ );
+
+ browser.test.assertEq(
+ "firefox-container-1",
+ tabs[0].cookieStoreId,
+ "Expecting tab for firefox-container-1 cookieStoreId"
+ );
+
+ browser.test.assertEq(
+ "firefox-container-2",
+ tabs[1].cookieStoreId,
+ "Expecting tab for firefox-container-2 cookieStoreId"
+ );
+
+ await browser.tabs.remove([tab1.id, tab2.id, tab3.id]);
+ browser.test.sendMessage("test-done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("test-done");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_events.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_events.js
new file mode 100644
index 0000000000..fa23482a3a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_events.js
@@ -0,0 +1,591 @@
+/* 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 () => {
+ let tabmail = document.getElementById("tabmail");
+
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("tabsEvents", null);
+ let testFolder = rootFolder.findSubFolder("tabsEvents");
+ createMessages(testFolder, 5);
+ let messages = [...testFolder.messages];
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "page1.html": "<html><body>Page 1</body></html>",
+ "page2.html": "<html><body>Page 2</body></html>",
+ "background.js": async () => {
+ // Executes a command, but first loads a second extension with terminated
+ // background and waits for it to be restarted due to the executed command.
+ async function capturePrimedEvent(eventName, callback) {
+ let eventPageExtensionReadyPromise = window.waitForMessage();
+ browser.test.sendMessage("capturePrimedEvent", eventName);
+ await eventPageExtensionReadyPromise;
+ let eventPageExtensionFinishedPromise = window.waitForMessage();
+ callback();
+ return eventPageExtensionFinishedPromise;
+ }
+
+ let listener = {
+ events: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ this.events.push(args);
+ if (this.currentPromise) {
+ let p = this.currentPromise;
+ this.currentPromise = null;
+ p.resolve(args);
+ }
+ },
+ onCreated(...args) {
+ this.pushEvent("onCreated", ...args);
+ },
+ onUpdated(...args) {
+ this.pushEvent("onUpdated", ...args);
+ },
+ onActivated(...args) {
+ this.pushEvent("onActivated", ...args);
+ },
+ onRemoved(...args) {
+ this.pushEvent("onRemoved", ...args);
+ },
+ async nextEvent() {
+ if (this.events.length == 0) {
+ return new Promise(
+ resolve => (this.currentPromise = { resolve })
+ );
+ }
+ return Promise.resolve(this.events[0]);
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ await this.nextEvent();
+ let [actualEvent, ...actualArgs] = this.events.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(
+ typeof expectedArgs[i],
+ typeof actualArgs[i]
+ );
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(
+ expectedArgs[i][key],
+ actualArgs[i][key]
+ );
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+ return actualArgs;
+ },
+ async pageLoad(tab, active = true) {
+ while (true) {
+ // Read the first event without consuming it.
+ let [actualEvent, actualTabId, actualInfo, actualTab] =
+ await this.nextEvent();
+ browser.test.assertEq("onUpdated", actualEvent);
+ browser.test.assertEq(tab, actualTabId);
+
+ if (
+ actualInfo.status == "loading" ||
+ actualTab.url == "about:blank"
+ ) {
+ // We're not interested in these events. Take them off the list.
+ browser.test.log("Skipping this event.");
+ this.events.shift();
+ } else {
+ break;
+ }
+ }
+ await this.checkEvent(
+ "onUpdated",
+ tab,
+ { status: "complete" },
+ {
+ id: tab,
+ windowId: initialWindow,
+ active,
+ mailTab: false,
+ }
+ );
+ },
+ };
+
+ browser.tabs.onCreated.addListener(listener.onCreated.bind(listener));
+ browser.tabs.onUpdated.addListener(listener.onUpdated.bind(listener), {
+ properties: ["status"],
+ });
+ browser.tabs.onActivated.addListener(
+ listener.onActivated.bind(listener)
+ );
+ browser.tabs.onRemoved.addListener(listener.onRemoved.bind(listener));
+
+ browser.test.log(
+ "Collect the ID of the initial tab (there must be only one) and window."
+ );
+
+ let initialTabs = await browser.tabs.query({});
+ browser.test.assertEq(1, initialTabs.length);
+ browser.test.assertEq(0, initialTabs[0].index);
+ browser.test.assertTrue(initialTabs[0].mailTab);
+ browser.test.assertEq("mail", initialTabs[0].type);
+ let [{ id: initialTab, windowId: initialWindow }] = initialTabs;
+
+ browser.test.log("Add a first content tab and wait for it to load.");
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 1,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "content",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.tabs.create({
+ url: browser.runtime.getURL("page1.html"),
+ })
+ )
+ );
+ let [{ id: contentTab1 }] = await listener.checkEvent("onCreated", {
+ index: 1,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "content",
+ });
+ browser.test.assertTrue(contentTab1 != initialTab);
+ await listener.pageLoad(contentTab1);
+ browser.test.assertEq(
+ "content",
+ (await browser.tabs.get(contentTab1)).type
+ );
+
+ browser.test.log("Add a second content tab and wait for it to load.");
+
+ // The external extension is looking for the onUpdated event, it either be
+ // a loading or completed event. Compare with whatever the local extension
+ // is getting.
+ let locContentTabUpdateInfoPromise = new Promise(resolve => {
+ let listener = (...args) => {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve(args);
+ };
+ browser.tabs.onUpdated.addListener(listener, {
+ properties: ["status"],
+ });
+ });
+ let primedContentTabUpdateInfo = await capturePrimedEvent(
+ "onUpdated",
+ () =>
+ browser.tabs.create({
+ url: browser.runtime.getURL("page2.html"),
+ })
+ );
+ let [{ id: contentTab2 }] = await listener.checkEvent("onCreated", {
+ index: 2,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "content",
+ });
+ let locContentTabUpdateInfo = await locContentTabUpdateInfoPromise;
+ window.assertDeepEqual(
+ locContentTabUpdateInfo,
+ primedContentTabUpdateInfo,
+ "primed onUpdated event and non-primed onUpdeated event should receive the same values",
+ { strict: true }
+ );
+
+ browser.test.assertTrue(
+ ![initialTab, contentTab1].includes(contentTab2)
+ );
+ await listener.pageLoad(contentTab2);
+ browser.test.assertEq(
+ "content",
+ (await browser.tabs.get(contentTab2)).type
+ );
+
+ browser.test.log("Add the calendar tab.");
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 3,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "calendar",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.test.sendMessage("openCalendarTab")
+ )
+ );
+ let [{ id: calendarTab }] = await listener.checkEvent("onCreated", {
+ index: 3,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "calendar",
+ });
+ browser.test.assertTrue(
+ ![initialTab, contentTab1, contentTab2].includes(calendarTab)
+ );
+
+ browser.test.log("Add the task tab.");
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 4,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "tasks",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.test.sendMessage("openTaskTab")
+ )
+ );
+ let [{ id: taskTab }] = await listener.checkEvent("onCreated", {
+ index: 4,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "tasks",
+ });
+ browser.test.assertTrue(
+ ![initialTab, contentTab1, contentTab2, calendarTab].includes(taskTab)
+ );
+
+ browser.test.log("Open a folder in a tab.");
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 5,
+ windowId: initialWindow,
+ active: true,
+ mailTab: true,
+ type: "mail",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.test.sendMessage("openFolderTab")
+ )
+ );
+ let [{ id: folderTab }] = await listener.checkEvent("onCreated", {
+ index: 5,
+ windowId: initialWindow,
+ active: true,
+ mailTab: true,
+ type: "mail",
+ });
+ browser.test.assertTrue(
+ ![
+ initialTab,
+ contentTab1,
+ contentTab2,
+ calendarTab,
+ taskTab,
+ ].includes(folderTab)
+ );
+
+ browser.test.log("Open a first message in a tab.");
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 6,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "messageDisplay",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.test.sendMessage("openMessageTab", false)
+ )
+ );
+
+ let [{ id: messageTab1 }] = await listener.checkEvent("onCreated", {
+ index: 6,
+ windowId: initialWindow,
+ active: true,
+ mailTab: false,
+ type: "messageDisplay",
+ });
+ browser.test.assertTrue(
+ ![
+ initialTab,
+ contentTab1,
+ contentTab2,
+ calendarTab,
+ taskTab,
+ folderTab,
+ ].includes(messageTab1)
+ );
+ await listener.pageLoad(messageTab1);
+
+ browser.test.log(
+ "Open a second message in a tab. In the background, just because."
+ );
+
+ window.assertDeepEqual(
+ [
+ {
+ index: 7,
+ windowId: initialWindow,
+ active: false,
+ mailTab: false,
+ type: "messageDisplay",
+ },
+ ],
+ await capturePrimedEvent("onCreated", () =>
+ browser.test.sendMessage("openMessageTab", true)
+ )
+ );
+ let [{ id: messageTab2 }] = await listener.checkEvent("onCreated", {
+ index: 7,
+ windowId: initialWindow,
+ active: false,
+ mailTab: false,
+ type: "messageDisplay",
+ });
+ browser.test.assertTrue(
+ ![
+ initialTab,
+ contentTab1,
+ contentTab2,
+ calendarTab,
+ taskTab,
+ folderTab,
+ messageTab1,
+ ].includes(messageTab2)
+ );
+ await listener.pageLoad(messageTab2, false);
+
+ browser.test.log(
+ "Activate each of the tabs in a somewhat random order to test the onActivated event."
+ );
+
+ let previousTabId = messageTab1;
+ for (let tab of [
+ initialTab,
+ calendarTab,
+ messageTab1,
+ taskTab,
+ contentTab1,
+ messageTab2,
+ folderTab,
+ contentTab2,
+ ]) {
+ window.assertDeepEqual(
+ [{ tabId: tab, windowId: initialWindow }],
+ await capturePrimedEvent("onActivated", () =>
+ browser.tabs.update(tab, { active: true })
+ )
+ );
+ await listener.checkEvent("onActivated", {
+ tabId: tab,
+ previousTabId,
+ windowId: initialWindow,
+ });
+ previousTabId = tab;
+ }
+
+ browser.test.log(
+ "Remove the first content tab. This was not active so no new tab should be activated."
+ );
+
+ window.assertDeepEqual(
+ [contentTab1, { windowId: initialWindow, isWindowClosing: false }],
+ await capturePrimedEvent("onRemoved", () =>
+ browser.tabs.remove(contentTab1)
+ )
+ );
+ await listener.checkEvent("onRemoved", contentTab1, {
+ windowId: initialWindow,
+ isWindowClosing: false,
+ });
+
+ browser.test.log(
+ "Remove the second content tab. This was active, and the calendar tab is after it, so that should be activated."
+ );
+
+ window.assertDeepEqual(
+ [contentTab2, { windowId: initialWindow, isWindowClosing: false }],
+ await capturePrimedEvent("onRemoved", () =>
+ browser.tabs.remove(contentTab2)
+ )
+ );
+ await listener.checkEvent("onRemoved", contentTab2, {
+ windowId: initialWindow,
+ isWindowClosing: false,
+ });
+ await listener.checkEvent("onActivated", {
+ tabId: calendarTab,
+ windowId: initialWindow,
+ });
+
+ browser.test.log("Remove the remaining tabs.");
+
+ for (let tab of [
+ taskTab,
+ messageTab1,
+ messageTab2,
+ folderTab,
+ calendarTab,
+ ]) {
+ window.assertDeepEqual(
+ [tab, { windowId: initialWindow, isWindowClosing: false }],
+ await capturePrimedEvent("onRemoved", () =>
+ browser.tabs.remove(tab)
+ )
+ );
+ await listener.checkEvent("onRemoved", tab, {
+ windowId: initialWindow,
+ isWindowClosing: false,
+ });
+ }
+
+ // Since the last tab was activated because all other tabs have been
+ // removed, previousTabId should be undefined.
+ await listener.checkEvent("onActivated", {
+ tabId: initialTab,
+ windowId: initialWindow,
+ previousTabId: undefined,
+ });
+
+ browser.test.assertEq(0, listener.events.length);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["tabs"],
+ },
+ });
+
+ // Function to start an event page extension (MV3), which can be called whenever
+ // the main test is about to trigger an event. The extension terminates its
+ // background and listens for that single event, verifying it is waking up correctly.
+ async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let eventName = browser.runtime.getManifest().description;
+
+ if (["onCreated", "onActivated", "onRemoved"].includes(eventName)) {
+ browser.tabs[eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${eventName} received`, args);
+ }
+ });
+ }
+
+ if (eventName == "onUpdated") {
+ browser.tabs.onUpdated.addListener(
+ (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage("onUpdated received", args);
+ }
+ },
+ {
+ properties: ["status"],
+ }
+ );
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ permissions: ["tabs"],
+ browser_specific_settings: {
+ gecko: { id: `tabs.eventpage.${eventName}@mochi.test` },
+ },
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "tabs", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "tabs", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "tabs", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+ }
+
+ extension.onMessage("openCalendarTab", () => {
+ let calendarTabButton = document.getElementById("calendarButton");
+ EventUtils.synthesizeMouseAtCenter(calendarTabButton, {
+ clickCount: 1,
+ });
+ });
+
+ extension.onMessage("openTaskTab", () => {
+ let taskTabButton = document.getElementById("tasksButton");
+ EventUtils.synthesizeMouseAtCenter(taskTabButton, { clickCount: 1 });
+ });
+
+ extension.onMessage("openFolderTab", () => {
+ tabmail.openTab("mail3PaneTab", {
+ folderURI: rootFolder.URI,
+ background: false,
+ });
+ });
+
+ extension.onMessage("openMessageTab", background => {
+ let msgHdr = messages.shift();
+ tabmail.openTab("mailMessageTab", {
+ messageURI: testFolder.getUriForMsg(msgHdr),
+ background,
+ });
+ });
+
+ extension.onMessage("capturePrimedEvent", async eventName => {
+ let primedEventData = await event_page_extension(eventName, () => {
+ // Resume execution in the main test, after the event page extension is
+ // ready to capture the event with deactivated background.
+ extension.sendMessage();
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_move.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_move.js
new file mode 100644
index 0000000000..9a249c62cb
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_move.js
@@ -0,0 +1,306 @@
+/* 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_setup(async () => {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("testFolder", null);
+ await createMessages(rootFolder.getChildNamed("testFolder"), 5);
+});
+
+add_task(async function test_tabs_move() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Works as intended only if tabs are created one after the other.
+ async function createTab(url) {
+ let createdTab;
+ let loadPromise = new Promise(resolve => {
+ let urlSeen = false;
+ let listener = (tabId, changeInfo) => {
+ if (changeInfo.url && changeInfo.url == url) {
+ urlSeen = true;
+ }
+ if (changeInfo.status == "complete" && urlSeen) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(listener);
+ });
+ createdTab = await browser.tabs.create({ url });
+ await loadPromise;
+ return createdTab;
+ }
+
+ // Works as intended only if windows are created one after the other.
+ async function createWindow({ url, type }) {
+ let createdWindow;
+ let loadPromise = new Promise(resolve => {
+ if (!url) {
+ resolve();
+ } else {
+ let urlSeen = false;
+ let listener = async (tabId, changeInfo) => {
+ if (changeInfo.url && changeInfo.url == url) {
+ urlSeen = true;
+ }
+ if (changeInfo.status == "complete" && urlSeen) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(listener);
+ }
+ });
+ createdWindow = await browser.windows.create({ type, url });
+ await loadPromise;
+ return createdWindow;
+ }
+
+ let mailWindow = await browser.windows.getCurrent();
+
+ let tab1 = await createTab(browser.runtime.getURL("test1.html"));
+ let tab2 = await createTab(browser.runtime.getURL("test2.html"));
+ let tab3 = await createTab(browser.runtime.getURL("test3.html"));
+ let tab4 = await createTab(browser.runtime.getURL("test4.html"));
+
+ let tabs = await browser.tabs.query({ windowId: mailWindow.id });
+ browser.test.assertEq(5, tabs.length, "Number of tabs is correct");
+ browser.test.assertEq(
+ tab1.id,
+ tabs[1].id,
+ "Id of tab at index 1 should be that of tab1"
+ );
+ browser.test.assertEq(
+ tab2.id,
+ tabs[2].id,
+ "Id of tab at index 2 should be that of tab2"
+ );
+ browser.test.assertEq(
+ tab3.id,
+ tabs[3].id,
+ "Id of tab at index 3 should be that of tab3"
+ );
+ browser.test.assertEq(
+ tab4.id,
+ tabs[4].id,
+ "Id of tab at index 4 should be that of tab4"
+ );
+ browser.test.assertEq(1, tabs[1].index, "Index of tab1 is correct");
+ browser.test.assertEq(2, tabs[2].index, "Index of tab2 is correct");
+ browser.test.assertEq(3, tabs[3].index, "Index of tab3 is correct");
+ browser.test.assertEq(4, tabs[4].index, "Index of tab4 is correct");
+
+ // Move two tabs to the end of the current window.
+ await browser.tabs.move([tab2.id, tab1.id], { index: -1 });
+
+ tabs = await browser.tabs.query({ windowId: mailWindow.id });
+ browser.test.assertEq(
+ 5,
+ tabs.length,
+ "Number of tabs after move #1 is correct"
+ );
+ browser.test.assertEq(
+ tab3.id,
+ tabs[1].id,
+ "Id of tab at index 1 should be that of tab3 after move #1"
+ );
+ browser.test.assertEq(
+ tab4.id,
+ tabs[2].id,
+ "Id of tab at index 2 should be that of tab4 after move #1"
+ );
+ browser.test.assertEq(
+ tab2.id,
+ tabs[3].id,
+ "Id of tab at index 3 should be that of tab2 after move #1"
+ );
+ browser.test.assertEq(
+ tab1.id,
+ tabs[4].id,
+ "Id of tab at index 4 should be that of tab1 after move #1"
+ );
+ browser.test.assertEq(
+ 1,
+ tabs[1].index,
+ "Index of tab3 after move #1 is correct"
+ );
+ browser.test.assertEq(
+ 2,
+ tabs[2].index,
+ "Index of tab4 after move #1 is correct"
+ );
+ browser.test.assertEq(
+ 3,
+ tabs[3].index,
+ "Index of tab2 after move #1 is correct"
+ );
+ browser.test.assertEq(
+ 4,
+ tabs[4].index,
+ "Index of tab1 after move #1 is correct"
+ );
+
+ // Move a single tab to a specific location in current window.
+ await browser.tabs.move(tab3.id, { index: 3 });
+
+ tabs = await browser.tabs.query({ windowId: mailWindow.id });
+ browser.test.assertEq(
+ 5,
+ tabs.length,
+ "Number of tabs after move #2 is correct"
+ );
+ browser.test.assertEq(
+ tab4.id,
+ tabs[1].id,
+ "Id of tab at index 1 should be that of tab4 after move #2"
+ );
+ browser.test.assertEq(
+ tab3.id,
+ tabs[2].id,
+ "Id of tab at index 2 should be that of tab3 after move #2"
+ );
+ browser.test.assertEq(
+ tab2.id,
+ tabs[3].id,
+ "Id of tab at index 3 should be that of tab2 after move #2"
+ );
+ browser.test.assertEq(
+ tab1.id,
+ tabs[4].id,
+ "Id of tab at index 4 should be that of tab1 after move #2"
+ );
+ browser.test.assertEq(
+ 1,
+ tabs[1].index,
+ "Index of tab4 after move #2 is correct"
+ );
+ browser.test.assertEq(
+ 2,
+ tabs[2].index,
+ "Index of tab3 after move #2 is correct"
+ );
+ browser.test.assertEq(
+ 3,
+ tabs[3].index,
+ "Index of tab2 after move #2 is correct"
+ );
+ browser.test.assertEq(
+ 4,
+ tabs[4].index,
+ "Index of tab1 after move #2 is correct"
+ );
+
+ // Moving tabs to a popup should fail.
+ let popupWindow = await createWindow({
+ url: browser.runtime.getURL("test1.html"),
+ type: "popup",
+ });
+ await browser.test.assertRejects(
+ browser.tabs.move([tab3.id, tabs[4].id], {
+ windowId: popupWindow.id,
+ index: -1,
+ }),
+ `Window with ID ${popupWindow.id} is not a normal window`,
+ "Moving tabs to a popup window should fail."
+ );
+
+ // Moving a tab from a popup should fail.
+ let [popupTab] = await browser.tabs.query({ windowId: popupWindow.id });
+ await browser.test.assertRejects(
+ browser.tabs.move(popupTab.id, {
+ windowId: mailWindow.id,
+ index: -1,
+ }),
+ `Tab with ID ${popupTab.id} does not belong to a normal window`,
+ "Moving tabs from a popup window should fail."
+ );
+
+ // Moving a tab to an invalid window should fail.
+ await browser.test.assertRejects(
+ browser.tabs.move(popupTab.id, { windowId: 1234, index: -1 }),
+ `Invalid window ID: 1234`,
+ "Moving tabs to an invalid window should fail."
+ );
+
+ // Move tab between windows.
+ let secondMailWindow = await createWindow({ type: "normal" });
+ let [movedTab] = await browser.tabs.move(tab3.id, {
+ windowId: secondMailWindow.id,
+ index: -1,
+ });
+
+ tabs = await browser.tabs.query({ windowId: mailWindow.id });
+ browser.test.assertEq(
+ 4,
+ tabs.length,
+ "Number of tabs after move #3 is correct"
+ );
+ browser.test.assertEq(
+ tab4.id,
+ tabs[1].id,
+ "Id of tab at index 1 should be that of tab4 after move #3"
+ );
+ browser.test.assertEq(
+ tab2.id,
+ tabs[2].id,
+ "Id of tab at index 2 should be that of tab2 after move #3"
+ );
+ browser.test.assertEq(
+ tab1.id,
+ tabs[3].id,
+ "Id of tab at index 3 should be that of tab1 after move #3"
+ );
+ browser.test.assertEq(
+ 1,
+ tabs[1].index,
+ "Index of tab4 after move #3 is correct"
+ );
+ browser.test.assertEq(
+ 2,
+ tabs[2].index,
+ "Index of tab2 after move #3 is correct"
+ );
+ browser.test.assertEq(
+ 3,
+ tabs[3].index,
+ "Index of tab1 after move #3 is correct"
+ );
+
+ tabs = await browser.tabs.query({ windowId: secondMailWindow.id });
+ browser.test.assertEq(
+ 2,
+ tabs.length,
+ "Number of tabs in the second normal window after move #3 is correct"
+ );
+ browser.test.assertEq(
+ movedTab.id,
+ tabs[1].id,
+ "Id of tab at index 1 of the second normal window should be that of the moved tab"
+ );
+
+ await browser.tabs.remove(tab1.id);
+ await browser.tabs.remove(tab2.id);
+ await browser.tabs.remove(tab4.id);
+ await browser.windows.remove(popupWindow.id);
+ await browser.windows.remove(secondMailWindow.id);
+
+ browser.test.notifyPass();
+ },
+ "test1.html": "<html><body>I'm page #1!</body></html>",
+ "test2.html": "<html><body>I'm page #2!</body></html>",
+ "test3.html": "<html><body>I'm page #3!</body></html>",
+ "test4.html": "<html><body>I'm page #4!</body></html>",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_onCreated_bug1817872.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_onCreated_bug1817872.js
new file mode 100644
index 0000000000..515c7695bf
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_onCreated_bug1817872.js
@@ -0,0 +1,226 @@
+var gAccount;
+var gMessages;
+var gFolder;
+
+add_setup(() => {
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ let rootFolder = gAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+
+ gFolder = subFolders.test0;
+ gMessages = [...subFolders.test0.messages];
+});
+
+async function getTestExtension() {
+ let files = {
+ "background.js": async () => {
+ let [location] = await window.waitForMessage();
+
+ let [mailTab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(
+ "mail",
+ mailTab.type,
+ "Should have found a mail tab."
+ );
+
+ // Get displayed message.
+ let message1 = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(
+ !!message1,
+ "We should have a displayed message."
+ );
+
+ // Open message in a new tab, wait for onCreated and for onUpdated.
+ let messageTab = await new Promise(resolve => {
+ let createListener = tab => {
+ browser.tabs.onCreated.removeListener(createListener);
+ browser.test.assertEq(
+ "loading",
+ tab.status,
+ "The tab is expected to be still loading."
+ );
+ browser.tabs.onUpdated.addListener(updateListener, {
+ tabId: tab.id,
+ });
+ };
+ let updateListener = (tabId, changeInfo, tab) => {
+ if (changeInfo.status) {
+ browser.test.assertEq(
+ tab.status,
+ changeInfo.status,
+ "We should see the same status in tab and in changeInfo."
+ );
+ if (changeInfo.status == "complete") {
+ browser.tabs.onUpdated.removeListener(updateListener);
+ resolve(tab);
+ }
+ }
+ };
+ browser.tabs.onCreated.addListener(createListener);
+ browser.messageDisplay.open({
+ location,
+ messageId: message1.id,
+ });
+ });
+
+ // We should now be able to get the message.
+ let message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2?.id,
+ "We should see the same message."
+ );
+
+ // We should be able to get the message later as well.
+ await new Promise(resolve => window.setTimeout(resolve));
+ let message3 = await browser.messageDisplay.getDisplayedMessage(
+ messageTab.id
+ );
+ browser.test.assertTrue(
+ !!message3,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message3?.id,
+ "We should see the same message."
+ );
+
+ browser.tabs.remove(messageTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ return ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs"],
+ },
+ });
+}
+
+/**
+ * Open a message tab and check its status, wait till loaded and get the message.
+ */
+add_task(async function test_onCreated_message_tab() {
+ let extension = await getTestExtension();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+ extension.sendMessage("tab");
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Open a message window and check its status, wait till loaded and get the message.
+ */
+add_task(async function test_onCreated_message_window() {
+ let extension = await getTestExtension();
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+ extension.sendMessage("window");
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Open an address book tab and check its status.
+ */
+add_task(async function test_onCreated_addressBook_tab() {
+ let files = {
+ "background.js": async () => {
+ let [mailTab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(
+ "mail",
+ mailTab.type,
+ "Should have found a mail tab."
+ );
+
+ // Open ab tab, wait for onCreated and for onUpdated.
+ let abTab = await new Promise(resolve => {
+ let createListener = tab => {
+ browser.test.assertEq(
+ "loading",
+ tab.status,
+ "The tab is expected to be still loading."
+ );
+ browser.tabs.onUpdated.addListener(updateListener, {
+ tabId: tab.id,
+ });
+ };
+ let updateListener = (tabId, changeInfo, tab) => {
+ if (changeInfo.status) {
+ browser.test.assertEq(
+ tab.status,
+ changeInfo.status,
+ "We should see the same status in tab and in changeInfo."
+ );
+ if (changeInfo.status == "complete") {
+ browser.tabs.onUpdated.removeListener(updateListener);
+ resolve(tab);
+ }
+ }
+ };
+ browser.tabs.onCreated.addListener(createListener);
+ browser.addressBooks.openUI();
+ });
+ browser.test.assertEq(
+ "addressBook",
+ abTab.type,
+ "We should find an addressBook tab."
+ );
+ browser.tabs.remove(abTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ await extension.startup();
+ extension.sendMessage("window");
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_query.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_query.js
new file mode 100644
index 0000000000..6cecde63c7
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_query.js
@@ -0,0 +1,113 @@
+/* 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 function testQuery() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // There should be a single mailtab at startup.
+ let tabs = await browser.tabs.query({});
+
+ browser.test.assertEq(1, tabs.length, "Found one tab at startup");
+ browser.test.assertEq("mail", tabs[0].type, "Tab is mail tab");
+ let mailTab = tabs[0];
+
+ // Create a content tab.
+ let contentTab = await browser.tabs.create({ url: "test.html" });
+ browser.test.assertTrue(
+ contentTab.id != mailTab.id,
+ "Id of content tab is different from mail tab"
+ );
+
+ // Query spaces.
+ let spaces = await browser.spaces.query({ id: mailTab.spaceId });
+ browser.test.assertEq(1, spaces.length, "Found one matching space");
+ browser.test.assertEq(
+ "mail",
+ spaces[0].name,
+ "Space is the mail space"
+ );
+
+ // Query for all tabs.
+ tabs = await browser.tabs.query({});
+ browser.test.assertEq(2, tabs.length, "Found two tabs");
+
+ // Query for the content tab.
+ tabs = await browser.tabs.query({ type: "content" });
+ browser.test.assertEq(1, tabs.length, "Found one content tab");
+ browser.test.assertEq(
+ contentTab.id,
+ tabs[0].id,
+ "Id of content tab is correct"
+ );
+
+ // Query for the mail tab using spaceId.
+ tabs = await browser.tabs.query({ spaceId: mailTab.spaceId });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ mailTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ // Query for the mail tab using type.
+ tabs = await browser.tabs.query({ type: "mail" });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ mailTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ // Query for the mail tab using mailTab.
+ tabs = await browser.tabs.query({ mailTab: true });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ mailTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ // Query for the content tab but also using mailTab.
+ tabs = await browser.tabs.query({ mailTab: true, type: "content" });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ mailTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ // Query for active tab.
+ tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ contentTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ // Query for highlighted tab.
+ tabs = await browser.tabs.query({ highlighted: true });
+ browser.test.assertEq(1, tabs.length, "Found one mail tab");
+ browser.test.assertEq(
+ contentTab.id,
+ tabs[0].id,
+ "Id of mail tab is correct"
+ );
+
+ await browser.tabs.remove(contentTab.id);
+ browser.test.notifyPass();
+ },
+ "test.html": "<html><body>I'm a real page!</body></html>",
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tabs_update_reload.js b/comm/mail/components/extensions/test/browser/browser_ext_tabs_update_reload.js
new file mode 100644
index 0000000000..acd3bce0a7
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tabs_update_reload.js
@@ -0,0 +1,578 @@
+/* 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/. */
+
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(protocolScheme) {},
+ getApplicationDescription(scheme) {},
+ getProtocolHandlerInfo(protocolScheme) {
+ return {
+ possibleApplicationHandlers: Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ ),
+ };
+ },
+ getProtocolHandlerInfoFromOS(protocolScheme, found) {},
+ isExposedProtocol(protocolScheme) {},
+ loadURI(uri, windowContext) {
+ this._loadedURLs.push(uri.spec);
+ },
+ setProtocolHandlerDefaults(handlerInfo, osHandlerExists) {},
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+var gAccount;
+var gMessages;
+var gFolder;
+
+add_setup(() => {
+ gAccount = createAccount();
+ addIdentity(gAccount);
+ let rootFolder = gAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test0", null);
+
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test0, 5);
+
+ gFolder = subFolders.test0;
+ gMessages = [...subFolders.test0.messages];
+});
+
+/**
+ * Update registered WebExtension protocol handler pages.
+ */
+add_task(async function testUpdateTabs_WebExtProtocolHandler() {
+ let files = {
+ "background.js": async () => {
+ // Test a mail tab.
+
+ let [mailTab] = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertTrue(!!mailTab, "Should have found a mail tab.");
+
+ // Load a message.
+ let { messages } = await browser.messages.list(mailTab.displayedFolder);
+ await browser.mailTabs.setSelectedMessages(mailTab.id, [messages[0].id]);
+ let message1 = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(
+ !!message1,
+ "We should have a displayed message."
+ );
+ mailTab = await browser.tabs.get(mailTab.id);
+ browser.test.assertTrue(
+ mailTab.url.startsWith("mailbox:"),
+ "A message should be loaded"
+ );
+
+ // Update to a registered WebExtension protocol handler.
+ await new Promise(resolve => {
+ let urlSeen = false;
+ let updateListener = (tabId, changeInfo, tab) => {
+ if (
+ changeInfo.url &&
+ changeInfo.url.endsWith("handler.html#ext%2Btest%3A1234-1")
+ ) {
+ urlSeen = true;
+ }
+ if (urlSeen && changeInfo.status == "complete") {
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(updateListener);
+ browser.tabs.update(mailTab.id, { url: "ext+test:1234-1" });
+ });
+
+ mailTab = await browser.tabs.get(mailTab.id);
+ browser.test.assertTrue(
+ mailTab.url.endsWith("handler.html#ext%2Btest%3A1234-1"),
+ "Should have found the correct protocol handler url loaded"
+ );
+
+ // Test a message tab.
+
+ let messageTab = await browser.messageDisplay.open({
+ location: "tab",
+ messageId: message1.id,
+ });
+ browser.test.assertEq(
+ "messageDisplay",
+ messageTab.type,
+ "Should have found a message tab."
+ );
+ browser.test.assertTrue(
+ mailTab.windowId == messageTab.windowId,
+ "Tab should be in the main window."
+ );
+
+ // Updating a message tab to a registered WebExtension protocol handler
+ // should throw.
+ browser.test.assertRejects(
+ browser.tabs.update(messageTab.id, { url: "ext+test:1234-1" }),
+ /Loading a registered WebExtension protocol handler url is only supported for content tabs and mail tabs./,
+ "Updating a message tab to a registered WebExtension protocol handler should throw"
+ );
+ browser.tabs.remove(messageTab.id);
+
+ // Test a message window.
+
+ let messageWindowTab = await browser.messageDisplay.open({
+ location: "window",
+ messageId: message1.id,
+ });
+ browser.test.assertEq(
+ "messageDisplay",
+ messageWindowTab.type,
+ "Should have found a message tab."
+ );
+ browser.test.assertFalse(
+ mailTab.windowId == messageWindowTab.windowId,
+ "Tab should not be in the main window."
+ );
+
+ // Updating a message window to a registered WebExtension protocol handler
+ // should throw.
+ browser.test.assertRejects(
+ browser.tabs.update(messageWindowTab.id, { url: "ext+test:1234-1" }),
+ /Loading a registered WebExtension protocol handler url is only supported for content tabs and mail tabs./,
+ "Updating a message tab to a registered WebExtension protocol handler should throw"
+ );
+
+ browser.tabs.remove(messageWindowTab.id);
+
+ // Test a compose window.
+
+ let details1 = { to: ["Mr. Holmes <holmes@bakerstreet.invalid>"] };
+ let composeTab = await browser.compose.beginNew(details1);
+ browser.test.assertEq(
+ "messageCompose",
+ composeTab.type,
+ "Should have found a compose tab."
+ );
+ browser.test.assertFalse(
+ mailTab.windowId == composeTab.windowId,
+ "Tab should not be in the main window."
+ );
+ let details2 = await browser.compose.getComposeDetails(composeTab.id);
+ window.assertDeepEqual(
+ details1.to,
+ details2.to,
+ "We should see the correct compose details."
+ );
+
+ // Updating a message window to a registered WebExtension protocol handler
+ // should throw.
+ browser.test.assertRejects(
+ browser.tabs.update(composeTab.id, { url: "ext+test:1234-1" }),
+ /Loading a registered WebExtension protocol handler url is only supported for content tabs and mail tabs./,
+ "Updating a message tab to a registered WebExtension protocol handler should throw"
+ );
+
+ browser.tabs.remove(composeTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ "handler.html": "<html><body><p>Test Protocol Handler</p></body></html>",
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs", "compose"],
+ protocol_handlers: [
+ {
+ protocol: "ext+test",
+ name: "Protocol Handler Example",
+ uriTemplate: "/handler.html#%s",
+ },
+ ],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+/**
+ * Reload and update tabs and check if it fails for forbidden cases, keep track
+ * of urls opened externally.
+ */
+add_task(async function testUpdateReloadTabs() {
+ let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+ });
+
+ let files = {
+ "background.js": async () => {
+ // Test a mail tab.
+
+ let [mailTab] = await browser.mailTabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertTrue(!!mailTab, "Should have found a mail tab.");
+
+ // Load a URL.
+ await new Promise(resolve => {
+ let urlSeen = false;
+ let updateListener = (tabId, changeInfo, tab) => {
+ if (changeInfo.url == "https://www.example.com/") {
+ urlSeen = true;
+ }
+ if (urlSeen && changeInfo.status == "complete") {
+ resolve();
+ }
+ };
+ browser.tabs.onUpdated.addListener(updateListener);
+ browser.tabs.update(mailTab.id, { url: "https://www.example.com/" });
+ });
+
+ browser.test.assertEq(
+ "https://www.example.com/",
+ (await browser.tabs.get(mailTab.id)).url,
+ "Should have found the correct url loaded"
+ );
+
+ // This should not throw.
+ await browser.tabs.reload(mailTab.id);
+
+ // Update a tel:// url.
+ await browser.tabs.update(mailTab.id, { url: "tel:1234-1" });
+ await window.sendMessage("check_external_loaded_url", "tel:1234-1");
+
+ // We should still have the same url displayed.
+ browser.test.assertEq(
+ "https://www.example.com/",
+ (await browser.tabs.get(mailTab.id)).url,
+ "Should have found the correct url loaded"
+ );
+
+ // Load a message.
+ let { messages } = await browser.messages.list(mailTab.displayedFolder);
+ await browser.mailTabs.setSelectedMessages(mailTab.id, [messages[1].id]);
+ let message1 = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(
+ !!message1,
+ "We should have a displayed message."
+ );
+ mailTab = await browser.tabs.get(mailTab.id);
+ browser.test.assertFalse(
+ "https://www.example.com/" == mailTab.url,
+ "Webpage should no longer be loaded"
+ );
+
+ // Reload should now fail.
+ browser.test.assertRejects(
+ browser.tabs.reload(mailTab.id),
+ /Reloading is only supported for tabs displaying a content page/,
+ "Reloading a mail tab not displaying a content page should throw"
+ );
+
+ // We should still see the same message.
+ let message2 = await browser.messageDisplay.getDisplayedMessage(
+ mailTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a tel:// url.
+ await browser.tabs.update(mailTab.id, { url: "tel:1234-2" });
+ await window.sendMessage("check_external_loaded_url", "tel:1234-2");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(mailTab.id);
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a non-registered WebExtension protocol handler.
+ await browser.tabs.update(mailTab.id, { url: "ext+test:1234-1" });
+ await window.sendMessage("check_external_loaded_url", "ext+test:1234-1");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(mailTab.id);
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Test a message tab.
+
+ let messageTab = await browser.messageDisplay.open({
+ location: "tab",
+ messageId: message1.id,
+ });
+ browser.test.assertEq(
+ "messageDisplay",
+ messageTab.type,
+ "Should have found a message tab."
+ );
+ browser.test.assertTrue(
+ mailTab.windowId == messageTab.windowId,
+ "Tab should be in the main window."
+ );
+
+ browser.test.assertRejects(
+ browser.tabs.reload(messageTab.id),
+ /Reloading is only supported for tabs displaying a content page/,
+ "Reloading a message tab should throw"
+ );
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a tel:// url.
+ await browser.tabs.update(messageTab.id, { url: "tel:1234-3" });
+ await window.sendMessage("check_external_loaded_url", "tel:1234-3");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a non-registered WebExtension protocol handler.
+ await browser.tabs.update(mailTab.id, { url: "ext+test:1234-2" });
+ await window.sendMessage("check_external_loaded_url", "ext+test:1234-2");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ browser.tabs.remove(messageTab.id);
+
+ // Test a message window.
+
+ let messageWindowTab = await browser.messageDisplay.open({
+ location: "window",
+ messageId: message1.id,
+ });
+ browser.test.assertEq(
+ "messageDisplay",
+ messageWindowTab.type,
+ "Should have found a message tab."
+ );
+ browser.test.assertFalse(
+ mailTab.windowId == messageWindowTab.windowId,
+ "Tab should not be in the main window."
+ );
+
+ browser.test.assertRejects(
+ browser.tabs.reload(messageWindowTab.id),
+ /Reloading is only supported for tabs displaying a content page/,
+ "Reloading a message window should throw"
+ );
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageWindowTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a tel:// url.
+ await browser.tabs.update(messageWindowTab.id, { url: "tel:1234-4" });
+ await window.sendMessage("check_external_loaded_url", "tel:1234-4");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageWindowTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ // Update a non-registered WebExtension protocol handler.
+ await browser.tabs.update(mailTab.id, { url: "ext+test:1234-3" });
+ await window.sendMessage("check_external_loaded_url", "ext+test:1234-3");
+
+ // We should still see the same message.
+ message2 = await browser.messageDisplay.getDisplayedMessage(
+ messageWindowTab.id
+ );
+ browser.test.assertTrue(
+ !!message2,
+ "We should have a displayed message."
+ );
+ browser.test.assertTrue(
+ message1.id == message2.id,
+ "We should see the same message."
+ );
+
+ browser.tabs.remove(messageWindowTab.id);
+
+ // Test a compose window.
+
+ let details1 = { to: ["Mr. Holmes <holmes@bakerstreet.invalid>"] };
+ let composeTab = await browser.compose.beginNew(details1);
+ browser.test.assertEq(
+ "messageCompose",
+ composeTab.type,
+ "Should have found a compose tab."
+ );
+ browser.test.assertFalse(
+ mailTab.windowId == composeTab.windowId,
+ "Tab should not be in the main window."
+ );
+ let details2 = await browser.compose.getComposeDetails(composeTab.id);
+ window.assertDeepEqual(
+ details1.to,
+ details2.to,
+ "We should see the correct compose details."
+ );
+
+ browser.test.assertRejects(
+ browser.tabs.reload(composeTab.id),
+ /Reloading is only supported for tabs displaying a content page/,
+ "Reloading a compose window should throw"
+ );
+
+ // We should still see the same composer.
+ details2 = await browser.compose.getComposeDetails(composeTab.id);
+ window.assertDeepEqual(
+ details1.to,
+ details2.to,
+ "We should see the correct compose details."
+ );
+
+ // Update a tel:// url.
+ await browser.tabs.update(composeTab.id, { url: "tel:1234-5" });
+ await window.sendMessage("check_external_loaded_url", "tel:1234-5");
+
+ // We should still see the same composer.
+ details2 = await browser.compose.getComposeDetails(composeTab.id);
+ window.assertDeepEqual(
+ details1.to,
+ details2.to,
+ "We should see the correct compose details."
+ );
+
+ // Update a non-registered WebExtension protocol handler.
+ await browser.tabs.update(mailTab.id, { url: "ext+test:1234-4" });
+ await window.sendMessage("check_external_loaded_url", "ext+test:1234-4");
+
+ // We should still see the same composer.
+ details2 = await browser.compose.getComposeDetails(composeTab.id);
+ window.assertDeepEqual(
+ details1.to,
+ details2.to,
+ "We should see the correct compose details."
+ );
+
+ browser.tabs.remove(composeTab.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "tabs", "compose"],
+ },
+ });
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.displayFolder(gFolder);
+
+ extension.onMessage("check_external_loaded_url", async expected => {
+ Assert.equal(
+ 1,
+ mockExternalProtocolService._loadedURLs.length,
+ "Should have found a single loaded url"
+ );
+ Assert.equal(
+ mockExternalProtocolService._loadedURLs[0],
+ expected,
+ "Should have found the expected url"
+ );
+ mockExternalProtocolService._loadedURLs = [];
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ Assert.equal(
+ 0,
+ mockExternalProtocolService._loadedURLs.length,
+ "Should not have any unexpected urls loaded externally"
+ );
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_themes_onUpdated.js b/comm/mail/components/extensions/test/browser/browser_ext_themes_onUpdated.js
new file mode 100644
index 0000000000..b2e6a40fb1
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_themes_onUpdated.js
@@ -0,0 +1,150 @@
+"use strict";
+
+// This test checks whether browser.theme.onUpdated works
+// when a static theme is applied
+
+const ACCENT_COLOR = "#a14040";
+const TEXT_COLOR = "#fac96e";
+const BACKGROUND =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0" +
+ "DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
+
+add_task(async function test_on_updated() {
+ const theme = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.theme.onUpdated.addListener(updateInfo => {
+ browser.test.sendMessage("theme-updated", updateInfo);
+ });
+ },
+ });
+
+ await extension.startup();
+
+ info("Testing update event on static theme startup");
+ let updatedPromise = extension.awaitMessage("theme-updated");
+ await theme.startup();
+ const { theme: receivedTheme, windowId } = await updatedPromise;
+ Assert.ok(!windowId, "No window id in static theme update event");
+ Assert.ok(
+ receivedTheme.images.theme_frame.includes("image1.png"),
+ "Theme theme_frame image should be applied"
+ );
+ Assert.equal(
+ receivedTheme.colors.frame,
+ ACCENT_COLOR,
+ "Theme frame color should be applied"
+ );
+ Assert.equal(
+ receivedTheme.colors.tab_background_text,
+ TEXT_COLOR,
+ "Theme tab_background_text color should be applied"
+ );
+
+ info("Testing update event on static theme unload");
+ updatedPromise = extension.awaitMessage("theme-updated");
+ await theme.unload();
+ const updateInfo = await updatedPromise;
+ Assert.ok(!windowId, "No window id in static theme update event on unload");
+ Assert.equal(
+ Object.keys(updateInfo.theme),
+ 0,
+ "unloading theme sends empty theme in update event"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_on_updated_eventpage() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.eventPages.enabled", true]],
+ });
+ const theme = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": () => {
+ // Whenever the extension starts or wakes up, the eventCounter is reset
+ // and allows to observe the order of events fired. In case of a wake-up,
+ // the first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ browser.theme.onUpdated.addListener(async updateInfo => {
+ browser.test.sendMessage("theme-updated", {
+ eventCount: ++eventCounter,
+ ...updateInfo,
+ });
+ });
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_specific_settings: { gecko: { id: "themes@mochi.test" } },
+ },
+ });
+
+ await extension.startup();
+ assertPersistentListeners(extension, "theme", "onUpdated", {
+ primed: false,
+ });
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ assertPersistentListeners(extension, "theme", "onUpdated", {
+ primed: true,
+ });
+
+ info("Testing update event on static theme startup");
+
+ await theme.startup();
+
+ const {
+ eventCount,
+ theme: receivedTheme,
+ windowId,
+ } = await extension.awaitMessage("theme-updated");
+ Assert.equal(eventCount, 1, "Event counter should be correct");
+ Assert.ok(!windowId, "No window id in static theme update event");
+ Assert.ok(
+ receivedTheme.images.theme_frame.includes("image1.png"),
+ "Theme theme_frame image should be applied"
+ );
+
+ await theme.unload();
+ await extension.awaitMessage("theme-updated");
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_tooltip_in_extension_pages.js b/comm/mail/components/extensions/test/browser/browser_ext_tooltip_in_extension_pages.js
new file mode 100644
index 0000000000..483482981e
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_tooltip_in_extension_pages.js
@@ -0,0 +1,685 @@
+/* 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/. */
+
+let account;
+let subFolders;
+let messages;
+
+async function showTooltip(elementSelector, tooltip, browser, description) {
+ Assert.ok(!!tooltip, "tooltip element should exist");
+ tooltip.ownerGlobal.windowUtils.disableNonTestMouseEvents(true);
+ try {
+ while (tooltip.state != "open") {
+ // We first have to click on the element, otherwise a mousemove event will not
+ // trigger the tooltip.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ await synthesizeMouseAtCenterAndRetry(
+ elementSelector,
+ { button: 1 },
+ browser
+ );
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ await synthesizeMouseAtCenterAndRetry(
+ elementSelector,
+ { type: "mousemove" },
+ browser
+ );
+
+ try {
+ await TestUtils.waitForCondition(
+ () => tooltip.state == "open",
+ `Tooltip should have been shown for ${description}`
+ );
+ } catch (e) {
+ console.log(`Tooltip was not shown for ${description}, trying again.`);
+ }
+ }
+ } finally {
+ tooltip.ownerGlobal.windowUtils.disableNonTestMouseEvents(false);
+ }
+}
+
+add_setup(async () => {
+ account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ await TestUtils.waitForCondition(
+ () => subFolders[0].messages.hasMoreElements(),
+ "Messages should be added to folder"
+ );
+ messages = subFolders[0].messages;
+
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ folderURI: subFolders[0],
+ messagePaneVisible: true,
+ });
+ about3Pane.threadTree.selectedIndex = 0;
+ await awaitBrowserLoaded(
+ about3Pane.messageBrowser.contentWindow.getMessagePaneBrowser()
+ );
+});
+
+add_task(async function test_browserAction_in_about3pane() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.browserAction.openPopup();
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let popupBrowser = document.querySelector(".webextension-popup-browser");
+ let tooltip = document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "browserAction in about3pane"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_browserAction_in_message_window() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the message window.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.windows.remove(tab.windowId);
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // Open the popup after a message has been displayed.
+ browser.messageDisplay.onMessageDisplayed.addListener(
+ async (tab, message) => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.browserAction.openPopup({ windowId: tab.windowId });
+ }
+ );
+
+ // Open a message in a window.
+ let { messages } = await browser.messages.query({});
+ browser.messageDisplay.open({
+ location: "window",
+ messageId: messages[0].id,
+ });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead"],
+ browser_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ default_windows: ["messageDisplay"],
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let messageWindow = Services.wm.getMostRecentWindow("mail:messageWindow");
+ let popupBrowser = messageWindow.document.querySelector(
+ ".webextension-popup-browser"
+ );
+ let tooltip = messageWindow.document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "browserAction in message window"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_composeAction() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the compose window.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.windows.remove(tab.windowId);
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ let composeTab = await browser.compose.beginNew();
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.composeAction.openPopup({ windowId: composeTab.windowId });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["compose"],
+ compose_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+ let popupBrowser = composeWindow.document.querySelector(
+ ".webextension-popup-browser"
+ );
+ let tooltip = composeWindow.document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "composeAction in compose window"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_messageDisplayAction_in_about3pane() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.messageDisplayAction.openPopup();
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead"],
+ message_display_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ // The tooltip and the popup panel are defined in the top level messenger
+ // window, not in about:message.
+ let popupBrowser = document.querySelector(".webextension-popup-browser");
+ let tooltip = document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "messageDisplayAction in about3pane"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_messageDisplayAction_in_message_tab() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the message tab.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // Open the popup after a message has been displayed.
+ browser.messageDisplay.onMessageDisplayed.addListener(
+ async (tab, message) => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.messageDisplayAction.openPopup({ windowId: tab.windowId });
+ }
+ );
+
+ // Open a message in a tab.
+ let { messages } = await browser.messages.query({});
+ browser.messageDisplay.open({
+ location: "tab",
+ messageId: messages[0].id,
+ });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead"],
+ message_display_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ // The tooltip and the popup panel are defined in the top level messenger
+ // window, not in about:message.
+ let popupBrowser = document.querySelector(".webextension-popup-browser");
+ let tooltip = document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "messageDisplayAction in message tab"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_messageDisplayAction_in_message_window() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the message window.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.windows.remove(tab.windowId);
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // Open the popup after a message has been displayed.
+ browser.messageDisplay.onMessageDisplayed.addListener(
+ async (tab, message) => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 125));
+ browser.messageDisplayAction.openPopup({ windowId: tab.windowId });
+ }
+ );
+
+ // Open a message in a window.
+ let { messages } = await browser.messages.query({});
+ browser.messageDisplay.open({
+ location: "window",
+ messageId: messages[0].id,
+ });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead"],
+ message_display_action: {
+ default_title: "default",
+ default_popup: "page.html",
+ },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let messageWindow = Services.wm.getMostRecentWindow("mail:messageWindow");
+ let popupBrowser = messageWindow.document.querySelector(
+ ".webextension-popup-browser"
+ );
+ let tooltip = messageWindow.document.getElementById("remoteBrowserTooltip");
+ await showTooltip(
+ "p",
+ tooltip,
+ popupBrowser,
+ "messageDisplayAction in message window"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_extension_window() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the extension window.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.windows.remove(tab.windowId);
+
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // Open an extension window.
+ browser.windows.create({ type: "popup", url: "page.html" });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let extensionWindow = Services.wm.getMostRecentWindow(
+ "mail:extensionPopup"
+ );
+ let tooltip = extensionWindow.document.getElementById(
+ "remoteBrowserTooltip"
+ );
+ await showTooltip(
+ "p",
+ tooltip,
+ extensionWindow.browser,
+ "extension window"
+ );
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_extension_tab() {
+ let files = {
+ "background.js": async () => {
+ async function checkTooltip() {
+ // Trigger the tooltip and wait for the status.
+ let [state] = await window.sendMessage("check tooltip");
+ browser.test.assertEq("open", state, "Should find the tooltip open");
+
+ // Close the extension tab.
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.tabs.remove(tab.id);
+
+ browser.test.notifyPass("finished");
+ }
+
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message == "page loaded") {
+ sendResponse();
+ checkTooltip();
+ }
+ });
+
+ // Open an extension tab.
+ browser.tabs.create({ url: "page.html" });
+ },
+ "page.js": async function () {
+ browser.runtime.sendMessage("page loaded");
+ },
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Page</title>
+ </head>
+ <body>
+ <h1>Tooltip test</h1>
+ <p title="Tooltip">I am an element with a tooltip</p>
+ <script src="page.js"></script>
+ </body>
+ </html>`,
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("check tooltip", async () => {
+ let tooltip = window.document.getElementById("remoteBrowserTooltip");
+ let browser = window.gTabmail.currentTabInfo.browser;
+ await showTooltip("p", tooltip, browser, "extension tab");
+ extension.sendMessage(tooltip.state);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows.js b/comm/mail/components/extensions/test/browser/browser_ext_windows.js
new file mode 100644
index 0000000000..6c996a8ca5
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows.js
@@ -0,0 +1,439 @@
+/* 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/. */
+
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ _loadedURLs: [],
+ externalProtocolHandlerExists(protocolScheme) {},
+ getApplicationDescription(scheme) {},
+ getProtocolHandlerInfo(protocolScheme) {},
+ getProtocolHandlerInfoFromOS(protocolScheme, found) {},
+ isExposedProtocol(protocolScheme) {},
+ loadURI(uri, windowContext) {
+ this._loadedURLs.push(uri.spec);
+ },
+ setProtocolHandlerDefaults(handlerInfo, osHandlerExists) {},
+ urlLoaded(url) {
+ let found = this._loadedURLs.includes(url);
+ this._loadedURLs = this._loadedURLs.filter(e => e != url);
+ return found;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+add_task(async function test_openDefaultBrowser() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ const urls = {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://www.google.de/": true,
+ "https://www.google.de/": true,
+ "ftp://www.google.de/": false,
+ };
+
+ for (let [url, expected] of Object.entries(urls)) {
+ let rv = null;
+ try {
+ await browser.windows.openDefaultBrowser(url);
+ rv = true;
+ } catch (e) {
+ rv = false;
+ }
+ browser.test.assertEq(
+ rv,
+ expected,
+ `Checking result for browser.windows.openDefaultBrowser(${url})`
+ );
+ }
+ browser.test.sendMessage("ready", urls);
+ },
+ });
+
+ await extension.startup();
+ let urls = await extension.awaitMessage("ready");
+ for (let [url, expected] of Object.entries(urls)) {
+ Assert.equal(
+ mockExternalProtocolService.urlLoaded(url),
+ expected,
+ `Double check result for browser.windows.openDefaultBrowser(${url})`
+ );
+ }
+
+ await extension.unload();
+});
+
+add_task(async function test_focusWindows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let listener = {
+ waitingPromises: [],
+ waitForEvent() {
+ return new Promise(resolve => {
+ listener.waitingPromises.push(resolve);
+ });
+ },
+ checkWaiting() {
+ if (listener.waitingPromises.length < 1) {
+ browser.test.fail("Unexpected event fired");
+ }
+ },
+ created(win) {
+ listener.checkWaiting();
+ listener.waitingPromises.shift()(["onCreated", win]);
+ },
+ focusChanged(windowId) {
+ listener.checkWaiting();
+ listener.waitingPromises.shift()(["onFocusChanged", windowId]);
+ },
+ removed(windowId) {
+ listener.checkWaiting();
+ listener.waitingPromises.shift()(["onRemoved", windowId]);
+ },
+ };
+ browser.windows.onCreated.addListener(listener.created);
+ browser.windows.onFocusChanged.addListener(listener.focusChanged);
+ browser.windows.onRemoved.addListener(listener.removed);
+
+ let firstWindow = await browser.windows.getCurrent();
+ browser.test.assertEq("normal", firstWindow.type);
+
+ let currentWindows = await browser.windows.getAll();
+ browser.test.assertEq(1, currentWindows.length);
+ browser.test.assertEq(firstWindow.id, currentWindows[0].id);
+
+ // Open a new mail window.
+
+ let createdWindowPromise = listener.waitForEvent();
+ let focusChangedPromise1 = listener.waitForEvent();
+ let focusChangedPromise2 = listener.waitForEvent();
+ let eventName, createdWindow, windowId;
+
+ browser.test.sendMessage("openWindow");
+ [eventName, createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("onCreated", eventName);
+ browser.test.assertEq("normal", createdWindow.type);
+
+ [eventName, windowId] = await focusChangedPromise1;
+ browser.test.assertEq("onFocusChanged", eventName);
+ browser.test.assertEq(browser.windows.WINDOW_ID_NONE, windowId);
+
+ [eventName, windowId] = await focusChangedPromise2;
+ browser.test.assertEq("onFocusChanged", eventName);
+ browser.test.assertEq(createdWindow.id, windowId);
+
+ currentWindows = await browser.windows.getAll();
+ browser.test.assertEq(2, currentWindows.length);
+ browser.test.assertEq(firstWindow.id, currentWindows[0].id);
+ browser.test.assertEq(createdWindow.id, currentWindows[1].id);
+
+ // Focus the first window.
+
+ let platformInfo = await browser.runtime.getPlatformInfo();
+
+ let focusChangedPromise3;
+ if (["mac", "win"].includes(platformInfo.os)) {
+ // Mac and Windows don't fire this event. Pretend they do.
+ focusChangedPromise3 = Promise.resolve([
+ "onFocusChanged",
+ browser.windows.WINDOW_ID_NONE,
+ ]);
+ } else {
+ focusChangedPromise3 = listener.waitForEvent();
+ }
+ let focusChangedPromise4 = listener.waitForEvent();
+
+ browser.test.sendMessage("switchWindows");
+ [eventName, windowId] = await focusChangedPromise3;
+ browser.test.assertEq("onFocusChanged", eventName);
+ browser.test.assertEq(browser.windows.WINDOW_ID_NONE, windowId);
+
+ [eventName, windowId] = await focusChangedPromise4;
+ browser.test.assertEq("onFocusChanged", eventName);
+ browser.test.assertEq(firstWindow.id, windowId);
+
+ // Close the first window.
+
+ let removedWindowPromise = listener.waitForEvent();
+
+ browser.test.sendMessage("closeWindow");
+ [eventName, windowId] = await removedWindowPromise;
+ browser.test.assertEq("onRemoved", eventName);
+ browser.test.assertEq(createdWindow.id, windowId);
+
+ currentWindows = await browser.windows.getAll();
+ browser.test.assertEq(1, currentWindows.length);
+ browser.test.assertEq(firstWindow.id, currentWindows[0].id);
+
+ browser.windows.onCreated.removeListener(listener.created);
+ browser.windows.onFocusChanged.removeListener(listener.focusChanged);
+ browser.windows.onRemoved.removeListener(listener.removed);
+
+ browser.test.notifyPass();
+ },
+ });
+
+ let account = createAccount();
+
+ await extension.startup();
+
+ await extension.awaitMessage("openWindow");
+ let newWindowPromise = BrowserTestUtils.domWindowOpened();
+ window.MsgOpenNewWindowForFolder(account.incomingServer.rootFolder.URI);
+ let newWindow = await newWindowPromise;
+
+ await extension.awaitMessage("switchWindows");
+ window.focus();
+
+ await extension.awaitMessage("closeWindow");
+ newWindow.close();
+
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function checkTitlePreface() {
+ let l10n = new Localization([
+ "branding/brand.ftl",
+ "messenger/extensions/popup.ftl",
+ ]);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "content.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8"/>
+ <title>A test document</title>
+ <script type="text/javascript" src="content.js"></script>
+ </head>
+ <body>
+ <p>This is text.</p>
+ </body>
+ </html>
+ `,
+ "content.js": `
+ browser.runtime.onMessage.addListener(
+ (data, sender) => {
+ if (data.command == "close") {
+ window.close();
+ }
+ }
+ );`,
+ "utils.js": await getUtilsJS(),
+ "background.js": async () => {
+ let popup;
+
+ // Test titlePreface during window creation.
+ {
+ let titlePreface = "PREFACE1";
+ let windowCreatePromise = window.waitForEvent("windows.onCreated");
+ // Do not await the create statement, but instead check if the onCreated
+ // event is delayed correctly to get the correct values.
+ browser.windows.create({
+ titlePreface,
+ url: "content.html",
+ type: "popup",
+ allowScriptsToClose: true,
+ });
+ popup = (await windowCreatePromise)[0];
+ let [expectedTitle] = await window.sendMessage(
+ "checkTitle",
+ titlePreface
+ );
+ browser.test.assertEq(
+ expectedTitle,
+ popup.title,
+ `Should find the correct title`
+ );
+ browser.test.assertEq(
+ true,
+ popup.focused,
+ `Should find the correct focus state`
+ );
+ }
+
+ // Test titlePreface during window update.
+ {
+ let titlePreface = "PREFACE2";
+ let updated = await browser.windows.update(popup.id, {
+ titlePreface,
+ });
+ let [expectedTitle] = await window.sendMessage(
+ "checkTitle",
+ titlePreface
+ );
+ browser.test.assertEq(
+ expectedTitle,
+ updated.title,
+ `Should find the correct title`
+ );
+ browser.test.assertEq(
+ true,
+ updated.focused,
+ `Should find the correct focus state`
+ );
+ }
+
+ // Finish
+ {
+ let windowRemovePromise = window.waitForEvent("windows.onRemoved");
+ browser.test.log(
+ "Testing allowScriptsToClose, waiting for window to close."
+ );
+ await browser.runtime.sendMessage({ command: "close" });
+ await windowRemovePromise;
+ }
+
+ // Test title after create without a preface.
+ {
+ let popup = await browser.windows.create({
+ url: "content.html",
+ type: "popup",
+ allowScriptsToClose: true,
+ });
+ let [expectedTitle] = await window.sendMessage("checkTitle", "");
+ browser.test.assertEq(
+ expectedTitle,
+ popup.title,
+ `Should find the correct title`
+ );
+ browser.test.assertEq(
+ true,
+ popup.focused,
+ `Should find the correct focus state`
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("checkTitle", async titlePreface => {
+ let win = Services.wm.getMostRecentWindow("mail:extensionPopup");
+
+ let defaultTitle = await l10n.formatValue("extension-popup-default-title");
+
+ let expectedTitle = titlePreface + "A test document";
+ // If we're on Mac, we don't display the separator and the app name (which
+ // is also used as default title).
+ if (AppConstants.platform != "macosx") {
+ expectedTitle += ` - ${defaultTitle}`;
+ }
+
+ Assert.equal(
+ win.document.title,
+ expectedTitle,
+ `Check if title is as expected.`
+ );
+ extension.sendMessage(expectedTitle);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_popupLayoutProperties() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "test.html": `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <title>TEST</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ </head>
+ <body>
+ <p>Test body</p>
+ </body>
+ </html>`,
+ "background.js": async () => {
+ async function checkWindow(windowId, expected, retries = 0) {
+ let win = await browser.windows.get(windowId);
+
+ if (
+ retries &&
+ Object.keys(expected).some(key => expected[key] != win[key])
+ ) {
+ browser.test.log(
+ `Got mismatched size (${JSON.stringify(
+ expected
+ )} != ${JSON.stringify(win)}). Retrying after a short delay.`
+ );
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 200));
+ return checkWindow(windowId, expected, retries - 1);
+ }
+
+ for (let [key, value] of Object.entries(expected)) {
+ browser.test.assertEq(
+ value,
+ win[key],
+ `Should find the correct updated value for ${key}`
+ );
+ }
+
+ return true;
+ }
+
+ let tests = [
+ { retries: 0, properties: { state: "minimized" } },
+ { retries: 0, properties: { state: "maximized" } },
+ { retries: 0, properties: { state: "fullscreen" } },
+ {
+ retries: 5,
+ properties: { width: 210, height: 220, left: 90, top: 80 },
+ },
+ ];
+
+ // Test create.
+ for (let test of tests) {
+ let win = await browser.windows.create({
+ type: "popup",
+ url: "test.html",
+ ...test.properties,
+ });
+ await checkWindow(win.id, test.properties, test.retries);
+ await browser.windows.remove(win.id);
+ }
+
+ // Test update.
+ for (let test of tests) {
+ let win = await browser.windows.create({
+ type: "popup",
+ url: "test.html",
+ });
+ await browser.windows.update(win.id, test.properties);
+ await checkWindow(win.id, test.properties, test.retries);
+ await browser.windows.remove(win.id);
+ }
+
+ browser.test.notifyPass();
+ },
+ },
+ manifest: {
+ background: { scripts: ["background.js"] },
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows_bug1732559.js b/comm/mail/components/extensions/test/browser/browser_ext_windows_bug1732559.js
new file mode 100644
index 0000000000..ee5acf743f
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows_bug1732559.js
@@ -0,0 +1,94 @@
+/* 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 function check_focus() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Create a promise which waits until the script in the window is loaded
+ // and the email field has focus, so we can send our fake keystrokes.
+ let loadPromise = new Promise(resolve => {
+ let listener = async (msg, sender) => {
+ if (msg == "loaded") {
+ browser.runtime.onMessage.removeListener(listener);
+ resolve(sender.tab.windowId);
+ }
+ };
+ browser.runtime.onMessage.addListener(listener);
+ });
+
+ let openedWin = await browser.windows.create({
+ url: "focus.html",
+ type: "popup",
+ allowScriptsToClose: true,
+ });
+ let loadedWinId = await loadPromise;
+
+ browser.test.assertEq(
+ openedWin.id,
+ loadedWinId,
+ "The correct window should have been loaded"
+ );
+
+ let removePromise = new Promise(resolve => {
+ browser.windows.onRemoved.addListener(id => {
+ if (id == openedWin.id) {
+ resolve();
+ }
+ });
+ });
+
+ window.sendMessage("sendKeyStrokes", openedWin.id);
+
+ await removePromise;
+ browser.test.notifyPass("finished");
+ },
+ "focus.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <script src="utils.js"></script>
+ <script src="focus.js"></script>
+ <title>Focus Test</title>
+ </head>
+ <body>
+ <input id="email" type="text"/>
+ <input id="delay" type="number" min="0" max="10" size="2"/>
+ </body>
+ </html>`,
+ "focus.js": () => {
+ async function load() {
+ let email = document.getElementById("email");
+ email.focus();
+
+ await new Promise(r => window.setTimeout(r));
+ await browser.runtime.sendMessage("loaded");
+
+ // Fails as expected if focus is not set in
+ // https://searchfox.org/comm-central/rev/be2751632bd695d17732ff590a71acb9b1ef920c/mail/components/extensions/extensionPopup.js#126-130
+ await window.waitForCondition(
+ () => email.value == "happy typing",
+ `Input field should have the correct value. Expected: "happy typing", actual: "${email.value}"`
+ );
+
+ window.close();
+ }
+ document.addEventListener("DOMContentLoaded", load, { once: true });
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("sendKeyStrokes", id => {
+ let window = Services.wm.getOuterWindowWithId(id);
+ EventUtils.sendString("happy typing", window);
+ extension.sendMessage("happy typing");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows_create_normal_cookieStoreId.js b/comm/mail/components/extensions/test/browser/browser_ext_windows_create_normal_cookieStoreId.js
new file mode 100644
index 0000000000..19d34c26c5
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows_create_normal_cookieStoreId.js
@@ -0,0 +1,116 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// Supported for creating normal windows is very limited in Thunderbird, a url
+// in the createData is ignored for example. This test only verifies that all the
+// things that are officially not supported, fail.
+
+add_task(async function no_cookies_permission() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({ cookieStoreId: "firefox-container-1" }),
+ /No permission for cookieStoreId/,
+ "cookieStoreId requires cookies permission"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function invalid_cookieStoreId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({ cookieStoreId: "not-firefox-container-1" }),
+ /Illegal cookieStoreId/,
+ "cookieStoreId must be valid"
+ );
+
+ await browser.test.assertRejects(
+ browser.windows.create({ cookieStoreId: "firefox-private" }),
+ /Illegal to set private cookieStoreId in a non-private window/,
+ "cookieStoreId cannot be private in a non-private window"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function userContext_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", false]],
+ });
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({ cookieStoreId: "firefox-container-1" }),
+ /Contextual identities are currently disabled/,
+ "cookieStoreId cannot be a container tab ID when contextual identities are disabled"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function cookieStoreId_and_tabId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies"],
+ },
+ async background() {
+ for (let cookieStoreId of ["firefox-default", "firefox-container-1"]) {
+ let { id: normalTabId } = await browser.tabs.create({ cookieStoreId });
+
+ await browser.test.assertRejects(
+ browser.windows.create({
+ cookieStoreId: "firefox-container-2",
+ tabId: normalTabId,
+ }),
+ /`tabId` may not be used in conjunction with `cookieStoreId`/,
+ "Cannot use cookieStoreId for pre-existing tabs"
+ );
+
+ await browser.tabs.remove(normalTabId);
+ }
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows_create_popup_cookieStoreId.js b/comm/mail/components/extensions/test/browser/browser_ext_windows_create_popup_cookieStoreId.js
new file mode 100644
index 0000000000..e547fb6b9b
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows_create_popup_cookieStoreId.js
@@ -0,0 +1,255 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function no_cookies_permission() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({
+ type: "popup",
+ cookieStoreId: "firefox-container-1",
+ }),
+ /No permission for cookieStoreId/,
+ "cookieStoreId requires cookies permission"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function invalid_cookieStoreId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({
+ type: "popup",
+ cookieStoreId: "not-firefox-container-1",
+ }),
+ /Illegal cookieStoreId/,
+ "cookieStoreId must be valid"
+ );
+
+ await browser.test.assertRejects(
+ browser.windows.create({
+ type: "popup",
+ cookieStoreId: "firefox-private",
+ }),
+ /Illegal to set private cookieStoreId in a non-private window/,
+ "cookieStoreId cannot be private in a non-private window"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function userContext_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", false]],
+ });
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.windows.create({
+ type: "popup",
+ cookieStoreId: "firefox-container-1",
+ }),
+ /Contextual identities are currently disabled/,
+ "cookieStoreId cannot be a container tab ID when contextual identities are disabled"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function valid_cookieStoreId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ const testCases = [
+ {
+ description: "one URL",
+ createParams: {
+ type: "popup",
+ url: "about:blank",
+ cookieStoreId: "firefox-container-1",
+ },
+ expectedCookieStoreIds: ["firefox-container-1"],
+ expectedExecuteScriptResult: ["about:blank - null"],
+ },
+ {
+ description: "one URL in an array",
+ createParams: {
+ type: "popup",
+ url: ["about:blank"],
+ cookieStoreId: "firefox-container-1",
+ },
+ expectedCookieStoreIds: ["firefox-container-1"],
+ expectedExecuteScriptResult: ["about:blank - null"],
+ },
+ ];
+
+ async function background(testCases) {
+ let readyTabs = new Map();
+ let tabReadyCheckers = new Set();
+ browser.webNavigation.onCompleted.addListener(({ url, tabId, frameId }) => {
+ if (frameId === 0) {
+ readyTabs.set(tabId, url);
+ browser.test.log(`Detected navigation in tab ${tabId} to ${url}.`);
+
+ for (let check of tabReadyCheckers) {
+ check(tabId, url);
+ }
+ }
+ });
+ async function awaitTabReady(tabId, expectedUrl) {
+ if (readyTabs.get(tabId) === expectedUrl) {
+ browser.test.log(`Tab ${tabId} was ready with URL ${expectedUrl}.`);
+ return;
+ }
+ await new Promise(resolve => {
+ browser.test.log(
+ `Waiting for tab ${tabId} to load URL ${expectedUrl}...`
+ );
+ tabReadyCheckers.add(function check(completedTabId, completedUrl) {
+ if (completedTabId === tabId && completedUrl === expectedUrl) {
+ tabReadyCheckers.delete(check);
+ resolve();
+ }
+ });
+ });
+ browser.test.log(`Tab ${tabId} is ready with URL ${expectedUrl}.`);
+ }
+
+ async function executeScriptAndGetResult(tabId) {
+ try {
+ return (
+ await browser.tabs.executeScript(tabId, {
+ matchAboutBlank: true,
+ code: "`${document.URL} - ${origin}`",
+ })
+ )[0];
+ } catch (e) {
+ return e.message;
+ }
+ }
+ for (let {
+ description,
+ createParams,
+ expectedCookieStoreIds,
+ expectedExecuteScriptResult,
+ } of testCases) {
+ let win = await browser.windows.create(createParams);
+
+ browser.test.assertEq(
+ expectedCookieStoreIds.length,
+ win.tabs.length,
+ "Expected number of tabs"
+ );
+
+ for (let [i, expectedCookieStoreId] of Object.entries(
+ expectedCookieStoreIds
+ )) {
+ browser.test.assertEq(
+ expectedCookieStoreId,
+ win.tabs[i].cookieStoreId,
+ `expected cookieStoreId for tab ${i} (${description})`
+ );
+ }
+
+ for (let [i, expectedResult] of Object.entries(
+ expectedExecuteScriptResult
+ )) {
+ // Wait until the the tab can process the tabs.executeScript calls.
+ // TODO: Remove this when bug 1418655 and bug 1397667 are fixed.
+ let expectedUrl = Array.isArray(createParams.url)
+ ? createParams.url[i]
+ : createParams.url || "about:home";
+ await awaitTabReady(win.tabs[i].id, expectedUrl);
+
+ let result = await executeScriptAndGetResult(win.tabs[i].id);
+ browser.test.assertEq(
+ expectedResult,
+ result,
+ `expected executeScript result for tab ${i} (${description})`
+ );
+ }
+
+ await browser.windows.remove(win.id);
+ }
+ browser.test.sendMessage("done");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies", "webNavigation"],
+ },
+ background: `(${background})(${JSON.stringify(testCases)})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function cookieStoreId_and_tabId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies"],
+ },
+ async background() {
+ for (let cookieStoreId of ["firefox-default", "firefox-container-1"]) {
+ let { id: normalTabId } = await browser.tabs.create({ cookieStoreId });
+
+ await browser.test.assertRejects(
+ browser.windows.create({
+ type: "popup",
+ cookieStoreId: "firefox-container-2",
+ tabId: normalTabId,
+ }),
+ /`tabId` may not be used in conjunction with `cookieStoreId`/,
+ "Cannot use cookieStoreId for pre-existing tabs"
+ );
+
+ await browser.tabs.remove(normalTabId);
+ }
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows_events.js b/comm/mail/components/extensions/test/browser/browser_ext_windows_events.js
new file mode 100644
index 0000000000..89cbd77e55
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows_events.js
@@ -0,0 +1,405 @@
+/* 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 () => {
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("windowsEvents", null);
+ let testFolder = rootFolder.findSubFolder("windowsEvents");
+ createMessages(testFolder, 5);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Executes a command, but first loads a second extension with terminated
+ // background and waits for it to be restarted due to the executed command.
+ async function capturePrimedEvent(eventName, callback) {
+ let eventPageExtensionReadyPromise = window.waitForMessage();
+ browser.test.sendMessage("capturePrimedEvent", eventName);
+ await eventPageExtensionReadyPromise;
+ let eventPageExtensionFinishedPromise = window.waitForMessage();
+ callback();
+ return eventPageExtensionFinishedPromise;
+ }
+
+ let listener = {
+ tabEvents: [],
+ windowEvents: [],
+ currentPromise: null,
+
+ pushEvent(...args) {
+ browser.test.log(JSON.stringify(args));
+ let queue = args[0].startsWith("windows.")
+ ? this.windowEvents
+ : this.tabEvents;
+ queue.push(args);
+ if (queue.currentPromise) {
+ let p = queue.currentPromise;
+ queue.currentPromise = null;
+ p.resolve();
+ }
+ },
+ windowsOnCreated(...args) {
+ this.pushEvent("windows.onCreated", ...args);
+ },
+ windowsOnRemoved(...args) {
+ this.pushEvent("windows.onRemoved", ...args);
+ },
+ tabsOnCreated(...args) {
+ this.pushEvent("tabs.onCreated", ...args);
+ },
+ tabsOnRemoved(...args) {
+ this.pushEvent("tabs.onRemoved", ...args);
+ },
+ async checkEvent(expectedEvent, ...expectedArgs) {
+ let queue = expectedEvent.startsWith("windows.")
+ ? this.windowEvents
+ : this.tabEvents;
+ if (queue.length == 0) {
+ await new Promise(
+ resolve => (queue.currentPromise = { resolve })
+ );
+ }
+ let [actualEvent, ...actualArgs] = queue.shift();
+ browser.test.assertEq(expectedEvent, actualEvent);
+ browser.test.assertEq(expectedArgs.length, actualArgs.length);
+
+ for (let i = 0; i < expectedArgs.length; i++) {
+ browser.test.assertEq(
+ typeof expectedArgs[i],
+ typeof actualArgs[i]
+ );
+ if (typeof expectedArgs[i] == "object") {
+ for (let key of Object.keys(expectedArgs[i])) {
+ browser.test.assertEq(
+ expectedArgs[i][key],
+ actualArgs[i][key]
+ );
+ }
+ } else {
+ browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+ }
+ }
+
+ return actualArgs;
+ },
+ };
+ browser.tabs.onCreated.addListener(
+ listener.tabsOnCreated.bind(listener)
+ );
+ browser.tabs.onRemoved.addListener(
+ listener.tabsOnRemoved.bind(listener)
+ );
+ browser.windows.onCreated.addListener(
+ listener.windowsOnCreated.bind(listener)
+ );
+ browser.windows.onRemoved.addListener(
+ listener.windowsOnRemoved.bind(listener)
+ );
+
+ browser.test.log(
+ "Collect the ID of the initial window (there must be only one) and tab."
+ );
+
+ let initialWindows = await browser.windows.getAll({ populate: true });
+ browser.test.assertEq(1, initialWindows.length);
+ let [{ id: initialWindow, tabs: initialTabs }] = initialWindows;
+ browser.test.assertEq(1, initialTabs.length);
+ browser.test.assertEq(0, initialTabs[0].index);
+ browser.test.assertTrue(initialTabs[0].mailTab);
+ let [{ id: initialTab }] = initialTabs;
+
+ browser.test.log("Open a new main window (messenger.xhtml).");
+
+ let primedMainWindowInfo = await window.sendMessage("openMainWindow");
+ let [{ id: mainWindow }] = await listener.checkEvent(
+ "windows.onCreated",
+ { type: "normal" }
+ );
+ let [{ id: mainTab }] = await listener.checkEvent("tabs.onCreated", {
+ index: 0,
+ windowId: mainWindow,
+ active: true,
+ mailTab: true,
+ });
+ window.assertDeepEqual(
+ [
+ {
+ id: mainWindow,
+ type: "normal",
+ },
+ ],
+ primedMainWindowInfo
+ );
+
+ browser.test.log("Open a compose window (messengercompose.xhtml).");
+
+ let primedComposeWindowInfo = await capturePrimedEvent(
+ "onCreated",
+ () => browser.compose.beginNew()
+ );
+ let [{ id: composeWindow }] = await listener.checkEvent(
+ "windows.onCreated",
+ {
+ type: "messageCompose",
+ }
+ );
+ let [{ id: composeTab }] = await listener.checkEvent("tabs.onCreated", {
+ index: 0,
+ windowId: composeWindow,
+ active: true,
+ mailTab: false,
+ });
+ window.assertDeepEqual(
+ [
+ {
+ id: composeWindow,
+ type: "messageCompose",
+ },
+ ],
+ primedComposeWindowInfo
+ );
+
+ browser.test.log("Open a message in a window (messageWindow.xhtml).");
+
+ let primedDisplayWindowInfo = await window.sendMessage(
+ "openDisplayWindow"
+ );
+ let [{ id: displayWindow }] = await listener.checkEvent(
+ "windows.onCreated",
+ {
+ type: "messageDisplay",
+ }
+ );
+ let [{ id: displayTab }] = await listener.checkEvent("tabs.onCreated", {
+ index: 0,
+ windowId: displayWindow,
+ active: true,
+ mailTab: false,
+ });
+ window.assertDeepEqual(
+ [
+ {
+ id: displayWindow,
+ type: "messageDisplay",
+ },
+ ],
+ primedDisplayWindowInfo
+ );
+
+ browser.test.log("Open a page in a popup window.");
+
+ let primedPopupWindowInfo = await capturePrimedEvent("onCreated", () =>
+ browser.windows.create({
+ url: "test.html",
+ type: "popup",
+ width: 800,
+ height: 500,
+ })
+ );
+ let [{ id: popupWindow }] = await listener.checkEvent(
+ "windows.onCreated",
+ {
+ type: "popup",
+ width: 800,
+ height: 500,
+ }
+ );
+ let [{ id: popupTab }] = await listener.checkEvent("tabs.onCreated", {
+ index: 0,
+ windowId: popupWindow,
+ active: true,
+ mailTab: false,
+ });
+ window.assertDeepEqual(
+ [
+ {
+ id: popupWindow,
+ type: "popup",
+ width: 800,
+ height: 500,
+ },
+ ],
+ primedPopupWindowInfo
+ );
+
+ browser.test.log("Pause to let windows load properly.");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2500));
+
+ browser.test.log("Change focused window.");
+
+ let focusInfoPromise = new Promise(resolve => {
+ let listener = windowId => {
+ browser.windows.onFocusChanged.removeListener(listener);
+ resolve(windowId);
+ };
+ browser.windows.onFocusChanged.addListener(listener);
+ });
+ let [primedFocusInfo] = await capturePrimedEvent("onFocusChanged", () =>
+ browser.windows.update(composeWindow, { focused: true })
+ );
+ let focusInfo = await focusInfoPromise;
+ let platformInfo = await browser.runtime.getPlatformInfo();
+
+ let expectedWindow = ["mac", "win"].includes(platformInfo.os)
+ ? composeWindow
+ : browser.windows.WINDOW_ID_NONE;
+ window.assertDeepEqual(expectedWindow, primedFocusInfo);
+ window.assertDeepEqual(expectedWindow, focusInfo);
+
+ browser.test.log("Close the new main window.");
+
+ let primedMainWindowRemoveInfo = await capturePrimedEvent(
+ "onRemoved",
+ () => browser.windows.remove(mainWindow)
+ );
+ await listener.checkEvent("windows.onRemoved", mainWindow);
+ await listener.checkEvent("tabs.onRemoved", mainTab, {
+ windowId: mainWindow,
+ isWindowClosing: true,
+ });
+ window.assertDeepEqual([mainWindow], primedMainWindowRemoveInfo);
+
+ browser.test.log("Close the compose window.");
+
+ let primedComposWindowRemoveInfo = await capturePrimedEvent(
+ "onRemoved",
+ () => browser.windows.remove(composeWindow)
+ );
+ await listener.checkEvent("windows.onRemoved", composeWindow);
+ await listener.checkEvent("tabs.onRemoved", composeTab, {
+ windowId: composeWindow,
+ isWindowClosing: true,
+ });
+ window.assertDeepEqual([composeWindow], primedComposWindowRemoveInfo);
+
+ browser.test.log("Close the message window.");
+
+ let primedDisplayWindowRemoveInfo = await capturePrimedEvent(
+ "onRemoved",
+ () => browser.windows.remove(displayWindow)
+ );
+ await listener.checkEvent("windows.onRemoved", displayWindow);
+ await listener.checkEvent("tabs.onRemoved", displayTab, {
+ windowId: displayWindow,
+ isWindowClosing: true,
+ });
+ window.assertDeepEqual([displayWindow], primedDisplayWindowRemoveInfo);
+
+ browser.test.log("Close the popup window.");
+
+ let primedPopupWindowRemoveInfo = await capturePrimedEvent(
+ "onRemoved",
+ () => browser.windows.remove(popupWindow)
+ );
+ await listener.checkEvent("windows.onRemoved", popupWindow);
+ await listener.checkEvent("tabs.onRemoved", popupTab, {
+ windowId: popupWindow,
+ isWindowClosing: true,
+ });
+ window.assertDeepEqual([popupWindow], primedPopupWindowRemoveInfo);
+
+ let finalWindows = await browser.windows.getAll({ populate: true });
+ browser.test.assertEq(1, finalWindows.length);
+ browser.test.assertEq(initialWindow, finalWindows[0].id);
+ browser.test.assertEq(1, finalWindows[0].tabs.length);
+ browser.test.assertEq(initialTab, finalWindows[0].tabs[0].id);
+
+ browser.test.assertEq(0, listener.tabEvents.length);
+ browser.test.assertEq(0, listener.windowEvents.length);
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ // Function to start an event page extension (MV3), which can be called whenever
+ // the main test is about to trigger an event. The extension terminates its
+ // background and listens for that single event, verifying it is waking up correctly.
+ async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let eventName = browser.runtime.getManifest().description;
+
+ if (
+ ["onCreated", "onFocusChanged", "onRemoved"].includes(eventName)
+ ) {
+ browser.windows[eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${eventName} received`, args);
+ }
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ browser_specific_settings: {
+ gecko: { id: `windows.eventpage.${eventName}@mochi.test` },
+ },
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "windows", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "windows", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "windows", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+ }
+
+ extension.onMessage("openMainWindow", async () => {
+ let primedEventData = await event_page_extension("onCreated", () => {
+ return window.MsgOpenNewWindowForFolder(testFolder.URI);
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ extension.onMessage("openDisplayWindow", async () => {
+ let primedEventData = await event_page_extension("onCreated", () => {
+ return openMessageInWindow([...testFolder.messages][0]);
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ extension.onMessage("capturePrimedEvent", async eventName => {
+ let primedEventData = await event_page_extension(eventName, () => {
+ // Resume execution of the main test, after the event page extension has
+ // primed its event listeners.
+ extension.sendMessage();
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/browser_ext_windows_types.js b/comm/mail/components/extensions/test/browser/browser_ext_windows_types.js
new file mode 100644
index 0000000000..af9ad35f8a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/browser_ext_windows_types.js
@@ -0,0 +1,121 @@
+/* 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 () => {
+ let files = {
+ "background.js": async () => {
+ // Message compose window.
+
+ let createdWindowPromise = window.waitForEvent("windows.onCreated");
+ await browser.compose.beginNew();
+ let [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageCompose", createdWindow.type);
+
+ let windowDetail = await browser.windows.get(createdWindow.id, {
+ populate: true,
+ });
+ browser.test.assertEq("messageCompose", windowDetail.type);
+ browser.test.assertEq(1, windowDetail.tabs.length);
+ browser.test.assertEq("messageCompose", windowDetail.tabs[0].type);
+ // These three properties should not be present, but not fail either.
+ browser.test.assertEq(undefined, windowDetail.tabs[0].favIconUrl);
+ browser.test.assertEq(undefined, windowDetail.tabs[0].title);
+ browser.test.assertEq(undefined, windowDetail.tabs[0].url);
+
+ let removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ await browser.tabs.remove(windowDetail.tabs[0].id);
+ await removedWindowPromise;
+
+ // Message display window.
+
+ createdWindowPromise = window.waitForEvent("windows.onCreated");
+ browser.test.sendMessage("openMessage");
+ [createdWindow] = await createdWindowPromise;
+ browser.test.assertEq("messageDisplay", createdWindow.type);
+
+ windowDetail = await browser.windows.get(createdWindow.id, {
+ populate: true,
+ });
+ browser.test.assertEq("messageDisplay", windowDetail.type);
+ browser.test.assertEq(1, windowDetail.tabs.length);
+ browser.test.assertEq("messageDisplay", windowDetail.tabs[0].type);
+ browser.test.assertEq("about:blank", windowDetail.tabs[0].url);
+ // These properties should not be present, but not fail either.
+ browser.test.assertEq(undefined, windowDetail.tabs[0].favIconUrl);
+ browser.test.assertEq(undefined, windowDetail.tabs[0].title);
+
+ removedWindowPromise = window.waitForEvent("windows.onRemoved");
+ browser.test.sendMessage("closeMessage");
+ await removedWindowPromise;
+
+ browser.test.notifyPass();
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks", "tabs"],
+ },
+ });
+
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test1", null);
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test1, 1);
+
+ await extension.startup();
+
+ await extension.awaitMessage("openMessage");
+ let newWindow = await openMessageInWindow([...subFolders.test1.messages][0]);
+
+ await extension.awaitMessage("closeMessage");
+ newWindow.close();
+
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_tabs_of_second_tabmail() {
+ let files = {
+ "background.js": async () => {
+ let testWindow = await browser.windows.create({ type: "normal" });
+ browser.test.assertEq("normal", testWindow.type);
+
+ let tabs = await await browser.tabs.query({ windowId: testWindow.id });
+ browser.test.assertEq(1, tabs.length);
+ browser.test.assertEq("mail", tabs[0].type);
+
+ await browser.windows.remove(testWindow.id);
+
+ browser.test.notifyPass();
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["background.js"] },
+ },
+ });
+
+ let account = createAccount();
+ addIdentity(account);
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test1", null);
+ let subFolders = {};
+ for (let folder of rootFolder.subFolders) {
+ subFolders[folder.name] = folder;
+ }
+ createMessages(subFolders.test1, 1);
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/browser/data/cloudFile1.txt b/comm/mail/components/extensions/test/browser/data/cloudFile1.txt
new file mode 100644
index 0000000000..42c5dbfae0
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/cloudFile1.txt
@@ -0,0 +1 @@
+you got the moves!
diff --git a/comm/mail/components/extensions/test/browser/data/cloudFile2.txt b/comm/mail/components/extensions/test/browser/data/cloudFile2.txt
new file mode 100644
index 0000000000..42c5dbfae0
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/cloudFile2.txt
@@ -0,0 +1 @@
+you got the moves!
diff --git a/comm/mail/components/extensions/test/browser/data/content.html b/comm/mail/components/extensions/test/browser/data/content.html
new file mode 100644
index 0000000000..6a56ee6a5a
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/content.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title>A test document</title>
+</head>
+<body>
+ <p id="description">This is text.</p>
+ <p><a href="http://mochi.test:8888/">This is a link with text.</a></p>
+ <p><img src="http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/tb-logo.png" width="304" height="84" /></p>
+</body>
+</html>
diff --git a/comm/mail/components/extensions/test/browser/data/content_body.html b/comm/mail/components/extensions/test/browser/data/content_body.html
new file mode 100644
index 0000000000..7652f2d84d
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/content_body.html
@@ -0,0 +1 @@
+<p>This is text.</p><p><a href="http://mochi.test:8888/">This is a link with text.</a></p><p><img src="http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data/tb-logo.png" width="304" height="84" /></p>
diff --git a/comm/mail/components/extensions/test/browser/data/linktest.html b/comm/mail/components/extensions/test/browser/data/linktest.html
new file mode 100644
index 0000000000..f8b49156d8
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/linktest.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title>A test document</title>
+</head>
+<body>
+ <p><a id="linkExt1" href="https://example.org/browser/comm/mail/components/extensions/test/browser/data/content.html">self</a></p>
+ <p><a id="linkExt2" href="https://mozilla.org/">other</a></p>
+</body>
+</html>
diff --git a/comm/mail/components/extensions/test/browser/data/tb-logo.png b/comm/mail/components/extensions/test/browser/data/tb-logo.png
new file mode 100644
index 0000000000..aac56e2546
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/data/tb-logo.png
Binary files differ
diff --git a/comm/mail/components/extensions/test/browser/head.js b/comm/mail/components/extensions/test/browser/head.js
new file mode 100644
index 0000000000..ed25bde87f
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/head.js
@@ -0,0 +1,1533 @@
+/* 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 { MailConsts } = ChromeUtils.import("resource:///modules/MailConsts.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+var { getCachedAllowedSpaces, setCachedAllowedSpaces } = ChromeUtils.import(
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+const { storeState, getState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+const { getDefaultItemIdsForSpace, getAvailableItemIdsForSpace } =
+ ChromeUtils.importESModule("resource:///modules/CustomizableItems.sys.mjs");
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+// Persistent Listener test functionality
+var { assertPersistentListeners } = ExtensionTestUtils.testAssertions;
+
+// There are shutdown issues for which multiple rejections are left uncaught.
+// This bug should be fixed, but for the moment this directory is whitelisted.
+//
+// NOTE: Entire directory whitelisting should be kept to a minimum. Normally you
+// should use "expectUncaughtRejection" to flag individual failures.
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(/No matching message handler/);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Receiving end does not exist/
+);
+
+// Adjust timeout to take care of code coverage runs and fission runs to be a
+// lot slower.
+let originalRequestLongerTimeout = requestLongerTimeout;
+// eslint-disable-next-line no-global-assign
+requestLongerTimeout = factor => {
+ let ccovMultiplier = AppConstants.MOZ_CODE_COVERAGE ? 2 : 1;
+ let fissionMultiplier = SpecialPowers.useRemoteSubframes ? 2 : 1;
+ originalRequestLongerTimeout(ccovMultiplier * fissionMultiplier * factor);
+};
+requestLongerTimeout(1);
+
+add_setup(async () => {
+ await check3PaneState(true, true);
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.tabInfo.length > 1) {
+ info(`Will close ${tabmail.tabInfo.length - 1} tabs left over from others`);
+ for (let i = tabmail.tabInfo.length - 1; i > 0; i--) {
+ tabmail.closeTab(i);
+ }
+ is(tabmail.tabInfo.length, 1, "One tab open from start");
+ }
+});
+registerCleanupFunction(() => {
+ let tabmail = document.getElementById("tabmail");
+ is(tabmail.tabInfo.length, 1, "Only one tab open at end of test");
+
+ while (tabmail.tabInfo.length > 1) {
+ tabmail.closeTab(tabmail.tabInfo[1]);
+ }
+
+ // Some tests that open new windows don't return focus to the main window
+ // in a way that satisfies mochitest, and the test times out.
+ Services.focus.focusedWindow = window;
+ // Focus an element in the main window, then blur it again to avoid it
+ // hijacking keypresses.
+ let mainWindowElement = document.getElementById("button-appmenu");
+ mainWindowElement.focus();
+ mainWindowElement.blur();
+
+ MailServices.accounts.accounts.forEach(cleanUpAccount);
+ check3PaneState(true, true);
+
+ // The unified toolbar must have been cleaned up. If this fails, check if a
+ // test loaded an extension with a browser_action without setting "useAddonManager"
+ // to either "temporary" or "permanent", which triggers onUninstalled to be
+ // called on extension unload.
+ let cachedAllowedSpaces = getCachedAllowedSpaces();
+ is(
+ cachedAllowedSpaces.size,
+ 0,
+ `Stored known extension spaces should be cleared: ${JSON.stringify(
+ Object.fromEntries(cachedAllowedSpaces)
+ )}`
+ );
+ setCachedAllowedSpaces(new Map());
+ Services.prefs.clearUserPref("mail.pane_config.dynamic");
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view"
+ );
+});
+
+/**
+ * Enforce a certain state in the unified toolbar.
+ * @param {Object} state - A dictionary with arrays of buttons assigned to a space
+ */
+async function enforceState(state) {
+ const stateChangeObserved = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ storeState(state);
+ await stateChangeObserved;
+}
+
+async function check3PaneState(folderPaneOpen = null, messagePaneOpen = null) {
+ let tabmail = document.getElementById("tabmail");
+ let tab = tabmail.currentTabInfo;
+ if (tab.chromeBrowser.contentDocument.readyState != "complete") {
+ await BrowserTestUtils.waitForEvent(
+ tab.chromeBrowser.contentWindow,
+ "load"
+ );
+ }
+
+ let { paneLayout } = tabmail.currentAbout3Pane;
+ if (folderPaneOpen !== null) {
+ Assert.equal(
+ paneLayout.folderPaneVisible,
+ folderPaneOpen,
+ "State of folder pane splitter is correct"
+ );
+ paneLayout.folderPaneVisible = folderPaneOpen;
+ }
+
+ if (messagePaneOpen !== null) {
+ Assert.equal(
+ paneLayout.messagePaneVisible,
+ messagePaneOpen,
+ "State of message pane splitter is correct"
+ );
+ paneLayout.messagePaneVisible = messagePaneOpen;
+ }
+}
+
+function createAccount(type = "none") {
+ let account;
+
+ if (type == "local") {
+ MailServices.accounts.createLocalMailAccount();
+ account = MailServices.accounts.FindAccountForServer(
+ MailServices.accounts.localFoldersServer
+ );
+ } else {
+ account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ type
+ );
+ }
+
+ info(`Created account ${account.toString()}`);
+ return account;
+}
+
+function cleanUpAccount(account) {
+ // If the current displayed message/folder belongs to the account to be removed,
+ // select the root folder, otherwise the removal of this account will trigger
+ // a "shouldn't have any listeners left" assertion in nsMsgDatabase.cpp.
+ let [folder] = window.GetSelectedMsgFolders();
+ if (folder && folder.server && folder.server == account.incomingServer) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.currentAbout3Pane.displayFolder(folder.server.rootFolder.URI);
+ }
+
+ let serverKey = account.incomingServer.key;
+ let serverType = account.incomingServer.type;
+ info(
+ `Cleaning up ${serverType} account ${account.key} and server ${serverKey}`
+ );
+ MailServices.accounts.removeAccount(account, true);
+
+ try {
+ let server = MailServices.accounts.getIncomingServer(serverKey);
+ if (server) {
+ info(`Cleaning up leftover ${serverType} server ${serverKey}`);
+ MailServices.accounts.removeIncomingServer(server, false);
+ }
+ } catch (e) {}
+}
+
+function addIdentity(account, email = "mochitest@localhost") {
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = email;
+ account.addIdentity(identity);
+ if (!account.defaultIdentity) {
+ account.defaultIdentity = identity;
+ }
+ info(`Created identity ${identity.toString()}`);
+ return identity;
+}
+
+async function createSubfolder(parent, name) {
+ parent.createSubfolder(name, null);
+ return parent.getChildNamed(name);
+}
+
+function createMessages(folder, makeMessagesArg) {
+ if (typeof makeMessagesArg == "number") {
+ makeMessagesArg = { count: makeMessagesArg };
+ }
+ if (!createMessages.messageGenerator) {
+ createMessages.messageGenerator = new MessageGenerator();
+ }
+
+ let messages = createMessages.messageGenerator.makeMessages(makeMessagesArg);
+ let messageStrings = messages.map(message => message.toMboxString());
+ folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folder.addMessageBatch(messageStrings);
+}
+
+async function createMessageFromFile(folder, path) {
+ let message = await IOUtils.readUTF8(path);
+
+ // A cheap hack to make this acceptable to addMessageBatch. It works for
+ // existing uses but may not work for future uses.
+ let fromAddress = message.match(/From: .* <(.*@.*)>/)[0];
+ message = `From ${fromAddress}\r\n${message}`;
+
+ folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folder.addMessageBatch([message]);
+ folder.callFilterPlugins(null);
+}
+
+async function promiseAnimationFrame(win = window) {
+ await new Promise(win.requestAnimationFrame);
+ // dispatchToMainThread throws if used as the first argument of Promise.
+ return new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+}
+
+async function focusWindow(win) {
+ if (Services.focus.activeWindow == win) {
+ return;
+ }
+
+ let promise = new Promise(resolve => {
+ win.addEventListener(
+ "focus",
+ function () {
+ resolve();
+ },
+ { capture: true, once: true }
+ );
+ });
+
+ win.focus();
+ await promise;
+}
+
+function promisePopupShown(popup) {
+ return new Promise(resolve => {
+ if (popup.state == "open") {
+ resolve();
+ } else {
+ let onPopupShown = event => {
+ popup.removeEventListener("popupshown", onPopupShown);
+ resolve();
+ };
+ popup.addEventListener("popupshown", onPopupShown);
+ }
+ });
+}
+
+function getPanelForNode(node) {
+ while (node.localName != "panel") {
+ node = node.parentNode;
+ }
+ return node;
+}
+
+/**
+ * Wait until the browser is fully loaded.
+ *
+ * @param {xul:browser} browser - A xul:browser.
+ * @param {string|function} [wantLoad = null] - If a function, takes a URL and
+ * returns true if that's the load we're interested in. If a string, gives the
+ * URL of the load we're interested in. If not present, the first load resolves
+ * the promise.
+ *
+ * @returns {Promise} When a load event is triggered for the browser or the browser
+ * is already fully loaded.
+ */
+function awaitBrowserLoaded(browser, wantLoad) {
+ let testFn = () => true;
+ if (wantLoad) {
+ testFn = typeof wantLoad === "function" ? wantLoad : url => url == wantLoad;
+ }
+
+ return TestUtils.waitForCondition(
+ () =>
+ browser.ownerGlobal.document.readyState === "complete" &&
+ (browser.webProgress?.isLoadingDocument === false ||
+ browser.contentDocument?.readyState === "complete") &&
+ browser.currentURI &&
+ testFn(browser.currentURI.spec),
+ "Browser should be loaded"
+ );
+}
+
+var awaitExtensionPanel = async function (
+ extension,
+ win = window,
+ awaitLoad = true
+) {
+ let { originalTarget: browser } = await BrowserTestUtils.waitForEvent(
+ win.document,
+ "WebExtPopupLoaded",
+ true,
+ event => event.detail.extension.id === extension.id
+ );
+
+ if (awaitLoad) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+ }
+ await promisePopupShown(getPanelForNode(browser));
+
+ return browser;
+};
+
+function getBrowserActionPopup(extension, win = window) {
+ return win.top.document.getElementById("webextension-remote-preload-panel");
+}
+
+function closeBrowserAction(extension, win = window) {
+ let popup = getBrowserActionPopup(extension, win);
+ let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ popup.hidePopup();
+
+ return hidden;
+}
+
+async function openNewMailWindow(options = {}) {
+ if (!options.newAccountWizard) {
+ Services.prefs.setBoolPref(
+ "mail.provider.suppress_dialog_on_startup",
+ true
+ );
+ }
+
+ let win = window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,all,dialog=no"
+ );
+ await Promise.all([
+ BrowserTestUtils.waitForEvent(win, "focus", true),
+ BrowserTestUtils.waitForEvent(win, "activate", true),
+ ]);
+
+ return win;
+}
+
+async function openComposeWindow(account) {
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ params.identity = account.defaultIdentity;
+ params.composeFields = composeFields;
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened(
+ undefined,
+ async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ if (
+ win.document.documentURI !=
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml"
+ ) {
+ return false;
+ }
+ await BrowserTestUtils.waitForEvent(win, "compose-editor-ready");
+ return true;
+ }
+ );
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ return composeWindowPromise;
+}
+
+async function openMessageInTab(msgHdr) {
+ if (!msgHdr.QueryInterface(Ci.nsIMsgDBHdr)) {
+ throw new Error("No message passed to openMessageInTab");
+ }
+
+ // Ensure the behaviour pref is set to open a new tab. It is the default,
+ // but you never know.
+ let oldPrefValue = Services.prefs.getIntPref("mail.openMessageBehavior");
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.NEW_TAB
+ );
+ MailUtils.displayMessages([msgHdr]);
+ Services.prefs.setIntPref("mail.openMessageBehavior", oldPrefValue);
+
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ let tab = win.document.getElementById("tabmail").currentTabInfo;
+ await BrowserTestUtils.waitForEvent(tab.chromeBrowser, "MsgLoaded");
+ return tab;
+}
+
+async function openMessageInWindow(msgHdr) {
+ if (!msgHdr.QueryInterface(Ci.nsIMsgDBHdr)) {
+ throw new Error("No message passed to openMessageInWindow");
+ }
+
+ let messageWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ async win =>
+ win.document.documentURI ==
+ "chrome://messenger/content/messageWindow.xhtml"
+ );
+ MailUtils.openMessageInNewWindow(msgHdr);
+
+ let messageWindow = await messageWindowPromise;
+ await BrowserTestUtils.waitForEvent(messageWindow, "MsgLoaded");
+ return messageWindow;
+}
+
+async function promiseMessageLoaded(browser, msgHdr) {
+ let messageURI = msgHdr.folder.getUriForMsg(msgHdr);
+ messageURI = MailServices.messageServiceFromURI(messageURI).getUrlForUri(
+ messageURI,
+ null
+ );
+
+ await awaitBrowserLoaded(browser, uri => uri == messageURI.spec);
+}
+
+/**
+ * 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;
+ let composeFields = composeWindows[0].gMsgCompose.compFields;
+
+ await new Promise(resolve => composeWindows[0].setTimeout(resolve));
+
+ if ("identityId" in expected) {
+ is(composeWindows[0].getCurrentIdentityKey(), expected.identityId);
+ }
+
+ if (expected.attachVCard) {
+ is(
+ expected.attachVCard,
+ composeFields.attachVCard,
+ "attachVCard in window should be correct"
+ );
+ }
+
+ 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");
+ }
+
+ if (expected.overrideDefaultFcc) {
+ if (expected.overrideDefaultFccFolder) {
+ let server = MailServices.accounts.getAccount(
+ expected.overrideDefaultFccFolder.accountId
+ ).incomingServer;
+ let rootURI = server.rootFolder.URI;
+ is(
+ rootURI + expected.overrideDefaultFccFolder.path,
+ composeFields.fcc,
+ "fcc should be correct"
+ );
+ } else {
+ ok(
+ composeFields.fcc.startsWith("nocopy://"),
+ "fcc should start with nocopy://"
+ );
+ }
+ } else {
+ is("", composeFields.fcc, "fcc should be empty");
+ }
+
+ if (expected.additionalFccFolder) {
+ let server = MailServices.accounts.getAccount(
+ expected.additionalFccFolder.accountId
+ ).incomingServer;
+ let rootURI = server.rootFolder.URI;
+ is(
+ rootURI + expected.additionalFccFolder.path,
+ composeFields.fcc2,
+ "fcc2 should be correct"
+ );
+ } else {
+ ok(
+ composeFields.fcc2 == "" || composeFields.fcc2.startsWith("nocopy://"),
+ "fcc2 should not contain a folder uri"
+ );
+ }
+
+ if (expected.hasOwnProperty("priority")) {
+ is(
+ composeFields.priority.toLowerCase(),
+ expected.priority == "normal" ? "" : expected.priority,
+ "priority in composeFields should be correct"
+ );
+ }
+
+ if (expected.hasOwnProperty("returnReceipt")) {
+ is(
+ composeFields.returnReceipt,
+ expected.returnReceipt,
+ "returnReceipt in composeFields should be correct"
+ );
+ for (let item of composeDocument.querySelectorAll(`menuitem[command="cmd_toggleReturnReceipt"],
+ toolbarbutton[command="cmd_toggleReturnReceipt"]`)) {
+ is(
+ item.getAttribute("checked") == "true",
+ expected.returnReceipt,
+ "returnReceipt in window should be correct"
+ );
+ }
+ }
+
+ if (expected.hasOwnProperty("deliveryStatusNotification")) {
+ is(
+ composeFields.DSN,
+ !!expected.deliveryStatusNotification,
+ "deliveryStatusNotification in composeFields should be correct"
+ );
+ is(
+ composeDocument.getElementById("dsnMenu").getAttribute("checked") ==
+ "true",
+ !!expected.deliveryStatusNotification,
+ "deliveryStatusNotification in window should be correct"
+ );
+ }
+
+ if (expected.hasOwnProperty("deliveryFormat")) {
+ const deliveryFormats = {
+ auto: Ci.nsIMsgCompSendFormat.Auto,
+ plaintext: Ci.nsIMsgCompSendFormat.PlainText,
+ html: Ci.nsIMsgCompSendFormat.HTML,
+ both: Ci.nsIMsgCompSendFormat.Both,
+ };
+ const formatToId = new Map([
+ [Ci.nsIMsgCompSendFormat.PlainText, "format_plain"],
+ [Ci.nsIMsgCompSendFormat.HTML, "format_html"],
+ [Ci.nsIMsgCompSendFormat.Both, "format_both"],
+ [Ci.nsIMsgCompSendFormat.Auto, "format_auto"],
+ ]);
+ let expectedFormat = deliveryFormats[expected.deliveryFormat || "auto"];
+ is(
+ expectedFormat,
+ composeFields.deliveryFormat,
+ "deliveryFormat in composeFields should be correct"
+ );
+ for (let [format, id] of formatToId.entries()) {
+ let menuitem = composeDocument.getElementById(id);
+ is(
+ format == expectedFormat,
+ menuitem.getAttribute("checked") == "true",
+ "checked state of the deliveryFormat menu item <${id}> in window should be correct"
+ );
+ }
+ }
+}
+
+async function synthesizeMouseAtCenterAndRetry(selector, event, browser) {
+ let success = false;
+ let type = event.type || "click";
+ for (let retries = 0; !success && retries < 2; retries++) {
+ let clickPromise = BrowserTestUtils.waitForContentEvent(browser, type).then(
+ () => true
+ );
+ // Linux: Sometimes the actor used to simulate the mouse event in the content process does not
+ // react, even though the content page signals to be fully loaded. There is no status signal
+ // we could wait for, the loaded page *should* be ready at this point. To mitigate, we wait
+ // for the click event and if we do not see it within a certain time, we click again.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ let failPromise = new Promise(r =>
+ browser.ownerGlobal.setTimeout(r, 500)
+ ).then(() => false);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(selector, event, browser);
+ success = await Promise.race([clickPromise, failPromise]);
+ }
+ Assert.ok(success, `Should have received ${type} event.`);
+}
+
+async function openContextMenu(selector = "#img1", win = window) {
+ let contentAreaContextMenu = win.document.getElementById("browserContext");
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popupshown"
+ );
+ let tabmail = document.getElementById("tabmail");
+ await synthesizeMouseAtCenterAndRetry(
+ selector,
+ { type: "mousedown", button: 2 },
+ tabmail.selectedBrowser
+ );
+ await synthesizeMouseAtCenterAndRetry(
+ selector,
+ { type: "contextmenu" },
+ tabmail.selectedBrowser
+ );
+ await popupShownPromise;
+ return contentAreaContextMenu;
+}
+
+async function openContextMenuInPopup(extension, selector, win = window) {
+ let contentAreaContextMenu =
+ win.top.document.getElementById("browserContext");
+ let stack = getBrowserActionPopup(extension, win);
+ let browser = stack.querySelector("browser");
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popupshown"
+ );
+ await synthesizeMouseAtCenterAndRetry(
+ selector,
+ { type: "mousedown", button: 2 },
+ browser
+ );
+ await synthesizeMouseAtCenterAndRetry(
+ selector,
+ { type: "contextmenu" },
+ browser
+ );
+ await popupShownPromise;
+ return contentAreaContextMenu;
+}
+
+async function closeExtensionContextMenu(
+ itemToSelect,
+ modifiers = {},
+ win = window
+) {
+ let contentAreaContextMenu =
+ win.top.document.getElementById("browserContext");
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popuphidden"
+ );
+ if (itemToSelect) {
+ itemToSelect.closest("menupopup").activateItem(itemToSelect, modifiers);
+ } else {
+ contentAreaContextMenu.hidePopup();
+ }
+ await popupHiddenPromise;
+
+ // Bug 1351638: parent menu fails to close intermittently, make sure it does.
+ contentAreaContextMenu.hidePopup();
+}
+
+async function openSubmenu(submenuItem, win = window) {
+ const submenu = submenuItem.menupopup;
+ const shown = BrowserTestUtils.waitForEvent(submenu, "popupshown");
+ submenuItem.openMenu(true);
+ await shown;
+ return submenu;
+}
+
+async function closeContextMenu(contextMenu) {
+ let contentAreaContextMenu =
+ contextMenu || document.getElementById("browserContext");
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popuphidden"
+ );
+ contentAreaContextMenu.hidePopup();
+ await popupHiddenPromise;
+}
+
+async function getUtilsJS() {
+ let response = await fetch(getRootDirectory(gTestPath) + "utils.js");
+ return response.text();
+}
+
+async function checkContent(browser, expected) {
+ await SpecialPowers.spawn(browser, [expected], expected => {
+ let body = content.document.body;
+ Assert.ok(body, "body");
+ let computedStyle = content.getComputedStyle(body);
+
+ if ("backgroundColor" in expected) {
+ Assert.equal(
+ computedStyle.backgroundColor,
+ expected.backgroundColor,
+ "backgroundColor"
+ );
+ }
+ if ("color" in expected) {
+ Assert.equal(computedStyle.color, expected.color, "color");
+ }
+ if ("foo" in expected) {
+ Assert.equal(body.getAttribute("foo"), expected.foo, "foo");
+ }
+ if ("textContent" in expected) {
+ // In message display, we only really want the message body, but the
+ // document body also has headers. For the purposes of these tests,
+ // we can just select an descendant node, since what really matters is
+ // whether (or not) a script ran, not the exact result.
+ body = body.querySelector(".moz-text-flowed") ?? body;
+ Assert.equal(body.textContent, expected.textContent, "textContent");
+ }
+ });
+}
+
+function contentTabOpenPromise(tabmail, url) {
+ return new Promise(resolve => {
+ let tabMonitor = {
+ onTabTitleChanged(aTab) {},
+ onTabClosing(aTab) {},
+ onTabPersist(aTab) {},
+ onTabRestored(aTab) {},
+ onTabSwitched(aNewTab, aOldTab) {},
+ async onTabOpened(aTab) {
+ let result = awaitBrowserLoaded(
+ aTab.linkedBrowser,
+ urlToMatch => urlToMatch == url
+ ).then(() => aTab);
+
+ let reporterListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+ onStateChange() {},
+ onProgressChange() {},
+ onLocationChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in nsIURI*/ aLocation
+ ) {
+ if (aLocation.spec == url) {
+ aTab.browser.removeProgressListener(reporterListener);
+ tabmail.unregisterTabMonitor(tabMonitor);
+ TestUtils.executeSoon(() => resolve(result));
+ }
+ },
+ onStatusChange() {},
+ onSecurityChange() {},
+ onContentBlockingEvent() {},
+ };
+ aTab.browser.addProgressListener(reporterListener);
+ },
+ };
+ tabmail.registerTabMonitor(tabMonitor);
+ });
+}
+
+/**
+ * @typedef ConfigData
+ * @property {string} actionType - type of action button in underscore notation
+ * @property {string} window - the window to perform the test in
+ * @property {string} [testType] - supported tests are "open-with-mouse-click" and
+ * "open-with-menu-command"
+ * @property {string} [default_area] - area to be used for the test
+ * @property {boolean} [use_default_popup] - select if the default_popup should be
+ * used for the test
+ * @property {boolean} [disable_button] - select if the button should be disabled
+ * @property {Function} [backend_script] - custom backend script to be used for the
+ * test, will override the default backend_script of the selected test
+ * @property {Function} [background_script] - custom background script to be used for the
+ * test, will override the default background_script of the selected test
+ * @property {[string]} [permissions] - custom permissions to be used for the test,
+ * must not be specified together with testType
+ */
+
+/**
+ * Creates an extension with an action button and either runs one of the default
+ * tests, or loads a custom background script and a custom backend scripts to run
+ * an arbitrary test.
+ *
+ * @param {ConfigData} configData - test configuration
+ */
+async function run_popup_test(configData) {
+ if (!configData.actionType) {
+ throw new Error("Mandatory configData.actionType is missing");
+ }
+ if (!configData.window) {
+ throw new Error("Mandatory configData.window is missing");
+ }
+
+ // Get camelCase API names from action type.
+ configData.apiName = configData.actionType.replace(/_([a-z])/g, function (g) {
+ return g[1].toUpperCase();
+ });
+ configData.moduleName =
+ configData.actionType == "action" ? "browserAction" : configData.apiName;
+
+ let backend_script = configData.backend_script;
+
+ let extensionDetails = {
+ files: {
+ "popup.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <title>Popup</title>
+ </head>
+ <body>
+ <p>Hello</p>
+ <script src="popup.js"></script>
+ </body>
+ </html>`,
+ "popup.js": async function () {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => window.setTimeout(resolve, 1000));
+ await browser.runtime.sendMessage("popup opened");
+ await new Promise(resolve => window.setTimeout(resolve));
+ window.close();
+ },
+ "utils.js": await getUtilsJS(),
+ "helper.js": function () {
+ window.actionType = browser.runtime.getManifest().description;
+ // Get camelCase API names from action type.
+ window.apiName = window.actionType.replace(/_([a-z])/g, function (g) {
+ return g[1].toUpperCase();
+ });
+ window.getPopupOpenedPromise = function () {
+ return new Promise(resolve => {
+ const handleMessage = async (message, sender, sendResponse) => {
+ if (message && message == "popup opened") {
+ sendResponse();
+ window.setTimeout(resolve);
+ browser.runtime.onMessage.removeListener(handleMessage);
+ }
+ };
+ browser.runtime.onMessage.addListener(handleMessage);
+ });
+ };
+ },
+ },
+ manifest: {
+ manifest_version: configData.manifest_version || 2,
+ browser_specific_settings: {
+ gecko: {
+ id: `${configData.actionType}@mochi.test`,
+ },
+ },
+ description: configData.actionType,
+ background: { scripts: ["utils.js", "helper.js", "background.js"] },
+ },
+ useAddonManager: "temporary",
+ };
+
+ switch (configData.testType) {
+ case "open-with-mouse-click":
+ backend_script = async function (extension, configData) {
+ let win = configData.window;
+
+ await extension.startup();
+ await promiseAnimationFrame(win);
+ await new Promise(resolve => win.setTimeout(resolve));
+ await extension.awaitMessage("ready");
+
+ let buttonId = `${configData.actionType}_mochi_test-${configData.moduleName}-toolbarbutton`;
+ let toolbarId;
+ switch (configData.actionType) {
+ case "compose_action":
+ toolbarId = "composeToolbar2";
+ if (configData.default_area == "formattoolbar") {
+ toolbarId = "FormatToolbar";
+ }
+ break;
+ case "action":
+ case "browser_action":
+ if (configData.default_windows?.join(",") === "messageDisplay") {
+ toolbarId = "mail-bar3";
+ } else {
+ toolbarId = "unified-toolbar";
+ }
+ break;
+ case "message_display_action":
+ toolbarId = "header-view-toolbar";
+ break;
+ default:
+ throw new Error(
+ `Unsupported configData.actionType: ${configData.actionType}`
+ );
+ }
+
+ let toolbar, button;
+ if (toolbarId === "unified-toolbar") {
+ toolbar = win.document.querySelector("unified-toolbar");
+ button = win.document.querySelector(
+ `#unifiedToolbarContent [extension="${configData.actionType}@mochi.test"]`
+ );
+ } else {
+ toolbar = win.document.getElementById(toolbarId);
+ button = win.document.getElementById(buttonId);
+ }
+ ok(button, "Button created");
+ ok(toolbar.contains(button), "Button added to toolbar");
+ let label;
+ if (toolbarId === "unified-toolbar") {
+ const state = getState();
+ const itemId = `ext-${configData.actionType}@mochi.test`;
+ if (state.mail) {
+ ok(
+ state.mail.includes(itemId),
+ "Button should be in unified toolbar mail space"
+ );
+ }
+ ok(
+ getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Button should be in default set for unified toolbar mail space"
+ );
+ ok(
+ getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Button should be available in unified toolbar mail space"
+ );
+
+ let icon = button.querySelector(".button-icon");
+ is(
+ getComputedStyle(icon).content,
+ `url("chrome://messenger/content/extension.svg")`,
+ "Default icon"
+ );
+ label = button.querySelector(".button-label");
+ is(label.textContent, "This is a test", "Correct label");
+ } else {
+ if (toolbar.hasAttribute("customizable")) {
+ ok(
+ toolbar.currentSet.split(",").includes(buttonId),
+ `Button should have been added to currentSet property of toolbar ${toolbarId}`
+ );
+ ok(
+ toolbar.getAttribute("currentset").split(",").includes(buttonId),
+ `Button should have been added to currentset attribute of toolbar ${toolbarId}`
+ );
+ }
+ ok(
+ Services.xulStore
+ .getValue(win.location.href, toolbarId, "currentset")
+ .split(",")
+ .includes(buttonId),
+ `Button should have been added to currentset xulStore of toolbar ${toolbarId}`
+ );
+
+ let icon = button.querySelector(".toolbarbutton-icon");
+ is(
+ getComputedStyle(icon).listStyleImage,
+ `url("chrome://messenger/content/extension.svg")`,
+ "Default icon"
+ );
+ label = button.querySelector(".toolbarbutton-text");
+ is(label.value, "This is a test", "Correct label");
+ }
+
+ if (
+ !configData.use_default_popup &&
+ configData?.manifest_version == 3
+ ) {
+ assertPersistentListeners(
+ extension,
+ configData.moduleName,
+ "onClicked",
+ {
+ primed: false,
+ }
+ );
+ }
+ if (configData.terminateBackground) {
+ await extension.terminateBackground({
+ disableResetIdleForTest: true,
+ });
+ if (
+ !configData.use_default_popup &&
+ configData?.manifest_version == 3
+ ) {
+ assertPersistentListeners(
+ extension,
+ configData.moduleName,
+ "onClicked",
+ {
+ primed: true,
+ }
+ );
+ }
+ }
+
+ let clickedPromise;
+ if (!configData.disable_button) {
+ clickedPromise = extension.awaitMessage("actionButtonClicked");
+ }
+ EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, win);
+ if (configData.disable_button) {
+ // We're testing that nothing happens. Give it time to potentially happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => win.setTimeout(resolve, 500));
+ // In case the background was terminated, it should not restart.
+ // If it does, we will get an extra "ready" message and fail.
+ // Listeners should still be primed.
+ if (
+ configData.terminateBackground &&
+ configData?.manifest_version == 3
+ ) {
+ assertPersistentListeners(
+ extension,
+ configData.moduleName,
+ "onClicked",
+ {
+ primed: true,
+ }
+ );
+ }
+ } else {
+ let hasFiredBefore = await clickedPromise;
+ await promiseAnimationFrame(win);
+ await new Promise(resolve => win.setTimeout(resolve));
+ if (toolbarId === "unified-toolbar") {
+ is(
+ win.document.querySelector(
+ `#unifiedToolbarContent [extension="${configData.actionType}@mochi.test"]`
+ ),
+ button
+ );
+ label = button.querySelector(".button-label");
+ is(label.textContent, "New title", "Correct label");
+ } else {
+ is(win.document.getElementById(buttonId), button);
+ label = button.querySelector(".toolbarbutton-text");
+ is(label.value, "New title", "Correct label");
+ }
+
+ if (configData.terminateBackground) {
+ // The onClicked event should have restarted the background script.
+ await extension.awaitMessage("ready");
+ // Could be undefined, but it must not be true
+ is(false, !!hasFiredBefore);
+ }
+ if (
+ !configData.use_default_popup &&
+ configData?.manifest_version == 3
+ ) {
+ assertPersistentListeners(
+ extension,
+ configData.moduleName,
+ "onClicked",
+ {
+ primed: false,
+ }
+ );
+ }
+ }
+
+ // Check the open state of the action button.
+ await TestUtils.waitForCondition(
+ () => button.getAttribute("open") != "true",
+ "Button should not have open state after the popup closed."
+ );
+
+ await extension.unload();
+ await promiseAnimationFrame(win);
+ await new Promise(resolve => win.setTimeout(resolve));
+
+ ok(!win.document.getElementById(buttonId), "Button destroyed");
+
+ if (toolbarId === "unified-toolbar") {
+ const state = getState();
+ const itemId = `ext-${configData.actionType}@mochi.test`;
+ if (state.mail) {
+ ok(
+ !state.mail.includes(itemId),
+ "Button should have been removed from unified toolbar mail space"
+ );
+ }
+ ok(
+ !getDefaultItemIdsForSpace("mail").includes(itemId),
+ "Button should have been removed from default set for unified toolbar mail space"
+ );
+ ok(
+ !getAvailableItemIdsForSpace("mail").includes(itemId),
+ "Button should have no longer be available in unified toolbar mail space"
+ );
+ } else {
+ ok(
+ !Services.xulStore
+ .getValue(win.top.location.href, toolbarId, "currentset")
+ .split(",")
+ .includes(buttonId),
+ `Button should have been removed from currentset xulStore of toolbar ${toolbarId}`
+ );
+ }
+ };
+ if (configData.use_default_popup) {
+ // With popup.
+ extensionDetails.files["background.js"] = async function () {
+ browser.test.log("popup background script ran");
+ let popupPromise = window.getPopupOpenedPromise();
+ browser.test.sendMessage("ready");
+ await popupPromise;
+ await browser[window.apiName].setTitle({ title: "New title" });
+ browser.test.sendMessage("actionButtonClicked");
+ };
+ } else if (configData.disable_button) {
+ // Without popup and disabled button.
+ extensionDetails.files["background.js"] = async function () {
+ browser.test.log("nopopup & button disabled background script ran");
+ browser[window.apiName].onClicked.addListener(async (tab, info) => {
+ browser.test.fail(
+ "Should not have seen the onClicked event for a disabled button"
+ );
+ });
+ browser[window.apiName].disable();
+ browser.test.sendMessage("ready");
+ };
+ } else {
+ // Without popup.
+ extensionDetails.files["background.js"] = async function () {
+ let hasFiredBefore = false;
+ browser.test.log("nopopup background script ran");
+ browser[window.apiName].onClicked.addListener(async (tab, info) => {
+ browser.test.assertEq("object", typeof tab);
+ browser.test.assertEq("object", typeof info);
+ browser.test.assertEq(0, info.button);
+ browser.test.assertTrue(Array.isArray(info.modifiers));
+ browser.test.assertEq(0, info.modifiers.length);
+ let [currentTab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertEq(
+ currentTab.id,
+ tab.id,
+ "Should find the correct tab"
+ );
+ await browser[window.apiName].setTitle({ title: "New title" });
+ await new Promise(resolve => window.setTimeout(resolve));
+ browser.test.sendMessage("actionButtonClicked", hasFiredBefore);
+ hasFiredBefore = true;
+ });
+ browser.test.sendMessage("ready");
+ };
+ }
+ break;
+
+ case "open-with-menu-command":
+ extensionDetails.manifest.permissions = ["menus"];
+ backend_script = async function (extension, configData) {
+ let win = configData.window;
+ let buttonId = `${configData.actionType}_mochi_test-${configData.moduleName}-toolbarbutton`;
+ let menuId = "toolbar-context-menu";
+ let isUnifiedToolbar = false;
+ if (
+ configData.actionType == "compose_action" &&
+ configData.default_area == "formattoolbar"
+ ) {
+ menuId = "format-toolbar-context-menu";
+ }
+ if (configData.actionType == "message_display_action") {
+ menuId = "header-toolbar-context-menu";
+ }
+ if (
+ (configData.actionType == "browser_action" ||
+ configData.actionType == "action") &&
+ configData.default_windows?.join(",") !== "messageDisplay"
+ ) {
+ menuId = "unifiedToolbarMenu";
+ isUnifiedToolbar = true;
+ }
+ const getButton = windowContent => {
+ if (isUnifiedToolbar) {
+ return windowContent.document.querySelector(
+ `#unifiedToolbarContent [extension="${configData.actionType}@mochi.test"]`
+ );
+ }
+ return windowContent.document.getElementById(buttonId);
+ };
+
+ extension.onMessage("triggerClick", async () => {
+ let button = getButton(win);
+ let menu = win.document.getElementById(menuId);
+ let onShownPromise = extension.awaitMessage("onShown");
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ button,
+ { type: "contextmenu" },
+ win
+ );
+ await shownPromise;
+ await onShownPromise;
+ await new Promise(resolve => win.setTimeout(resolve));
+
+ let menuitem = win.document.getElementById(
+ `${configData.actionType}_mochi_test-menuitem-_testmenu`
+ );
+ Assert.ok(menuitem);
+ menuitem.parentNode.activateItem(menuitem);
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => win.setTimeout(r, 250));
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+
+ // Check the open state of the action button.
+ let button = getButton(win);
+ await TestUtils.waitForCondition(
+ () => button.getAttribute("open") != "true",
+ "Button should not have open state after the popup closed."
+ );
+
+ await extension.unload();
+ };
+ if (configData.use_default_popup) {
+ // With popup.
+ extensionDetails.files["background.js"] = async function () {
+ browser.test.log("popup background script ran");
+ await new Promise(resolve => {
+ browser.menus.create(
+ {
+ id: "testmenu",
+ title: `Open ${window.actionType}`,
+ contexts: [window.actionType],
+ command: `_execute_${window.actionType}`,
+ },
+ resolve
+ );
+ });
+
+ await browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ let popupPromise = window.getPopupOpenedPromise();
+ await window.sendMessage("triggerClick");
+ await popupPromise;
+
+ browser.test.notifyPass();
+ };
+ } else if (configData.disable_button) {
+ // Without popup and disabled button.
+ extensionDetails.files["background.js"] = async function () {
+ browser.test.log("nopopup & button disabled background script ran");
+ await new Promise(resolve => {
+ browser.menus.create(
+ {
+ id: "testmenu",
+ title: `Open ${window.actionType}`,
+ contexts: [window.actionType],
+ command: `_execute_${window.actionType}`,
+ },
+ resolve
+ );
+ });
+
+ await browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ browser[window.apiName].onClicked.addListener(async (tab, info) => {
+ browser.test.fail(
+ "Should not have seen the onClicked event for a disabled button"
+ );
+ });
+
+ await browser[window.apiName].disable();
+ await window.sendMessage("triggerClick");
+ browser.test.notifyPass();
+ };
+ } else {
+ // Without popup.
+ extensionDetails.files["background.js"] = async function () {
+ browser.test.log("nopopup background script ran");
+ await new Promise(resolve => {
+ browser.menus.create(
+ {
+ id: "testmenu",
+ title: `Open ${window.actionType}`,
+ contexts: [window.actionType],
+ command: `_execute_${window.actionType}`,
+ },
+ resolve
+ );
+ });
+
+ await browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ let clickPromise = new Promise(resolve => {
+ let listener = async (tab, info) => {
+ browser[window.apiName].onClicked.removeListener(listener);
+ browser.test.assertEq("object", typeof tab);
+ browser.test.assertEq("object", typeof info);
+ browser.test.assertEq(0, info.button);
+ browser.test.assertTrue(Array.isArray(info.modifiers));
+ browser.test.assertEq(0, info.modifiers.length);
+ browser.test.log(`Tab ID is ${tab.id}`);
+ resolve();
+ };
+ browser[window.apiName].onClicked.addListener(listener);
+ });
+ await window.sendMessage("triggerClick");
+ await clickPromise;
+
+ browser.test.notifyPass();
+ };
+ }
+ break;
+ }
+
+ extensionDetails.manifest[configData.actionType] = {
+ default_title: "This is a test",
+ };
+ if (configData.use_default_popup) {
+ extensionDetails.manifest[configData.actionType].default_popup =
+ "popup.html";
+ }
+ if (configData.default_area) {
+ extensionDetails.manifest[configData.actionType].default_area =
+ configData.default_area;
+ }
+ if (configData.hasOwnProperty("background")) {
+ extensionDetails.files["background.js"] = configData.background_script;
+ }
+ if (configData.hasOwnProperty("permissions")) {
+ extensionDetails.manifest.permissions = configData.permissions;
+ }
+ if (configData.default_windows) {
+ extensionDetails.manifest[configData.actionType].default_windows =
+ configData.default_windows;
+ }
+
+ let extension = ExtensionTestUtils.loadExtension(extensionDetails);
+ await backend_script(extension, configData);
+}
+
+async function run_action_button_order_test(configs, window, actionType) {
+ // Get camelCase API names from action type.
+ let apiName = actionType.replace(/_([a-z])/g, function (g) {
+ return g[1].toUpperCase();
+ });
+
+ function get_id(name) {
+ return `${name}_mochi_test-${apiName}-toolbarbutton`;
+ }
+
+ function test_buttons(configs, window, toolbars) {
+ for (let toolbarId of toolbars) {
+ let expected = configs.filter(e => e.toolbar == toolbarId);
+ let selector =
+ toolbarId === "unified-toolbar"
+ ? `#unifiedToolbarContent [extension$="@mochi.test"]`
+ : `#${toolbarId} toolbarbutton[id$="${get_id("")}"]`;
+ let buttons = window.document.querySelectorAll(selector);
+ Assert.equal(
+ expected.length,
+ buttons.length,
+ `Should find the correct number of buttons in ${toolbarId} toolbar`
+ );
+ for (let i = 0; i < buttons.length; i++) {
+ if (toolbarId === "unified-toolbar") {
+ Assert.equal(
+ `${expected[i].name}@mochi.test`,
+ buttons[i].getAttribute("extension"),
+ `Should find the correct button at location #${i}`
+ );
+ } else {
+ Assert.equal(
+ get_id(expected[i].name),
+ buttons[i].id,
+ `Should find the correct button at location #${i}`
+ );
+ }
+ }
+ }
+ }
+
+ // Create extension data.
+ let toolbars = new Set();
+ for (let config of configs) {
+ toolbars.add(config.toolbar);
+ config.extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ applications: {
+ gecko: {
+ id: `${config.name}@mochi.test`,
+ },
+ },
+ [actionType]: {
+ default_title: config.name,
+ },
+ },
+ };
+ if (config.area) {
+ config.extensionData.manifest[actionType].default_area = config.area;
+ }
+ if (config.default_windows) {
+ config.extensionData.manifest[actionType].default_windows =
+ config.default_windows;
+ }
+ }
+
+ // Test order of buttons after first install.
+ for (let config of configs) {
+ config.extension = ExtensionTestUtils.loadExtension(config.extensionData);
+ await config.extension.startup();
+ }
+ test_buttons(configs, window, toolbars);
+
+ // Disable all buttons.
+ for (let config of configs) {
+ let addon = await AddonManager.getAddonByID(config.extension.id);
+ await addon.disable();
+ }
+ test_buttons([], window, toolbars);
+
+ // Re-enable all buttons in reversed order, displayed order should not change.
+ for (let config of [...configs].reverse()) {
+ let addon = await AddonManager.getAddonByID(config.extension.id);
+ await addon.enable();
+ }
+ test_buttons(configs, window, toolbars);
+
+ // Re-install all extensions in reversed order, displayed order should not change.
+ for (let config of [...configs].reverse()) {
+ config.extension2 = ExtensionTestUtils.loadExtension(config.extensionData);
+ await config.extension2.startup();
+ }
+ test_buttons(configs, window, toolbars);
+
+ // Remove all extensions.
+ for (let config of [...configs].reverse()) {
+ await config.extension.unload();
+ await config.extension2.unload();
+ }
+ test_buttons([], window, toolbars);
+}
+
+/**
+ * Helper method to switch to a cards view with vertical layout.
+ */
+async function ensure_cards_view() {
+ const { threadTree, threadPane } =
+ document.getElementById("tabmail").currentAbout3Pane;
+
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 2);
+ Services.xulStore.setValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view",
+ "cards"
+ );
+ threadPane.updateThreadView("cards");
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-card",
+ "The tree view switched to a cards layout"
+ );
+}
+
+/**
+ * Helper method to switch to a table view with classic layout.
+ */
+async function ensure_table_view() {
+ const { threadTree, threadPane } =
+ document.getElementById("tabmail").currentAbout3Pane;
+
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 0);
+ Services.xulStore.setValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view",
+ "table"
+ );
+ threadPane.updateThreadView("table");
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-row",
+ "The tree view switched to a table layout"
+ );
+}
diff --git a/comm/mail/components/extensions/test/browser/head_menus.js b/comm/mail/components/extensions/test/browser/head_menus.js
new file mode 100644
index 0000000000..346c4ca044
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/head_menus.js
@@ -0,0 +1,733 @@
+/* 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/. */
+
+/* globals synthesizeMouseAtCenterAndRetry, awaitBrowserLoaded */
+
+"use strict";
+
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+const treeClick = mailTestUtils.treeClick.bind(null, EventUtils, window);
+
+var URL_BASE =
+ "http://mochi.test:8888/browser/comm/mail/components/extensions/test/browser/data";
+
+/**
+ * Left-click on something and wait for the context menu to appear.
+ * For elements in the parent process only.
+ *
+ * @param {Element} menu - The <menu> that should appear.
+ * @param {Element} element - The element to be clicked on.
+ * @returns {Promise} A promise that resolves when the menu appears.
+ */
+function leftClick(menu, element) {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(element, {}, element.ownerGlobal);
+ return shownPromise;
+}
+/**
+ * Right-click on something and wait for the context menu to appear.
+ * For elements in the parent process only.
+ *
+ * @param {Element} menu - The <menu> that should appear.
+ * @param {Element} element - The element to be clicked on.
+ * @returns {Promise} A promise that resolves when the menu appears.
+ */
+function rightClick(menu, element) {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ element,
+ { type: "contextmenu" },
+ element.ownerGlobal
+ );
+ return shownPromise;
+}
+
+/**
+ * Right-click on something in a content document and wait for the context
+ * menu to appear.
+ *
+ * @param {Element} menu - The <menu> that should appear.
+ * @param {string} selector - CSS selector of the element to be clicked on.
+ * @param {Element} browser - <browser> containing the element.
+ * @returns {Promise} A promise that resolves when the menu appears.
+ */
+async function rightClickOnContent(menu, selector, browser) {
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ await synthesizeMouseAtCenterAndRetry(
+ selector,
+ { type: "contextmenu" },
+ browser
+ );
+ return shownPromise;
+}
+
+/**
+ * Check the parameters of a browser.onShown event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {Array?} expectedInfo.menuIds
+ * @param {Array?} expectedInfo.contexts
+ * @param {Array?} expectedInfo.attachments
+ * @param {object?} expectedInfo.displayedFolder
+ * @param {object?} expectedInfo.selectedFolder
+ * @param {Array?} expectedInfo.selectedMessages
+ * @param {RegExp?} expectedInfo.pageUrl
+ * @param {string?} expectedInfo.selectionText
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkShownEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onShown");
+ Assert.deepEqual(info.menuIds, expectedInfo.menuIds);
+ Assert.deepEqual(info.contexts, expectedInfo.contexts);
+
+ Assert.equal(
+ !!info.attachments,
+ !!expectedInfo.attachments,
+ "attachments in info"
+ );
+ if (expectedInfo.attachments) {
+ Assert.equal(info.attachments.length, expectedInfo.attachments.length);
+ for (let i = 0; i < expectedInfo.attachments.length; i++) {
+ Assert.equal(info.attachments[i].name, expectedInfo.attachments[i].name);
+ Assert.equal(info.attachments[i].size, expectedInfo.attachments[i].size);
+ }
+ }
+
+ for (let infoKey of ["displayedFolder", "selectedFolder"]) {
+ Assert.equal(
+ !!info[infoKey],
+ !!expectedInfo[infoKey],
+ `${infoKey} in info`
+ );
+ if (expectedInfo[infoKey]) {
+ Assert.equal(info[infoKey].accountId, expectedInfo[infoKey].accountId);
+ Assert.equal(info[infoKey].path, expectedInfo[infoKey].path);
+ Assert.ok(Array.isArray(info[infoKey].subFolders));
+ }
+ }
+
+ Assert.equal(
+ !!info.selectedMessages,
+ !!expectedInfo.selectedMessages,
+ "selectedMessages in info"
+ );
+ if (expectedInfo.selectedMessages) {
+ Assert.equal(info.selectedMessages.id, null);
+ Assert.equal(
+ info.selectedMessages.messages.length,
+ expectedInfo.selectedMessages.messages.length
+ );
+ for (let i = 0; i < expectedInfo.selectedMessages.messages.length; i++) {
+ Assert.equal(
+ info.selectedMessages.messages[i].subject,
+ expectedInfo.selectedMessages.messages[i].subject
+ );
+ }
+ }
+
+ Assert.equal(!!info.pageUrl, !!expectedInfo.pageUrl, "pageUrl in info");
+ if (expectedInfo.pageUrl) {
+ if (typeof expectedInfo.pageUrl == "string") {
+ Assert.equal(info.pageUrl, expectedInfo.pageUrl);
+ } else {
+ Assert.ok(info.pageUrl.match(expectedInfo.pageUrl));
+ }
+ }
+
+ Assert.equal(
+ !!info.selectionText,
+ !!expectedInfo.selectionText,
+ "selectionText in info"
+ );
+ if (expectedInfo.selectionText) {
+ Assert.equal(info.selectionText, expectedInfo.selectionText);
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+/**
+ * Check the parameters of a browser.onClicked event was fired.
+ *
+ * @see mail/components/extensions/schemas/menus.json
+ *
+ * @param extension
+ * @param {object} expectedInfo
+ * @param {string?} expectedInfo.selectionText
+ * @param {string?} expectedInfo.linkText
+ * @param {RegExp?} expectedInfo.pageUrl
+ * @param {RegExp?} expectedInfo.linkUrl
+ * @param {RegExp?} expectedInfo.srcUrl
+ * @param {object} expectedTab
+ * @param {boolean} expectedTab.active
+ * @param {integer} expectedTab.index
+ * @param {boolean} expectedTab.mailTab
+ */
+async function checkClickedEvent(extension, expectedInfo, expectedTab) {
+ let [info, tab] = await extension.awaitMessage("onClicked");
+
+ Assert.equal(info.selectionText, expectedInfo.selectionText, "selectionText");
+ Assert.equal(info.linkText, expectedInfo.linkText, "linkText");
+ if (expectedInfo.menuItemId) {
+ Assert.equal(info.menuItemId, expectedInfo.menuItemId, "menuItemId");
+ }
+
+ for (let infoKey of ["pageUrl", "linkUrl", "srcUrl"]) {
+ Assert.equal(
+ !!info[infoKey],
+ !!expectedInfo[infoKey],
+ `${infoKey} in info`
+ );
+ if (expectedInfo[infoKey]) {
+ if (typeof expectedInfo[infoKey] == "string") {
+ Assert.equal(info[infoKey], expectedInfo[infoKey]);
+ } else {
+ Assert.ok(info[infoKey].match(expectedInfo[infoKey]));
+ }
+ }
+ }
+
+ Assert.equal(tab.active, expectedTab.active, "tab is active");
+ Assert.equal(tab.index, expectedTab.index, "tab index");
+ Assert.equal(tab.mailTab, expectedTab.mailTab, "tab is mailTab");
+}
+
+async function getMenuExtension(manifest) {
+ let details = {
+ files: {
+ "background.js": async () => {
+ let contexts = [
+ "audio",
+ "compose_action",
+ "compose_action_menu",
+ "message_display_action",
+ "message_display_action_menu",
+ "editable",
+ "frame",
+ "image",
+ "link",
+ "page",
+ "password",
+ "selection",
+ "tab",
+ "video",
+ "message_list",
+ "folder_pane",
+ "compose_attachments",
+ "compose_body",
+ "tools_menu",
+ ];
+ if (browser.runtime.getManifest().manifest_version > 2) {
+ contexts.push("action", "action_menu");
+ } else {
+ contexts.push("browser_action", "browser_action_menu");
+ }
+
+ for (let context of contexts) {
+ browser.menus.create({
+ id: context,
+ title: context,
+ contexts: [context],
+ });
+ }
+
+ browser.menus.onShown.addListener((...args) => {
+ browser.test.sendMessage("onShown", args);
+ });
+
+ browser.menus.onClicked.addListener((...args) => {
+ browser.test.sendMessage("onClicked", args);
+ });
+ browser.test.sendMessage("menus-created");
+ },
+ },
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "menus@mochi.test",
+ },
+ },
+ background: { scripts: ["background.js"] },
+ ...manifest,
+ },
+ useAddonManager: "temporary",
+ };
+
+ if (!details.manifest.permissions) {
+ details.manifest.permissions = [];
+ }
+ details.manifest.permissions.push("menus");
+ console.log(JSON.stringify(details, 2));
+ let extension = ExtensionTestUtils.loadExtension(details);
+ if (details.manifest.host_permissions) {
+ // MV3 has to manually grant the requested permission.
+ await ExtensionPermissions.add("menus@mochi.test", {
+ permissions: [],
+ origins: details.manifest.host_permissions,
+ });
+ }
+ return extension;
+}
+
+async function subtest_content(
+ extension,
+ extensionHasPermission,
+ browser,
+ pageUrl,
+ tab
+) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+
+ let menuId = browser.getAttribute("context");
+ let ownerDocument;
+ if (browser.ownerGlobal.parent.location.href == "about:3pane") {
+ ownerDocument = browser.ownerGlobal.parent.document;
+ } else if (menuId == "browserContext") {
+ ownerDocument = browser.ownerGlobal.top.document;
+ } else {
+ ownerDocument = browser.ownerDocument;
+ }
+ let menu = ownerDocument.getElementById(menuId);
+
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser);
+
+ info("Test a part of the page with no content.");
+
+ await rightClickOnContent(menu, "body", browser);
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_page"));
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["page"],
+ contexts: ["page", "all"],
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ },
+ tab
+ );
+
+ info("Test selection.");
+
+ await SpecialPowers.spawn(browser, [], () => {
+ let text = content.document.querySelector("p");
+ content.getSelection().selectAllChildren(text);
+ });
+ await rightClickOnContent(menu, "p", browser);
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_selection"));
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ selectionText: extensionHasPermission ? "This is text." : undefined,
+ menuIds: ["selection"],
+ contexts: ["selection", "all"],
+ },
+ tab
+ );
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ selectionText: "This is text.",
+ },
+ tab
+ );
+ menu.activateItem(
+ menu.querySelector("#menus_mochi_test-menuitem-_selection")
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.
+
+ info("Test link.");
+
+ await rightClickOnContent(menu, "a", browser);
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_link"));
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ menuIds: ["link"],
+ contexts: ["link", "all"],
+ },
+ tab
+ );
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ linkUrl: "http://mochi.test:8888/",
+ linkText: "This is a link with text.",
+ },
+ tab
+ );
+ menu.activateItem(menu.querySelector("#menus_mochi_test-menuitem-_link"));
+ await clickedPromise;
+ await hiddenPromise;
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+
+ info("Test image.");
+
+ await rightClickOnContent(menu, "img", browser);
+ Assert.ok(menu.querySelector("#menus_mochi_test-menuitem-_image"));
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ menuIds: ["image"],
+ contexts: ["image", "all"],
+ },
+ tab
+ );
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ srcUrl: `${URL_BASE}/tb-logo.png`,
+ },
+ tab
+ );
+ menu.activateItem(menu.querySelector("#menus_mochi_test-menuitem-_image"));
+ await clickedPromise;
+ await hiddenPromise;
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+}
+
+async function openExtensionSubMenu(menu) {
+ // The extension submenu ends with a number, which increases over time, but it
+ // does not have a underscore.
+ let submenu;
+ for (let item of menu.querySelectorAll("[id^=menus_mochi_test-menuitem-]")) {
+ if (!item.id.includes("-_")) {
+ submenu = item;
+ break;
+ }
+ }
+ Assert.ok(submenu, `Found submenu: ${submenu.id}`);
+
+ // Open submenu.
+ let submenuPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ submenu.openMenu(true);
+ await submenuPromise;
+
+ return submenu;
+}
+
+async function subtest_compose_body(
+ extension,
+ extensionHasPermission,
+ browser,
+ pageUrl,
+ tab
+) {
+ await awaitBrowserLoaded(browser, url => url != "about:blank");
+
+ let ownerDocument = browser.ownerDocument;
+ let menu = ownerDocument.getElementById(browser.getAttribute("context"));
+
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser);
+
+ info("Test a part of the page with no content.");
+ {
+ await rightClickOnContent(menu, "body", browser);
+ Assert.ok(menu.querySelector(`#menus_mochi_test-menuitem-_compose_body`));
+ Assert.ok(menu.querySelector(`#menus_mochi_test-menuitem-_editable`));
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hiddenPromise;
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: ["editable", "compose_body"],
+ contexts: ["editable", "compose_body", "all"],
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ },
+ tab
+ );
+ }
+
+ info("Test selection.");
+ {
+ await SpecialPowers.spawn(browser, [], () => {
+ let text = content.document.querySelector("p");
+ content.getSelection().selectAllChildren(text);
+ });
+
+ await rightClickOnContent(menu, "p", browser);
+ let submenu = await openExtensionSubMenu(menu);
+
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ selectionText: extensionHasPermission ? "This is text." : undefined,
+ menuIds: ["editable", "selection", "compose_body"],
+ contexts: ["editable", "selection", "compose_body", "all"],
+ },
+ tab
+ );
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_selection"));
+ Assert.ok(
+ submenu.querySelector("#menus_mochi_test-menuitem-_compose_body")
+ );
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_editable"));
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(submenu, "popuphidden");
+ let clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ selectionText: "This is text.",
+ },
+ tab
+ );
+ menu.activateItem(
+ submenu.querySelector("#menus_mochi_test-menuitem-_selection")
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.
+ }
+
+ info("Test link.");
+ {
+ await rightClickOnContent(menu, "a", browser);
+ let submenu = await openExtensionSubMenu(menu);
+
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ menuIds: ["editable", "link", "compose_body"],
+ contexts: ["editable", "link", "compose_body", "all"],
+ },
+ tab
+ );
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_link"));
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_editable"));
+ Assert.ok(
+ submenu.querySelector("#menus_mochi_test-menuitem-_compose_body")
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(submenu, "popuphidden");
+ let clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ linkUrl: "http://mochi.test:8888/",
+ linkText: "This is a link with text.",
+ },
+ tab
+ );
+ menu.activateItem(
+ submenu.querySelector("#menus_mochi_test-menuitem-_link")
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.
+ }
+
+ info("Test image.");
+ {
+ await rightClickOnContent(menu, "img", browser);
+ let submenu = await openExtensionSubMenu(menu);
+
+ await checkShownEvent(
+ extension,
+ {
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ menuIds: ["editable", "image", "compose_body"],
+ contexts: ["editable", "image", "compose_body", "all"],
+ },
+ tab
+ );
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_image"));
+ Assert.ok(submenu.querySelector("#menus_mochi_test-menuitem-_editable"));
+ Assert.ok(
+ submenu.querySelector("#menus_mochi_test-menuitem-_compose_body")
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ srcUrl: `${URL_BASE}/tb-logo.png`,
+ },
+ tab
+ );
+ menu.activateItem(
+ submenu.querySelector("#menus_mochi_test-menuitem-_image")
+ );
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+ await synthesizeMouseAtCenterAndRetry("body", {}, browser); // Select nothing.
+ }
+}
+
+// Test UI elements which have been made accessible for the menus API.
+// Assumed to be run after subtest_content, so we know everything has finished
+// loading.
+async function subtest_element(
+ extension,
+ extensionHasPermission,
+ element,
+ pageUrl,
+ tab
+) {
+ for (let selectedTest of [false, true]) {
+ element.focus();
+ if (selectedTest) {
+ element.value = "This is selected text.";
+ element.select();
+ } else {
+ element.value = "";
+ }
+
+ let event = await rightClick(element.ownerGlobal, element);
+ let menu = event.target;
+ let trigger = menu.triggerNode;
+ let menuitem = menu.querySelector("#menus_mochi_test-menuitem-_editable");
+ Assert.equal(
+ element.id,
+ trigger.id,
+ "Contextmenu of correct element has been triggered."
+ );
+ Assert.equal(
+ menuitem.id,
+ "menus_mochi_test-menuitem-_editable",
+ "Contextmenu includes menu."
+ );
+
+ await checkShownEvent(
+ extension,
+ {
+ menuIds: selectedTest ? ["editable", "selection"] : ["editable"],
+ contexts: selectedTest
+ ? ["editable", "selection", "all"]
+ : ["editable", "all"],
+ pageUrl: extensionHasPermission ? pageUrl : undefined,
+ selectionText:
+ extensionHasPermission && selectedTest
+ ? "This is selected text."
+ : undefined,
+ },
+ tab
+ );
+
+ // With text being selected, there will be two "context" entries in an
+ // extension submenu. Open the submenu.
+ let submenu = null;
+ if (selectedTest) {
+ for (let foundMenu of menu.querySelectorAll(
+ "[id^='menus_mochi_test-menuitem-']"
+ )) {
+ if (!foundMenu.id.startsWith("menus_mochi_test-menuitem-_")) {
+ submenu = foundMenu;
+ }
+ }
+ Assert.ok(submenu, "Submenu found.");
+ let submenuPromise = BrowserTestUtils.waitForEvent(
+ element.ownerGlobal,
+ "popupshown"
+ );
+ submenu.openMenu(true);
+ await submenuPromise;
+ }
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ element.ownerGlobal,
+ "popuphidden"
+ );
+ let clickedPromise = checkClickedEvent(
+ extension,
+ {
+ pageUrl,
+ selectionText: selectedTest ? "This is selected text." : undefined,
+ },
+ tab
+ );
+ if (submenu) {
+ submenu.menupopup.activateItem(menuitem);
+ } else {
+ menu.activateItem(menuitem);
+ }
+ await clickedPromise;
+ await hiddenPromise;
+
+ // Sometimes, the popup will open then instantly disappear. It seems to
+ // still be hiding after the previous appearance. If we wait a little bit,
+ // this doesn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+ }
+}
diff --git a/comm/mail/components/extensions/test/browser/messages/attachedMessageSample.eml b/comm/mail/components/extensions/test/browser/messages/attachedMessageSample.eml
new file mode 100644
index 0000000000..0575e8542c
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/messages/attachedMessageSample.eml
@@ -0,0 +1,186 @@
+Message-ID: <sample.eml@mime.sample>
+Date: Fri, 20 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>
+Cc: Robin <damian@wayne-enterprises.com>
+From: Batman <bruce@wayne-enterprises.com>
+Subject: Attached message with attachments
+Mime-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="------------49CVLb1N6p6Spdka4qq7Naeg"
+
+This is a multi-part message in MIME format.
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ </head>
+ <body>
+ <p>This message has one normal attachment and one email attachment,
+ which itself has 3 attachments.<br>
+ </p>
+ </body>
+</html>
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: message/rfc822; charset=UTF-8; name="sample02.eml"
+Content-Disposition: attachment; filename="sample02.eml"
+Content-Transfer-Encoding: 7bit
+
+Message-ID: <sample-attached.eml@mime.sample>
+From: Superman <clark.kent@dailyplanet.com>
+To: =?iso-8859-1?Q?Heinz_M=FCller?= <mueller@examples.com>
+Cc: Jimmy <jimmy.Olsen@dailyplanet.com>
+Subject: Test message
+Date: Wed, 17 May 2000 19:32:47 -0400
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="----=_NextPart_000_0002_01BFC036.AE309650"
+X-Priority: 3 (Normal)
+X-MSMail-Priority: Normal
+X-Mailer: Microsoft Outlook IMO, Build 9.0.2416 (9.0.2910.0)
+Importance: Normal
+X-MimeOLE: Produced By Microsoft MimeOLE V5.00.2314.1300
+
+This is a multi-part message in MIME format.
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: text/plain;
+ charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+
+Die Hasen und die Fr=F6sche=20
+=20
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="blueball1.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="blueball2.png"
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAgAABAAABgAAAAA
+CCkAEEIAEEoACDEAEFIIIXMIKXsIKYQIIWsAGFoACDkIIWMQOZwYQqUYQq0YQrUQOaUQMZQAGFIQ
+MYwpUrU5Y8Y5Y84pWs4YSs4YQs4YQr1Ca8Z7nNacvd6Mtd5jlOcxa94hUt4YStYYQsYQMaUAACHO
+5+/n7++cxu9ShO8pWucQOa1Ke86tzt6lzu9ajO8QMZxahNat1ufO7++Mve9Ke+8YOaUYSsaMvee1
+5++Uve8AAClajOdzpe9rnO8IKYwxY+8pWu8IIXsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAADBMg1VAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAGISURBVHicddJtV5swGAbgEk6AJhBSk4bMCUynBSLaqovbrG/bfPn/vyh70lbssceb
+L5xznTsh5BmNhgQoRChwo50EOIohUYLDj4zHhKYQkrEoQdvock4ne0IKMVUpKZLQDeqSTIsv+18P
+yqqWUw2IBsRM7307PPp+fDJrWtnpLDJvewYxnewfnvanZ+fzpmwXijC8KbqEa3Fx2ff91Y95U9XC
+UpaDeQwiMpHXP/v+1++bWVPWQoGFawtjury9vru/f/C1Vi7ezT0WWpQHf/7+u/G71aLThK/MjRxm
+T6KdzZ9fGk9yatMsTgZLl3XVgFRAC6spj/13enssqJVtWVa3NdBSacL8+VZmYqKmdd1CSYoOiMOS
+GwtzlqqlFFIuOqv0a1ZEZrUkWICLLFW266y1KvWE1zV/iDAH1EopnVLCiygZCIomH3NCKX0lnI+B
+1iuuzCGTxwXjnDO4d7NpbX42YJJHkBwmAm2TxwAZg40J3+Xtbv1rgOAZwG0NxW62p+lT+Yi747sD
+/wEUVMzYmWkOvwAAACV0RVh0Q29tbWVudABjbGlwMmdpZiB2LjAuNiBieSBZdmVzIFBpZ3VldDZz
+O7wAAAAASUVORK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="greenball.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAAAEAAAGAAAIQAA
+CAAAMQAAQgAAUgAAWgAASgAIYwAIcwAIewAQjAAIawAAOQAAYwAQlAAQnAAhpQAQpQAhrQBCvRhj
+xjFjxjlSxiEpzgAYvQAQrQAYrQAhvQCU1mOt1nuE1lJK3hgh1gAYxgAYtQAAKQBCzhDO55Te563G
+55SU52NS5yEh3gAYzgBS3iGc52vW75y974yE71JC7xCt73ul3nNa7ykh5wAY1gAx5wBS7yFr7zlK
+7xgp5wAp7wAx7wAIhAAQtQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAp1fnZAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAFtSURBVHicddJtV8IgFAdwD2zIgMEE1+NcqdsoK+m5tCyz7/+ZiLmHsyzvq53zO/cy
++N9ery1bVe9PWQA9z4MQ+H8Yoj7GASZ95IHfaBGmLOSchyIgyOu22mgQSjUcDuNYcoGjLiLK1cHh
+0fHJaTKKOcMItgYxT89OzsfjyTTLC8UF0c2ZNmKquJhczq6ub+YmSVUYRF59GeDastu7+9nD41Nm
+kiJ2jc2J3kAWZ9Pr55fH18XSmRuKUTXUaqHy7O19tfr4NFle/w3YDrWRUIlZrL/W86XJkyJVG9Ea
+EjIx2XyZmZJGioeUaL+2AY8TY8omR6nkLKhu70zjUKVJXsp3quS2DVSJWNh3zzJKCyexI0ZxBP3a
+fE0ElyqOlZJyw8r3BE2SFiJCyxA434SCkg65RhdeQBljQtCg39LWrA90RDDG1EWrYUO23hMANUKR
+Rl61E529cR++D2G5LK002dr/qrcfu9u0V3bxn/XdhR/NYeeN0ggsLAAAACV0RVh0Q29tbWVudABj
+bGlwMmdpZiB2LjAuNiBieSBZdmVzIFBpZ3VldDZzO7wAAAAASUVORK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="redball.png"
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAABAAALAAAVAAAa
+AAAXAAARAAAKAAADAAAcAAAyAABEAABNAABIAAA9AAAjAAAWAAAmAABhAAB7AACGAACHAAB9AAB0
+AABgAAA5AAAUAAAGAAAnAABLAABvAACQAAClAAC7AAC/AACrAAChAACMAABzAABbAAAuAAAIAABM
+AAB3AACZAAC0GRnKODjVPT3bKSndBQW4AACoAAB5AAAxAAAYAAAEAABFAACaAAC7JCTRYWHfhITm
+f3/mVlbqHx/SAAC5AACjAABdAABCAAAoAAAJAABnAAC6Dw/QVFTek5PlrKzpmZntZWXvJSXXAADB
+AACxAACcAABtAABTAAA2AAAbAAAFAABKAACBAADLICDdZ2fonJzrpqbtiorvUVHvFBTRAADDAAC2
+AAB4AABeAABAAAAiAABXAACSAADCAADaGxvoVVXseHjveHjvV1fvJibhAADOAAC3AACnAACVAABH
+AAArAAAPAACdAADFAADhBQXrKCjvPDzvNTXvGxvjAADQAADJAAC1AACXAACEAABsAABPAAASAAAC
+AABiAADpAADvAgLnAADYAADLAAC6AACwAABwAAATAAAkAABYAADIAADTAADNAACzAACDAABuAAAe
+AAB+AADAAACkAACNAAB/AABpAABQAAAwAACRAACpAAC8AACqAACbAABlAABJAAAqAAAOAAA0AACs
+AACvAACtAACmAACJAAB6AABrAABaAAA+AAApAABqAACCAACfAACeAACWAACPAAB8AAAZAAAHAABV
+AACOAACKAAA4AAAQAAA/AAByAACAAABcAAA3AAAsAABmAABDAABWAAAgAAAzAAA8AAA6AAAfAAAM
+AAAdAAANAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAD8LtlFAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAIISURBVHicY2CAg/8QwIABmJhZWFnZ2Dk4MaU5uLh5eHn5+LkFBDlQJf8zC/EIi4iK
+iUtI8koJScsgyf5nlpWTV1BUUlZRVVPX4NFk1UJIyghp6+jq6RsYGhmbKJgK85mZW8Dk/rNaSlhZ
+29ja2Ts4Ojkr6Li4urFDNf53N/Ow8vTy9vH18w8IDAoWDQkNC4+ASP5ni4wKio6JjYtPSExKTnFW
+SE1LF4A69n9GZlZ2Tm5efkFhUXFySWlZlEd5RSVY7j+TkGRVdU1tXX1DY1Ozcktpa1t7h2YnOAj+
+d7l1tyo79vT29SdNSJ44SbFVdHIo9xSIHNPUaWqTpifNSJrZnK00S0U1a/acUG5piNz/uXLzVJ2q
+m6dXz584S2WB1cJFi5cshZr539xVftnyFKUVTi2TVjqvyhJLXb1m7TqoHPt6F/HW0g0bN63crGqV
+tWXrtu07BJihcsw71+zanRW8Z89eq337RQ/Ip60xO3gIElX/LbikDm8T36KwbNmRo7O3zpHkPSZw
+HBqL//8flz1x2OOkyKJTi7aqbzutfUZI2gIuF8F2lr/D5dw2+fZdwpl8YVOlI+CJ4/9/joOyYed5
+QzMvhGqnm2V0WiClm///D0lfXHtJ6vLlK9w7rx7vQk5SQJbFtSms1y9evXid7QZacgOxmSxktNzd
+tSwwU+J/VICaCPFIYU3XAJhIOtjf5sfyAAAAJXRFWHRDb21tZW50AGNsaXAyZ2lmIHYuMC42IGJ5
+IFl2ZXMgUGlndWV0NnM7vAAAAABJRU5ErkJggg==
+
+------=_NextPart_000_0002_01BFC036.AE309650--
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: image/png;
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="yellowball.png"
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAgAABAAABgA
+AAAACCkAEEIAEEoACDEAEFIIIXMIKXsIKYQIIWsAGFoACDkIIWMQOZwYQqUYQq0YQrUQOaUQ
+MZQAGFIQMYwpUrU5Y8Y5Y84pWs4YSs4YQs4YQr1Ca8Z7nNacvd6Mtd5jlOcxa94hUt4YStYY
+QsYQMaUAACHO5+/n7++cxu9ShO8pWucQOa1Ke86tzt6lzu9ajO8QMZxahNat1ufO7++Mve9K
+e+8YOaUYSsaMvee15++Uve8AAClajOdzpe9rnO8IKYwxY+8pWu8IIXsAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADB
+Mg1VAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAuMT1evmgAAAGI
+SURBVHicddJtV5swGAbgEk6AJhBSk4bMCUynBSLaqovbrG/bfPn/vyh70lbsscebL5xznTsh
+5BmNhgQoRChwo50EOIohUYLDj4zHhKYQkrEoQdvock4ne0IKMVUpKZLQDeqSTIsv+18PyqqW
+Uw2IBsRM7307PPp+fDJrWtnpLDJvewYxnewfnvanZ+fzpmwXijC8KbqEa3Fx2ff91Y95U9XC
+UpaDeQwiMpHXP/v+1++bWVPWQoGFawtjury9vru/f/C1Vi7ezT0WWpQHf/7+u/G71aLThK/M
+jRxmT6KdzZ9fGk9yatMsTgZLl3XVgFRAC6spj/13enssqJVtWVa3NdBSacL8+VZmYqKmdd1C
+SYoOiMOSGwtzlqqlFFIuOqv0a1ZEZrUkWICLLFW266y1KvWE1zV/iDAH1EopnVLCiygZCIom
+H3NCKX0lnI+B1iuuzCGTxwXjnDO4d7NpbX42YJJHkBwmAm2TxwAZg40J3+Xtbv1rgOAZwG0N
+xW62p+lT+Yi747sD/wEUVMzYmWkOvwAAACV0RVh0Q29tbWVudABjbGlwMmdpZiB2LjAuNiBi
+eSBZdmVzIFBpZ3VldDZzO7wAAAAASUVORK5CYII=
+
+--------------49CVLb1N6p6Spdka4qq7Naeg--
diff --git a/comm/mail/components/extensions/test/browser/messages/messageWithLink.eml b/comm/mail/components/extensions/test/browser/messages/messageWithLink.eml
new file mode 100644
index 0000000000..469a799f05
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/messages/messageWithLink.eml
@@ -0,0 +1,26 @@
+Message-ID: <sample.eml@mime.sample>
+Date: Fri, 20 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>
+Cc: Robin <damian@wayne-enterprises.com>
+From: Batman <bruce@wayne-enterprises.com>
+Subject: Message with a link
+Mime-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="------------49CVLb1N6p6Spdka4qq7Naeg"
+
+This is a multi-part message in MIME format.
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ </head>
+ <body>
+ <p>This is an interesting <a id="link" href="https://www.example.de/messageLink.html">link</a></p>
+ </body>
+</html>
+
+--------------49CVLb1N6p6Spdka4qq7Naeg--
diff --git a/comm/mail/components/extensions/test/browser/test_browserAction.js b/comm/mail/components/extensions/test/browser/test_browserAction.js
new file mode 100644
index 0000000000..209c701168
--- /dev/null
+++ b/comm/mail/components/extensions/test/browser/test_browserAction.js
@@ -0,0 +1,845 @@
+/* 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/. */
+
+let account;
+let messages;
+
+add_setup(async () => {
+ account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = rootFolder.subFolders;
+ createMessages(subFolders[0], 10);
+ messages = subFolders[0].messages;
+});
+
+// This test uses a command from the menus API to open the popup.
+add_task(async function test_popup_open_with_menu_command_mv2() {
+ info("3-pane tab");
+ let testConfig = {
+ actionType: "browser_action",
+ testType: "open-with-menu-command",
+ window,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ actionType: "browser_action",
+ testType: "open-with-menu-command",
+ default_windows: ["messageDisplay"],
+ window: messageWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ messageWindow.close();
+ }
+});
+
+add_task(async function test_popup_open_with_menu_command_mv3() {
+ info("3-pane tab");
+ let testConfig = {
+ manifest_version: 3,
+ actionType: "action",
+ testType: "open-with-menu-command",
+ window,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+
+ info("Message window");
+ {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let testConfig = {
+ manifest_version: 3,
+ actionType: "action",
+ testType: "open-with-menu-command",
+ default_windows: ["messageDisplay"],
+ window: messageWindow,
+ };
+
+ await run_popup_test({
+ ...testConfig,
+ });
+ await run_popup_test({
+ ...testConfig,
+ use_default_popup: true,
+ });
+ await run_popup_test({
+ ...testConfig,
+ disable_button: true,
+ });
+ messageWindow.close();
+ }
+});
+
+add_task(async function test_theme_icons() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_properties@mochi.test",
+ },
+ },
+ browser_action: {
+ default_title: "default",
+ default_icon: "default.png",
+ theme_icons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ },
+ },
+ });
+
+ let unifiedToolbarUpdate = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ await extension.startup();
+ await unifiedToolbarUpdate;
+ await TestUtils.waitForCondition(
+ () =>
+ document.querySelector(
+ `#unifiedToolbarContent [extension="browser_action_properties@mochi.test"]`
+ ),
+ "Button added to unified toolbar"
+ );
+
+ let uuid = extension.uuid;
+ let icon = document.querySelector(
+ `#unifiedToolbarContent [extension="browser_action_properties@mochi.test"] .button-icon`
+ );
+
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ Assert.equal(
+ window.getComputedStyle(icon).content,
+ `url("moz-extension://${uuid}/light.png")`,
+ `Dark theme should use light icon.`
+ );
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ Assert.equal(
+ window.getComputedStyle(icon).content,
+ `url("moz-extension://${uuid}/dark.png")`,
+ `Light theme should use dark icon.`
+ );
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ Assert.equal(
+ window.getComputedStyle(icon).content,
+ `url("moz-extension://${uuid}/default.png")`,
+ `Default theme should use default icon.`
+ );
+ await extension.unload();
+});
+
+add_task(async function test_theme_icons_messagewindow() {
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_properties@mochi.test",
+ },
+ },
+ browser_action: {
+ default_title: "default",
+ default_icon: "default.png",
+ default_windows: ["messageDisplay"],
+ theme_icons: [
+ {
+ dark: "dark.png",
+ light: "light.png",
+ size: 16,
+ },
+ ],
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let uuid = extension.uuid;
+ let button = messageWindow.document.getElementById(
+ "browser_action_properties_mochi_test-browserAction-toolbarbutton"
+ );
+
+ let dark_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-dark@mozilla.org"
+ );
+ await dark_theme.enable();
+ Assert.equal(
+ window.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/light.png")`,
+ `Dark theme should use light icon.`
+ );
+
+ let light_theme = await AddonManager.getAddonByID(
+ "thunderbird-compact-light@mozilla.org"
+ );
+ await light_theme.enable();
+ Assert.equal(
+ window.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/dark.png")`,
+ `Light theme should use dark icon.`
+ );
+
+ // Disabling a theme will enable the default theme.
+ await light_theme.disable();
+ Assert.equal(
+ window.getComputedStyle(button).listStyleImage,
+ `url("moz-extension://${uuid}/default.png")`,
+ `Default theme should use default icon.`
+ );
+
+ await extension.unload();
+ messageWindow.close();
+});
+
+add_task(async function test_button_order() {
+ info("3-pane tab");
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ toolbar: "unified-toolbar",
+ },
+ {
+ name: "addon2",
+ toolbar: "unified-toolbar",
+ },
+ {
+ name: "addon3",
+ toolbar: "unified-toolbar",
+ },
+ {
+ name: "addon4",
+ toolbar: "unified-toolbar",
+ },
+ ],
+ window,
+ "browser_action"
+ );
+
+ info("Message window");
+ let messageWindow = await openMessageInWindow(messages.getNext());
+ await run_action_button_order_test(
+ [
+ {
+ name: "addon1",
+ toolbar: "mail-bar3",
+ default_windows: ["messageDisplay"],
+ },
+ {
+ name: "addon2",
+ toolbar: "mail-bar3",
+ default_windows: ["messageDisplay"],
+ },
+ ],
+ messageWindow,
+ "browser_action"
+ );
+ messageWindow.close();
+});
+
+add_task(async function test_upgrade() {
+ // Add a browser_action, to make sure the currentSet has been initialized.
+ let extension1 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension1",
+ applications: { gecko: { id: "Extension1@mochi.test" } },
+ browser_action: {
+ default_title: "Extension1",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension1 ready");
+ },
+ });
+ await extension1.startup();
+ await extension1.awaitMessage("Extension1 ready");
+
+ // Add extension without a browser_action.
+ let extension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "1.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 ready");
+ },
+ });
+ await extension2.startup();
+ await extension2.awaitMessage("Extension2 ready");
+
+ // Update the extension, now including a browser_action.
+ let updatedExtension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 2,
+ version: "2.0",
+ name: "Extension2",
+ applications: { gecko: { id: "Extension2@mochi.test" } },
+ browser_action: {
+ default_title: "Extension2",
+ },
+ },
+ background() {
+ browser.test.sendMessage("Extension2 updated");
+ },
+ });
+ await updatedExtension2.startup();
+ await updatedExtension2.awaitMessage("Extension2 updated");
+
+ let button = document.querySelector(
+ `.unified-toolbar [extension="Extension2@mochi.test"]`
+ );
+
+ Assert.ok(button, "Button should exist");
+
+ await extension1.unload();
+ await extension2.unload();
+ await updatedExtension2.unload();
+});
+
+add_task(async function test_iconPath() {
+ // String values for the default_icon manifest entry have been tested in the
+ // theme_icons test already. Here we test imagePath objects for the manifest key
+ // and string values as well as objects for the setIcons() function.
+ let files = {
+ "background.js": async () => {
+ await window.sendMessage("checkState", "icon1.png");
+
+ await browser.browserAction.setIcon({ path: "icon2.png" });
+ await window.sendMessage("checkState", "icon2.png");
+
+ await browser.browserAction.setIcon({ path: { 16: "icon3.png" } });
+ await window.sendMessage("checkState", "icon3.png");
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action@mochi.test",
+ },
+ },
+ browser_action: {
+ default_title: "default",
+ default_icon: { 16: "icon1.png" },
+ },
+ background: { scripts: ["utils.js", "background.js"] },
+ },
+ });
+
+ extension.onMessage("checkState", async expected => {
+ let uuid = extension.uuid;
+ let icon = document.querySelector(
+ `.unified-toolbar [extension="browser_action@mochi.test"] .button-icon`
+ );
+
+ Assert.equal(
+ window.getComputedStyle(icon).content,
+ `url("moz-extension://${uuid}/${expected}")`,
+ `Icon path should be correct.`
+ );
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_allowedSpaces() {
+ let tabmail = document.getElementById("tabmail");
+ let unifiedToolbar = document.querySelector("unified-toolbar");
+
+ function buttonInUnifiedToolbar() {
+ let button = unifiedToolbar.querySelector(
+ '[item-id="ext-browser_action_spaces@mochi.test"]'
+ );
+ if (!button) {
+ return false;
+ }
+ return BrowserTestUtils.is_visible(button);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ allowed_spaces: ["calendar", "default"],
+ },
+ },
+ });
+
+ let mailSpace = window.gSpacesToolbar.spaces.find(
+ space => space.name == "mail"
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+
+ let unifiedToolbarUpdate = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+
+ await extension.startup();
+ await unifiedToolbarUpdate;
+
+ ok(
+ !buttonInUnifiedToolbar(),
+ "Button shouldn't be in the mail space toolbar"
+ );
+
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(
+ tabmail,
+ window.gSpacesToolbar.spaces.find(space => space.name == "calendar")
+ );
+ await toolbarMutation;
+
+ ok(
+ buttonInUnifiedToolbar(),
+ "Button should be in the calendar space toolbar"
+ );
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ tabmail.openTab("contentTab", { url: "about:blank" });
+ await toolbarMutation;
+
+ ok(buttonInUnifiedToolbar(), "Button should be in the default space toolbar");
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+ await toolbarMutation;
+
+ ok(
+ !buttonInUnifiedToolbar(),
+ "Button should be hidden again in the mail space toolbar"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_allowedInAllSpaces() {
+ let tabmail = document.getElementById("tabmail");
+ let unifiedToolbar = document.querySelector("unified-toolbar");
+
+ function buttonInUnifiedToolbar() {
+ let button = unifiedToolbar.querySelector(
+ '[item-id="ext-browser_action_all_spaces@mochi.test"]'
+ );
+ if (!button) {
+ return false;
+ }
+ return BrowserTestUtils.is_visible(button);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_all_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ allowed_spaces: [],
+ },
+ },
+ });
+
+ let mailSpace = window.gSpacesToolbar.spaces.find(
+ space => space.name == "mail"
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+
+ let unifiedToolbarUpdate = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+
+ await extension.startup();
+ await unifiedToolbarUpdate;
+
+ ok(buttonInUnifiedToolbar(), "Button should be in the mail space toolbar");
+
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(
+ tabmail,
+ window.gSpacesToolbar.spaces.find(space => space.name == "calendar")
+ );
+ await toolbarMutation;
+
+ ok(
+ buttonInUnifiedToolbar(),
+ "Button should be in the calendar space toolbar"
+ );
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ tabmail.openTab("contentTab", { url: "about:blank" });
+ await toolbarMutation;
+
+ ok(buttonInUnifiedToolbar(), "Button should be in the default space toolbar");
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+ await toolbarMutation;
+
+ ok(
+ buttonInUnifiedToolbar(),
+ "Button should still be in the mail space toolbar"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_allowedSpacesDefault() {
+ let tabmail = document.getElementById("tabmail");
+ let unifiedToolbar = document.querySelector("unified-toolbar");
+
+ function buttonInUnifiedToolbar() {
+ let button = unifiedToolbar.querySelector(
+ '[item-id="ext-browser_action_default_spaces@mochi.test"]'
+ );
+ if (!button) {
+ return false;
+ }
+ return BrowserTestUtils.is_visible(button);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_default_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ default_title: "Test Action",
+ },
+ },
+ });
+
+ let mailSpace = window.gSpacesToolbar.spaces.find(
+ space => space.name == "mail"
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+
+ let unifiedToolbarUpdate = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+
+ await extension.startup();
+ await unifiedToolbarUpdate;
+
+ ok(buttonInUnifiedToolbar(), "Button should be in the mail space toolbar");
+
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(
+ tabmail,
+ window.gSpacesToolbar.spaces.find(space => space.name == "calendar")
+ );
+ await toolbarMutation;
+
+ ok(
+ !buttonInUnifiedToolbar(),
+ "Button should not be in the calendar space toolbar"
+ );
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ tabmail.openTab("contentTab", { url: "about:blank" });
+ await toolbarMutation;
+
+ ok(
+ !buttonInUnifiedToolbar(),
+ "Button should not be in the default space toolbar"
+ );
+
+ tabmail.closeTab();
+ toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+ await toolbarMutation;
+
+ ok(
+ buttonInUnifiedToolbar(),
+ "Button should still be in the mail space toolbar again"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_update_allowedSpaces() {
+ let tabmail = document.getElementById("tabmail");
+ let unifiedToolbar = document.querySelector("unified-toolbar");
+
+ function buttonInUnifiedToolbar() {
+ let button = unifiedToolbar.querySelector(
+ '[item-id="ext-browser_action_spaces@mochi.test"]'
+ );
+ if (!button) {
+ return false;
+ }
+ return BrowserTestUtils.is_visible(button);
+ }
+
+ async function closeSpaceTab() {
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ tabmail.closeTab();
+ await toolbarMutation;
+ }
+
+ async function ensureActiveMailSpace() {
+ let mailSpace = window.gSpacesToolbar.spaces.find(
+ space => space.name == "mail"
+ );
+ if (window.gSpacesToolbar.currentSpace != mailSpace) {
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(tabmail, mailSpace);
+ await toolbarMutation;
+ }
+ }
+
+ async function checkUnifiedToolbar(extension, expectedSpaces) {
+ // Make sure the mail space is open.
+ await ensureActiveMailSpace();
+
+ let unifiedToolbarUpdate = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ await extension.startup();
+ await unifiedToolbarUpdate;
+
+ // Test mail space.
+ {
+ let expected = expectedSpaces.includes("mail");
+ Assert.equal(
+ buttonInUnifiedToolbar(),
+ expected,
+ `Button should${expected ? " " : " not "}be in the mail space toolbar`
+ );
+ }
+
+ // Test calendar space.
+ {
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ window.gSpacesToolbar.openSpace(
+ tabmail,
+ window.gSpacesToolbar.spaces.find(space => space.name == "calendar")
+ );
+ await toolbarMutation;
+
+ let expected = expectedSpaces.includes("calendar");
+ Assert.equal(
+ buttonInUnifiedToolbar(),
+ expected,
+ `Button should${
+ expected ? " " : " not "
+ }be in the calendar space toolbar`
+ );
+ await closeSpaceTab();
+ }
+
+ // Test default space.
+ {
+ let toolbarMutation = BrowserTestUtils.waitForMutationCondition(
+ unifiedToolbar,
+ { childList: true },
+ () => true
+ );
+ tabmail.openTab("contentTab", { url: "about:blank" });
+ await toolbarMutation;
+
+ let expected = expectedSpaces.includes("default");
+ Assert.equal(
+ buttonInUnifiedToolbar(),
+ expected,
+ `Button should${
+ expected ? " " : " not "
+ }be in the default space toolbar`
+ );
+ await closeSpaceTab();
+ }
+
+ // Test mail space again.
+ {
+ await ensureActiveMailSpace();
+ let expected = expectedSpaces.includes("mail");
+ Assert.equal(
+ buttonInUnifiedToolbar(),
+ expected,
+ `Button should${expected ? " " : " not "}be in the mail space toolbar`
+ );
+ }
+ }
+
+ // Install extension and test that the button is shown in the default space and
+ // in the calendar space.
+ let extension1 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ allowed_spaces: ["calendar", "default"],
+ },
+ },
+ });
+ await checkUnifiedToolbar(extension1, ["calendar", "default"]);
+
+ // Update extension by installing a newer version on top. Verify that it is now
+ // also shown in the mail space.
+ let extension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ allowed_spaces: ["mail", "calendar", "default"],
+ },
+ },
+ });
+ await checkUnifiedToolbar(extension2, ["mail", "calendar", "default"]);
+
+ // Update extension by installing a newer version on top. Verify that it is now
+ // no longer shown in the calendar space.
+ let extension3 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browser_action_spaces@mochi.test",
+ },
+ },
+ browser_action: {
+ allowed_spaces: ["mail", "default"],
+ },
+ },
+ });
+ await checkUnifiedToolbar(extension3, ["mail", "default"]);
+
+ await extension1.unload();
+ await extension2.unload();
+ await extension3.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/.eslintrc.js b/comm/mail/components/extensions/test/xpcshell/.eslintrc.js
new file mode 100644
index 0000000000..60d784b53c
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/.eslintrc.js
@@ -0,0 +1,13 @@
+"use strict";
+
+module.exports = {
+ env: {
+ // The tests in this folder are testing based on WebExtensions, so lets
+ // just define the webextensions environment here.
+ webextensions: true,
+ // Many parts of WebExtensions test definitions (e.g. content scripts) also
+ // interact with the browser environment, so define that here as we don't
+ // have an easy way to handle per-function/scope usage yet.
+ browser: true,
+ },
+};
diff --git a/comm/mail/components/extensions/test/xpcshell/data/utils.js b/comm/mail/components/extensions/test/xpcshell/data/utils.js
new file mode 100644
index 0000000000..9025982e33
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/data/utils.js
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Functions for extensions to use, so that we avoid repeating ourselves.
+
+function assertDeepEqual(
+ expected,
+ actual,
+ description = "Values should be equal",
+ options = {}
+) {
+ let ok;
+ let strict = !!options?.strict;
+ try {
+ ok = assertDeepEqualNested(expected, actual, strict);
+ } catch (e) {
+ ok = false;
+ }
+ if (!ok) {
+ browser.test.fail(
+ `Deep equal test. \n Expected value: ${JSON.stringify(
+ expected
+ )} \n Actual value: ${JSON.stringify(actual)},
+ ${description}`
+ );
+ }
+}
+
+function assertDeepEqualNested(expected, actual, strict) {
+ if (expected === null) {
+ browser.test.assertTrue(actual === null);
+ return actual === null;
+ }
+
+ if (expected === undefined) {
+ browser.test.assertTrue(actual === undefined);
+ return actual === undefined;
+ }
+
+ if (["boolean", "number", "string"].includes(typeof expected)) {
+ browser.test.assertEq(typeof expected, typeof actual);
+ browser.test.assertEq(expected, actual);
+ return typeof expected == typeof actual && expected == actual;
+ }
+
+ if (Array.isArray(expected)) {
+ browser.test.assertTrue(Array.isArray(actual));
+ browser.test.assertEq(expected.length, actual.length);
+ let ok = 0;
+ let all = 0;
+ for (let i = 0; i < expected.length; i++) {
+ all++;
+ if (assertDeepEqualNested(expected[i], actual[i], strict)) {
+ ok++;
+ }
+ }
+ return (
+ Array.isArray(actual) && expected.length == actual.length && all == ok
+ );
+ }
+
+ let expectedKeys = Object.keys(expected);
+ let actualKeys = Object.keys(actual);
+ // Ignore any extra keys on the actual object in non-strict mode (default).
+ let lengthOk = strict
+ ? expectedKeys.length == actualKeys.length
+ : expectedKeys.length <= actualKeys.length;
+ browser.test.assertTrue(lengthOk);
+
+ let ok = 0;
+ let all = 0;
+ for (let key of expectedKeys) {
+ all++;
+ browser.test.assertTrue(actualKeys.includes(key), `Key ${key} exists`);
+ if (assertDeepEqualNested(expected[key], actual[key], strict)) {
+ ok++;
+ }
+ }
+ return all == ok && lengthOk;
+}
+
+function waitForMessage() {
+ return waitForEvent("test.onMessage");
+}
+
+function waitForEvent(eventName) {
+ let [namespace, name] = eventName.split(".");
+ return new Promise(resolve => {
+ browser[namespace][name].addListener(function listener(...args) {
+ browser[namespace][name].removeListener(listener);
+ resolve(args);
+ });
+ });
+}
+
+async function waitForCondition(condition, msg, interval = 100, maxTries = 50) {
+ let conditionPassed = false;
+ let tries = 0;
+ for (; tries < maxTries && !conditionPassed; tries++) {
+ await new Promise(resolve =>
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ window.setTimeout(resolve, interval)
+ );
+ try {
+ conditionPassed = await condition();
+ } catch (e) {
+ throw Error(`${msg} - threw exception: ${e}`);
+ }
+ }
+ if (conditionPassed) {
+ browser.test.succeed(
+ `waitForCondition succeeded after ${tries} retries - ${msg}`
+ );
+ } else {
+ browser.test.fail(`${msg} - timed out after ${maxTries} retries`);
+ }
+}
+
+function sendMessage(...args) {
+ let replyPromise = waitForMessage();
+ browser.test.sendMessage(...args);
+ return replyPromise;
+}
diff --git a/comm/mail/components/extensions/test/xpcshell/head-imap.js b/comm/mail/components/extensions/test/xpcshell/head-imap.js
new file mode 100644
index 0000000000..ac85c52b64
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/head-imap.js
@@ -0,0 +1,12 @@
+/* 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 head.js */
+
+var IS_IMAP = true;
+
+let wrappedCreateAccount = createAccount;
+createAccount = function (type = "imap") {
+ return wrappedCreateAccount(type);
+};
diff --git a/comm/mail/components/extensions/test/xpcshell/head-nntp.js b/comm/mail/components/extensions/test/xpcshell/head-nntp.js
new file mode 100644
index 0000000000..0b4a56d0dc
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/head-nntp.js
@@ -0,0 +1,12 @@
+/* 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 head.js */
+
+var IS_NNTP = true;
+
+let wrappedCreateAccount = createAccount;
+createAccount = function (type = "nntp") {
+ return wrappedCreateAccount(type);
+};
diff --git a/comm/mail/components/extensions/test/xpcshell/head.js b/comm/mail/components/extensions/test/xpcshell/head.js
new file mode 100644
index 0000000000..f8c0c0e7b9
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/head.js
@@ -0,0 +1,298 @@
+/* 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 { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+var { fsDebugAll, gThreadManager, nsMailServer } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+);
+var { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+// Persistent Listener test functionality
+var { assertPersistentListeners } = ExtensionTestUtils.testAssertions;
+
+ExtensionTestUtils.init(this);
+
+var IS_IMAP = false;
+var IS_NNTP = false;
+
+function formatVCard(strings, ...values) {
+ let arr = [];
+ for (let str of strings) {
+ arr.push(str);
+ arr.push(values.shift());
+ }
+ let lines = arr.join("").split("\n");
+ let indent = lines[1].length - lines[1].trimLeft().length;
+ let outLines = [];
+ for (let line of lines) {
+ if (line.length > 0) {
+ outLines.push(line.substring(indent) + "\r\n");
+ }
+ }
+ return outLines.join("");
+}
+
+function createAccount(type = "none") {
+ let account;
+
+ if (type == "local") {
+ MailServices.accounts.createLocalMailAccount();
+ account = MailServices.accounts.FindAccountForServer(
+ MailServices.accounts.localFoldersServer
+ );
+ } else {
+ account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ type
+ );
+ }
+
+ if (type == "imap") {
+ IMAPServer.open();
+ account.incomingServer.port = IMAPServer.port;
+ account.incomingServer.username = "user";
+ account.incomingServer.password = "password";
+ }
+
+ if (type == "nntp") {
+ NNTPServer.open();
+ account.incomingServer.port = NNTPServer.port;
+ }
+ info(`Created account ${account.toString()}`);
+ return account;
+}
+
+function cleanUpAccount(account) {
+ let serverKey = account.incomingServer.key;
+ let serverType = account.incomingServer.type;
+ info(
+ `Cleaning up ${serverType} account ${account.key} and server ${serverKey}`
+ );
+ MailServices.accounts.removeAccount(account, true);
+
+ try {
+ let server = MailServices.accounts.getIncomingServer(serverKey);
+ if (server) {
+ info(`Cleaning up leftover ${serverType} server ${serverKey}`);
+ MailServices.accounts.removeIncomingServer(server, false);
+ }
+ } catch (e) {}
+}
+
+registerCleanupFunction(() => {
+ MailServices.accounts.accounts.forEach(cleanUpAccount);
+});
+
+function addIdentity(account, email = "xpcshell@localhost") {
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = email;
+ account.addIdentity(identity);
+ if (!account.defaultIdentity) {
+ account.defaultIdentity = identity;
+ }
+ info(`Created identity ${identity.toString()}`);
+ return identity;
+}
+
+async function createSubfolder(parent, name) {
+ if (parent.server.type == "nntp") {
+ createNewsgroup(name);
+ let account = MailServices.accounts.FindAccountForServer(parent.server);
+ subscribeNewsgroup(account, name);
+ return parent.getChildNamed(name);
+ }
+
+ let promiseAdded = PromiseTestUtils.promiseFolderAdded(name);
+ parent.createSubfolder(name, null);
+ await promiseAdded;
+ return parent.getChildNamed(name);
+}
+
+function createMessages(folder, makeMessagesArg) {
+ if (typeof makeMessagesArg == "number") {
+ makeMessagesArg = { count: makeMessagesArg };
+ }
+ if (!createMessages.messageGenerator) {
+ createMessages.messageGenerator = new MessageGenerator();
+ }
+
+ let messages = createMessages.messageGenerator.makeMessages(makeMessagesArg);
+ return addGeneratedMessages(folder, messages);
+}
+
+class FakeGeneratedMessage {
+ constructor(msg) {
+ this.msg = msg;
+ }
+ toMessageString() {
+ return this.msg;
+ }
+ toMboxString() {
+ // A cheap hack. It works for existing uses but may not work for future uses.
+ let fromAddress = this.msg.match(/From: .* <(.*@.*)>/)[0];
+ let mBoxString = `From ${fromAddress}\r\n${this.msg}`;
+ // Ensure a trailing empty line.
+ if (!mBoxString.endsWith("\r\n")) {
+ mBoxString = mBoxString + "\r\n";
+ }
+ return mBoxString;
+ }
+}
+
+async function createMessageFromFile(folder, path) {
+ let message = await IOUtils.readUTF8(path);
+ return addGeneratedMessages(folder, [new FakeGeneratedMessage(message)]);
+}
+
+async function createMessageFromString(folder, message) {
+ return addGeneratedMessages(folder, [new FakeGeneratedMessage(message)]);
+}
+
+async function addGeneratedMessages(folder, messages) {
+ if (folder.server.type == "imap") {
+ return IMAPServer.addMessages(folder, messages);
+ }
+ if (folder.server.type == "nntp") {
+ return NNTPServer.addMessages(folder, messages);
+ }
+
+ let messageStrings = messages.map(message => message.toMboxString());
+ folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folder.addMessageBatch(messageStrings);
+ folder.callFilterPlugins(null);
+ return Promise.resolve();
+}
+
+async function getUtilsJS() {
+ return IOUtils.readUTF8(do_get_file("data/utils.js").path);
+}
+
+var IMAPServer = {
+ open() {
+ let { ImapDaemon, ImapMessage, IMAP_RFC3501_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Imapd.jsm"
+ );
+ IMAPServer.ImapMessage = ImapMessage;
+
+ this.daemon = new ImapDaemon();
+ this.server = new nsMailServer(
+ daemon => new IMAP_RFC3501_handler(daemon),
+ this.daemon
+ );
+ this.server.start();
+
+ registerCleanupFunction(() => this.close());
+ },
+ close() {
+ this.server.stop();
+ },
+ get port() {
+ return this.server.port;
+ },
+
+ addMessages(folder, messages) {
+ let fakeFolder = IMAPServer.daemon.getMailbox(folder.name);
+ messages.forEach(message => {
+ if (typeof message != "string") {
+ message = message.toMessageString();
+ }
+ let msgURI = Services.io.newURI(
+ "data:text/plain;base64," + btoa(message)
+ );
+ let imapMsg = new IMAPServer.ImapMessage(
+ msgURI.spec,
+ fakeFolder.uidnext++,
+ []
+ );
+ fakeFolder.addMessage(imapMsg);
+ });
+
+ return new Promise(resolve =>
+ mailTestUtils.updateFolderAndNotify(folder, resolve)
+ );
+ },
+};
+
+function subscribeNewsgroup(account, group) {
+ account.incomingServer.QueryInterface(Ci.nsINntpIncomingServer);
+ account.incomingServer.subscribeToNewsgroup(group);
+ account.incomingServer.maximumConnectionsNumber = 1;
+}
+
+function createNewsgroup(group) {
+ if (!NNTPServer.hasGroup(group)) {
+ NNTPServer.addGroup(group);
+ }
+}
+
+var NNTPServer = {
+ open() {
+ let { NNTP_RFC977_handler, NntpDaemon } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Nntpd.jsm"
+ );
+
+ this.daemon = new NntpDaemon();
+ this.server = new nsMailServer(
+ daemon => new NNTP_RFC977_handler(daemon),
+ this.daemon
+ );
+ this.server.start();
+
+ registerCleanupFunction(() => this.close());
+ },
+
+ close() {
+ this.server.stop();
+ },
+ get port() {
+ return this.server.port;
+ },
+
+ addGroup(group) {
+ return this.daemon.addGroup(group);
+ },
+
+ hasGroup(group) {
+ return this.daemon.getGroup(group) != null;
+ },
+
+ addMessages(folder, messages) {
+ let { NewsArticle } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Nntpd.jsm"
+ );
+
+ let group = folder.name;
+ messages.forEach(message => {
+ if (typeof message != "string") {
+ message = message.toMessageString();
+ }
+ // The NNTP daemon needs a trailing empty line.
+ if (!message.endsWith("\r\n")) {
+ message = message + "\r\n";
+ }
+ let article = new NewsArticle(message);
+ article.groups = [group];
+ this.daemon.addArticle(article);
+ });
+
+ return new Promise(resolve => {
+ mailTestUtils.updateFolderAndNotify(folder, resolve);
+ });
+ },
+};
diff --git a/comm/mail/components/extensions/test/xpcshell/images/redPixel.png b/comm/mail/components/extensions/test/xpcshell/images/redPixel.png
new file mode 100644
index 0000000000..abda018027
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/images/redPixel.png
Binary files differ
diff --git a/comm/mail/components/extensions/test/xpcshell/images/whitePixel.png b/comm/mail/components/extensions/test/xpcshell/images/whitePixel.png
new file mode 100644
index 0000000000..5514ad40e9
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/images/whitePixel.png
Binary files differ
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/alternative.eml b/comm/mail/components/extensions/test/xpcshell/messages/alternative.eml
new file mode 100644
index 0000000000..11de6a87d6
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/alternative.eml
@@ -0,0 +1,23 @@
+Message-ID: <alternative.eml@mime.sample>
+Date: Fri, 19 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>, Karl <friedrich@example.com>
+From: Doug Sauder <dwsauder@example.com>
+Subject: Default content-types
+Mime-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="=====================_714967308==_.ALT"
+
+This message is in MIME format. The first part should be readable text,
+while the remaining parts are likely unreadable without MIME-aware tools.
+
+--=====================_714967308==_.ALT
+Content-Transfer-Encoding: quoted-printable
+
+I am TEXT!
+
+--=====================_714967308==_.ALT
+Content-Type: text/html
+
+<html><body>I <b>am</b> HTML!</body></html>
+
+--=====================_714967308==_.ALT--
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/attachedMessageWithMissingHeaders.eml b/comm/mail/components/extensions/test/xpcshell/messages/attachedMessageWithMissingHeaders.eml
new file mode 100644
index 0000000000..85a54b66c5
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/attachedMessageWithMissingHeaders.eml
@@ -0,0 +1,35 @@
+Message-ID: <sample.eml@mime.sample>
+Date: Fri, 20 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>
+From: Batman <bruce@example.com>
+Subject: Attached message without subject
+Mime-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="------------49CVLb1N6p6Spdka4qq7Naeg"
+
+This is a multi-part message in MIME format.
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ </head>
+ <body>
+ <p>This message has one email attachment with missing headers.<br>
+ </p>
+ </body>
+</html>
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: message/rfc822; charset=UTF-8; name="message1.eml"
+Content-Disposition: attachment; filename="message1.eml"
+Content-Transfer-Encoding: 7bit
+
+Message-ID: <sample-attached.eml@mime.sample>
+MIME-Version: 1.0
+
+This is my body
+
+--------------49CVLb1N6p6Spdka4qq7Naeg--
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/nestedMessages.eml b/comm/mail/components/extensions/test/xpcshell/messages/nestedMessages.eml
new file mode 100644
index 0000000000..5ced639ff8
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/nestedMessages.eml
@@ -0,0 +1,127 @@
+Message-ID: <sample.eml@mime.sample>
+Date: Fri, 20 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>
+Cc: Robin <damian@wayne-enterprises.com>
+From: Batman <bruce@wayne-enterprises.com>
+Subject: Attached message with attachments
+Mime-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="------------49CVLb1N6p6Spdka4qq7Naeg"
+
+This is a multi-part message in MIME format.
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+<html>
+ <head>
+
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+ </head>
+ <body>
+ <p>This message has one normal attachment and one email attachment,
+ which itself has 3 attachments.<br>
+ </p>
+ </body>
+</html>
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: message/rfc822; charset=UTF-8; name="message1.eml"
+Content-Disposition: attachment; filename="message1.eml"
+Content-Transfer-Encoding: 7bit
+
+Message-ID: <sample-attached.eml@mime.sample>
+From: Superman <clark.kent@dailyplanet.com>
+To: Jimmy <jimmy.olsen@dailyplanet.com>
+Subject: Test message 1
+Date: Wed, 17 May 2000 19:32:47 -0400
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="----=_NextPart_000_0002_01BFC036.AE309650"
+
+This is a multi-part message in MIME format.
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: text/plain;
+ charset="iso-8859-1"
+Content-Transfer-Encoding: 7bit
+
+Message with multiple attachments.
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="whitePixel.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="whitePixel.png"
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnn
+AAAAAElFTkSuQmCC
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="greenPixel.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACx
+jwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY+C76AoAAhUBJel4xsMAAAAASUVO
+RK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="redPixel.png"
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACx
+jwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY+hgkAYAAbcApOp/9LEAAAAASUVO
+RK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: message/rfc822; charset=UTF-8; name="message2.eml"
+Content-Disposition: attachment; filename="message2.eml"
+Content-Transfer-Encoding: 7bit
+
+Message-ID: <sample-nested-attached.eml@mime.sample>
+From: Jimmy <jimmy.olsen@dailyplanet.com>
+To: Superman <clark.kent@dailyplanet.com>
+Subject: Test message 2
+Date: Wed, 16 May 2000 19:32:47 -0400
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="----=_NextPart_000_0003_01BFC036.AE309650"
+
+This is a multi-part message in MIME format.
+
+------=_NextPart_000_0003_01BFC036.AE309650
+Content-Type: text/plain;
+ charset="iso-8859-1"
+Content-Transfer-Encoding: 7bit
+
+This message has an attachment
+
+------=_NextPart_000_0003_01BFC036.AE309650
+Content-Type: image/png;
+ name="whitePixel.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="whitePixel.png"
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnn
+AAAAAElFTkSuQmCC
+
+------=_NextPart_000_0003_01BFC036.AE309650--
+
+------=_NextPart_000_0002_01BFC036.AE309650--
+
+--------------49CVLb1N6p6Spdka4qq7Naeg
+Content-Type: image/png;
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="yellowPixel.png"
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1B
+AACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY/j/iQEABOUB8pypNlQA
+AAAASUVORK5CYII=
+
+--------------49CVLb1N6p6Spdka4qq7Naeg--
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample01.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample01.eml
new file mode 100644
index 0000000000..f7ac14a07d
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample01.eml
@@ -0,0 +1,11 @@
+From: Bug Reporter <new@thunderbird.bug>
+Newsgroups: gmane.comp.mozilla.thundebird.user
+Subject: =?UTF-8?B?zrHOu8+GzqzOss63z4TOvw==?=
+Date: Thu, 27 May 2021 21:23:35 +0100
+Message-ID: <01.eml@mime.sample>
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf-8;
+Content-Transfer-Encoding: base64
+Content-Disposition: inline
+
+zobOu8+GzrEK
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample02.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample02.eml
new file mode 100644
index 0000000000..74b60b5665
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample02.eml
@@ -0,0 +1,121 @@
+From: "Doug Sauder" <doug@example.com>
+To: =?iso-8859-1?Q?Heinz_M=FCller?= <mueller@example.com>
+Subject: Test message from Microsoft Outlook 00
+Date: Wed, 17 May 2000 19:32:47 -0400
+Message-ID: <02.eml@mime.sample>
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="----=_NextPart_000_0002_01BFC036.AE309650"
+X-Priority: 3 (Normal)
+X-MSMail-Priority: Normal
+X-Mailer: Microsoft Outlook IMO, Build 9.0.2416 (9.0.2910.0)
+Importance: Normal
+X-MimeOLE: Produced By Microsoft MimeOLE V5.00.2314.1300
+
+This is a multi-part message in MIME format.
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: text/plain;
+ charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+
+Die Hasen und die Fr=F6sche=20
+=20
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="blueball1.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="blueball2.png"
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAgAABAAABgAAAAA
+CCkAEEIAEEoACDEAEFIIIXMIKXsIKYQIIWsAGFoACDkIIWMQOZwYQqUYQq0YQrUQOaUQMZQAGFIQ
+MYwpUrU5Y8Y5Y84pWs4YSs4YQs4YQr1Ca8Z7nNacvd6Mtd5jlOcxa94hUt4YStYYQsYQMaUAACHO
+5+/n7++cxu9ShO8pWucQOa1Ke86tzt6lzu9ajO8QMZxahNat1ufO7++Mve9Ke+8YOaUYSsaMvee1
+5++Uve8AAClajOdzpe9rnO8IKYwxY+8pWu8IIXsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAADBMg1VAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAGISURBVHicddJtV5swGAbgEk6AJhBSk4bMCUynBSLaqovbrG/bfPn/vyh70lbssceb
+L5xznTsh5BmNhgQoRChwo50EOIohUYLDj4zHhKYQkrEoQdvock4ne0IKMVUpKZLQDeqSTIsv+18P
+yqqWUw2IBsRM7307PPp+fDJrWtnpLDJvewYxnewfnvanZ+fzpmwXijC8KbqEa3Fx2ff91Y95U9XC
+UpaDeQwiMpHXP/v+1++bWVPWQoGFawtjury9vru/f/C1Vi7ezT0WWpQHf/7+u/G71aLThK/MjRxm
+T6KdzZ9fGk9yatMsTgZLl3XVgFRAC6spj/13enssqJVtWVa3NdBSacL8+VZmYqKmdd1CSYoOiMOS
+GwtzlqqlFFIuOqv0a1ZEZrUkWICLLFW266y1KvWE1zV/iDAH1EopnVLCiygZCIomH3NCKX0lnI+B
+1iuuzCGTxwXjnDO4d7NpbX42YJJHkBwmAm2TxwAZg40J3+Xtbv1rgOAZwG0NxW62p+lT+Yi747sD
+/wEUVMzYmWkOvwAAACV0RVh0Q29tbWVudABjbGlwMmdpZiB2LjAuNiBieSBZdmVzIFBpZ3VldDZz
+O7wAAAAASUVORK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png;
+ name="greenball.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAAAEAAAGAAAIQAA
+CAAAMQAAQgAAUgAAWgAASgAIYwAIcwAIewAQjAAIawAAOQAAYwAQlAAQnAAhpQAQpQAhrQBCvRhj
+xjFjxjlSxiEpzgAYvQAQrQAYrQAhvQCU1mOt1nuE1lJK3hgh1gAYxgAYtQAAKQBCzhDO55Te563G
+55SU52NS5yEh3gAYzgBS3iGc52vW75y974yE71JC7xCt73ul3nNa7ykh5wAY1gAx5wBS7yFr7zlK
+7xgp5wAp7wAx7wAIhAAQtQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAp1fnZAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAFtSURBVHicddJtV8IgFAdwD2zIgMEE1+NcqdsoK+m5tCyz7/+ZiLmHsyzvq53zO/cy
++N9ery1bVe9PWQA9z4MQ+H8Yoj7GASZ95IHfaBGmLOSchyIgyOu22mgQSjUcDuNYcoGjLiLK1cHh
+0fHJaTKKOcMItgYxT89OzsfjyTTLC8UF0c2ZNmKquJhczq6ub+YmSVUYRF59GeDastu7+9nD41Nm
+kiJ2jc2J3kAWZ9Pr55fH18XSmRuKUTXUaqHy7O19tfr4NFle/w3YDrWRUIlZrL/W86XJkyJVG9Ea
+EjIx2XyZmZJGioeUaL+2AY8TY8omR6nkLKhu70zjUKVJXsp3quS2DVSJWNh3zzJKCyexI0ZxBP3a
+fE0ElyqOlZJyw8r3BE2SFiJCyxA434SCkg65RhdeQBljQtCg39LWrA90RDDG1EWrYUO23hMANUKR
+Rl61E529cR++D2G5LK002dr/qrcfu9u0V3bxn/XdhR/NYeeN0ggsLAAAACV0RVh0Q29tbWVudABj
+bGlwMmdpZiB2LjAuNiBieSBZdmVzIFBpZ3VldDZzO7wAAAAASUVORK5CYII=
+
+------=_NextPart_000_0002_01BFC036.AE309650
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="redball.png"
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAABAAALAAAVAAAa
+AAAXAAARAAAKAAADAAAcAAAyAABEAABNAABIAAA9AAAjAAAWAAAmAABhAAB7AACGAACHAAB9AAB0
+AABgAAA5AAAUAAAGAAAnAABLAABvAACQAAClAAC7AAC/AACrAAChAACMAABzAABbAAAuAAAIAABM
+AAB3AACZAAC0GRnKODjVPT3bKSndBQW4AACoAAB5AAAxAAAYAAAEAABFAACaAAC7JCTRYWHfhITm
+f3/mVlbqHx/SAAC5AACjAABdAABCAAAoAAAJAABnAAC6Dw/QVFTek5PlrKzpmZntZWXvJSXXAADB
+AACxAACcAABtAABTAAA2AAAbAAAFAABKAACBAADLICDdZ2fonJzrpqbtiorvUVHvFBTRAADDAAC2
+AAB4AABeAABAAAAiAABXAACSAADCAADaGxvoVVXseHjveHjvV1fvJibhAADOAAC3AACnAACVAABH
+AAArAAAPAACdAADFAADhBQXrKCjvPDzvNTXvGxvjAADQAADJAAC1AACXAACEAABsAABPAAASAAAC
+AABiAADpAADvAgLnAADYAADLAAC6AACwAABwAAATAAAkAABYAADIAADTAADNAACzAACDAABuAAAe
+AAB+AADAAACkAACNAAB/AABpAABQAAAwAACRAACpAAC8AACqAACbAABlAABJAAAqAAAOAAA0AACs
+AACvAACtAACmAACJAAB6AABrAABaAAA+AAApAABqAACCAACfAACeAACWAACPAAB8AAAZAAAHAABV
+AACOAACKAAA4AAAQAAA/AAByAACAAABcAAA3AAAsAABmAABDAABWAAAgAAAzAAA8AAA6AAAfAAAM
+AAAdAAANAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAD8LtlFAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAIISURBVHicY2CAg/8QwIABmJhZWFnZ2Dk4MaU5uLh5eHn5+LkFBDlQJf8zC/EIi4iK
+iUtI8koJScsgyf5nlpWTV1BUUlZRVVPX4NFk1UJIyghp6+jq6RsYGhmbKJgK85mZW8Dk/rNaSlhZ
+29ja2Ts4Ojkr6Li4urFDNf53N/Ow8vTy9vH18w8IDAoWDQkNC4+ASP5ni4wKio6JjYtPSExKTnFW
+SE1LF4A69n9GZlZ2Tm5efkFhUXFySWlZlEd5RSVY7j+TkGRVdU1tXX1DY1Ozcktpa1t7h2YnOAj+
+d7l1tyo79vT29SdNSJ44SbFVdHIo9xSIHNPUaWqTpifNSJrZnK00S0U1a/acUG5piNz/uXLzVJ2q
+m6dXz584S2WB1cJFi5cshZr539xVftnyFKUVTi2TVjqvyhJLXb1m7TqoHPt6F/HW0g0bN63crGqV
+tWXrtu07BJihcsw71+zanRW8Z89eq337RQ/Ip60xO3gIElX/LbikDm8T36KwbNmRo7O3zpHkPSZw
+HBqL//8flz1x2OOkyKJTi7aqbzutfUZI2gIuF8F2lr/D5dw2+fZdwpl8YVOlI+CJ4/9/joOyYed5
+QzMvhGqnm2V0WiClm///D0lfXHtJ6vLlK9w7rx7vQk5SQJbFtSms1y9evXid7QZacgOxmSxktNzd
+tSwwU+J/VICaCPFIYU3XAJhIOtjf5sfyAAAAJXRFWHRDb21tZW50AGNsaXAyZ2lmIHYuMC42IGJ5
+IFl2ZXMgUGlndWV0NnM7vAAAAABJRU5ErkJggg==
+
+------=_NextPart_000_0002_01BFC036.AE309650--
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample03.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample03.eml
new file mode 100644
index 0000000000..3eb8e06802
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample03.eml
@@ -0,0 +1,43 @@
+From: =?iso-8859-1?Q?Heinz_M=FCller?= <mueller@example.com>
+To: "Joe Blow" <jblow@example.com>
+Subject: Test message from Microsoft Outlook 00
+Date: Wed, 17 May 2000 19:35:05 -0400
+Message-ID: <03.eml@mime.sample>
+MIME-Version: 1.0
+Content-Type: image/png;
+ name="doubelspace ball.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="doubelspace ball.png"
+X-Priority: 3 (Normal)
+X-MSMail-Priority: Normal
+X-Mailer: Microsoft Outlook IMO, Build 9.0.2416 (9.0.2910.0)
+Importance: Normal
+X-MimeOLE: Produced By Microsoft MimeOLE V5.00.2314.1300
+
+iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAMAAAC6CgRnAAADAFBMVEX///8AAAABAAALAAAVAAAa
+AAAXAAARAAAKAAADAAAcAAAyAABEAABNAABIAAA9AAAjAAAWAAAmAABhAAB7AACGAACHAAB9AAB0
+AABgAAA5AAAUAAAGAAAnAABLAABvAACQAAClAAC7AAC/AACrAAChAACMAABzAABbAAAuAAAIAABM
+AAB3AACZAAC0GRnKODjVPT3bKSndBQW4AACoAAB5AAAxAAAYAAAEAABFAACaAAC7JCTRYWHfhITm
+f3/mVlbqHx/SAAC5AACjAABdAABCAAAoAAAJAABnAAC6Dw/QVFTek5PlrKzpmZntZWXvJSXXAADB
+AACxAACcAABtAABTAAA2AAAbAAAFAABKAACBAADLICDdZ2fonJzrpqbtiorvUVHvFBTRAADDAAC2
+AAB4AABeAABAAAAiAABXAACSAADCAADaGxvoVVXseHjveHjvV1fvJibhAADOAAC3AACnAACVAABH
+AAArAAAPAACdAADFAADhBQXrKCjvPDzvNTXvGxvjAADQAADJAAC1AACXAACEAABsAABPAAASAAAC
+AABiAADpAADvAgLnAADYAADLAAC6AACwAABwAAATAAAkAABYAADIAADTAADNAACzAACDAABuAAAe
+AAB+AADAAACkAACNAAB/AABpAABQAAAwAACRAACpAAC8AACqAACbAABlAABJAAAqAAAOAAA0AACs
+AACvAACtAACmAACJAAB6AABrAABaAAA+AAApAABqAACCAACfAACeAACWAACPAAB8AAAZAAAHAABV
+AACOAACKAAA4AAAQAAA/AAByAACAAABcAAA3AAAsAABmAABDAABWAAAgAAAzAAA8AAA6AAAfAAAM
+AAAdAAANAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAD8LtlFAAAAAXRSTlMAQObYZgAAABZ0RVh0U29mdHdhcmUAZ2lmMnBuZyAyLjAu
+MT1evmgAAAIISURBVHicY2CAg/8QwIABmJhZWFnZ2Dk4MaU5uLh5eHn5+LkFBDlQJf8zC/EIi4iK
+iUtI8koJScsgyf5nlpWTV1BUUlZRVVPX4NFk1UJIyghp6+jq6RsYGhmbKJgK85mZW8Dk/rNaSlhZ
+29ja2Ts4Ojkr6Li4urFDNf53N/Ow8vTy9vH18w8IDAoWDQkNC4+ASP5ni4wKio6JjYtPSExKTnFW
+SE1LF4A69n9GZlZ2Tm5efkFhUXFySWlZlEd5RSVY7j+TkGRVdU1tXX1DY1Ozcktpa1t7h2YnOAj+
+d7l1tyo79vT29SdNSJ44SbFVdHIo9xSIHNPUaWqTpifNSJrZnK00S0U1a/acUG5piNz/uXLzVJ2q
+m6dXz584S2WB1cJFi5cshZr539xVftnyFKUVTi2TVjqvyhJLXb1m7TqoHPt6F/HW0g0bN63crGqV
+tWXrtu07BJihcsw71+zanRW8Z89eq337RQ/Ip60xO3gIElX/LbikDm8T36KwbNmRo7O3zpHkPSZw
+HBqL//8flz1x2OOkyKJTi7aqbzutfUZI2gIuF8F2lr/D5dw2+fZdwpl8YVOlI+CJ4/9/joOyYed5
+QzMvhGqnm2V0WiClm///D0lfXHtJ6vLlK9w7rx7vQk5SQJbFtSms1y9evXid7QZacgOxmSxktNzd
+tSwwU+J/VICaCPFIYU3XAJhIOtjf5sfyAAAAJXRFWHRDb21tZW50AGNsaXAyZ2lmIHYuMC42IGJ5
+IFl2ZXMgUGlndWV0NnM7vAAAAABJRU5ErkJggg==
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample04.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample04.eml
new file mode 100644
index 0000000000..6dd2a94b56
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample04.eml
@@ -0,0 +1,10 @@
+Newsgroups: gmane.comp.mozilla.thundebird.user
+From: Bug Reporter <new@thunderbird.bug>
+Subject: =?koi8-r?B?4czGwdfJ1Ao=?=
+Date: Sun, 27 May 2001 21:23:35 +0100
+MIME-Version: 1.0
+Message-ID: <04.eml@mime.sample>
+Content-Type: text/plain; charset=koi8-r;
+Content-Transfer-Encoding: base64
+
+98/Q0s/TCg== \ No newline at end of file
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample05.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample05.eml
new file mode 100644
index 0000000000..6e70eee744
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample05.eml
@@ -0,0 +1,10 @@
+Newsgroups: gmane.comp.mozilla.thundebird.user
+From: Bug Reporter <new@thunderbird.bug>
+Subject: =?windows-1251?B?wOv04OLo8go=?=
+Date: Sun, 27 May 2001 21:23:35 +0100
+MIME-Version: 1.0
+Message-ID: <05.eml@mime.sample>
+Content-Type: text/plain; charset=windows-1251;
+Content-Transfer-Encoding: base64
+
+wu7v8O7xCg== \ No newline at end of file
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample06.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample06.eml
new file mode 100644
index 0000000000..a5b3a40ac5
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample06.eml
@@ -0,0 +1,8 @@
+Newsgroups: gmane.comp.mozilla.thundebird.user
+From: Bug Reporter <new@thunderbird.bug>
+Subject: I have no content type
+Date: Sun, 27 May 2001 21:23:35 +0100
+MIME-Version: 1.0
+Message-ID: <06.eml@mime.sample>
+
+No content type
diff --git a/comm/mail/components/extensions/test/xpcshell/messages/sample07.eml b/comm/mail/components/extensions/test/xpcshell/messages/sample07.eml
new file mode 100644
index 0000000000..29283b2ce0
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/messages/sample07.eml
@@ -0,0 +1,24 @@
+Message-ID: <07.eml@mime.sample>
+Date: Fri, 19 May 2000 00:29:55 -0400
+To: Heinz <mueller@example.com>
+From: Doug Sauder <dwsauder@example.com>
+Subject: Default content-types
+Mime-Version: 1.0
+Content-Type: multipart/alternative;
+ boundary="=====================_714967308==_.ALT"
+
+This message is in MIME format. The first part should be readable text,
+while the remaining parts are likely unreadable without MIME-aware tools.
+
+--=====================_714967308==_.ALT
+Content-Transfer-Encoding: quoted-printable
+
+Die Hasen
+
+--=====================_714967308==_.ALT
+Content-Type: text/html
+
+<html><body><b>Die Hasen</b></body></html>
+
+--=====================_714967308==_.ALT--
+
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_accounts.js b/comm/mail/components/extensions/test/xpcshell/test_ext_accounts.js
new file mode 100644
index 0000000000..ac6f5482ce
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_accounts.js
@@ -0,0 +1,1089 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_task(async function test_accounts() {
+ // Here all the accounts are local but the first account will behave as
+ // an actual local account and will be kept last always.
+ let files = {
+ "background.js": async () => {
+ let [account1Id, account1Name] = await window.waitForMessage();
+
+ let defaultAccount = await browser.accounts.getDefault();
+ browser.test.assertEq(
+ null,
+ defaultAccount,
+ "The default account should be null, as none is defined."
+ );
+
+ let result1 = await browser.accounts.list();
+ browser.test.assertEq(1, result1.length);
+ window.assertDeepEqual(
+ {
+ id: account1Id,
+ name: account1Name,
+ type: "none",
+ folders: [
+ {
+ accountId: account1Id,
+ name: "Trash",
+ path: "/Trash",
+ type: "trash",
+ },
+ {
+ accountId: account1Id,
+ name: "Outbox",
+ path: "/Unsent Messages",
+ type: "outbox",
+ },
+ ],
+ },
+ result1[0]
+ );
+
+ // Test that excluding folders works.
+ let result1WithOutFolders = await browser.accounts.list(false);
+ for (let account of result1WithOutFolders) {
+ browser.test.assertEq(null, account.folders, "Folders not included");
+ }
+
+ let [account2Id, account2Name] = await window.sendMessage(
+ "create account 2"
+ );
+ // The new account is defined as default and should be returned first.
+ let result2 = await browser.accounts.list();
+ browser.test.assertEq(2, result2.length);
+ window.assertDeepEqual(
+ [
+ {
+ id: account2Id,
+ name: account2Name,
+ type: "imap",
+ folders: [
+ {
+ accountId: account2Id,
+ name: "Inbox",
+ path: "/INBOX",
+ type: "inbox",
+ },
+ ],
+ },
+ {
+ id: account1Id,
+ name: account1Name,
+ type: "none",
+ folders: [
+ {
+ accountId: account1Id,
+ name: "Trash",
+ path: "/Trash",
+ type: "trash",
+ },
+ {
+ accountId: account1Id,
+ name: "Outbox",
+ path: "/Unsent Messages",
+ type: "outbox",
+ },
+ ],
+ },
+ ],
+ result2
+ );
+
+ let result3 = await browser.accounts.get(account1Id);
+ window.assertDeepEqual(result1[0], result3);
+ let result4 = await browser.accounts.get(account2Id);
+ window.assertDeepEqual(result2[0], result4);
+
+ let result3WithoutFolders = await browser.accounts.get(account1Id, false);
+ browser.test.assertEq(
+ null,
+ result3WithoutFolders.folders,
+ "Folders not included"
+ );
+ let result4WithoutFolders = await browser.accounts.get(account2Id, false);
+ browser.test.assertEq(
+ null,
+ result4WithoutFolders.folders,
+ "Folders not included"
+ );
+
+ await window.sendMessage("create folders");
+ let result5 = await browser.accounts.get(account1Id);
+ let platformInfo = await browser.runtime.getPlatformInfo();
+ window.assertDeepEqual(
+ [
+ {
+ accountId: account1Id,
+ name: "Trash",
+ path: "/Trash",
+ subFolders: [
+ {
+ accountId: account1Id,
+ name: "%foo %test% 'bar'(!)+",
+ path: "/Trash/%foo %test% 'bar'(!)+",
+ },
+ {
+ accountId: account1Id,
+ name: "Ϟ",
+ // This character is not supported on Windows, so it gets hashed,
+ // by NS_MsgHashIfNecessary.
+ path: platformInfo.os == "win" ? "/Trash/b52bc214" : "/Trash/Ϟ",
+ },
+ ],
+ type: "trash",
+ },
+ {
+ accountId: account1Id,
+ name: "Outbox",
+ path: "/Unsent Messages",
+ type: "outbox",
+ },
+ ],
+ result5.folders
+ );
+
+ // Check we can access the folders through folderPathToURI.
+ for (let folder of result5.folders) {
+ await browser.messages.list(folder);
+ }
+
+ let result6 = await browser.accounts.get(account2Id);
+ window.assertDeepEqual(
+ [
+ {
+ accountId: account2Id,
+ name: "Inbox",
+ path: "/INBOX",
+ subFolders: [
+ {
+ accountId: account2Id,
+ name: "%foo %test% 'bar'(!)+",
+ path: "/INBOX/%foo %test% 'bar'(!)+",
+ },
+ {
+ accountId: account2Id,
+ name: "Ϟ",
+ path: "/INBOX/&A94-",
+ },
+ ],
+ type: "inbox",
+ },
+ {
+ // The trash folder magically appears at this point.
+ // It wasn't here before.
+ accountId: "account2",
+ name: "Trash",
+ path: "/Trash",
+ type: "trash",
+ },
+ ],
+ result6.folders
+ );
+
+ // Check we can access the folders through folderPathToURI.
+ for (let folder of result6.folders) {
+ await browser.messages.list(folder);
+ }
+
+ defaultAccount = await browser.accounts.getDefault();
+ browser.test.assertEq(result2[0].id, defaultAccount.id);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsIdentities", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ let account1 = createAccount();
+ extension.sendMessage(account1.key, account1.incomingServer.prettyName);
+
+ await extension.awaitMessage("create account 2");
+ let account2 = createAccount("imap");
+ IMAPServer.open();
+ account2.incomingServer.port = IMAPServer.port;
+ account2.incomingServer.username = "user";
+ account2.incomingServer.password = "password";
+ MailServices.accounts.defaultAccount = account2;
+ extension.sendMessage(account2.key, account2.incomingServer.prettyName);
+
+ await extension.awaitMessage("create folders");
+ let inbox1 = account1.incomingServer.rootFolder.subFolders[0];
+ // Test our code can handle characters that might be escaped.
+ inbox1.createSubfolder("%foo %test% 'bar'(!)+", null);
+ inbox1.createSubfolder("Ϟ", null); // Test our code can handle unicode.
+
+ let inbox2 = account2.incomingServer.rootFolder.subFolders[0];
+ inbox2.QueryInterface(Ci.nsIMsgImapMailFolder).hierarchyDelimiter = "/";
+ // Test our code can handle characters that might be escaped.
+ inbox2.createSubfolder("%foo %test% 'bar'(!)+", null);
+ await PromiseTestUtils.promiseFolderAdded("%foo %test% 'bar'(!)+");
+ inbox2.createSubfolder("Ϟ", null); // Test our code can handle unicode.
+ await PromiseTestUtils.promiseFolderAdded("Ϟ");
+
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(account1);
+ cleanUpAccount(account2);
+});
+
+add_task(async function test_identities() {
+ let account1 = createAccount();
+ let account2 = createAccount("imap");
+ let identity0 = addIdentity(account1, "id0@invalid");
+ let identity1 = addIdentity(account1, "id1@invalid");
+ let identity2 = addIdentity(account1, "id2@invalid");
+ let identity3 = addIdentity(account2, "id3@invalid");
+ addIdentity(account2, "id4@invalid");
+ identity2.label = "A label";
+ identity2.fullName = "Identity 2!";
+ identity2.organization = "Dis Organization";
+ identity2.replyTo = "reply@invalid";
+ identity2.composeHtml = true;
+ identity2.htmlSigText = "This is me. And this is my Dog.";
+ identity2.htmlSigFormat = false;
+
+ equal(account1.defaultIdentity.key, identity0.key);
+ equal(account2.defaultIdentity.key, identity3.key);
+ let files = {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(2, accounts.length);
+
+ const localAccount = accounts.find(account => account.type == "none");
+ const imapAccount = accounts.find(account => account.type == "imap");
+
+ // Register event listener.
+ let onCreatedLog = [];
+ browser.identities.onCreated.addListener((id, created) => {
+ onCreatedLog.push({ id, created });
+ });
+ let onUpdatedLog = [];
+ browser.identities.onUpdated.addListener((id, changed) => {
+ onUpdatedLog.push({ id, changed });
+ });
+ let onDeletedLog = [];
+ browser.identities.onDeleted.addListener(id => {
+ onDeletedLog.push(id);
+ });
+
+ const { id: accountId, identities } = localAccount;
+ const identityIds = identities.map(i => i.id);
+ browser.test.assertEq(3, identities.length);
+
+ browser.test.assertEq(accountId, identities[0].accountId);
+ browser.test.assertEq("id0@invalid", identities[0].email);
+ browser.test.assertEq(accountId, identities[1].accountId);
+ browser.test.assertEq("id1@invalid", identities[1].email);
+ browser.test.assertEq(accountId, identities[2].accountId);
+ browser.test.assertEq("id2@invalid", identities[2].email);
+ browser.test.assertEq("A label", identities[2].label);
+ browser.test.assertEq("Identity 2!", identities[2].name);
+ browser.test.assertEq("Dis Organization", identities[2].organization);
+ browser.test.assertEq("reply@invalid", identities[2].replyTo);
+ browser.test.assertEq(true, identities[2].composeHtml);
+ browser.test.assertEq(
+ "This is me. And this is my Dog.",
+ identities[2].signature
+ );
+ browser.test.assertEq(true, identities[2].signatureIsPlainText);
+
+ // Testing browser.identities.list().
+
+ let allIdentities = await browser.identities.list();
+ browser.test.assertEq(5, allIdentities.length);
+
+ let localIdentities = await browser.identities.list(localAccount.id);
+ browser.test.assertEq(
+ 3,
+ localIdentities.length,
+ "number of local identities is correct"
+ );
+ for (let i = 0; i < 2; i++) {
+ browser.test.assertEq(
+ localAccount.identities[i].id,
+ localIdentities[i].id,
+ "returned local identity is correct"
+ );
+ }
+
+ let imapIdentities = await browser.identities.list(imapAccount.id);
+ browser.test.assertEq(
+ 2,
+ imapIdentities.length,
+ "number of imap identities is correct"
+ );
+ for (let i = 0; i < 1; i++) {
+ browser.test.assertEq(
+ imapAccount.identities[i].id,
+ imapIdentities[i].id,
+ "returned imap identity is correct"
+ );
+ }
+
+ // Testing browser.identities.get().
+
+ let badIdentity = await browser.identities.get("funny");
+ browser.test.assertEq(null, badIdentity);
+
+ for (let identity of identities) {
+ let testIdentity = await browser.identities.get(identity.id);
+ for (let prop of Object.keys(identity)) {
+ browser.test.assertEq(
+ identity[prop],
+ testIdentity[prop],
+ `Testing identity.${prop}`
+ );
+ }
+ }
+
+ // Testing browser.identities.delete().
+
+ let imapDefaultIdentity = await browser.identities.getDefault(
+ imapAccount.id
+ );
+ let imapNonDefaultIdentity = imapIdentities.find(
+ identity => identity.id != imapDefaultIdentity.id
+ );
+
+ await browser.identities.delete(imapNonDefaultIdentity.id);
+ imapIdentities = await browser.identities.list(imapAccount.id);
+ browser.test.assertEq(
+ 1,
+ imapIdentities.length,
+ "number of imap identities after delete is correct"
+ );
+ browser.test.assertEq(
+ imapDefaultIdentity.id,
+ imapIdentities[0].id,
+ "leftover identity after delete is correct"
+ );
+
+ await browser.test.assertRejects(
+ browser.identities.delete(imapDefaultIdentity.id),
+ `Identity ${imapDefaultIdentity.id} is the default identity of account ${imapAccount.id} and cannot be deleted`,
+ "browser.identities.delete threw exception"
+ );
+
+ await browser.test.assertRejects(
+ browser.identities.delete("somethingInvalid"),
+ "Identity not found: somethingInvalid",
+ "browser.identities.delete threw exception"
+ );
+
+ // Testing browser.identities.create().
+
+ let createTests = [
+ {
+ // Set all.
+ accountId: imapAccount.id,
+ details: {
+ email: "id0+test@invalid",
+ label: "TestLabel",
+ name: "Mr. Test",
+ organization: "MZLA",
+ replyTo: "id0+test@invalid",
+ signature: "This is Bruce. And this is my Cat.",
+ composeHtml: true,
+ signatureIsPlainText: false,
+ },
+ },
+ {
+ // Set some.
+ accountId: imapAccount.id,
+ details: {
+ email: "id0+work@invalid",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ },
+ },
+ {
+ // Set none.
+ accountId: imapAccount.id,
+ details: {},
+ },
+ {
+ // Set some on an invalid account.
+ accountId: "somethingInvalid",
+ details: {
+ email: "id0+work@invalid",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ },
+ expectedThrow: `Account not found: somethingInvalid`,
+ },
+ {
+ // Try to set a protected property.
+ accountId: imapAccount.id,
+ details: {
+ accountId: "accountId5",
+ },
+ expectedThrow: `Setting the accountId property of a MailIdentity is not supported.`,
+ },
+ {
+ // Try to set a protected property together with others.
+ accountId: imapAccount.id,
+ details: {
+ id: "id8",
+ email: "id0+work@invalid",
+ label: "TestLabel",
+ name: "Mr. Test",
+ organization: "MZLA",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ signatureIsPlainText: false,
+ },
+ expectedThrow: `Setting the id property of a MailIdentity is not supported.`,
+ },
+ ];
+ for (let createTest of createTests) {
+ if (createTest.expectedThrow) {
+ await browser.test.assertRejects(
+ browser.identities.create(createTest.accountId, createTest.details),
+ createTest.expectedThrow,
+ `It rejects as expected: ${createTest.expectedThrow}.`
+ );
+ } else {
+ let createPromise = new Promise(resolve => {
+ const callback = (id, identity) => {
+ browser.identities.onCreated.removeListener(callback);
+ resolve(identity);
+ };
+ browser.identities.onCreated.addListener(callback);
+ });
+ let createdIdentity = await browser.identities.create(
+ createTest.accountId,
+ createTest.details
+ );
+ let createdIdentity2 = await createPromise;
+
+ let expected = createTest.details;
+ for (let prop of Object.keys(expected)) {
+ browser.test.assertEq(
+ expected[prop],
+ createdIdentity[prop],
+ `Testing created identity.${prop}`
+ );
+ browser.test.assertEq(
+ expected[prop],
+ createdIdentity2[prop],
+ `Testing created identity.${prop}`
+ );
+ }
+ await browser.identities.delete(createdIdentity.id);
+ }
+
+ let foundIdentities = await browser.identities.list(imapAccount.id);
+ browser.test.assertEq(
+ 1,
+ foundIdentities.length,
+ "number of imap identities after create/delete is correct"
+ );
+ }
+
+ // Testing browser.identities.update().
+
+ let updateTests = [
+ {
+ // Set all.
+ identityId: identities[2].id,
+ details: {
+ email: "id0+test@invalid",
+ label: "TestLabel",
+ name: "Mr. Test",
+ organization: "MZLA",
+ replyTo: "id0+test@invalid",
+ signature: "This is Bruce. And this is my Cat.",
+ composeHtml: true,
+ signatureIsPlainText: false,
+ },
+ },
+ {
+ // Set some.
+ identityId: identities[2].id,
+ details: {
+ email: "id0+work@invalid",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ },
+ expected: {
+ email: "id0+work@invalid",
+ label: "TestLabel",
+ name: "Mr. Test",
+ organization: "MZLA",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ signatureIsPlainText: false,
+ },
+ },
+ {
+ // Clear.
+ identityId: identities[2].id,
+ details: {
+ email: "",
+ label: "",
+ name: "",
+ organization: "",
+ replyTo: "",
+ signature: "",
+ composeHtml: false,
+ signatureIsPlainText: true,
+ },
+ },
+ {
+ // Try to update an invalid identity.
+ identityId: "somethingInvalid",
+ details: {
+ email: "id0+work@invalid",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ },
+ expectedThrow: "Identity not found: somethingInvalid",
+ },
+ {
+ // Try to update a protected property.
+ identityId: identities[2].id,
+ details: {
+ accountId: "accountId5",
+ },
+ expectedThrow:
+ "Setting the accountId property of a MailIdentity is not supported.",
+ },
+ {
+ // Try to update another protected property together with others.
+ identityId: identities[2].id,
+ details: {
+ id: "id8",
+ email: "id0+work@invalid",
+ label: "TestLabel",
+ name: "Mr. Test",
+ organization: "MZLA",
+ replyTo: "",
+ signature: "I am Batman.",
+ composeHtml: false,
+ signatureIsPlainText: false,
+ },
+ expectedThrow:
+ "Setting the id property of a MailIdentity is not supported.",
+ },
+ ];
+ for (let updateTest of updateTests) {
+ if (updateTest.expectedThrow) {
+ await browser.test.assertRejects(
+ browser.identities.update(
+ updateTest.identityId,
+ updateTest.details
+ ),
+ updateTest.expectedThrow,
+ `It rejects as expected: ${updateTest.expectedThrow}.`
+ );
+ continue;
+ }
+
+ let updatePromise = new Promise(resolve => {
+ const callback = (id, changed) => {
+ browser.identities.onUpdated.removeListener(callback);
+ resolve(changed);
+ };
+ browser.identities.onUpdated.addListener(callback);
+ });
+ let updatedIdentity = await browser.identities.update(
+ updateTest.identityId,
+ updateTest.details
+ );
+ await updatePromise;
+
+ let returnedIdentity = await browser.identities.get(
+ updateTest.identityId
+ );
+
+ let expected = updateTest.expected || updateTest.details;
+ for (let prop of Object.keys(expected)) {
+ browser.test.assertEq(
+ expected[prop],
+ updatedIdentity[prop],
+ `Testing updated identity.${prop}`
+ );
+ browser.test.assertEq(
+ expected[prop],
+ returnedIdentity[prop],
+ `Testing returned identity.${prop}`
+ );
+ }
+ }
+
+ // Testing getDefault().
+
+ let defaultIdentity = await browser.identities.getDefault(accountId);
+ browser.test.assertEq(identities[0].id, defaultIdentity.id);
+
+ await browser.identities.setDefault(accountId, identityIds[2]);
+ defaultIdentity = await browser.identities.getDefault(accountId);
+ browser.test.assertEq(identities[2].id, defaultIdentity.id);
+
+ let { identities: newIdentities } = await browser.accounts.get(accountId);
+ browser.test.assertEq(3, newIdentities.length);
+ browser.test.assertEq(identityIds[2], newIdentities[0].id);
+ browser.test.assertEq(identityIds[0], newIdentities[1].id);
+ browser.test.assertEq(identityIds[1], newIdentities[2].id);
+
+ await browser.identities.setDefault(accountId, identityIds[1]);
+ defaultIdentity = await browser.identities.getDefault(accountId);
+ browser.test.assertEq(identities[1].id, defaultIdentity.id);
+
+ ({ identities: newIdentities } = await browser.accounts.get(accountId));
+ browser.test.assertEq(3, newIdentities.length);
+ browser.test.assertEq(identityIds[1], newIdentities[0].id);
+ browser.test.assertEq(identityIds[2], newIdentities[1].id);
+ browser.test.assertEq(identityIds[0], newIdentities[2].id);
+
+ // Check event listeners.
+ window.assertDeepEqual(
+ onCreatedLog,
+ [
+ {
+ id: "id6",
+ created: {
+ accountId: "account4",
+ id: "id6",
+ label: "TestLabel",
+ name: "Mr. Test",
+ email: "id0+test@invalid",
+ replyTo: "id0+test@invalid",
+ organization: "MZLA",
+ composeHtml: true,
+ signature: "This is Bruce. And this is my Cat.",
+ signatureIsPlainText: false,
+ },
+ },
+ {
+ id: "id7",
+ created: {
+ accountId: "account4",
+ id: "id7",
+ label: "",
+ name: "",
+ email: "id0+work@invalid",
+ replyTo: "",
+ organization: "",
+ composeHtml: false,
+ signature: "I am Batman.",
+ signatureIsPlainText: true,
+ },
+ },
+ {
+ id: "id8",
+ created: {
+ accountId: "account4",
+ id: "id8",
+ label: "",
+ name: "",
+ email: "",
+ replyTo: "",
+ organization: "",
+ composeHtml: true,
+ signature: "",
+ signatureIsPlainText: true,
+ },
+ },
+ ],
+ "captured onCreated events are correct"
+ );
+ window.assertDeepEqual(
+ onUpdatedLog,
+ [
+ {
+ id: "id3",
+ changed: {
+ label: "TestLabel",
+ name: "Mr. Test",
+ email: "id0+test@invalid",
+ replyTo: "id0+test@invalid",
+ organization: "MZLA",
+ signature: "This is Bruce. And this is my Cat.",
+ signatureIsPlainText: false,
+ accountId: "account3",
+ id: "id3",
+ },
+ },
+ {
+ id: "id3",
+ changed: {
+ email: "id0+work@invalid",
+ replyTo: "",
+ composeHtml: false,
+ signature: "I am Batman.",
+ accountId: "account3",
+ id: "id3",
+ },
+ },
+ {
+ id: "id3",
+ changed: {
+ label: "",
+ name: "",
+ email: "",
+ organization: "",
+ signature: "",
+ signatureIsPlainText: true,
+ accountId: "account3",
+ id: "id3",
+ },
+ },
+ ],
+ "captured onUpdated events are correct"
+ );
+ window.assertDeepEqual(
+ onDeletedLog,
+ ["id5", "id6", "id7", "id8"],
+ "captured onDeleted events are correct"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsIdentities"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ equal(account1.defaultIdentity.key, identity1.key);
+
+ cleanUpAccount(account1);
+ cleanUpAccount(account2);
+});
+
+add_task(async function test_identities_without_write_permissions() {
+ let account = createAccount();
+ let identity0 = addIdentity(account, "id0@invalid");
+
+ equal(account.defaultIdentity.key, identity0.key);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+
+ const [{ identities }] = accounts;
+ browser.test.assertEq(1, identities.length);
+
+ // Testing browser.identities.update().
+
+ await browser.test.assertThrows(
+ () => browser.identities.update(identities[0].id, {}),
+ "browser.identities.update is not a function",
+ "It rejects for a missing permission."
+ );
+
+ // Testing browser.identities.delete().
+
+ await browser.test.assertThrows(
+ () => browser.identities.delete(identities[0].id),
+ "browser.identities.delete is not a function",
+ "It rejects for a missing permission."
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: {
+ permissions: ["accountsRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(account);
+});
+
+add_task(async function test_accounts_events() {
+ let account1 = createAccount();
+ addIdentity(account1, "id1@invalid");
+
+ let files = {
+ "background.js": async () => {
+ // Register event listener.
+ let onCreatedLog = [];
+ let onUpdatedLog = [];
+ let onDeletedLog = [];
+
+ let createListener = (id, created) => {
+ onCreatedLog.push({ id, created });
+ };
+ let updateListener = (id, changed) => {
+ onUpdatedLog.push({ id, changed });
+ };
+ let deleteListener = id => {
+ onDeletedLog.push(id);
+ };
+
+ await browser.accounts.onCreated.addListener(createListener);
+ await browser.accounts.onUpdated.addListener(updateListener);
+ await browser.accounts.onDeleted.addListener(deleteListener);
+
+ // Create accounts.
+ let imapAccountKey = await window.sendMessage("createAccount", {
+ type: "imap",
+ identity: "user@invalidImap",
+ });
+ let localAccountKey = await window.sendMessage("createAccount", {
+ type: "none",
+ identity: "user@invalidLocal",
+ });
+ let popAccountKey = await window.sendMessage("createAccount", {
+ type: "pop3",
+ identity: "user@invalidPop",
+ });
+
+ // Update account identities.
+ let accounts = await browser.accounts.list();
+ let imapAccount = accounts.find(a => a.id == imapAccountKey);
+ let localAccount = accounts.find(a => a.id == localAccountKey);
+ let popAccount = accounts.find(a => a.id == popAccountKey);
+
+ let id1 = await browser.identities.create(imapAccount.id, {
+ composeHtml: true,
+ email: "user1@inter.net",
+ name: "user1",
+ });
+ let id2 = await browser.identities.create(localAccount.id, {
+ composeHtml: false,
+ email: "user2@inter.net",
+ name: "user2",
+ });
+ let id3 = await browser.identities.create(popAccount.id, {
+ composeHtml: false,
+ email: "user3@inter.net",
+ name: "user3",
+ });
+
+ await browser.identities.setDefault(imapAccount.id, id1.id);
+ browser.test.assertEq(
+ id1.id,
+ (await browser.identities.getDefault(imapAccount.id)).id
+ );
+ await browser.identities.setDefault(localAccount.id, id2.id);
+ browser.test.assertEq(
+ id2.id,
+ (await browser.identities.getDefault(localAccount.id)).id
+ );
+ await browser.identities.setDefault(popAccount.id, id3.id);
+ browser.test.assertEq(
+ id3.id,
+ (await browser.identities.getDefault(popAccount.id)).id
+ );
+
+ // Update account names.
+ await window.sendMessage("updateAccountName", {
+ accountKey: imapAccountKey,
+ name: "Test1",
+ });
+ await window.sendMessage("updateAccountName", {
+ accountKey: localAccountKey,
+ name: "Test2",
+ });
+ await window.sendMessage("updateAccountName", {
+ accountKey: popAccountKey,
+ name: "Test3",
+ });
+
+ // Delete accounts.
+ await window.sendMessage("removeAccount", {
+ accountKey: imapAccountKey,
+ });
+ await window.sendMessage("removeAccount", {
+ accountKey: localAccountKey,
+ });
+ await window.sendMessage("removeAccount", {
+ accountKey: popAccountKey,
+ });
+
+ await browser.accounts.onCreated.removeListener(createListener);
+ await browser.accounts.onUpdated.removeListener(updateListener);
+ await browser.accounts.onDeleted.removeListener(deleteListener);
+
+ // Check event listeners.
+ browser.test.assertEq(3, onCreatedLog.length);
+ window.assertDeepEqual(
+ [
+ {
+ id: "account7",
+ created: {
+ id: "account7",
+ type: "imap",
+ identities: [],
+ name: "Mail for account7user@localhost",
+ folders: null,
+ },
+ },
+ {
+ id: "account8",
+ created: {
+ id: "account8",
+ type: "none",
+ identities: [],
+ name: "account8user on localhost",
+ folders: null,
+ },
+ },
+ {
+ id: "account9",
+ created: {
+ id: "account9",
+ type: "pop3",
+ identities: [],
+ name: "account9user on localhost",
+ folders: null,
+ },
+ },
+ ],
+ onCreatedLog,
+ "captured onCreated events are correct"
+ );
+ window.assertDeepEqual(
+ [
+ {
+ id: "account7",
+ changed: { id: "account7", name: "Mail for user@localhost" },
+ },
+ {
+ id: "account7",
+ changed: {
+ id: "account7",
+ defaultIdentity: { id: "id11" },
+ },
+ },
+ {
+ id: "account8",
+ changed: {
+ id: "account8",
+ defaultIdentity: { id: "id12" },
+ },
+ },
+ {
+ id: "account9",
+ changed: {
+ id: "account9",
+ defaultIdentity: { id: "id13" },
+ },
+ },
+ {
+ id: "account7",
+ changed: {
+ id: "account7",
+ defaultIdentity: { id: "id14" },
+ },
+ },
+ {
+ id: "account8",
+ changed: {
+ id: "account8",
+ defaultIdentity: { id: "id15" },
+ },
+ },
+ {
+ id: "account9",
+ changed: {
+ id: "account9",
+ defaultIdentity: { id: "id16" },
+ },
+ },
+ {
+ id: "account7",
+ changed: {
+ id: "account7",
+ name: "Test1",
+ },
+ },
+ {
+ id: "account8",
+ changed: {
+ id: "account8",
+ name: "Test2",
+ },
+ },
+ {
+ id: "account9",
+ changed: {
+ id: "account9",
+ name: "Test3",
+ },
+ },
+ ],
+ onUpdatedLog,
+ "captured onUpdated events are correct"
+ );
+ window.assertDeepEqual(
+ ["account7", "account8", "account9"],
+ onDeletedLog,
+ "captured onDeleted events are correct"
+ );
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => window.setTimeout(r, 250));
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsIdentities"],
+ },
+ });
+
+ extension.onMessage("createAccount", details => {
+ let account = createAccount(details.type);
+ addIdentity(account, details.identity);
+ extension.sendMessage(account.key);
+ });
+ extension.onMessage("updateAccountName", details => {
+ let account = MailServices.accounts.getAccount(details.accountKey);
+ account.incomingServer.prettyName = details.name;
+ extension.sendMessage();
+ });
+ extension.onMessage("removeAccount", details => {
+ let account = MailServices.accounts.getAccount(details.accountKey);
+ cleanUpAccount(account);
+ extension.sendMessage();
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(account1);
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_accounts_mv3_event_pages.js b/comm/mail/components/extensions/test/xpcshell/test_ext_accounts_mv3_event_pages.js
new file mode 100644
index 0000000000..0ac4394f40
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_accounts_mv3_event_pages.js
@@ -0,0 +1,220 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+add_task(async function test_accounts_MV3_event_pages() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, the eventCounter is reset and
+ // allows to observe the order of events fired. In case of a wake-up, the
+ // first observed event is the one that woke up the background.
+ let eventCounter = 0;
+
+ for (let eventName of ["onCreated", "onUpdated", "onDeleted"]) {
+ browser.accounts[eventName].addListener(async (...args) => {
+ browser.test.sendMessage(`${eventName} event received`, {
+ eventCount: ++eventCounter,
+ args,
+ });
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsIdentities"],
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "accounts.onCreated",
+ "accounts.onUpdated",
+ "accounts.onDeleted",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ let testData = [
+ {
+ type: "imap",
+ identity: "user@invalidImap",
+ expectedUpdate: true,
+ expectedName: accountKey => `Mail for ${accountKey}user@localhost`,
+ expectedType: "imap",
+ updatedName: "Test1",
+ },
+ {
+ type: "pop3",
+ identity: "user@invalidPop",
+ expectedUpdate: false,
+ expectedName: accountKey => `${accountKey}user on localhost`,
+ expectedType: "pop3",
+ updatedName: "Test2",
+ },
+ {
+ type: "none",
+ identity: "user@invalidLocal",
+ expectedUpdate: false,
+ expectedName: accountKey => `${accountKey}user on localhost`,
+ expectedType: "none",
+ updatedName: "Test3",
+ },
+ {
+ type: "local",
+ identity: "user@invalidLocal",
+ expectedUpdate: false,
+ expectedName: accountKey => "Local Folders",
+ expectedType: "none",
+ updatedName: "Test4",
+ },
+ ];
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+
+ // Verify persistent listener, not yet primed.
+ checkPersistentListeners({ primed: false });
+
+ // Create.
+
+ for (let details of testData) {
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ let account = createAccount(details.type);
+ details.account = account;
+
+ {
+ let rv = await extension.awaitMessage("onCreated event received");
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ args: [
+ details.account.key,
+ {
+ id: details.account.key,
+ name: details.expectedName(account.key),
+ type: details.expectedType,
+ folders: null,
+ identities: [],
+ },
+ ],
+ },
+ rv,
+ "The primed onCreated event should return the correct values"
+ );
+ }
+
+ if (details.expectedUpdate) {
+ let rv = await extension.awaitMessage("onUpdated event received");
+ Assert.deepEqual(
+ {
+ eventCount: 2,
+ args: [
+ details.account.key,
+ { id: details.account.key, name: "Mail for user@localhost" },
+ ],
+ },
+ rv,
+ "The non-primed onUpdated event should return the correct values"
+ );
+ }
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ // Update.
+
+ for (let details of testData) {
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ let account = MailServices.accounts.getAccount(details.account.key);
+ account.incomingServer.prettyName = details.updatedName;
+ let rv = await extension.awaitMessage("onUpdated event received");
+
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ args: [
+ details.account.key,
+ {
+ id: details.account.key,
+ name: details.updatedName,
+ },
+ ],
+ },
+ rv,
+ "The primed onUpdated event should return the correct values"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ // Delete.
+
+ for (let details of testData) {
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ cleanUpAccount(details.account);
+ let rv = await extension.awaitMessage("onDeleted event received");
+
+ Assert.deepEqual(
+ {
+ eventCount: 1,
+ args: [details.account.key],
+ },
+ rv,
+ "The primed onDeleted event should return the correct values"
+ );
+
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+ }
+
+ await extension.unload();
+
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook.js b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook.js
new file mode 100644
index 0000000000..8fcc3ca14f
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook.js
@@ -0,0 +1,2043 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AddrBookCard: "resource:///modules/AddrBookCard.jsm",
+ AddrBookUtils: "resource:///modules/AddrBookUtils.jsm",
+});
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+add_setup(async () => {
+ Services.prefs.setIntPref("ldap_2.servers.osx.dirType", -1);
+
+ registerCleanupFunction(() => {
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+ });
+});
+
+add_task(async function test_addressBooks() {
+ async function background() {
+ let firstBookId, secondBookId, newContactId;
+
+ let events = [];
+ let eventPromise;
+ let eventPromiseResolve;
+ for (let eventNamespace of ["addressBooks", "contacts", "mailingLists"]) {
+ for (let eventName of [
+ "onCreated",
+ "onUpdated",
+ "onDeleted",
+ "onMemberAdded",
+ "onMemberRemoved",
+ ]) {
+ if (eventName in browser[eventNamespace]) {
+ browser[eventNamespace][eventName].addListener((...args) => {
+ events.push({ namespace: eventNamespace, name: eventName, args });
+ if (eventPromiseResolve) {
+ let resolve = eventPromiseResolve;
+ eventPromiseResolve = null;
+ resolve();
+ }
+ });
+ }
+ }
+ }
+
+ let outsideEvent = function (action, ...args) {
+ eventPromise = new Promise(resolve => {
+ eventPromiseResolve = resolve;
+ });
+ return window.sendMessage("outsideEventsTest", action, ...args);
+ };
+ let checkEvents = async function (...expectedEvents) {
+ if (eventPromiseResolve) {
+ await eventPromise;
+ }
+
+ browser.test.assertEq(
+ expectedEvents.length,
+ events.length,
+ "Correct number of events"
+ );
+
+ if (expectedEvents.length != events.length) {
+ for (let event of events) {
+ let args = event.args.join(", ");
+ browser.test.log(`${event.namespace}.${event.name}(${args})`);
+ }
+ throw new Error("Wrong number of events, stopping.");
+ }
+
+ for (let [namespace, name, ...expectedArgs] of expectedEvents) {
+ let event = events.shift();
+ browser.test.assertEq(
+ namespace,
+ event.namespace,
+ "Event namespace is correct"
+ );
+ browser.test.assertEq(name, event.name, "Event type is correct");
+ browser.test.assertEq(
+ expectedArgs.length,
+ event.args.length,
+ "Argument count is correct"
+ );
+ window.assertDeepEqual(expectedArgs, event.args);
+ if (expectedEvents.length == 1) {
+ return event.args;
+ }
+ }
+
+ return null;
+ };
+
+ async function addressBookTest() {
+ browser.test.log("Starting addressBookTest");
+ let list = await browser.addressBooks.list();
+ browser.test.assertEq(2, list.length);
+ for (let b of list) {
+ browser.test.assertEq(5, Object.keys(b).length);
+ browser.test.assertEq(36, b.id.length);
+ browser.test.assertEq("addressBook", b.type);
+ browser.test.assertTrue("name" in b);
+ browser.test.assertFalse(b.readOnly);
+ browser.test.assertFalse(b.remote);
+ }
+
+ let completeList = await browser.addressBooks.list(true);
+ browser.test.assertEq(2, completeList.length);
+ for (let b of completeList) {
+ browser.test.assertEq(7, Object.keys(b).length);
+ }
+
+ firstBookId = list[0].id;
+ secondBookId = list[1].id;
+
+ let firstBook = await browser.addressBooks.get(firstBookId);
+ browser.test.assertEq(5, Object.keys(firstBook).length);
+
+ let secondBook = await browser.addressBooks.get(secondBookId, true);
+ browser.test.assertEq(7, Object.keys(secondBook).length);
+ browser.test.assertTrue(Array.isArray(secondBook.contacts));
+ browser.test.assertEq(0, secondBook.contacts.length);
+ browser.test.assertTrue(Array.isArray(secondBook.mailingLists));
+ browser.test.assertEq(0, secondBook.mailingLists.length);
+ let newBookId = await browser.addressBooks.create({ name: "test name" });
+ browser.test.assertEq(36, newBookId.length);
+ await checkEvents([
+ "addressBooks",
+ "onCreated",
+ { type: "addressBook", id: newBookId },
+ ]);
+
+ list = await browser.addressBooks.list();
+ browser.test.assertEq(3, list.length);
+
+ let newBook = await browser.addressBooks.get(newBookId);
+ browser.test.assertEq(newBookId, newBook.id);
+ browser.test.assertEq("addressBook", newBook.type);
+ browser.test.assertEq("test name", newBook.name);
+
+ await browser.addressBooks.update(newBookId, { name: "new name" });
+ await checkEvents([
+ "addressBooks",
+ "onUpdated",
+ { type: "addressBook", id: newBookId },
+ ]);
+ let updatedBook = await browser.addressBooks.get(newBookId);
+ browser.test.assertEq("new name", updatedBook.name);
+
+ list = await browser.addressBooks.list();
+ browser.test.assertEq(3, list.length);
+
+ await browser.addressBooks.delete(newBookId);
+ await checkEvents(["addressBooks", "onDeleted", newBookId]);
+
+ list = await browser.addressBooks.list();
+ browser.test.assertEq(2, list.length);
+
+ for (let operation of ["get", "update", "delete"]) {
+ let args = [newBookId];
+ if (operation == "update") {
+ args.push({ name: "" });
+ }
+
+ try {
+ await browser.addressBooks[operation].apply(
+ browser.addressBooks,
+ args
+ );
+ browser.test.fail(
+ `Calling ${operation} on a non-existent address book should throw`
+ );
+ } catch (ex) {
+ browser.test.assertEq(
+ `addressBook with id=${newBookId} could not be found.`,
+ ex.message,
+ `browser.addressBooks.${operation} threw exception`
+ );
+ }
+ }
+
+ // Test the prevention of creating new address book with an empty name
+ await browser.test.assertRejects(
+ browser.addressBooks.create({ name: "" }),
+ "An unexpected error occurred",
+ "browser.addressBooks.create threw exception"
+ );
+
+ browser.test.assertEq(0, events.length, "No events left unconsumed");
+ browser.test.log("Completed addressBookTest");
+ }
+
+ async function contactsTest() {
+ browser.test.log("Starting contactsTest");
+ let contacts = await browser.contacts.list(firstBookId);
+ browser.test.assertTrue(Array.isArray(contacts));
+ browser.test.assertEq(0, contacts.length);
+
+ newContactId = await browser.contacts.create(firstBookId, {
+ FirstName: "first",
+ LastName: "last",
+ Notes: "Notes",
+ SomethingCustom: "Custom property",
+ });
+ browser.test.assertEq(36, newContactId.length);
+ await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ ]);
+
+ contacts = await browser.contacts.list(firstBookId);
+ browser.test.assertEq(1, contacts.length, "Contact added to first book.");
+ browser.test.assertEq(contacts[0].id, newContactId);
+
+ contacts = await browser.contacts.list(secondBookId);
+ browser.test.assertEq(
+ 0,
+ contacts.length,
+ "Contact not added to second book."
+ );
+
+ let newContact = await browser.contacts.get(newContactId);
+ browser.test.assertEq(6, Object.keys(newContact).length);
+ browser.test.assertEq(newContactId, newContact.id);
+ browser.test.assertEq(firstBookId, newContact.parentId);
+ browser.test.assertEq("contact", newContact.type);
+ browser.test.assertEq(false, newContact.readOnly);
+ browser.test.assertEq(false, newContact.remote);
+ browser.test.assertEq(5, Object.keys(newContact.properties).length);
+ browser.test.assertEq("first", newContact.properties.FirstName);
+ browser.test.assertEq("last", newContact.properties.LastName);
+ browser.test.assertEq("Notes", newContact.properties.Notes);
+ browser.test.assertEq(
+ "Custom property",
+ newContact.properties.SomethingCustom
+ );
+ browser.test.assertEq(
+ `BEGIN:VCARD\r\nVERSION:4.0\r\nNOTE:Notes\r\nN:last;first;;;\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
+ newContact.properties.vCard
+ );
+
+ // Changing the UID should throw.
+ try {
+ await browser.contacts.update(newContactId, {
+ vCard: `BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:SomethingNew\r\nEND:VCARD\r\n`,
+ });
+ browser.test.fail(
+ `Updating a contact with a vCard with a differnt UID should throw`
+ );
+ } catch (ex) {
+ browser.test.assertEq(
+ `The card's UID ${newContactId} may not be changed: BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:SomethingNew\r\nEND:VCARD\r\n.`,
+ ex.message,
+ `browser.contacts.update threw exception`
+ );
+ }
+
+ // Test Custom1.
+ {
+ await browser.contacts.update(newContactId, {
+ vCard: `BEGIN:VCARD\r\nVERSION:4.0\r\nNOTE:Notes\r\nN:last;first;;;\r\nX-CUSTOM1;VALUE=TEXT:Original custom value\r\nEND:VCARD`,
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ Custom1: { oldValue: null, newValue: "Original custom value" },
+ },
+ ]);
+ let updContact1 = await browser.contacts.get(newContactId);
+ browser.test.assertEq(
+ "Original custom value",
+ updContact1.properties.Custom1
+ );
+
+ await browser.contacts.update(newContactId, {
+ Custom1: "Updated custom value",
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ Custom1: {
+ oldValue: "Original custom value",
+ newValue: "Updated custom value",
+ },
+ },
+ ]);
+ let updContact2 = await browser.contacts.get(newContactId);
+ browser.test.assertEq(
+ "Updated custom value",
+ updContact2.properties.Custom1
+ );
+ browser.test.assertTrue(
+ updContact2.properties.vCard.includes(
+ "X-CUSTOM1;VALUE=TEXT:Updated custom value"
+ ),
+ "vCard should include the correct x-custom1 entry"
+ );
+ }
+
+ // If a vCard and legacy properties are given, vCard must win.
+ await browser.contacts.update(newContactId, {
+ vCard: `BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
+ FirstName: "Superman",
+ PrimaryEmail: "c.kent@dailyplanet.com",
+ PreferDisplayName: "0",
+ OtherCustom: "Yet another custom property",
+ Notes: "Ignored Notes",
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ PrimaryEmail: { oldValue: null, newValue: "first@last" },
+ LastName: { oldValue: "last", newValue: null },
+ OtherCustom: {
+ oldValue: null,
+ newValue: "Yet another custom property",
+ },
+ PreferDisplayName: { oldValue: null, newValue: "0" },
+ Custom1: { oldValue: "Updated custom value", newValue: null },
+ },
+ ]);
+
+ let updatedContact = await browser.contacts.get(newContactId);
+ browser.test.assertEq(6, Object.keys(updatedContact.properties).length);
+ browser.test.assertEq("first", updatedContact.properties.FirstName);
+ browser.test.assertEq(
+ "first@last",
+ updatedContact.properties.PrimaryEmail
+ );
+ browser.test.assertTrue(!("LastName" in updatedContact.properties));
+ browser.test.assertTrue(
+ !("Notes" in updatedContact.properties),
+ "The vCard is not specifying Notes and the specified Notes property should be ignored."
+ );
+ browser.test.assertEq(
+ "Custom property",
+ updatedContact.properties.SomethingCustom,
+ "Untouched custom properties should not be changed by updating the vCard"
+ );
+ browser.test.assertEq(
+ "Yet another custom property",
+ updatedContact.properties.OtherCustom,
+ "Custom properties should be added even while updating a vCard"
+ );
+ browser.test.assertEq(
+ "0",
+ updatedContact.properties.PreferDisplayName,
+ "Setting non-banished properties parallel to a vCard should update"
+ );
+ browser.test.assertEq(
+ `BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
+ updatedContact.properties.vCard
+ );
+
+ // Manually Remove properties.
+ await browser.contacts.update(newContactId, {
+ LastName: "lastname",
+ PrimaryEmail: null,
+ SecondEmail: "test@invalid.de",
+ SomethingCustom: null,
+ OtherCustom: null,
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ LastName: { oldValue: null, newValue: "lastname" },
+ // It is how it is. Defining a 2nd email with no 1st, will make it the first.
+ PrimaryEmail: { oldValue: "first@last", newValue: "test@invalid.de" },
+ SomethingCustom: { oldValue: "Custom property", newValue: null },
+ OtherCustom: {
+ oldValue: "Yet another custom property",
+ newValue: null,
+ },
+ },
+ ]);
+
+ updatedContact = await browser.contacts.get(newContactId);
+ browser.test.assertEq(5, Object.keys(updatedContact.properties).length);
+ // LastName and FirstName are stored in the same multi field property and changing LastName should not change FirstName.
+ browser.test.assertEq("first", updatedContact.properties.FirstName);
+ browser.test.assertEq("lastname", updatedContact.properties.LastName);
+ browser.test.assertEq(
+ "test@invalid.de",
+ updatedContact.properties.PrimaryEmail
+ );
+ browser.test.assertTrue(
+ !("SomethingCustom" in updatedContact.properties)
+ );
+ browser.test.assertTrue(!("OtherCustom" in updatedContact.properties));
+ browser.test.assertEq(
+ `BEGIN:VCARD\r\nVERSION:4.0\r\nN:lastname;first;;;\r\nEMAIL:test@invalid.de\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
+ updatedContact.properties.vCard
+ );
+
+ // Add an email address, going from 1 to 2.Also remove FirstName, LastName should stay.
+ await browser.contacts.update(newContactId, {
+ FirstName: null,
+ PrimaryEmail: "new1@invalid.de",
+ SecondEmail: "new2@invalid.de",
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ PrimaryEmail: {
+ oldValue: "test@invalid.de",
+ newValue: "new1@invalid.de",
+ },
+ SecondEmail: { oldValue: null, newValue: "new2@invalid.de" },
+ FirstName: { oldValue: "first", newValue: null },
+ },
+ ]);
+
+ updatedContact = await browser.contacts.get(newContactId);
+ browser.test.assertEq(5, Object.keys(updatedContact.properties).length);
+ browser.test.assertEq("lastname", updatedContact.properties.LastName);
+ browser.test.assertEq(
+ "new1@invalid.de",
+ updatedContact.properties.PrimaryEmail
+ );
+ browser.test.assertEq(
+ "new2@invalid.de",
+ updatedContact.properties.SecondEmail
+ );
+ browser.test.assertEq(
+ `BEGIN:VCARD\r\nVERSION:4.0\r\nN:lastname;;;;\r\nEMAIL;PREF=1:new1@invalid.de\r\nUID:${newContactId}\r\nEMAIL:new2@invalid.de\r\nEND:VCARD\r\n`,
+ updatedContact.properties.vCard
+ );
+
+ // Remove and email address, going from 2 to 1.
+ await browser.contacts.update(newContactId, {
+ SecondEmail: null,
+ });
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: firstBookId, id: newContactId },
+ {
+ SecondEmail: { oldValue: "new2@invalid.de", newValue: null },
+ },
+ ]);
+
+ updatedContact = await browser.contacts.get(newContactId);
+ browser.test.assertEq(4, Object.keys(updatedContact.properties).length);
+ browser.test.assertEq("lastname", updatedContact.properties.LastName);
+ browser.test.assertEq(
+ "new1@invalid.de",
+ updatedContact.properties.PrimaryEmail
+ );
+ browser.test.assertEq(
+ `BEGIN:VCARD\r\nVERSION:4.0\r\nN:lastname;;;;\r\nEMAIL;PREF=1:new1@invalid.de\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
+ updatedContact.properties.vCard
+ );
+
+ // Set a fixed UID.
+ let fixedContactId = await browser.contacts.create(
+ firstBookId,
+ "this is a test",
+ {
+ FirstName: "a",
+ LastName: "test",
+ }
+ );
+ browser.test.assertEq("this is a test", fixedContactId);
+ await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: firstBookId, id: "this is a test" },
+ ]);
+
+ let fixedContact = await browser.contacts.get("this is a test");
+ browser.test.assertEq("this is a test", fixedContact.id);
+
+ await browser.contacts.delete("this is a test");
+ await checkEvents([
+ "contacts",
+ "onDeleted",
+ firstBookId,
+ "this is a test",
+ ]);
+
+ try {
+ await browser.contacts.create(firstBookId, newContactId, {
+ FirstName: "uh",
+ LastName: "oh",
+ });
+ browser.test.fail(`Adding a contact with a duplicate id should throw`);
+ } catch (ex) {
+ browser.test.assertEq(
+ `Duplicate contact id: ${newContactId}`,
+ ex.message,
+ `browser.contacts.create threw exception`
+ );
+ }
+
+ browser.test.assertEq(0, events.length, "No events left unconsumed");
+ browser.test.log("Completed contactsTest");
+ }
+
+ async function mailingListsTest() {
+ browser.test.log("Starting mailingListsTest");
+ let mailingLists = await browser.mailingLists.list(firstBookId);
+ browser.test.assertTrue(Array.isArray(mailingLists));
+ browser.test.assertEq(0, mailingLists.length);
+
+ let newMailingListId = await browser.mailingLists.create(firstBookId, {
+ name: "name",
+ });
+ browser.test.assertEq(36, newMailingListId.length);
+ await checkEvents([
+ "mailingLists",
+ "onCreated",
+ { type: "mailingList", parentId: firstBookId, id: newMailingListId },
+ ]);
+
+ mailingLists = await browser.mailingLists.list(firstBookId);
+ browser.test.assertEq(
+ 1,
+ mailingLists.length,
+ "List added to first book."
+ );
+
+ mailingLists = await browser.mailingLists.list(secondBookId);
+ browser.test.assertEq(
+ 0,
+ mailingLists.length,
+ "List not added to second book."
+ );
+
+ let newAddressList = await browser.mailingLists.get(newMailingListId);
+ browser.test.assertEq(8, Object.keys(newAddressList).length);
+ browser.test.assertEq(newMailingListId, newAddressList.id);
+ browser.test.assertEq(firstBookId, newAddressList.parentId);
+ browser.test.assertEq("mailingList", newAddressList.type);
+ browser.test.assertEq("name", newAddressList.name);
+ browser.test.assertEq("", newAddressList.nickName);
+ browser.test.assertEq("", newAddressList.description);
+ browser.test.assertEq(false, newAddressList.readOnly);
+ browser.test.assertEq(false, newAddressList.remote);
+
+ // Test that a valid name is ensured for an existing mail list
+ await browser.test.assertRejects(
+ browser.mailingLists.update(newMailingListId, {
+ name: "",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.update(newMailingListId, {
+ name: "Two spaces invalid name",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.update(newMailingListId, {
+ name: "><<<",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ await browser.mailingLists.update(newMailingListId, {
+ name: "name!",
+ nickName: "nickname!",
+ description: "description!",
+ });
+ await checkEvents([
+ "mailingLists",
+ "onUpdated",
+ { type: "mailingList", parentId: firstBookId, id: newMailingListId },
+ ]);
+
+ let updatedMailingList = await browser.mailingLists.get(newMailingListId);
+ browser.test.assertEq("name!", updatedMailingList.name);
+ browser.test.assertEq("nickname!", updatedMailingList.nickName);
+ browser.test.assertEq("description!", updatedMailingList.description);
+
+ await browser.mailingLists.addMember(newMailingListId, newContactId);
+ await checkEvents([
+ "mailingLists",
+ "onMemberAdded",
+ { type: "contact", parentId: newMailingListId, id: newContactId },
+ ]);
+
+ let listMembers = await browser.mailingLists.listMembers(
+ newMailingListId
+ );
+ browser.test.assertTrue(Array.isArray(listMembers));
+ browser.test.assertEq(1, listMembers.length);
+
+ let anotherContactId = await browser.contacts.create(firstBookId, {
+ FirstName: "second",
+ LastName: "last",
+ PrimaryEmail: "em@il",
+ });
+ await checkEvents([
+ "contacts",
+ "onCreated",
+ {
+ type: "contact",
+ parentId: firstBookId,
+ id: anotherContactId,
+ readOnly: false,
+ },
+ ]);
+
+ await browser.mailingLists.addMember(newMailingListId, anotherContactId);
+ await checkEvents([
+ "mailingLists",
+ "onMemberAdded",
+ { type: "contact", parentId: newMailingListId, id: anotherContactId },
+ ]);
+
+ listMembers = await browser.mailingLists.listMembers(newMailingListId);
+ browser.test.assertEq(2, listMembers.length);
+
+ await browser.contacts.delete(anotherContactId);
+ await checkEvents(
+ ["contacts", "onDeleted", firstBookId, anotherContactId],
+ ["mailingLists", "onMemberRemoved", newMailingListId, anotherContactId]
+ );
+ listMembers = await browser.mailingLists.listMembers(newMailingListId);
+ browser.test.assertEq(1, listMembers.length);
+
+ await browser.mailingLists.removeMember(newMailingListId, newContactId);
+ await checkEvents([
+ "mailingLists",
+ "onMemberRemoved",
+ newMailingListId,
+ newContactId,
+ ]);
+ listMembers = await browser.mailingLists.listMembers(newMailingListId);
+ browser.test.assertEq(0, listMembers.length);
+
+ await browser.mailingLists.delete(newMailingListId);
+ await checkEvents([
+ "mailingLists",
+ "onDeleted",
+ firstBookId,
+ newMailingListId,
+ ]);
+
+ mailingLists = await browser.mailingLists.list(firstBookId);
+ browser.test.assertEq(0, mailingLists.length);
+
+ for (let operation of [
+ "get",
+ "update",
+ "delete",
+ "listMembers",
+ "addMember",
+ "removeMember",
+ ]) {
+ let args = [newMailingListId];
+ switch (operation) {
+ case "update":
+ args.push({ name: "" });
+ break;
+ case "addMember":
+ case "removeMember":
+ args.push(newContactId);
+ break;
+ }
+
+ try {
+ await browser.mailingLists[operation].apply(
+ browser.mailingLists,
+ args
+ );
+ browser.test.fail(
+ `Calling ${operation} on a non-existent mailing list should throw`
+ );
+ } catch (ex) {
+ browser.test.assertEq(
+ `mailingList with id=${newMailingListId} could not be found.`,
+ ex.message,
+ `browser.mailingLists.${operation} threw exception`
+ );
+ }
+ }
+
+ // Test that a valid name is ensured for a new mail list
+ await browser.test.assertRejects(
+ browser.mailingLists.create(firstBookId, {
+ name: "",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.create(firstBookId, {
+ name: "Two spaces invalid name",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.create(firstBookId, {
+ name: "><<<",
+ }),
+ "An unexpected error occurred",
+ "browser.mailingLists.update threw exception"
+ );
+
+ browser.test.assertEq(0, events.length, "No events left unconsumed");
+ browser.test.log("Completed mailingListsTest");
+ }
+
+ async function contactRemovalTest() {
+ browser.test.log("Starting contactRemovalTest");
+ await browser.contacts.delete(newContactId);
+ await checkEvents(["contacts", "onDeleted", firstBookId, newContactId]);
+
+ for (let operation of ["get", "update", "delete"]) {
+ let args = [newContactId];
+ if (operation == "update") {
+ args.push({});
+ }
+
+ try {
+ await browser.contacts[operation].apply(browser.contacts, args);
+ browser.test.fail(
+ `Calling ${operation} on a non-existent contact should throw`
+ );
+ } catch (ex) {
+ browser.test.assertEq(
+ `contact with id=${newContactId} could not be found.`,
+ ex.message,
+ `browser.contacts.${operation} threw exception`
+ );
+ }
+ }
+
+ let contacts = await browser.contacts.list(firstBookId);
+ browser.test.assertEq(0, contacts.length);
+
+ browser.test.assertEq(0, events.length, "No events left unconsumed");
+ browser.test.log("Completed contactRemovalTest");
+ }
+
+ async function outsideEventsTest() {
+ browser.test.log("Starting outsideEventsTest");
+ let [bookId, newBookPrefId] = await outsideEvent("createAddressBook");
+ let [newBook] = await checkEvents([
+ "addressBooks",
+ "onCreated",
+ { type: "addressBook", id: bookId },
+ ]);
+ browser.test.assertEq("external add", newBook.name);
+
+ await outsideEvent("updateAddressBook", newBookPrefId);
+ let [updatedBook] = await checkEvents([
+ "addressBooks",
+ "onUpdated",
+ { type: "addressBook", id: bookId },
+ ]);
+ browser.test.assertEq("external edit", updatedBook.name);
+
+ await outsideEvent("deleteAddressBook", newBookPrefId);
+ await checkEvents(["addressBooks", "onDeleted", bookId]);
+
+ let [parentId1, contactId] = await outsideEvent("createContact");
+ let [newContact] = await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: parentId1, id: contactId },
+ ]);
+ browser.test.assertEq("external", newContact.properties.FirstName);
+ browser.test.assertEq("add", newContact.properties.LastName);
+ browser.test.assertTrue(
+ newContact.properties.vCard.includes("VERSION:4.0"),
+ "vCard should be version 4.0"
+ );
+
+ // Update the contact from outside.
+ await outsideEvent("updateContact", contactId);
+ let [updatedContact] = await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: parentId1, id: contactId },
+ { LastName: { oldValue: "add", newValue: "edit" } },
+ ]);
+ browser.test.assertEq("external", updatedContact.properties.FirstName);
+ browser.test.assertEq("edit", updatedContact.properties.LastName);
+
+ let [parentId2, listId] = await outsideEvent("createMailingList");
+ let [newList] = await checkEvents([
+ "mailingLists",
+ "onCreated",
+ { type: "mailingList", parentId: parentId2, id: listId },
+ ]);
+ browser.test.assertEq("external add", newList.name);
+
+ await outsideEvent("updateMailingList", listId);
+ let [updatedList] = await checkEvents([
+ "mailingLists",
+ "onUpdated",
+ { type: "mailingList", parentId: parentId2, id: listId },
+ ]);
+ browser.test.assertEq("external edit", updatedList.name);
+
+ await outsideEvent("addMailingListMember", listId, contactId);
+ await checkEvents([
+ "mailingLists",
+ "onMemberAdded",
+ { type: "contact", parentId: listId, id: contactId },
+ ]);
+ let listMembers = await browser.mailingLists.listMembers(listId);
+ browser.test.assertEq(1, listMembers.length);
+
+ await outsideEvent("removeMailingListMember", listId, contactId);
+ await checkEvents(["mailingLists", "onMemberRemoved", listId, contactId]);
+
+ await outsideEvent("deleteMailingList", listId);
+ await checkEvents(["mailingLists", "onDeleted", parentId2, listId]);
+
+ await outsideEvent("deleteContact", contactId);
+ await checkEvents(["contacts", "onDeleted", parentId1, contactId]);
+
+ browser.test.log("Completed outsideEventsTest");
+ }
+
+ await addressBookTest();
+ await contactsTest();
+ await mailingListsTest();
+ await contactRemovalTest();
+ await outsideEventsTest();
+
+ browser.test.notifyPass("addressBooks");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ let parent = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+ function findContact(id) {
+ for (let child of parent.childCards) {
+ if (child.UID == id) {
+ return child;
+ }
+ }
+ return null;
+ }
+ function findMailingList(id) {
+ for (let list of parent.childNodes) {
+ if (list.UID == id) {
+ return list;
+ }
+ }
+ return null;
+ }
+
+ extension.onMessage("outsideEventsTest", async (action, ...args) => {
+ switch (action) {
+ case "createAddressBook": {
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "external add",
+ "",
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+ extension.sendMessage(book.UID, dirPrefId);
+ return;
+ }
+ case "updateAddressBook": {
+ let book = MailServices.ab.getDirectoryFromId(args[0]);
+ book.dirName = "external edit";
+ extension.sendMessage();
+ return;
+ }
+ case "deleteAddressBook": {
+ let book = MailServices.ab.getDirectoryFromId(args[0]);
+ MailServices.ab.deleteAddressBook(book.URI);
+ extension.sendMessage();
+ return;
+ }
+ case "createContact": {
+ let contact = new AddrBookCard();
+ contact.firstName = "external";
+ contact.lastName = "add";
+ contact.primaryEmail = "test@invalid";
+
+ let newContact = parent.addCard(contact);
+ extension.sendMessage(parent.UID, newContact.UID);
+ return;
+ }
+ case "updateContact": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ contact.firstName = "external";
+ contact.lastName = "edit";
+ parent.modifyCard(contact);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "deleteContact": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ parent.deleteCards([contact]);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "createMailingList": {
+ let list = Cc[
+ "@mozilla.org/addressbook/directoryproperty;1"
+ ].createInstance(Ci.nsIAbDirectory);
+ list.isMailList = true;
+ list.dirName = "external add";
+
+ let newList = parent.addMailList(list);
+ extension.sendMessage(parent.UID, newList.UID);
+ return;
+ }
+ case "updateMailingList": {
+ let list = findMailingList(args[0]);
+ if (list) {
+ list.dirName = "external edit";
+ list.editMailListToDatabase(null);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "deleteMailingList": {
+ let list = findMailingList(args[0]);
+ if (list) {
+ parent.deleteDirectory(list);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "addMailingListMember": {
+ let list = findMailingList(args[0]);
+ let contact = findContact(args[1]);
+
+ if (list && contact) {
+ list.addCard(contact);
+ equal(1, list.childCards.length);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "removeMailingListMember": {
+ let list = findMailingList(args[0]);
+ let contact = findContact(args[1]);
+
+ if (list && contact) {
+ list.deleteCards([contact]);
+ equal(0, list.childCards.length);
+ ok(findContact(args[1]), "Contact was not removed");
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ }
+ throw new Error(
+ `Message "${action}" passed to handler didn't do anything.`
+ );
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
+
+add_task(async function test_addressBooks_MV3_event_pages() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ // Create and register event listener.
+ for (let event of [
+ "addressBooks.onCreated",
+ "addressBooks.onUpdated",
+ "addressBooks.onDeleted",
+ "contacts.onCreated",
+ "contacts.onUpdated",
+ "contacts.onDeleted",
+ "mailingLists.onCreated",
+ "mailingLists.onUpdated",
+ "mailingLists.onDeleted",
+ "mailingLists.onMemberAdded",
+ "mailingLists.onMemberRemoved",
+ ]) {
+ let [apiName, eventName] = event.split(".");
+ browser[apiName][eventName].addListener((...args) => {
+ // Only send the first event after background wake-up, this should be
+ // the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${apiName}.${eventName} received`, args);
+ }
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ browser_specific_settings: { gecko: { id: "addressbook@xpcshell.test" } },
+ },
+ });
+
+ let parent = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+ function findContact(id) {
+ for (let child of parent.childCards) {
+ if (child.UID == id) {
+ return child;
+ }
+ }
+ return null;
+ }
+ function findMailingList(id) {
+ for (let list of parent.childNodes) {
+ if (list.UID == id) {
+ return list;
+ }
+ }
+ return null;
+ }
+ function outsideEvent(action, ...args) {
+ switch (action) {
+ case "createAddressBook": {
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "external add",
+ "",
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+ return [book, dirPrefId];
+ }
+ case "updateAddressBook": {
+ let book = MailServices.ab.getDirectoryFromId(args[0]);
+ book.dirName = "external edit";
+ return [];
+ }
+ case "deleteAddressBook": {
+ let book = MailServices.ab.getDirectoryFromId(args[0]);
+ MailServices.ab.deleteAddressBook(book.URI);
+ return [];
+ }
+ case "createContact": {
+ let contact = new AddrBookCard();
+ contact.firstName = "external";
+ contact.lastName = "add";
+ contact.primaryEmail = "test@invalid";
+
+ let newContact = parent.addCard(contact);
+ return [parent.UID, newContact.UID];
+ }
+ case "updateContact": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ contact.firstName = "external";
+ contact.lastName = "edit";
+ parent.modifyCard(contact);
+ return [];
+ }
+ break;
+ }
+ case "deleteContact": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ parent.deleteCards([contact]);
+ return [];
+ }
+ break;
+ }
+ case "createMailingList": {
+ let list = Cc[
+ "@mozilla.org/addressbook/directoryproperty;1"
+ ].createInstance(Ci.nsIAbDirectory);
+ list.isMailList = true;
+ list.dirName = "external add";
+
+ let newList = parent.addMailList(list);
+ return [parent.UID, newList.UID];
+ }
+ case "updateMailingList": {
+ let list = findMailingList(args[0]);
+ if (list) {
+ list.dirName = "external edit";
+ list.editMailListToDatabase(null);
+ return [];
+ }
+ break;
+ }
+ case "deleteMailingList": {
+ let list = findMailingList(args[0]);
+ if (list) {
+ parent.deleteDirectory(list);
+ return [];
+ }
+ break;
+ }
+ case "addMailingListMember": {
+ let list = findMailingList(args[0]);
+ let contact = findContact(args[1]);
+
+ if (list && contact) {
+ list.addCard(contact);
+ equal(1, list.childCards.length);
+ return [];
+ }
+ break;
+ }
+ case "removeMailingListMember": {
+ let list = findMailingList(args[0]);
+ let contact = findContact(args[1]);
+
+ if (list && contact) {
+ list.deleteCards([contact]);
+ equal(0, list.childCards.length);
+ ok(findContact(args[1]), "Contact was not removed");
+ return [];
+ }
+ break;
+ }
+ }
+ throw new Error(
+ `Message "${action}" passed to handler didn't do anything.`
+ );
+ }
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "addressBook.onAddressBookCreated",
+ "addressBook.onAddressBookUpdated",
+ "addressBook.onAddressBookDeleted",
+ "addressBook.onContactCreated",
+ "addressBook.onContactUpdated",
+ "addressBook.onContactDeleted",
+ "addressBook.onMailingListCreated",
+ "addressBook.onMailingListUpdated",
+ "addressBook.onMailingListDeleted",
+ "addressBook.onMemberAdded",
+ "addressBook.onMemberRemoved",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+ checkPersistentListeners({ primed: false });
+
+ // addressBooks.onCreated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ let [newBook, dirPrefId] = outsideEvent("createAddressBook");
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [
+ {
+ id: newBook.UID,
+ type: "addressBook",
+ name: "external add",
+ readOnly: false,
+ remote: false,
+ },
+ ],
+ await extension.awaitMessage("addressBooks.onCreated received"),
+ "The primed addressBooks.onCreated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // addressBooks.onUpdated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("updateAddressBook", dirPrefId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [
+ {
+ id: newBook.UID,
+ type: "addressBook",
+ name: "external edit",
+ readOnly: false,
+ remote: false,
+ },
+ ],
+ await extension.awaitMessage("addressBooks.onUpdated received"),
+ "The primed addressBooks.onUpdated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // addressBooks.onDeleted.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("deleteAddressBook", dirPrefId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [newBook.UID],
+ await extension.awaitMessage("addressBooks.onDeleted received"),
+ "The primed addressBooks.onDeleted event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // contacts.onCreated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ let [parentId1, contactId] = outsideEvent("createContact");
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ let [createdNode] = await extension.awaitMessage(
+ "contacts.onCreated received"
+ );
+ Assert.deepEqual(
+ {
+ type: "contact",
+ parentId: parentId1,
+ id: contactId,
+ },
+ {
+ type: createdNode.type,
+ parentId: createdNode.parentId,
+ id: createdNode.id,
+ },
+ "The primed contacts.onCreated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // contacts.onUpdated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("updateContact", contactId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ let [updatedNode, changedProperties] = await extension.awaitMessage(
+ "contacts.onUpdated received"
+ );
+ Assert.deepEqual(
+ [
+ { type: "contact", parentId: parentId1, id: contactId },
+ { LastName: { oldValue: "add", newValue: "edit" } },
+ ],
+ [
+ {
+ type: updatedNode.type,
+ parentId: updatedNode.parentId,
+ id: updatedNode.id,
+ },
+ changedProperties,
+ ],
+ "The primed contacts.onUpdated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // mailingLists.onCreated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ let [parentId2, listId] = outsideEvent("createMailingList");
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [
+ {
+ type: "mailingList",
+ parentId: parentId2,
+ id: listId,
+ name: "external add",
+ nickName: "",
+ description: "",
+ readOnly: false,
+ remote: false,
+ },
+ ],
+ await extension.awaitMessage("mailingLists.onCreated received"),
+ "The primed mailingLists.onCreated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // mailingList.onUpdated.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("updateMailingList", listId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [
+ {
+ type: "mailingList",
+ parentId: parentId2,
+ id: listId,
+ name: "external edit",
+ nickName: "",
+ description: "",
+ readOnly: false,
+ remote: false,
+ },
+ ],
+ await extension.awaitMessage("mailingLists.onUpdated received"),
+ "The primed mailingLists.onUpdated event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // mailingList.onMemberAdded.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("addMailingListMember", listId, contactId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ let [addedNode] = await extension.awaitMessage(
+ "mailingLists.onMemberAdded received"
+ );
+ Assert.deepEqual(
+ { type: "contact", parentId: listId, id: contactId },
+ { type: addedNode.type, parentId: addedNode.parentId, id: addedNode.id },
+ "The primed mailingLists.onMemberAdded event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // mailingList.onMemberRemoved.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("removeMailingListMember", listId, contactId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [listId, contactId],
+ await extension.awaitMessage("mailingLists.onMemberRemoved received"),
+ "The primed mailingLists.onMemberRemoved event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // mailingList.onDeleted.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("deleteMailingList", listId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [parentId2, listId],
+ await extension.awaitMessage("mailingLists.onDeleted received"),
+ "The primed mailingLists.onDeleted event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ // contacts.onDeleted.
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ checkPersistentListeners({ primed: true });
+ outsideEvent("deleteContact", contactId);
+ // The event should have restarted the background.
+ await extension.awaitMessage("background started");
+ Assert.deepEqual(
+ [parentId1, contactId],
+ await extension.awaitMessage("contacts.onDeleted received"),
+ "The primed contacts.onDeleted event should return the correct values"
+ );
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+add_task(async function test_photos() {
+ async function background() {
+ let events = [];
+ let eventPromise;
+ let eventPromiseResolve;
+ for (let eventNamespace of ["addressBooks", "contacts"]) {
+ for (let eventName of ["onCreated", "onUpdated", "onDeleted"]) {
+ if (eventName in browser[eventNamespace]) {
+ browser[eventNamespace][eventName].addListener((...args) => {
+ events.push({ namespace: eventNamespace, name: eventName, args });
+ if (eventPromiseResolve) {
+ let resolve = eventPromiseResolve;
+ eventPromiseResolve = null;
+ resolve();
+ }
+ });
+ }
+ }
+ }
+
+ let getDataUrl = function (file) {
+ return new Promise((resolve, reject) => {
+ var reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = function () {
+ resolve(reader.result);
+ };
+ reader.onerror = function (error) {
+ reject(new Error(error));
+ };
+ });
+ };
+
+ let updateAndVerifyPhoto = async function (
+ parentId,
+ id,
+ photoFile,
+ photoData
+ ) {
+ eventPromise = new Promise(resolve => {
+ eventPromiseResolve = resolve;
+ });
+ await browser.contacts.setPhoto(id, photoFile);
+
+ await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId, id },
+ {},
+ ]);
+ let updatedPhoto = await browser.contacts.getPhoto(id);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(updatedPhoto instanceof File);
+ browser.test.assertEq("image/png", updatedPhoto.type);
+ browser.test.assertEq(`${id}.png`, updatedPhoto.name);
+ browser.test.assertEq(photoData, await getDataUrl(updatedPhoto));
+ };
+ let normalizeVCard = function (vCard) {
+ return vCard
+ .replaceAll("\r\n", "")
+ .replaceAll("\n", "")
+ .replaceAll(" ", "");
+ };
+ let outsideEvent = function (action, ...args) {
+ eventPromise = new Promise(resolve => {
+ eventPromiseResolve = resolve;
+ });
+ return window.sendMessage("outsideEventsTest", action, ...args);
+ };
+ let checkEvents = async function (...expectedEvents) {
+ if (eventPromiseResolve) {
+ await eventPromise;
+ }
+
+ browser.test.assertEq(
+ expectedEvents.length,
+ events.length,
+ "Correct number of events"
+ );
+
+ if (expectedEvents.length != events.length) {
+ for (let event of events) {
+ let args = event.args.join(", ");
+ browser.test.log(`${event.namespace}.${event.name}(${args})`);
+ }
+ throw new Error("Wrong number of events, stopping.");
+ }
+
+ for (let [namespace, name, ...expectedArgs] of expectedEvents) {
+ let event = events.shift();
+ browser.test.assertEq(
+ namespace,
+ event.namespace,
+ "Event namespace is correct"
+ );
+ browser.test.assertEq(name, event.name, "Event type is correct");
+ browser.test.assertEq(
+ expectedArgs.length,
+ event.args.length,
+ "Argument count is correct"
+ );
+ window.assertDeepEqual(expectedArgs, event.args);
+ if (expectedEvents.length == 1) {
+ return event.args;
+ }
+ }
+
+ return null;
+ };
+
+ let whitePixelData =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC";
+ let bluePixelData =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQg==";
+ let greenPixelData =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY5CeYAMAAbEA6ASxSWcAAAAASUVORK5CYII=";
+ let redPixelData =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY3growIAAycBLhVrvukAAAAASUVORK5CYII=";
+ let vCard3WhitePixel =
+ "PHOTO;ENCODING=B;TYPE=PNG:iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC";
+ let vCard4WhitePixel =
+ "PHOTO;VALUE=URL:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC";
+ let vCard4BluePixel =
+ "PHOTO;VALUE=URL:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQg==";
+
+ // Create a photo file, which is linked to a local file to simulate a file
+ // opened through a filepicker.
+ let [redPixelRealFile] = await window.sendMessage("getRedPixelFile");
+
+ // Create a photo file, which is a simple data blob.
+ let greenPixelFile = await fetch(greenPixelData)
+ .then(res => res.arrayBuffer())
+ .then(buf => new File([buf], "greenPixel.png", { type: "image/png" }));
+
+ // -------------------------------------------------------------------------
+ // Test vCard v4 with a photoName set.
+ // -------------------------------------------------------------------------
+
+ let [parentId1, contactId1, photoName1] = await outsideEvent(
+ "createV4ContactWithPhotoName"
+ );
+ let [newContact] = await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: parentId1, id: contactId1 },
+ ]);
+ browser.test.assertEq("external", newContact.properties.FirstName);
+ browser.test.assertEq("add", newContact.properties.LastName);
+ browser.test.assertTrue(
+ newContact.properties.vCard.includes("VERSION:4.0"),
+ "vCard should be version 4.0"
+ );
+ browser.test.assertTrue(
+ normalizeVCard(newContact.properties.vCard).includes(vCard4WhitePixel),
+ `vCard should include the correct Photo property [${normalizeVCard(
+ newContact.properties.vCard
+ )}] vs [${vCard4WhitePixel}]`
+ );
+ // Check internal photoUrl is the correct fileUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId1,
+ `^file:.*?${photoName1}$`
+ );
+
+ // Test if we can get the photo through the API.
+
+ let photo = await browser.contacts.getPhoto(contactId1);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(photo instanceof File);
+ browser.test.assertEq("image/png", photo.type);
+ browser.test.assertEq(`${contactId1}.png`, photo.name);
+ browser.test.assertEq(
+ whitePixelData,
+ await getDataUrl(photo),
+ "vCard 4.0 contact with photo from internal fileUrl from photoName should return the correct photo file"
+ );
+ // Re-check internal photoUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId1,
+ `^file:.*?${photoName1}$`
+ );
+
+ // Test if we can update the photo through the API by providing a file which
+ // is linked to a local file. Since this vCard had only a photoName set and
+ // its photo stored as a local file, the updated photo should also be stored
+ // as a local file.
+
+ await updateAndVerifyPhoto(
+ parentId1,
+ contactId1,
+ redPixelRealFile,
+ redPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId1,
+ `^file:.*?${contactId1}\.png$`
+ );
+
+ // Test if we can update the photo through the API, by providing a pure data
+ // blob (decoupled from a local file, without file.mozFullPath set).
+
+ await updateAndVerifyPhoto(
+ parentId1,
+ contactId1,
+ greenPixelFile,
+ greenPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId1,
+ `^file:.*?${contactId1}-1\.png$`
+ );
+
+ // Test if we get the correct photo if it is updated by the user, storing the
+ // photo in its vCard (outside of the API).
+
+ await outsideEvent("updateV4ContactWithBluePixel", contactId1);
+ let [updatedContact1] = await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: parentId1, id: contactId1 },
+ { LastName: { oldValue: "add", newValue: "edit" } },
+ ]);
+ browser.test.assertEq("external", updatedContact1.properties.FirstName);
+ browser.test.assertEq("edit", updatedContact1.properties.LastName);
+ let updatedPhoto1 = await browser.contacts.getPhoto(contactId1);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(updatedPhoto1 instanceof File);
+ browser.test.assertEq("image/png", updatedPhoto1.type);
+ browser.test.assertEq(`${contactId1}.png`, updatedPhoto1.name);
+ browser.test.assertEq(bluePixelData, await getDataUrl(updatedPhoto1));
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId1,
+ bluePixelData
+ );
+
+ // -------------------------------------------------------------------------
+ // Test vCard v4 with a photoName and also a photo in its vCard.
+ // -------------------------------------------------------------------------
+
+ let [parentId2, contactId2] = await outsideEvent(
+ "createV4ContactWithBothPhotoProps"
+ );
+ let [newContact2] = await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: parentId2, id: contactId2 },
+ ]);
+ browser.test.assertEq("external", newContact2.properties.FirstName);
+ browser.test.assertEq("add", newContact2.properties.LastName);
+ browser.test.assertTrue(
+ newContact2.properties.vCard.includes("VERSION:4.0"),
+ "vCard should be version 4.0"
+ );
+ // The card should not include vCard4WhitePixel (which photoName points to),
+ // but the value of vCard4BluePixel stored in the vCard photo property.
+ browser.test.assertTrue(
+ normalizeVCard(newContact2.properties.vCard).includes(vCard4BluePixel),
+ `vCard should include the correct Photo property [${normalizeVCard(
+ newContact2.properties.vCard
+ )}] vs [${vCard4BluePixel}]`
+ );
+ // Check internal photoUrl is the correct dataUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId2,
+ bluePixelData
+ );
+
+ // Test if we can get the correct photo through the API.
+
+ let photo3 = await browser.contacts.getPhoto(contactId2);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(photo3 instanceof File);
+ browser.test.assertEq("image/png", photo3.type);
+ browser.test.assertEq(`${contactId2}.png`, photo3.name);
+ browser.test.assertEq(
+ bluePixelData,
+ await getDataUrl(photo3),
+ "vCard 4.0 contact with photo from internal dataUrl from vCard (vCard wins over photoName) should return the correct photo file"
+ );
+ // Re-check internal photoUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId2,
+ bluePixelData
+ );
+
+ // Test if we can update the photo through the API by providing a file which
+ // is linked to a local file. Since this vCard had its photo stored as dataUrl
+ // in the vCard, the updated photo should be stored as a dataUrl as well.
+
+ await updateAndVerifyPhoto(
+ parentId2,
+ contactId2,
+ redPixelRealFile,
+ redPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId2,
+ redPixelData
+ );
+
+ // Test if we can update the photo through the API, by providing a pure data
+ // blob (decoupled from a local file, without file.mozFullPath set).
+
+ await updateAndVerifyPhoto(
+ parentId2,
+ contactId2,
+ greenPixelFile,
+ greenPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId2,
+ greenPixelData
+ );
+
+ // -------------------------------------------------------------------------
+ // Test vCard v3 with a photoName set.
+ // -------------------------------------------------------------------------
+
+ let [parentId3, contactId3, photoName4] = await outsideEvent(
+ "createV3ContactWithPhotoName"
+ );
+ let [newContact4] = await checkEvents([
+ "contacts",
+ "onCreated",
+ { type: "contact", parentId: parentId3, id: contactId3 },
+ ]);
+ browser.test.assertEq("external", newContact4.properties.FirstName);
+ browser.test.assertEq("add", newContact4.properties.LastName);
+ browser.test.assertTrue(
+ newContact4.properties.vCard.includes("VERSION:3.0"),
+ "vCard should be version 3.0"
+ );
+ browser.test.assertTrue(
+ normalizeVCard(newContact4.properties.vCard).includes(vCard3WhitePixel),
+ `vCard should include the correct Photo property [${normalizeVCard(
+ newContact4.properties.vCard
+ )}] vs [${vCard3WhitePixel}]`
+ );
+ // Check internal photoUrl is the correct fileUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId3,
+ `^file:.*?${photoName4}$`
+ );
+ let photo4 = await browser.contacts.getPhoto(contactId3);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(photo4 instanceof File);
+ browser.test.assertEq("image/png", photo4.type);
+ browser.test.assertEq(`${contactId3}.png`, photo4.name);
+ browser.test.assertEq(
+ whitePixelData,
+ await getDataUrl(photo4),
+ "vCard 3.0 contact with photo from internal fileUrl from photoName should return the correct photo file"
+ );
+ // Re-check internal photoUrl.
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId3,
+ `^file:.*?${photoName4}$`
+ );
+
+ // Test if we can update the photo through the API by providing a file which
+ // is linked to a local file. Since this vCard had only a photoName set and
+ // its photo stored as a local file, the updated photo should also be stored
+ // as a local file.
+
+ await updateAndVerifyPhoto(
+ parentId3,
+ contactId3,
+ redPixelRealFile,
+ redPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId3,
+ `^file:.*?${contactId3}\.png$`
+ );
+
+ // Test if we can update the photo through the API, by providing a pure data
+ // blob (decoupled from a local file, without file.mozFullPath set).
+
+ await updateAndVerifyPhoto(
+ parentId3,
+ contactId3,
+ greenPixelFile,
+ greenPixelData
+ );
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId3,
+ `^file:.*?${contactId3}-1\.png$`
+ );
+
+ // Test if we get the correct photo if it is updated by the user, storing the
+ // photo in its vCard (outside of the API).
+
+ await outsideEvent("updateV3ContactWithBluePixel", contactId3);
+ let [updatedContact3] = await checkEvents([
+ "contacts",
+ "onUpdated",
+ { type: "contact", parentId: parentId3, id: contactId3 },
+ { LastName: { oldValue: "add", newValue: "edit" } },
+ ]);
+ browser.test.assertEq("external", updatedContact3.properties.FirstName);
+ browser.test.assertEq("edit", updatedContact3.properties.LastName);
+ let updatedPhoto3 = await browser.contacts.getPhoto(contactId3);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(updatedPhoto3 instanceof File);
+ browser.test.assertEq("image/png", updatedPhoto3.type);
+ browser.test.assertEq(`${contactId3}.png`, updatedPhoto3.name);
+ browser.test.assertEq(bluePixelData, await getDataUrl(updatedPhoto3));
+ await window.sendMessage(
+ "verifyInternalPhotoUrl",
+ contactId3,
+ bluePixelData
+ );
+
+ // Cleanup. Delete all created contacts.
+
+ await outsideEvent("deleteContact", contactId1);
+ await checkEvents(["contacts", "onDeleted", parentId1, contactId1]);
+ await outsideEvent("deleteContact", contactId2);
+ await checkEvents(["contacts", "onDeleted", parentId2, contactId2]);
+ await outsideEvent("deleteContact", contactId3);
+ await checkEvents(["contacts", "onDeleted", parentId3, contactId3]);
+ browser.test.notifyPass("addressBooksPhotos");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ let parent = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+ function findContact(id) {
+ for (let child of parent.childCards) {
+ if (child.UID == id) {
+ return child;
+ }
+ }
+ return null;
+ }
+
+ async function getUniqueWhitePixelFile() {
+ // Copy photo file into the required Photos subfolder of the profile folder.
+ let photoName = `${AddrBookUtils.newUID()}.png`;
+ await IOUtils.copy(
+ do_get_file("images/whitePixel.png").path,
+ PathUtils.join(PathUtils.profileDir, "Photos", photoName)
+ );
+ return photoName;
+ }
+
+ extension.onMessage("getRedPixelFile", async () => {
+ let redPixelFile = await File.createFromNsIFile(
+ do_get_file("images/redPixel.png")
+ );
+ extension.sendMessage(redPixelFile);
+ });
+
+ extension.onMessage("verifyInternalPhotoUrl", (id, expected) => {
+ let contact = findContact(id);
+ let photoUrl = contact.photoURL;
+ if (expected.startsWith("data:")) {
+ Assert.equal(expected, photoUrl, `photoURL should be correct`);
+ } else {
+ let regExp = new RegExp(expected);
+ Assert.ok(
+ regExp.test(photoUrl),
+ `photoURL <${photoUrl}> should match expected regExp <${expected}>`
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("outsideEventsTest", async (action, ...args) => {
+ switch (action) {
+ case "createV4ContactWithPhotoName": {
+ let photoName = await getUniqueWhitePixelFile();
+ let contact = new AddrBookCard();
+ contact.firstName = "external";
+ contact.lastName = "add";
+ contact.primaryEmail = "test@invalid";
+ contact.setProperty("PhotoName", photoName);
+
+ let newContact = parent.addCard(contact);
+ extension.sendMessage(parent.UID, newContact.UID, photoName);
+ return;
+ }
+ case "createV4ContactWithBothPhotoProps": {
+ // This contact has whitePixel as file but bluePixel in the vCard.
+ let photoName = await getUniqueWhitePixelFile();
+ let contact = new AddrBookCard();
+ contact.setProperty("PhotoName", photoName);
+ contact.setProperty(
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ VERSION:4.0
+ EMAIL;PREF=1:test@invalid
+ N:add;external;;;
+ UID:fd9aecf9-2453-4ba1-bec6-574a15bb380b
+ PHOTO;VALUE=URL:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAA
+ ACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQg==
+ END:VCARD
+ `
+ );
+ let newContact = parent.addCard(contact);
+ extension.sendMessage(parent.UID, newContact.UID, photoName);
+ return;
+ }
+ case "updateV4ContactWithBluePixel": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ contact.setProperty(
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ VERSION:4.0
+ EMAIL;PREF=1:test@invalid
+ N:edit;external;;;
+ PHOTO;VALUE=URL:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAA
+ ACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQg==
+ END:VCARD
+ `
+ );
+ parent.modifyCard(contact);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "createV3ContactWithPhotoName": {
+ let photoName = await getUniqueWhitePixelFile();
+ let contact = new AddrBookCard();
+ contact.setProperty("PhotoName", photoName);
+ contact.setProperty(
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ VERSION:3.0
+ EMAIL:test@invalid
+ N:add;external
+ UID:fd9aecf9-2453-4ba1-bec6-574a15bb380c
+ END:VCARD
+ `
+ );
+ let newContact = parent.addCard(contact);
+ extension.sendMessage(parent.UID, newContact.UID, photoName);
+ return;
+ }
+ case "updateV3ContactWithBluePixel": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ contact.setProperty(
+ "_vCard",
+ formatVCard`
+ BEGIN:VCARD
+ VERSION:3.0
+ EMAIL:test@invalid
+ N:edit;external
+ PHOTO;ENCODING=b;TYPE=PNG:iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAD
+ ElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQg==
+ END:VCARD
+ `
+ );
+ parent.modifyCard(contact);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ case "deleteContact": {
+ let contact = findContact(args[0]);
+ if (contact) {
+ parent.deleteCards([contact]);
+ extension.sendMessage();
+ return;
+ }
+ break;
+ }
+ }
+ throw new Error(
+ `Message "${action}" passed to handler didn't do anything.`
+ );
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("addressBooksPhotos");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_provider.js b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_provider.js
new file mode 100644
index 0000000000..a09540dcbe
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_provider.js
@@ -0,0 +1,139 @@
+/* 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 { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_task(async function () {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let id = "9b9074ff-8fa4-4c58-9c3b-bc9ea2e17db1";
+ let dummy = async (node, searchString, query) => {
+ await browser.test.assertTrue(
+ false,
+ "Should have removed this address book"
+ );
+ };
+ await browser.addressBooks.provider.onSearchRequest.addListener(dummy, {
+ addressBookName: "dummy",
+ isSecure: false,
+ id,
+ });
+ await browser.addressBooks.provider.onSearchRequest.removeListener(dummy);
+ id = "00e1d9af-a846-4ef5-a6ac-15e8926bf6d3";
+ await browser.addressBooks.provider.onSearchRequest.addListener(
+ async (node, searchString, query) => {
+ await browser.test.assertEq(
+ id,
+ node.id,
+ "Addressbook should have the id we requested"
+ );
+ return {
+ results: [
+ {
+ DisplayName: searchString,
+ PrimaryEmail: searchString + "@example.com",
+ },
+ ],
+ isCompleteResult: true,
+ };
+ },
+ {
+ addressBookName: "xpcshell",
+ isSecure: false,
+ id,
+ }
+ );
+ await browser.addressBooks.provider.onSearchRequest.addListener(
+ async (node, searchString, query) => {
+ await browser.test.assertTrue(
+ false,
+ "Should not have created a duplicate address book"
+ );
+ },
+ {
+ addressBookName: "xpcshell",
+ isSecure: false,
+ id,
+ }
+ );
+ },
+ manifest: { permissions: ["addressBooks"] },
+ });
+
+ await extension.startup();
+
+ const dummyUID = "9b9074ff-8fa4-4c58-9c3b-bc9ea2e17db1";
+ let searchBook = MailServices.ab.getDirectoryFromUID(dummyUID);
+ ok(searchBook == null, "Dummy directory was removed by extension");
+
+ const UID = "00e1d9af-a846-4ef5-a6ac-15e8926bf6d3";
+ searchBook = MailServices.ab.getDirectoryFromUID(UID);
+ ok(searchBook != null, "Extension registered an async directory");
+
+ let foundCards = 0;
+ await new Promise(resolve => {
+ searchBook.search(null, "test", {
+ onSearchFoundCard(card) {
+ ok(card != null, "A card was found.");
+ equal(card.directoryUID, UID, "The card comes from the directory.");
+ equal(
+ card.primaryEmail,
+ "test@example.com",
+ "The card has the correct email address."
+ );
+ equal(
+ card.displayName,
+ "test",
+ "The card has the correct display name."
+ );
+ foundCards++;
+ },
+ onSearchFinished(status, isCompleteResult) {
+ ok(Components.isSuccessCode(status), "Search finished successfully.");
+ equal(foundCards, 1, "One card was found.");
+ ok(isCompleteResult, "A full result set was received.");
+ resolve();
+ },
+ });
+ });
+
+ let autoCompleteSearch = Cc[
+ "@mozilla.org/autocomplete/search;1?name=addrbook"
+ ].createInstance(Ci.nsIAutoCompleteSearch);
+ await new Promise(resolve => {
+ autoCompleteSearch.startSearch("test", null, null, {
+ onSearchResult(aSearch, aResult) {
+ equal(aSearch, autoCompleteSearch, "This is our search.");
+ if (aResult.searchResult == Ci.nsIAutoCompleteResult.RESULT_SUCCESS) {
+ equal(aResult.matchCount, 1, "One match was found.");
+ equal(
+ aResult.getValueAt(0),
+ "test <test@example.com>",
+ "The match had the expected value."
+ );
+ resolve();
+ } else {
+ equal(
+ aResult.searchResult,
+ Ci.nsIAutoCompleteResult.RESULT_NOMATCH_ONGOING,
+ "We should be waiting for the extension's results."
+ );
+ }
+ },
+ });
+ });
+
+ await extension.unload();
+ searchBook = MailServices.ab.getDirectoryFromUID(UID);
+ ok(searchBook == null, "Extension directory removed after unload");
+});
+
+registerCleanupFunction(() => {
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_quickSearch.js b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_quickSearch.js
new file mode 100644
index 0000000000..9a6bbd8f4e
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_quickSearch.js
@@ -0,0 +1,238 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { LDAPServer } = ChromeUtils.import(
+ "resource://testing-common/LDAPServer.jsm"
+);
+
+add_setup(async () => {
+ Services.prefs.setIntPref("ldap_2.servers.osx.dirType", -1);
+
+ registerCleanupFunction(() => {
+ LDAPServer.close();
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+ });
+});
+
+add_task(async function test_quickSearch() {
+ async function background() {
+ let book1 = await browser.addressBooks.create({ name: "book1" });
+ let book2 = await browser.addressBooks.create({ name: "book2" });
+
+ let book1contacts = {
+ charlie: await browser.contacts.create(book1, { FirstName: "charlie" }),
+ juliet: await browser.contacts.create(book1, { FirstName: "juliet" }),
+ mike: await browser.contacts.create(book1, { FirstName: "mike" }),
+ oscar: await browser.contacts.create(book1, { FirstName: "oscar" }),
+ papa: await browser.contacts.create(book1, { FirstName: "papa" }),
+ romeo: await browser.contacts.create(book1, { FirstName: "romeo" }),
+ victor: await browser.contacts.create(book1, { FirstName: "victor" }),
+ };
+
+ let book2contacts = {
+ bigBird: await browser.contacts.create(book2, {
+ FirstName: "Big",
+ LastName: "Bird",
+ }),
+ cookieMonster: await browser.contacts.create(book2, {
+ FirstName: "Cookie",
+ LastName: "Monster",
+ }),
+ elmo: await browser.contacts.create(book2, { FirstName: "Elmo" }),
+ grover: await browser.contacts.create(book2, { FirstName: "Grover" }),
+ oscarTheGrouch: await browser.contacts.create(book2, {
+ FirstName: "Oscar",
+ LastName: "The Grouch",
+ }),
+ };
+
+ // A search string without a match in either book.
+ let results = await browser.contacts.quickSearch(book1, "snuffleupagus");
+ browser.test.assertEq(0, results.length);
+
+ // A search string with a match in the book we're searching.
+ results = await browser.contacts.quickSearch(book1, "mike");
+ browser.test.assertEq(1, results.length);
+ browser.test.assertEq(book1contacts.mike, results[0].id);
+
+ // A search string passed via queryInfo
+ results = await browser.contacts.quickSearch(book1, {
+ searchString: "mike",
+ });
+ browser.test.assertEq(1, results.length);
+ browser.test.assertEq(book1contacts.mike, results[0].id);
+
+ // A search string with a match in the book we're not searching.
+ results = await browser.contacts.quickSearch(book1, "elmo");
+ browser.test.assertEq(0, results.length);
+
+ // A search string with a match in both books.
+ results = await browser.contacts.quickSearch(book1, "oscar");
+ browser.test.assertEq(1, results.length);
+ browser.test.assertEq(book1contacts.oscar, results[0].id);
+
+ // A search string with a match in both books. Looking in all books.
+ results = await browser.contacts.quickSearch("oscar");
+ browser.test.assertEq(2, results.length);
+ browser.test.assertEq(book1contacts.oscar, results[0].id);
+ browser.test.assertEq(book2contacts.oscarTheGrouch, results[1].id);
+
+ // No valid search strings.
+ results = await browser.contacts.quickSearch(" ");
+ browser.test.assertEq(0, results.length);
+
+ await browser.addressBooks.delete(book1);
+ await browser.addressBooks.delete(book2);
+
+ browser.test.notifyPass("addressBooks");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: { permissions: ["addressBooks"] },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
+
+add_task(async function test_quickSearch_types() {
+ // If nsIAbLDAPDirectory doesn't exist in our build options, someone has
+ // specified --disable-ldap
+ if (!("nsIAbLDAPDirectory" in Ci)) {
+ return;
+ }
+
+ // Add a card to the personal AB.
+ let personaAB = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+
+ let contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact.UID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
+ contact.displayName = "personal contact";
+ contact.firstName = "personal";
+ contact.lastName = "contact";
+ contact.primaryEmail = "personal@invalid";
+ contact = personaAB.addCard(contact);
+
+ // Set up the history AB as read-only.
+ let historyAB = MailServices.ab.getDirectory("jsaddrbook://history.sqlite");
+
+ contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact.UID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxy";
+ contact.displayName = "history contact";
+ contact.firstName = "history";
+ contact.lastName = "contact";
+ contact.primaryEmail = "history@invalid";
+ contact = historyAB.addCard(contact);
+
+ historyAB.setBoolValue("readOnly", true);
+
+ Assert.ok(historyAB.readOnly);
+
+ // Set up an LDAP address book.
+ LDAPServer.open();
+
+ // Create an LDAP directory
+ MailServices.ab.newAddressBook(
+ "test",
+ `ldap://localhost:${LDAPServer.port}/people??sub?(objectclass=*)`,
+ Ci.nsIAbManager.LDAP_DIRECTORY_TYPE
+ );
+
+ async function background() {
+ function checkCards(cards, expectedNames) {
+ browser.test.assertEq(expectedNames.length, cards.length);
+ let expected = new Set(expectedNames);
+ for (let card of cards) {
+ expected.delete(card.properties.FirstName);
+ }
+ browser.test.assertEq(
+ 0,
+ expected.size,
+ "Should have seen all expected cards"
+ );
+ }
+ // No arguments should get cards from all address books.
+ let results = await browser.contacts.quickSearch("contact");
+ checkCards(results, ["personal", "history", "LDAP"]);
+
+ // An empty argument should get cards from all address books.
+ results = await browser.contacts.quickSearch({ searchString: "contact" });
+ checkCards(results, ["personal", "history", "LDAP"]);
+
+ // Skip remote address books.
+ results = await browser.contacts.quickSearch({
+ searchString: "contact",
+ includeRemote: false,
+ });
+ checkCards(results, ["personal", "history"]);
+
+ // Skip local address books.
+ results = await browser.contacts.quickSearch({
+ searchString: "contact",
+ includeLocal: false,
+ });
+ checkCards(results, ["LDAP"]);
+
+ // Skip read-only address books.
+ results = await browser.contacts.quickSearch({
+ searchString: "contact",
+ includeReadOnly: false,
+ });
+ checkCards(results, ["personal"]);
+
+ // Skip read-write address books.
+ results = await browser.contacts.quickSearch({
+ searchString: "contact",
+ includeReadWrite: false,
+ });
+ checkCards(results, ["LDAP", "history"]);
+
+ browser.test.notifyPass("addressBooks");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: { permissions: ["addressBooks"] },
+ });
+
+ let startupPromise = extension.startup();
+
+ // This for loop handles returning responses for LDAP. It should run once
+ // for each test that queries the remote address book.
+ for (let i = 0; i < 4; i++) {
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry({
+ dn: "uid=ldap,dc=contact,dc=invalid",
+ attributes: {
+ objectClass: "person",
+ cn: "LDAP contact",
+ givenName: "LDAP",
+ mail: "eurus@contact.invalid",
+ sn: "contact",
+ },
+ });
+ LDAPServer.writeSearchResultDone();
+ }
+
+ await startupPromise;
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_readonly.js b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_readonly.js
new file mode 100644
index 0000000000..3b40dc67a2
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_readonly.js
@@ -0,0 +1,148 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_setup(async () => {
+ Services.prefs.setIntPref("ldap_2.servers.osx.dirType", -1);
+
+ registerCleanupFunction(() => {
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+ });
+
+ let historyAB = MailServices.ab.getDirectory("jsaddrbook://history.sqlite");
+
+ let contact1 = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact1.UID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
+ contact1.displayName = "contact number one";
+ contact1.firstName = "contact";
+ contact1.lastName = "one";
+ contact1.primaryEmail = "contact1@invalid";
+ contact1 = historyAB.addCard(contact1);
+
+ let mailList = Cc[
+ "@mozilla.org/addressbook/directoryproperty;1"
+ ].createInstance(Ci.nsIAbDirectory);
+ mailList.isMailList = true;
+ mailList.dirName = "Mailing";
+ mailList.listNickName = "Mailing";
+ mailList.description = "";
+
+ historyAB.addMailList(mailList);
+ historyAB.setBoolValue("readOnly", true);
+
+ Assert.ok(historyAB.readOnly);
+});
+
+add_task(async function test_addressBooks_readonly() {
+ async function background() {
+ let list = await browser.addressBooks.list();
+
+ // The read only AB should be in the list.
+ let readOnlyAB = list.find(ab => ab.name == "Collected Addresses");
+ browser.test.assertTrue(!!readOnlyAB, "Should have found the address book");
+
+ browser.test.assertTrue(
+ readOnlyAB.readOnly,
+ "Should have marked the address book as read-only"
+ );
+
+ let card = await browser.contacts.get(
+ "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ );
+ browser.test.assertTrue(!!card, "Should have found the card");
+
+ browser.test.assertTrue(
+ card.readOnly,
+ "Should have marked the card as read-only"
+ );
+
+ await browser.test.assertRejects(
+ browser.contacts.create(readOnlyAB.id, {
+ email: "test@example.com",
+ }),
+ "Cannot create a contact in a read-only address book",
+ "Should reject creating an address book card"
+ );
+
+ await browser.test.assertRejects(
+ browser.contacts.update(card.id, card.properties),
+ "Cannot modify a contact in a read-only address book",
+ "Should reject modifying an address book card"
+ );
+
+ await browser.test.assertRejects(
+ browser.contacts.delete(card.id),
+ "Cannot delete a contact in a read-only address book",
+ "Should reject deleting an address book card"
+ );
+
+ // Mailing List
+
+ let mailingLists = await browser.mailingLists.list(readOnlyAB.id);
+ let readOnlyML = mailingLists[0];
+ browser.test.assertTrue(!!readOnlyAB, "Should have found the mailing list");
+
+ browser.test.assertTrue(
+ readOnlyML.readOnly,
+ "Should have marked the mailing list as read-only"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.create(readOnlyAB.id, { name: "Test" }),
+ "Cannot create a mailing list in a read-only address book",
+ "Should reject creating a mailing list"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.update(readOnlyML.id, { name: "newTest" }),
+ "Cannot modify a mailing list in a read-only address book",
+ "Should reject modifying a mailing list"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.delete(readOnlyML.id),
+ "Cannot delete a mailing list in a read-only address book",
+ "Should reject deleting a mailing list"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.addMember(readOnlyML.id, card.id),
+ "Cannot add to a mailing list in a read-only address book",
+ "Should reject deleting a mailing list"
+ );
+
+ await browser.test.assertRejects(
+ browser.mailingLists.removeMember(readOnlyML.id, card.id),
+ "Cannot remove from a mailing list in a read-only address book",
+ "Should reject deleting a mailing list"
+ );
+
+ browser.test.notifyPass("addressBooks");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_remote.js b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_remote.js
new file mode 100644
index 0000000000..7a34c8ce86
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_addressBook_remote.js
@@ -0,0 +1,101 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { LDAPServer } = ChromeUtils.import(
+ "resource://testing-common/LDAPServer.jsm"
+);
+
+add_setup(async () => {
+ // If nsIAbLDAPDirectory doesn't exist in our build options, someone has
+ // specified --disable-ldap.
+ if (!("nsIAbLDAPDirectory" in Ci)) {
+ return;
+ }
+ Services.prefs.setIntPref("ldap_2.servers.osx.dirType", -1);
+
+ LDAPServer.open();
+
+ // Create an LDAP directory.
+ MailServices.ab.newAddressBook(
+ "test",
+ `ldap://localhost:${LDAPServer.port}/people??sub?(objectclass=*)`,
+ Ci.nsIAbManager.LDAP_DIRECTORY_TYPE
+ );
+
+ registerCleanupFunction(() => {
+ LDAPServer.close();
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+ });
+});
+
+add_task(async function test_addressBooks_remote() {
+ async function background() {
+ let list = await browser.addressBooks.list();
+
+ // The remote AB should be in the list.
+ let remoteAB = list.find(ab => ab.name == "test");
+ browser.test.assertTrue(!!remoteAB, "Should have found the address book");
+
+ browser.test.assertTrue(
+ remoteAB.remote,
+ "Should have marked the address book as remote"
+ );
+
+ let cards = await browser.contacts.quickSearch("eurus");
+ browser.test.assertTrue(
+ cards.length,
+ "Should have found at least one card"
+ );
+
+ browser.test.assertTrue(
+ cards[0].remote,
+ "Should have marked the card as remote"
+ );
+
+ // Mailing lists are not supported for LDAP address books.
+
+ browser.test.notifyPass("addressBooks");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": background,
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["addressBooks"],
+ },
+ });
+
+ let startupPromise = extension.startup();
+
+ await LDAPServer.read(LDAPServer.BindRequest);
+ LDAPServer.writeBindResponse();
+
+ await LDAPServer.read(LDAPServer.SearchRequest);
+ LDAPServer.writeSearchResultEntry({
+ dn: "uid=eurus,dc=bakerstreet,dc=invalid",
+ attributes: {
+ objectClass: "person",
+ cn: "Eurus Holmes",
+ givenName: "Eurus",
+ mail: "eurus@bakerstreet.invalid",
+ sn: "Holmes",
+ },
+ });
+ LDAPServer.writeSearchResultDone();
+
+ await startupPromise;
+ await extension.awaitFinish("addressBooks");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_alias.js b/comm/mail/components/extensions/test/xpcshell/test_ext_alias.js
new file mode 100644
index 0000000000..3fff1e0e08
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_alias.js
@@ -0,0 +1,123 @@
+/* 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 { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+AddonTestUtils.maybeInit(this);
+const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write(
+ "<!DOCTYPE html><html><head><meta charset='utf8'></head><body></body></html>"
+ );
+});
+
+add_task(async function test_alias() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let pending = new Set(["contentscript", "webscript"]);
+
+ browser.runtime.onMessage.addListener(message => {
+ if (message == "contentscript") {
+ pending.delete(message);
+ browser.test.succeed("Content script has completed");
+ } else if (message == "webscript") {
+ pending.delete(message);
+ browser.test.succeed("Web accessible script has completed");
+ }
+
+ if (pending.size == 0) {
+ browser.test.notifyPass("ext_alias");
+ }
+ });
+
+ browser.test.assertEq(
+ "object",
+ typeof browser,
+ "Background script has browser object"
+ );
+ browser.test.assertEq(
+ "object",
+ typeof messenger,
+ "Background script has messenger object"
+ );
+ browser.test.assertEq(
+ "alias@xpcshell",
+ messenger.runtime.getManifest().applications.gecko.id, // eslint-disable-line no-undef
+ "Background script can access the manifest"
+ );
+ },
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy"],
+ js: ["content.js"],
+ },
+ ],
+
+ applications: { gecko: { id: "alias@xpcshell" } },
+ web_accessible_resources: ["web.html", "web.js"],
+ },
+ files: {
+ "content.js": `
+ browser.test.assertEq("object", typeof browser, "Content script has browser object");
+ browser.test.assertEq("object", typeof messenger, "Content script has messenger object");
+ browser.test.assertEq(
+ "alias@xpcshell",
+ messenger.runtime.getManifest().applications.gecko.id,
+ "Content script can access manifest"
+ );
+
+ // Unprivileged content in a frame
+ let frame = document.createElement("iframe");
+ frame.src = browser.runtime.getURL("web.html");
+ document.body.appendChild(frame);
+
+ browser.runtime.sendMessage("contentscript");
+ `,
+ "web.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset='utf8'>
+ <script src="web.js"></script>
+ </head>
+ <body>
+ </body>
+ </html>
+ `,
+ "web.js": `
+ browser.test.assertEq("object", typeof browser, "Web accessible script has browser object");
+ browser.test.assertEq("object", typeof messenger, "Web accessible script has messenger object");
+ browser.test.assertEq(
+ "alias@xpcshell",
+ messenger.runtime.getManifest().applications.gecko.id,
+ "Web accessible script can access manifest"
+ );
+
+ browser.runtime.sendMessage("webscript");
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ const contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await extension.awaitFinish("ext_alias");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_browserAction_unifiedtoolbar_restart.js b/comm/mail/components/extensions/test/xpcshell/test_ext_browserAction_unifiedtoolbar_restart.js
new file mode 100644
index 0000000000..ef2687af68
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_browserAction_unifiedtoolbar_restart.js
@@ -0,0 +1,350 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+var { getCachedAllowedSpaces, setCachedAllowedSpaces } = ChromeUtils.import(
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+var { storeState, getState } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizationState.mjs"
+);
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+const {
+ createAppInfo,
+ createHttpServer,
+ createTempXPIFile,
+ promiseRestartManager,
+ promiseShutdownManager,
+ promiseStartupManager,
+ promiseCompleteAllInstalls,
+ promiseFindAddonUpdates,
+} = AddonTestUtils;
+
+// Prepare test environment to be able to load add-on updates.
+const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+let gProfD = do_get_profile();
+let profileDir = gProfD.clone();
+profileDir.append("extensions");
+const stageDir = profileDir.clone();
+stageDir.append("staged");
+
+let server = createHttpServer({
+ hosts: ["example.com"],
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "102");
+
+async function enforceState(state) {
+ const stateChangeObserved = TestUtils.topicObserved(
+ "unified-toolbar-state-change"
+ );
+ storeState(state);
+ await stateChangeObserved;
+}
+
+function check(testType, expectedCache, expectedMail, expectedCalendar) {
+ let extensionId = `browser_action_spaces_${testType}@mochi.test`;
+
+ Assert.equal(
+ getCachedAllowedSpaces().has(extensionId),
+ expectedCache != null,
+ "CachedAllowedSpaces should include the test extension"
+ );
+ if (expectedCache != null) {
+ Assert.deepEqual(
+ getCachedAllowedSpaces().get(extensionId),
+ expectedCache,
+ "CachedAllowedSpaces should be correct"
+ );
+ }
+ Assert.equal(
+ getState().mail.includes(`ext-${extensionId}`),
+ expectedMail,
+ "The mail state should include the action button of the test extension"
+ );
+ Assert.equal(
+ getState().calendar.includes(`ext-${extensionId}`),
+ expectedCalendar,
+ "The calendar state should include the action button of the test extension"
+ );
+}
+
+function addXPI(testType, thisVersion, nextVersion, browser_action) {
+ server.registerFile(
+ `/addons/${testType}_v${thisVersion}.xpi`,
+ createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: testType,
+ version: `${thisVersion}.0`,
+ background: { scripts: ["background.js"] },
+ applications: {
+ gecko: {
+ id: `browser_action_spaces_${testType}@mochi.test`,
+ update_url: nextVersion
+ ? `http://example.com/${testType}_updates_v${nextVersion}.json`
+ : null,
+ },
+ },
+ browser_action,
+ },
+ "background.js": `
+ if (browser.runtime.getManifest().name == "delayed") {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ browser.test.sendMessage("update postponed by ${thisVersion}");
+ });
+ }
+ browser.test.log(" ===== ready ${testType} ${thisVersion}");
+ browser.test.sendMessage("ready ${thisVersion}");`,
+ })
+ );
+ if (nextVersion) {
+ addUpdateJSON(testType, nextVersion);
+ }
+}
+
+function addUpdateJSON(testType, nextVersion) {
+ let extensionId = `browser_action_spaces_${testType}@mochi.test`;
+
+ AddonTestUtils.registerJSON(
+ server,
+ `/${testType}_updates_v${nextVersion}.json`,
+ {
+ addons: {
+ [extensionId]: {
+ updates: [
+ {
+ version: `${nextVersion}.0`,
+ update_link: `http://example.com/addons/${testType}_v${nextVersion}.xpi`,
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ },
+ },
+ },
+ ],
+ },
+ },
+ }
+ );
+}
+
+async function checkForExtensionUpdate(testType, extension) {
+ let update = await promiseFindAddonUpdates(extension.addon);
+ let install = update.updateAvailable;
+ await promiseCompleteAllInstalls([install]);
+
+ if (testType == "normal") {
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_INSTALLED,
+ "Update should have been installed"
+ );
+ } else {
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_POSTPONED,
+ "Update should have been postponed"
+ );
+ }
+}
+
+async function runTest(testType) {
+ // Simulate starting up the app.
+ await promiseStartupManager();
+
+ // Set a customized state for the spaces we are working with in this test.
+ await enforceState({
+ mail: ["spacer", "search-bar", "spacer"],
+ calendar: ["spacer", "search-bar", "spacer"],
+ });
+
+ // Check conditions before installing the add-on.
+ check(testType, null, false, false);
+
+ // Add the required update JSON to our test server, to be able to update to v2.
+ addUpdateJSON(testType, 2);
+ // Install addon v1 without a browserAction.
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ files: {
+ "background.js": function () {
+ if (browser.runtime.getManifest().name == "delayed") {
+ function handleUpdateAvailable(details) {
+ browser.test.sendMessage("update postponed by 1");
+ }
+ browser.runtime.onUpdateAvailable.addListener(handleUpdateAvailable);
+ }
+ browser.test.sendMessage("ready 1");
+ },
+ },
+ manifest: {
+ background: { scripts: ["background.js"] },
+ version: "1.0",
+ name: testType,
+ applications: {
+ gecko: {
+ id: `browser_action_spaces_${testType}@mochi.test`,
+ update_url: `http://example.com/${testType}_updates_v2.json`,
+ },
+ },
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("ready 1");
+
+ // State should not have changed.
+ check(testType, null, false, false);
+
+ // v2 will add the mail space and the default space.
+ addXPI(testType, 2, 3, { allowed_spaces: ["mail", "default"] });
+ await checkForExtensionUpdate(testType, extension);
+
+ if (testType == "delayed") {
+ await extension.awaitMessage("update postponed by 1");
+ // Restart to install the update v2.
+ await promiseRestartManager();
+ }
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 2");
+
+ // The button should have been added to the mail space.
+ check(testType, ["mail", "default"], true, false);
+
+ // Remove our extension button from all customized states.
+ await enforceState({
+ mail: ["spacer", "search-bar", "spacer"],
+ calendar: ["spacer", "search-bar", "spacer"],
+ });
+
+ // Simulate restarting the app.
+ await promiseRestartManager();
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 2");
+
+ // The button should not be re-added to any space after the restart.
+ check(testType, ["mail", "default"], false, false);
+
+ // v3 will add the calendar space.
+ addXPI(testType, 3, 4, {
+ allowed_spaces: ["mail", "calendar", "default"],
+ });
+ await checkForExtensionUpdate(testType, extension);
+
+ if (testType == "delayed") {
+ await extension.awaitMessage("update postponed by 2");
+ // Restart to install the update v3.
+ await promiseRestartManager();
+ }
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 3");
+
+ // The button should have been added to the calendar space.
+ check(testType, ["mail", "calendar", "default"], false, true);
+
+ // Simulate restarting the app.
+ await promiseRestartManager();
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 3");
+
+ // Should not have changed.
+ check(testType, ["mail", "calendar", "default"], false, true);
+
+ // v4 will remove the calendar space again.
+ addXPI(testType, 4, 5, { allowed_spaces: ["mail", "default"] });
+ await checkForExtensionUpdate(testType, extension);
+
+ if (testType == "delayed") {
+ await extension.awaitMessage("update postponed by 3");
+ // Restart to install the update v4.
+ await promiseRestartManager();
+ }
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 4");
+
+ // The calendar space should no longer be known and the button should be removed
+ // from the calendar space.
+ check(testType, ["mail", "default"], false, false);
+
+ // Simulate restarting the app.
+ await promiseRestartManager();
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 4");
+
+ // Should not have changed.
+ check(testType, ["mail", "default"], false, false);
+
+ // v5 will remove the entire browser_action. Testing the onUpdate code path in
+ // ext-browserAction.
+ addXPI(testType, 5, 6, null);
+ await checkForExtensionUpdate(testType, extension);
+
+ if (testType == "delayed") {
+ await extension.awaitMessage("update postponed by 4");
+ // Restart to install the update v5.
+ await promiseRestartManager();
+ }
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 5");
+
+ // There should no longer be a cached entry for any known spaces.
+ check(testType, null, false, false);
+
+ // Simulate restarting the app.
+ await promiseRestartManager();
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 5");
+
+ // Should not have changed.
+ check(testType, null, false, false);
+
+ // v6 will add the mail space again.
+ addXPI(testType, 6, null, { allowed_spaces: ["mail", "default"] });
+ await checkForExtensionUpdate(testType, extension);
+
+ if (testType == "delayed") {
+ await extension.awaitMessage("update postponed by 5");
+ // Restart to install the update v6.
+ await promiseRestartManager();
+ }
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("ready 6");
+
+ // The button should have been added to the mail space.
+ check(testType, ["mail", "default"], true, false);
+
+ // Unload the extension. Testing the onUninstall code path in ext-browserAction.
+ await extension.unload();
+
+ // There should no longer be a cached entry for any known spaces.
+ check(testType, null, false, false);
+
+ await promiseShutdownManager();
+}
+
+add_task(async function test_normal_updates() {
+ await runTest("normal");
+});
+
+add_task(async function test_delayed_updates() {
+ await runTest("delayed");
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_experiments.js b/comm/mail/components/extensions/test/xpcshell/test_ext_experiments.js
new file mode 100644
index 0000000000..d8ccd58da6
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_experiments.js
@@ -0,0 +1,279 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_task(async function test_managers() {
+ let account = createAccount();
+ let folder = await createSubfolder(
+ account.incomingServer.rootFolder,
+ "test1"
+ );
+ await createMessages(folder, 5);
+
+ let files = {
+ "background.js": async () => {
+ let [testAccount] = await browser.accounts.list();
+ let testFolder = testAccount.folders.find(f => f.name == "test1");
+ let {
+ messages: [testMessage],
+ } = await browser.messages.list(testFolder);
+
+ let messageCount = await browser.testapi.testCanGetFolder(testFolder);
+ browser.test.assertEq(5, messageCount);
+
+ let convertedFolder = await browser.testapi.testCanConvertFolder();
+ browser.test.assertEq(testFolder.accountId, convertedFolder.accountId);
+ browser.test.assertEq(testFolder.path, convertedFolder.path);
+
+ let subject = await browser.testapi.testCanGetMessage(testMessage.id);
+ browser.test.assertEq(testMessage.subject, subject);
+
+ let convertedMessage = await browser.testapi.testCanConvertMessage();
+ browser.test.log(JSON.stringify(convertedMessage));
+ browser.test.assertEq(testMessage.id, convertedMessage.id);
+ browser.test.assertEq(testMessage.subject, convertedMessage.subject);
+
+ let messageList = await browser.testapi.testCanStartMessageList();
+ browser.test.assertEq(36, messageList.id.length);
+ browser.test.assertEq(4, messageList.messages.length);
+ browser.test.assertEq(
+ testMessage.subject,
+ messageList.messages[0].subject
+ );
+
+ messageList = await browser.messages.continueList(messageList.id);
+ browser.test.assertEq(null, messageList.id);
+ browser.test.assertEq(1, messageList.messages.length);
+ browser.test.assertTrue(
+ testMessage.subject != messageList.messages[0].subject
+ );
+
+ let [bookUID, contactUID, listUID] = await window.sendMessage("get UIDs");
+ let [foundBook, foundContact, foundList] =
+ await browser.testapi.testCanFindAddressBookItems(
+ bookUID,
+ contactUID,
+ listUID
+ );
+ browser.test.assertEq("new book", foundBook.name);
+ browser.test.assertEq("new contact", foundContact.properties.DisplayName);
+ browser.test.assertEq("new list", foundList.name);
+
+ browser.test.notifyPass("finished");
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ ...files,
+ "schema.json": [
+ {
+ namespace: "testapi",
+ functions: [
+ {
+ name: "testCanGetFolder",
+ type: "function",
+ async: true,
+ parameters: [
+ {
+ name: "folder",
+ $ref: "folders.MailFolder",
+ },
+ ],
+ },
+ {
+ name: "testCanConvertFolder",
+ type: "function",
+ async: true,
+ parameters: [],
+ },
+ {
+ name: "testCanGetMessage",
+ type: "function",
+ async: true,
+ parameters: [
+ {
+ name: "messageId",
+ type: "integer",
+ },
+ ],
+ },
+ {
+ name: "testCanConvertMessage",
+ type: "function",
+ async: true,
+ parameters: [],
+ },
+ {
+ name: "testCanStartMessageList",
+ type: "function",
+ async: true,
+ parameters: [],
+ },
+ {
+ name: "testCanFindAddressBookItems",
+ type: "function",
+ async: true,
+ parameters: [
+ { name: "bookUID", type: "string" },
+ { name: "contactUID", type: "string" },
+ { name: "listUID", type: "string" },
+ ],
+ },
+ ],
+ },
+ ],
+ "implementation.js": () => {
+ var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+ );
+ var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ this.testapi = class extends ExtensionCommon.ExtensionAPI {
+ getAPI(context) {
+ return {
+ testapi: {
+ async testCanGetFolder({ accountId, path }) {
+ let realFolder = context.extension.folderManager.get(
+ accountId,
+ path
+ );
+ return realFolder.getTotalMessages(false);
+ },
+ async testCanConvertFolder() {
+ let realFolder = MailServices.accounts.allFolders.find(
+ f => f.name == "test1"
+ );
+ return context.extension.folderManager.convert(realFolder);
+ },
+ async testCanGetMessage(messageId) {
+ let realMessage =
+ context.extension.messageManager.get(messageId);
+ return realMessage.subject;
+ },
+ async testCanConvertMessage() {
+ let realFolder = MailServices.accounts.allFolders.find(
+ f => f.name == "test1"
+ );
+ let realMessage = [...realFolder.messages][0];
+ return context.extension.messageManager.convert(realMessage);
+ },
+ async testCanStartMessageList() {
+ let realFolder = MailServices.accounts.allFolders.find(
+ f => f.name == "test1"
+ );
+ return context.extension.messageManager.startMessageList(
+ realFolder.messages
+ );
+ },
+ async testCanFindAddressBookItems(
+ bookUID,
+ contactUID,
+ listUID
+ ) {
+ let foundBook =
+ context.extension.addressBookManager.findAddressBookById(
+ bookUID
+ );
+ let foundContact =
+ context.extension.addressBookManager.findContactById(
+ contactUID
+ );
+ let foundList =
+ context.extension.addressBookManager.findMailingListById(
+ listUID
+ );
+
+ return [
+ await context.extension.addressBookManager.convert(
+ foundBook
+ ),
+ await context.extension.addressBookManager.convert(
+ foundContact
+ ),
+ await context.extension.addressBookManager.convert(
+ foundList
+ ),
+ ];
+ },
+ },
+ };
+ }
+ };
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "addressBooks", "messagesRead"],
+ experiment_apis: {
+ testapi: {
+ schema: "schema.json",
+ parent: {
+ scopes: ["addon_parent"],
+ paths: [["testapi"]],
+ script: "implementation.js",
+ },
+ },
+ },
+ },
+ });
+
+ let dirPrefId = MailServices.ab.newAddressBook(
+ "new book",
+ "",
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+
+ let contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact.displayName = "new contact";
+ contact.firstName = "new";
+ contact.lastName = "contact";
+ contact.primaryEmail = "new.contact@invalid";
+ contact = book.addCard(contact);
+
+ let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(
+ Ci.nsIAbDirectory
+ );
+ list.isMailList = true;
+ list.dirName = "new list";
+ list = book.addMailList(list);
+ list.addCard(contact);
+
+ Services.prefs.setIntPref("extensions.webextensions.messagesPerPage", 4);
+
+ await extension.startup();
+ await extension.awaitMessage("get UIDs");
+ extension.sendMessage(book.UID, contact.UID, list.UID);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ Services.prefs.clearUserPref("extensions.webextensions.messagesPerPage");
+
+ await new Promise(resolve => {
+ let observer = {
+ observe() {
+ Services.obs.removeObserver(observer, "addrbook-directory-deleted");
+ resolve();
+ },
+ };
+ Services.obs.addObserver(observer, "addrbook-directory-deleted");
+ MailServices.ab.deleteAddressBook(book.URI);
+ });
+});
+
+registerCleanupFunction(() => {
+ // Make sure any open database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_folders.js b/comm/mail/components/extensions/test/xpcshell/test_ext_folders.js
new file mode 100644
index 0000000000..39a8d63016
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_folders.js
@@ -0,0 +1,560 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_folders() {
+ let files = {
+ "background.js": async () => {
+ let [accountId, IS_IMAP] = await window.waitForMessage();
+
+ let account = await browser.accounts.get(accountId);
+ browser.test.assertEq(3, account.folders.length);
+
+ // Test create.
+
+ let onCreatedPromise = window.waitForEvent("folders.onCreated");
+ let folder1 = await browser.folders.create(account, "folder1");
+ let [createdFolder] = await onCreatedPromise;
+ for (let folder of [folder1, createdFolder]) {
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq("folder1", folder.name);
+ browser.test.assertEq("/folder1", folder.path);
+ }
+
+ account = await browser.accounts.get(accountId);
+ // Check order of the returned folders being correct (new folder not last).
+ browser.test.assertEq(4, account.folders.length);
+ if (IS_IMAP) {
+ browser.test.assertEq("Inbox", account.folders[0].name);
+ browser.test.assertEq("Trash", account.folders[1].name);
+ } else {
+ browser.test.assertEq("Trash", account.folders[0].name);
+ browser.test.assertEq("Outbox", account.folders[1].name);
+ }
+ browser.test.assertEq("folder1", account.folders[2].name);
+ browser.test.assertEq("unused", account.folders[3].name);
+
+ let folder2 = await browser.folders.create(folder1, "folder+2");
+ browser.test.assertEq(accountId, folder2.accountId);
+ browser.test.assertEq("folder+2", folder2.name);
+ browser.test.assertEq("/folder1/folder+2", folder2.path);
+
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(4, account.folders.length);
+ browser.test.assertEq(1, account.folders[2].subFolders.length);
+ browser.test.assertEq(
+ "/folder1/folder+2",
+ account.folders[2].subFolders[0].path
+ );
+
+ // Test reject on creating already existing folder.
+ await browser.test.assertRejects(
+ browser.folders.create(folder1, "folder+2"),
+ `folders.create() failed, because folder+2 already exists in /folder1`,
+ "browser.folders.create threw exception"
+ );
+
+ // Test rename.
+
+ {
+ let onRenamedPromise = window.waitForEvent("folders.onRenamed");
+ let folder3 = await browser.folders.rename(
+ { accountId, path: "/folder1/folder+2" },
+ "folder3"
+ );
+ let [originalFolder, renamedFolder] = await onRenamedPromise;
+ // Test the original folder.
+ browser.test.assertEq(accountId, originalFolder.accountId);
+ browser.test.assertEq("folder+2", originalFolder.name);
+ browser.test.assertEq("/folder1/folder+2", originalFolder.path);
+ // Test the renamed folder.
+ for (let folder of [folder3, renamedFolder]) {
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq("folder3", folder.name);
+ browser.test.assertEq("/folder1/folder3", folder.path);
+ }
+
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(4, account.folders.length);
+ browser.test.assertEq(1, account.folders[2].subFolders.length);
+ browser.test.assertEq(
+ "/folder1/folder3",
+ account.folders[2].subFolders[0].path
+ );
+
+ // Test reject on renaming absolute root.
+ await browser.test.assertRejects(
+ browser.folders.rename({ accountId, path: "/" }, "UhhOh"),
+ `folders.rename() failed, because it cannot rename the root of the account`,
+ "browser.folders.rename threw exception"
+ );
+
+ // Test reject on renaming to existing folder.
+ await browser.test.assertRejects(
+ browser.folders.rename(
+ { accountId, path: "/folder1/folder3" },
+ "folder3"
+ ),
+ `folders.rename() failed, because folder3 already exists in /folder1`,
+ "browser.folders.rename threw exception"
+ );
+ }
+
+ // Test delete (and onMoved).
+
+ {
+ // The delete request will trigger an onDelete event for IMAP and an
+ // onMoved event for local folders.
+ let deletePromise = window.waitForEvent(
+ `folders.${IS_IMAP ? "onDeleted" : "onMoved"}`
+ );
+ await browser.folders.delete({ accountId, path: "/folder1/folder3" });
+ // The onMoved event returns the original/deleted and the new folder.
+ // The onDeleted event returns just the original/deleted folder.
+ let [originalFolder, folderMovedToTrash] = await deletePromise;
+
+ // Test the originalFolder folder.
+ browser.test.assertEq(accountId, originalFolder.accountId);
+ browser.test.assertEq("folder3", originalFolder.name);
+ browser.test.assertEq("/folder1/folder3", originalFolder.path);
+
+ // Check if it really is in trash folder.
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(4, account.folders.length);
+ let trashFolder = account.folders.find(f => f.name == "Trash");
+ browser.test.assertTrue(trashFolder);
+ browser.test.assertEq("/Trash", trashFolder.path);
+ browser.test.assertEq(1, trashFolder.subFolders.length);
+ browser.test.assertEq(
+ "/Trash/folder3",
+ trashFolder.subFolders[0].path
+ );
+ browser.test.assertEq("/folder1", account.folders[2].path);
+
+ if (!IS_IMAP) {
+ // For non IMAP folders, the delete request has triggered an onMoved
+ // event, check if that has reported moving the folder to trash.
+ browser.test.assertEq(accountId, folderMovedToTrash.accountId);
+ browser.test.assertEq("folder3", folderMovedToTrash.name);
+ browser.test.assertEq("/Trash/folder3", folderMovedToTrash.path);
+
+ // Delete the folder from trash.
+ let onDeletedPromise = window.waitForEvent("folders.onDeleted");
+ await browser.folders.delete({ accountId, path: "/Trash/folder3" });
+ let [deletedFolder] = await onDeletedPromise;
+ browser.test.assertEq(accountId, deletedFolder.accountId);
+ browser.test.assertEq("folder3", deletedFolder.name);
+ browser.test.assertEq("/Trash/folder3", deletedFolder.path);
+ // Check if the folder is gone.
+ let trashSubfolders = await browser.folders.getSubFolders(
+ trashFolder,
+ false
+ );
+ browser.test.assertEq(
+ 0,
+ trashSubfolders.length,
+ "Folder has been deleted from trash."
+ );
+ } else {
+ // The IMAP test server signals success for the delete request, but
+ // keeps the folder. Testing for this broken behavior to get notified
+ // via test fails, if this behaviour changes.
+ await browser.folders.delete({ accountId, path: "/Trash/folder3" });
+ let trashSubfolders = await browser.folders.getSubFolders(
+ trashFolder,
+ false
+ );
+ browser.test.assertEq(
+ "/Trash/folder3",
+ trashSubfolders[0].path,
+ "IMAP test server cannot delete from trash, the folder is still there."
+ );
+ }
+
+ // Test reject on deleting non-existing folder.
+ await browser.test.assertRejects(
+ browser.folders.delete({ accountId, path: "/folder1/folder5" }),
+ `Folder not found: /folder1/folder5`,
+ "browser.folders.delete threw exception"
+ );
+
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(4, account.folders.length);
+ browser.test.assertEq("/folder1", account.folders[2].path);
+ }
+
+ // Test move.
+
+ {
+ await browser.folders.create(folder1, "folder4");
+ let onMovedPromise = window.waitForEvent("folders.onMoved");
+ let folder4_moved = await browser.folders.move(
+ { accountId, path: "/folder1/folder4" },
+ { accountId, path: "/" }
+ );
+ let [originalFolder, movedFolder] = await onMovedPromise;
+ // Test the original folder.
+ browser.test.assertEq(accountId, originalFolder.accountId);
+ browser.test.assertEq("folder4", originalFolder.name);
+ browser.test.assertEq("/folder1/folder4", originalFolder.path);
+ // Test the moved folder.
+ for (let folder of [folder4_moved, movedFolder]) {
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq("folder4", folder.name);
+ browser.test.assertEq("/folder4", folder.path);
+ }
+
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(5, account.folders.length);
+ browser.test.assertEq("/folder4", account.folders[3].path);
+
+ // Test reject on moving to already existing folder.
+ await browser.test.assertRejects(
+ browser.folders.move(folder4_moved, account),
+ `folders.move() failed, because folder4 already exists in /`,
+ "browser.folders.move threw exception"
+ );
+ }
+
+ // Test copy.
+
+ {
+ let onCopiedPromise = window.waitForEvent("folders.onCopied");
+ let folder4_copied = await browser.folders.copy(
+ { accountId, path: "/folder4" },
+ { accountId, path: "/folder1" }
+ );
+ let [originalFolder, copiedFolder] = await onCopiedPromise;
+ // Test the original folder.
+ browser.test.assertEq(accountId, originalFolder.accountId);
+ browser.test.assertEq("folder4", originalFolder.name);
+ browser.test.assertEq("/folder4", originalFolder.path);
+ // Test the copied folder.
+ for (let folder of [folder4_copied, copiedFolder]) {
+ browser.test.assertEq(accountId, folder.accountId);
+ browser.test.assertEq("folder4", folder.name);
+ browser.test.assertEq("/folder1/folder4", folder.path);
+ }
+
+ account = await browser.accounts.get(accountId);
+ browser.test.assertEq(5, account.folders.length);
+ browser.test.assertEq(1, account.folders[2].subFolders.length);
+ browser.test.assertEq("/folder4", account.folders[3].path);
+ browser.test.assertEq(
+ "/folder1/folder4",
+ account.folders[2].subFolders[0].path
+ );
+
+ // Test reject on copy to already existing folder.
+ await browser.test.assertRejects(
+ browser.folders.copy(folder4_copied, folder1),
+ `folders.copy() failed, because folder4 already exists in /folder1`,
+ "browser.folders.copy threw exception"
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsFolders", "messagesDelete"],
+ },
+ });
+
+ let account = createAccount();
+ // Not all folders appear immediately on IMAP. Creating a new one causes them to appear.
+ await createSubfolder(account.incomingServer.rootFolder, "unused");
+
+ // We should now have three folders. For IMAP accounts they are Inbox, Trash,
+ // and unused. Otherwise they are Trash, Unsent Messages and unused.
+
+ await extension.startup();
+ extension.sendMessage(account.key, IS_IMAP);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_without_delete_permission() {
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+
+ // Test reject on delete without messagesDelete permission.
+ await browser.test.assertRejects(
+ browser.folders.delete({ accountId, path: "/unused" }),
+ `Using folders.delete() requires the "accountsFolders" and the "messagesDelete" permission`,
+ "It rejects for a missing permission."
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsFolders"],
+ },
+ });
+
+ let account = createAccount();
+ // Not all folders appear immediately on IMAP. Creating a new one causes them to appear.
+ await createSubfolder(account.incomingServer.rootFolder, "unused");
+
+ // We should now have three folders. For IMAP accounts they are Inbox,
+ // Trash, and unused. Otherwise they are Trash, Unsent Messages and unused.
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(async function test_getParentFolders_getSubFolders() {
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+ let account = await browser.accounts.get(accountId);
+
+ async function createSubFolder(folderOrAccount, name) {
+ let subFolder = await browser.folders.create(folderOrAccount, name);
+ let basePath = folderOrAccount.path || "/";
+ if (!basePath.endsWith("/")) {
+ basePath = basePath + "/";
+ }
+ browser.test.assertEq(accountId, subFolder.accountId);
+ browser.test.assertEq(name, subFolder.name);
+ browser.test.assertEq(`${basePath}${name}`, subFolder.path);
+ return subFolder;
+ }
+
+ // Create a new root folder in the account.
+ let root = await createSubFolder(account, "MyRoot");
+
+ // Build a flat list of newly created nested folders in MyRoot.
+ let flatFolders = [root];
+ for (let i = 0; i < 10; i++) {
+ flatFolders.push(await createSubFolder(flatFolders[i], `level${i}`));
+ }
+
+ // Test getParentFolders().
+
+ // Pop out the last child folder and get its parents.
+ let lastChild = flatFolders.pop();
+ let parentsWithSubDefault = await browser.folders.getParentFolders(
+ lastChild
+ );
+ let parentsWithSubFalse = await browser.folders.getParentFolders(
+ lastChild,
+ false
+ );
+ let parentsWithSubTrue = await browser.folders.getParentFolders(
+ lastChild,
+ true
+ );
+
+ browser.test.assertEq(10, parentsWithSubDefault.length, "Correct depth.");
+ browser.test.assertEq(10, parentsWithSubFalse.length, "Correct depth.");
+ browser.test.assertEq(10, parentsWithSubTrue.length, "Correct depth.");
+
+ // Reverse the flatFolders array, to match the expected return value of
+ // getParentFolders().
+ flatFolders.reverse();
+
+ // Build expected nested subfolder structure.
+ lastChild.subFolders = [];
+ let flatFoldersWithSub = [];
+ for (let i = 0; i < 10; i++) {
+ let f = {};
+ Object.assign(f, flatFolders[i]);
+ if (i == 0) {
+ f.subFolders = [lastChild];
+ } else {
+ f.subFolders = [flatFoldersWithSub[i - 1]];
+ }
+ flatFoldersWithSub.push(f);
+ }
+
+ // Test return values of getParentFolders(). The way the flatFolder array
+ // has been created, its entries do not have subFolder properties.
+ for (let i = 0; i < 10; i++) {
+ window.assertDeepEqual(parentsWithSubFalse[i], flatFolders[i]);
+ window.assertDeepEqual(flatFolders[i], parentsWithSubFalse[i]);
+
+ window.assertDeepEqual(parentsWithSubTrue[i], flatFoldersWithSub[i]);
+ window.assertDeepEqual(flatFoldersWithSub[i], parentsWithSubTrue[i]);
+
+ // Default = false
+ window.assertDeepEqual(parentsWithSubDefault[i], flatFolders[i]);
+ window.assertDeepEqual(flatFolders[i], parentsWithSubDefault[i]);
+ }
+
+ // Test getSubFolders().
+
+ let expectedSubsWithSub = [flatFoldersWithSub[8]];
+ let expectedSubsWithoutSub = [flatFolders[8]];
+
+ // Test excluding subfolders (so only the direct subfolder are reported).
+ let subsWithSubFalse = await browser.folders.getSubFolders(root, false);
+ window.assertDeepEqual(expectedSubsWithoutSub, subsWithSubFalse);
+ window.assertDeepEqual(subsWithSubFalse, expectedSubsWithoutSub);
+
+ // Test including all subfolders.
+ let subsWithSubTrue = await browser.folders.getSubFolders(root, true);
+ window.assertDeepEqual(expectedSubsWithSub, subsWithSubTrue);
+ window.assertDeepEqual(subsWithSubTrue, expectedSubsWithSub);
+
+ // Test default subfolder handling of getSubFolders (= true).
+ let subsWithSubDefault = await browser.folders.getSubFolders(root);
+ window.assertDeepEqual(subsWithSubDefault, subsWithSubTrue);
+ window.assertDeepEqual(subsWithSubTrue, subsWithSubDefault);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsFolders"],
+ },
+ });
+
+ let account = createAccount();
+ // Not all folders appear immediately on IMAP. Creating a new one causes them to appear.
+ await createSubfolder(account.incomingServer.rootFolder, "unused");
+
+ // We should now have three folders. For IMAP accounts they are Inbox,
+ // Trash, and unused. Otherwise they are Trash, Unsent Messages and unused.
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+add_task(async function test_getFolderInfo() {
+ let files = {
+ "background.js": async () => {
+ let [accountId, IS_NNTP] = await window.waitForMessage();
+
+ let account = await browser.accounts.get(accountId);
+ browser.test.assertEq(IS_NNTP ? 1 : 3, account.folders.length);
+ let folders = await browser.folders.getSubFolders(account, false);
+ let InfoTestFolder = folders.find(f => f.name == "InfoTest");
+
+ // Verify initial state.
+ let info = await browser.folders.getFolderInfo(InfoTestFolder);
+ window.assertDeepEqual(
+ { totalMessageCount: 12, unreadMessageCount: 12, favorite: false },
+ info
+ );
+
+ // Test flipping favorite to true and marking all messages as read.
+ let onFolderInfoChangedPromise = window.waitForEvent(
+ "folders.onFolderInfoChanged"
+ );
+ await window.sendMessage("markAllAsRead");
+ await window.sendMessage("setFavorite", true);
+ let [mailFolder, mailFolderInfo] = await onFolderInfoChangedPromise;
+ window.assertDeepEqual(
+ { unreadMessageCount: 0, favorite: true },
+ mailFolderInfo
+ );
+ browser.test.assertEq(InfoTestFolder.path, mailFolder.path);
+
+ info = await browser.folders.getFolderInfo(InfoTestFolder);
+ window.assertDeepEqual(
+ { totalMessageCount: 12, unreadMessageCount: 0, favorite: true },
+ info
+ );
+
+ // Test flipping favorite back to false.
+ onFolderInfoChangedPromise = window.waitForEvent(
+ "folders.onFolderInfoChanged"
+ );
+ await window.sendMessage("setFavorite", false);
+ [mailFolder, mailFolderInfo] = await onFolderInfoChangedPromise;
+ window.assertDeepEqual({ favorite: false }, mailFolderInfo);
+ browser.test.assertEq(InfoTestFolder.path, mailFolder.path);
+
+ // Test setting some messages back to unread.
+ onFolderInfoChangedPromise = window.waitForEvent(
+ "folders.onFolderInfoChanged"
+ );
+ await window.sendMessage("markSomeAsUnread", 5);
+ [mailFolder, mailFolderInfo] = await onFolderInfoChangedPromise;
+ window.assertDeepEqual({ unreadMessageCount: 5 }, mailFolderInfo);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsFolders", "messagesDelete"],
+ },
+ });
+
+ let account = createAccount();
+ // Not all folders appear immediately on IMAP. Creating a new one causes them to appear.
+ let InfoTestFolder = await createSubfolder(
+ account.incomingServer.rootFolder,
+ "InfoTest"
+ );
+ await createMessages(InfoTestFolder, 12);
+
+ extension.onMessage("markAllAsRead", () => {
+ InfoTestFolder.markAllMessagesRead(null);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("markSomeAsUnread", count => {
+ let messages = InfoTestFolder.messages;
+ while (messages.hasMoreElements() && count > 0) {
+ let msg = messages.getNext();
+ msg.markRead(false);
+ count--;
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("setFavorite", value => {
+ if (value) {
+ InfoTestFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ } else {
+ InfoTestFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ }
+ extension.sendMessage();
+ });
+
+ // We should now have three folders. For IMAP accounts they are Inbox, Trash,
+ // and InfoTest. Otherwise they are Trash, Unsent Messages and InfoTest.
+
+ await extension.startup();
+ extension.sendMessage(account.key, IS_NNTP);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_folders_mv3_event_pages.js b/comm/mail/components/extensions/test/xpcshell/test_ext_folders_mv3_event_pages.js
new file mode 100644
index 0000000000..eac947cda8
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_folders_mv3_event_pages.js
@@ -0,0 +1,374 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+registerCleanupFunction(async () => {
+ // Remove the temporary MozillaMailnews folder, which is not deleted in time when
+ // the cleanupFunction registered by AddonTestUtils.maybeInit() checks for left over
+ // files in the temp folder.
+ // Note: PathUtils.tempDir points to the system temp folder, which is different.
+ let path = PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "MozillaMailnews"
+ );
+ await IOUtils.remove(path, { recursive: true });
+});
+
+// Test events and persistent events for Manifest V3 for onCreated, onRenamed,
+// onMoved, onCopied and onDeleted.
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_folders_MV3_event_pages() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ addIdentity(account, "id1@invalid");
+
+ let files = {
+ "background.js": () => {
+ for (let eventName of [
+ "onCreated",
+ "onDeleted",
+ "onCopied",
+ "onRenamed",
+ "onMoved",
+ ]) {
+ browser.folders[eventName].addListener(async (...args) => {
+ browser.test.log(`${eventName} received: ${JSON.stringify(args)}`);
+ browser.test.sendMessage(`${eventName} received`, args);
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead"],
+ },
+ });
+
+ // Function to start an event page extension (MV3), which can be called whenever
+ // the main test is about to trigger an event. The extension terminates its
+ // background and listens for that single event, verifying it is waking up correctly.
+ async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let _eventName = browser.runtime.getManifest().description;
+
+ browser.folders[_eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${_eventName} received`, args);
+ }
+ });
+
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ permissions: ["accountsRead"],
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "folders", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "folders", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "folders", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background started");
+
+ // Create a test folder before terminating the background script, to make sure
+ // everything is sane.
+
+ rootFolder.createSubfolder("TestFolder", null);
+ await extension.awaitMessage("onCreated received");
+ if (IS_IMAP) {
+ // IMAP creates a default Trash folder on the fly.
+ await extension.awaitMessage("onCreated received");
+ }
+
+ // Create SubFolder1.
+
+ {
+ rootFolder.createSubfolder("SubFolder1", null);
+ let createData = await extension.awaitMessage("onCreated received");
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder1",
+ path: "/SubFolder1",
+ },
+ ],
+ createData,
+ "The onCreated event should return the correct values"
+ );
+ }
+
+ // Create SubFolder2 (used for primed onFolderInfoChanged).
+
+ {
+ let primedChangeData = await event_page_extension(
+ "onFolderInfoChanged",
+ () => {
+ rootFolder.createSubfolder("SubFolder3", null);
+ }
+ );
+ let createData = await extension.awaitMessage("onCreated received");
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder3",
+ },
+ ],
+ createData,
+ "The onCreated event should return the correct values"
+ );
+ // Testing for onFolderInfoChanged is difficult, because it may not be for
+ // the last created folder, but for one of the folders created earlier. We
+ // therefore do not check the folder, but only the value.
+ Assert.deepEqual(
+ { totalMessageCount: 0, unreadMessageCount: 0 },
+ primedChangeData[1],
+ "The primed onFolderInfoChanged event should return the correct values"
+ );
+ }
+
+ // Copy.
+
+ {
+ let primedCopyData = await event_page_extension("onCopied", () => {
+ MailServices.copy.copyFolder(
+ rootFolder.getChildNamed("SubFolder3"),
+ rootFolder.getChildNamed("SubFolder1"),
+ false,
+ null,
+ null
+ );
+ });
+ let copyData = await extension.awaitMessage("onCopied received");
+ Assert.deepEqual(
+ primedCopyData,
+ copyData,
+ "The primed onCopied event should return the correct values"
+ );
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder3",
+ },
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder1/SubFolder3",
+ },
+ ],
+ copyData,
+ "The onCopied event should return the correct values"
+ );
+
+ if (IS_IMAP) {
+ // IMAP fires an additional create event.
+ let createData = await extension.awaitMessage("onCreated received");
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder1/SubFolder3",
+ },
+ ],
+ createData,
+ "The onCreated event should return the correct MailFolder values."
+ );
+ }
+ }
+
+ // Move.
+
+ {
+ let primedMoveData = await event_page_extension("onMoved", () => {
+ MailServices.copy.copyFolder(
+ rootFolder.getChildNamed("SubFolder1").getChildNamed("SubFolder3"),
+ rootFolder.getChildNamed("SubFolder3"),
+ true,
+ null,
+ null
+ );
+ });
+
+ let moveData = await extension.awaitMessage("onMoved received");
+ Assert.deepEqual(
+ primedMoveData,
+ moveData,
+ "The primed onMoved event should return the correct values"
+ );
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder1/SubFolder3",
+ },
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder3/SubFolder3",
+ },
+ ],
+ moveData,
+ "The onMoved event should return the correct values"
+ );
+
+ if (IS_IMAP) {
+ // IMAP fires additional rename and delete events.
+ let renameData = await extension.awaitMessage("onRenamed received");
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder1/SubFolder3",
+ },
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder3/SubFolder3",
+ },
+ ],
+ renameData,
+ "The onRenamed event should return the correct MailFolder values."
+ );
+ let deleteData = await extension.awaitMessage("onDeleted received");
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder1/SubFolder3",
+ },
+ ],
+ deleteData,
+ "The onDeleted event should return the correct MailFolder values."
+ );
+ }
+ }
+
+ // Delete.
+
+ {
+ let primedDeleteData = await event_page_extension("onDeleted", () => {
+ let subFolder1 = rootFolder.getChildNamed("SubFolder3");
+ subFolder1.propagateDelete(
+ subFolder1.getChildNamed("SubFolder3"),
+ true
+ );
+ });
+ let deleteData = await extension.awaitMessage("onDeleted received");
+ Assert.deepEqual(
+ primedDeleteData,
+ deleteData,
+ "The primed onDeleted event should return the correct values"
+ );
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "SubFolder3",
+ path: "/SubFolder3/SubFolder3",
+ },
+ ],
+ deleteData,
+ "The onDeleted event should return the correct values"
+ );
+ }
+
+ // Rename.
+
+ {
+ let primedRenameData = await event_page_extension("onRenamed", () => {
+ rootFolder.getChildNamed("TestFolder").rename("TestFolder2", null);
+ });
+ let renameData = await extension.awaitMessage("onRenamed received");
+ Assert.deepEqual(
+ primedRenameData,
+ renameData,
+ "The primed onRenamed event should return the correct values"
+ );
+ if (IS_IMAP) {
+ // IMAP server sends an additional onDeleted and onCreated.
+ await extension.awaitMessage("onDeleted received");
+ await extension.awaitMessage("onCreated received");
+ }
+ Assert.deepEqual(
+ [
+ {
+ accountId: account.key,
+ name: "TestFolder",
+ path: "/TestFolder",
+ },
+ {
+ accountId: account.key,
+ name: "TestFolder2",
+ path: "/TestFolder2",
+ },
+ ],
+ renameData,
+ "The onRenamed event should return the correct values"
+ );
+ }
+
+ await extension.unload();
+
+ cleanUpAccount(account);
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_identities_mv3_event_pages.js b/comm/mail/components/extensions/test/xpcshell/test_ext_identities_mv3_event_pages.js
new file mode 100644
index 0000000000..0b12f8ca1c
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_identities_mv3_event_pages.js
@@ -0,0 +1,146 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+add_task(async function test_identities_MV3_event_pages() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let account1 = createAccount();
+ addIdentity(account1, "id1@invalid");
+
+ let files = {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+
+ for (let eventName of ["onCreated", "onUpdated", "onDeleted"]) {
+ browser.identities[eventName].addListener((...args) => {
+ // Only send the first event after background wake-up, this should be the
+ // only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${eventName} received`, args);
+ }
+ });
+ }
+
+ browser.test.sendMessage("background started");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ manifest_version: 3,
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "accountsIdentities"],
+ browser_specific_settings: { gecko: { id: "identities@xpcshell.test" } },
+ },
+ });
+
+ function checkPersistentListeners({ primed }) {
+ // A persistent event is referenced by its moduleName as defined in
+ // ext-mails.json, not by its actual namespace.
+ const persistent_events = [
+ "identities.onCreated",
+ "identities.onUpdated",
+ "identities.onDeleted",
+ ];
+
+ for (let event of persistent_events) {
+ let [moduleName, eventName] = event.split(".");
+ assertPersistentListeners(extension, moduleName, eventName, {
+ primed,
+ });
+ }
+ }
+
+ await extension.startup();
+
+ await extension.awaitMessage("background started");
+ // Verify persistent listener, not yet primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Create.
+
+ let id2 = addIdentity(account1, "id2@invalid");
+ let createData = await extension.awaitMessage("onCreated received");
+ Assert.deepEqual(
+ [
+ "id2",
+ {
+ accountId: "account1",
+ id: "id2",
+ label: "",
+ name: "",
+ email: "id2@invalid",
+ replyTo: "",
+ organization: "",
+ composeHtml: true,
+ signature: "",
+ signatureIsPlainText: true,
+ },
+ ],
+ createData,
+ "The primed onCreated event should return the correct values"
+ );
+
+ await extension.awaitMessage("background started");
+ // Verify persistent listener, not yet primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Update
+
+ id2.fullName = "Updated Name";
+ let updateData = await extension.awaitMessage("onUpdated received");
+ Assert.deepEqual(
+ ["id2", { name: "Updated Name", accountId: "account1", id: "id2" }],
+ updateData,
+ "The primed onUpdated event should return the correct values"
+ );
+ await extension.awaitMessage("background started");
+ // Verify persistent listener, not yet primed.
+ checkPersistentListeners({ primed: false });
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listeners.
+ checkPersistentListeners({ primed: true });
+
+ // Delete
+
+ account1.removeIdentity(id2);
+ let deleteData = await extension.awaitMessage("onDeleted received");
+ Assert.deepEqual(
+ ["id2"],
+ deleteData,
+ "The primed onDeleted event should return the correct values"
+ );
+ // The background should have been restarted.
+ await extension.awaitMessage("background started");
+ // The listener should no longer be primed.
+ checkPersistentListeners({ primed: false });
+
+ await extension.unload();
+
+ cleanUpAccount(account1);
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages.js
new file mode 100644
index 0000000000..24b2cb1484
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages.js
@@ -0,0 +1,730 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { ExtensionsUI } = ChromeUtils.import(
+ "resource:///modules/ExtensionsUI.jsm"
+);
+
+let account, rootFolder, subFolders;
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function setup() {
+ account = createAccount();
+ rootFolder = account.incomingServer.rootFolder;
+ subFolders = {
+ test3: await createSubfolder(rootFolder, "test3"),
+ test4: await createSubfolder(rootFolder, "test4"),
+ trash: rootFolder.getChildNamed("Trash"),
+ };
+ await createMessages(subFolders.trash, 99);
+ await createMessages(subFolders.test4, 1);
+ }
+);
+
+add_task(async function non_canonical_permission_description_mapping() {
+ let { msgs } = ExtensionsUI._buildStrings({
+ addon: { name: "FakeExtension" },
+ permissions: {
+ origins: [],
+ permissions: ["accountsRead", "messagesMove"],
+ },
+ });
+ equal(2, msgs.length, "Correct amount of descriptions");
+ equal(
+ "See your mail accounts, their identities and their folders",
+ msgs[0],
+ "Correct description for accountsRead"
+ );
+ equal(
+ "Copy or move your email messages (including moving them to the trash folder)",
+ msgs[1],
+ "Correct description for messagesMove"
+ );
+});
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_pagination() {
+ let files = {
+ "background.js": async () => {
+ // Test a response of 99 messages at 10 messages per page.
+ let [folder] = await window.waitForMessage();
+ let page = await browser.messages.list(folder);
+ browser.test.assertEq(36, page.id.length);
+ browser.test.assertEq(10, page.messages.length);
+
+ let originalPageId = page.id;
+ let numPages = 1;
+ let numMessages = 10;
+ while (page.id) {
+ page = await browser.messages.continueList(page.id);
+ browser.test.assertTrue(page.messages.length > 0);
+ numPages++;
+ numMessages += page.messages.length;
+ if (numMessages < 99) {
+ browser.test.assertEq(originalPageId, page.id);
+ } else {
+ browser.test.assertEq(null, page.id);
+ }
+ }
+ browser.test.assertEq(10, numPages);
+ browser.test.assertEq(99, numMessages);
+
+ browser.test.assertRejects(
+ browser.messages.continueList(originalPageId),
+ /No message list for id .*\. Have you reached the end of a list\?/
+ );
+
+ await window.sendMessage("setPref");
+
+ // Do the same test, but with the default 100 messages per page.
+ page = await browser.messages.list(folder);
+ browser.test.assertEq(null, page.id);
+ browser.test.assertEq(99, page.messages.length);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ Services.prefs.setIntPref("extensions.webextensions.messagesPerPage", 10);
+
+ await extension.startup();
+ extension.sendMessage({ accountId: account.key, path: "/Trash" });
+
+ await extension.awaitMessage("setPref");
+ Services.prefs.clearUserPref("extensions.webextensions.messagesPerPage");
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_delete_without_permission() {
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+ let testFolder4 = folders.find(f => f.name == "test4");
+
+ let { messages: folder4Messages } = await browser.messages.list(
+ testFolder4
+ );
+
+ // Try to delete a message.
+ await browser.test.assertThrows(
+ () => browser.messages.delete([folder4Messages[0].id], true),
+ `browser.messages.delete is not a function`,
+ "Should reject deleting without proper permission"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_specific_settings: {
+ gecko: { id: "messages.delete@mochi.test" },
+ },
+ permissions: ["accountsRead", "messagesMove", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_move_and_copy_without_permission() {
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+ let testFolder4 = folders.find(f => f.name == "test4");
+ let testFolder3 = folders.find(f => f.name == "test3");
+
+ let { messages: folder4Messages } = await browser.messages.list(
+ testFolder4
+ );
+
+ // Try to move a message.
+ await browser.test.assertRejects(
+ browser.messages.move([folder4Messages[0].id], testFolder3),
+ `Using messages.move() requires the "accountsRead" and the "messagesMove" permission`,
+ "Should reject move without proper permission"
+ );
+
+ // Try to copy a message.
+ await browser.test.assertRejects(
+ browser.messages.copy([folder4Messages[0].id], testFolder3),
+ `Using messages.copy() requires the "accountsRead" and the "messagesMove" permission`,
+ "Should reject copy without proper permission"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ browser_specific_settings: {
+ gecko: { id: "messages.move@mochi.test" },
+ },
+ permissions: ["messagesRead", "accountsRead"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_tags() {
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+ let { folders } = await browser.accounts.get(accountId);
+ let testFolder4 = folders.find(f => f.name == "test4");
+ let { messages: folder4Messages } = await browser.messages.list(
+ testFolder4
+ );
+
+ let tags1 = await browser.messages.listTags();
+ window.assertDeepEqual(
+ [
+ {
+ key: "$label1",
+ tag: "Important",
+ color: "#FF0000",
+ ordinal: "",
+ },
+ {
+ key: "$label2",
+ tag: "Work",
+ color: "#FF9900",
+ ordinal: "",
+ },
+ {
+ key: "$label3",
+ tag: "Personal",
+ color: "#009900",
+ ordinal: "",
+ },
+ {
+ key: "$label4",
+ tag: "To Do",
+ color: "#3333FF",
+ ordinal: "",
+ },
+ {
+ key: "$label5",
+ tag: "Later",
+ color: "#993399",
+ ordinal: "",
+ },
+ ],
+ tags1
+ );
+
+ // Test some allowed special chars and that the key is created as lower
+ // case.
+ let goodKeys = [
+ "TestKey",
+ "Test_Key",
+ "Test\\Key",
+ "Test}Key",
+ "Test&Key",
+ "Test!Key",
+ "Test§Key",
+ "Test$Key",
+ "Test=Key",
+ "Test?Key",
+ ];
+ for (let key of goodKeys) {
+ await browser.messages.createTag(key, "Test Tag", "#123456");
+ let goodTags = await browser.messages.listTags();
+ window.assertDeepEqual(
+ [
+ {
+ key: "$label1",
+ tag: "Important",
+ color: "#FF0000",
+ ordinal: "",
+ },
+ {
+ key: "$label2",
+ tag: "Work",
+ color: "#FF9900",
+ ordinal: "",
+ },
+ {
+ key: "$label3",
+ tag: "Personal",
+ color: "#009900",
+ ordinal: "",
+ },
+ {
+ key: "$label4",
+ tag: "To Do",
+ color: "#3333FF",
+ ordinal: "",
+ },
+ {
+ key: "$label5",
+ tag: "Later",
+ color: "#993399",
+ ordinal: "",
+ },
+ {
+ key: key.toLowerCase(),
+ tag: "Test Tag",
+ color: "#123456",
+ ordinal: "",
+ },
+ ],
+ goodTags
+ );
+ await browser.messages.deleteTag(key.toLowerCase());
+ }
+
+ await browser.messages.createTag("custom_tag", "Custom Tag", "#123456");
+ let tags2 = await browser.messages.listTags();
+ window.assertDeepEqual(
+ [
+ {
+ key: "$label1",
+ tag: "Important",
+ color: "#FF0000",
+ ordinal: "",
+ },
+ {
+ key: "$label2",
+ tag: "Work",
+ color: "#FF9900",
+ ordinal: "",
+ },
+ {
+ key: "$label3",
+ tag: "Personal",
+ color: "#009900",
+ ordinal: "",
+ },
+ {
+ key: "$label4",
+ tag: "To Do",
+ color: "#3333FF",
+ ordinal: "",
+ },
+ {
+ key: "$label5",
+ tag: "Later",
+ color: "#993399",
+ ordinal: "",
+ },
+ {
+ key: "custom_tag",
+ tag: "Custom Tag",
+ color: "#123456",
+ ordinal: "",
+ },
+ ],
+ tags2
+ );
+
+ await browser.messages.updateTag("$label5", {
+ tag: "Much Later",
+ color: "#225599",
+ });
+ let tags3 = await browser.messages.listTags();
+ window.assertDeepEqual(
+ [
+ {
+ key: "$label1",
+ tag: "Important",
+ color: "#FF0000",
+ ordinal: "",
+ },
+ {
+ key: "$label2",
+ tag: "Work",
+ color: "#FF9900",
+ ordinal: "",
+ },
+ {
+ key: "$label3",
+ tag: "Personal",
+ color: "#009900",
+ ordinal: "",
+ },
+ {
+ key: "$label4",
+ tag: "To Do",
+ color: "#3333FF",
+ ordinal: "",
+ },
+ {
+ key: "$label5",
+ tag: "Much Later",
+ color: "#225599",
+ ordinal: "",
+ },
+ {
+ key: "custom_tag",
+ tag: "Custom Tag",
+ color: "#123456",
+ ordinal: "",
+ },
+ ],
+ tags3
+ );
+
+ // Test rejects for createTag().
+ let badKeys = [
+ "Bad Key",
+ "Bad%Key",
+ "Bad/Key",
+ "Bad*Key",
+ 'Bad"Key',
+ "Bad{Key}",
+ "Bad(Key)",
+ "Bad<Key>",
+ ];
+ for (let badKey of badKeys) {
+ await browser.test.assertThrows(
+ () =>
+ browser.messages.createTag(badKey, "Important Stuff", "#223344"),
+ /Type error for parameter key/,
+ `Should reject creating an invalid key: ${badKey}`
+ );
+ }
+
+ await browser.test.assertThrows(
+ () =>
+ browser.messages.createTag(
+ "GoodKeyBadColor",
+ "Important Stuff",
+ "#223"
+ ),
+ /Type error for parameter color /,
+ "Should reject creating a key using an invalid short color"
+ );
+
+ await browser.test.assertThrows(
+ () =>
+ browser.messages.createTag(
+ "GoodKeyBadColor",
+ "Important Stuff",
+ "123223"
+ ),
+ /Type error for parameter color /,
+ "Should reject creating a key using an invalid color without leading #"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.createTag("$label5", "Important Stuff", "#223344"),
+ `Specified key already exists: $label5`,
+ "Should reject creating a key which exists already"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.createTag(
+ "Custom_Tag",
+ "Important Stuff",
+ "#223344"
+ ),
+ `Specified key already exists: custom_tag`,
+ "Should reject creating a key which exists already"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.createTag("GoodKey", "Important", "#223344"),
+ `Specified tag already exists: Important`,
+ "Should reject creating a key using a tag which exists already"
+ );
+
+ // Test rejects for updateTag();
+ await browser.test.assertThrows(
+ () => browser.messages.updateTag("Bad Key", { tag: "Much Later" }),
+ /Type error for parameter key/,
+ "Should reject updating an invalid key"
+ );
+
+ await browser.test.assertThrows(
+ () =>
+ browser.messages.updateTag("GoodKeyBadColor", { color: "123223" }),
+ /Error processing color/,
+ "Should reject updating a key using an invalid color"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.updateTag("$label50", { tag: "Much Later" }),
+ `Specified key does not exist: $label50`,
+ "Should reject updating an unknown key"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.updateTag("$label5", { tag: "Important" }),
+ `Specified tag already exists: Important`,
+ "Should reject updating a key using a tag which exists already"
+ );
+
+ // Test rejects for deleteTag();
+ await browser.test.assertThrows(
+ () => browser.messages.deleteTag("Bad Key"),
+ /Type error for parameter key/,
+ "Should reject deleting an invalid key"
+ );
+
+ await browser.test.assertRejects(
+ browser.messages.deleteTag("$label50"),
+ `Specified key does not exist: $label50`,
+ "Should reject deleting an unknown key"
+ );
+
+ // Test tagging messages, deleting tag and re-creating tag.
+ await browser.messages.update(folder4Messages[0].id, {
+ tags: ["custom_tag"],
+ });
+ let message1 = await browser.messages.get(folder4Messages[0].id);
+ window.assertDeepEqual(["custom_tag"], message1.tags);
+
+ await browser.messages.deleteTag("custom_tag");
+ let message2 = await browser.messages.get(folder4Messages[0].id);
+ window.assertDeepEqual([], message2.tags);
+
+ await browser.messages.createTag("custom_tag", "Custom Tag", "#123456");
+ let message3 = await browser.messages.get(folder4Messages[0].id);
+ window.assertDeepEqual(["custom_tag"], message3.tags);
+
+ // Test deleting built-in tag.
+ await browser.messages.deleteTag("$label5");
+ let tags4 = await browser.messages.listTags();
+ window.assertDeepEqual(
+ [
+ {
+ key: "$label1",
+ tag: "Important",
+ color: "#FF0000",
+ ordinal: "",
+ },
+ {
+ key: "$label2",
+ tag: "Work",
+ color: "#FF9900",
+ ordinal: "",
+ },
+ {
+ key: "$label3",
+ tag: "Personal",
+ color: "#009900",
+ ordinal: "",
+ },
+ {
+ key: "$label4",
+ tag: "To Do",
+ color: "#3333FF",
+ ordinal: "",
+ },
+ {
+ key: "custom_tag",
+ tag: "Custom Tag",
+ color: "#123456",
+ ordinal: "",
+ },
+ ],
+ tags4
+ );
+
+ // Clean up.
+ await browser.messages.update(folder4Messages[0].id, { tags: [] });
+ await browser.messages.deleteTag("custom_tag");
+ await browser.messages.createTag("$label5", "Later", "#993399");
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead", "messagesTags"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_tags_no_permission() {
+ let files = {
+ "background.js": async () => {
+ await browser.test.assertThrows(
+ () =>
+ browser.messages.createTag(
+ "custom_tag",
+ "Important Stuff",
+ "#223344"
+ ),
+ /browser.messages.createTag is not a function/,
+ "Should reject creating tags without messagesTags permission"
+ );
+
+ await browser.test.assertThrows(
+ () => browser.messages.updateTag("$label5", { tag: "Much Later" }),
+ /browser.messages.updateTag is not a function/,
+ "Should reject updating tags without messagesTags permission"
+ );
+
+ await browser.test.assertThrows(
+ () => browser.messages.deleteTag("$label5"),
+ /browser.messages.deleteTag is not a function/,
+ "Should reject deleting tags without messagesTags permission"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["messagesRead", "accountsRead"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+// The IMAP fakeserver just can't handle this.
+add_task({ skip_if: () => IS_IMAP || IS_NNTP }, async function test_archive() {
+ let account2 = createAccount();
+ account2.addIdentity(MailServices.accounts.createIdentity());
+ let inbox2 = await createSubfolder(
+ account2.incomingServer.rootFolder,
+ "test"
+ );
+ await createMessages(inbox2, 15);
+
+ let month = 10;
+ for (let message of inbox2.messages) {
+ message.date = new Date(2018, month++, 15) * 1000;
+ }
+
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+
+ let accountBefore = await browser.accounts.get(accountId);
+ browser.test.assertEq(3, accountBefore.folders.length);
+ browser.test.assertEq("/test", accountBefore.folders[2].path);
+
+ let messagesBefore = await browser.messages.list(
+ accountBefore.folders[2]
+ );
+ browser.test.assertEq(15, messagesBefore.messages.length);
+ await browser.messages.archive(messagesBefore.messages.map(m => m.id));
+
+ let accountAfter = await browser.accounts.get(accountId);
+ browser.test.assertEq(4, accountAfter.folders.length);
+ browser.test.assertEq("/test", accountAfter.folders[3].path);
+ browser.test.assertEq("/Archives", accountAfter.folders[0].path);
+ browser.test.assertEq(3, accountAfter.folders[0].subFolders.length);
+ browser.test.assertEq(
+ "/Archives/2018",
+ accountAfter.folders[0].subFolders[0].path
+ );
+ browser.test.assertEq(
+ "/Archives/2019",
+ accountAfter.folders[0].subFolders[1].path
+ );
+ browser.test.assertEq(
+ "/Archives/2020",
+ accountAfter.folders[0].subFolders[2].path
+ );
+
+ let messagesAfter = await browser.messages.list(accountAfter.folders[3]);
+ browser.test.assertEq(0, messagesAfter.messages.length);
+
+ let messages2018 = await browser.messages.list(
+ accountAfter.folders[0].subFolders[0]
+ );
+ browser.test.assertEq(2, messages2018.messages.length);
+
+ let messages2019 = await browser.messages.list(
+ accountAfter.folders[0].subFolders[1]
+ );
+ browser.test.assertEq(12, messages2019.messages.length);
+
+ let messages2020 = await browser.messages.list(
+ accountAfter.folders[0].subFolders[2]
+ );
+ browser.test.assertEq(1, messages2020.messages.length);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesMove", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account2.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_attachments.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_attachments.js
new file mode 100644
index 0000000000..e46e35afe7
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_attachments.js
@@ -0,0 +1,499 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+add_task(
+ {
+ skip_if: () => IS_IMAP,
+ },
+ async function test_setup() {
+ let _account = createAccount();
+ let _testFolder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+
+ let textAttachment = {
+ body: "textAttachment",
+ filename: "test.txt",
+ contentType: "text/plain",
+ };
+ let binaryAttachment = {
+ body: btoa("binaryAttachment"),
+ filename: "test",
+ contentType: "application/octet-stream",
+ encoding: "base64",
+ };
+
+ await createMessages(_testFolder, {
+ count: 1,
+ subject: "0 attachments",
+ });
+ await createMessages(_testFolder, {
+ count: 1,
+ subject: "1 text attachment",
+ attachments: [textAttachment],
+ });
+ await createMessages(_testFolder, {
+ count: 1,
+ subject: "1 binary attachment",
+ attachments: [binaryAttachment],
+ });
+ await createMessages(_testFolder, {
+ count: 1,
+ subject: "2 attachments",
+ attachments: [binaryAttachment, textAttachment],
+ });
+ await createMessageFromFile(
+ _testFolder,
+ do_get_file("messages/nestedMessages.eml").path
+ );
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_IMAP,
+ },
+ async function test_attachments() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [account] = await browser.accounts.list();
+ let testFolder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(5, messages.length);
+
+ let attachments, attachment, file;
+
+ // "0 attachments" message.
+
+ attachments = await browser.messages.listAttachments(messages[0].id);
+ browser.test.assertEq("0 attachments", messages[0].subject);
+ browser.test.assertEq(0, attachments.length);
+
+ // "1 text attachment" message.
+
+ attachments = await browser.messages.listAttachments(messages[1].id);
+ browser.test.assertEq("1 text attachment", messages[1].subject);
+ browser.test.assertEq(1, attachments.length);
+
+ attachment = attachments[0];
+ browser.test.assertEq("text/plain", attachment.contentType);
+ browser.test.assertEq("test.txt", attachment.name);
+ browser.test.assertEq("1.2", attachment.partName);
+ browser.test.assertEq(14, attachment.size);
+
+ file = await browser.messages.getAttachmentFile(
+ messages[1].id,
+ attachment.partName
+ );
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(file instanceof File);
+ browser.test.assertEq("test.txt", file.name);
+ browser.test.assertEq(14, file.size);
+
+ browser.test.assertEq("textAttachment", await file.text());
+
+ let reader = new FileReader();
+ let data = await new Promise(resolve => {
+ reader.onload = e => resolve(e.target.result);
+ reader.readAsDataURL(file);
+ });
+
+ browser.test.assertEq(
+ "data:text/plain;base64,dGV4dEF0dGFjaG1lbnQ=",
+ data
+ );
+
+ // "1 binary attachment" message.
+
+ attachments = await browser.messages.listAttachments(messages[2].id);
+ browser.test.assertEq("1 binary attachment", messages[2].subject);
+ browser.test.assertEq(1, attachments.length);
+
+ attachment = attachments[0];
+ browser.test.assertEq(
+ attachment.contentType,
+ "application/octet-stream"
+ );
+ browser.test.assertEq("test", attachment.name);
+ browser.test.assertEq("1.2", attachment.partName);
+ browser.test.assertEq(16, attachment.size);
+
+ file = await browser.messages.getAttachmentFile(
+ messages[2].id,
+ attachment.partName
+ );
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(file instanceof File);
+ browser.test.assertEq("test", file.name);
+ browser.test.assertEq(16, file.size);
+
+ browser.test.assertEq("binaryAttachment", await file.text());
+
+ reader = new FileReader();
+ data = await new Promise(resolve => {
+ reader.onload = e => resolve(e.target.result);
+ reader.readAsDataURL(file);
+ });
+
+ browser.test.assertEq(
+ "data:application/octet-stream;base64,YmluYXJ5QXR0YWNobWVudA==",
+ data
+ );
+
+ // "2 attachments" message.
+
+ attachments = await browser.messages.listAttachments(messages[3].id);
+ browser.test.assertEq("2 attachments", messages[3].subject);
+ browser.test.assertEq(2, attachments.length);
+
+ attachment = attachments[0];
+ browser.test.assertEq(
+ attachment.contentType,
+ "application/octet-stream"
+ );
+ browser.test.assertEq("test", attachment.name);
+ browser.test.assertEq("1.2", attachment.partName);
+ browser.test.assertEq(16, attachment.size);
+
+ file = await browser.messages.getAttachmentFile(
+ messages[3].id,
+ attachment.partName
+ );
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(file instanceof File);
+ browser.test.assertEq("test", file.name);
+ browser.test.assertEq(16, file.size);
+
+ browser.test.assertEq("binaryAttachment", await file.text());
+
+ attachment = attachments[1];
+ browser.test.assertEq("text/plain", attachment.contentType);
+ browser.test.assertEq("test.txt", attachment.name);
+ browser.test.assertEq("1.3", attachment.partName);
+ browser.test.assertEq(14, attachment.size);
+
+ file = await browser.messages.getAttachmentFile(
+ messages[3].id,
+ attachment.partName
+ );
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(file instanceof File);
+ browser.test.assertEq("test.txt", file.name);
+ browser.test.assertEq(14, file.size);
+
+ browser.test.assertEq("textAttachment", await file.text());
+
+ await browser.test.assertRejects(
+ browser.messages.listAttachments(0),
+ /^Message not found: \d+\.$/,
+ "Bad message ID should throw"
+ );
+ await browser.test.assertRejects(
+ browser.messages.getAttachmentFile(0, "1.2"),
+ /^Message not found: \d+\.$/,
+ "Bad message ID should throw"
+ );
+ browser.test.assertThrows(
+ () => browser.messages.getAttachmentFile(messages[3].id, "silly"),
+ /^Type error for parameter partName .* for messages\.getAttachmentFile\.$/,
+ "Bad part name should throw"
+ );
+ await browser.test.assertRejects(
+ browser.messages.getAttachmentFile(messages[3].id, "1.42"),
+ /Part 1.42 not found in message \d+\./,
+ "Non-existent part should throw"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => IS_IMAP,
+ },
+ async function test_messages_as_attachments() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [account] = await browser.accounts.list();
+ let testFolder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(5, messages.length);
+ let message = messages[4];
+
+ function validateMessage(msg, expectedValues) {
+ for (let expectedValueName in expectedValues) {
+ let value = msg[expectedValueName];
+ let expected = expectedValues[expectedValueName];
+ if (Array.isArray(expected)) {
+ browser.test.assertTrue(
+ Array.isArray(value),
+ `Value for ${expectedValueName} should be an Array.`
+ );
+ browser.test.assertEq(
+ expected.length,
+ value.length,
+ `Value for ${expectedValueName} should have the correct Array size.`
+ );
+ for (let i = 0; i < expected.length; i++) {
+ browser.test.assertEq(
+ expected[i],
+ value[i],
+ `Value for ${expectedValueName}[${i}] should be correct.`
+ );
+ }
+ } else if (expected instanceof Date) {
+ browser.test.assertTrue(
+ value instanceof Date,
+ `Value for ${expectedValueName} should be a Date.`
+ );
+ browser.test.assertEq(
+ expected.getTime(),
+ value.getTime(),
+ `Date value for ${expectedValueName} should be correct.`
+ );
+ } else {
+ browser.test.assertEq(
+ expected,
+ value,
+ `Value for ${expectedValueName} should be correct.`
+ );
+ }
+ }
+ }
+
+ // Request attachments.
+ let attachments = await browser.messages.listAttachments(message.id);
+ browser.test.assertEq(2, attachments.length);
+ browser.test.assertEq("1.2", attachments[0].partName);
+ browser.test.assertEq("1.3", attachments[1].partName);
+
+ browser.test.assertEq("message1.eml", attachments[0].name);
+ browser.test.assertEq("yellowPixel.png", attachments[1].name);
+
+ // Validate the returned MessageHeader for attached message1.eml.
+ let subMessage = attachments[0].message;
+ browser.test.assertTrue(
+ subMessage.id != message.id,
+ `Id of attached SubMessage (${subMessage.id}) should be different from the id of the outer message (${message.id})`
+ );
+ validateMessage(subMessage, {
+ date: new Date(958606367000),
+ author: "Superman <clark.kent@dailyplanet.com>",
+ recipients: ["Jimmy <jimmy.olsen@dailyplanet.com>"],
+ ccList: [],
+ bccList: [],
+ subject: "Test message 1",
+ new: false,
+ headersOnly: false,
+ flagged: false,
+ junk: false,
+ junkScore: 0,
+ headerMessageId: "sample-attached.eml@mime.sample",
+ size: 0,
+ tags: [],
+ external: true,
+ });
+
+ // Get attachments of sub-message messag1.eml.
+ let subAttachments = await browser.messages.listAttachments(
+ subMessage.id
+ );
+ browser.test.assertEq(4, subAttachments.length);
+ browser.test.assertEq("1.2.1.2", subAttachments[0].partName);
+ browser.test.assertEq("1.2.1.3", subAttachments[1].partName);
+ browser.test.assertEq("1.2.1.4", subAttachments[2].partName);
+ browser.test.assertEq("1.2.1.5", subAttachments[3].partName);
+
+ browser.test.assertEq("whitePixel.png", subAttachments[0].name);
+ browser.test.assertEq("greenPixel.png", subAttachments[1].name);
+ browser.test.assertEq("redPixel.png", subAttachments[2].name);
+ browser.test.assertEq("message2.eml", subAttachments[3].name);
+
+ // Validate the returned MessageHeader for sub-message message2.eml
+ // attached to sub-message message1.eml.
+ let subSubMessage = subAttachments[3].message;
+ browser.test.assertTrue(
+ ![message.id, subMessage.id].includes(subSubMessage.id),
+ `Id of attached SubSubMessage (${subSubMessage.id}) should be different from the id of the outer message (${message.id}) and from the SubMessage (${subMessage.id})`
+ );
+ validateMessage(subSubMessage, {
+ date: new Date(958519967000),
+ author: "Jimmy <jimmy.olsen@dailyplanet.com>",
+ recipients: ["Superman <clark.kent@dailyplanet.com>"],
+ ccList: [],
+ bccList: [],
+ subject: "Test message 2",
+ new: false,
+ headersOnly: false,
+ flagged: false,
+ junk: false,
+ junkScore: 0,
+ headerMessageId: "sample-nested-attached.eml@mime.sample",
+ size: 0,
+ tags: [],
+ external: true,
+ });
+
+ // Test getAttachmentFile().
+ // Note: This function has x-ray vision into sub-messages and can get
+ // any part inside the message, even if - technically - the attachments
+ // belong to subMessages. There is no difference between requesting
+ // part 1.2.1.2 from the main message or from message1.eml (part 1.2).
+ // X-ray vision from a sub-message back into a parent is not allowed.
+ let platform = await browser.runtime.getPlatformInfo();
+ let fileTests = [
+ {
+ partName: "1.2",
+ name: "message1.eml",
+ size:
+ platform.os != "win" &&
+ (account.type == "none" || account.type == "nntp")
+ ? 2517
+ : 2601,
+ text: "Message-ID: <sample-attached.eml@mime.sample>",
+ },
+ {
+ partName: "1.2.1.2",
+ name: "whitePixel.png",
+ size: 69,
+ data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC",
+ },
+ {
+ partName: "1.2.1.3",
+ name: "greenPixel.png",
+ size: 119,
+ data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY+C76AoAAhUBJel4xsMAAAAASUVORK5CYII=",
+ },
+ {
+ partName: "1.2.1.4",
+ name: "redPixel.png",
+ size: 119,
+ data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY+hgkAYAAbcApOp/9LEAAAAASUVORK5CYII=",
+ },
+ {
+ partName: "1.2.1.5",
+ name: "message2.eml",
+ size:
+ platform.os != "win" &&
+ (account.type == "none" || account.type == "nntp")
+ ? 838
+ : 867,
+ text: "Message-ID: <sample-nested-attached.eml@mime.sample>",
+ },
+ {
+ partName: "1.2.1.5.1.2",
+ name: "whitePixel.png",
+ size: 69,
+ data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC",
+ },
+ {
+ partName: "1.3",
+ name: "yellowPixel.png",
+ size: 119,
+ data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY/j/iQEABOUB8pypNlQAAAAASUVORK5CYII=",
+ },
+ ];
+ let testMessages = [
+ {
+ id: message.id,
+ expectedFileCounts: 7,
+ },
+ {
+ id: subMessage.id,
+ subPart: "1.2.",
+ expectedFileCounts: 5,
+ },
+ {
+ id: subSubMessage.id,
+ subPart: "1.2.1.5.",
+ expectedFileCounts: 1,
+ },
+ ];
+ for (let msg of testMessages) {
+ let fileCounts = 0;
+ for (let test of fileTests) {
+ if (msg.subPart && !test.partName.startsWith(msg.subPart)) {
+ await browser.test.assertRejects(
+ browser.messages.getAttachmentFile(msg.id, test.partName),
+ `Part ${test.partName} not found in message ${msg.id}.`,
+ "Sub-message should not be able to get parts from parent message"
+ );
+ continue;
+ }
+ fileCounts++;
+
+ let file = await browser.messages.getAttachmentFile(
+ msg.id,
+ test.partName
+ );
+
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(file instanceof File);
+ browser.test.assertEq(test.name, file.name);
+ browser.test.assertEq(test.size, file.size);
+
+ if (test.text) {
+ browser.test.assertTrue(
+ (await file.text()).startsWith(test.text)
+ );
+ }
+
+ if (test.data) {
+ let reader = new FileReader();
+ let data = await new Promise(resolve => {
+ reader.onload = e => resolve(e.target.result);
+ reader.readAsDataURL(file);
+ });
+ browser.test.assertEq(
+ test.data,
+ data.replaceAll("\r\n", "\n").trim()
+ );
+ }
+ }
+ browser.test.assertEq(
+ msg.expectedFileCounts,
+ fileCounts,
+ "Should have requested to correct amount of attachment files."
+ );
+ }
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_get.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_get.js
new file mode 100644
index 0000000000..2872a2141f
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_get.js
@@ -0,0 +1,1073 @@
+/* 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/. */
+
+"use strict";
+
+var { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { OpenPGPTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mozmill/OpenPGPTestUtils.jsm"
+);
+
+const OPENPGP_TEST_DIR = do_get_file("../../../../test/browser/openpgp");
+const OPENPGP_KEY_PATH = PathUtils.join(
+ OPENPGP_TEST_DIR.path,
+ "data",
+ "keys",
+ "alice@openpgp.example-0xf231550c4f47e38e-secret.asc"
+);
+
+/**
+ * Test the messages.getRaw and messages.getFull functions. Since each message
+ * is unique and there are minor differences between the account
+ * implementations, we don't compare exactly with a reference message.
+ */
+add_task(async function test_plain_mv2() {
+ let _account = createAccount();
+ let _folder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+ await createMessages(_folder, 1);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+
+ for (let account of accounts) {
+ let folder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(1, messages.length);
+
+ let [message] = messages;
+
+ // Expected message content:
+ // -------------------------
+ // From andy@anway.invalid
+ // Content-Type: text/plain; charset=ISO-8859-1; format=flowed
+ // Subject: Big Meeting Today
+ // From: "Andy Anway" <andy@anway.invalid>
+ // To: "Bob Bell" <bob@bell.invalid>
+ // Message-Id: <0@made.up.invalid>
+ // Date: Wed, 06 Nov 2019 22:37:40 +1300
+ //
+ // Hello Bob Bell!
+ //
+
+ browser.test.assertEq("Big Meeting Today", message.subject);
+ browser.test.assertEq(
+ '"Andy Anway" <andy@anway.invalid>',
+ message.author
+ );
+
+ // The msgHdr of NNTP messages have no recipients.
+ if (account.type != "nntp") {
+ browser.test.assertEq(
+ "Bob Bell <bob@bell.invalid>",
+ message.recipients[0]
+ );
+ }
+
+ let strMessage_1 = await browser.messages.getRaw(message.id);
+ browser.test.assertEq("string", typeof strMessage_1);
+ let strMessage_2 = await browser.messages.getRaw(message.id, {
+ data_format: "BinaryString",
+ });
+ browser.test.assertEq("string", typeof strMessage_2);
+ let fileMessage_3 = await browser.messages.getRaw(message.id, {
+ data_format: "File",
+ });
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(fileMessage_3 instanceof File);
+ // Since we do not have utf-8 chars in the test message, the returned BinaryString is
+ // identical to the return value of File.text().
+ let strMessage_3 = await fileMessage_3.text();
+
+ for (let strMessage of [strMessage_1, strMessage_2, strMessage_3]) {
+ // Fold Windows line-endings \r\n to \n.
+ strMessage = strMessage.replace(/\r/g, "");
+ browser.test.assertTrue(
+ strMessage.includes("Subject: Big Meeting Today\n")
+ );
+ browser.test.assertTrue(
+ strMessage.includes('From: "Andy Anway" <andy@anway.invalid>\n')
+ );
+ browser.test.assertTrue(
+ strMessage.includes('To: "Bob Bell" <bob@bell.invalid>\n')
+ );
+ browser.test.assertTrue(strMessage.includes("Hello Bob Bell!"));
+ }
+
+ // {
+ // "contentType": "message/rfc822",
+ // "headers": {
+ // "content-type": ["text/plain; charset=ISO-8859-1; format=flowed"],
+ // "subject": ["Big Meeting Today"],
+ // "from": ["\"Andy Anway\" <andy@anway.invalid>"],
+ // "to": ["\"Bob Bell\" <bob@bell.invalid>"],
+ // "message-id": ["<0@made.up.invalid>"],
+ // "date": ["Wed, 06 Nov 2019 22:37:40 +1300"]
+ // },
+ // "partName": "",
+ // "size": 17,
+ // "parts": [
+ // {
+ // "body": "Hello Bob Bell!\n\n",
+ // "contentType": "text/plain",
+ // "headers": {
+ // "content-type": ["text/plain; charset=ISO-8859-1; format=flowed"]
+ // },
+ // "partName": "1",
+ // "size": 17
+ // }
+ // ]
+ // }
+
+ let fullMessage = await browser.messages.getFull(message.id);
+ browser.test.log(JSON.stringify(fullMessage));
+ browser.test.assertEq("object", typeof fullMessage);
+ browser.test.assertEq("message/rfc822", fullMessage.contentType);
+
+ browser.test.assertEq("object", typeof fullMessage.headers);
+ for (let header of [
+ "content-type",
+ "date",
+ "from",
+ "message-id",
+ "subject",
+ "to",
+ ]) {
+ browser.test.assertTrue(Array.isArray(fullMessage.headers[header]));
+ browser.test.assertEq(1, fullMessage.headers[header].length);
+ }
+ browser.test.assertEq(
+ "Big Meeting Today",
+ fullMessage.headers.subject[0]
+ );
+ browser.test.assertEq(
+ '"Andy Anway" <andy@anway.invalid>',
+ fullMessage.headers.from[0]
+ );
+ browser.test.assertEq(
+ '"Bob Bell" <bob@bell.invalid>',
+ fullMessage.headers.to[0]
+ );
+
+ browser.test.assertTrue(Array.isArray(fullMessage.parts));
+ browser.test.assertEq(1, fullMessage.parts.length);
+ browser.test.assertEq("object", typeof fullMessage.parts[0]);
+ browser.test.assertEq(
+ "Hello Bob Bell!",
+ fullMessage.parts[0].body.trimRight()
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: { permissions: ["accountsRead", "messagesRead"] },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+});
+
+add_task(async function test_plain_mv3() {
+ let _account = createAccount();
+ let _folder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+ await createMessages(_folder, 1);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+
+ for (let account of accounts) {
+ let folder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(1, messages.length);
+
+ let [message] = messages;
+
+ // Expected message content:
+ // -------------------------
+ // From chris@clarke.invalid
+ // Content-Type: text/plain; charset=ISO-8859-1; format=flowed
+ // Subject: Small Party Tomorrow
+ // From: "Chris Clarke" <chris@clarke.invalid>
+ // To: "David Davol" <david@davol.invalid>
+ // Message-Id: <1@made.up.invalid>
+ // Date: Tue, 01 Feb 2000 01:00:00 +0100
+ //
+ // Hello David Davol!
+ //
+
+ browser.test.assertEq("Small Party Tomorrow", message.subject);
+ browser.test.assertEq(
+ '"Chris Clarke" <chris@clarke.invalid>',
+ message.author
+ );
+
+ // The msgHdr of NNTP messages have no recipients.
+ if (account.type != "nntp") {
+ browser.test.assertEq(
+ "David Davol <david@davol.invalid>",
+ message.recipients[0]
+ );
+ }
+
+ let fileMessage_1 = await browser.messages.getRaw(message.id);
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(fileMessage_1 instanceof File);
+ // Since we do not have utf-8 chars in the test message, the returned
+ // BinaryString is identical to the return value of File.text().
+ let strMessage_1 = await fileMessage_1.text();
+
+ let strMessage_2 = await browser.messages.getRaw(message.id, {
+ data_format: "BinaryString",
+ });
+ browser.test.assertEq("string", typeof strMessage_2);
+
+ let fileMessage_3 = await browser.messages.getRaw(message.id, {
+ data_format: "File",
+ });
+ // eslint-disable-next-line mozilla/use-isInstance
+ browser.test.assertTrue(fileMessage_3 instanceof File);
+ let strMessage_3 = await fileMessage_3.text();
+
+ for (let strMessage of [strMessage_1, strMessage_2, strMessage_3]) {
+ // Fold Windows line-endings \r\n to \n.
+ strMessage = strMessage.replace(/\r/g, "");
+ browser.test.assertTrue(
+ strMessage.includes("Subject: Small Party Tomorrow\n")
+ );
+ browser.test.assertTrue(
+ strMessage.includes('From: "Chris Clarke" <chris@clarke.invalid>\n')
+ );
+ browser.test.assertTrue(
+ strMessage.includes('To: "David Davol" <david@davol.invalid>\n')
+ );
+ browser.test.assertTrue(strMessage.includes("Hello David Davol!"));
+ }
+
+ // {
+ // "contentType": "message/rfc822",
+ // "headers": {
+ // "content-type": ["text/plain; charset=ISO-8859-1; format=flowed"],
+ // "subject": ["Small Party Tomorrow"],
+ // "from": ["\"Chris Clarke\" <chris@clarke.invalid>"],
+ // "to": ["\"David Davol\" <David Davol>"],
+ // "message-id": ["<1@made.up.invalid>"],
+ // "date": ["Tue, 01 Feb 2000 01:00:00 +0100"]
+ // },
+ // "partName": "",
+ // "size": 20,
+ // "parts": [
+ // {
+ // "body": "David Davol!\n\n",
+ // "contentType": "text/plain",
+ // "headers": {
+ // "content-type": ["text/plain; charset=ISO-8859-1; format=flowed"]
+ // },
+ // "partName": "1",
+ // "size": 20
+ // }
+ // ]
+ // }
+
+ let fullMessage = await browser.messages.getFull(message.id);
+ browser.test.log(JSON.stringify(fullMessage));
+ browser.test.assertEq("object", typeof fullMessage);
+ browser.test.assertEq("message/rfc822", fullMessage.contentType);
+
+ browser.test.assertEq("object", typeof fullMessage.headers);
+ for (let header of [
+ "content-type",
+ "date",
+ "from",
+ "message-id",
+ "subject",
+ "to",
+ ]) {
+ browser.test.assertTrue(Array.isArray(fullMessage.headers[header]));
+ browser.test.assertEq(1, fullMessage.headers[header].length);
+ }
+ browser.test.assertEq(
+ "Small Party Tomorrow",
+ fullMessage.headers.subject[0]
+ );
+ browser.test.assertEq(
+ '"Chris Clarke" <chris@clarke.invalid>',
+ fullMessage.headers.from[0]
+ );
+ browser.test.assertEq(
+ '"David Davol" <david@davol.invalid>',
+ fullMessage.headers.to[0]
+ );
+
+ browser.test.assertTrue(Array.isArray(fullMessage.parts));
+ browser.test.assertEq(1, fullMessage.parts.length);
+ browser.test.assertEq("object", typeof fullMessage.parts[0]);
+ browser.test.assertEq(
+ "Hello David Davol!",
+ fullMessage.parts[0].body.trimRight()
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: {
+ manifest_version: 3,
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+});
+
+/**
+ * Test that mime parsers for all message types retrieve the correctly decoded
+ * headers and bodies. Bodies should no not be returned, if it is an attachment.
+ * Sizes are not checked for.
+ */
+add_task(async function test_encoding() {
+ let _account = createAccount();
+ let _folder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+
+ // Main body with disposition inline, base64 encoded,
+ // subject is UTF-8 encoded word.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample01.eml").path
+ );
+ // A multipart/mixed mime message, to header is iso-8859-1 encoded word,
+ // body is quoted printable with iso-8859-1, attachments with different names
+ // and filenames.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample02.eml").path
+ );
+ // Message with attachment only, From header is iso-8859-1 encoded word.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample03.eml").path
+ );
+ // Message with koi8-r + base64 encoded body, subject is koi8-r encoded word.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample04.eml").path
+ );
+ // Message with windows-1251 + base64 encoded body, subject is windows-1251
+ // encoded word.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample05.eml").path
+ );
+ // Message without plain/text content-type.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample06.eml").path
+ );
+ // A multipart/alternative message without plain/text content-type.
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/sample07.eml").path
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+
+ let expectedData = {
+ "01.eml@mime.sample": {
+ msgHeaders: {
+ subject: "αλφάβητο",
+ author: "Bug Reporter <new@thunderbird.bug>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Bug Reporter <new@thunderbird.bug>"],
+ newsgroups: ["gmane.comp.mozilla.thundebird.user"],
+ subject: ["αλφάβητο"],
+ date: ["Thu, 27 May 2021 21:23:35 +0100"],
+ "message-id": ["<01.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": ["text/plain; charset=utf-8;"],
+ "content-transfer-encoding": ["base64"],
+ "content-disposition": ["inline"],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1",
+ size: 0,
+ body: "Άλφα\n",
+ headers: {
+ "content-type": ["text/plain; charset=utf-8;"],
+ },
+ },
+ ],
+ },
+ },
+ "02.eml@mime.sample": {
+ msgHeaders: {
+ subject: "Test message from Microsoft Outlook 00",
+ author: '"Doug Sauder" <doug@example.com>',
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ['"Doug Sauder" <doug@example.com>'],
+ to: ["Heinz Müller <mueller@example.com>"],
+ subject: ["Test message from Microsoft Outlook 00"],
+ date: ["Wed, 17 May 2000 19:32:47 -0400"],
+ "message-id": ["<02.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": [
+ 'multipart/mixed; boundary="----=_NextPart_000_0002_01BFC036.AE309650"',
+ ],
+ "x-priority": ["3 (Normal)"],
+ "x-msmail-priority": ["Normal"],
+ "x-mailer": [
+ "Microsoft Outlook IMO, Build 9.0.2416 (9.0.2910.0)",
+ ],
+ importance: ["Normal"],
+ "x-mimeole": ["Produced By Microsoft MimeOLE V5.00.2314.1300"],
+ },
+ parts: [
+ {
+ contentType: "multipart/mixed",
+ partName: "1",
+ size: 0,
+ headers: {
+ "content-type": [
+ 'multipart/mixed; boundary="----=_NextPart_000_0002_01BFC036.AE309650"',
+ ],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1.1",
+ size: 0,
+ body: `\nDie Hasen und die Frösche \n \n`,
+ headers: {
+ "content-type": ['text/plain; charset="iso-8859-1"'],
+ },
+ },
+ {
+ contentType: "image/png",
+ partName: "1.2",
+ size: 0,
+ name: "blueball2.png",
+ headers: {
+ "content-type": ['image/png; name="blueball1.png"'],
+ },
+ },
+ {
+ contentType: "image/png",
+ partName: "1.3",
+ size: 0,
+ name: "greenball.png",
+ headers: {
+ "content-type": ['image/png; name="greenball.png"'],
+ },
+ },
+ {
+ contentType: "image/png",
+ partName: "1.4",
+ size: 0,
+ name: "redball.png",
+ headers: {
+ "content-type": ["image/png"],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ "03.eml@mime.sample": {
+ msgHeaders: {
+ subject: "Test message from Microsoft Outlook 00",
+ author: "Heinz Müller <mueller@example.com>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Heinz Müller <mueller@example.com>"],
+ to: ['"Joe Blow" <jblow@example.com>'],
+ subject: ["Test message from Microsoft Outlook 00"],
+ date: ["Wed, 17 May 2000 19:35:05 -0400"],
+ "message-id": ["<03.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": ['image/png; name="doubelspace ball.png"'],
+ "content-transfer-encoding": ["base64"],
+ "content-disposition": [
+ 'attachment; filename="doubelspace ball.png"',
+ ],
+ "x-priority": ["3 (Normal)"],
+ "x-msmail-priority": ["Normal"],
+ "x-mailer": [
+ "Microsoft Outlook IMO, Build 9.0.2416 (9.0.2910.0)",
+ ],
+ importance: ["Normal"],
+ "x-mimeole": ["Produced By Microsoft MimeOLE V5.00.2314.1300"],
+ },
+ parts: [
+ {
+ contentType: "image/png",
+ name: "doubelspace ball.png",
+ partName: "1",
+ size: 0,
+ headers: {
+ "content-type": ['image/png; name="doubelspace ball.png"'],
+ },
+ },
+ ],
+ },
+ },
+ "04.eml@mime.sample": {
+ msgHeaders: {
+ subject: "Алфавит",
+ author: "Bug Reporter <new@thunderbird.bug>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Bug Reporter <new@thunderbird.bug>"],
+ newsgroups: ["gmane.comp.mozilla.thundebird.user"],
+ subject: ["Алфавит"],
+ date: ["Sun, 27 May 2001 21:23:35 +0100"],
+ "message-id": ["<04.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": ["text/plain; charset=koi8-r;"],
+ "content-transfer-encoding": ["base64"],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1",
+ size: 0,
+ body: "Вопрос\n",
+ headers: {
+ "content-type": ["text/plain; charset=koi8-r;"],
+ },
+ },
+ ],
+ },
+ },
+ "05.eml@mime.sample": {
+ msgHeaders: {
+ subject: "Алфавит",
+ author: "Bug Reporter <new@thunderbird.bug>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Bug Reporter <new@thunderbird.bug>"],
+ newsgroups: ["gmane.comp.mozilla.thundebird.user"],
+ subject: ["Алфавит"],
+ date: ["Sun, 27 May 2001 21:23:35 +0100"],
+ "message-id": ["<05.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": ["text/plain; charset=windows-1251;"],
+ "content-transfer-encoding": ["base64"],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1",
+ size: 0,
+ body: "Вопрос\n",
+ headers: {
+ "content-type": ["text/plain; charset=windows-1251;"],
+ },
+ },
+ ],
+ },
+ },
+ "06.eml@mime.sample": {
+ msgHeaders: {
+ subject: "I have no content type",
+ author: "Bug Reporter <new@thunderbird.bug>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Bug Reporter <new@thunderbird.bug>"],
+ newsgroups: ["gmane.comp.mozilla.thundebird.user"],
+ subject: ["I have no content type"],
+ date: ["Sun, 27 May 2001 21:23:35 +0100"],
+ "message-id": ["<06.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1",
+ size: 0,
+ body: "No content type\n",
+ headers: {
+ "content-type": ["text/plain"],
+ },
+ },
+ ],
+ },
+ },
+ "07.eml@mime.sample": {
+ msgHeaders: {
+ subject: "Default content-types",
+ author: "Doug Sauder <dwsauder@example.com>",
+ },
+ msgParts: {
+ contentType: "message/rfc822",
+ partName: "",
+ size: 0,
+ headers: {
+ from: ["Doug Sauder <dwsauder@example.com>"],
+ to: ["Heinz <mueller@example.com>"],
+ subject: ["Default content-types"],
+ date: ["Fri, 19 May 2000 00:29:55 -0400"],
+ "message-id": ["<07.eml@mime.sample>"],
+ "mime-version": ["1.0"],
+ "content-type": [
+ 'multipart/alternative; boundary="=====================_714967308==_.ALT"',
+ ],
+ },
+ parts: [
+ {
+ contentType: "multipart/alternative",
+ partName: "1",
+ size: 0,
+ headers: {
+ "content-type": [
+ 'multipart/alternative; boundary="=====================_714967308==_.ALT"',
+ ],
+ },
+ parts: [
+ {
+ contentType: "text/plain",
+ partName: "1.1",
+ size: 0,
+ body: "Die Hasen\n",
+ headers: {
+ "content-type": ["text/plain"],
+ },
+ },
+ {
+ contentType: "text/html",
+ partName: "1.2",
+ size: 0,
+ body: "<html><body><b>Die Hasen</b></body></html>\n",
+ headers: {
+ "content-type": ["text/html"],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ };
+
+ function checkMsgHeaders(expected, actual) {
+ // Check if all expected properties are there.
+ for (let property of Object.keys(expected)) {
+ browser.test.assertEq(
+ expected.hasOwnProperty(property),
+ actual.hasOwnProperty(property),
+ `expected property ${property} is present`
+ );
+ // Check property content.
+ browser.test.assertEq(
+ expected[property],
+ actual[property],
+ `property ${property} is correct`
+ );
+ }
+ }
+
+ function checkMsgParts(expected, actual) {
+ // Check if all expected properties are there.
+ for (let property of Object.keys(expected)) {
+ browser.test.assertEq(
+ expected.hasOwnProperty(property),
+ actual.hasOwnProperty(property),
+ `expected property ${property} is present`
+ );
+ if (
+ ["parts", "headers", "size"].includes(property) ||
+ (["body"].includes(property) && expected[property] == "")
+ ) {
+ continue;
+ }
+ // Check property content.
+ browser.test.assertEq(
+ JSON.stringify(expected[property].replaceAll("\r\n", "\n")),
+ JSON.stringify(actual[property].replaceAll("\r\n", "\n")),
+ `property ${property} is correct`
+ );
+ }
+
+ // Check for unexpected properties.
+ for (let property of Object.keys(actual)) {
+ browser.test.assertEq(
+ expected.hasOwnProperty(property),
+ actual.hasOwnProperty(property),
+ `property ${property} is expected`
+ );
+ }
+
+ // Check if all expected headers are there.
+ if (expected.headers) {
+ for (let header of Object.keys(expected.headers)) {
+ browser.test.assertEq(
+ expected.headers.hasOwnProperty(header),
+ actual.headers.hasOwnProperty(header),
+ `expected header ${header} is present`
+ );
+ // Check header content.
+ // Note: jsmime does not eat TABs after a CLRF.
+ browser.test.assertEq(
+ expected.headers[header].toString().replaceAll("\t", " "),
+ actual.headers[header].toString().replaceAll("\t", " "),
+ `header ${header} is correct`
+ );
+ }
+ // Check for unexpected headers.
+ for (let header of Object.keys(actual.headers)) {
+ browser.test.assertEq(
+ expected.headers.hasOwnProperty(header),
+ actual.headers.hasOwnProperty(header),
+ `header ${header} is expected`
+ );
+ }
+ }
+
+ // Check sub-parts.
+ browser.test.assertEq(
+ Array.isArray(expected.parts),
+ Array.isArray(actual.parts),
+ `has sub-parts`
+ );
+ if (Array.isArray(expected.parts)) {
+ browser.test.assertEq(
+ expected.parts.length,
+ actual.parts.length,
+ "number of parts"
+ );
+ for (let i in expected.parts) {
+ checkMsgParts(expected.parts[i], actual.parts[i]);
+ }
+ }
+ }
+
+ for (let account of accounts) {
+ let folder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(7, messages.length);
+
+ for (let message of messages) {
+ let fullMessage = await browser.messages.getFull(message.id);
+ browser.test.assertEq("object", typeof fullMessage);
+
+ let expected = expectedData[message.headerMessageId];
+ checkMsgHeaders(expected.msgHeaders, message);
+ checkMsgParts(expected.msgParts, fullMessage);
+ }
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ manifest: { permissions: ["accountsRead", "messagesRead"] },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+});
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_openpgp() {
+ let _account = createAccount();
+ let _identity = addIdentity(_account);
+ let _folder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+
+ // Load an encrypted message.
+
+ let messagePath = PathUtils.join(
+ OPENPGP_TEST_DIR.path,
+ "data",
+ "eml",
+ "unsigned-encrypted-to-0xf231550c4f47e38e-from-0xfbfcc82a015e7330.eml"
+ );
+ await createMessageFromFile(_folder, messagePath);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [account] = await browser.accounts.list();
+ let folder = account.folders.find(f => f.name == "test1");
+
+ // Read the message, without the key set up. The headers should be
+ // readable, but not the message itself.
+
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(1, messages.length);
+
+ let [message] = messages;
+ browser.test.assertEq("...", message.subject);
+ browser.test.assertEq(
+ "Bob Babbage <bob@openpgp.example>",
+ message.author
+ );
+ browser.test.assertEq("alice@openpgp.example", message.recipients[0]);
+
+ let fullMessage = await browser.messages.getFull(message.id);
+ browser.test.log(JSON.stringify(fullMessage));
+ browser.test.assertEq("object", typeof fullMessage);
+ browser.test.assertEq("message/rfc822", fullMessage.contentType);
+
+ browser.test.assertEq("object", typeof fullMessage.headers);
+ for (let header of [
+ "content-type",
+ "date",
+ "from",
+ "message-id",
+ "subject",
+ "to",
+ ]) {
+ browser.test.assertTrue(Array.isArray(fullMessage.headers[header]));
+ browser.test.assertEq(1, fullMessage.headers[header].length);
+ }
+ browser.test.assertEq("...", fullMessage.headers.subject[0]);
+ browser.test.assertEq(
+ "Bob Babbage <bob@openpgp.example>",
+ fullMessage.headers.from[0]
+ );
+ browser.test.assertEq(
+ "alice@openpgp.example",
+ fullMessage.headers.to[0]
+ );
+
+ browser.test.assertTrue(Array.isArray(fullMessage.parts));
+ browser.test.assertEq(1, fullMessage.parts.length);
+
+ let part = fullMessage.parts[0];
+ browser.test.assertEq("object", typeof part);
+ browser.test.assertEq("multipart/encrypted", part.contentType);
+ browser.test.assertEq(undefined, part.parts);
+
+ // Now set up the key and read the message again. It should all be
+ // there this time.
+
+ await window.sendMessage("load key");
+
+ ({ messages } = await browser.messages.list(folder));
+ browser.test.assertEq(1, messages.length);
+ [message] = messages;
+ browser.test.assertEq("...", message.subject);
+ browser.test.assertEq(
+ "Bob Babbage <bob@openpgp.example>",
+ message.author
+ );
+ browser.test.assertEq("alice@openpgp.example", message.recipients[0]);
+
+ fullMessage = await browser.messages.getFull(message.id);
+ browser.test.log(JSON.stringify(fullMessage));
+ browser.test.assertEq("object", typeof fullMessage);
+ browser.test.assertEq("message/rfc822", fullMessage.contentType);
+
+ browser.test.assertEq("object", typeof fullMessage.headers);
+ for (let header of [
+ "content-type",
+ "date",
+ "from",
+ "message-id",
+ "subject",
+ "to",
+ ]) {
+ browser.test.assertTrue(Array.isArray(fullMessage.headers[header]));
+ browser.test.assertEq(1, fullMessage.headers[header].length);
+ }
+ browser.test.assertEq("...", fullMessage.headers.subject[0]);
+ browser.test.assertEq(
+ "Bob Babbage <bob@openpgp.example>",
+ fullMessage.headers.from[0]
+ );
+ browser.test.assertEq(
+ "alice@openpgp.example",
+ fullMessage.headers.to[0]
+ );
+
+ browser.test.assertTrue(Array.isArray(fullMessage.parts));
+ browser.test.assertEq(1, fullMessage.parts.length);
+
+ part = fullMessage.parts[0];
+ browser.test.assertEq("object", typeof part);
+ browser.test.assertEq("multipart/encrypted", part.contentType);
+ browser.test.assertTrue(Array.isArray(part.parts));
+ browser.test.assertEq(1, part.parts.length);
+
+ part = part.parts[0];
+ browser.test.assertEq("object", typeof part);
+ browser.test.assertEq("multipart/fake-container", part.contentType);
+ browser.test.assertTrue(Array.isArray(part.parts));
+ browser.test.assertEq(1, part.parts.length);
+
+ part = part.parts[0];
+ browser.test.assertEq("object", typeof part);
+ browser.test.assertEq("text/plain", part.contentType);
+ browser.test.assertEq(
+ "Sundays are nothing without callaloo.",
+ part.body.trimRight()
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("load key");
+ info(`Adding key from ${OPENPGP_KEY_PATH}`);
+ await OpenPGPTestUtils.initOpenPGP();
+ let [id] = await OpenPGPTestUtils.importPrivateKey(
+ null,
+ new FileUtils.File(OPENPGP_KEY_PATH)
+ );
+ _identity.setUnicharAttribute("openpgp_key_id", id);
+ extension.sendMessage();
+
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+ }
+);
+
+add_task(async function test_attached_message_with_missing_headers() {
+ let _account = createAccount();
+ let _folder = await createSubfolder(
+ _account.incomingServer.rootFolder,
+ "test1"
+ );
+
+ await createMessageFromFile(
+ _folder,
+ do_get_file("messages/attachedMessageWithMissingHeaders.eml").path
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+
+ for (let account of accounts) {
+ let folder = account.folders.find(f => f.name == "test1");
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(1, messages.length);
+
+ let msg = messages[0];
+ let attachments = await browser.messages.listAttachments(msg.id);
+ browser.test.assertEq(
+ attachments.length,
+ 1,
+ "Should have found the correct number of attachments"
+ );
+
+ let attachedMessage = attachments[0].message;
+ browser.test.assertTrue(
+ !!attachedMessage,
+ "Should have found an attached message"
+ );
+ browser.test.assertEq(
+ attachedMessage.date.getTime(),
+ 0,
+ "The date should be correct"
+ );
+ browser.test.assertEq(
+ attachedMessage.subject,
+ "",
+ "The subject should be empty"
+ );
+ browser.test.assertEq(
+ attachedMessage.author,
+ "",
+ "The author should be empty"
+ );
+ browser.test.assertEq(
+ attachedMessage.headerMessageId,
+ "sample-attached.eml@mime.sample",
+ "The headerMessageId should be correct"
+ );
+ window.assertDeepEqual(
+ attachedMessage.recipients,
+ [],
+ "The recipients should be correct"
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_id.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_id.js
new file mode 100644
index 0000000000..dac01fa514
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_id.js
@@ -0,0 +1,256 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var subFolders;
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function setup() {
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ subFolders = {
+ test1: await createSubfolder(rootFolder, "test1"),
+ test2: await createSubfolder(rootFolder, "test2"),
+ test3: await createSubfolder(rootFolder, "test3"),
+ attachment: await createSubfolder(rootFolder, "attachment"),
+ };
+ await createMessages(subFolders.test1, 5);
+ let textAttachment = {
+ body: "textAttachment",
+ filename: "test.txt",
+ contentType: "text/plain",
+ };
+ await createMessages(subFolders.attachment, {
+ count: 1,
+ subject: "Msg with text attachment",
+ attachments: [textAttachment],
+ });
+ }
+);
+
+// In this test we'll move and copy some messages around between
+// folders. Every operation should result in the message's id property
+// changing to a never-seen-before value.
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_identifiers() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let [{ folders }] = await browser.accounts.list();
+ let testFolder1 = folders.find(f => f.name == "test1");
+ let testFolder2 = folders.find(f => f.name == "test2");
+ let testFolder3 = folders.find(f => f.name == "test3");
+
+ let { messages } = await browser.messages.list(testFolder1);
+ browser.test.assertEq(
+ 5,
+ messages.length,
+ "message count in testFolder1"
+ );
+ browser.test.assertEq(1, messages[0].id);
+ browser.test.assertEq(2, messages[1].id);
+ browser.test.assertEq(3, messages[2].id);
+ browser.test.assertEq(4, messages[3].id);
+ browser.test.assertEq(5, messages[4].id);
+
+ let subjects = messages.map(m => m.subject);
+
+ // Move two messages. We could do this in one operation, but to be
+ // sure of the order, do it in separate operations.
+
+ await browser.messages.move([1], testFolder2);
+ await browser.messages.move([3], testFolder2);
+
+ ({ messages } = await browser.messages.list(testFolder1));
+ browser.test.assertEq(
+ 3,
+ messages.length,
+ "message count in testFolder1"
+ );
+ browser.test.assertEq(2, messages[0].id);
+ browser.test.assertEq(4, messages[1].id);
+ browser.test.assertEq(5, messages[2].id);
+ browser.test.assertEq(subjects[1], messages[0].subject);
+ browser.test.assertEq(subjects[3], messages[1].subject);
+ browser.test.assertEq(subjects[4], messages[2].subject);
+
+ ({ messages } = await browser.messages.list(testFolder2));
+ browser.test.assertEq(
+ 2,
+ messages.length,
+ "message count in testFolder2"
+ );
+ browser.test.assertEq(6, messages[0].id, "new id created");
+ browser.test.assertEq(7, messages[1].id, "new id created");
+ browser.test.assertEq(subjects[0], messages[0].subject);
+ browser.test.assertEq(subjects[2], messages[1].subject);
+
+ // Copy one message.
+
+ await browser.messages.copy([6], testFolder3);
+
+ ({ messages } = await browser.messages.list(testFolder2));
+ browser.test.assertEq(
+ 2,
+ messages.length,
+ "message count in testFolder2"
+ );
+ browser.test.assertEq(6, messages[0].id);
+ browser.test.assertEq(7, messages[1].id);
+ browser.test.assertEq(subjects[0], messages[0].subject);
+ browser.test.assertEq(subjects[2], messages[1].subject);
+
+ ({ messages } = await browser.messages.list(testFolder3));
+ browser.test.assertEq(
+ 1,
+ messages.length,
+ "message count in testFolder3"
+ );
+ browser.test.assertEq(8, messages[0].id, "new id created");
+ browser.test.assertEq(subjects[0], messages[0].subject);
+
+ // Move the copied message back to the previous folder. There should
+ // now be two copies there, each with their own ID.
+
+ await browser.messages.move([8], testFolder2);
+
+ ({ messages } = await browser.messages.list(testFolder2));
+ browser.test.assertEq(
+ 3,
+ messages.length,
+ "message count in testFolder2"
+ );
+ browser.test.assertEq(6, messages[0].id);
+ browser.test.assertEq(7, messages[1].id);
+ browser.test.assertEq(
+ 9,
+ messages[2].id,
+ "new id created, not a duplicate"
+ );
+ browser.test.assertEq(subjects[0], messages[0].subject);
+ browser.test.assertEq(subjects[2], messages[1].subject);
+ browser.test.assertEq(
+ subjects[0],
+ messages[2].subject,
+ "same message as another in this folder"
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesMove", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
+
+// In this test we'll remove an attachment from a message and its id property
+// should not change. (Bug 1645595). Test does not work with IMAP test server,
+// which has issues with attachments.
+add_task(
+ {
+ skip_if: () => IS_NNTP || IS_IMAP,
+ },
+ async function test_attachments() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ let id;
+
+ browser.test.onMessage.addListener(async () => {
+ // This listener gets called once the attachment has been removed.
+ // Make sure we still get the message and it no longer has the
+ // attachment.
+ let modifiedMessage = await browser.messages.getFull(id);
+ browser.test.assertEq(
+ "Msg with text attachment",
+ modifiedMessage.headers.subject[0]
+ );
+ browser.test.assertEq(
+ "text/x-moz-deleted",
+ modifiedMessage.parts[0].parts[1].contentType
+ );
+ browser.test.assertEq(
+ "Deleted: test.txt",
+ modifiedMessage.parts[0].parts[1].name
+ );
+ browser.test.notifyPass("finished");
+ });
+
+ let [{ folders }] = await browser.accounts.list();
+ let testFolder = folders.find(f => f.name == "attachment");
+ let { messages } = await browser.messages.list(testFolder);
+ browser.test.assertEq(1, messages.length);
+ id = messages[0].id;
+
+ let originalMessage = await browser.messages.getFull(id);
+ browser.test.assertEq(
+ "Msg with text attachment",
+ originalMessage.headers.subject[0]
+ );
+ browser.test.assertEq(
+ "text/plain",
+ originalMessage.parts[0].parts[1].contentType
+ );
+ browser.test.assertEq(
+ "test.txt",
+ originalMessage.parts[0].parts[1].name
+ );
+ browser.test.sendMessage("removeAttachment", id);
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ let observer = {
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "attachment-delete-msgkey-changed") {
+ extension.sendMessage();
+ }
+ },
+ };
+ Services.obs.addObserver(observer, "attachment-delete-msgkey-changed");
+
+ extension.onMessage("removeAttachment", () => {
+ let msgHdr = subFolders.attachment.messages.getNext();
+ let msgUri = msgHdr.folder.getUriForMsg(msgHdr);
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ messenger.detachAttachment(
+ "text/plain",
+ `${msgUri}?part=1.2&filename=test.txt`,
+ "test.txt",
+ msgUri,
+ false /* do not save */,
+ true /* do not ask */
+ );
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+ }
+);
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_import.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_import.js
new file mode 100644
index 0000000000..c3bef58835
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_import.js
@@ -0,0 +1,121 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+var { MailStringUtils } = ChromeUtils.import(
+ "resource:///modules/MailStringUtils.jsm"
+);
+
+add_task(async function test_import() {
+ let _account = createAccount();
+ await createSubfolder(_account.incomingServer.rootFolder, "test1");
+ await createSubfolder(_account.incomingServer.rootFolder, "test2");
+ await createSubfolder(_account.incomingServer.rootFolder, "test3");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ async function do_import(expected, file, folder, options) {
+ let msg = await browser.messages.import(file, folder, options);
+ browser.test.assertEq(
+ "alternative.eml@mime.sample",
+ msg.headerMessageId,
+ "should find the correct message after import"
+ );
+ let { messages } = await browser.messages.list(folder);
+ browser.test.assertEq(
+ 1,
+ messages.length,
+ "should find the imported message in the destination folder"
+ );
+ for (let [propName, value] of Object.entries(expected)) {
+ window.assertDeepEqual(
+ value,
+ messages[0][propName],
+ `Property ${propName} should be correct`
+ );
+ }
+ }
+
+ let accounts = await browser.accounts.list();
+ browser.test.assertEq(1, accounts.length);
+ let [account] = accounts;
+ let folder1 = account.folders.find(f => f.name == "test1");
+ let folder2 = account.folders.find(f => f.name == "test2");
+ let folder3 = account.folders.find(f => f.name == "test3");
+ browser.test.assertTrue(folder1, "Test folder should exist");
+ browser.test.assertTrue(folder2, "Test folder should exist");
+ browser.test.assertTrue(folder3, "Test folder should exist");
+
+ let [emlFileContent] = await window.sendMessage(
+ "getFileContent",
+ "messages/alternative.eml"
+ );
+ let file = new File([emlFileContent], "test.eml");
+
+ if (account.type == "nntp" || account.type == "imap") {
+ // nsIMsgCopyService.copyFileMessage() not implemented for NNTP.
+ // offline/online behavior of IMAP nsIMsgCopyService.copyFileMessage()
+ // is too erratic to be supported ATM.
+ await browser.test.assertRejects(
+ browser.messages.import(file, folder1),
+ `browser.messenger.import() is not supported for ${account.type} accounts`,
+ "Should throw for unsupported accounts"
+ );
+ } else {
+ await do_import(
+ {
+ new: false,
+ read: false,
+ flagged: false,
+ },
+ file,
+ folder1
+ );
+ await do_import(
+ {
+ new: true,
+ read: true,
+ flagged: true,
+ tags: ["$label1"],
+ },
+ file,
+ folder2,
+ {
+ new: true,
+ read: true,
+ flagged: true,
+ tags: ["$label1"],
+ }
+ );
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ },
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead", "messagesImport"],
+ },
+ });
+
+ extension.onMessage("getFileContent", async path => {
+ let raw = await IOUtils.read(do_get_file(path).path);
+ extension.sendMessage(MailStringUtils.uint8ArrayToByteString(raw));
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(_account);
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_move_copy_delete.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_move_copy_delete.js
new file mode 100644
index 0000000000..81011374e3
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_move_copy_delete.js
@@ -0,0 +1,656 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+var { ExtensionsUI } = ChromeUtils.import(
+ "resource:///modules/ExtensionsUI.jsm"
+);
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+var { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+Services.prefs.setBoolPref(
+ "mail.server.server1.autosync_offline_stores",
+ false
+);
+
+registerCleanupFunction(async () => {
+ // Remove the temporary MozillaMailnews folder, which is not deleted in time when
+ // the cleanupFunction registered by AddonTestUtils.maybeInit() checks for left over
+ // files in the temp folder.
+ // Note: PathUtils.tempDir points to the system temp folder, which is different.
+ let path = PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "MozillaMailnews"
+ );
+ await IOUtils.remove(path, { recursive: true });
+});
+
+// Function to start an event page extension (MV3), which can be called whenever
+// the main test is about to trigger an event. The extension terminates its
+// background and listens for that single event, verifying it is waking up correctly.
+async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let _eventName = browser.runtime.getManifest().description;
+
+ browser.messages[_eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${_eventName} received`, args);
+ }
+ });
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ browser_specific_settings: {
+ gecko: { id: "event_page_extension@mochi.test" },
+ },
+ permissions: ["accountsRead", "messagesRead", "messagesMove"],
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "messages", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+}
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_move_copy_delete() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let subFolders = {
+ test1: await createSubfolder(rootFolder, "test1"),
+ test2: await createSubfolder(rootFolder, "test2"),
+ test3: await createSubfolder(rootFolder, "test3"),
+ trash: rootFolder.getChildNamed("Trash"),
+ };
+ await createMessages(subFolders.trash, 4);
+ // 4 messages must be created before this line or test_move_copy_delete will break.
+ await createMessages(subFolders.test1, 5);
+
+ let files = {
+ "background.js": async () => {
+ async function capturePrimedEvent(eventName, callback) {
+ let eventPageExtensionReadyPromise = window.waitForMessage();
+ browser.test.sendMessage("capturePrimedEvent", eventName);
+ await eventPageExtensionReadyPromise;
+ let eventPageExtensionFinishedPromise = window.waitForMessage();
+ callback();
+ return eventPageExtensionFinishedPromise;
+ }
+
+ async function checkMessagesInFolder(expectedKeys, folder) {
+ let expectedSubjects = expectedKeys.map(k => messages[k].subject);
+
+ let { messages: actualMessages } = await browser.messages.list(
+ folder
+ );
+ browser.test.log("expect: " + expectedSubjects.sort());
+ browser.test.log(
+ "actual: " + actualMessages.map(m => m.subject).sort()
+ );
+
+ browser.test.assertEq(
+ expectedSubjects.sort().toString(),
+ actualMessages
+ .map(m => m.subject)
+ .sort()
+ .toString(),
+ "Messages on server should be correct"
+ );
+ for (let m of actualMessages) {
+ browser.test.assertTrue(
+ expectedSubjects.includes(m.subject),
+ `${m.subject} at ${m.id}`
+ );
+ messages[m.subject.split(" ")[0]].id = m.id;
+ }
+
+ // Return the messages for convenience.
+ return actualMessages;
+ }
+
+ function newMovePromise(numberOfEventsToCollapse = 1) {
+ return new Promise(resolve => {
+ let seenEvents = 0;
+ let seenSrcMsgs = [];
+ let seenDstMsgs = [];
+ const listener = (srcMsgs, dstMsgs) => {
+ seenEvents++;
+ seenSrcMsgs.push(...srcMsgs.messages);
+ seenDstMsgs.push(...dstMsgs.messages);
+ if (seenEvents == numberOfEventsToCollapse) {
+ browser.messages.onMoved.removeListener(listener);
+ resolve({ srcMsgs: seenSrcMsgs, dstMsgs: seenDstMsgs });
+ }
+ };
+ browser.messages.onMoved.addListener(listener);
+ });
+ }
+
+ function newCopyPromise(numberOfEventsToCollapse = 1) {
+ return new Promise(resolve => {
+ let seenEvents = 0;
+ let seenSrcMsgs = [];
+ let seenDstMsgs = [];
+ const listener = (srcMsgs, dstMsgs) => {
+ seenEvents++;
+ seenSrcMsgs.push(...srcMsgs.messages);
+ seenDstMsgs.push(...dstMsgs.messages);
+ if (seenEvents == numberOfEventsToCollapse) {
+ browser.messages.onCopied.removeListener(listener);
+ resolve({ srcMsgs: seenSrcMsgs, dstMsgs: seenDstMsgs });
+ }
+ };
+ browser.messages.onCopied.addListener(listener);
+ });
+ }
+
+ function newDeletePromise(numberOfEventsToCollapse = 1) {
+ return new Promise(resolve => {
+ let seenEvents = 0;
+ let seenMsgs = [];
+ const listener = msgs => {
+ seenEvents++;
+ seenMsgs.push(...msgs.messages);
+ if (seenEvents == numberOfEventsToCollapse) {
+ browser.messages.onDeleted.removeListener(listener);
+ resolve(seenMsgs);
+ }
+ };
+ browser.messages.onDeleted.addListener(listener);
+ });
+ }
+
+ async function checkEventInformation(
+ infoPromise,
+ expected,
+ messages,
+ dstFolder
+ ) {
+ let eventInfo = await infoPromise;
+ browser.test.assertEq(eventInfo.srcMsgs.length, expected.length);
+ browser.test.assertEq(eventInfo.dstMsgs.length, expected.length);
+ for (let msg of expected) {
+ let idx = eventInfo.srcMsgs.findIndex(
+ e => e.id == messages[msg].id
+ );
+ browser.test.assertEq(
+ eventInfo.srcMsgs[idx].subject,
+ messages[msg].subject
+ );
+ browser.test.assertEq(
+ eventInfo.dstMsgs[idx].subject,
+ messages[msg].subject
+ );
+ browser.test.assertEq(
+ eventInfo.dstMsgs[idx].folder.path,
+ dstFolder.path
+ );
+ }
+ }
+
+ let [accountId] = await window.sendMessage("getAccount");
+ let { folders } = await browser.accounts.get(accountId);
+ let testFolder1 = folders.find(f => f.name == "test1");
+ let testFolder2 = folders.find(f => f.name == "test2");
+ let testFolder3 = folders.find(f => f.name == "test3");
+ let trashFolder = folders.find(f => f.name == "Trash");
+
+ let { messages: folder1Messages } = await browser.messages.list(
+ testFolder1
+ );
+
+ // Since the ID of a message changes when it is moved, track by subject.
+ let messages = {};
+ for (let m of folder1Messages) {
+ messages[m.subject.split(" ")[0]] = { id: m.id, subject: m.subject };
+ }
+
+ // To help with debugging, output the IDs of our five messages.
+ browser.test.log(JSON.stringify(messages)); // Red:1, Green:2, Blue:3, My:4, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Move one message to another folder.");
+ let movePromise = newMovePromise();
+ let primedMoveInfo = await capturePrimedEvent("onMoved", () =>
+ browser.messages.move([messages.Red.id], testFolder2)
+ );
+ window.assertDeepEqual(
+ await movePromise,
+ {
+ srcMsgs: primedMoveInfo[0].messages,
+ dstMsgs: primedMoveInfo[1].messages,
+ },
+ "The primed and non-primed onMoved events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Red"],
+ messages,
+ testFolder2
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+
+ await checkMessagesInFolder(
+ ["Green", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder(["Red"], testFolder2);
+ browser.test.log(JSON.stringify(messages)); // Red:6, Green:2, Blue:3, My:4, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> And back again.");
+ movePromise = newMovePromise();
+ primedMoveInfo = await capturePrimedEvent("onMoved", () =>
+ browser.messages.move([messages.Red.id], testFolder1)
+ );
+ window.assertDeepEqual(
+ await movePromise,
+ {
+ srcMsgs: primedMoveInfo[0].messages,
+ dstMsgs: primedMoveInfo[1].messages,
+ },
+ "The primed and non-primed onMoved events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Red"],
+ messages,
+ testFolder1
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+
+ await checkMessagesInFolder(
+ ["Red", "Green", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder([], testFolder2);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:2, Blue:3, My:4, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Move two messages to another folder.");
+ movePromise = newMovePromise();
+ primedMoveInfo = await capturePrimedEvent("onMoved", () =>
+ browser.messages.move(
+ [messages.Green.id, messages.My.id],
+ testFolder2
+ )
+ );
+ window.assertDeepEqual(
+ await movePromise,
+ {
+ srcMsgs: primedMoveInfo[0].messages,
+ dstMsgs: primedMoveInfo[1].messages,
+ },
+ "The primed and non-primed onMoved events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Green", "My"],
+ messages,
+ testFolder2
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+
+ await checkMessagesInFolder(["Red", "Blue", "Happy"], testFolder1);
+ await checkMessagesInFolder(["Green", "My"], testFolder2);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:8, Blue:3, My:9, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Move one back again: " + messages.My.id);
+ movePromise = newMovePromise();
+ primedMoveInfo = await capturePrimedEvent("onMoved", () =>
+ browser.messages.move([messages.My.id], testFolder1)
+ );
+ window.assertDeepEqual(
+ await movePromise,
+ {
+ srcMsgs: primedMoveInfo[0].messages,
+ dstMsgs: primedMoveInfo[1].messages,
+ },
+ "The primed and non-primed onMoved events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(movePromise, ["My"], messages, testFolder1);
+
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+
+ await checkMessagesInFolder(
+ ["Red", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder(["Green"], testFolder2);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:8, Blue:3, My:10, Happy:5
+
+ browser.test.log("");
+ browser.test.log(
+ " --> Move messages from different folders to a third folder."
+ );
+ // We collapse the two events (one for each source folder).
+ movePromise = newMovePromise(2);
+ await browser.messages.move(
+ [messages.Green.id, messages.My.id],
+ testFolder3
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Green", "My"],
+ messages,
+ testFolder3
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder3.name);
+
+ await checkMessagesInFolder(["Red", "Blue", "Happy"], testFolder1);
+ await checkMessagesInFolder([], testFolder2);
+ await checkMessagesInFolder(["Green", "My"], testFolder3);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:11, Blue:3, My:12, Happy:5
+
+ browser.test.log("");
+ browser.test.log(
+ " --> The following tests should not trigger move events."
+ );
+ let listenerCalls = 0;
+ const listenerFunc = () => {
+ listenerCalls++;
+ };
+ browser.messages.onMoved.addListener(listenerFunc);
+
+ // Move a message to the folder it's already in.
+ await browser.messages.move([messages.Green.id], testFolder3);
+ await checkMessagesInFolder(["Green", "My"], testFolder3);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:11, Blue:3, My:12, Happy:5
+
+ // Move no messages.
+ await browser.messages.move([], testFolder3);
+ await checkMessagesInFolder(["Red", "Blue", "Happy"], testFolder1);
+ await checkMessagesInFolder([], testFolder2);
+ await checkMessagesInFolder(["Green", "My"], testFolder3);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:11, Blue:3, My:12, Happy:5
+
+ // Move a non-existent message.
+ await browser.test.assertRejects(
+ browser.messages.move([9999], testFolder1),
+ /Error moving message/,
+ "something should happen"
+ );
+
+ // Move to a non-existent folder.
+ await browser.test.assertRejects(
+ browser.messages.move([messages.Red.id], {
+ accountId,
+ path: "/missing",
+ }),
+ /Error moving message/,
+ "something should happen"
+ );
+
+ // Check that no move event was triggered.
+ browser.messages.onMoved.removeListener(listenerFunc);
+ browser.test.assertEq(0, listenerCalls);
+
+ browser.test.log("");
+ browser.test.log(
+ " --> Put everything back where it was at the start of the test."
+ );
+ movePromise = newMovePromise();
+ await browser.messages.move(
+ [messages.My.id, messages.Green.id],
+ testFolder1
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Green", "My"],
+ messages,
+ testFolder1
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder3.name);
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+
+ await checkMessagesInFolder(
+ ["Red", "Green", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder([], testFolder2);
+ await checkMessagesInFolder([], testFolder3);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:13, Blue:3, My:14, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Copy one message to another folder.");
+ let copyPromise = newCopyPromise();
+ let primedCopyInfo = await capturePrimedEvent("onCopied", () =>
+ browser.messages.copy([messages.Happy.id], testFolder2)
+ );
+ window.assertDeepEqual(
+ await copyPromise,
+ {
+ srcMsgs: primedCopyInfo[0].messages,
+ dstMsgs: primedCopyInfo[1].messages,
+ },
+ "The primed and non-primed onCopied events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(
+ copyPromise,
+ ["Happy"],
+ messages,
+ testFolder2
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder3.name);
+
+ await checkMessagesInFolder(
+ ["Red", "Green", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ let { messages: folder2Messages } = await browser.messages.list(
+ testFolder2
+ );
+ browser.test.assertEq(1, folder2Messages.length);
+ browser.test.assertEq(
+ messages.Happy.subject,
+ folder2Messages[0].subject
+ );
+ browser.test.assertTrue(folder2Messages[0].id != messages.Happy.id);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:13, Blue:3, My:14, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Delete the copied message.");
+ let deletePromise = newDeletePromise();
+ let primedDeleteLog = await capturePrimedEvent("onDeleted", () =>
+ browser.messages.delete([folder2Messages[0].id], true)
+ );
+ // Check if the delete information is correct.
+ let deleteLog = await deletePromise;
+ window.assertDeepEqual(
+ [
+ {
+ id: null,
+ messages: deleteLog,
+ },
+ ],
+ primedDeleteLog,
+ "The primed and non-primed onDeleted events should return the same values",
+ { strict: true }
+ );
+ browser.test.assertEq(1, deleteLog.length);
+ browser.test.assertEq(folder2Messages[0].id, deleteLog[0].id);
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder3.name);
+
+ // Check if the message was deleted.
+ await checkMessagesInFolder(
+ ["Red", "Green", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder([], testFolder2);
+ await checkMessagesInFolder([], testFolder3);
+ browser.test.log(JSON.stringify(messages)); // Red:7, Green:13, Blue:3, My:14, Happy:5
+
+ browser.test.log("");
+ browser.test.log(" --> Move a message to the trash.");
+ movePromise = newMovePromise();
+ primedMoveInfo = await capturePrimedEvent("onMoved", () =>
+ browser.messages.move([messages.Green.id], trashFolder)
+ );
+ window.assertDeepEqual(
+ await movePromise,
+ {
+ srcMsgs: primedMoveInfo[0].messages,
+ dstMsgs: primedMoveInfo[1].messages,
+ },
+ "The primed and non-primed onMoved events should return the same values",
+ { strict: true }
+ );
+ await checkEventInformation(
+ movePromise,
+ ["Green"],
+ messages,
+ trashFolder
+ );
+
+ await window.sendMessage("forceServerUpdate", testFolder1.name);
+ await window.sendMessage("forceServerUpdate", testFolder2.name);
+ await window.sendMessage("forceServerUpdate", testFolder3.name);
+
+ await checkMessagesInFolder(
+ ["Red", "Blue", "My", "Happy"],
+ testFolder1
+ );
+ await checkMessagesInFolder([], testFolder2);
+ await checkMessagesInFolder([], testFolder3);
+
+ let { messages: trashFolderMessages } = await browser.messages.list(
+ trashFolder
+ );
+ browser.test.assertTrue(
+ trashFolderMessages.find(m => m.subject == messages.Green.subject)
+ );
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: [
+ "accountsRead",
+ "messagesMove",
+ "messagesRead",
+ "messagesDelete",
+ ],
+ browser_specific_settings: {
+ gecko: { id: "messages.move@mochi.test" },
+ },
+ },
+ });
+
+ extension.onMessage("forceServerUpdate", async foldername => {
+ if (IS_IMAP) {
+ let folder = rootFolder
+ .getChildNamed(foldername)
+ .QueryInterface(Ci.nsIMsgImapMailFolder);
+
+ let listener = new PromiseTestUtils.PromiseUrlListener();
+ folder.updateFolderWithListener(null, listener);
+ await listener.promise;
+
+ // ...and download for offline use.
+ let promiseUrlListener = new PromiseTestUtils.PromiseUrlListener();
+ folder.downloadAllForOffline(promiseUrlListener, null);
+ await promiseUrlListener.promise;
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("capturePrimedEvent", async eventName => {
+ let primedEventData = await event_page_extension(eventName, () => {
+ // Resume execution in the main test, after the event page extension is
+ // ready to capture the event with deactivated background.
+ extension.sendMessage();
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ extension.onMessage("getAccount", () => {
+ extension.sendMessage(account.key);
+ });
+
+ // The sync between the IMAP Service and the fake IMAP Server is partially
+ // broken: It is not possible to re-move messages cleanly. The move commands
+ // are send to the server about 500ms after the local operation and the server
+ // will update the local state wrongly.
+ // In this test we enforce a server update after each operation. If this is
+ // still causing intermittent fails, enable the offline mode for this test.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1797764#c24
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(account);
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_onNewMailReceived.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_onNewMailReceived.js
new file mode 100644
index 0000000000..5c8e62872d
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_onNewMailReceived.js
@@ -0,0 +1,153 @@
+/* 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 { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+registerCleanupFunction(async () => {
+ // Remove the temporary MozillaMailnews folder, which is not deleted in time when
+ // the cleanupFunction registered by AddonTestUtils.maybeInit() checks for left over
+ // files in the temp folder.
+ // Note: PathUtils.tempDir points to the system temp folder, which is different.
+ let path = PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "MozillaMailnews"
+ );
+ await IOUtils.remove(path, { recursive: true });
+});
+
+// Function to start an event page extension (MV3), which can be called whenever
+// the main test is about to trigger an event. The extension terminates its
+// background and listens for that single event, verifying it is waking up correctly.
+async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let _eventName = browser.runtime.getManifest().description;
+
+ browser.messages[_eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${_eventName} received`, args);
+ }
+ });
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ browser_specific_settings: {
+ gecko: { id: "event_page_extension@mochi.test" },
+ },
+ permissions: ["accountsRead", "messagesRead", "messagesMove"],
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "messages", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+}
+
+add_task(async function () {
+ await AddonTestUtils.promiseStartupManager();
+
+ let account = createAccount();
+ let inbox = await createSubfolder(account.incomingServer.rootFolder, "test1");
+
+ let files = {
+ "background.js": async () => {
+ browser.messages.onNewMailReceived.addListener((folder, messageList) => {
+ window.assertDeepEqual(
+ { accountId: "account1", name: "test1", path: "/test1" },
+ folder
+ );
+ browser.test.sendMessage("onNewMailReceived event received", [
+ folder,
+ messageList,
+ ]);
+ });
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+
+ // Create a new message.
+
+ await createMessages(inbox, 1);
+ inbox.hasNewMessages = true;
+ inbox.setNumNewMessages(1);
+ inbox.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NewMail;
+
+ let inboxMessages = [...inbox.messages];
+ let newMessages = await extension.awaitMessage(
+ "onNewMailReceived event received"
+ );
+ equal(newMessages[1].messages.length, 1);
+ equal(newMessages[1].messages[0].subject, inboxMessages[0].subject);
+
+ // Create 2 more new messages.
+
+ let primedOnNewMailReceivedEventData = await event_page_extension(
+ "onNewMailReceived",
+ async () => {
+ await createMessages(inbox, 2);
+ inbox.hasNewMessages = true;
+ inbox.setNumNewMessages(2);
+ inbox.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NewMail;
+ }
+ );
+
+ inboxMessages = [...inbox.messages];
+ newMessages = await extension.awaitMessage(
+ "onNewMailReceived event received"
+ );
+ Assert.deepEqual(
+ primedOnNewMailReceivedEventData,
+ newMessages,
+ "The primed and non-primed onNewMailReceived events should return the same values"
+ );
+ equal(newMessages[1].messages.length, 2);
+ equal(newMessages[1].messages[0].subject, inboxMessages[1].subject);
+ equal(newMessages[1].messages[1].subject, inboxMessages[2].subject);
+
+ await extension.unload();
+
+ cleanUpAccount(account);
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_query.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_query.js
new file mode 100644
index 0000000000..9d9e5d8595
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_query.js
@@ -0,0 +1,333 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+add_task(async function test_query() {
+ let account = createAccount();
+
+ let textAttachment = {
+ body: "textAttachment",
+ filename: "test.txt",
+ contentType: "text/plain",
+ };
+
+ let subFolders = {
+ test1: await createSubfolder(account.incomingServer.rootFolder, "test1"),
+ test2: await createSubfolder(account.incomingServer.rootFolder, "test2"),
+ };
+ await createMessages(subFolders.test1, { count: 9, age_incr: { days: 2 } });
+
+ let messages = [...subFolders.test1.messages];
+ // NB: Here, the messages are zero-indexed. In the test they're one-indexed.
+ subFolders.test1.markMessagesRead([messages[0]], true);
+ subFolders.test1.markMessagesFlagged([messages[1]], true);
+ subFolders.test1.markMessagesFlagged([messages[6]], true);
+
+ subFolders.test1.addKeywordsToMessages(messages.slice(0, 1), "notATag");
+ subFolders.test1.addKeywordsToMessages(messages.slice(2, 4), "$label2");
+ subFolders.test1.addKeywordsToMessages(messages.slice(3, 6), "$label3");
+
+ addIdentity(account, messages[5].author.replace(/.*<(.*)>/, "$1"));
+ // No recipient support for NNTP.
+ if (account.incomingServer.type != "nntp") {
+ addIdentity(account, messages[2].recipients.replace(/.*<(.*)>/, "$1"));
+ }
+
+ await createMessages(subFolders.test2, { count: 7, age_incr: { days: 2 } });
+ // Email with multipart/alternative.
+ await createMessageFromFile(
+ subFolders.test2,
+ do_get_file("messages/alternative.eml").path
+ );
+
+ await createMessages(subFolders.test2, {
+ count: 1,
+ subject: "1 text attachment",
+ attachments: [textAttachment],
+ });
+
+ let files = {
+ "background.js": async () => {
+ let [accountId] = await window.waitForMessage();
+ let _account = await browser.accounts.get(accountId);
+ let accountType = _account.type;
+
+ let messages1 = await browser.messages.list({
+ accountId,
+ path: "/test1",
+ });
+ browser.test.assertEq(9, messages1.messages.length);
+ let messages2 = await browser.messages.list({
+ accountId,
+ path: "/test2",
+ });
+ browser.test.assertEq(9, messages2.messages.length);
+
+ // Check all messages are returned.
+ let { messages: allMessages } = await browser.messages.query({});
+ browser.test.assertEq(18, allMessages.length);
+
+ let folder1 = { accountId, path: "/test1" };
+ let folder2 = { accountId, path: "/test2" };
+ let rootFolder = { accountId, path: "/" };
+
+ // Query messages from test1. No messages from test2 should be returned.
+ // We'll use these messages as a reference for further tests.
+ let { messages: referenceMessages } = await browser.messages.query({
+ folder: folder1,
+ });
+ browser.test.assertEq(9, referenceMessages.length);
+ browser.test.assertTrue(
+ referenceMessages.every(m => m.folder.path == "/test1")
+ );
+
+ // Test includeSubFolders: Default (False).
+ let { messages: searchRecursiveDefault } = await browser.messages.query({
+ folder: rootFolder,
+ });
+ browser.test.assertEq(
+ 0,
+ searchRecursiveDefault.length,
+ "includeSubFolders: Default"
+ );
+
+ // Test includeSubFolders: True.
+ let { messages: searchRecursiveTrue } = await browser.messages.query({
+ folder: rootFolder,
+ includeSubFolders: true,
+ });
+ browser.test.assertEq(
+ 18,
+ searchRecursiveTrue.length,
+ "includeSubFolders: True"
+ );
+
+ // Test includeSubFolders: False.
+ let { messages: searchRecursiveFalse } = await browser.messages.query({
+ folder: rootFolder,
+ includeSubFolders: false,
+ });
+ browser.test.assertEq(
+ 0,
+ searchRecursiveFalse.length,
+ "includeSubFolders: False"
+ );
+
+ // Test attachment query: False.
+ let { messages: searchAttachmentFalse } = await browser.messages.query({
+ attachment: false,
+ includeSubFolders: true,
+ });
+ browser.test.assertEq(
+ 17,
+ searchAttachmentFalse.length,
+ "attachment: False"
+ );
+
+ // Test attachment query: True.
+ let { messages: searchAttachmentTrue } = await browser.messages.query({
+ attachment: true,
+ includeSubFolders: true,
+ });
+ browser.test.assertEq(1, searchAttachmentTrue.length, "attachment: True");
+
+ // Dump the reference messages to the console for easier debugging.
+ browser.test.log("Reference messages:");
+ for (let m of referenceMessages) {
+ let date = m.date.toISOString().substring(0, 10);
+ let author = m.author.replace(/"(.*)".*/, "$1").padEnd(16, " ");
+ // No recipient support for NNTP.
+ let recipients =
+ accountType == "nntp"
+ ? ""
+ : m.recipients[0].replace(/(.*) <.*>/, "$1").padEnd(16, " ");
+ browser.test.log(
+ `[${m.id}] ${date} From: ${author} To: ${recipients} Subject: ${m.subject}`
+ );
+ }
+
+ let subtest = async function (queryInfo, ...expectedMessageIndices) {
+ if (!queryInfo.folder) {
+ queryInfo.folder = folder1;
+ }
+ browser.test.log("Testing " + JSON.stringify(queryInfo));
+ let { messages: actualMessages } = await browser.messages.query(
+ queryInfo
+ );
+
+ browser.test.assertEq(
+ expectedMessageIndices.length,
+ actualMessages.length,
+ "Correct number of messages"
+ );
+ for (let index of expectedMessageIndices) {
+ // browser.test.log(`Looking for message ${index}`);
+ if (!actualMessages.some(am => am.id == index)) {
+ browser.test.fail(`Message ${index} was not returned`);
+ browser.test.log(
+ "These messages were returned: " + actualMessages.map(am => am.id)
+ );
+ }
+ }
+ };
+
+ // Date range query. The messages are 0 days old, 2 days old, 4 days old, etc..
+ let today = new Date();
+ let date1 = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate() - 5
+ );
+ let date2 = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate() - 11
+ );
+ await subtest({ fromDate: today });
+ await subtest({ fromDate: date1 }, 1, 2, 3);
+ await subtest({ fromDate: date2 }, 1, 2, 3, 4, 5, 6);
+ await subtest({ toDate: date1 }, 4, 5, 6, 7, 8, 9);
+ await subtest({ toDate: date2 }, 7, 8, 9);
+ await subtest({ fromDate: date1, toDate: date2 });
+ await subtest({ fromDate: date2, toDate: date1 }, 4, 5, 6);
+
+ // Unread query. Only message 1 has been read.
+ await subtest({ unread: false }, 1);
+ await subtest({ unread: true }, 2, 3, 4, 5, 6, 7, 8, 9);
+
+ // Flagged query. Messages 2 and 7 are flagged.
+ await subtest({ flagged: true }, 2, 7);
+ await subtest({ flagged: false }, 1, 3, 4, 5, 6, 8, 9);
+
+ // Subject query.
+ let keyword = referenceMessages[1].subject.split(" ")[1];
+ await subtest({ subject: keyword }, 2);
+ await subtest({ fullText: keyword }, 2);
+
+ // Author query.
+ keyword = referenceMessages[2].author.replace('"', "").split(" ")[0];
+ await subtest({ author: keyword }, 3);
+ await subtest({ fullText: keyword }, 3);
+
+ // Recipients query.
+ // No recipient support for NNTP.
+ if (accountType != "nntp") {
+ keyword = referenceMessages[7].recipients[0].split(" ")[0];
+ await subtest({ recipients: keyword }, 8);
+ await subtest({ fullText: keyword }, 8);
+ await subtest({ body: keyword }, 8);
+ }
+
+ // From Me and To Me. These use the identities added to account.
+ await subtest({ fromMe: true }, 6);
+ // No recipient support for NNTP.
+ if (accountType != "nntp") {
+ await subtest({ toMe: true }, 3);
+ }
+
+ // Tags query.
+ await subtest({ tags: { mode: "any", tags: { notATag: true } } });
+ await subtest({ tags: { mode: "any", tags: { $label2: true } } }, 3, 4);
+ await subtest(
+ { tags: { mode: "any", tags: { $label3: true } } },
+ 4,
+ 5,
+ 6
+ );
+ await subtest(
+ { tags: { mode: "any", tags: { $label2: true, $label3: true } } },
+ 3,
+ 4,
+ 5,
+ 6
+ );
+ await subtest({
+ tags: { mode: "all", tags: { $label1: true, $label2: true } },
+ });
+ await subtest(
+ { tags: { mode: "all", tags: { $label2: true, $label3: true } } },
+ 4
+ );
+ await subtest(
+ { tags: { mode: "any", tags: { $label2: false, $label3: false } } },
+ 1,
+ 2,
+ 7,
+ 8,
+ 9
+ );
+ await subtest(
+ { tags: { mode: "all", tags: { $label2: false, $label3: false } } },
+ 1,
+ 2,
+ 3,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9
+ );
+
+ // headerMessageId query
+ await subtest({ headerMessageId: "0@made.up.invalid" }, 1);
+ await subtest({ headerMessageId: "7@made.up.invalid" }, 8);
+ await subtest({ headerMessageId: "8@made.up.invalid" }, 9);
+ await subtest({ headerMessageId: "unknown@made.up.invalid" });
+
+ // attachment query
+ await subtest({ folder: folder2, attachment: true }, 18);
+
+ // text in nested html part of multipart/alternative
+ await subtest({ folder: folder2, body: "I am HTML!" }, 17);
+
+ // No recipient support for NNTP.
+ if (accountType != "nntp") {
+ // advanced search on recipients
+ await subtest({ folder: folder2, recipients: "karl; heinz" }, 17);
+ await subtest(
+ { folder: folder2, recipients: "<friedrich@example.COM>; HEINZ" },
+ 17
+ );
+ await subtest(
+ {
+ folder: folder2,
+ recipients: "karl <friedrich@example.COM>; HEINZ",
+ },
+ 17
+ );
+ await subtest({
+ folder: folder2,
+ recipients: "Heinz <friedrich@example.COM>; Karl",
+ });
+ }
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(account.key);
+ await extension.awaitFinish("finished");
+ await extension.unload();
+});
+
+registerCleanupFunction(() => {
+ // Make sure any open address book database is given a chance to close.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+});
diff --git a/comm/mail/components/extensions/test/xpcshell/test_ext_messages_update.js b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_update.js
new file mode 100644
index 0000000000..4771f3ee17
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/test_ext_messages_update.js
@@ -0,0 +1,415 @@
+/* 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/. */
+
+"use strict";
+
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+var { ExtensionsUI } = ChromeUtils.import(
+ "resource:///modules/ExtensionsUI.jsm"
+);
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ExtensionTestUtils.mockAppInfo();
+AddonTestUtils.maybeInit(this);
+
+registerCleanupFunction(async () => {
+ // Remove the temporary MozillaMailnews folder, which is not deleted in time when
+ // the cleanupFunction registered by AddonTestUtils.maybeInit() checks for left over
+ // files in the temp folder.
+ // Note: PathUtils.tempDir points to the system temp folder, which is different.
+ let path = PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "MozillaMailnews"
+ );
+ await IOUtils.remove(path, { recursive: true });
+});
+
+// Function to start an event page extension (MV3), which can be called whenever
+// the main test is about to trigger an event. The extension terminates its
+// background and listens for that single event, verifying it is waking up correctly.
+async function event_page_extension(eventName, actionCallback) {
+ let ext = ExtensionTestUtils.loadExtension({
+ files: {
+ "background.js": async () => {
+ // Whenever the extension starts or wakes up, hasFired is set to false. In
+ // case of a wake-up, the first fired event is the one that woke up the background.
+ let hasFired = false;
+ let _eventName = browser.runtime.getManifest().description;
+
+ browser.messages[_eventName].addListener(async (...args) => {
+ // Only send the first event after background wake-up, this should
+ // be the only one expected.
+ if (!hasFired) {
+ hasFired = true;
+ browser.test.sendMessage(`${_eventName} received`, args);
+ }
+ });
+ browser.test.sendMessage("background started");
+ },
+ },
+ manifest: {
+ manifest_version: 3,
+ description: eventName,
+ background: { scripts: ["background.js"] },
+ browser_specific_settings: {
+ gecko: { id: "event_page_extension@mochi.test" },
+ },
+ permissions: ["accountsRead", "messagesRead", "messagesMove"],
+ },
+ });
+ await ext.startup();
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.terminateBackground({ disableResetIdleForTest: true });
+ // Verify the primed persistent listener.
+ assertPersistentListeners(ext, "messages", eventName, { primed: true });
+
+ await actionCallback();
+ let rv = await ext.awaitMessage(`${eventName} received`);
+ await ext.awaitMessage("background started");
+ // The listener should be persistent, but not primed.
+ assertPersistentListeners(ext, "messages", eventName, { primed: false });
+
+ await ext.unload();
+ return rv;
+}
+
+add_task(
+ {
+ skip_if: () => IS_NNTP,
+ },
+ async function test_update() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let account = createAccount();
+ let rootFolder = account.incomingServer.rootFolder;
+ let testFolder0 = await createSubfolder(rootFolder, "test0");
+ await createMessages(testFolder0, 1);
+ testFolder0.addKeywordsToMessages(
+ [[...testFolder0.messages][0]],
+ "testkeyword"
+ );
+
+ let files = {
+ "background.js": async () => {
+ async function capturePrimedEvent(eventName, callback) {
+ let eventPageExtensionReadyPromise = window.waitForMessage();
+ browser.test.sendMessage("capturePrimedEvent", eventName);
+ await eventPageExtensionReadyPromise;
+ let eventPageExtensionFinishedPromise = window.waitForMessage();
+ callback();
+ return eventPageExtensionFinishedPromise;
+ }
+
+ function newUpdatePromise(numberOfEventsToCollapse = 1) {
+ return new Promise(resolve => {
+ let seenEvents = {};
+ const listener = (msg, props) => {
+ if (!seenEvents.hasOwnProperty(msg.id)) {
+ seenEvents[msg.id] = {
+ counts: 0,
+ props: {},
+ };
+ }
+
+ seenEvents[msg.id].counts++;
+ for (let prop of Object.keys(props)) {
+ seenEvents[msg.id].props[prop] = props[prop];
+ }
+
+ if (seenEvents[msg.id].counts == numberOfEventsToCollapse) {
+ browser.messages.onUpdated.removeListener(listener);
+ resolve({ msg, props: seenEvents[msg.id].props });
+ }
+ };
+ browser.messages.onUpdated.addListener(listener);
+ });
+ }
+ let tags = await browser.messages.listTags();
+ let [data] = await window.sendMessage("getFolder");
+ let messageList = await browser.messages.list(data.folder);
+ browser.test.assertEq(1, messageList.messages.length);
+ let message = messageList.messages[0];
+ browser.test.assertFalse(message.flagged);
+ browser.test.assertFalse(message.read);
+ browser.test.assertFalse(message.junk);
+ browser.test.assertEq(0, message.junkScore);
+ browser.test.assertEq(0, message.tags.length);
+ browser.test.assertEq(data.size, message.size);
+ browser.test.assertEq("0@made.up.invalid", message.headerMessageId);
+
+ // Test that setting flagged works.
+ let updatePromise = newUpdatePromise();
+ let primedUpdatedInfo = await capturePrimedEvent("onUpdated", () =>
+ browser.messages.update(message.id, { flagged: true })
+ );
+ let updateInfo = await updatePromise;
+ window.assertDeepEqual(
+ [updateInfo.msg, updateInfo.props],
+ primedUpdatedInfo,
+ "The primed and non-primed onUpdated events should return the same values",
+ { strict: true }
+ );
+ browser.test.assertEq(message.id, updateInfo.msg.id);
+ window.assertDeepEqual({ flagged: true }, updateInfo.props);
+ await window.sendMessage("flagged");
+
+ // Test that setting read works.
+ updatePromise = newUpdatePromise();
+ primedUpdatedInfo = await capturePrimedEvent("onUpdated", () =>
+ browser.messages.update(message.id, { read: true })
+ );
+ updateInfo = await updatePromise;
+ window.assertDeepEqual(
+ [updateInfo.msg, updateInfo.props],
+ primedUpdatedInfo,
+ "The primed and non-primed onUpdated events should return the same values",
+ { strict: true }
+ );
+ browser.test.assertEq(message.id, updateInfo.msg.id);
+ window.assertDeepEqual({ read: true }, updateInfo.props);
+ await window.sendMessage("read");
+
+ // Test that setting junk works.
+ updatePromise = newUpdatePromise();
+ primedUpdatedInfo = await capturePrimedEvent("onUpdated", () =>
+ browser.messages.update(message.id, { junk: true })
+ );
+ updateInfo = await updatePromise;
+ window.assertDeepEqual(
+ [updateInfo.msg, updateInfo.props],
+ primedUpdatedInfo,
+ "The primed and non-primed onUpdated events should return the same values",
+ { strict: true }
+ );
+ browser.test.assertEq(message.id, updateInfo.msg.id);
+ window.assertDeepEqual({ junk: true }, updateInfo.props);
+ await window.sendMessage("junk");
+
+ // Test that setting one tag works.
+ updatePromise = newUpdatePromise();
+ primedUpdatedInfo = await capturePrimedEvent("onUpdated", () =>
+ browser.messages.update(message.id, { tags: [tags[0].key] })
+ );
+ updateInfo = await updatePromise;
+ window.assertDeepEqual(
+ [updateInfo.msg, updateInfo.props],
+ primedUpdatedInfo,
+ "The primed and non-primed onUpdated events should return the same values",
+ { strict: true }
+ );
+ browser.test.assertEq(message.id, updateInfo.msg.id);
+ window.assertDeepEqual({ tags: [tags[0].key] }, updateInfo.props);
+ await window.sendMessage("tags1");
+
+ // Test that setting two tags works. We get 3 events: one removing tags0,
+ // one adding tags1 and one adding tags2. updatePromise is waiting for
+ // the third one before resolving.
+ updatePromise = newUpdatePromise(3);
+ await browser.messages.update(message.id, {
+ tags: [tags[1].key, tags[2].key],
+ });
+ updateInfo = await updatePromise;
+ browser.test.assertEq(message.id, updateInfo.msg.id);
+ window.assertDeepEqual(
+ { tags: [tags[1].key, tags[2].key] },
+ updateInfo.props
+ );
+ await window.sendMessage("tags2");
+
+ // Test that unspecified properties aren't changed.
+ let listenerCalls = 0;
+ const listenerFunc = (msg, props) => {
+ listenerCalls++;
+ };
+ browser.messages.onUpdated.addListener(listenerFunc);
+ await browser.messages.update(message.id, {});
+ await window.sendMessage("empty");
+ // Check if the no-op update call triggered a listener.
+ await new Promise(resolve => setTimeout(resolve));
+ browser.messages.onUpdated.removeListener(listenerFunc);
+ browser.test.assertEq(
+ 0,
+ listenerCalls,
+ "Not expecting listener callbacks on no-op updates."
+ );
+
+ message = await browser.messages.get(message.id);
+ browser.test.assertTrue(message.flagged);
+ browser.test.assertTrue(message.read);
+ browser.test.assertTrue(message.junk);
+ browser.test.assertEq(100, message.junkScore);
+ browser.test.assertEq(2, message.tags.length);
+ browser.test.assertEq(tags[1].key, message.tags[0]);
+ browser.test.assertEq(tags[2].key, message.tags[1]);
+ browser.test.assertEq("0@made.up.invalid", message.headerMessageId);
+
+ // Test that clearing properties works.
+ updatePromise = newUpdatePromise(5);
+ await browser.messages.update(message.id, {
+ flagged: false,
+ read: false,
+ junk: false,
+ tags: [],
+ });
+ updateInfo = await updatePromise;
+ window.assertDeepEqual(
+ {
+ flagged: false,
+ read: false,
+ junk: false,
+ tags: [],
+ },
+ updateInfo.props
+ );
+ await window.sendMessage("clear");
+
+ message = await browser.messages.get(message.id);
+ browser.test.assertFalse(message.flagged);
+ browser.test.assertFalse(message.read);
+ browser.test.assertFalse(message.external);
+ browser.test.assertFalse(message.junk);
+ browser.test.assertEq(0, message.junkScore);
+ browser.test.assertEq(0, message.tags.length);
+ browser.test.assertEq("0@made.up.invalid", message.headerMessageId);
+
+ browser.test.notifyPass("finished");
+ },
+ "utils.js": await getUtilsJS(),
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ files,
+ manifest: {
+ background: { scripts: ["utils.js", "background.js"] },
+ permissions: ["accountsRead", "messagesRead"],
+ browser_specific_settings: {
+ gecko: { id: "messages.update@mochi.test" },
+ },
+ },
+ });
+
+ let message = [...testFolder0.messages][0];
+ ok(!message.isFlagged);
+ ok(!message.isRead);
+ equal(message.getStringProperty("keywords"), "testkeyword");
+
+ extension.onMessage("capturePrimedEvent", async eventName => {
+ let primedEventData = await event_page_extension(eventName, () => {
+ // Resume execution in the main test, after the event page extension is
+ // ready to capture the event with deactivated background.
+ extension.sendMessage();
+ });
+ extension.sendMessage(...primedEventData);
+ });
+
+ extension.onMessage("flagged", async () => {
+ await TestUtils.waitForCondition(() => message.isFlagged);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("read", async () => {
+ await TestUtils.waitForCondition(() => message.isRead);
+ extension.sendMessage();
+ });
+
+ extension.onMessage("junk", async () => {
+ await TestUtils.waitForCondition(
+ () => message.getStringProperty("junkscore") == 100
+ );
+ extension.sendMessage();
+ });
+
+ extension.onMessage("tags1", async () => {
+ if (IS_IMAP) {
+ // Only IMAP sets the junk/nonjunk keyword.
+ await TestUtils.waitForCondition(
+ () =>
+ message.getStringProperty("keywords") == "testkeyword junk $label1"
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => message.getStringProperty("keywords") == "testkeyword $label1"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("tags2", async () => {
+ if (IS_IMAP) {
+ await TestUtils.waitForCondition(
+ () =>
+ message.getStringProperty("keywords") ==
+ "testkeyword junk $label2 $label3"
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () =>
+ message.getStringProperty("keywords") ==
+ "testkeyword $label2 $label3"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("empty", async () => {
+ await TestUtils.waitForCondition(() => message.isFlagged);
+ await TestUtils.waitForCondition(() => message.isRead);
+ if (IS_IMAP) {
+ await TestUtils.waitForCondition(
+ () =>
+ message.getStringProperty("keywords") ==
+ "testkeyword junk $label2 $label3"
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () =>
+ message.getStringProperty("keywords") ==
+ "testkeyword $label2 $label3"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("clear", async () => {
+ await TestUtils.waitForCondition(() => !message.isFlagged);
+ await TestUtils.waitForCondition(() => !message.isRead);
+ await TestUtils.waitForCondition(
+ () => message.getStringProperty("junkscore") == 0
+ );
+ if (IS_IMAP) {
+ await TestUtils.waitForCondition(
+ () => message.getStringProperty("keywords") == "testkeyword nonjunk"
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => message.getStringProperty("keywords") == "testkeyword"
+ );
+ }
+ extension.sendMessage();
+ });
+
+ extension.onMessage("getFolder", async () => {
+ extension.sendMessage({
+ folder: { accountId: account.key, path: "/test0" },
+ size: message.messageSize,
+ });
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("finished");
+ await extension.unload();
+
+ cleanUpAccount(account);
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
diff --git a/comm/mail/components/extensions/test/xpcshell/xpcshell-imap.ini b/comm/mail/components/extensions/test/xpcshell/xpcshell-imap.ini
new file mode 100644
index 0000000000..88659a20ad
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/xpcshell-imap.ini
@@ -0,0 +1,7 @@
+[default]
+dupe-manifest = true
+head = head.js head-imap.js
+support-files = data/utils.js
+tags = imap webextensions
+
+[include:xpcshell.ini]
diff --git a/comm/mail/components/extensions/test/xpcshell/xpcshell-local.ini b/comm/mail/components/extensions/test/xpcshell/xpcshell-local.ini
new file mode 100644
index 0000000000..19d50044cd
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/xpcshell-local.ini
@@ -0,0 +1,23 @@
+[default]
+dupe-manifest = true
+head = head.js
+support-files = data/utils.js
+tags = local webextensions
+
+[include:xpcshell.ini]
+[test_ext_accounts.js]
+[test_ext_accounts_mv3_event_pages.js]
+[test_ext_identities_mv3_event_pages.js]
+[test_ext_addressBook.js]
+support-files = images/**
+tags = addrbook
+[test_ext_addressBook_readonly.js]
+tags = addrbook
+[test_ext_addressBook_remote.js]
+tags = addrbook
+[test_ext_addressBook_provider.js]
+tags = addrbook
+[test_ext_addressBook_quickSearch.js]
+tags = addrbook
+[test_ext_alias.js]
+[test_ext_browserAction_unifiedtoolbar_restart.js]
diff --git a/comm/mail/components/extensions/test/xpcshell/xpcshell-nntp.ini b/comm/mail/components/extensions/test/xpcshell/xpcshell-nntp.ini
new file mode 100644
index 0000000000..66e23e03bd
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/xpcshell-nntp.ini
@@ -0,0 +1,7 @@
+[default]
+dupe-manifest = true
+head = head.js head-nntp.js
+support-files = data/utils.js
+tags = nntp webextensions
+
+[include:xpcshell.ini]
diff --git a/comm/mail/components/extensions/test/xpcshell/xpcshell.ini b/comm/mail/components/extensions/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..666a67d5da
--- /dev/null
+++ b/comm/mail/components/extensions/test/xpcshell/xpcshell.ini
@@ -0,0 +1,17 @@
+[test_ext_experiments.js]
+tags = addrbook
+[test_ext_folders.js] # NNTP disabled (no support for folder operations).
+[test_ext_folders_mv3_event_pages.js] # NNTP disabled (no support for folder operations).
+[test_ext_messages.js] # NNTP disabled (no support for Trash folder).
+[test_ext_messages_attachments.js] # IMAP disabled (doesn't work with test server).
+support-files = messages/**
+[test_ext_messages_get.js] # NNTP disabled for PGP tests.
+support-files = messages/**
+[test_ext_messages_id.js] # NNTP disabled (message move not supported).
+[test_ext_messages_import.js]
+support-files = messages/**
+[test_ext_messages_move_copy_delete.js] # NNTP disabled (no support for Trash folder).
+[test_ext_messages_onNewMailReceived.js]
+[test_ext_messages_query.js]
+support-files = messages/alternative.eml
+[test_ext_messages_update.js] # NNTP disabled (no support for Trash folder).